claude-simple 0.2.28 → 0.2.31

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 (77) hide show
  1. package/README.md +17 -1
  2. package/bin/edge-ctl.js +151 -4
  3. package/client.js +8 -2
  4. package/dist/addon-fit.min.js +0 -0
  5. package/dist/app.0.2.31.min.js +1308 -0
  6. package/dist/index.html +2 -2
  7. package/dist/style.0.2.31.min.css +2012 -0
  8. package/dist/xterm.min.css +0 -0
  9. package/dist/xterm.min.js +0 -0
  10. package/gateway.js +0 -0
  11. package/package.json +2 -2
  12. package/public/addon-fit.min.js +0 -0
  13. package/public/agent-panel.js +47 -20
  14. package/public/api.js +1 -1
  15. package/public/app.js +5 -5
  16. package/public/base.css +0 -0
  17. package/public/cache.js +19 -0
  18. package/public/chat.js +80 -19
  19. package/public/files.js +26 -10
  20. package/public/git.js +2 -2
  21. package/public/icons/icon-180.png +0 -0
  22. package/public/icons/icon-192.png +0 -0
  23. package/public/icons/icon-512.png +0 -0
  24. package/public/icons/icon-maskable-512.png +0 -0
  25. package/public/index.html +2 -2
  26. package/public/lib.js +0 -0
  27. package/public/manifest.webmanifest +0 -0
  28. package/public/markdown.js +16 -0
  29. package/public/math.js +145 -7
  30. package/public/mobile.js +0 -0
  31. package/public/notifications.js +0 -0
  32. package/public/offline.html +0 -0
  33. package/public/pwa.js +1 -1
  34. package/public/router.js +0 -0
  35. package/public/scroll-follow.js +78 -0
  36. package/public/settings.js +41 -0
  37. package/public/shell/boot.js +0 -0
  38. package/public/shell/dashboard.js +0 -0
  39. package/public/shell/hub.js +0 -0
  40. package/public/shell/layout.js +0 -0
  41. package/public/shell/notes.js +0 -0
  42. package/public/shell/session-context.js +0 -0
  43. package/public/shell/session-list.js +49 -16
  44. package/public/shell/session-nav.js +66 -18
  45. package/public/shell/templates.js +0 -0
  46. package/public/shell/util.js +0 -0
  47. package/public/shell.js +0 -0
  48. package/public/state.js +7 -7
  49. package/public/style.css +5 -0
  50. package/public/sw.js +10 -5
  51. package/public/terminal.js +0 -0
  52. package/public/wake-lock.js +0 -0
  53. package/public/workspace-path.js +61 -0
  54. package/public/xterm.min.css +0 -0
  55. package/public/xterm.min.js +0 -0
  56. package/src/agent-quota.js +228 -0
  57. package/src/agents/claude.js +0 -0
  58. package/src/agents/codex.js +2 -0
  59. package/src/agents/cursor.js +2 -1
  60. package/src/agents/gemini.js +2 -1
  61. package/src/agents/grok.js +6 -1
  62. package/src/agents/meta-agent.js +7 -4
  63. package/src/agents/models.js +21 -12
  64. package/src/agents/sessions.js +133 -69
  65. package/src/agents/stream-batch.js +0 -0
  66. package/src/app.js +80 -36
  67. package/src/auto-update.js +2 -0
  68. package/src/collab.js +3 -0
  69. package/src/platform.js +133 -0
  70. package/src/project-api-path.js +14 -0
  71. package/src/runtime/approvals.js +0 -0
  72. package/src/runtime/chat-broadcast.js +12 -0
  73. package/src/runtime/outbox.js +0 -0
  74. package/src/runtime/port-websocket.js +80 -0
  75. package/src/runtime/protocol.js +0 -0
  76. package/dist/app.0.2.28.min.js +0 -1220
  77. package/dist/style.0.2.28.min.css +0 -2007
@@ -4,6 +4,7 @@ import readline from 'readline';
4
4
  import path from 'path';
5
5
  import os from 'os';
6
6
  import crypto from 'crypto';
7
+ import { pathsEqual } from '../platform.js';
7
8
 
8
9
  // ─── Claude ───────────────────────────────────────────────────────────────────
9
10
 
