hanzi-browse 2.2.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 +182 -0
- package/dist/agent/loop.d.ts +63 -0
- package/dist/agent/loop.js +186 -0
- package/dist/agent/system-prompt.d.ts +7 -0
- package/dist/agent/system-prompt.js +41 -0
- package/dist/agent/tools.d.ts +9 -0
- package/dist/agent/tools.js +154 -0
- package/dist/cli/detect-credentials.d.ts +31 -0
- package/dist/cli/detect-credentials.js +44 -0
- package/dist/cli/import-credentials-handler.d.ts +14 -0
- package/dist/cli/import-credentials-handler.js +22 -0
- package/dist/cli/session-files.d.ts +28 -0
- package/dist/cli/session-files.js +118 -0
- package/dist/cli/setup.d.ts +10 -0
- package/dist/cli/setup.js +915 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +506 -0
- package/dist/dashboard/assets/index-CEFyesbT.js +46 -0
- package/dist/dashboard/assets/index-Dnht2kLU.css +1 -0
- package/dist/dashboard/index.html +13 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1116 -0
- package/dist/ipc/index.d.ts +8 -0
- package/dist/ipc/index.js +8 -0
- package/dist/ipc/native-host.d.ts +96 -0
- package/dist/ipc/native-host.js +223 -0
- package/dist/ipc/websocket-client.d.ts +73 -0
- package/dist/ipc/websocket-client.js +199 -0
- package/dist/license/manager.d.ts +20 -0
- package/dist/license/manager.js +15 -0
- package/dist/llm/client.d.ts +72 -0
- package/dist/llm/client.js +227 -0
- package/dist/llm/credentials.d.ts +61 -0
- package/dist/llm/credentials.js +200 -0
- package/dist/llm/vertex.d.ts +22 -0
- package/dist/llm/vertex.js +335 -0
- package/dist/managed/api-http.test.d.ts +7 -0
- package/dist/managed/api-http.test.js +623 -0
- package/dist/managed/api.d.ts +51 -0
- package/dist/managed/api.js +1448 -0
- package/dist/managed/api.test.d.ts +10 -0
- package/dist/managed/api.test.js +146 -0
- package/dist/managed/auth.d.ts +38 -0
- package/dist/managed/auth.js +192 -0
- package/dist/managed/billing.d.ts +70 -0
- package/dist/managed/billing.js +227 -0
- package/dist/managed/deploy.d.ts +17 -0
- package/dist/managed/deploy.js +385 -0
- package/dist/managed/e2e.test.d.ts +15 -0
- package/dist/managed/e2e.test.js +151 -0
- package/dist/managed/hardening.test.d.ts +14 -0
- package/dist/managed/hardening.test.js +346 -0
- package/dist/managed/integration.test.d.ts +8 -0
- package/dist/managed/integration.test.js +274 -0
- package/dist/managed/log.d.ts +18 -0
- package/dist/managed/log.js +31 -0
- package/dist/managed/server.d.ts +12 -0
- package/dist/managed/server.js +69 -0
- package/dist/managed/store-pg.d.ts +191 -0
- package/dist/managed/store-pg.js +479 -0
- package/dist/managed/store.d.ts +188 -0
- package/dist/managed/store.js +379 -0
- package/dist/relay/auto-start.d.ts +19 -0
- package/dist/relay/auto-start.js +71 -0
- package/dist/relay/server.d.ts +17 -0
- package/dist/relay/server.js +403 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.js +4 -0
- package/dist/types/session.d.ts +134 -0
- package/dist/types/session.js +16 -0
- package/package.json +61 -0
- package/skills/README.md +48 -0
- package/skills/a11y-auditor/SKILL.md +42 -0
- package/skills/e2e-tester/SKILL.md +154 -0
- package/skills/hanzi-browse/SKILL.md +182 -0
- package/skills/linkedin-prospector/SKILL.md +149 -0
- package/skills/social-poster/SKILL.md +146 -0
- package/skills/x-marketer/SKILL.md +479 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* WebSocket Relay Server
|
|
4
|
+
*
|
|
5
|
+
* Stateless message router between extension, MCP server, and CLI.
|
|
6
|
+
* Replaces file-based IPC with real-time WebSocket communication.
|
|
7
|
+
*
|
|
8
|
+
* Roles:
|
|
9
|
+
* - extension: Chrome extension service worker (one at a time)
|
|
10
|
+
* - mcp: MCP server (can have multiple)
|
|
11
|
+
* - cli: CLI clients (can have multiple)
|
|
12
|
+
*
|
|
13
|
+
* Routing:
|
|
14
|
+
* - extension → originating mcp/cli client when tagged, otherwise broadcast
|
|
15
|
+
* - mcp/cli → send to extension
|
|
16
|
+
*/
|
|
17
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
18
|
+
import { randomUUID } from 'crypto';
|
|
19
|
+
import { getClaudeCredentials, getClaudeKeychainCredentials, getCodexCredentials, refreshClaudeToken, saveClaudeCredentials, } from '../llm/credentials.js';
|
|
20
|
+
const DEFAULT_PORT = 7862;
|
|
21
|
+
const port = parseInt(process.env.WS_RELAY_PORT || String(DEFAULT_PORT), 10);
|
|
22
|
+
const clients = new Map();
|
|
23
|
+
// Queue messages for extension when it's disconnected (service worker sleeping)
|
|
24
|
+
const extensionQueue = [];
|
|
25
|
+
const MAX_QUEUE_SIZE = 50;
|
|
26
|
+
const QUEUE_MAX_AGE_MS = 60000; // Drop queued messages older than 60s
|
|
27
|
+
const queueTimestamps = [];
|
|
28
|
+
function log(msg) {
|
|
29
|
+
console.error(`[Relay] ${msg}`);
|
|
30
|
+
}
|
|
31
|
+
function getClientsByRole(role) {
|
|
32
|
+
return Array.from(clients.values()).filter(c => c.role === role);
|
|
33
|
+
}
|
|
34
|
+
function getExtension() {
|
|
35
|
+
return getClientsByRole('extension')[0];
|
|
36
|
+
}
|
|
37
|
+
function sendToConsumers(message, targetClientId, exclude) {
|
|
38
|
+
for (const [ws, client] of clients) {
|
|
39
|
+
const isConsumer = client.role === 'mcp' || client.role === 'cli';
|
|
40
|
+
const matchesTarget = !targetClientId || client.clientId === targetClientId;
|
|
41
|
+
if (ws !== exclude && ws.readyState === WebSocket.OPEN && isConsumer && matchesTarget) {
|
|
42
|
+
ws.send(message);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function sendToExtension(message) {
|
|
47
|
+
const ext = getExtension();
|
|
48
|
+
if (ext && ext.ws.readyState === WebSocket.OPEN) {
|
|
49
|
+
ext.ws.send(message);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
// Extension not connected — queue the message for delivery on reconnect
|
|
53
|
+
// Deduplicate start_task by sessionId: if a start_task for the same session
|
|
54
|
+
// is already queued, replace it instead of adding a duplicate.
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(message);
|
|
57
|
+
if (parsed.type === 'mcp_start_task' && parsed.sessionId) {
|
|
58
|
+
for (let i = 0; i < extensionQueue.length; i++) {
|
|
59
|
+
try {
|
|
60
|
+
const queued = JSON.parse(extensionQueue[i]);
|
|
61
|
+
if (queued.type === 'mcp_start_task' && queued.sessionId === parsed.sessionId) {
|
|
62
|
+
log(`Deduplicating queued start_task for session ${parsed.sessionId}`);
|
|
63
|
+
extensionQueue[i] = message;
|
|
64
|
+
queueTimestamps[i] = Date.now();
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch { /* skip malformed */ }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch { /* not JSON, queue as-is */ }
|
|
73
|
+
if (extensionQueue.length >= MAX_QUEUE_SIZE) {
|
|
74
|
+
extensionQueue.shift();
|
|
75
|
+
queueTimestamps.shift();
|
|
76
|
+
}
|
|
77
|
+
extensionQueue.push(message);
|
|
78
|
+
queueTimestamps.push(Date.now());
|
|
79
|
+
log(`Extension offline, queued message (${extensionQueue.length} pending)`);
|
|
80
|
+
return true; // Return true — message is queued, not lost
|
|
81
|
+
}
|
|
82
|
+
function flushExtensionQueue(ext) {
|
|
83
|
+
if (extensionQueue.length === 0)
|
|
84
|
+
return;
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
let delivered = 0;
|
|
87
|
+
let expired = 0;
|
|
88
|
+
while (extensionQueue.length > 0) {
|
|
89
|
+
const msg = extensionQueue.shift();
|
|
90
|
+
const ts = queueTimestamps.shift();
|
|
91
|
+
if (now - ts > QUEUE_MAX_AGE_MS) {
|
|
92
|
+
expired++;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
ext.ws.send(msg);
|
|
96
|
+
delivered++;
|
|
97
|
+
}
|
|
98
|
+
log(`Flushed queue: ${delivered} delivered, ${expired} expired`);
|
|
99
|
+
}
|
|
100
|
+
const wss = new WebSocketServer({ port }, () => {
|
|
101
|
+
log(`Listening on ws://localhost:${port}`);
|
|
102
|
+
});
|
|
103
|
+
wss.on('error', (err) => {
|
|
104
|
+
if (err.code === 'EADDRINUSE') {
|
|
105
|
+
log(`Port ${port} already in use — another relay is running. Exiting.`);
|
|
106
|
+
process.exit(0);
|
|
107
|
+
}
|
|
108
|
+
log(`Server error: ${err.message}`);
|
|
109
|
+
process.exit(1);
|
|
110
|
+
});
|
|
111
|
+
wss.on('connection', (ws) => {
|
|
112
|
+
log(`New connection (${clients.size + 1} total)`);
|
|
113
|
+
ws.on('message', (data) => {
|
|
114
|
+
let msg;
|
|
115
|
+
try {
|
|
116
|
+
msg = JSON.parse(data.toString());
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
log('Invalid JSON received, ignoring');
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// Handle registration
|
|
123
|
+
if (msg.type === 'register') {
|
|
124
|
+
const role = msg.role;
|
|
125
|
+
if (!['extension', 'mcp', 'cli'].includes(role)) {
|
|
126
|
+
ws.send(JSON.stringify({ type: 'error', error: `Invalid role: ${role}` }));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// If a new extension registers, disconnect old one
|
|
130
|
+
if (role === 'extension') {
|
|
131
|
+
const existing = getExtension();
|
|
132
|
+
if (existing && existing.ws !== ws) {
|
|
133
|
+
log('New extension connecting, closing old one');
|
|
134
|
+
existing.ws.close(1000, 'replaced');
|
|
135
|
+
clients.delete(existing.ws);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
clients.set(ws, {
|
|
139
|
+
ws,
|
|
140
|
+
role,
|
|
141
|
+
clientId: randomUUID().slice(0, 8),
|
|
142
|
+
sessionId: msg.sessionId,
|
|
143
|
+
registeredAt: Date.now(),
|
|
144
|
+
});
|
|
145
|
+
ws.send(JSON.stringify({ type: 'registered', role, clientId: clients.get(ws).clientId }));
|
|
146
|
+
log(`Client registered as ${role} (${clients.size} total)`);
|
|
147
|
+
// Deliver any queued messages to the extension
|
|
148
|
+
if (role === 'extension') {
|
|
149
|
+
flushExtensionQueue(clients.get(ws));
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// Route messages based on sender role
|
|
154
|
+
const client = clients.get(ws);
|
|
155
|
+
if (!client) {
|
|
156
|
+
// Unregistered client — require registration first
|
|
157
|
+
ws.send(JSON.stringify({ type: 'error', error: 'Must register first' }));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// Handle status_query — relay answers directly (no round trip to extension)
|
|
161
|
+
if (msg.type === 'status_query') {
|
|
162
|
+
const ext = getExtension();
|
|
163
|
+
ws.send(JSON.stringify({
|
|
164
|
+
type: 'status_response',
|
|
165
|
+
requestId: msg.requestId,
|
|
166
|
+
extensionConnected: !!ext && ext.ws.readyState === WebSocket.OPEN,
|
|
167
|
+
}));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// Handle read_credentials — relay reads from filesystem (replaces native host)
|
|
171
|
+
if (msg.type === 'read_credentials' && client.role === 'extension') {
|
|
172
|
+
const { credentialType } = msg;
|
|
173
|
+
try {
|
|
174
|
+
if (credentialType === 'claude') {
|
|
175
|
+
const creds = getClaudeCredentials() || getClaudeKeychainCredentials();
|
|
176
|
+
if (creds) {
|
|
177
|
+
ws.send(JSON.stringify({
|
|
178
|
+
type: 'credentials_result',
|
|
179
|
+
requestId: msg.requestId,
|
|
180
|
+
credentialType: 'claude',
|
|
181
|
+
credentials: {
|
|
182
|
+
accessToken: creds.accessToken,
|
|
183
|
+
refreshToken: creds.refreshToken,
|
|
184
|
+
expiresAt: creds.expiresAt,
|
|
185
|
+
},
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
ws.send(JSON.stringify({
|
|
190
|
+
type: 'credentials_result',
|
|
191
|
+
requestId: msg.requestId,
|
|
192
|
+
credentialType: 'claude',
|
|
193
|
+
error: 'Claude credentials not found. Run `claude login` first.',
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else if (credentialType === 'codex') {
|
|
198
|
+
const creds = getCodexCredentials();
|
|
199
|
+
if (creds) {
|
|
200
|
+
ws.send(JSON.stringify({
|
|
201
|
+
type: 'credentials_result',
|
|
202
|
+
requestId: msg.requestId,
|
|
203
|
+
credentialType: 'codex',
|
|
204
|
+
credentials: {
|
|
205
|
+
accessToken: creds.accessToken,
|
|
206
|
+
refreshToken: creds.refreshToken,
|
|
207
|
+
accountId: creds.accountId,
|
|
208
|
+
},
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
ws.send(JSON.stringify({
|
|
213
|
+
type: 'credentials_result',
|
|
214
|
+
requestId: msg.requestId,
|
|
215
|
+
credentialType: 'codex',
|
|
216
|
+
error: 'Codex credentials not found. Run `codex auth login` first.',
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
ws.send(JSON.stringify({
|
|
222
|
+
type: 'credentials_result',
|
|
223
|
+
requestId: msg.requestId,
|
|
224
|
+
error: `Unknown credential type: ${credentialType}`,
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
ws.send(JSON.stringify({
|
|
230
|
+
type: 'credentials_result',
|
|
231
|
+
requestId: msg.requestId,
|
|
232
|
+
error: err.message,
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// Handle proxy_api_call — relay proxies API calls with impersonation headers
|
|
238
|
+
if (msg.type === 'proxy_api_call' && client.role === 'extension') {
|
|
239
|
+
handleApiProxy(ws, msg);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const raw = data.toString();
|
|
243
|
+
if (client.role === 'extension') {
|
|
244
|
+
// Extension → originating MCP/CLI client when known, otherwise broadcast
|
|
245
|
+
sendToConsumers(raw, typeof msg.sourceClientId === 'string' ? msg.sourceClientId : undefined);
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
// MCP/CLI → send to extension (queued if offline)
|
|
249
|
+
sendToExtension(JSON.stringify({ ...msg, sourceClientId: client.clientId }));
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
ws.on('close', () => {
|
|
253
|
+
const client = clients.get(ws);
|
|
254
|
+
if (client) {
|
|
255
|
+
log(`${client.role} disconnected (${clients.size - 1} remaining)`);
|
|
256
|
+
clients.delete(ws);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
ws.on('error', (err) => {
|
|
260
|
+
log(`WebSocket error: ${err.message}`);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
/**
|
|
264
|
+
* Proxy API calls with Claude Code impersonation headers.
|
|
265
|
+
* Reads OAuth token, makes the API call, streams SSE events back to extension.
|
|
266
|
+
*/
|
|
267
|
+
async function handleApiProxy(ws, msg) {
|
|
268
|
+
const { requestId, url, body } = msg;
|
|
269
|
+
const PROXY_TIMEOUT_MS = 150000;
|
|
270
|
+
const controller = new AbortController();
|
|
271
|
+
const timeoutId = setTimeout(() => controller.abort(), PROXY_TIMEOUT_MS);
|
|
272
|
+
const EXPIRY_BUFFER_MS = 60 * 1000;
|
|
273
|
+
const getFreshClaudeCredentials = async () => {
|
|
274
|
+
const existing = getClaudeCredentials() || getClaudeKeychainCredentials();
|
|
275
|
+
if (!existing) {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
if (existing.expiresAt && existing.expiresAt > Date.now() + EXPIRY_BUFFER_MS) {
|
|
279
|
+
return existing;
|
|
280
|
+
}
|
|
281
|
+
log('Claude OAuth token expired or near expiry, refreshing before proxy call');
|
|
282
|
+
const refreshed = await refreshClaudeToken(existing.refreshToken);
|
|
283
|
+
saveClaudeCredentials(refreshed);
|
|
284
|
+
return refreshed;
|
|
285
|
+
};
|
|
286
|
+
try {
|
|
287
|
+
// Read OAuth credentials
|
|
288
|
+
let creds = await getFreshClaudeCredentials();
|
|
289
|
+
if (!creds) {
|
|
290
|
+
ws.send(JSON.stringify({ type: 'proxy_api_error', requestId, error: 'No Claude credentials found' }));
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
// Claude Code impersonation headers
|
|
294
|
+
const headers = {
|
|
295
|
+
'Content-Type': 'application/json',
|
|
296
|
+
'Authorization': `Bearer ${creds.accessToken}`,
|
|
297
|
+
'anthropic-version': '2023-06-01',
|
|
298
|
+
'anthropic-dangerous-direct-browser-access': 'true',
|
|
299
|
+
'anthropic-beta': 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14',
|
|
300
|
+
'x-app': 'cli',
|
|
301
|
+
'user-agent': 'claude-code/2.1.29 (Darwin; arm64)',
|
|
302
|
+
};
|
|
303
|
+
let response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
|
|
304
|
+
if (response.status === 401) {
|
|
305
|
+
log('Claude proxy request got 401, refreshing token and retrying once');
|
|
306
|
+
const refreshed = await refreshClaudeToken(creds.refreshToken);
|
|
307
|
+
saveClaudeCredentials(refreshed);
|
|
308
|
+
creds = refreshed;
|
|
309
|
+
headers.Authorization = `Bearer ${creds.accessToken}`;
|
|
310
|
+
response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
|
|
311
|
+
}
|
|
312
|
+
if (!response.ok) {
|
|
313
|
+
const errorText = await response.text().catch(() => '');
|
|
314
|
+
clearTimeout(timeoutId);
|
|
315
|
+
ws.send(JSON.stringify({
|
|
316
|
+
type: 'proxy_api_error',
|
|
317
|
+
requestId,
|
|
318
|
+
error: `API error: ${response.status} - ${errorText.slice(0, 500)}`,
|
|
319
|
+
}));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
// Parse SSE stream and forward events to extension
|
|
323
|
+
const reader = response.body.getReader();
|
|
324
|
+
const decoder = new TextDecoder();
|
|
325
|
+
let buffer = '';
|
|
326
|
+
while (true) {
|
|
327
|
+
const { done, value } = await reader.read();
|
|
328
|
+
if (done)
|
|
329
|
+
break;
|
|
330
|
+
buffer += decoder.decode(value, { stream: true });
|
|
331
|
+
const lines = buffer.split('\n');
|
|
332
|
+
buffer = lines.pop();
|
|
333
|
+
for (const line of lines) {
|
|
334
|
+
if (!line.startsWith('data: '))
|
|
335
|
+
continue;
|
|
336
|
+
const data = line.slice(6);
|
|
337
|
+
if (data === '[DONE]')
|
|
338
|
+
continue;
|
|
339
|
+
try {
|
|
340
|
+
const event = JSON.parse(data);
|
|
341
|
+
// Forward each SSE event to extension
|
|
342
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
343
|
+
ws.send(JSON.stringify({
|
|
344
|
+
type: 'proxy_stream_chunk',
|
|
345
|
+
requestId,
|
|
346
|
+
data: event,
|
|
347
|
+
}));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
// Skip malformed JSON
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
clearTimeout(timeoutId);
|
|
356
|
+
// Signal stream complete
|
|
357
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
358
|
+
ws.send(JSON.stringify({ type: 'proxy_stream_end', requestId }));
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
clearTimeout(timeoutId);
|
|
363
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
364
|
+
ws.send(JSON.stringify({
|
|
365
|
+
type: 'proxy_api_error',
|
|
366
|
+
requestId,
|
|
367
|
+
error: err.name === 'AbortError'
|
|
368
|
+
? `API proxy request timed out after ${PROXY_TIMEOUT_MS / 1000} seconds`
|
|
369
|
+
: err.message,
|
|
370
|
+
}));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// Graceful shutdown
|
|
375
|
+
process.on('SIGINT', () => {
|
|
376
|
+
log('Shutting down...');
|
|
377
|
+
wss.close();
|
|
378
|
+
process.exit(0);
|
|
379
|
+
});
|
|
380
|
+
process.on('SIGTERM', () => {
|
|
381
|
+
log('Shutting down...');
|
|
382
|
+
wss.close();
|
|
383
|
+
process.exit(0);
|
|
384
|
+
});
|
|
385
|
+
// Keep alive — log stats periodically
|
|
386
|
+
setInterval(() => {
|
|
387
|
+
const roles = { extension: 0, mcp: 0, cli: 0 };
|
|
388
|
+
for (const client of clients.values()) {
|
|
389
|
+
roles[client.role]++;
|
|
390
|
+
}
|
|
391
|
+
if (clients.size > 0) {
|
|
392
|
+
log(`Clients: ${clients.size} (ext:${roles.extension} mcp:${roles.mcp} cli:${roles.cli})`);
|
|
393
|
+
}
|
|
394
|
+
}, 30000);
|
|
395
|
+
// Ping the extension every 20 seconds to keep its service worker alive.
|
|
396
|
+
// Chrome suspends MV3 service workers after ~30s of inactivity, which drops
|
|
397
|
+
// the WebSocket. Application-level pings (not WS frames) wake the worker.
|
|
398
|
+
setInterval(() => {
|
|
399
|
+
const ext = getExtension();
|
|
400
|
+
if (ext && ext.ws.readyState === WebSocket.OPEN) {
|
|
401
|
+
ext.ws.send(JSON.stringify({ type: 'ping' }));
|
|
402
|
+
}
|
|
403
|
+
}, 20000);
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Types
|
|
3
|
+
*
|
|
4
|
+
* Defines the session state machine and related types for browser automation tasks.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* All possible states a session can be in.
|
|
8
|
+
*
|
|
9
|
+
* State transitions:
|
|
10
|
+
* CREATED → EXECUTING → COMPLETED
|
|
11
|
+
* ↓
|
|
12
|
+
* WAITING_FOR_USER → EXECUTING
|
|
13
|
+
* Any state can transition to FAILED or CANCELLED
|
|
14
|
+
*/
|
|
15
|
+
export type SessionState = "CREATED" | "EXECUTING" | "WAITING_FOR_USER" | "COMPLETED" | "FAILED" | "CANCELLED";
|
|
16
|
+
/**
|
|
17
|
+
* Valid state transitions for the session state machine.
|
|
18
|
+
*/
|
|
19
|
+
export declare const VALID_TRANSITIONS: Record<SessionState, SessionState[]>;
|
|
20
|
+
/**
|
|
21
|
+
* A question to ask the user for missing information.
|
|
22
|
+
*/
|
|
23
|
+
export interface Question {
|
|
24
|
+
/** Unique identifier for the question */
|
|
25
|
+
id: string;
|
|
26
|
+
/** The field/info this question is asking about (e.g., "email", "departure_date") */
|
|
27
|
+
field: string;
|
|
28
|
+
/** Human-readable question to display */
|
|
29
|
+
question: string;
|
|
30
|
+
/** Optional hint or example */
|
|
31
|
+
hint?: string;
|
|
32
|
+
/** Whether this is required or optional */
|
|
33
|
+
required: boolean;
|
|
34
|
+
/** Data type expected (for validation) */
|
|
35
|
+
type?: "text" | "email" | "date" | "password" | "number" | "choice";
|
|
36
|
+
/** For choice type, the available options */
|
|
37
|
+
options?: string[];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A single entry in the execution trace.
|
|
41
|
+
*/
|
|
42
|
+
export interface TraceEntry {
|
|
43
|
+
timestamp: string;
|
|
44
|
+
type: "navigation" | "click" | "fill" | "screenshot" | "thinking" | "error" | "info";
|
|
45
|
+
description: string;
|
|
46
|
+
url?: string;
|
|
47
|
+
selector?: string;
|
|
48
|
+
value?: string;
|
|
49
|
+
screenshot?: string;
|
|
50
|
+
success: boolean;
|
|
51
|
+
error?: string;
|
|
52
|
+
metadata?: Record<string, unknown>;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Main session object tracking a browser automation task.
|
|
56
|
+
*/
|
|
57
|
+
export interface Session {
|
|
58
|
+
id: string;
|
|
59
|
+
state: SessionState;
|
|
60
|
+
task: string;
|
|
61
|
+
url?: string;
|
|
62
|
+
context?: string;
|
|
63
|
+
domain?: string;
|
|
64
|
+
collectedInfo: Record<string, string>;
|
|
65
|
+
pendingQuestions: Question[];
|
|
66
|
+
executionTrace: TraceEntry[];
|
|
67
|
+
currentStep?: string;
|
|
68
|
+
answer?: string;
|
|
69
|
+
error?: string;
|
|
70
|
+
createdAt: Date;
|
|
71
|
+
updatedAt: Date;
|
|
72
|
+
completedAt?: Date;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Serializable session data for storage/transport.
|
|
76
|
+
* Dates are converted to ISO strings.
|
|
77
|
+
*/
|
|
78
|
+
export interface SerializedSession {
|
|
79
|
+
id: string;
|
|
80
|
+
state: SessionState;
|
|
81
|
+
task: string;
|
|
82
|
+
url?: string;
|
|
83
|
+
context?: string;
|
|
84
|
+
domain?: string;
|
|
85
|
+
collectedInfo: Record<string, string>;
|
|
86
|
+
pendingQuestions: Question[];
|
|
87
|
+
executionTrace: TraceEntry[];
|
|
88
|
+
currentStep?: string;
|
|
89
|
+
answer?: string;
|
|
90
|
+
error?: string;
|
|
91
|
+
createdAt: string;
|
|
92
|
+
updatedAt: string;
|
|
93
|
+
completedAt?: string;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Options for creating a new session.
|
|
97
|
+
*/
|
|
98
|
+
export interface CreateSessionOptions {
|
|
99
|
+
task: string;
|
|
100
|
+
url?: string;
|
|
101
|
+
context?: string;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Result of a state transition attempt.
|
|
105
|
+
*/
|
|
106
|
+
export interface TransitionResult {
|
|
107
|
+
/** Whether the transition was successful */
|
|
108
|
+
success: boolean;
|
|
109
|
+
/** Previous state (if successful) */
|
|
110
|
+
previousState?: SessionState;
|
|
111
|
+
/** New state (if successful) */
|
|
112
|
+
newState?: SessionState;
|
|
113
|
+
/** Error message (if failed) */
|
|
114
|
+
error?: string;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Session status for external API responses.
|
|
118
|
+
* Simplified view of session state.
|
|
119
|
+
*/
|
|
120
|
+
export interface SessionStatus {
|
|
121
|
+
sessionId: string;
|
|
122
|
+
status: SessionState;
|
|
123
|
+
task: string;
|
|
124
|
+
domain?: string;
|
|
125
|
+
currentStep?: string;
|
|
126
|
+
/** Summary of steps taken */
|
|
127
|
+
steps: string[];
|
|
128
|
+
/** If NEEDS_INFO, the questions to answer */
|
|
129
|
+
questions?: string[];
|
|
130
|
+
/** If COMPLETED, the answer/result */
|
|
131
|
+
answer?: string;
|
|
132
|
+
/** If FAILED, the error message */
|
|
133
|
+
error?: string;
|
|
134
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Types
|
|
3
|
+
*
|
|
4
|
+
* Defines the session state machine and related types for browser automation tasks.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Valid state transitions for the session state machine.
|
|
8
|
+
*/
|
|
9
|
+
export const VALID_TRANSITIONS = {
|
|
10
|
+
CREATED: ["EXECUTING", "FAILED", "CANCELLED"],
|
|
11
|
+
EXECUTING: ["COMPLETED", "WAITING_FOR_USER", "FAILED", "CANCELLED"],
|
|
12
|
+
WAITING_FOR_USER: ["EXECUTING", "FAILED", "CANCELLED"],
|
|
13
|
+
COMPLETED: [], // Terminal state
|
|
14
|
+
FAILED: [], // Terminal state
|
|
15
|
+
CANCELLED: [], // Terminal state
|
|
16
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hanzi-browse",
|
|
3
|
+
"version": "2.2.0",
|
|
4
|
+
"description": "Give your AI agent a real browser — click, type, fill forms, test workflows, post content, and read authenticated pages",
|
|
5
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
6
|
+
"author": "hanzili",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/hanzili/hanzi-browse.git"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"hanzi-browse": "./dist/index.js",
|
|
14
|
+
"hanzi-browser": "./dist/cli.js",
|
|
15
|
+
"hanzi-relay": "./dist/relay/server.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"skills",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc && cd dashboard && npm run build",
|
|
24
|
+
"build:server": "tsc",
|
|
25
|
+
"build:dashboard": "cd dashboard && npm run build",
|
|
26
|
+
"dev": "tsc --watch",
|
|
27
|
+
"start": "node dist/index.js",
|
|
28
|
+
"cli": "node dist/cli.js",
|
|
29
|
+
"test": "npx tsx test/test-agents.ts",
|
|
30
|
+
"test:live": "npx tsx test/test-live.ts",
|
|
31
|
+
"test:followup": "npx tsx test/test-followup.ts"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@modelcontextprotocol/sdk": "^1.25.3",
|
|
35
|
+
"better-auth": "^1.5.5",
|
|
36
|
+
"pg": "^8.11.3",
|
|
37
|
+
"stripe": "^20.4.1",
|
|
38
|
+
"undici": "^7.24.5",
|
|
39
|
+
"ws": "^8.19.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^20.11.0",
|
|
43
|
+
"@types/pg": "^8.18.0",
|
|
44
|
+
"@types/ws": "^8.18.1",
|
|
45
|
+
"typescript": "^5.3.0",
|
|
46
|
+
"vitest": "^4.1.1"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18.0.0"
|
|
50
|
+
},
|
|
51
|
+
"keywords": [
|
|
52
|
+
"mcp",
|
|
53
|
+
"browser",
|
|
54
|
+
"chrome",
|
|
55
|
+
"automation",
|
|
56
|
+
"claude",
|
|
57
|
+
"model-context-protocol",
|
|
58
|
+
"browser-automation",
|
|
59
|
+
"web-agent"
|
|
60
|
+
]
|
|
61
|
+
}
|
package/skills/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Hanzi Browse Skills
|
|
2
|
+
|
|
3
|
+
Agent skills for [Hanzi Browse](https://browse.hanzilla.co) — give your AI agent a real browser.
|
|
4
|
+
|
|
5
|
+
## Core Skill
|
|
6
|
+
|
|
7
|
+
| Skill | Description |
|
|
8
|
+
|-------|-------------|
|
|
9
|
+
| [hanzi-browse](hanzi-browse/) | Browser automation via MCP — click, type, fill forms, read authenticated pages |
|
|
10
|
+
|
|
11
|
+
## Workflow Skills
|
|
12
|
+
|
|
13
|
+
| Skill | Description |
|
|
14
|
+
|-------|-------------|
|
|
15
|
+
| [e2e-tester](e2e-tester/) | Test web apps like a QA person with real browser interactions |
|
|
16
|
+
| [social-poster](social-poster/) | Draft and post content across LinkedIn, Twitter/X, Reddit |
|
|
17
|
+
| [linkedin-prospector](linkedin-prospector/) | Find and connect with prospects on LinkedIn |
|
|
18
|
+
| [a11y-auditor](a11y-auditor/) | Run accessibility audits in a real browser |
|
|
19
|
+
| [x-marketer](x-marketer/) | Twitter/X marketing workflows |
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
### Claude Code
|
|
24
|
+
```bash
|
|
25
|
+
# Copy a skill to your project
|
|
26
|
+
cp -r hanzi-browse/ .claude/skills/hanzi-browse/
|
|
27
|
+
|
|
28
|
+
# Or install globally
|
|
29
|
+
cp -r hanzi-browse/ ~/.claude/skills/hanzi-browse/
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Cursor
|
|
33
|
+
```bash
|
|
34
|
+
cp -r hanzi-browse/ .cursor/skills/hanzi-browse/
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Other agents
|
|
38
|
+
Copy the skill directory to your agent's skills folder. See [awesome-agent-skills](https://github.com/VoltAgent/awesome-agent-skills) for paths.
|
|
39
|
+
|
|
40
|
+
## Setup
|
|
41
|
+
|
|
42
|
+
The skills require the Hanzi Browse MCP server:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx hanzi-browse setup
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
This installs the Chrome extension and configures your AI agents automatically.
|