claude-simple 0.1.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.
Files changed (50) hide show
  1. package/README.md +225 -0
  2. package/client.js +139 -0
  3. package/dist/addon-fit.min.js +2 -0
  4. package/dist/app.0.1.0.min.js +825 -0
  5. package/dist/highlight.min.css +10 -0
  6. package/dist/highlight.min.js +1244 -0
  7. package/dist/index.html +16 -0
  8. package/dist/marked.min.js +6 -0
  9. package/dist/style.0.1.0.min.css +1050 -0
  10. package/dist/xterm.min.css +209 -0
  11. package/dist/xterm.min.js +2 -0
  12. package/gateway.js +349 -0
  13. package/package.json +41 -0
  14. package/public/addon-fit.min.js +2 -0
  15. package/public/agent-panel.js +1079 -0
  16. package/public/api.js +78 -0
  17. package/public/app.js +95 -0
  18. package/public/base.css +4 -0
  19. package/public/cache.js +27 -0
  20. package/public/chat.js +1087 -0
  21. package/public/files.js +476 -0
  22. package/public/git.js +130 -0
  23. package/public/index.html +61 -0
  24. package/public/lib.js +2 -0
  25. package/public/router.js +71 -0
  26. package/public/settings.js +90 -0
  27. package/public/shell/boot.js +159 -0
  28. package/public/shell/dashboard.js +269 -0
  29. package/public/shell/hub.js +192 -0
  30. package/public/shell/layout.js +195 -0
  31. package/public/shell/notes.js +323 -0
  32. package/public/shell/session-context.js +19 -0
  33. package/public/shell/session-list.js +442 -0
  34. package/public/shell/session-nav.js +299 -0
  35. package/public/shell/templates.js +342 -0
  36. package/public/shell/util.js +63 -0
  37. package/public/shell.js +589 -0
  38. package/public/state.js +362 -0
  39. package/public/style.css +1048 -0
  40. package/public/terminal.js +177 -0
  41. package/public/xterm.min.css +209 -0
  42. package/public/xterm.min.js +2 -0
  43. package/server.js +12 -0
  44. package/src/agents/claude.js +58 -0
  45. package/src/agents/codex.js +284 -0
  46. package/src/agents/grok.js +318 -0
  47. package/src/agents/meta-agent.js +1952 -0
  48. package/src/agents/models.js +339 -0
  49. package/src/agents/sessions.js +1508 -0
  50. package/src/app.js +1282 -0