@@ -105,7 +106,7 @@ async function scanClaudeConfigDir(configDir, nameMap, filterCwd) {
105
106
  const filePath = path.join(projectDir, fileEntry.name);
106
107
  try {
107
108
  const [stat, meta] = await Promise.all([fs.stat(filePath), readClaudeSessionMeta(filePath)]);
108
- if (filterCwd && meta.cwd !== filterCwd) return;
109
+ if (filterCwd && !pathsEqual(meta.cwd, filterCwd)) return;
109
110
  const sessionId = meta.sessionId || fileEntry.name.replace('.jsonl', '');
110
111
  const firstMsg = await readClaudeFirstMessage(filePath);
111
112
  sessions.push({
@@ -286,13 +287,20 @@ async function findCodexRollouts(codexHome) {
286
287
  return files;
287
288
  }
288
289
 
290
+ const codexMetadata = new Map();
289
291
  async function readCodexMeta(filePath) {
292
+ const stat = await fs.stat(filePath);
293
+ const cached = codexMetadata.get(filePath);
294
+ if (cached?.size === stat.size && cached?.mtimeMs === stat.mtimeMs) return cached.meta;
290
295
  let id = null, cwd = null, ts = null, turnCount = 0;
291
296
  try {
292
297
  // Full scan: meta is early; turnCount needs whole file
293
298
  const stream = fsSync.createReadStream(filePath);
294
299
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
295
300
  for await (const line of rl) {
301
+ const head = line.slice(0, 400);
302
+ if (!/"type"\s*:\s*"(?:session_meta|turn_context)"/.test(head) &&
303
+ !/"(?:role|type)"\s*:\s*"(?:user|user_message)"/.test(head)) continue;
296
304
  try {
297
305
  const d = JSON.parse(line);
298
306
  if (d.type === 'session_meta' && d.payload) {
@@ -316,7 +324,10 @@ async function readCodexMeta(filePath) {
316
324
  const m = path.basename(filePath).match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
317
325
  if (m) id = m[1];
318
326
  }
319
- return { id, cwd, ts, turnCount };
327
+ const meta = { id, cwd, ts, turnCount };
328
+ codexMetadata.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, meta });
329
+ while (codexMetadata.size > 2000) codexMetadata.delete(codexMetadata.keys().next().value);
330
+ return meta;
320
331
  }
321
332
 
322
333
  export async function scanCodexSessions(codexHome, filterCwd = null) {
@@ -325,25 +336,27 @@ export async function scanCodexSessions(codexHome, filterCwd = null) {
325
336
  const rollouts = await findCodexRollouts(home);
326
337
  const sessions = [];
327
338
 
328
- await Promise.all(rollouts.map(async filePath => {
329
- try {
330
- const [stat, meta] = await Promise.all([fs.stat(filePath), readCodexMeta(filePath)]);
331
- if (!meta.id) return;
332
- if (filterCwd && meta.cwd && meta.cwd !== filterCwd) return;
333
- const idx = index.get(meta.id);
334
- sessions.push({
335
- sessionId: meta.id,
336
- agent: 'codex',
337
- cwd: meta.cwd || null,
338
- configDir: home,
339
- display: idx?.thread_name || meta.id.slice(0, 8),
340
- updatedAt: stat.mtimeMs,
341
- hasMemory: false,
342
- filePath,
343
- turnCount: meta.turnCount || 0,
344
- });
345
- } catch {}
346
- }));
339
+ for (let offset = 0; offset < rollouts.length; offset += 8) {
340
+ await Promise.all(rollouts.slice(offset, offset + 8).map(async filePath => {
341
+ try {
342
+ const [stat, meta] = await Promise.all([fs.stat(filePath), readCodexMeta(filePath)]);
343
+ if (!meta.id) return;
344
+ if (filterCwd && meta.cwd && !pathsEqual(meta.cwd, filterCwd)) return;
345
+ const idx = index.get(meta.id);
346
+ sessions.push({
347
+ sessionId: meta.id,
348
+ agent: 'codex',
349
+ cwd: meta.cwd || null,
350
+ configDir: home,
351
+ display: idx?.thread_name || meta.id.slice(0, 8),
352
+ updatedAt: stat.mtimeMs,
353
+ hasMemory: false,
354
+ filePath,
355
+ turnCount: meta.turnCount || 0,
356
+ });
357
+ } catch {}
358
+ }));
359
+ }
347
360
  return sessions;
348
361
  }
349
362
 
@@ -361,12 +374,36 @@ export async function findCodexSessionFile(sessionId, codexHome) {
361
374
  return null;
362
375
  }
363
376
 
377
+ const codexTranscripts = new Map();
378
+
364
379
  export async function parseCodexMessages(filePath) {
365
- const messages = [];
380
+ const stat = await fs.stat(filePath);
381
+ let entry = codexTranscripts.get(filePath);
382
+ if (entry?.loading) {
383
+ await entry.loading;
384
+ return parseCodexMessages(filePath);
385
+ }
386
+ if (!entry || entry.ino !== stat.ino || stat.size < entry.size ||
387
+ (stat.size === entry.size && stat.mtimeMs !== entry.mtimeMs)) {
388
+ entry = { ino: stat.ino, size: 0, offset: 0, messages: [], seenAssistant: new Set(), seenCalls: new Set() };
389
+ codexTranscripts.set(filePath, entry);
390
+ }
391
+ codexTranscripts.delete(filePath);
392
+ codexTranscripts.set(filePath, entry);
393
+ while (codexTranscripts.size > 8) codexTranscripts.delete(codexTranscripts.keys().next().value);
394
+ if (entry.size === stat.size && entry.mtimeMs === stat.mtimeMs) return entry.messages;
395
+ const loading = updateCodexTranscript(filePath, entry, stat);
396
+ entry.loading = loading;
397
+ try { await loading; } finally { entry.loading = null; }
398
+ return entry.messages;
399
+ }
400
+
401
+ async function updateCodexTranscript(filePath, entry, stat) {
402
+ const messages = entry.messages;
366
403
  // Codex writes the same assistant turn twice:
367
404
  // response_item (message/assistant) AND event_msg (agent_message)
368
405
  // Prefer response_item; only use event_msg when that text wasn't already seen.
369
- const seenAssistant = new Set();
406
+ const seenAssistant = entry.seenAssistant;
370
407
 
371
408
  const assistantKey = (text, ts) => {
372
409
  const t = String(text || '').trim();
@@ -389,54 +426,81 @@ export async function parseCodexMessages(filePath) {
389
426
  };
390
427
 
391
428
  try {
392
- const stream = fsSync.createReadStream(filePath);
393
- const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
394
- for await (const line of rl) {
395
- let d;
396
- try { d = JSON.parse(line); } catch { continue; }
397
-
398
- if (d.type === 'response_item' && d.payload) {
399
- const p = d.payload;
400
- if (p.type === 'message' && p.role === 'user') {
401
- const texts = (p.content || [])
402
- .filter(c => c.type === 'input_text' || c.type === 'text')
403
- .map(c => c.text || '')
404
- .filter(Boolean);
405
- const text = texts.join('\n').trim();
406
- // skip huge injected system/env blobs
407
- if (text && !text.startsWith('# AGENTS.md') && !text.startsWith('<environment_context')
408
- && !text.startsWith('<permissions') && text.length < 2000) {
409
- messages.push({ role: 'user', type: 'text', content: text, ts: d.timestamp });
429
+ if (stat.size <= entry.offset) return;
430
+ const stream = fsSync.createReadStream(filePath, { start: entry.offset, end: stat.size - 1 });
431
+ let pending = Buffer.alloc(0);
432
+ for await (const chunk of stream) {
433
+ pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
434
+ let start = 0;
435
+ let end;
436
+ while ((end = pending.indexOf(10, start)) !== -1) {
437
+ const line = pending.subarray(start, end).toString('utf8');
438
+ entry.offset += end - start + 1;
439
+ start = end + 1;
440
+ // Reasoning and tool outputs dominate large rollouts. Do not JSON-parse
441
+ // their multi-megabyte payloads when building the visible transcript.
442
+ const head = line.slice(0, 400);
443
+ if (!/"type"\s*:\s*"(?:response_item|event_msg)"/.test(head) ||
444
+ !/"type"\s*:\s*"(?:message|agent_message|function_call|command_execution|custom_tool_call)"/.test(head)) continue;
445
+ let d;
446
+ try { d = JSON.parse(line); } catch { continue; }
447
+
448
+ if (d.type === 'response_item' && d.payload) {
449
+ const p = d.payload;
450
+ if (p.type === 'message' && p.role === 'user') {
451
+ const texts = (p.content || [])
452
+ .filter(c => c.type === 'input_text' || c.type === 'text')
453
+ .map(c => c.text || '')
454
+ .filter(Boolean);
455
+ const text = texts.join('\n').trim();
456
+ // skip huge injected system/env blobs
457
+ if (text && !text.startsWith('# AGENTS.md') && !text.startsWith('<environment_context')
458
+ && !text.startsWith('<permissions')) {
459
+ seenAssistant.clear();
460
+ messages.push({ role: 'user', type: 'text', content: text, ts: d.timestamp });
461
+ }
462
+ } else if (p.type === 'message' && p.role === 'assistant') {
463
+ const texts = (p.content || [])
464
+ .filter(c => c.type === 'output_text' || c.type === 'text')
465
+ .map(c => c.text || '')
466
+ .filter(Boolean);
467
+ pushAssistant(texts.join('\n'), d.timestamp);
468
+ } else if (p.type === 'function_call') {
469
+ if (p.call_id && entry.seenCalls.has(p.call_id)) continue;
470
+ if (p.call_id) entry.seenCalls.add(p.call_id);
471
+ let input = p.arguments;
472
+ try { input = typeof input === 'string' ? JSON.parse(input) : input; } catch {}
473
+ messages.push({ role: 'tool', type: 'tool_use', name: p.name || 'function', id: p.call_id, input: input ?? {}, ts: d.timestamp });
474
+ } else if (p.type === 'command_execution' || p.type === 'custom_tool_call') {
475
+ if (p.call_id && entry.seenCalls.has(p.call_id)) continue;
476
+ if (p.call_id) entry.seenCalls.add(p.call_id);
477
+ let input = p.input;
478
+ try { input = typeof input === 'string' ? JSON.parse(input) : input; } catch {}
479
+ messages.push({
480
+ role: 'tool',
481
+ type: 'tool_use',
482
+ name: p.type === 'command_execution' ? 'Bash' : (p.name || 'tool'),
483
+ id: p.call_id,
484
+ input: p.type === 'command_execution' ? { command: p.command || '' } : (input ?? {}),
485
+ ts: d.timestamp,
486
+ });
410
487
  }
411
- } else if (p.type === 'message' && p.role === 'assistant') {
412
- const texts = (p.content || [])
413
- .filter(c => c.type === 'output_text' || c.type === 'text')
414
- .map(c => c.text || '')
415
- .filter(Boolean);
416
- pushAssistant(texts.join('\n'), d.timestamp);
417
- } else if (p.type === 'function_call') {
418
- let input = p.arguments;
419
- try { input = typeof input === 'string' ? JSON.parse(input) : input; } catch {}
420
- messages.push({ role: 'tool', type: 'tool_use', name: p.name || 'function', input: input ?? {}, ts: d.timestamp });
421
- } else if (p.type === 'command_execution' || p.type === 'custom_tool_call') {
422
- messages.push({
423
- role: 'tool',
424
- type: 'tool_use',
425
- name: p.type === 'command_execution' ? 'Bash' : (p.name || 'tool'),
426
- input: { command: p.command || p.input || '' },
427
- ts: d.timestamp,
428
- });
429
488
  }
430
- }
431
489
 
432
- // Secondary channel — skip if response_item already recorded the same body
433
- if (d.type === 'event_msg' && d.payload?.type === 'agent_message') {
434
- const text = d.payload.message || d.payload.text;
435
- pushAssistant(text, d.timestamp);
490
+ // Secondary channel — skip if response_item already recorded the same body
491
+ if (d.type === 'event_msg' && d.payload?.type === 'agent_message') {
492
+ const text = d.payload.message || d.payload.text;
493
+ pushAssistant(text, d.timestamp);
494
+ }
436
495
  }
496
+ pending = pending.subarray(start);
437
497
  }
438
- } catch {}
439
- return messages;
498
+ entry.size = stat.size;
499
+ entry.mtimeMs = stat.mtimeMs;
500
+ } catch (error) {
501
+ codexTranscripts.delete(filePath);
502
+ throw error;
503
+ }
440
504
  }
441
505
 
442
506
  function replaceSessionId(value, sourceSessionId, targetSessionId) {
@@ -611,7 +675,7 @@ export async function scanGrokSessions(grokHome, filterCwd = null) {
611
675
  // dir name is encodeURIComponent(cwd)
612
676
  let cwd;
613
677
  try { cwd = decodeURIComponent(dirEntry.name); } catch { cwd = dirEntry.name; }
614
- if (filterCwd && cwd !== filterCwd) return;
678
+ if (filterCwd && !pathsEqual(cwd, filterCwd)) return;
615
679
 
616
680
  const projectDir = path.join(sessionsRoot, dirEntry.name);
617
681
  let sessionDirs;
@@ -1191,7 +1255,7 @@ export async function scanGeminiSessions(geminiHome, filterCwd = null) {
1191
1255
  records.set(sessionId, existing);
1192
1256
  }));
1193
1257
  } catch {}
1194
- return [...records.values()].filter(s => !filterCwd || s.cwd === filterCwd);
1258
+ return [...records.values()].filter(s => !filterCwd || pathsEqual(s.cwd, filterCwd));
1195
1259
  }
1196
1260
 
1197
1261
  export async function parseGeminiMessages(filePath) {
@@ -1329,7 +1393,7 @@ export async function scanCursorSessions(cursorHome, filterCwd = null) {
1329
1393
  });
1330
1394
  }));
1331
1395
  } catch {}
1332
- return sessions.filter(s => !filterCwd || s.cwd === filterCwd);
1396
+ return sessions.filter(s => !filterCwd || pathsEqual(s.cwd, filterCwd));
1333
1397
  }
1334
1398
 
1335
1399
  async function loadCursorContext(sessionId, cursorHome) {
File without changes
package/src/app.js CHANGED
@@ -33,10 +33,12 @@ import { discoverAllAgentModels } from './agents/models.js';
33
33
  import { createTextBatcher } from './agents/stream-batch.js';
34
34
  import { normalizeCommand, mapAgentEmit } from './runtime/protocol.js';
35
35
  import { createOutbox } from './runtime/outbox.js';
36
+ import { broadcastChatFrame } from './runtime/chat-broadcast.js';
36
37
  import { createExternalSessionMirror } from './runtime/external-session-sync.js';
37
38
  import { resolveApproval, cancelApprovalsForSession } from './runtime/approvals.js';
38
39
  import { createMcpHttpHandler, createMcpService } from './mcp.js';
39
40
  import { createCollab, roleAtLeast, proxyToLocalPort, machineId as localMachineId } from './collab.js';
41
+ import { createQuotaReader } from './agent-quota.js';
40
42
  import { parseHistoryQuery, sliceHistoryMessages } from './session-history.js';
41
43
  import {
42
44
  CLAUDE_ENV_KEYS,
@@ -49,6 +51,17 @@ import {
49
51
  normalizeProfileName,
50
52
  envForProfile,
51
53
  } from './agent-env.js';
54
+ import {
55
+ containedPath,
56
+ defaultInteractiveShell,
57
+ expandHome,
58
+ processKillSignal,
59
+ resolveWinExecutable,
60
+ shellExecSpec,
61
+ spawnCommandOpts,
62
+ userLocalBinDirs,
63
+ } from './platform.js';
64
+ import { canonicalizeProjectApiPath } from './project-api-path.js';
52
65
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
53
66
  const rootDir = path.join(__dirname, '..');
54
67
 
@@ -63,18 +76,28 @@ const SESSION_META_FILE = process.env.SESSION_META_FILE || path.join(rootDir,
63
76
  // without requiring the service unit to mirror an interactive shell PATH.
64
77
  function resolveUserLocalCommand(configured, fallback) {
65
78
  const command = configured || fallback;
66
- if (command.includes('/') || command.includes('\\')) return command.replace(/^~/, os.homedir());
67
- for (const dir of [path.join(os.homedir(), '.local', 'bin'), path.join(os.homedir(), 'bin')]) {
79
+ if (command.includes('/') || command.includes('\\')) return expandHome(command);
80
+ for (const dir of userLocalBinDirs()) {
68
81
  const candidate = path.join(dir, command);
69
82
  if (fsSync.existsSync(candidate)) return candidate;
83
+ if (process.platform === 'win32') {
84
+ for (const ext of ['.cmd', '.exe', '.bat']) {
85
+ if (fsSync.existsSync(candidate + ext)) return candidate + ext;
86
+ }
87
+ }
70
88
  }
71
89
  return command;
72
90
  }
73
- const CLAUDE_CLI_PATH = process.env.CLAUDE_CLI_PATH || 'claude';
74
- const CODEX_CLI_PATH = process.env.CODEX_CLI_PATH || 'codex';
75
- const GROK_CLI_PATH = process.env.GROK_CLI_PATH || 'grok';
76
- const GEMINI_CLI_PATH = resolveUserLocalCommand(process.env.GEMINI_CLI_PATH || process.env.AGY_PATH, 'agy');
77
- const CURSOR_AGENT_PATH = process.env.CURSOR_AGENT_PATH || 'cursor-agent';
91
+ function resolveAgentCommand(configured, fallback) {
92
+ const local = resolveUserLocalCommand(configured, fallback);
93
+ return resolveWinExecutable(local, { extraDirs: userLocalBinDirs() });
94
+ }
95
+
96
+ const CLAUDE_CLI_PATH = resolveAgentCommand(process.env.CLAUDE_CLI_PATH, 'claude');
97
+ const CODEX_CLI_PATH = resolveAgentCommand(process.env.CODEX_CLI_PATH, 'codex');
98
+ const GROK_CLI_PATH = resolveAgentCommand(process.env.GROK_CLI_PATH, 'grok');
99
+ const GEMINI_CLI_PATH = resolveAgentCommand(process.env.GEMINI_CLI_PATH || process.env.AGY_PATH, 'agy');
100
+ const CURSOR_AGENT_PATH = resolveAgentCommand(process.env.CURSOR_AGENT_PATH, 'cursor-agent');
78
101
  const CODEX_HOME = (process.env.CODEX_HOME || path.join(os.homedir(), '.codex')).replace(/^~/, os.homedir());
79
102
  const GROK_HOME = (process.env.GROK_HOME || path.join(os.homedir(), '.grok')).replace(/^~/, os.homedir());
80
103
  const GEMINI_HOME = (process.env.GEMINI_HOME || path.join(os.homedir(), '.gemini')).replace(/^~/, os.homedir());
@@ -286,20 +309,20 @@ function authMiddleware(req, res, next) {
286
309
  // ─── Path helpers ─────────────────────────────────────────────────────────────
287
310
  function safePath(base, rel) {
288
311
  const resolved = path.resolve(base, rel || '');
289
- if (!resolved.startsWith(path.resolve(base))) throw new Error('Path traversal');
312
+ if (!containedPath(base, resolved)) throw new Error('Path traversal');
290
313
  return resolved;
291
314
  }
292
315
 
293
316
  function expandPath(p) {
294
- if (!p) return p;
295
- return p.replace(/^~/, os.homedir());
317
+ return expandHome(p);
296
318
  }
297
319
 
298
320
  function resolveProjectRoot(req) {
299
321
  const root = req.query.root || req.body?.root;
300
322
  if (root) {
301
323
  const resolved = path.resolve(expandPath(root));
302
- if (!path.isAbsolute(resolved) || resolved === '/' || resolved.includes('\0'))
324
+ const driveRoot = path.parse(resolved).root;
325
+ if (!path.isAbsolute(resolved) || resolved === '/' || resolved === driveRoot || resolved.includes('\0'))
303
326
  throw new Error('Invalid root path');
304
327
  return resolved;
305
328
  }
@@ -309,7 +332,7 @@ function resolveProjectRoot(req) {
309
332
  // ─── Git helpers ──────────────────────────────────────────────────────────────
310
333
  function git(args, cwd) {
311
334
  return new Promise((resolve, reject) => {
312
- const child = spawn('git', args, { cwd, shell: false });
335
+ const child = spawn('git', args, { cwd, ...spawnCommandOpts() });
313
336
  let out = '', err = '';
314
337
  child.stdout.on('data', d => out += d);
315
338
  child.stderr.on('data', d => err += d);
@@ -337,17 +360,8 @@ function sendJson(ws, data) {
337
360
  }
338
361
  }
339
362
 
340
- function broadcastChat(sessionId, data, fallbackWs) {
341
- let n = 0;
342
- if (sessionId) {
343
- for (const c of chatClients) {
344
- if (c.sessionId === sessionId) {
345
- sendJson(c.ws, data);
346
- n++;
347
- }
348
- }
349
- }
350
- if (n === 0 && fallbackWs) sendJson(fallbackWs, data);
363
+ function broadcastChat(sessionId, data, ownerWs) {
364
+ broadcastChatFrame(chatClients, sessionId, data, ownerWs, sendJson);
351
365
  }
352
366
 
353
367
  async function listSessions(filterCwd = null, agentFilter = null) {
@@ -506,7 +520,15 @@ function compactHistoryMessages(messages) {
506
520
 
507
521
  // ─── App factory ──────────────────────────────────────────────────────────────
508
522
  export function createApp() {
523
+ const readQuota = createQuotaReader({ claudePath: CLAUDE_CLI_PATH, codexPath: CODEX_CLI_PATH, codexHome: CODEX_HOME,
524
+ grokHome: GROK_HOME, defaultClaudeDir: CLAUDE_CONFIG_DIRS[0], allowedClaudeDirs: () => CLAUDE_CONFIG_DIRS });
509
525
  const app = express();
526
+ app.use((req, _res, next) => {
527
+ const pathOnly = (req.url || '').split('?')[0];
528
+ const canon = canonicalizeProjectApiPath(pathOnly);
529
+ if (canon !== pathOnly) req.url = canon + (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '');
530
+ next();
531
+ });
510
532
  const COLLAB_FILE = process.env.COLLAB_FILE || path.join(rootDir, '.collab.json');
511
533
  const collab = createCollab({ filePath: COLLAB_FILE });
512
534
  const allowRemoteProxy = process.env.PORT_PROXY_ALLOW_REMOTE === '1';
@@ -627,7 +649,12 @@ export function createApp() {
627
649
  })().catch(e => res.status(500).json({ error: e.message }));
628
650
  });
629
651
 
630
- app.use(express.json({ limit: '10mb' }));
652
+ const jsonBodyParser = express.json({ limit: '10mb' });
653
+ app.use((req, res, next) => {
654
+ // Proxy bodies belong to the target app, including arbitrary JSON bytes.
655
+ if (req.path === '/api/port-proxy' || req.path.startsWith('/r/')) return next();
656
+ return jsonBodyParser(req, res, next);
657
+ });
631
658
 
632
659
  // MCP uses a separate short-lived bearer token. Normal UI JWTs deliberately
633
660
  // cannot call /mcp, so a browser token leaked through a URL cannot become an
@@ -989,6 +1016,14 @@ export function createApp() {
989
1016
  }
990
1017
  });
991
1018
 
1019
+ app.get('/api/agents/quota', authMiddleware, async (req, res) => {
1020
+ res.setHeader('Cache-Control', 'private, no-store');
1021
+ const agent = req.query.agent;
1022
+ if (!['claude', 'codex', 'grok'].includes(agent)) return res.status(400).json({ error: 'Unsupported agent' });
1023
+ try { res.json(await readQuota({ agent, configDir: req.query.configDir })); }
1024
+ catch { res.status(400).json({ error: 'Unknown Claude config directory' }); }
1025
+ });
1026
+
992
1027
  app.get('/api/projects/:id/files', authMiddleware, async (req, res) => {
993
1028
  try {
994
1029
  const projectPath = resolveProjectRoot(req);
@@ -1322,10 +1357,12 @@ export function createApp() {
1322
1357
  const since = Date.now() - days * 86400000;
1323
1358
  sessions = sessions.filter(s => (s.updatedAt || 0) >= since);
1324
1359
  }
1360
+ const offset = Math.max(0, parseInt(req.query.offset || '0', 10) || 0);
1361
+ sessions = sessions.slice(offset);
1325
1362
  if (limit > 0 && sessions.length > limit) sessions = sessions.slice(0, limit);
1326
1363
  const etag = `"s${crypto.createHash('sha1').update(JSON.stringify(sessions)).digest('base64url').slice(0, 20)}"`;
1327
1364
  res.setHeader('ETag', etag);
1328
- res.setHeader('Cache-Control', 'private, max-age=15');
1365
+ res.setHeader('Cache-Control', 'private, no-store, no-cache');
1329
1366
  if (req.headers['if-none-match'] === etag) return res.status(304).end();
1330
1367
  res.json(sessions);
1331
1368
  } catch (e) {
@@ -2132,10 +2169,11 @@ export function createApp() {
2132
2169
  const shellCmd = cmd.command || '';
2133
2170
  const cwd = cmd.cwd || os.homedir();
2134
2171
  wireSend({ type: 'shell-start', command: shellCmd });
2135
- shellProc = spawn(process.env.SHELL || 'bash', ['-c', shellCmd], {
2172
+ const spec = shellExecSpec(shellCmd);
2173
+ shellProc = spawn(spec.bin, spec.args, {
2136
2174
  cwd,
2137
2175
  env: { ...process.env },
2138
- shell: false,
2176
+ ...spawnCommandOpts({ extra: { shell: false } }),
2139
2177
  });
2140
2178
  shellOut = createTextBatcher(
2141
2179
  (data) => wireSend({ type: 'shell-output', stream: 'stdout', data }),
@@ -2194,17 +2232,21 @@ export function createApp() {
2194
2232
 
2195
2233
  let proc;
2196
2234
  let hasPython = false;
2197
- try {
2198
- execSync('python3 --version', { stdio: 'ignore' });
2199
- hasPython = true;
2200
- } catch {}
2235
+ const win = process.platform === 'win32';
2236
+ if (!win) {
2237
+ try {
2238
+ execSync('python3 --version', { stdio: 'ignore' });
2239
+ hasPython = true;
2240
+ } catch {}
2241
+ }
2201
2242
 
2202
2243
  const cols = parseInt(url.searchParams.get('cols') || '80', 10);
2203
2244
  const rows = parseInt(url.searchParams.get('rows') || '24', 10);
2204
2245
 
2205
2246
  try {
2206
- const shellBin = process.env.SHELL || 'zsh';
2207
- if (hasPython) {
2247
+ const interactive = defaultInteractiveShell();
2248
+ const shellBin = interactive.bin;
2249
+ if (hasPython && !win) {
2208
2250
  const pythonScript = `
2209
2251
  import os, pty, sys, termios, fcntl, struct, select
2210
2252
  shell = '${shellBin}'
@@ -2245,14 +2287,16 @@ else:
2245
2287
  stdio: ['pipe', 'pipe', 'pipe'],
2246
2288
  });
2247
2289
  } else {
2248
- const args = ['-i'];
2249
- if (shellBin.includes('bash')) {
2290
+ const args = [...interactive.args];
2291
+ if (!win && !args.length) args.push('-i');
2292
+ if (!win && shellBin.includes('bash')) {
2250
2293
  args.push('--norc', '--noprofile');
2251
2294
  }
2252
2295
  proc = spawn(shellBin, args, {
2253
2296
  cwd: fsSync.existsSync(cwd) ? cwd : os.homedir(),
2254
2297
  env: { ...process.env, TERM: 'xterm-256color', PS1: '\\[\\033[32m\\]\\w\\[\\033[0m\\]$ ', HISTFILE: '' },
2255
2298
  stdio: ['pipe', 'pipe', 'pipe'],
2299
+ ...spawnCommandOpts({ extra: { shell: false } }),
2256
2300
  });
2257
2301
  }
2258
2302
  } catch (e) {
@@ -2279,7 +2323,7 @@ else:
2279
2323
  }
2280
2324
  });
2281
2325
 
2282
- ws.on('close', () => { outBatch.flush(); try { proc.kill('SIGHUP'); } catch {} });
2326
+ ws.on('close', () => { outBatch.flush(); try { proc.kill(processKillSignal()); } catch {} });
2283
2327
  ws.on('error', e => console.error('[ws/shell]', e.message));
2284
2328
  });
2285
2329
 
@@ -80,6 +80,8 @@ function runNpmInstall(mode, pkgName, version, cwd) {
80
80
  cwd: mode === 'global' ? osHomedir() : cwd,
81
81
  env: process.env,
82
82
  stdio: ['ignore', 'pipe', 'pipe'],
83
+ shell: process.platform === 'win32',
84
+ windowsHide: process.platform === 'win32',
83
85
  });
84
86
  let out = '';
85
87
  let err = '';
package/src/collab.js CHANGED
@@ -250,6 +250,8 @@ export function proxyToLocalPort({ host, port, reqPath, method, headers, body, a
250
250
  }, (res) => {
251
251
  const chunks = [];
252
252
  res.on('data', c => chunks.push(c));
253
+ res.on('error', reject);
254
+ res.on('aborted', () => reject(new Error('upstream response aborted')));
253
255
  res.on('end', () => resolve({
254
256
  status: res.statusCode,
255
257
  headers: res.headers,
@@ -257,6 +259,7 @@ export function proxyToLocalPort({ host, port, reqPath, method, headers, body, a
257
259
  }));
258
260
  });
259
261
  r.on('error', reject);
262
+ r.on('timeout', () => r.destroy(new Error('upstream request timed out')));
260
263
  if (body && body.length) r.write(body);
261
264
  r.end();
262
265
  });