claude-simple 0.1.0 → 0.2.1
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/client.js +31 -2
- package/dist/app.0.2.1.min.js +868 -0
- package/dist/highlight.min.js +448 -430
- package/dist/index.html +2 -2
- package/dist/style.0.2.1.min.css +1182 -0
- package/package.json +1 -1
- package/public/agent-panel.js +44 -0
- package/public/api.js +61 -10
- package/public/app.js +4 -0
- package/public/chat.js +338 -31
- package/public/files.js +22 -30
- package/public/git.js +21 -17
- package/public/icons/icon-180.png +0 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/icon-maskable-512.png +0 -0
- package/public/index.html +16 -3
- package/public/lib.js +7 -2
- package/public/manifest.webmanifest +36 -0
- package/public/offline.html +33 -0
- package/public/pwa.js +78 -0
- package/public/shell/boot.js +3 -0
- package/public/shell/hub.js +4 -2
- package/public/shell/notes.js +93 -31
- package/public/shell/session-list.js +29 -10
- package/public/shell/session-nav.js +7 -2
- package/public/shell/templates.js +11 -3
- package/public/shell.js +101 -8
- package/public/state.js +13 -2
- package/public/style.css +135 -3
- package/public/sw.js +104 -0
- package/public/terminal.js +22 -0
- package/public/wake-lock.js +45 -0
- package/server.js +5 -1
- package/src/agents/claude.js +108 -10
- package/src/agents/codex.js +1 -1
- package/src/agents/grok.js +40 -32
- package/src/agents/meta-agent.js +40 -1
- package/src/agents/stream-batch.js +62 -0
- package/src/app.js +312 -41
- package/src/auto-update.js +215 -0
- package/src/runtime/approvals.js +67 -0
- package/src/runtime/outbox.js +143 -0
- package/src/runtime/protocol.js +224 -0
- package/dist/app.0.1.0.min.js +0 -825
- package/dist/style.0.1.0.min.css +0 -1050
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coalesce chatty stream chunks into fewer WebSocket frames.
|
|
3
|
+
* Keeps UI latency low (flush on size or ~maxWait ms) while cutting
|
|
4
|
+
* mobile tunnel traffic: each JSON frame has fixed tunnel/header overhead.
|
|
5
|
+
*/
|
|
6
|
+
export function createDeltaBatcher(send, type, extra = {}, opts = {}) {
|
|
7
|
+
const maxWait = opts.maxWait ?? 48;
|
|
8
|
+
const maxSize = opts.maxSize ?? 480;
|
|
9
|
+
let buf = '';
|
|
10
|
+
let timer = null;
|
|
11
|
+
|
|
12
|
+
function flush() {
|
|
13
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
14
|
+
if (!buf) return;
|
|
15
|
+
const data = buf;
|
|
16
|
+
buf = '';
|
|
17
|
+
send({ type, data, ...extra });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
push(chunk) {
|
|
22
|
+
if (chunk == null || chunk === '') return;
|
|
23
|
+
buf += String(chunk);
|
|
24
|
+
if (buf.length >= maxSize) {
|
|
25
|
+
flush();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (!timer) timer = setTimeout(flush, maxWait);
|
|
29
|
+
},
|
|
30
|
+
flush,
|
|
31
|
+
pending() { return buf.length; },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Batch raw text for shell / terminal style streams. */
|
|
36
|
+
export function createTextBatcher(sendFn, opts = {}) {
|
|
37
|
+
const maxWait = opts.maxWait ?? 32;
|
|
38
|
+
const maxSize = opts.maxSize ?? 2048;
|
|
39
|
+
let buf = '';
|
|
40
|
+
let timer = null;
|
|
41
|
+
|
|
42
|
+
function flush() {
|
|
43
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
44
|
+
if (!buf) return;
|
|
45
|
+
const data = buf;
|
|
46
|
+
buf = '';
|
|
47
|
+
sendFn(data);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
push(chunk) {
|
|
52
|
+
if (chunk == null || chunk === '') return;
|
|
53
|
+
buf += String(chunk);
|
|
54
|
+
if (buf.length >= maxSize) {
|
|
55
|
+
flush();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (!timer) timer = setTimeout(flush, maxWait);
|
|
59
|
+
},
|
|
60
|
+
flush,
|
|
61
|
+
};
|
|
62
|
+
}
|
package/src/app.js
CHANGED
|
@@ -9,6 +9,7 @@ import fsSync from 'fs';
|
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import os from 'os';
|
|
11
11
|
import crypto from 'crypto';
|
|
12
|
+
import zlib from 'zlib';
|
|
12
13
|
import { fileURLToPath } from 'url';
|
|
13
14
|
import { runClaude } from './agents/claude.js';
|
|
14
15
|
import { runCodex } from './agents/codex.js';
|
|
@@ -25,6 +26,10 @@ import {
|
|
|
25
26
|
} from './agents/sessions.js';
|
|
26
27
|
import { createMetaAgent } from './agents/meta-agent.js';
|
|
27
28
|
import { discoverAllAgentModels } from './agents/models.js';
|
|
29
|
+
import { createTextBatcher } from './agents/stream-batch.js';
|
|
30
|
+
import { normalizeCommand, mapAgentEmit } from './runtime/protocol.js';
|
|
31
|
+
import { createOutbox } from './runtime/outbox.js';
|
|
32
|
+
import { resolveApproval, cancelApprovalsForSession } from './runtime/approvals.js';
|
|
28
33
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
34
|
const rootDir = path.join(__dirname, '..');
|
|
30
35
|
|
|
@@ -269,6 +274,27 @@ async function isGitRepo(dir) {
|
|
|
269
274
|
|
|
270
275
|
// ─── Active agent sessions ────────────────────────────────────────────────────
|
|
271
276
|
const activeSessions = new Map();
|
|
277
|
+
/** Live chat sockets so a mobile reconnect can resume an in-flight run. */
|
|
278
|
+
const chatClients = new Set();
|
|
279
|
+
|
|
280
|
+
function sendJson(ws, data) {
|
|
281
|
+
if (ws.readyState === ws.OPEN) {
|
|
282
|
+
try { ws.send(JSON.stringify(data)); } catch { /* ignore */ }
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function broadcastChat(sessionId, data, fallbackWs) {
|
|
287
|
+
let n = 0;
|
|
288
|
+
if (sessionId) {
|
|
289
|
+
for (const c of chatClients) {
|
|
290
|
+
if (c.sessionId === sessionId) {
|
|
291
|
+
sendJson(c.ws, data);
|
|
292
|
+
n++;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (n === 0 && fallbackWs) sendJson(fallbackWs, data);
|
|
297
|
+
}
|
|
272
298
|
|
|
273
299
|
async function listSessions(filterCwd = null, agentFilter = null) {
|
|
274
300
|
const agents = agentFilter
|
|
@@ -283,6 +309,21 @@ async function listSessions(filterCwd = null, agentFilter = null) {
|
|
|
283
309
|
});
|
|
284
310
|
}
|
|
285
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Wrap agent emit → map/filter → outbox (delta coalesce + batch frames).
|
|
314
|
+
* Agents may still call send() with UI-shaped events; unknown noise is dropped.
|
|
315
|
+
*/
|
|
316
|
+
function createAgentSend(outbox, agent) {
|
|
317
|
+
return (data) => {
|
|
318
|
+
const mapped = mapAgentEmit(data, { agent, compactTools: true });
|
|
319
|
+
// permission_request / already-mapped events: mapAgentEmit keeps known types
|
|
320
|
+
if (mapped) outbox.send(mapped);
|
|
321
|
+
else if (data?.type === 'permission_request' || data?.type === 'permission_resolved') {
|
|
322
|
+
outbox.send(data);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
286
327
|
async function dispatchAgent(agent, command, options, send) {
|
|
287
328
|
const a = (agent || 'claude').toLowerCase();
|
|
288
329
|
if (!ENABLED_AGENTS.includes(a)) {
|
|
@@ -300,20 +341,94 @@ async function dispatchAgent(agent, command, options, send) {
|
|
|
300
341
|
}
|
|
301
342
|
|
|
302
343
|
function abortSession(sessionId) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
344
|
+
if (!sessionId) return false;
|
|
345
|
+
let ok = false;
|
|
346
|
+
cancelApprovalsForSession(sessionId);
|
|
347
|
+
// Grok/Codex may register under temp key + real sessionId — abort every match.
|
|
348
|
+
for (const [key, s] of activeSessions) {
|
|
349
|
+
if (key !== sessionId && s?.sessionId !== sessionId && s?.key !== sessionId) continue;
|
|
350
|
+
if (s.controller) s.controller.abort();
|
|
351
|
+
if (typeof s.abort === 'function') s.abort();
|
|
352
|
+
else if (s.child) {
|
|
353
|
+
try { s.child.kill('SIGTERM'); } catch {}
|
|
354
|
+
}
|
|
355
|
+
s.busy = false;
|
|
356
|
+
ok = true;
|
|
357
|
+
}
|
|
358
|
+
return ok;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Whether a session still has an in-flight UI-blocking run. */
|
|
362
|
+
function isSessionBusy(sessionId) {
|
|
363
|
+
if (!sessionId) return false;
|
|
364
|
+
for (const [key, s] of activeSessions) {
|
|
365
|
+
if (key !== sessionId && s?.sessionId !== sessionId) continue;
|
|
366
|
+
if (s && s.busy !== false) return true;
|
|
309
367
|
}
|
|
310
|
-
return
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** True if any agent turn is in flight (used by auto-update before restart). */
|
|
372
|
+
export function hasBusyAgent() {
|
|
373
|
+
for (const s of activeSessions.values()) {
|
|
374
|
+
if (s && s.busy !== false) return true;
|
|
375
|
+
}
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function markSessionIdle(sessionId, tempKey) {
|
|
380
|
+
for (const key of [sessionId, tempKey].filter(Boolean)) {
|
|
381
|
+
const s = activeSessions.get(key);
|
|
382
|
+
if (s) s.busy = false;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Shrink history payloads: huge tool inputs dominate mobile transfers. */
|
|
387
|
+
function compactHistoryMessages(messages) {
|
|
388
|
+
if (!Array.isArray(messages)) return messages;
|
|
389
|
+
return messages.map((m) => {
|
|
390
|
+
if (!m || typeof m !== 'object') return m;
|
|
391
|
+
if (m.type === 'tool_use' && m.input != null) {
|
|
392
|
+
let raw;
|
|
393
|
+
try { raw = typeof m.input === 'string' ? m.input : JSON.stringify(m.input); }
|
|
394
|
+
catch { raw = String(m.input); }
|
|
395
|
+
if (raw.length > 1600) {
|
|
396
|
+
return { ...m, input: { _truncated: true, preview: raw.slice(0, 1600), bytes: raw.length } };
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (m.type === 'text' && typeof m.content === 'string' && m.content.length > 48_000) {
|
|
400
|
+
return { ...m, content: `${m.content.slice(0, 48_000)}\n…` };
|
|
401
|
+
}
|
|
402
|
+
return m;
|
|
403
|
+
});
|
|
311
404
|
}
|
|
312
405
|
|
|
313
406
|
// ─── App factory ──────────────────────────────────────────────────────────────
|
|
314
407
|
export function createApp() {
|
|
315
408
|
const app = express();
|
|
316
409
|
|
|
410
|
+
// Gzip JSON/text API responses when client accepts it (hub tunnel + mobile).
|
|
411
|
+
app.use((req, res, next) => {
|
|
412
|
+
const ae = req.headers['accept-encoding'] || '';
|
|
413
|
+
if (!/\bgzip\b/i.test(ae) || req.method === 'HEAD') return next();
|
|
414
|
+
const origJson = res.json.bind(res);
|
|
415
|
+
res.json = (body) => {
|
|
416
|
+
let buf;
|
|
417
|
+
try { buf = Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)); }
|
|
418
|
+
catch { return origJson(body); }
|
|
419
|
+
if (buf.length < 900) return origJson(body);
|
|
420
|
+
zlib.gzip(buf, { level: 6 }, (err, zipped) => {
|
|
421
|
+
if (err || !zipped || zipped.length >= buf.length * 0.95) return origJson(body);
|
|
422
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
423
|
+
res.setHeader('Content-Encoding', 'gzip');
|
|
424
|
+
res.setHeader('Vary', 'Accept-Encoding');
|
|
425
|
+
res.setHeader('Content-Length', zipped.length);
|
|
426
|
+
res.status(res.statusCode || 200).end(zipped);
|
|
427
|
+
});
|
|
428
|
+
};
|
|
429
|
+
next();
|
|
430
|
+
});
|
|
431
|
+
|
|
317
432
|
app.post('/api/upload-image', authMiddleware, (req, res) => {
|
|
318
433
|
(async () => {
|
|
319
434
|
const ext = (req.headers['x-filename'] || 'image.png').split('.').pop().replace(/[^a-z0-9]/gi, '') || 'png';
|
|
@@ -641,11 +756,11 @@ export function createApp() {
|
|
|
641
756
|
|
|
642
757
|
app.put('/api/sessions/meta', authMiddleware, async (req, res) => {
|
|
643
758
|
try {
|
|
644
|
-
const { sessionId, agent, favorite, notes, title } = req.body || {};
|
|
759
|
+
const { sessionId, agent, favorite, notes, title, hidden } = req.body || {};
|
|
645
760
|
if (!sessionId) return res.status(400).json({ error: 'sessionId required' });
|
|
646
761
|
const key = sessionMetaKey(agent || 'claude', sessionId);
|
|
647
762
|
const db = await loadSessionMeta();
|
|
648
|
-
const prev = db[key] || { favorite: false, notes: '', title: '', updatedAt: 0 };
|
|
763
|
+
const prev = db[key] || { favorite: false, notes: '', title: '', hidden: false, updatedAt: 0 };
|
|
649
764
|
const next = {
|
|
650
765
|
favorite: favorite !== undefined ? Boolean(favorite) : Boolean(prev.favorite),
|
|
651
766
|
notes: notes !== undefined ? String(notes || '') : String(prev.notes || ''),
|
|
@@ -653,10 +768,11 @@ export function createApp() {
|
|
|
653
768
|
title: title !== undefined
|
|
654
769
|
? String(title || '').trim().slice(0, 200)
|
|
655
770
|
: String(prev.title || '').trim(),
|
|
771
|
+
hidden: hidden !== undefined ? Boolean(hidden) : Boolean(prev.hidden),
|
|
656
772
|
updatedAt: Date.now(),
|
|
657
773
|
};
|
|
658
774
|
// Drop empty entries to keep file small
|
|
659
|
-
if (!next.favorite && !next.notes && !next.title) {
|
|
775
|
+
if (!next.favorite && !next.notes && !next.title && !next.hidden) {
|
|
660
776
|
delete db[key];
|
|
661
777
|
} else {
|
|
662
778
|
db[key] = next;
|
|
@@ -665,7 +781,7 @@ export function createApp() {
|
|
|
665
781
|
res.json({
|
|
666
782
|
success: true,
|
|
667
783
|
key,
|
|
668
|
-
...(db[key] || { favorite: false, notes: '', title: '', updatedAt: Date.now() }),
|
|
784
|
+
...(db[key] || { favorite: false, notes: '', title: '', hidden: false, updatedAt: Date.now() }),
|
|
669
785
|
});
|
|
670
786
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
671
787
|
});
|
|
@@ -701,8 +817,21 @@ export function createApp() {
|
|
|
701
817
|
try {
|
|
702
818
|
const filterCwd = typeof req.query.cwd === 'string' ? req.query.cwd : null;
|
|
703
819
|
const agent = typeof req.query.agent === 'string' ? req.query.agent : null;
|
|
704
|
-
const
|
|
705
|
-
|
|
820
|
+
const days = parseInt(req.query.days || '0', 10) || 0;
|
|
821
|
+
let limit = parseInt(req.query.limit || '0', 10) || 0;
|
|
822
|
+
if (limit < 0) limit = 0;
|
|
823
|
+
if (limit > 800) limit = 800;
|
|
824
|
+
let sessions = (await listSessions(filterCwd || null, agent)).map(toClientSession);
|
|
825
|
+
if (days > 0) {
|
|
826
|
+
const since = Date.now() - days * 86400000;
|
|
827
|
+
sessions = sessions.filter(s => (s.updatedAt || 0) >= since);
|
|
828
|
+
}
|
|
829
|
+
if (limit > 0 && sessions.length > limit) sessions = sessions.slice(0, limit);
|
|
830
|
+
const etag = `"s${crypto.createHash('sha1').update(JSON.stringify(sessions)).digest('base64url').slice(0, 20)}"`;
|
|
831
|
+
res.setHeader('ETag', etag);
|
|
832
|
+
res.setHeader('Cache-Control', 'private, max-age=15');
|
|
833
|
+
if (req.headers['if-none-match'] === etag) return res.status(304).end();
|
|
834
|
+
res.json(sessions);
|
|
706
835
|
} catch (e) {
|
|
707
836
|
res.status(500).json({ error: e.message });
|
|
708
837
|
}
|
|
@@ -752,11 +881,12 @@ export function createApp() {
|
|
|
752
881
|
const { sessionId } = req.params;
|
|
753
882
|
if (!/^[0-9a-f-]{36}$/i.test(sessionId)) return res.status(400).json({ error: 'Invalid session id' });
|
|
754
883
|
const preferred = typeof req.query.agent === 'string' ? req.query.agent : null;
|
|
755
|
-
// tail=0 → full history; default last
|
|
756
|
-
let tail = parseInt(req.query.tail ?? '
|
|
757
|
-
if (Number.isNaN(tail)) tail =
|
|
884
|
+
// tail=0 → full history; default last 80 messages (mobile-friendly)
|
|
885
|
+
let tail = parseInt(req.query.tail ?? '80', 10);
|
|
886
|
+
if (Number.isNaN(tail)) tail = 80;
|
|
758
887
|
if (tail < 0) tail = 0;
|
|
759
888
|
if (tail > 2000) tail = 2000;
|
|
889
|
+
const compact = req.query.compact !== '0' && req.query.compact !== 'false';
|
|
760
890
|
const opts = {
|
|
761
891
|
claudeConfigDirs: CLAUDE_CONFIG_DIRS,
|
|
762
892
|
codexHome: CODEX_HOME,
|
|
@@ -782,6 +912,7 @@ export function createApp() {
|
|
|
782
912
|
messages = messages.slice(-tail);
|
|
783
913
|
truncated = true;
|
|
784
914
|
}
|
|
915
|
+
if (compact) messages = compactHistoryMessages(messages);
|
|
785
916
|
const payload = {
|
|
786
917
|
messages,
|
|
787
918
|
context: Array.isArray(bundle) ? null : (bundle.context || null),
|
|
@@ -789,6 +920,7 @@ export function createApp() {
|
|
|
789
920
|
total,
|
|
790
921
|
truncated,
|
|
791
922
|
tail: truncated ? tail : total,
|
|
923
|
+
compact,
|
|
792
924
|
};
|
|
793
925
|
res.setHeader('X-Session-Agent', payload.agent);
|
|
794
926
|
res.setHeader('Cache-Control', 'private, max-age=30');
|
|
@@ -1059,6 +1191,7 @@ export function createApp() {
|
|
|
1059
1191
|
const result = await meta.runLoop(msgs, {
|
|
1060
1192
|
chatSessionId: session.id,
|
|
1061
1193
|
onContent: (content) => sseWrite(res, 'content', { content }),
|
|
1194
|
+
onApproval: ({ id, name, args }) => sseWrite(res, 'approval', { id, name, args }),
|
|
1062
1195
|
onTool: (name, args, phase, toolResult, id) => {
|
|
1063
1196
|
if (name === 'analyze_images' && phase === 'done' && toolResult?.thread_id) {
|
|
1064
1197
|
if (!session.vlmThreadIds.includes(toolResult.thread_id)) {
|
|
@@ -1095,10 +1228,31 @@ export function createApp() {
|
|
|
1095
1228
|
}
|
|
1096
1229
|
});
|
|
1097
1230
|
|
|
1231
|
+
// User approves/denies a pending sensitive tool call (e.g. run_command) from /api/ai/chat.
|
|
1232
|
+
app.post('/api/ai/approve', authMiddleware, async (req, res) => {
|
|
1233
|
+
const { id, approved } = req.body || {};
|
|
1234
|
+
if (!id) return res.status(400).json({ error: 'id required' });
|
|
1235
|
+
const ok = meta.resolveApproval(id, !!approved);
|
|
1236
|
+
if (!ok) return res.status(404).json({ error: 'no pending approval for id (may have timed out)' });
|
|
1237
|
+
res.json({ ok: true });
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1098
1240
|
// ─── HTTP + WebSocket server ─────────────────────────────────────────────────
|
|
1099
1241
|
const server = http.createServer(app);
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1242
|
+
const wssOpts = {
|
|
1243
|
+
noServer: true,
|
|
1244
|
+
perMessageDeflate: {
|
|
1245
|
+
zlibDeflateOptions: { chunkSize: 1024, memLevel: 7, level: 3 },
|
|
1246
|
+
zlibInflateOptions: { chunkSize: 10 * 1024 },
|
|
1247
|
+
clientNoContextTakeover: true,
|
|
1248
|
+
serverNoContextTakeover: true,
|
|
1249
|
+
serverMaxWindowBits: 10,
|
|
1250
|
+
concurrencyLimit: 8,
|
|
1251
|
+
threshold: 256,
|
|
1252
|
+
},
|
|
1253
|
+
};
|
|
1254
|
+
const wssChat = new WebSocketServer(wssOpts);
|
|
1255
|
+
const wssShell = new WebSocketServer(wssOpts);
|
|
1102
1256
|
|
|
1103
1257
|
server.on('upgrade', (req, socket, head) => {
|
|
1104
1258
|
const pathname = new URL(req.url, 'http://localhost').pathname;
|
|
@@ -1127,41 +1281,154 @@ export function createApp() {
|
|
|
1127
1281
|
return;
|
|
1128
1282
|
}
|
|
1129
1283
|
|
|
1130
|
-
const
|
|
1131
|
-
|
|
1284
|
+
const client = { ws, sessionId: url.searchParams.get('sessionId') || null };
|
|
1285
|
+
chatClients.add(client);
|
|
1286
|
+
|
|
1287
|
+
// Broadcast to every chat socket on the same session (mobile reconnect / multi-tab).
|
|
1288
|
+
const deliver = (data) => {
|
|
1289
|
+
if (data?.sessionId) client.sessionId = data.sessionId;
|
|
1290
|
+
broadcastChat(client.sessionId, data, ws);
|
|
1132
1291
|
};
|
|
1133
1292
|
|
|
1134
1293
|
let shellProc = null;
|
|
1294
|
+
let shellOut = null;
|
|
1295
|
+
let shellErr = null;
|
|
1296
|
+
// App-level ping only (JSON survives hub tunnel). Interval kept modest for mobile radio.
|
|
1297
|
+
const heartbeat = setInterval(() => {
|
|
1298
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
1299
|
+
sendJson(ws, { type: 'ping' });
|
|
1300
|
+
}, 35_000);
|
|
1301
|
+
|
|
1302
|
+
// Low-pressure outbox: coalesce deltas + optional multi-event batch frames
|
|
1303
|
+
const outbox = createOutbox(deliver, {
|
|
1304
|
+
maxWait: 48,
|
|
1305
|
+
maxDeltaChars: 720,
|
|
1306
|
+
maxBatchItems: 10,
|
|
1307
|
+
maxBatchBytes: 10_000,
|
|
1308
|
+
});
|
|
1309
|
+
const wireSend = (data) => outbox.send(data);
|
|
1135
1310
|
|
|
1136
1311
|
ws.on('message', async (raw) => {
|
|
1137
1312
|
let msg;
|
|
1138
1313
|
try { msg = JSON.parse(raw.toString()); }
|
|
1139
1314
|
catch { return; }
|
|
1140
1315
|
|
|
1141
|
-
if (msg.type === '
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
}
|
|
1316
|
+
if (msg.type === 'ping') {
|
|
1317
|
+
sendJson(ws, { type: 'pong' });
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
if (msg.type === 'pong') return;
|
|
1321
|
+
|
|
1322
|
+
const cmd = normalizeCommand(msg);
|
|
1323
|
+
if (!cmd) return;
|
|
1324
|
+
|
|
1325
|
+
if (cmd.cmd === 'session.subscribe') {
|
|
1326
|
+
client.sessionId = cmd.sessionId || client.sessionId || null;
|
|
1327
|
+
wireSend({
|
|
1328
|
+
type: 'run-status',
|
|
1329
|
+
sessionId: client.sessionId,
|
|
1330
|
+
running: isSessionBusy(client.sessionId),
|
|
1331
|
+
});
|
|
1332
|
+
outbox.flush();
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
if (cmd.cmd === 'approval.respond') {
|
|
1337
|
+
const ok = resolveApproval(cmd.requestId, { allow: cmd.allow, always: cmd.always });
|
|
1338
|
+
if (cmd.requestId) {
|
|
1339
|
+
const elHint = { type: 'permission_resolved', requestId: cmd.requestId, allow: !!cmd.allow };
|
|
1340
|
+
wireSend(elHint);
|
|
1341
|
+
}
|
|
1342
|
+
if (!ok) {
|
|
1343
|
+
wireSend({ type: 'status', message: 'permission request expired or unknown' });
|
|
1344
|
+
}
|
|
1345
|
+
outbox.flush();
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
if (cmd.cmd === 'turn.interrupt') {
|
|
1350
|
+
const sid = cmd.sessionId;
|
|
1351
|
+
const ok = abortSession(sid);
|
|
1352
|
+
wireSend({ type: 'abort-result', success: ok, sessionId: sid });
|
|
1353
|
+
outbox.flush();
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
if (cmd.cmd === 'turn.start') {
|
|
1358
|
+
const agent = cmd.agent || 'claude';
|
|
1359
|
+
const options = { ...cmd.options };
|
|
1360
|
+
if (cmd.sessionId) options.sessionId = cmd.sessionId;
|
|
1361
|
+
if (cmd.cwd) options.cwd = cmd.cwd;
|
|
1362
|
+
if (options.sessionId) client.sessionId = options.sessionId;
|
|
1363
|
+
const agentSend = createAgentSend(outbox, agent);
|
|
1364
|
+
const runSend = (data) => {
|
|
1365
|
+
if (data?.type === 'result' || data?.type === 'complete' || data?.type === 'error') {
|
|
1366
|
+
markSessionIdle(data.sessionId || client.sessionId, options.sessionId);
|
|
1367
|
+
outbox.flush();
|
|
1368
|
+
}
|
|
1369
|
+
if (data?.sessionId) client.sessionId = data.sessionId;
|
|
1370
|
+
if (data?.type === 'session-created' && data.sessionId) client.sessionId = data.sessionId;
|
|
1371
|
+
// permission_request bypasses map filter double-pass carefully
|
|
1372
|
+
if (data?.type === 'permission_request' || data?.type === 'permission_resolved') {
|
|
1373
|
+
outbox.send(data);
|
|
1374
|
+
outbox.flush();
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
agentSend(data);
|
|
1378
|
+
};
|
|
1379
|
+
await dispatchAgent(agent, cmd.command || '', options, runSend);
|
|
1380
|
+
outbox.flush();
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
if (cmd.cmd === 'shell.exec') {
|
|
1145
1385
|
if (shellProc) { try { shellProc.kill(); } catch {} shellProc = null; }
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1386
|
+
shellOut?.flush();
|
|
1387
|
+
shellErr?.flush();
|
|
1388
|
+
const shellCmd = cmd.command || '';
|
|
1389
|
+
const cwd = cmd.cwd || os.homedir();
|
|
1390
|
+
wireSend({ type: 'shell-start', command: shellCmd });
|
|
1391
|
+
shellProc = spawn(process.env.SHELL || 'bash', ['-c', shellCmd], {
|
|
1150
1392
|
cwd,
|
|
1151
1393
|
env: { ...process.env },
|
|
1152
1394
|
shell: false,
|
|
1153
1395
|
});
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1396
|
+
shellOut = createTextBatcher(
|
|
1397
|
+
(data) => wireSend({ type: 'shell-output', stream: 'stdout', data }),
|
|
1398
|
+
{ maxWait: 48, maxSize: 3072 },
|
|
1399
|
+
);
|
|
1400
|
+
shellErr = createTextBatcher(
|
|
1401
|
+
(data) => wireSend({ type: 'shell-output', stream: 'stderr', data }),
|
|
1402
|
+
{ maxWait: 48, maxSize: 3072 },
|
|
1403
|
+
);
|
|
1404
|
+
shellProc.stdout.on('data', d => shellOut.push(d.toString()));
|
|
1405
|
+
shellProc.stderr.on('data', d => shellErr.push(d.toString()));
|
|
1406
|
+
shellProc.on('close', (code) => {
|
|
1407
|
+
shellOut?.flush();
|
|
1408
|
+
shellErr?.flush();
|
|
1409
|
+
wireSend({ type: 'shell-exit', code });
|
|
1410
|
+
outbox.flush();
|
|
1411
|
+
shellProc = null;
|
|
1412
|
+
});
|
|
1413
|
+
shellProc.on('error', (e) => {
|
|
1414
|
+
shellOut?.flush();
|
|
1415
|
+
shellErr?.flush();
|
|
1416
|
+
wireSend({ type: 'shell-exit', code: 1, error: e.message });
|
|
1417
|
+
outbox.flush();
|
|
1418
|
+
shellProc = null;
|
|
1419
|
+
});
|
|
1420
|
+
return;
|
|
1161
1421
|
}
|
|
1162
1422
|
});
|
|
1163
1423
|
|
|
1164
|
-
ws.on('close', () => {
|
|
1424
|
+
ws.on('close', () => {
|
|
1425
|
+
chatClients.delete(client);
|
|
1426
|
+
clearInterval(heartbeat);
|
|
1427
|
+
outbox.flush();
|
|
1428
|
+
shellOut?.flush();
|
|
1429
|
+
shellErr?.flush();
|
|
1430
|
+
if (shellProc) { try { shellProc.kill(); } catch {} }
|
|
1431
|
+
});
|
|
1165
1432
|
ws.on('error', (e) => console.error('[ws]', e.message));
|
|
1166
1433
|
});
|
|
1167
1434
|
|
|
@@ -1249,10 +1516,14 @@ else:
|
|
|
1249
1516
|
return;
|
|
1250
1517
|
}
|
|
1251
1518
|
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1519
|
+
const outBatch = createTextBatcher(
|
|
1520
|
+
(data) => send({ type: 'output', data }),
|
|
1521
|
+
{ maxWait: 28, maxSize: 3072 },
|
|
1522
|
+
);
|
|
1523
|
+
proc.stdout.on('data', d => outBatch.push(d.toString()));
|
|
1524
|
+
proc.stderr.on('data', d => outBatch.push(d.toString()));
|
|
1525
|
+
proc.on('exit', (code) => { outBatch.flush(); send({ type: 'exit', code }); ws.close(); });
|
|
1526
|
+
proc.on('error', (e) => { outBatch.flush(); send({ type: 'error', data: e.message }); });
|
|
1256
1527
|
|
|
1257
1528
|
ws.on('message', raw => {
|
|
1258
1529
|
let msg; try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
@@ -1263,7 +1534,7 @@ else:
|
|
|
1263
1534
|
}
|
|
1264
1535
|
});
|
|
1265
1536
|
|
|
1266
|
-
ws.on('close', () => { try { proc.kill('SIGHUP'); } catch {} });
|
|
1537
|
+
ws.on('close', () => { outBatch.flush(); try { proc.kill('SIGHUP'); } catch {} });
|
|
1267
1538
|
ws.on('error', e => console.error('[ws/shell]', e.message));
|
|
1268
1539
|
});
|
|
1269
1540
|
|