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.
Files changed (46) hide show
  1. package/client.js +31 -2
  2. package/dist/app.0.2.1.min.js +868 -0
  3. package/dist/highlight.min.js +448 -430
  4. package/dist/index.html +2 -2
  5. package/dist/style.0.2.1.min.css +1182 -0
  6. package/package.json +1 -1
  7. package/public/agent-panel.js +44 -0
  8. package/public/api.js +61 -10
  9. package/public/app.js +4 -0
  10. package/public/chat.js +338 -31
  11. package/public/files.js +22 -30
  12. package/public/git.js +21 -17
  13. package/public/icons/icon-180.png +0 -0
  14. package/public/icons/icon-192.png +0 -0
  15. package/public/icons/icon-512.png +0 -0
  16. package/public/icons/icon-maskable-512.png +0 -0
  17. package/public/index.html +16 -3
  18. package/public/lib.js +7 -2
  19. package/public/manifest.webmanifest +36 -0
  20. package/public/offline.html +33 -0
  21. package/public/pwa.js +78 -0
  22. package/public/shell/boot.js +3 -0
  23. package/public/shell/hub.js +4 -2
  24. package/public/shell/notes.js +93 -31
  25. package/public/shell/session-list.js +29 -10
  26. package/public/shell/session-nav.js +7 -2
  27. package/public/shell/templates.js +11 -3
  28. package/public/shell.js +101 -8
  29. package/public/state.js +13 -2
  30. package/public/style.css +135 -3
  31. package/public/sw.js +104 -0
  32. package/public/terminal.js +22 -0
  33. package/public/wake-lock.js +45 -0
  34. package/server.js +5 -1
  35. package/src/agents/claude.js +108 -10
  36. package/src/agents/codex.js +1 -1
  37. package/src/agents/grok.js +40 -32
  38. package/src/agents/meta-agent.js +40 -1
  39. package/src/agents/stream-batch.js +62 -0
  40. package/src/app.js +312 -41
  41. package/src/auto-update.js +215 -0
  42. package/src/runtime/approvals.js +67 -0
  43. package/src/runtime/outbox.js +143 -0
  44. package/src/runtime/protocol.js +224 -0
  45. package/dist/app.0.1.0.min.js +0 -825
  46. 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
- 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 {}
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 true;
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 sessions = await listSessions(filterCwd || null, agent);
705
- res.json(sessions.map(toClientSession));
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 120 messages for fast first paint
756
- let tail = parseInt(req.query.tail ?? '120', 10);
757
- if (Number.isNaN(tail)) tail = 120;
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 wssChat = new WebSocketServer({ noServer: true });
1101
- const wssShell = new WebSocketServer({ noServer: true });
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 send = (data) => {
1131
- if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(data));
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 === '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') {
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
- 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], {
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
- 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 });
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', () => { if (shellProc) { try { shellProc.kill(); } catch {} } });
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
- 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 }); });
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