package/src/app.js ADDED
@@ -0,0 +1,1282 @@
1
+ import express from 'express';
2
+ import http from 'http';
3
+ import { WebSocketServer } from 'ws';
4
+ import jwt from 'jsonwebtoken';
5
+ import bcrypt from 'bcrypt';
6
+ import { spawn, execSync } from 'child_process';
7
+ import { promises as fs } from 'fs';
8
+ import fsSync from 'fs';
9
+ import path from 'path';
10
+ import os from 'os';
11
+ import crypto from 'crypto';
12
+ import { fileURLToPath } from 'url';
13
+ import { runClaude } from './agents/claude.js';
14
+ import { runCodex } from './agents/codex.js';
15
+ import { runGrok } from './agents/grok.js';
16
+ import {
17
+ scanAllSessions,
18
+ loadSessionMessages,
19
+ loadSessionMemory,
20
+ loadSessionUsage,
21
+ searchActivity,
22
+ groupByProject,
23
+ toClientSession,
24
+ convertSession,
25
+ } from './agents/sessions.js';
26
+ import { createMetaAgent } from './agents/meta-agent.js';
27
+ import { discoverAllAgentModels } from './agents/models.js';
28
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
+ const rootDir = path.join(__dirname, '..');
30
+
31
+ // ─── Config ──────────────────────────────────────────────────────────────────
32
+ export const PORT = parseInt(process.env.PORT || '3000');
33
+ const CREDENTIALS_FILE = process.env.CREDENTIALS_FILE || path.join(rootDir, '.credentials.json');
34
+ const WORKSPACES_FILE = process.env.WORKSPACES_FILE || path.join(rootDir, '.workspaces.json');
35
+ const PROJECT_NOTES_FILE = process.env.PROJECT_NOTES_FILE || path.join(rootDir, '.project_notes.json');
36
+ const SESSION_META_FILE = process.env.SESSION_META_FILE || path.join(rootDir, '.session_meta.json');
37
+ const CLAUDE_CLI_PATH = process.env.CLAUDE_CLI_PATH || 'claude';
38
+ const CODEX_CLI_PATH = process.env.CODEX_CLI_PATH || 'codex';
39
+ const GROK_CLI_PATH = process.env.GROK_CLI_PATH || 'grok';
40
+ const CODEX_HOME = (process.env.CODEX_HOME || path.join(os.homedir(), '.codex')).replace(/^~/, os.homedir());
41
+ const GROK_HOME = (process.env.GROK_HOME || path.join(os.homedir(), '.grok')).replace(/^~/, os.homedir());
42
+ const ENABLED_AGENTS = (() => {
43
+ const raw = (process.env.AGENTS || 'claude,codex,grok')
44
+ .split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
45
+ return [...new Set(raw.length ? raw : ['claude', 'codex', 'grok'])];
46
+ })();
47
+ const CLAUDE_CONFIG_DIRS = (() => {
48
+ const dirs = (process.env.CLAUDE_CONFIG_DIRS || '')
49
+ .split(',').map(s => s.trim().replace(/^~/, os.homedir())).filter(Boolean);
50
+ return [...new Set(dirs.length ? dirs : [path.join(os.homedir(), '.claude')])];
51
+ })();
52
+
53
+ // ─── Auth storage ─────────────────────────────────────────────────────────────
54
+ let credStore = null;
55
+
56
+ async function loadCreds() {
57
+ if (credStore) return credStore;
58
+ try {
59
+ credStore = JSON.parse(await fs.readFile(CREDENTIALS_FILE, 'utf8'));
60
+ } catch {
61
+ credStore = { users: [] };
62
+ }
63
+ if (!credStore.jwtSecret) {
64
+ credStore.jwtSecret = crypto.randomBytes(32).toString('hex');
65
+ await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(credStore, null, 2));
66
+ }
67
+ return credStore;
68
+ }
69
+
70
+ async function saveCreds(store) {
71
+ credStore = store;
72
+ await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(store, null, 2));
73
+ }
74
+
75
+ async function hasUsers() {
76
+ const store = await loadCreds();
77
+ return store.users.length > 0;
78
+ }
79
+
80
+ // ─── Workspace storage ────────────────────────────────────────────────────────
81
+ let wsStore = null;
82
+
83
+ async function loadWorkspacesStore() {
84
+ if (wsStore) return wsStore;
85
+ try {
86
+ wsStore = JSON.parse(await fs.readFile(WORKSPACES_FILE, 'utf8'));
87
+ } catch {
88
+ wsStore = { workspaces: CLAUDE_CONFIG_DIRS.map((d, i) => ({
89
+ id: crypto.randomUUID(),
90
+ name: i === 0 ? 'default' : path.basename(d),
91
+ configDir: d,
92
+ })) };
93
+ }
94
+ return wsStore;
95
+ }
96
+
97
+ async function saveWorkspacesStore() {
98
+ await fs.writeFile(WORKSPACES_FILE, JSON.stringify(wsStore, null, 2));
99
+ }
100
+
101
+ let projectNotesCache = null;
102
+ let sessionMetaCache = null;
103
+
104
+ /** Normalize project entry: old { notes } was the goal line. */
105
+ function normalizeProjectEntry(entry) {
106
+ if (!entry || typeof entry !== 'object') return { goal: '', notes: '', updatedAt: 0 };
107
+ const hasGoalKey = Object.prototype.hasOwnProperty.call(entry, 'goal');
108
+ if (!hasGoalKey && entry.notes != null) {
109
+ // legacy: single notes field = Project Goal
110
+ return { goal: String(entry.notes || ''), notes: '', updatedAt: entry.updatedAt || 0 };
111
+ }
112
+ return {
113
+ goal: String(entry.goal || ''),
114
+ notes: String(entry.notes || ''),
115
+ updatedAt: entry.updatedAt || 0,
116
+ };
117
+ }
118
+
119
+ async function loadProjectNotes() {
120
+ if (projectNotesCache) return projectNotesCache;
121
+ try {
122
+ projectNotesCache = JSON.parse(await fs.readFile(PROJECT_NOTES_FILE, 'utf8'));
123
+ } catch {
124
+ projectNotesCache = {};
125
+ }
126
+ return projectNotesCache;
127
+ }
128
+
129
+ async function saveProjectNotes() {
130
+ if (!projectNotesCache) return;
131
+ await fs.writeFile(PROJECT_NOTES_FILE, JSON.stringify(projectNotesCache, null, 2), 'utf8');
132
+ }
133
+
134
+ function sessionMetaKey(agent, sessionId) {
135
+ return `${agent || 'claude'}:${sessionId}`;
136
+ }
137
+
138
+ async function loadSessionMeta() {
139
+ if (sessionMetaCache) return sessionMetaCache;
140
+ try {
141
+ sessionMetaCache = JSON.parse(await fs.readFile(SESSION_META_FILE, 'utf8'));
142
+ } catch {
143
+ sessionMetaCache = {};
144
+ }
145
+ return sessionMetaCache;
146
+ }
147
+
148
+ async function saveSessionMeta() {
149
+ if (!sessionMetaCache) return;
150
+ await fs.writeFile(SESSION_META_FILE, JSON.stringify(sessionMetaCache, null, 2), 'utf8');
151
+ }
152
+
153
+ async function getWorkspaces() {
154
+ return (await loadWorkspacesStore()).workspaces;
155
+ }
156
+
157
+ async function syncConfigDirs() {
158
+ const wss = await getWorkspaces();
159
+ const dirs = [...new Set(wss.map(w => w.configDir))];
160
+ CLAUDE_CONFIG_DIRS.splice(0, CLAUDE_CONFIG_DIRS.length, ...dirs);
161
+ }
162
+
163
+ async function findUser(username) {
164
+ const store = await loadCreds();
165
+ return store.users.find(u => u.username === username) || null;
166
+ }
167
+
168
+ async function createUser(username, passwordHash) {
169
+ const store = await loadCreds();
170
+ const user = { id: Date.now(), username, passwordHash };
171
+ store.users.push(user);
172
+ await saveCreds(store);
173
+ return user;
174
+ }
175
+
176
+ function jwtSecret() { return credStore?.jwtSecret; }
177
+
178
+ function makeToken(user) {
179
+ return jwt.sign({ userId: user.id, username: user.username }, jwtSecret(), { expiresIn: '7d' });
180
+ }
181
+
182
+ function verifyToken(token) {
183
+ try { return jwt.verify(token, jwtSecret()); }
184
+ catch { return null; }
185
+ }
186
+
187
+ async function bootstrapEnvUser() {
188
+ const u = process.env.AUTH_USERNAME;
189
+ const p = process.env.AUTH_PASSWORD;
190
+ if (!u || !p) return;
191
+ if (await hasUsers()) return;
192
+ const hash = await bcrypt.hash(p, 12);
193
+ await createUser(u, hash);
194
+ console.log(`[auth] Bootstrapped user "${u}" from environment`);
195
+ }
196
+
197
+ // ─── Middleware ───────────────────────────────────────────────────────────────
198
+ // When this process is an edge worker (client.js), the hub authenticates users
199
+ // and forwards with X-Hub-Token: MACHINE_TOKEN. JWT still works for standalone UI.
200
+ function authMiddleware(req, res, next) {
201
+ const hubSecret = process.env.MACHINE_TOKEN;
202
+ const hubHeader = req.headers['x-hub-token'];
203
+ if (hubSecret && hubHeader && hubHeader === hubSecret) {
204
+ req.user = {
205
+ userId: 0,
206
+ username: req.headers['x-hub-user'] || 'hub',
207
+ viaHub: true,
208
+ };
209
+ return next();
210
+ }
211
+
212
+ const header = req.headers['authorization'];
213
+ let token = header && header.split(' ')[1];
214
+ if (!token && req.query.token) token = req.query.token;
215
+ // Hub also tunnels WS with ?token=MACHINE_TOKEN&hub=1
216
+ if (hubSecret && token && token === hubSecret) {
217
+ req.user = { userId: 0, username: 'hub', viaHub: true };
218
+ return next();
219
+ }
220
+ if (!token) return res.status(401).json({ error: 'No token' });
221
+ const decoded = verifyToken(token);
222
+ if (!decoded) return res.status(403).json({ error: 'Invalid token' });
223
+ req.user = decoded;
224
+ next();
225
+ }
226
+
227
+ // ─── Path helpers ─────────────────────────────────────────────────────────────
228
+ function safePath(base, rel) {
229
+ const resolved = path.resolve(base, rel || '');
230
+ if (!resolved.startsWith(path.resolve(base))) throw new Error('Path traversal');
231
+ return resolved;
232
+ }
233
+
234
+ function expandPath(p) {
235
+ if (!p) return p;
236
+ return p.replace(/^~/, os.homedir());
237
+ }
238
+
239
+ function resolveProjectRoot(req) {
240
+ const root = req.query.root || req.body?.root;
241
+ if (root) {
242
+ const resolved = path.resolve(expandPath(root));
243
+ if (!path.isAbsolute(resolved) || resolved === '/' || resolved.includes('\0'))
244
+ throw new Error('Invalid root path');
245
+ return resolved;
246
+ }
247
+ throw new Error('root path required');
248
+ }
249
+
250
+ // ─── Git helpers ──────────────────────────────────────────────────────────────
251
+ function git(args, cwd) {
252
+ return new Promise((resolve, reject) => {
253
+ const child = spawn('git', args, { cwd, shell: false });
254
+ let out = '', err = '';
255
+ child.stdout.on('data', d => out += d);
256
+ child.stderr.on('data', d => err += d);
257
+ child.on('close', code => {
258
+ if (code === 0) resolve(out);
259
+ else reject(new Error(err || `git exit ${code}`));
260
+ });
261
+ child.on('error', reject);
262
+ });
263
+ }
264
+
265
+ async function isGitRepo(dir) {
266
+ try { await git(['rev-parse', '--git-dir'], dir); return true; }
267
+ catch { return false; }
268
+ }
269
+
270
+ // ─── Active agent sessions ────────────────────────────────────────────────────
271
+ const activeSessions = new Map();
272
+
273
+ async function listSessions(filterCwd = null, agentFilter = null) {
274
+ const agents = agentFilter
275
+ ? [agentFilter]
276
+ : ENABLED_AGENTS;
277
+ return scanAllSessions({
278
+ claudeConfigDirs: CLAUDE_CONFIG_DIRS,
279
+ codexHome: CODEX_HOME,
280
+ grokHome: GROK_HOME,
281
+ filterCwd,
282
+ agents,
283
+ });
284
+ }
285
+
286
+ async function dispatchAgent(agent, command, options, send) {
287
+ const a = (agent || 'claude').toLowerCase();
288
+ if (!ENABLED_AGENTS.includes(a)) {
289
+ send({ type: 'error', message: `Agent "${a}" is not enabled. Enabled: ${ENABLED_AGENTS.join(', ')}` });
290
+ return;
291
+ }
292
+ const ctx = { activeSessions };
293
+ if (a === 'codex') {
294
+ await runCodex(command, options, send, { ...ctx, cliPath: CODEX_CLI_PATH });
295
+ } else if (a === 'grok') {
296
+ await runGrok(command, options, send, { ...ctx, cliPath: GROK_CLI_PATH });
297
+ } else {
298
+ await runClaude(command, options, send, { ...ctx, cliPath: CLAUDE_CLI_PATH });
299
+ }
300
+ }
301
+
302
+ function abortSession(sessionId) {
303
+ const s = activeSessions.get(sessionId);
304
+ if (!s) return false;
305
+ if (s.controller) s.controller.abort();
306
+ if (typeof s.abort === 'function') s.abort();
307
+ else if (s.child) {
308
+ try { s.child.kill('SIGTERM'); } catch {}
309
+ }
310
+ return true;
311
+ }
312
+
313
+ // ─── App factory ──────────────────────────────────────────────────────────────
314
+ export function createApp() {
315
+ const app = express();
316
+
317
+ app.post('/api/upload-image', authMiddleware, (req, res) => {
318
+ (async () => {
319
+ const ext = (req.headers['x-filename'] || 'image.png').split('.').pop().replace(/[^a-z0-9]/gi, '') || 'png';
320
+ const name = `claude-upload-${Date.now()}.${ext}`;
321
+ const dest = path.join(os.tmpdir(), name);
322
+ const writer = fsSync.createWriteStream(dest);
323
+ req.pipe(writer);
324
+ await new Promise((resolve, reject) => {
325
+ writer.on('finish', resolve);
326
+ writer.on('error', reject);
327
+ req.on('error', reject);
328
+ });
329
+ res.json({ path: dest });
330
+ })().catch(e => res.status(500).json({ error: e.message }));
331
+ });
332
+
333
+ app.post('/api/projects/:id/file', authMiddleware, (req, res) => {
334
+ (async () => {
335
+ const projectPath = resolveProjectRoot(req);
336
+ const rel = req.query.path;
337
+ if (!rel) return res.status(400).json({ error: 'path required' });
338
+ const filePath = safePath(projectPath, rel);
339
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
340
+ const writer = fsSync.createWriteStream(filePath);
341
+ req.pipe(writer);
342
+ await new Promise((resolve, reject) => {
343
+ writer.on('finish', resolve);
344
+ writer.on('error', reject);
345
+ req.on('error', reject);
346
+ });
347
+ const stat = await fs.stat(filePath);
348
+ res.json({ ok: true, size: stat.size });
349
+ })().catch(e => res.status(500).json({ error: e.message }));
350
+ });
351
+
352
+ app.use(express.json({ limit: '10mb' }));
353
+
354
+ const distDir = path.join(rootDir, 'dist');
355
+ const publicDir = path.join(rootDir, 'public');
356
+ // Only ship the built bundle in production. Local `node server.js` serves
357
+ // public/ so new modules (shell.js, state.js, …) are not 404s against stale dist/.
358
+ const usesDist = process.env.NODE_ENV === 'production' && fsSync.existsSync(distDir);
359
+ const staticDir = usesDist ? distDir : publicDir;
360
+ console.log(`[static] serving ${usesDist ? 'dist/' : 'public/'} (NODE_ENV=${process.env.NODE_ENV || 'unset'})`);
361
+ app.use(express.static(staticDir));
362
+
363
+ // Standalone: report "not a hub" with 200 so browser probe isn't a red 404
364
+ app.get('/api/hub', (_req, res) => {
365
+ res.json({ hub: false });
366
+ });
367
+
368
+ // Avoid noisy favicon 404s in the browser console
369
+ app.get('/favicon.ico', (_req, res) => {
370
+ res.type('image/svg+xml').send(
371
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#1d4ed8"/><text x="16" y="22" text-anchor="middle" font-size="16" fill="white" font-family="system-ui">A</text></svg>`
372
+ );
373
+ });
374
+
375
+ app.get('/api/auth/status', async (_req, res) => {
376
+ res.json({ needsSetup: !(await hasUsers()) });
377
+ });
378
+
379
+ app.post('/api/auth/register', async (req, res) => {
380
+ try {
381
+ if (await hasUsers()) return res.status(403).json({ error: 'User already exists' });
382
+ const { username, password } = req.body;
383
+ if (!username || !password) return res.status(400).json({ error: 'Username and password required' });
384
+ if (username.length < 3 || password.length < 6)
385
+ return res.status(400).json({ error: 'Username ≥3 chars, password ≥6 chars' });
386
+ const hash = await bcrypt.hash(password, 12);
387
+ const user = await createUser(username, hash);
388
+ res.json({ token: makeToken(user), user: { id: user.id, username: user.username } });
389
+ } catch (e) {
390
+ res.status(500).json({ error: e.message });
391
+ }
392
+ });
393
+
394
+ app.post('/api/auth/login', async (req, res) => {
395
+ try {
396
+ const { username, password } = req.body;
397
+ if (!username || !password) return res.status(400).json({ error: 'Username and password required' });
398
+ const user = await findUser(username);
399
+ if (!user) return res.status(401).json({ error: 'Invalid credentials' });
400
+ const ok = await bcrypt.compare(password, user.passwordHash);
401
+ if (!ok) return res.status(401).json({ error: 'Invalid credentials' });
402
+ res.json({ token: makeToken(user), user: { id: user.id, username: user.username } });
403
+ } catch (e) {
404
+ res.status(500).json({ error: e.message });
405
+ }
406
+ });
407
+
408
+ app.get('/api/projects/:id/files', authMiddleware, async (req, res) => {
409
+ try {
410
+ const projectPath = resolveProjectRoot(req);
411
+ const rel = req.query.path || '';
412
+ const dir = rel ? safePath(projectPath, rel) : projectPath;
413
+ const entries = await fs.readdir(dir, { withFileTypes: true });
414
+ const files = entries
415
+ .filter(e => !e.name.startsWith('.') || e.name === '.env')
416
+ .map(e => ({
417
+ name: e.name,
418
+ path: rel ? `${rel}/${e.name}` : e.name,
419
+ isDir: e.isDirectory(),
420
+ }));
421
+ res.json(files);
422
+ } catch (e) {
423
+ res.status(500).json({ error: e.message });
424
+ }
425
+ });
426
+
427
+ app.get('/api/projects/:id/file', authMiddleware, async (req, res) => {
428
+ try {
429
+ const projectPath = resolveProjectRoot(req);
430
+ const rel = req.query.path;
431
+ if (!rel) return res.status(400).json({ error: 'path required' });
432
+ const filePath = safePath(projectPath, rel);
433
+ const stat = await fs.stat(filePath);
434
+ if (req.query.download === 'true') {
435
+ const name = path.basename(filePath);
436
+ res.setHeader('Content-Disposition', `attachment; filename="${name}"`);
437
+ res.setHeader('Content-Type', 'application/octet-stream');
438
+ return fsSync.createReadStream(filePath).pipe(res);
439
+ }
440
+ // Office / PDF / binary previews need more headroom than plain text
441
+ const isOffice = /\.(docx|pptx|xlsx|pdf)$/i.test(rel);
442
+ const maxBytes = isOffice ? 15 * 1024 * 1024 : 2 * 1024 * 1024;
443
+ if (stat.size > maxBytes) {
444
+ return res.status(413).json({
445
+ error: isOffice ? 'File too large (>15MB)' : 'File too large (>2MB)',
446
+ });
447
+ }
448
+ const buf = await fs.readFile(filePath);
449
+ // NUL in first 512 bytes ⇒ binary (ZIP-based Office formats, images, …)
450
+ const head = buf.subarray(0, Math.min(512, buf.length));
451
+ const binary = isOffice || head.includes(0);
452
+ if (binary) {
453
+ return res.json({
454
+ content: buf.toString('base64'),
455
+ encoding: 'base64',
456
+ size: stat.size,
457
+ });
458
+ }
459
+ res.json({ content: buf.toString('utf8'), encoding: 'utf8', size: stat.size });
460
+ } catch (e) {
461
+ res.status(500).json({ error: e.message });
462
+ }
463
+ });
464
+
465
+ app.delete('/api/projects/:id/file', authMiddleware, async (req, res) => {
466
+ try {
467
+ const projectPath = resolveProjectRoot(req);
468
+ const rel = req.query.path;
469
+ if (!rel) return res.status(400).json({ error: 'path required' });
470
+ const filePath = safePath(projectPath, rel);
471
+ await fs.rm(filePath, { recursive: true, force: true });
472
+ res.json({ ok: true });
473
+ } catch (e) {
474
+ res.status(500).json({ error: e.message });
475
+ }
476
+ });
477
+
478
+ app.get('/api/projects/:id/git/status', authMiddleware, async (req, res) => {
479
+ try {
480
+ const projectPath = resolveProjectRoot(req);
481
+ if (!(await isGitRepo(projectPath))) return res.json({ isGit: false, files: [] });
482
+ const out = await git(['status', '--porcelain'], projectPath);
483
+ const branch = await git(['branch', '--show-current'], projectPath).catch(() => 'unknown');
484
+ const files = out.trim().split('\n').filter(Boolean).map(line => ({
485
+ status: line.slice(0, 2).trim(),
486
+ file: line.slice(3),
487
+ }));
488
+ res.json({ isGit: true, branch: branch.trim(), files });
489
+ } catch (e) {
490
+ res.status(500).json({ error: e.message });
491
+ }
492
+ });
493
+
494
+ app.get('/api/projects/:id/git/diff', authMiddleware, async (req, res) => {
495
+ try {
496
+ const projectPath = resolveProjectRoot(req);
497
+ if (!(await isGitRepo(projectPath))) return res.json({ diff: '' });
498
+ const staged = req.query.staged === 'true';
499
+ const args = staged ? ['diff', '--cached'] : ['diff'];
500
+ const diff = await git(args, projectPath).catch(() => '');
501
+ res.json({ diff });
502
+ } catch (e) {
503
+ res.status(500).json({ error: e.message });
504
+ }
505
+ });
506
+
507
+ app.post('/api/resolve-path', authMiddleware, async (req, res) => {
508
+ try {
509
+ const { path: rawPath } = req.body;
510
+ if (!rawPath?.trim()) return res.status(400).json({ error: 'path required' });
511
+ const resolved = path.resolve(expandPath(rawPath.trim()));
512
+ if (resolved === '/' || resolved.includes('\0'))
513
+ return res.status(400).json({ error: 'Invalid path' });
514
+ const stat = await fs.stat(resolved).catch(() => null);
515
+ if (!stat) return res.status(404).json({ error: `Path not found: ${resolved}` });
516
+ res.json({ path: resolved, isDir: stat.isDirectory(), isGit: await isGitRepo(resolved).catch(() => false) });
517
+ } catch (e) { res.status(400).json({ error: e.message }); }
518
+ });
519
+
520
+ app.get('/api/workspaces', authMiddleware, async (req, res) => {
521
+ res.json(await getWorkspaces());
522
+ });
523
+
524
+ app.post('/api/workspaces', authMiddleware, async (req, res) => {
525
+ try {
526
+ const { name, configDir } = req.body;
527
+ if (!name?.trim() || !configDir?.trim()) return res.status(400).json({ error: 'name and configDir required' });
528
+ const dir = configDir.trim().replace(/^~/, os.homedir());
529
+ const store = await loadWorkspacesStore();
530
+ if (store.workspaces.some(w => w.configDir === dir))
531
+ return res.status(409).json({ error: 'Workspace with this path already exists' });
532
+ const ws = { id: crypto.randomUUID(), name: name.trim(), configDir: dir };
533
+ store.workspaces.push(ws);
534
+ await saveWorkspacesStore();
535
+ await syncConfigDirs();
536
+ res.json(ws);
537
+ } catch (e) { res.status(500).json({ error: e.message }); }
538
+ });
539
+
540
+ app.delete('/api/workspaces/:id', authMiddleware, async (req, res) => {
541
+ try {
542
+ const store = await loadWorkspacesStore();
543
+ const idx = store.workspaces.findIndex(w => w.id === req.params.id);
544
+ if (idx === -1) return res.status(404).json({ error: 'Not found' });
545
+ if (store.workspaces.length === 1) return res.status(400).json({ error: 'Cannot remove the last workspace' });
546
+ store.workspaces.splice(idx, 1);
547
+ await saveWorkspacesStore();
548
+ await syncConfigDirs();
549
+ res.json({ ok: true });
550
+ } catch (e) { res.status(500).json({ error: e.message }); }
551
+ });
552
+
553
+ app.patch('/api/workspaces/:id', authMiddleware, async (req, res) => {
554
+ try {
555
+ const store = await loadWorkspacesStore();
556
+ const ws = store.workspaces.find(w => w.id === req.params.id);
557
+ if (!ws) return res.status(404).json({ error: 'Not found' });
558
+ if (req.body.name) ws.name = req.body.name.trim();
559
+ await saveWorkspacesStore();
560
+ res.json(ws);
561
+ } catch (e) { res.status(500).json({ error: e.message }); }
562
+ });
563
+
564
+ app.get('/api/agents', authMiddleware, async (_req, res) => {
565
+ try {
566
+ const defaults = await discoverAllAgentModels({
567
+ claudeConfigDirs: CLAUDE_CONFIG_DIRS,
568
+ codexHome: CODEX_HOME,
569
+ grokHome: GROK_HOME,
570
+ agents: ENABLED_AGENTS,
571
+ });
572
+ res.json({
573
+ enabled: ENABLED_AGENTS,
574
+ defaults,
575
+ homes: {
576
+ claude: CLAUDE_CONFIG_DIRS,
577
+ codex: CODEX_HOME,
578
+ grok: GROK_HOME,
579
+ },
580
+ paths: {
581
+ claude: CLAUDE_CLI_PATH,
582
+ codex: CODEX_CLI_PATH,
583
+ grok: GROK_CLI_PATH,
584
+ },
585
+ });
586
+ } catch (e) {
587
+ res.status(500).json({ error: e.message });
588
+ }
589
+ });
590
+
591
+ app.get('/api/projects/notes', authMiddleware, async (req, res) => {
592
+ try {
593
+ const root = req.query.root;
594
+ if (!root) return res.status(400).json({ error: 'root parameter required' });
595
+ const resolved = path.resolve(expandPath(root));
596
+ const db = await loadProjectNotes();
597
+ res.json(normalizeProjectEntry(db[resolved]));
598
+ } catch (e) { res.status(500).json({ error: e.message }); }
599
+ });
600
+
601
+ app.post('/api/projects/notes', authMiddleware, async (req, res) => {
602
+ try {
603
+ const { root, goal, notes } = req.body || {};
604
+ if (!root) return res.status(400).json({ error: 'root required' });
605
+ const resolved = path.resolve(expandPath(root));
606
+ const db = await loadProjectNotes();
607
+ const prev = normalizeProjectEntry(db[resolved]);
608
+ // Allow partial updates: omit field → keep previous
609
+ const next = {
610
+ goal: goal !== undefined ? String(goal || '') : prev.goal,
611
+ notes: notes !== undefined ? String(notes || '') : prev.notes,
612
+ updatedAt: Date.now(),
613
+ };
614
+ db[resolved] = next;
615
+ await saveProjectNotes();
616
+ res.json({ success: true, ...next });
617
+ } catch (e) { res.status(500).json({ error: e.message }); }
618
+ });
619
+
620
+ app.delete('/api/projects/notes', authMiddleware, async (req, res) => {
621
+ try {
622
+ const root = req.query.root;
623
+ if (!root) return res.status(400).json({ error: 'root parameter required' });
624
+ const resolved = path.resolve(expandPath(root));
625
+ const db = await loadProjectNotes();
626
+ if (db[resolved]) {
627
+ delete db[resolved];
628
+ await saveProjectNotes();
629
+ }
630
+ res.json({ success: true });
631
+ } catch (e) { res.status(500).json({ error: e.message }); }
632
+ });
633
+
634
+ // ── Session meta: favorites + notes + title (rename map) ─────────────────
635
+ app.get('/api/sessions/meta', authMiddleware, async (_req, res) => {
636
+ try {
637
+ const db = await loadSessionMeta();
638
+ res.json(db);
639
+ } catch (e) { res.status(500).json({ error: e.message }); }
640
+ });
641
+
642
+ app.put('/api/sessions/meta', authMiddleware, async (req, res) => {
643
+ try {
644
+ const { sessionId, agent, favorite, notes, title } = req.body || {};
645
+ if (!sessionId) return res.status(400).json({ error: 'sessionId required' });
646
+ const key = sessionMetaKey(agent || 'claude', sessionId);
647
+ const db = await loadSessionMeta();
648
+ const prev = db[key] || { favorite: false, notes: '', title: '', updatedAt: 0 };
649
+ const next = {
650
+ favorite: favorite !== undefined ? Boolean(favorite) : Boolean(prev.favorite),
651
+ notes: notes !== undefined ? String(notes || '') : String(prev.notes || ''),
652
+ // Custom display name mapping; empty string clears rename
653
+ title: title !== undefined
654
+ ? String(title || '').trim().slice(0, 200)
655
+ : String(prev.title || '').trim(),
656
+ updatedAt: Date.now(),
657
+ };
658
+ // Drop empty entries to keep file small
659
+ if (!next.favorite && !next.notes && !next.title) {
660
+ delete db[key];
661
+ } else {
662
+ db[key] = next;
663
+ }
664
+ await saveSessionMeta();
665
+ res.json({
666
+ success: true,
667
+ key,
668
+ ...(db[key] || { favorite: false, notes: '', title: '', updatedAt: Date.now() }),
669
+ });
670
+ } catch (e) { res.status(500).json({ error: e.message }); }
671
+ });
672
+
673
+ // Session / context usage (Grok signals + turn totals; Claude last usage; Codex meta)
674
+ // GET /api/usage?sessionId=...&agent=grok
675
+ app.get('/api/usage', authMiddleware, async (req, res) => {
676
+ try {
677
+ const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId : null;
678
+ const preferred = typeof req.query.agent === 'string' ? req.query.agent : null;
679
+ if (!sessionId || !/^[0-9a-f-]{36}$/i.test(sessionId)) {
680
+ return res.status(400).json({ error: 'sessionId (uuid) required' });
681
+ }
682
+ const opts = {
683
+ claudeConfigDirs: CLAUDE_CONFIG_DIRS,
684
+ codexHome: CODEX_HOME,
685
+ grokHome: GROK_HOME,
686
+ };
687
+ const order = [...new Set([preferred, ...ENABLED_AGENTS, 'claude', 'codex', 'grok'].filter(Boolean))];
688
+ let data = null;
689
+ for (const a of order) {
690
+ data = await loadSessionUsage(sessionId, a, opts);
691
+ if (data) break;
692
+ }
693
+ if (!data) return res.status(404).json({ error: 'Session not found' });
694
+ res.json(data);
695
+ } catch (e) {
696
+ res.status(500).json({ error: e.message });
697
+ }
698
+ });
699
+
700
+ app.get('/api/sessions', authMiddleware, async (req, res) => {
701
+ try {
702
+ const filterCwd = typeof req.query.cwd === 'string' ? req.query.cwd : null;
703
+ const agent = typeof req.query.agent === 'string' ? req.query.agent : null;
704
+ const sessions = await listSessions(filterCwd || null, agent);
705
+ res.json(sessions.map(toClientSession));
706
+ } catch (e) {
707
+ res.status(500).json({ error: e.message });
708
+ }
709
+ });
710
+
711
+ // Project-centric listing: groups sessions by cwd with latest activity
712
+ app.get('/api/projects', authMiddleware, async (req, res) => {
713
+ try {
714
+ const agent = typeof req.query.agent === 'string' ? req.query.agent : null;
715
+ const days = parseInt(req.query.days || '0', 10) || 0;
716
+ let sessions = await listSessions(null, agent);
717
+ if (days > 0) {
718
+ const since = Date.now() - days * 86400000;
719
+ sessions = sessions.filter(s => (s.updatedAt || 0) >= since);
720
+ }
721
+ res.json(groupByProject(sessions.map(toClientSession)));
722
+ } catch (e) {
723
+ res.status(500).json({ error: e.message });
724
+ }
725
+ });
726
+
727
+ // Recent activity + keyword search (metadata + light content peek)
728
+ // GET /api/activity?q=fix&days=7&agent=grok&limit=50&deep=1
729
+ app.get('/api/activity', authMiddleware, async (req, res) => {
730
+ try {
731
+ const q = typeof req.query.q === 'string' ? req.query.q : '';
732
+ const agent = typeof req.query.agent === 'string' ? req.query.agent : null;
733
+ const days = parseInt(req.query.days || '14', 10) || 0;
734
+ const limit = Math.min(parseInt(req.query.limit || '60', 10) || 60, 200);
735
+ const deep = req.query.deep !== '0' && req.query.deep !== 'false';
736
+ const sessions = await listSessions(null, agent);
737
+ const results = await searchActivity(sessions, { q, days, agent, limit, deep });
738
+ res.json({
739
+ q,
740
+ days,
741
+ agent,
742
+ count: results.length,
743
+ results,
744
+ });
745
+ } catch (e) {
746
+ res.status(500).json({ error: e.message });
747
+ }
748
+ });
749
+
750
+ app.get('/api/sessions/:sessionId/messages', authMiddleware, async (req, res) => {
751
+ try {
752
+ const { sessionId } = req.params;
753
+ if (!/^[0-9a-f-]{36}$/i.test(sessionId)) return res.status(400).json({ error: 'Invalid session id' });
754
+ const preferred = typeof req.query.agent === 'string' ? req.query.agent : null;
755
+ // tail=0 → full history; default last 120 messages for fast first paint
756
+ let tail = parseInt(req.query.tail ?? '120', 10);
757
+ if (Number.isNaN(tail)) tail = 120;
758
+ if (tail < 0) tail = 0;
759
+ if (tail > 2000) tail = 2000;
760
+ const opts = {
761
+ claudeConfigDirs: CLAUDE_CONFIG_DIRS,
762
+ codexHome: CODEX_HOME,
763
+ grokHome: GROK_HOME,
764
+ };
765
+ // Prefer stated agent; only fall back if not found (avoids scanning all agents every time)
766
+ const order = preferred
767
+ ? [preferred, ...ENABLED_AGENTS.filter(a => a !== preferred)]
768
+ : [...ENABLED_AGENTS];
769
+ let bundle = null;
770
+ for (const a of order) {
771
+ bundle = await loadSessionMessages(sessionId, a, opts);
772
+ if (bundle) break;
773
+ }
774
+ if (!bundle) return res.status(404).json({ error: 'Session not found' });
775
+ // Normalize: always { messages, context, agent }
776
+ let messages = Array.isArray(bundle)
777
+ ? bundle
778
+ : (bundle.messages || []);
779
+ const total = messages.length;
780
+ let truncated = false;
781
+ if (tail > 0 && messages.length > tail) {
782
+ messages = messages.slice(-tail);
783
+ truncated = true;
784
+ }
785
+ const payload = {
786
+ messages,
787
+ context: Array.isArray(bundle) ? null : (bundle.context || null),
788
+ agent: Array.isArray(bundle) ? (preferred || 'claude') : (bundle.agent || preferred || 'claude'),
789
+ total,
790
+ truncated,
791
+ tail: truncated ? tail : total,
792
+ };
793
+ res.setHeader('X-Session-Agent', payload.agent);
794
+ res.setHeader('Cache-Control', 'private, max-age=30');
795
+ res.json(payload);
796
+ } catch (e) {
797
+ res.status(500).json({ error: e.message });
798
+ }
799
+ });
800
+
801
+ app.post('/api/sessions/:sessionId/convert', authMiddleware, async (req, res) => {
802
+ try {
803
+ const { sessionId } = req.params;
804
+ const { targetAgent, sourceAgent } = req.body;
805
+ if (!/^[0-9a-f-]{36}$/i.test(sessionId)) return res.status(400).json({ error: 'Invalid session id' });
806
+ if (!targetAgent) return res.status(400).json({ error: 'targetAgent required' });
807
+
808
+ const opts = {
809
+ claudeConfigDirs: CLAUDE_CONFIG_DIRS,
810
+ codexHome: CODEX_HOME,
811
+ grokHome: GROK_HOME,
812
+ };
813
+
814
+ let detectedSourceAgent = sourceAgent;
815
+ if (!detectedSourceAgent) {
816
+ const order = [...new Set([...ENABLED_AGENTS, 'claude', 'codex', 'grok'])];
817
+ for (const a of order) {
818
+ const bundle = await loadSessionMessages(sessionId, a, opts);
819
+ if (bundle) {
820
+ detectedSourceAgent = a;
821
+ break;
822
+ }
823
+ }
824
+ }
825
+
826
+ if (!detectedSourceAgent) {
827
+ return res.status(404).json({ error: 'Source session not found' });
828
+ }
829
+
830
+ const { targetSessionId } = await convertSession(sessionId, detectedSourceAgent, targetAgent, opts);
831
+ res.json({ success: true, targetSessionId, targetAgent });
832
+ } catch (e) {
833
+ res.status(500).json({ error: e.message });
834
+ }
835
+ });
836
+
837
+ app.get('/api/sessions/:sessionId/memory', authMiddleware, async (req, res) => {
838
+ try {
839
+ const { sessionId } = req.params;
840
+ if (!/^[0-9a-f-]{36}$/i.test(sessionId)) return res.status(400).json({ error: 'Invalid session id' });
841
+ const preferred = typeof req.query.agent === 'string' ? req.query.agent : null;
842
+ const opts = {
843
+ claudeConfigDirs: CLAUDE_CONFIG_DIRS,
844
+ codexHome: CODEX_HOME,
845
+ grokHome: GROK_HOME,
846
+ };
847
+ const order = [...new Set([preferred, ...ENABLED_AGENTS, 'claude', 'codex', 'grok'].filter(Boolean))];
848
+ let memory = null;
849
+ for (const a of order) {
850
+ memory = await loadSessionMemory(sessionId, a, opts);
851
+ if (memory) break;
852
+ }
853
+ // Memory is Claude-centric; return empty instead of 404 so the tab doesn't hard-fail
854
+ res.json(memory || { index: null, files: [] });
855
+ } catch (e) {
856
+ res.status(500).json({ error: e.message });
857
+ }
858
+ });
859
+
860
+ app.get('/api/projects/:id/git/log', authMiddleware, async (req, res) => {
861
+ try {
862
+ const projectPath = resolveProjectRoot(req);
863
+ if (!(await isGitRepo(projectPath))) return res.json({ graph: '', commits: [] });
864
+ const graph = await git(
865
+ ['log', '--graph', '--oneline', '--all', '--decorate', '-30'],
866
+ projectPath
867
+ ).catch(() => '');
868
+ const commits = (await git(['log', '--oneline', '-20'], projectPath).catch(() => ''))
869
+ .trim().split('\n').filter(Boolean)
870
+ .map(line => ({ hash: line.slice(0, 7), message: line.slice(8) }));
871
+ res.json({ graph, commits });
872
+ } catch (e) {
873
+ res.status(500).json({ error: e.message });
874
+ }
875
+ });
876
+
877
+ // ─── Meta Agent (built-in AI: reports, Q&A, VLM, code run) ───────────────────
878
+ const meta = createMetaAgent({
879
+ rootDir,
880
+ listSessions,
881
+ searchActivity,
882
+ loadSessionMessages,
883
+ groupByProject,
884
+ toClientSession,
885
+ getProjectNotesStore: loadProjectNotes,
886
+ saveProjectNotesStore: saveProjectNotes,
887
+ normalizeProjectEntry,
888
+ git,
889
+ isGitRepo,
890
+ claudeConfigDirs: () => CLAUDE_CONFIG_DIRS,
891
+ codexHome: () => CODEX_HOME,
892
+ grokHome: () => GROK_HOME,
893
+ });
894
+
895
+ /** Write SSE event */
896
+ function sseWrite(res, event, data) {
897
+ if (res.writableEnded) return;
898
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
899
+ }
900
+
901
+ app.get('/api/ai/config', authMiddleware, async (_req, res) => {
902
+ try {
903
+ const c = await meta.loadConfig();
904
+ res.json(meta.publicConfig(c));
905
+ } catch (e) {
906
+ res.status(500).json({ error: e.message });
907
+ }
908
+ });
909
+
910
+ app.put('/api/ai/config', authMiddleware, async (req, res) => {
911
+ try {
912
+ const body = req.body || {};
913
+ const patch = {};
914
+ for (const k of [
915
+ 'url', 'model', 'key', 'vlm_url', 'vlm_key', 'vlm_model',
916
+ 'temperature', 'system', 'auto_report', 'auto_report_hour', 'auto_report_days',
917
+ ]) {
918
+ if (body[k] !== undefined) patch[k] = body[k];
919
+ }
920
+ // empty key string = keep existing
921
+ if (patch.key === '' || patch.key === '***') delete patch.key;
922
+ if (patch.vlm_key === '' || patch.vlm_key === '***') delete patch.vlm_key;
923
+ const pub = await meta.saveConfig(patch);
924
+ res.json(pub);
925
+ } catch (e) {
926
+ res.status(500).json({ error: e.message });
927
+ }
928
+ });
929
+
930
+ app.get('/api/ai/reports', authMiddleware, async (req, res) => {
931
+ try {
932
+ const limit = Math.min(parseInt(req.query.limit || '30', 10) || 30, 100);
933
+ res.json({ reports: await meta.listReports(limit) });
934
+ } catch (e) {
935
+ res.status(500).json({ error: e.message });
936
+ }
937
+ });
938
+
939
+ app.get('/api/ai/reports/:day', authMiddleware, async (req, res) => {
940
+ try {
941
+ const r = await meta.getReport(req.params.day);
942
+ if (!r) return res.status(404).json({ error: 'not found' });
943
+ res.json(r);
944
+ } catch (e) {
945
+ res.status(500).json({ error: e.message });
946
+ }
947
+ });
948
+
949
+ app.get('/api/ai/digest', authMiddleware, async (req, res) => {
950
+ try {
951
+ const days = Math.min(parseInt(req.query.days || '1', 10) || 1, 30);
952
+ const agent = typeof req.query.agent === 'string' ? req.query.agent : null;
953
+ const includeMessages = req.query.messages !== '0';
954
+ const digest = await meta.buildDigest({ days, agent, includeMessages });
955
+ res.json(digest);
956
+ } catch (e) {
957
+ res.status(500).json({ error: e.message });
958
+ }
959
+ });
960
+
961
+ // SSE: generate work report
962
+ app.post('/api/ai/report', authMiddleware, async (req, res) => {
963
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
964
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
965
+ res.setHeader('Connection', 'keep-alive');
966
+ res.flushHeaders?.();
967
+ const days = Math.min(parseInt(req.body?.days || '1', 10) || 1, 14);
968
+ try {
969
+ const result = await meta.generateReport({
970
+ days,
971
+ onContent: (content) => sseWrite(res, 'content', { content }),
972
+ onTool: (name, args, phase, result, id) => {
973
+ sseWrite(res, 'tool', { name, args, phase, result: phase === 'start' ? undefined : result, id });
974
+ },
975
+ });
976
+ sseWrite(res, 'done', {
977
+ content: result.content,
978
+ digest: result.digest?.totals,
979
+ });
980
+ } catch (e) {
981
+ sseWrite(res, 'error', { message: String(e.message || e) });
982
+ } finally {
983
+ res.end();
984
+ }
985
+ });
986
+
987
+ app.get('/api/ai/sessions', authMiddleware, async (_req, res) => {
988
+ try {
989
+ res.json({ sessions: await meta.listChatSessions() });
990
+ } catch (e) {
991
+ res.status(500).json({ error: e.message });
992
+ }
993
+ });
994
+
995
+ app.get('/api/ai/sessions/:id', authMiddleware, async (req, res) => {
996
+ try {
997
+ const s = await meta.getChatSession(req.params.id);
998
+ if (!s) return res.status(404).json({ error: 'not found' });
999
+ res.json(s);
1000
+ } catch (e) {
1001
+ res.status(500).json({ error: e.message });
1002
+ }
1003
+ });
1004
+
1005
+ app.delete('/api/ai/sessions/:id', authMiddleware, async (req, res) => {
1006
+ try {
1007
+ await meta.deleteChatSession(req.params.id);
1008
+ res.json({ ok: true });
1009
+ } catch (e) {
1010
+ res.status(500).json({ error: e.message });
1011
+ }
1012
+ });
1013
+
1014
+ // SSE: multi-turn agent chat (text only; images arrive as [imgN](path) refs → analyze_images)
1015
+ app.post('/api/ai/chat', authMiddleware, async (req, res) => {
1016
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
1017
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
1018
+ res.setHeader('Connection', 'keep-alive');
1019
+ res.flushHeaders?.();
1020
+
1021
+ try {
1022
+ const {
1023
+ message,
1024
+ sessionId,
1025
+ newSession = false,
1026
+ } = req.body || {};
1027
+
1028
+ const text = String(message || '').trim();
1029
+ if (!text) {
1030
+ sseWrite(res, 'error', { message: 'message required(图片请先粘贴为 [imgN](路径) 再发送)' });
1031
+ return res.end();
1032
+ }
1033
+
1034
+ const cfg = await meta.loadConfig();
1035
+
1036
+ let session = null;
1037
+ if (sessionId && !newSession) {
1038
+ session = await meta.getChatSession(sessionId);
1039
+ }
1040
+ if (!session) {
1041
+ session = {
1042
+ id: crypto.randomUUID(),
1043
+ title: text.replace(/\s+/g, ' ').trim().slice(0, 40) || '对话',
1044
+ messages: [],
1045
+ createdAt: Date.now(),
1046
+ };
1047
+ }
1048
+
1049
+ session.messages.push({ role: 'user', content: text });
1050
+ // Track VLM threads used in this chat for resume/debug
1051
+ if (!Array.isArray(session.vlmThreadIds)) session.vlmThreadIds = [];
1052
+ const msgs = [
1053
+ { role: 'system', content: cfg.system || meta.DEFAULT_SYSTEM },
1054
+ ...session.messages.filter((m) => m.role !== 'system'),
1055
+ ];
1056
+
1057
+ sseWrite(res, 'session', { id: session.id, title: session.title });
1058
+
1059
+ const result = await meta.runLoop(msgs, {
1060
+ chatSessionId: session.id,
1061
+ onContent: (content) => sseWrite(res, 'content', { content }),
1062
+ onTool: (name, args, phase, toolResult, id) => {
1063
+ if (name === 'analyze_images' && phase === 'done' && toolResult?.thread_id) {
1064
+ if (!session.vlmThreadIds.includes(toolResult.thread_id)) {
1065
+ session.vlmThreadIds.push(toolResult.thread_id);
1066
+ }
1067
+ }
1068
+ sseWrite(res, 'tool', {
1069
+ name,
1070
+ args,
1071
+ phase,
1072
+ id,
1073
+ result: phase === 'start' ? undefined : toolResult,
1074
+ });
1075
+ },
1076
+ cfg,
1077
+ });
1078
+
1079
+ session.messages = result.messages.filter((m) => m.role !== 'system');
1080
+ // keep first-user-message title stable unless still default
1081
+ if (!session.title || session.title === '对话') {
1082
+ session.title = text.replace(/\s+/g, ' ').trim().slice(0, 40) || session.title;
1083
+ }
1084
+ await meta.saveChatSession(session);
1085
+
1086
+ sseWrite(res, 'done', {
1087
+ content: result.content,
1088
+ sessionId: session.id,
1089
+ title: session.title,
1090
+ });
1091
+ } catch (e) {
1092
+ sseWrite(res, 'error', { message: String(e.message || e) });
1093
+ } finally {
1094
+ res.end();
1095
+ }
1096
+ });
1097
+
1098
+ // ─── HTTP + WebSocket server ─────────────────────────────────────────────────
1099
+ const server = http.createServer(app);
1100
+ const wssChat = new WebSocketServer({ noServer: true });
1101
+ const wssShell = new WebSocketServer({ noServer: true });
1102
+
1103
+ server.on('upgrade', (req, socket, head) => {
1104
+ const pathname = new URL(req.url, 'http://localhost').pathname;
1105
+ if (pathname === '/ws/chat') {
1106
+ wssChat.handleUpgrade(req, socket, head, ws => wssChat.emit('connection', ws, req));
1107
+ } else if (pathname === '/ws/shell') {
1108
+ wssShell.handleUpgrade(req, socket, head, ws => wssShell.emit('connection', ws, req));
1109
+ } else {
1110
+ socket.destroy();
1111
+ }
1112
+ });
1113
+
1114
+ wssChat.on('connection', (ws, req) => {
1115
+ const url = new URL(req.url, `http://localhost`);
1116
+ const token = url.searchParams.get('token');
1117
+ const hubSecret = process.env.MACHINE_TOKEN;
1118
+ let decoded = null;
1119
+ if (hubSecret && token === hubSecret) {
1120
+ decoded = { userId: 0, username: 'hub', viaHub: true };
1121
+ } else {
1122
+ decoded = verifyToken(token);
1123
+ }
1124
+ if (!decoded) {
1125
+ ws.send(JSON.stringify({ type: 'error', message: 'Unauthorized' }));
1126
+ ws.close(1008, 'Unauthorized');
1127
+ return;
1128
+ }
1129
+
1130
+ const send = (data) => {
1131
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(data));
1132
+ };
1133
+
1134
+ let shellProc = null;
1135
+
1136
+ ws.on('message', async (raw) => {
1137
+ let msg;
1138
+ try { msg = JSON.parse(raw.toString()); }
1139
+ catch { return; }
1140
+
1141
+ if (msg.type === 'claude-command' || msg.type === 'agent-command') {
1142
+ const agent = msg.agent || msg.options?.agent || 'claude';
1143
+ await dispatchAgent(agent, msg.command || '', msg.options || {}, send);
1144
+ } else if (msg.type === 'shell-command') {
1145
+ if (shellProc) { try { shellProc.kill(); } catch {} shellProc = null; }
1146
+ const cmd = msg.command || '';
1147
+ const cwd = msg.cwd || os.homedir();
1148
+ send({ type: 'shell-start', command: cmd });
1149
+ shellProc = spawn(process.env.SHELL || 'bash', ['-c', cmd], {
1150
+ cwd,
1151
+ env: { ...process.env },
1152
+ shell: false,
1153
+ });
1154
+ shellProc.stdout.on('data', d => send({ type: 'shell-output', stream: 'stdout', data: d.toString() }));
1155
+ shellProc.stderr.on('data', d => send({ type: 'shell-output', stream: 'stderr', data: d.toString() }));
1156
+ shellProc.on('close', (code) => { send({ type: 'shell-exit', code }); shellProc = null; });
1157
+ shellProc.on('error', (e) => { send({ type: 'shell-exit', code: 1, error: e.message }); shellProc = null; });
1158
+ } else if (msg.type === 'abort-session') {
1159
+ const ok = abortSession(msg.sessionId);
1160
+ send({ type: 'abort-result', success: ok, sessionId: msg.sessionId });
1161
+ }
1162
+ });
1163
+
1164
+ ws.on('close', () => { if (shellProc) { try { shellProc.kill(); } catch {} } });
1165
+ ws.on('error', (e) => console.error('[ws]', e.message));
1166
+ });
1167
+
1168
+ wssShell.on('connection', (ws, req) => {
1169
+ const url = new URL(req.url, 'http://localhost');
1170
+ const token = url.searchParams.get('token');
1171
+ const hubSecret = process.env.MACHINE_TOKEN;
1172
+ let decoded = null;
1173
+ if (hubSecret && token === hubSecret) {
1174
+ decoded = { userId: 0, username: 'hub', viaHub: true };
1175
+ } else {
1176
+ decoded = verifyToken(token);
1177
+ }
1178
+ if (!decoded) { ws.close(1008, 'Unauthorized'); return; }
1179
+
1180
+ const cwd = url.searchParams.get('cwd') || os.homedir();
1181
+ const send = d => { if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(d)); };
1182
+
1183
+ let proc;
1184
+ let hasPython = false;
1185
+ try {
1186
+ execSync('python3 --version', { stdio: 'ignore' });
1187
+ hasPython = true;
1188
+ } catch {}
1189
+
1190
+ const cols = parseInt(url.searchParams.get('cols') || '80', 10);
1191
+ const rows = parseInt(url.searchParams.get('rows') || '24', 10);
1192
+
1193
+ try {
1194
+ const shellBin = process.env.SHELL || 'zsh';
1195
+ if (hasPython) {
1196
+ const pythonScript = `
1197
+ import os, pty, sys, termios, fcntl, struct, select
1198
+ shell = '${shellBin}'
1199
+ cols, rows = ${cols}, ${rows}
1200
+ pid, fd = pty.fork()
1201
+ if pid == 0:
1202
+ os.execvp(shell, [shell])
1203
+ else:
1204
+ try:
1205
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
1206
+ except:
1207
+ pass
1208
+ while True:
1209
+ try:
1210
+ r, w, x = select.select([0, fd], [], [])
1211
+ if 0 in r:
1212
+ data = os.read(0, 1024)
1213
+ if not data: break
1214
+ if data.startswith(b'\\x00resize:'):
1215
+ try:
1216
+ _, c, r = data.split(b':')
1217
+ cols, rows = int(c), int(r)
1218
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
1219
+ except:
1220
+ pass
1221
+ else:
1222
+ os.write(fd, data)
1223
+ if fd in r:
1224
+ data = os.read(fd, 1024)
1225
+ if not data: break
1226
+ os.write(1, data)
1227
+ except:
1228
+ break
1229
+ `;
1230
+ proc = spawn('python3', ['-c', pythonScript], {
1231
+ cwd: fsSync.existsSync(cwd) ? cwd : os.homedir(),
1232
+ env: { ...process.env, TERM: 'xterm-256color' },
1233
+ stdio: ['pipe', 'pipe', 'pipe'],
1234
+ });
1235
+ } else {
1236
+ const args = ['-i'];
1237
+ if (shellBin.includes('bash')) {
1238
+ args.push('--norc', '--noprofile');
1239
+ }
1240
+ proc = spawn(shellBin, args, {
1241
+ cwd: fsSync.existsSync(cwd) ? cwd : os.homedir(),
1242
+ env: { ...process.env, TERM: 'xterm-256color', PS1: '\\[\\033[32m\\]\\w\\[\\033[0m\\]$ ', HISTFILE: '' },
1243
+ stdio: ['pipe', 'pipe', 'pipe'],
1244
+ });
1245
+ }
1246
+ } catch (e) {
1247
+ send({ type: 'error', data: e.message });
1248
+ ws.close();
1249
+ return;
1250
+ }
1251
+
1252
+ proc.stdout.on('data', d => send({ type: 'output', data: d.toString() }));
1253
+ proc.stderr.on('data', d => send({ type: 'output', data: d.toString() }));
1254
+ proc.on('exit', (code) => { send({ type: 'exit', code }); ws.close(); });
1255
+ proc.on('error', (e) => { send({ type: 'error', data: e.message }); });
1256
+
1257
+ ws.on('message', raw => {
1258
+ let msg; try { msg = JSON.parse(raw.toString()); } catch { return; }
1259
+ if (msg.type === 'input' && proc.stdin.writable) {
1260
+ proc.stdin.write(msg.data);
1261
+ } else if (msg.type === 'resize' && proc.stdin.writable && hasPython) {
1262
+ proc.stdin.write(`\x00resize:${msg.cols}:${msg.rows}`);
1263
+ }
1264
+ });
1265
+
1266
+ ws.on('close', () => { try { proc.kill('SIGHUP'); } catch {} });
1267
+ ws.on('error', e => console.error('[ws/shell]', e.message));
1268
+ });
1269
+
1270
+ return { app, server };
1271
+ }
1272
+
1273
+ // ─── Init (call before listen) ────────────────────────────────────────────────
1274
+ export async function init() {
1275
+ await loadCreds();
1276
+ await bootstrapEnvUser();
1277
+ await syncConfigDirs();
1278
+ console.log(`[agents] enabled: ${ENABLED_AGENTS.join(', ')}`);
1279
+ console.log(`[agents] claude=${CLAUDE_CLI_PATH} codex=${CODEX_CLI_PATH} grok=${GROK_CLI_PATH}`);
1280
+ }
1281
+
1282
+ // (session helpers live in ./agents/sessions.js)