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.
Files changed (44) hide show
  1. package/client.js +25 -1
  2. package/dist/app.0.2.0.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.0.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/src/agents/claude.js +108 -10
  35. package/src/agents/codex.js +1 -1
  36. package/src/agents/grok.js +40 -32
  37. package/src/agents/meta-agent.js +40 -1
  38. package/src/agents/stream-batch.js +62 -0
  39. package/src/app.js +304 -41
  40. package/src/runtime/approvals.js +67 -0
  41. package/src/runtime/outbox.js +143 -0
  42. package/src/runtime/protocol.js +224 -0
  43. package/dist/app.0.1.0.min.js +0 -825
  44. 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
- 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;
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
- return true;
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 sessions = await listSessions(filterCwd || null, agent);
705
- res.json(sessions.map(toClientSession));
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 120 messages for fast first paint
756
- let tail = parseInt(req.query.tail ?? '120', 10);
757
- if (Number.isNaN(tail)) tail = 120;
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 wssChat = new WebSocketServer({ noServer: true });
1101
- const wssShell = new WebSocketServer({ noServer: true });
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 send = (data) => {
1131
- if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(data));
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 === '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') {
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
- 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], {
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
- 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 });
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', () => { if (shellProc) { try { shellProc.kill(); } catch {} } });
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
- 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 }); });
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
+ }