claude-simple 0.1.0 → 0.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/client.js +25 -1
- package/dist/app.0.2.0.min.js +868 -0
- package/dist/highlight.min.js +448 -430
- package/dist/index.html +2 -2
- package/dist/style.0.2.0.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/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 +304 -41
- 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,86 @@ 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;
|
|
367
|
+
}
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function markSessionIdle(sessionId, tempKey) {
|
|
372
|
+
for (const key of [sessionId, tempKey].filter(Boolean)) {
|
|
373
|
+
const s = activeSessions.get(key);
|
|
374
|
+
if (s) s.busy = false;
|
|
309
375
|
}
|
|
310
|
-
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Shrink history payloads: huge tool inputs dominate mobile transfers. */
|
|
379
|
+
function compactHistoryMessages(messages) {
|
|
380
|
+
if (!Array.isArray(messages)) return messages;
|
|
381
|
+
return messages.map((m) => {
|
|
382
|
+
if (!m || typeof m !== 'object') return m;
|
|
383
|
+
if (m.type === 'tool_use' && m.input != null) {
|
|
384
|
+
let raw;
|
|
385
|
+
try { raw = typeof m.input === 'string' ? m.input : JSON.stringify(m.input); }
|
|
386
|
+
catch { raw = String(m.input); }
|
|
387
|
+
if (raw.length > 1600) {
|
|
388
|
+
return { ...m, input: { _truncated: true, preview: raw.slice(0, 1600), bytes: raw.length } };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (m.type === 'text' && typeof m.content === 'string' && m.content.length > 48_000) {
|
|
392
|
+
return { ...m, content: `${m.content.slice(0, 48_000)}\n…` };
|
|
393
|
+
}
|
|
394
|
+
return m;
|
|
395
|
+
});
|
|
311
396
|
}
|
|
312
397
|
|
|
313
398
|
// ─── App factory ──────────────────────────────────────────────────────────────
|
|
314
399
|
export function createApp() {
|
|
315
400
|
const app = express();
|
|
316
401
|
|
|
402
|
+
// Gzip JSON/text API responses when client accepts it (hub tunnel + mobile).
|
|
403
|
+
app.use((req, res, next) => {
|
|
404
|
+
const ae = req.headers['accept-encoding'] || '';
|
|
405
|
+
if (!/\bgzip\b/i.test(ae) || req.method === 'HEAD') return next();
|
|
406
|
+
const origJson = res.json.bind(res);
|
|
407
|
+
res.json = (body) => {
|
|
408
|
+
let buf;
|
|
409
|
+
try { buf = Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)); }
|
|
410
|
+
catch { return origJson(body); }
|
|
411
|
+
if (buf.length < 900) return origJson(body);
|
|
412
|
+
zlib.gzip(buf, { level: 6 }, (err, zipped) => {
|
|
413
|
+
if (err || !zipped || zipped.length >= buf.length * 0.95) return origJson(body);
|
|
414
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
415
|
+
res.setHeader('Content-Encoding', 'gzip');
|
|
416
|
+
res.setHeader('Vary', 'Accept-Encoding');
|
|
417
|
+
res.setHeader('Content-Length', zipped.length);
|
|
418
|
+
res.status(res.statusCode || 200).end(zipped);
|
|
419
|
+
});
|
|
420
|
+
};
|
|
421
|
+
next();
|
|
422
|
+
});
|
|
423
|
+
|
|
317
424
|
app.post('/api/upload-image', authMiddleware, (req, res) => {
|
|
318
425
|
(async () => {
|
|
319
426
|
const ext = (req.headers['x-filename'] || 'image.png').split('.').pop().replace(/[^a-z0-9]/gi, '') || 'png';
|
|
@@ -641,11 +748,11 @@ export function createApp() {
|
|
|
641
748
|
|
|
642
749
|
app.put('/api/sessions/meta', authMiddleware, async (req, res) => {
|
|
643
750
|
try {
|
|
644
|
-
const { sessionId, agent, favorite, notes, title } = req.body || {};
|
|
751
|
+
const { sessionId, agent, favorite, notes, title, hidden } = req.body || {};
|
|
645
752
|
if (!sessionId) return res.status(400).json({ error: 'sessionId required' });
|
|
646
753
|
const key = sessionMetaKey(agent || 'claude', sessionId);
|
|
647
754
|
const db = await loadSessionMeta();
|
|
648
|
-
const prev = db[key] || { favorite: false, notes: '', title: '', updatedAt: 0 };
|
|
755
|
+
const prev = db[key] || { favorite: false, notes: '', title: '', hidden: false, updatedAt: 0 };
|
|
649
756
|
const next = {
|
|
650
757
|
favorite: favorite !== undefined ? Boolean(favorite) : Boolean(prev.favorite),
|
|
651
758
|
notes: notes !== undefined ? String(notes || '') : String(prev.notes || ''),
|
|
@@ -653,10 +760,11 @@ export function createApp() {
|
|
|
653
760
|
title: title !== undefined
|
|
654
761
|
? String(title || '').trim().slice(0, 200)
|
|
655
762
|
: String(prev.title || '').trim(),
|
|
763
|
+
hidden: hidden !== undefined ? Boolean(hidden) : Boolean(prev.hidden),
|
|
656
764
|
updatedAt: Date.now(),
|
|
657
765
|
};
|
|
658
766
|
// Drop empty entries to keep file small
|
|
659
|
-
if (!next.favorite && !next.notes && !next.title) {
|
|
767
|
+
if (!next.favorite && !next.notes && !next.title && !next.hidden) {
|
|
660
768
|
delete db[key];
|
|
661
769
|
} else {
|
|
662
770
|
db[key] = next;
|
|
@@ -665,7 +773,7 @@ export function createApp() {
|
|
|
665
773
|
res.json({
|
|
666
774
|
success: true,
|
|
667
775
|
key,
|
|
668
|
-
...(db[key] || { favorite: false, notes: '', title: '', updatedAt: Date.now() }),
|
|
776
|
+
...(db[key] || { favorite: false, notes: '', title: '', hidden: false, updatedAt: Date.now() }),
|
|
669
777
|
});
|
|
670
778
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
671
779
|
});
|
|
@@ -701,8 +809,21 @@ export function createApp() {
|
|
|
701
809
|
try {
|
|
702
810
|
const filterCwd = typeof req.query.cwd === 'string' ? req.query.cwd : null;
|
|
703
811
|
const agent = typeof req.query.agent === 'string' ? req.query.agent : null;
|
|
704
|
-
const
|
|
705
|
-
|
|
812
|
+
const days = parseInt(req.query.days || '0', 10) || 0;
|
|
813
|
+
let limit = parseInt(req.query.limit || '0', 10) || 0;
|
|
814
|
+
if (limit < 0) limit = 0;
|
|
815
|
+
if (limit > 800) limit = 800;
|
|
816
|
+
let sessions = (await listSessions(filterCwd || null, agent)).map(toClientSession);
|
|
817
|
+
if (days > 0) {
|
|
818
|
+
const since = Date.now() - days * 86400000;
|
|
819
|
+
sessions = sessions.filter(s => (s.updatedAt || 0) >= since);
|
|
820
|
+
}
|
|
821
|
+
if (limit > 0 && sessions.length > limit) sessions = sessions.slice(0, limit);
|
|
822
|
+
const etag = `"s${crypto.createHash('sha1').update(JSON.stringify(sessions)).digest('base64url').slice(0, 20)}"`;
|
|
823
|
+
res.setHeader('ETag', etag);
|
|
824
|
+
res.setHeader('Cache-Control', 'private, max-age=15');
|
|
825
|
+
if (req.headers['if-none-match'] === etag) return res.status(304).end();
|
|
826
|
+
res.json(sessions);
|
|
706
827
|
} catch (e) {
|
|
707
828
|
res.status(500).json({ error: e.message });
|
|
708
829
|
}
|
|
@@ -752,11 +873,12 @@ export function createApp() {
|
|
|
752
873
|
const { sessionId } = req.params;
|
|
753
874
|
if (!/^[0-9a-f-]{36}$/i.test(sessionId)) return res.status(400).json({ error: 'Invalid session id' });
|
|
754
875
|
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 =
|
|
876
|
+
// tail=0 → full history; default last 80 messages (mobile-friendly)
|
|
877
|
+
let tail = parseInt(req.query.tail ?? '80', 10);
|
|
878
|
+
if (Number.isNaN(tail)) tail = 80;
|
|
758
879
|
if (tail < 0) tail = 0;
|
|
759
880
|
if (tail > 2000) tail = 2000;
|
|
881
|
+
const compact = req.query.compact !== '0' && req.query.compact !== 'false';
|
|
760
882
|
const opts = {
|
|
761
883
|
claudeConfigDirs: CLAUDE_CONFIG_DIRS,
|
|
762
884
|
codexHome: CODEX_HOME,
|
|
@@ -782,6 +904,7 @@ export function createApp() {
|
|
|
782
904
|
messages = messages.slice(-tail);
|
|
783
905
|
truncated = true;
|
|
784
906
|
}
|
|
907
|
+
if (compact) messages = compactHistoryMessages(messages);
|
|
785
908
|
const payload = {
|
|
786
909
|
messages,
|
|
787
910
|
context: Array.isArray(bundle) ? null : (bundle.context || null),
|
|
@@ -789,6 +912,7 @@ export function createApp() {
|
|
|
789
912
|
total,
|
|
790
913
|
truncated,
|
|
791
914
|
tail: truncated ? tail : total,
|
|
915
|
+
compact,
|
|
792
916
|
};
|
|
793
917
|
res.setHeader('X-Session-Agent', payload.agent);
|
|
794
918
|
res.setHeader('Cache-Control', 'private, max-age=30');
|
|
@@ -1059,6 +1183,7 @@ export function createApp() {
|
|
|
1059
1183
|
const result = await meta.runLoop(msgs, {
|
|
1060
1184
|
chatSessionId: session.id,
|
|
1061
1185
|
onContent: (content) => sseWrite(res, 'content', { content }),
|
|
1186
|
+
onApproval: ({ id, name, args }) => sseWrite(res, 'approval', { id, name, args }),
|
|
1062
1187
|
onTool: (name, args, phase, toolResult, id) => {
|
|
1063
1188
|
if (name === 'analyze_images' && phase === 'done' && toolResult?.thread_id) {
|
|
1064
1189
|
if (!session.vlmThreadIds.includes(toolResult.thread_id)) {
|
|
@@ -1095,10 +1220,31 @@ export function createApp() {
|
|
|
1095
1220
|
}
|
|
1096
1221
|
});
|
|
1097
1222
|
|
|
1223
|
+
// User approves/denies a pending sensitive tool call (e.g. run_command) from /api/ai/chat.
|
|
1224
|
+
app.post('/api/ai/approve', authMiddleware, async (req, res) => {
|
|
1225
|
+
const { id, approved } = req.body || {};
|
|
1226
|
+
if (!id) return res.status(400).json({ error: 'id required' });
|
|
1227
|
+
const ok = meta.resolveApproval(id, !!approved);
|
|
1228
|
+
if (!ok) return res.status(404).json({ error: 'no pending approval for id (may have timed out)' });
|
|
1229
|
+
res.json({ ok: true });
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1098
1232
|
// ─── HTTP + WebSocket server ─────────────────────────────────────────────────
|
|
1099
1233
|
const server = http.createServer(app);
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1234
|
+
const wssOpts = {
|
|
1235
|
+
noServer: true,
|
|
1236
|
+
perMessageDeflate: {
|
|
1237
|
+
zlibDeflateOptions: { chunkSize: 1024, memLevel: 7, level: 3 },
|
|
1238
|
+
zlibInflateOptions: { chunkSize: 10 * 1024 },
|
|
1239
|
+
clientNoContextTakeover: true,
|
|
1240
|
+
serverNoContextTakeover: true,
|
|
1241
|
+
serverMaxWindowBits: 10,
|
|
1242
|
+
concurrencyLimit: 8,
|
|
1243
|
+
threshold: 256,
|
|
1244
|
+
},
|
|
1245
|
+
};
|
|
1246
|
+
const wssChat = new WebSocketServer(wssOpts);
|
|
1247
|
+
const wssShell = new WebSocketServer(wssOpts);
|
|
1102
1248
|
|
|
1103
1249
|
server.on('upgrade', (req, socket, head) => {
|
|
1104
1250
|
const pathname = new URL(req.url, 'http://localhost').pathname;
|
|
@@ -1127,41 +1273,154 @@ export function createApp() {
|
|
|
1127
1273
|
return;
|
|
1128
1274
|
}
|
|
1129
1275
|
|
|
1130
|
-
const
|
|
1131
|
-
|
|
1276
|
+
const client = { ws, sessionId: url.searchParams.get('sessionId') || null };
|
|
1277
|
+
chatClients.add(client);
|
|
1278
|
+
|
|
1279
|
+
// Broadcast to every chat socket on the same session (mobile reconnect / multi-tab).
|
|
1280
|
+
const deliver = (data) => {
|
|
1281
|
+
if (data?.sessionId) client.sessionId = data.sessionId;
|
|
1282
|
+
broadcastChat(client.sessionId, data, ws);
|
|
1132
1283
|
};
|
|
1133
1284
|
|
|
1134
1285
|
let shellProc = null;
|
|
1286
|
+
let shellOut = null;
|
|
1287
|
+
let shellErr = null;
|
|
1288
|
+
// App-level ping only (JSON survives hub tunnel). Interval kept modest for mobile radio.
|
|
1289
|
+
const heartbeat = setInterval(() => {
|
|
1290
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
1291
|
+
sendJson(ws, { type: 'ping' });
|
|
1292
|
+
}, 35_000);
|
|
1293
|
+
|
|
1294
|
+
// Low-pressure outbox: coalesce deltas + optional multi-event batch frames
|
|
1295
|
+
const outbox = createOutbox(deliver, {
|
|
1296
|
+
maxWait: 48,
|
|
1297
|
+
maxDeltaChars: 720,
|
|
1298
|
+
maxBatchItems: 10,
|
|
1299
|
+
maxBatchBytes: 10_000,
|
|
1300
|
+
});
|
|
1301
|
+
const wireSend = (data) => outbox.send(data);
|
|
1135
1302
|
|
|
1136
1303
|
ws.on('message', async (raw) => {
|
|
1137
1304
|
let msg;
|
|
1138
1305
|
try { msg = JSON.parse(raw.toString()); }
|
|
1139
1306
|
catch { return; }
|
|
1140
1307
|
|
|
1141
|
-
if (msg.type === '
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
}
|
|
1308
|
+
if (msg.type === 'ping') {
|
|
1309
|
+
sendJson(ws, { type: 'pong' });
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
if (msg.type === 'pong') return;
|
|
1313
|
+
|
|
1314
|
+
const cmd = normalizeCommand(msg);
|
|
1315
|
+
if (!cmd) return;
|
|
1316
|
+
|
|
1317
|
+
if (cmd.cmd === 'session.subscribe') {
|
|
1318
|
+
client.sessionId = cmd.sessionId || client.sessionId || null;
|
|
1319
|
+
wireSend({
|
|
1320
|
+
type: 'run-status',
|
|
1321
|
+
sessionId: client.sessionId,
|
|
1322
|
+
running: isSessionBusy(client.sessionId),
|
|
1323
|
+
});
|
|
1324
|
+
outbox.flush();
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
if (cmd.cmd === 'approval.respond') {
|
|
1329
|
+
const ok = resolveApproval(cmd.requestId, { allow: cmd.allow, always: cmd.always });
|
|
1330
|
+
if (cmd.requestId) {
|
|
1331
|
+
const elHint = { type: 'permission_resolved', requestId: cmd.requestId, allow: !!cmd.allow };
|
|
1332
|
+
wireSend(elHint);
|
|
1333
|
+
}
|
|
1334
|
+
if (!ok) {
|
|
1335
|
+
wireSend({ type: 'status', message: 'permission request expired or unknown' });
|
|
1336
|
+
}
|
|
1337
|
+
outbox.flush();
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
if (cmd.cmd === 'turn.interrupt') {
|
|
1342
|
+
const sid = cmd.sessionId;
|
|
1343
|
+
const ok = abortSession(sid);
|
|
1344
|
+
wireSend({ type: 'abort-result', success: ok, sessionId: sid });
|
|
1345
|
+
outbox.flush();
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
if (cmd.cmd === 'turn.start') {
|
|
1350
|
+
const agent = cmd.agent || 'claude';
|
|
1351
|
+
const options = { ...cmd.options };
|
|
1352
|
+
if (cmd.sessionId) options.sessionId = cmd.sessionId;
|
|
1353
|
+
if (cmd.cwd) options.cwd = cmd.cwd;
|
|
1354
|
+
if (options.sessionId) client.sessionId = options.sessionId;
|
|
1355
|
+
const agentSend = createAgentSend(outbox, agent);
|
|
1356
|
+
const runSend = (data) => {
|
|
1357
|
+
if (data?.type === 'result' || data?.type === 'complete' || data?.type === 'error') {
|
|
1358
|
+
markSessionIdle(data.sessionId || client.sessionId, options.sessionId);
|
|
1359
|
+
outbox.flush();
|
|
1360
|
+
}
|
|
1361
|
+
if (data?.sessionId) client.sessionId = data.sessionId;
|
|
1362
|
+
if (data?.type === 'session-created' && data.sessionId) client.sessionId = data.sessionId;
|
|
1363
|
+
// permission_request bypasses map filter double-pass carefully
|
|
1364
|
+
if (data?.type === 'permission_request' || data?.type === 'permission_resolved') {
|
|
1365
|
+
outbox.send(data);
|
|
1366
|
+
outbox.flush();
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
agentSend(data);
|
|
1370
|
+
};
|
|
1371
|
+
await dispatchAgent(agent, cmd.command || '', options, runSend);
|
|
1372
|
+
outbox.flush();
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
if (cmd.cmd === 'shell.exec') {
|
|
1145
1377
|
if (shellProc) { try { shellProc.kill(); } catch {} shellProc = null; }
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1378
|
+
shellOut?.flush();
|
|
1379
|
+
shellErr?.flush();
|
|
1380
|
+
const shellCmd = cmd.command || '';
|
|
1381
|
+
const cwd = cmd.cwd || os.homedir();
|
|
1382
|
+
wireSend({ type: 'shell-start', command: shellCmd });
|
|
1383
|
+
shellProc = spawn(process.env.SHELL || 'bash', ['-c', shellCmd], {
|
|
1150
1384
|
cwd,
|
|
1151
1385
|
env: { ...process.env },
|
|
1152
1386
|
shell: false,
|
|
1153
1387
|
});
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1388
|
+
shellOut = createTextBatcher(
|
|
1389
|
+
(data) => wireSend({ type: 'shell-output', stream: 'stdout', data }),
|
|
1390
|
+
{ maxWait: 48, maxSize: 3072 },
|
|
1391
|
+
);
|
|
1392
|
+
shellErr = createTextBatcher(
|
|
1393
|
+
(data) => wireSend({ type: 'shell-output', stream: 'stderr', data }),
|
|
1394
|
+
{ maxWait: 48, maxSize: 3072 },
|
|
1395
|
+
);
|
|
1396
|
+
shellProc.stdout.on('data', d => shellOut.push(d.toString()));
|
|
1397
|
+
shellProc.stderr.on('data', d => shellErr.push(d.toString()));
|
|
1398
|
+
shellProc.on('close', (code) => {
|
|
1399
|
+
shellOut?.flush();
|
|
1400
|
+
shellErr?.flush();
|
|
1401
|
+
wireSend({ type: 'shell-exit', code });
|
|
1402
|
+
outbox.flush();
|
|
1403
|
+
shellProc = null;
|
|
1404
|
+
});
|
|
1405
|
+
shellProc.on('error', (e) => {
|
|
1406
|
+
shellOut?.flush();
|
|
1407
|
+
shellErr?.flush();
|
|
1408
|
+
wireSend({ type: 'shell-exit', code: 1, error: e.message });
|
|
1409
|
+
outbox.flush();
|
|
1410
|
+
shellProc = null;
|
|
1411
|
+
});
|
|
1412
|
+
return;
|
|
1161
1413
|
}
|
|
1162
1414
|
});
|
|
1163
1415
|
|
|
1164
|
-
ws.on('close', () => {
|
|
1416
|
+
ws.on('close', () => {
|
|
1417
|
+
chatClients.delete(client);
|
|
1418
|
+
clearInterval(heartbeat);
|
|
1419
|
+
outbox.flush();
|
|
1420
|
+
shellOut?.flush();
|
|
1421
|
+
shellErr?.flush();
|
|
1422
|
+
if (shellProc) { try { shellProc.kill(); } catch {} }
|
|
1423
|
+
});
|
|
1165
1424
|
ws.on('error', (e) => console.error('[ws]', e.message));
|
|
1166
1425
|
});
|
|
1167
1426
|
|
|
@@ -1249,10 +1508,14 @@ else:
|
|
|
1249
1508
|
return;
|
|
1250
1509
|
}
|
|
1251
1510
|
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1511
|
+
const outBatch = createTextBatcher(
|
|
1512
|
+
(data) => send({ type: 'output', data }),
|
|
1513
|
+
{ maxWait: 28, maxSize: 3072 },
|
|
1514
|
+
);
|
|
1515
|
+
proc.stdout.on('data', d => outBatch.push(d.toString()));
|
|
1516
|
+
proc.stderr.on('data', d => outBatch.push(d.toString()));
|
|
1517
|
+
proc.on('exit', (code) => { outBatch.flush(); send({ type: 'exit', code }); ws.close(); });
|
|
1518
|
+
proc.on('error', (e) => { outBatch.flush(); send({ type: 'error', data: e.message }); });
|
|
1256
1519
|
|
|
1257
1520
|
ws.on('message', raw => {
|
|
1258
1521
|
let msg; try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
@@ -1263,7 +1526,7 @@ else:
|
|
|
1263
1526
|
}
|
|
1264
1527
|
});
|
|
1265
1528
|
|
|
1266
|
-
ws.on('close', () => { try { proc.kill('SIGHUP'); } catch {} });
|
|
1529
|
+
ws.on('close', () => { outBatch.flush(); try { proc.kill('SIGHUP'); } catch {} });
|
|
1267
1530
|
ws.on('error', e => console.error('[ws/shell]', e.message));
|
|
1268
1531
|
});
|
|
1269
1532
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-flight tool permission requests (Claude canUseTool bridge).
|
|
3
|
+
* requestId → { resolve, reject, sessionId, createdAt }
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const pending = new Map();
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TTL_MS = 10 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} requestId
|
|
12
|
+
* @param {string | null} sessionId
|
|
13
|
+
* @param {number} [ttlMs]
|
|
14
|
+
* @returns {Promise<{ allow: boolean, always?: boolean }>}
|
|
15
|
+
*/
|
|
16
|
+
export function waitForApproval(requestId, sessionId, ttlMs = DEFAULT_TTL_MS) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const timer = setTimeout(() => {
|
|
19
|
+
pending.delete(requestId);
|
|
20
|
+
resolve({ allow: false, always: false, timeout: true });
|
|
21
|
+
}, ttlMs);
|
|
22
|
+
|
|
23
|
+
pending.set(requestId, {
|
|
24
|
+
resolve: (v) => {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
resolve(v);
|
|
27
|
+
},
|
|
28
|
+
reject: (e) => {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
reject(e);
|
|
31
|
+
},
|
|
32
|
+
sessionId: sessionId || null,
|
|
33
|
+
createdAt: Date.now(),
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} requestId
|
|
40
|
+
* @param {{ allow: boolean, always?: boolean }} decision
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
export function resolveApproval(requestId, decision) {
|
|
44
|
+
const entry = pending.get(requestId);
|
|
45
|
+
if (!entry) return false;
|
|
46
|
+
pending.delete(requestId);
|
|
47
|
+
entry.resolve({
|
|
48
|
+
allow: !!decision.allow,
|
|
49
|
+
always: !!decision.always,
|
|
50
|
+
});
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Drop pending approvals for a session (on interrupt). */
|
|
55
|
+
export function cancelApprovalsForSession(sessionId) {
|
|
56
|
+
if (!sessionId) return;
|
|
57
|
+
for (const [id, entry] of pending) {
|
|
58
|
+
if (entry.sessionId === sessionId || entry.sessionId == null) {
|
|
59
|
+
pending.delete(id);
|
|
60
|
+
entry.resolve({ allow: false, always: false, cancelled: true });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function pendingApprovalCount() {
|
|
66
|
+
return pending.size;
|
|
67
|
+
}
|