claude-simple 0.2.27 → 0.2.30

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 (79) hide show
  1. package/README.md +17 -1
  2. package/bin/edge-ctl.js +151 -4
  3. package/client.js +29 -4
  4. package/dist/addon-fit.min.js +0 -0
  5. package/dist/app.0.2.30.min.js +1308 -0
  6. package/dist/index.html +2 -2
  7. package/dist/style.0.2.30.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 +54 -22
  14. package/public/api.js +8 -5
  15. package/public/app.js +7 -5
  16. package/public/base.css +0 -0
  17. package/public/cache.js +19 -0
  18. package/public/chat.js +110 -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/notes.js +316 -0
  32. package/public/notifications.js +0 -0
  33. package/public/offline.html +0 -0
  34. package/public/pwa.js +1 -1
  35. package/public/router.js +4 -0
  36. package/public/scroll-follow.js +78 -0
  37. package/public/settings.js +41 -0
  38. package/public/shell/boot.js +0 -0
  39. package/public/shell/dashboard.js +0 -0
  40. package/public/shell/hub.js +0 -0
  41. package/public/shell/layout.js +0 -0
  42. package/public/shell/notes.js +0 -0
  43. package/public/shell/session-context.js +0 -0
  44. package/public/shell/session-list.js +79 -21
  45. package/public/shell/session-nav.js +70 -21
  46. package/public/shell/templates.js +3 -0
  47. package/public/shell/util.js +0 -0
  48. package/public/shell.js +15 -2
  49. package/public/state.js +7 -7
  50. package/public/style.css +161 -0
  51. package/public/sw.js +10 -5
  52. package/public/terminal.js +0 -0
  53. package/public/wake-lock.js +0 -0
  54. package/public/workspace-path.js +61 -0
  55. package/public/xterm.min.css +0 -0
  56. package/public/xterm.min.js +0 -0
  57. package/src/agent-quota.js +228 -0
  58. package/src/agents/claude.js +0 -0
  59. package/src/agents/codex.js +2 -0
  60. package/src/agents/cursor.js +2 -1
  61. package/src/agents/gemini.js +2 -1
  62. package/src/agents/grok.js +22 -1
  63. package/src/agents/meta-agent.js +7 -4
  64. package/src/agents/models.js +21 -12
  65. package/src/agents/sessions.js +270 -81
  66. package/src/agents/stream-batch.js +0 -0
  67. package/src/app.js +111 -51
  68. package/src/auto-update.js +2 -0
  69. package/src/collab.js +3 -0
  70. package/src/platform.js +133 -0
  71. package/src/project-api-path.js +14 -0
  72. package/src/runtime/approvals.js +0 -0
  73. package/src/runtime/chat-broadcast.js +12 -0
  74. package/src/runtime/outbox.js +0 -0
  75. package/src/runtime/port-websocket.js +80 -0
  76. package/src/runtime/protocol.js +0 -0
  77. package/src/session-history.js +31 -0
  78. package/dist/app.0.2.27.min.js +0 -1182
  79. package/dist/style.0.2.27.min.css +0 -1851
package/src/app.js CHANGED
@@ -33,10 +33,13 @@ 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';
42
+ import { parseHistoryQuery, sliceHistoryMessages } from './session-history.js';
40
43
  import {
41
44
  CLAUDE_ENV_KEYS,
42
45
  loadAgentEnvDoc,
@@ -48,6 +51,17 @@ import {
48
51
  normalizeProfileName,
49
52
  envForProfile,
50
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';
51
65
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
52
66
  const rootDir = path.join(__dirname, '..');
53
67
 
@@ -62,18 +76,28 @@ const SESSION_META_FILE = process.env.SESSION_META_FILE || path.join(rootDir,
62
76
  // without requiring the service unit to mirror an interactive shell PATH.
63
77
  function resolveUserLocalCommand(configured, fallback) {
64
78
  const command = configured || fallback;
65
- if (command.includes('/') || command.includes('\\')) return command.replace(/^~/, os.homedir());
66
- 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()) {
67
81
  const candidate = path.join(dir, command);
68
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
+ }
69
88
  }
70
89
  return command;
71
90
  }
72
- const CLAUDE_CLI_PATH = process.env.CLAUDE_CLI_PATH || 'claude';
73
- const CODEX_CLI_PATH = process.env.CODEX_CLI_PATH || 'codex';
74
- const GROK_CLI_PATH = process.env.GROK_CLI_PATH || 'grok';
75
- const GEMINI_CLI_PATH = resolveUserLocalCommand(process.env.GEMINI_CLI_PATH || process.env.AGY_PATH, 'agy');
76
- 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');
77
101
  const CODEX_HOME = (process.env.CODEX_HOME || path.join(os.homedir(), '.codex')).replace(/^~/, os.homedir());
78
102
  const GROK_HOME = (process.env.GROK_HOME || path.join(os.homedir(), '.grok')).replace(/^~/, os.homedir());
79
103
  const GEMINI_HOME = (process.env.GEMINI_HOME || path.join(os.homedir(), '.gemini')).replace(/^~/, os.homedir());
@@ -285,20 +309,20 @@ function authMiddleware(req, res, next) {
285
309
  // ─── Path helpers ─────────────────────────────────────────────────────────────
286
310
  function safePath(base, rel) {
287
311
  const resolved = path.resolve(base, rel || '');
288
- if (!resolved.startsWith(path.resolve(base))) throw new Error('Path traversal');
312
+ if (!containedPath(base, resolved)) throw new Error('Path traversal');
289
313
  return resolved;
290
314
  }
291
315
 
292
316
  function expandPath(p) {
293
- if (!p) return p;
294
- return p.replace(/^~/, os.homedir());
317
+ return expandHome(p);
295
318
  }
296
319
 
297
320
  function resolveProjectRoot(req) {
298
321
  const root = req.query.root || req.body?.root;
299
322
  if (root) {
300
323
  const resolved = path.resolve(expandPath(root));
301
- 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'))
302
326
  throw new Error('Invalid root path');
303
327
  return resolved;
304
328
  }
@@ -308,7 +332,7 @@ function resolveProjectRoot(req) {
308
332
  // ─── Git helpers ──────────────────────────────────────────────────────────────
309
333
  function git(args, cwd) {
310
334
  return new Promise((resolve, reject) => {
311
- const child = spawn('git', args, { cwd, shell: false });
335
+ const child = spawn('git', args, { cwd, ...spawnCommandOpts() });
312
336
  let out = '', err = '';
313
337
  child.stdout.on('data', d => out += d);
314
338
  child.stderr.on('data', d => err += d);
@@ -336,17 +360,8 @@ function sendJson(ws, data) {
336
360
  }
337
361
  }
338
362
 
339
- function broadcastChat(sessionId, data, fallbackWs) {
340
- let n = 0;
341
- if (sessionId) {
342
- for (const c of chatClients) {
343
- if (c.sessionId === sessionId) {
344
- sendJson(c.ws, data);
345
- n++;
346
- }
347
- }
348
- }
349
- if (n === 0 && fallbackWs) sendJson(fallbackWs, data);
363
+ function broadcastChat(sessionId, data, ownerWs) {
364
+ broadcastChatFrame(chatClients, sessionId, data, ownerWs, sendJson);
350
365
  }
351
366
 
352
367
  async function listSessions(filterCwd = null, agentFilter = null) {
@@ -505,7 +520,15 @@ function compactHistoryMessages(messages) {
505
520
 
506
521
  // ─── App factory ──────────────────────────────────────────────────────────────
507
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 });
508
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
+ });
509
532
  const COLLAB_FILE = process.env.COLLAB_FILE || path.join(rootDir, '.collab.json');
510
533
  const collab = createCollab({ filePath: COLLAB_FILE });
511
534
  const allowRemoteProxy = process.env.PORT_PROXY_ALLOW_REMOTE === '1';
@@ -626,7 +649,12 @@ export function createApp() {
626
649
  })().catch(e => res.status(500).json({ error: e.message }));
627
650
  });
628
651
 
629
- 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
+ });
630
658
 
631
659
  // MCP uses a separate short-lived bearer token. Normal UI JWTs deliberately
632
660
  // cannot call /mcp, so a browser token leaked through a URL cannot become an
@@ -988,6 +1016,14 @@ export function createApp() {
988
1016
  }
989
1017
  });
990
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
+
991
1027
  app.get('/api/projects/:id/files', authMiddleware, async (req, res) => {
992
1028
  try {
993
1029
  const projectPath = resolveProjectRoot(req);
@@ -1321,10 +1357,12 @@ export function createApp() {
1321
1357
  const since = Date.now() - days * 86400000;
1322
1358
  sessions = sessions.filter(s => (s.updatedAt || 0) >= since);
1323
1359
  }
1360
+ const offset = Math.max(0, parseInt(req.query.offset || '0', 10) || 0);
1361
+ sessions = sessions.slice(offset);
1324
1362
  if (limit > 0 && sessions.length > limit) sessions = sessions.slice(0, limit);
1325
1363
  const etag = `"s${crypto.createHash('sha1').update(JSON.stringify(sessions)).digest('base64url').slice(0, 20)}"`;
1326
1364
  res.setHeader('ETag', etag);
1327
- res.setHeader('Cache-Control', 'private, max-age=15');
1365
+ res.setHeader('Cache-Control', 'private, no-store, no-cache');
1328
1366
  if (req.headers['if-none-match'] === etag) return res.status(304).end();
1329
1367
  res.json(sessions);
1330
1368
  } catch (e) {
@@ -1376,11 +1414,9 @@ export function createApp() {
1376
1414
  const { sessionId } = req.params;
1377
1415
  if (!/^[0-9a-f-]{36}$/i.test(sessionId)) return res.status(400).json({ error: 'Invalid session id' });
1378
1416
  const preferred = typeof req.query.agent === 'string' ? req.query.agent : null;
1379
- // tail=0 → full history (default). Positive = last N messages only.
1380
- let tail = parseInt(req.query.tail ?? '0', 10);
1381
- if (Number.isNaN(tail)) tail = 0;
1382
- if (tail < 0) tail = 0;
1383
- if (tail > 50_000) tail = 50_000;
1417
+ // tail=0 → full history (default). Positive = last N. before = exclusive
1418
+ // end index for older pages (scroll-up).
1419
+ const { tail, before } = parseHistoryQuery(req.query);
1384
1420
  const compact = req.query.compact !== '0' && req.query.compact !== 'false';
1385
1421
  const opts = {
1386
1422
  claudeConfigDirs: CLAUDE_CONFIG_DIRS,
@@ -1403,20 +1439,16 @@ export function createApp() {
1403
1439
  let messages = Array.isArray(bundle)
1404
1440
  ? bundle
1405
1441
  : (bundle.messages || []);
1406
- const total = messages.length;
1407
- let truncated = false;
1408
- if (tail > 0 && messages.length > tail) {
1409
- messages = messages.slice(-tail);
1410
- truncated = true;
1411
- }
1412
- if (compact) messages = compactHistoryMessages(messages);
1442
+ const page = sliceHistoryMessages(messages, { tail, before });
1443
+ messages = compact ? compactHistoryMessages(page.messages) : page.messages;
1413
1444
  const payload = {
1414
1445
  messages,
1415
1446
  context: Array.isArray(bundle) ? null : (bundle.context || null),
1416
1447
  agent: Array.isArray(bundle) ? (preferred || 'claude') : (bundle.agent || preferred || 'claude'),
1417
- total,
1418
- truncated,
1419
- tail: truncated ? tail : total,
1448
+ total: page.total,
1449
+ truncated: page.truncated,
1450
+ oldestIndex: page.oldestIndex,
1451
+ tail: page.tail,
1420
1452
  compact,
1421
1453
  };
1422
1454
  res.setHeader('X-Session-Agent', payload.agent);
@@ -1552,6 +1584,27 @@ export function createApp() {
1552
1584
  }
1553
1585
  });
1554
1586
 
1587
+ app.get('/api/sessions/:sessionId/jsonl', authMiddleware, async (req, res) => {
1588
+ try {
1589
+ const { sessionId } = req.params;
1590
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
1591
+ return res.status(400).json({ error: 'Invalid session id' });
1592
+ }
1593
+ const agent = typeof req.query.agent === 'string' ? req.query.agent : null;
1594
+ if (agent && !['claude', 'codex', 'grok', 'gemini', 'cursor'].includes(agent)) {
1595
+ return res.status(400).json({ error: 'Invalid agent' });
1596
+ }
1597
+ const result = await loadSessionJsonl(sessionId, agent, {
1598
+ claudeConfigDirs: CLAUDE_CONFIG_DIRS, codexHome: CODEX_HOME,
1599
+ grokHome: GROK_HOME, geminiHome: GEMINI_HOME, cursorHome: CURSOR_HOME,
1600
+ }, { offset: req.query.offset, maxLines: req.query.limit,
1601
+ tail: req.query.tail === '1', maxChars: req.query.maxChars });
1602
+ if (!result) return res.status(404).json({ error: 'Session JSONL not found' });
1603
+ res.setHeader('Cache-Control', 'no-store');
1604
+ res.json({ ...result, sessionId, running: isSessionBusy(sessionId) });
1605
+ } catch (e) { res.status(500).json({ error: e.message }); }
1606
+ });
1607
+
1555
1608
  app.get('/api/sessions/:sessionId/memory/file', authMiddleware, async (req, res) => {
1556
1609
  try {
1557
1610
  const { sessionId } = req.params;
@@ -2116,10 +2169,11 @@ export function createApp() {
2116
2169
  const shellCmd = cmd.command || '';
2117
2170
  const cwd = cmd.cwd || os.homedir();
2118
2171
  wireSend({ type: 'shell-start', command: shellCmd });
2119
- shellProc = spawn(process.env.SHELL || 'bash', ['-c', shellCmd], {
2172
+ const spec = shellExecSpec(shellCmd);
2173
+ shellProc = spawn(spec.bin, spec.args, {
2120
2174
  cwd,
2121
2175
  env: { ...process.env },
2122
- shell: false,
2176
+ ...spawnCommandOpts({ extra: { shell: false } }),
2123
2177
  });
2124
2178
  shellOut = createTextBatcher(
2125
2179
  (data) => wireSend({ type: 'shell-output', stream: 'stdout', data }),
@@ -2178,17 +2232,21 @@ export function createApp() {
2178
2232
 
2179
2233
  let proc;
2180
2234
  let hasPython = false;
2181
- try {
2182
- execSync('python3 --version', { stdio: 'ignore' });
2183
- hasPython = true;
2184
- } catch {}
2235
+ const win = process.platform === 'win32';
2236
+ if (!win) {
2237
+ try {
2238
+ execSync('python3 --version', { stdio: 'ignore' });
2239
+ hasPython = true;
2240
+ } catch {}
2241
+ }
2185
2242
 
2186
2243
  const cols = parseInt(url.searchParams.get('cols') || '80', 10);
2187
2244
  const rows = parseInt(url.searchParams.get('rows') || '24', 10);
2188
2245
 
2189
2246
  try {
2190
- const shellBin = process.env.SHELL || 'zsh';
2191
- if (hasPython) {
2247
+ const interactive = defaultInteractiveShell();
2248
+ const shellBin = interactive.bin;
2249
+ if (hasPython && !win) {
2192
2250
  const pythonScript = `
2193
2251
  import os, pty, sys, termios, fcntl, struct, select
2194
2252
  shell = '${shellBin}'
@@ -2229,14 +2287,16 @@ else:
2229
2287
  stdio: ['pipe', 'pipe', 'pipe'],
2230
2288
  });
2231
2289
  } else {
2232
- const args = ['-i'];
2233
- if (shellBin.includes('bash')) {
2290
+ const args = [...interactive.args];
2291
+ if (!win && !args.length) args.push('-i');
2292
+ if (!win && shellBin.includes('bash')) {
2234
2293
  args.push('--norc', '--noprofile');
2235
2294
  }
2236
2295
  proc = spawn(shellBin, args, {
2237
2296
  cwd: fsSync.existsSync(cwd) ? cwd : os.homedir(),
2238
2297
  env: { ...process.env, TERM: 'xterm-256color', PS1: '\\[\\033[32m\\]\\w\\[\\033[0m\\]$ ', HISTFILE: '' },
2239
2298
  stdio: ['pipe', 'pipe', 'pipe'],
2299
+ ...spawnCommandOpts({ extra: { shell: false } }),
2240
2300
  });
2241
2301
  }
2242
2302
  } catch (e) {
@@ -2263,7 +2323,7 @@ else:
2263
2323
  }
2264
2324
  });
2265
2325
 
2266
- ws.on('close', () => { outBatch.flush(); try { proc.kill('SIGHUP'); } catch {} });
2326
+ ws.on('close', () => { outBatch.flush(); try { proc.kill(processKillSignal()); } catch {} });
2267
2327
  ws.on('error', e => console.error('[ws/shell]', e.message));
2268
2328
  });
2269
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
  });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * OS helpers so Edge can run on Windows as well as macOS/Linux.
3
+ * Unix remains the default; Windows gets cmd/PowerShell, .cmd spawn, and
4
+ * case-insensitive path checks.
5
+ */
6
+ import path from 'path';
7
+ import os from 'os';
8
+ import fs from 'fs';
9
+
10
+ export function isWin(platform = process.platform) {
11
+ return platform === 'win32';
12
+ }
13
+
14
+ export function expandHome(p, home = os.homedir()) {
15
+ if (!p) return p;
16
+ if (p === '~') return home;
17
+ if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(home, p.slice(2));
18
+ return p;
19
+ }
20
+
21
+ function normalizeForCompare(p, platform) {
22
+ let s = String(p);
23
+ if (isWin(platform)) s = s.replace(/[/\\]+/g, '\\').replace(/\\+$/, '');
24
+ else s = path.normalize(s);
25
+ return isWin(platform) ? s.toLowerCase() : s;
26
+ }
27
+
28
+ export function pathsEqual(a, b, platform = process.platform) {
29
+ if (a == null || b == null) return a === b;
30
+ return normalizeForCompare(a, platform) === normalizeForCompare(b, platform);
31
+ }
32
+
33
+ /** True if `resolved` is `base` or a descendant (not a sibling prefix). */
34
+ export function containedPath(base, resolved, platform = process.platform) {
35
+ const b = path.resolve(base);
36
+ const r = path.resolve(resolved);
37
+ const rel = path.relative(b, r);
38
+ if (!rel) return true;
39
+ if (path.isAbsolute(rel)) return false;
40
+ const first = rel.split(/[/\\]/)[0];
41
+ if (first === '..') return false;
42
+ if (isWin(platform) && rel.toLowerCase().startsWith('..')) return false;
43
+ return true;
44
+ }
45
+
46
+ export function defaultInteractiveShell({ platform = process.platform, env = process.env } = {}) {
47
+ if (isWin(platform)) {
48
+ const comspec = env.ComSpec || env.COMSPEC;
49
+ const shellEnv = env.SHELL || '';
50
+ // Git Bash sets SHELL=...\bash.exe; Edge still wants a native Windows shell.
51
+ if (shellEnv && /\.(exe|cmd|bat)$/i.test(shellEnv) && !/(^|[\\/])(bash|zsh|sh|dash|fish)(\.exe)?$/i.test(shellEnv)) {
52
+ return { bin: shellEnv, args: [] };
53
+ }
54
+ const sysroot = env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows';
55
+ const ps = path.join(sysroot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
56
+ if (fs.existsSync(ps)) {
57
+ return { bin: ps, args: ['-NoLogo', '-NoExit'] };
58
+ }
59
+ return { bin: comspec || path.join(sysroot, 'System32', 'cmd.exe'), args: [] };
60
+ }
61
+ return { bin: env.SHELL || 'zsh', args: ['-i'] };
62
+ }
63
+
64
+ export function shellExecSpec(command, { platform = process.platform, env = process.env } = {}) {
65
+ if (isWin(platform)) {
66
+ const sysroot = env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows';
67
+ const cmd = env.ComSpec || env.COMSPEC || path.join(sysroot, 'System32', 'cmd.exe');
68
+ return { bin: cmd, args: ['/d', '/s', '/c', command] };
69
+ }
70
+ const bin = env.SHELL || 'bash';
71
+ return { bin, args: ['-c', command] };
72
+ }
73
+
74
+ export function spawnCommandOpts({ platform = process.platform, extra = {} } = {}) {
75
+ if (isWin(platform)) {
76
+ return { shell: true, windowsHide: true, ...extra };
77
+ }
78
+ return { shell: false, ...extra };
79
+ }
80
+
81
+ export function processKillSignal(platform = process.platform) {
82
+ return isWin(platform) ? 'SIGTERM' : 'SIGHUP';
83
+ }
84
+
85
+ export function userLocalBinDirs(home = os.homedir(), platform = process.platform) {
86
+ const dirs = [
87
+ path.join(home, '.local', 'bin'),
88
+ path.join(home, 'bin'),
89
+ path.join(home, '.grok', 'bin'),
90
+ ];
91
+ if (isWin(platform)) {
92
+ dirs.push(
93
+ path.join(home, 'AppData', 'Roaming', 'npm'),
94
+ path.join(home, 'AppData', 'Local', 'npm'),
95
+ );
96
+ }
97
+ return dirs;
98
+ }
99
+
100
+ /**
101
+ * Resolve a bare command to an absolute .exe/.cmd on Windows so agents can be
102
+ * spawned with stdio pipes and shell:false. Unix returns the name unchanged.
103
+ */
104
+ export function resolveWinExecutable(command, {
105
+ platform = process.platform,
106
+ env = process.env,
107
+ extraDirs = [],
108
+ exists = fs.existsSync,
109
+ } = {}) {
110
+ if (!command) return command;
111
+ if (!isWin(platform)) return command;
112
+ if (command.includes('/') || command.includes('\\') || /\.[a-z0-9]+$/i.test(command)) {
113
+ return expandHome(command, env.USERPROFILE || env.HOME);
114
+ }
115
+ const exts = String(env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
116
+ .split(';')
117
+ .map(e => e.trim())
118
+ .filter(Boolean);
119
+ const pathDirs = String(env.PATH || '').split(';').map(d => d.trim()).filter(Boolean);
120
+ const dirs = [...extraDirs, ...pathDirs];
121
+ for (const dir of dirs) {
122
+ for (const ext of ['', ...exts]) {
123
+ const candidate = path.join(dir, command + ext);
124
+ if (exists(candidate)) return candidate;
125
+ }
126
+ }
127
+ return command;
128
+ }
129
+
130
+ export function spawnAgentOpts({ platform = process.platform } = {}) {
131
+ if (isWin(platform)) return { shell: false, windowsHide: true };
132
+ return { shell: false };
133
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Files/git APIs take the real directory in ?root=. The :id path segment is
3
+ * unused. Clients that put a Windows cwd (C:\\Users\\... or C:/Users/...) into
4
+ * :id make extra slashes; Hub/Express then 404. Collapse those to `_`.
5
+ */
6
+ export function canonicalizeProjectApiPath(pathname) {
7
+ const m = String(pathname || '').match(
8
+ /^(\/api\/projects\/)(.+)\/(files|file|git\/(?:status|diff|log))$/,
9
+ );
10
+ if (!m) return pathname;
11
+ const id = m[2];
12
+ if (!/[\\/:]/.test(id)) return pathname;
13
+ return `${m[1]}_/${m[3]}`;
14
+ }
File without changes
@@ -0,0 +1,12 @@
1
+ // The run owner (including a Hub task tunnel) must receive every frame even
2
+ // when it has not subscribed to the newly assigned native session ID.
3
+ export function broadcastChatFrame(clients, sessionId, data, ownerWs, send) {
4
+ const recipients = new Set();
5
+ if (ownerWs) recipients.add(ownerWs);
6
+ if (sessionId) {
7
+ for (const client of clients) {
8
+ if (client.sessionId === sessionId) recipients.add(client.ws);
9
+ }
10
+ }
11
+ for (const ws of recipients) send(ws, data);
12
+ }
File without changes
@@ -0,0 +1,80 @@
1
+ import { WebSocket } from 'ws';
2
+ import { isLoopbackHost } from '../collab.js';
3
+
4
+ const MAX_FRAME = 16 * 1024 * 1024;
5
+ const validClose = code => code === 1000 || code === 1001 || code === 1002 || code === 1003 ||
6
+ (code >= 1007 && code <= 1014) || (code >= 3000 && code <= 4999);
7
+
8
+ // Separate from agent tunnels: arbitrary application frames must remain opaque.
9
+ export function createPortWebSocketRelay(send, { allowRemote = false } = {}) {
10
+ const sockets = new Map();
11
+ function handle(msg) {
12
+ if (!msg.type?.startsWith('port-ws-')) return false;
13
+ const id = msg.tunnelId;
14
+ if (msg.type === 'port-ws-open') {
15
+ if (sockets.has(id)) return true;
16
+ try {
17
+ const params = new URLSearchParams(msg.query);
18
+ const host = params.get('host') || '127.0.0.1';
19
+ const port = Number(params.get('port'));
20
+ if ((!allowRemote && !isLoopbackHost(host)) || !Number.isInteger(port) || port < 1 || port > 65535) throw new Error('invalid target');
21
+ const url = new URL(`ws://${host.includes(':') ? `[${host}]` : host}:${port}`);
22
+ url.pathname = params.get('path') || '/';
23
+ url.search = params.get('q') || '';
24
+ const headers = {};
25
+ for (const name of ['Cookie', 'Origin', 'User-Agent']) {
26
+ if (typeof msg.headers?.[name] === 'string') headers[name] = msg.headers[name];
27
+ }
28
+ const ws = new WebSocket(url, msg.protocols || [], { headers, handshakeTimeout: 15000, maxPayload: MAX_FRAME, followRedirects: false });
29
+ const entry = { ws, timer: null };
30
+ sockets.set(id, entry);
31
+ ws.on('open', () => {
32
+ ws.pause();
33
+ entry.timer = setTimeout(() => ws.terminate(), 15000);
34
+ send({ type: 'port-ws-ready', tunnelId: id, protocol: ws.protocol });
35
+ });
36
+ ws.on('message', (data, binary) => {
37
+ send({ type: 'port-ws-msg', tunnelId: id, frameType: binary ? 2 : 1, data: data.toString('base64') });
38
+ });
39
+ ws.on('close', code => {
40
+ clearTimeout(entry.timer);
41
+ if (sockets.get(id) === entry) sockets.delete(id);
42
+ send({ type: 'port-ws-close', tunnelId: id, closeCode: validClose(code) ? code : 1011 });
43
+ });
44
+ ws.on('error', () => {
45
+ send({ type: 'port-ws-error', tunnelId: id });
46
+ ws.terminate();
47
+ });
48
+ } catch {
49
+ send({ type: 'port-ws-error', tunnelId: id });
50
+ }
51
+ return true;
52
+ }
53
+ const entry = sockets.get(id);
54
+ if (!entry) return true;
55
+ if (msg.type === 'port-ws-start') {
56
+ clearTimeout(entry.timer);
57
+ entry.ws.resume();
58
+ } else if (msg.type === 'port-ws-msg' && entry.ws.readyState === WebSocket.OPEN) {
59
+ if (![1, 2].includes(msg.frameType) || typeof msg.data !== 'string' || msg.data.length > MAX_FRAME * 4 / 3 + 4) {
60
+ entry.ws.terminate();
61
+ } else if (entry.ws.bufferedAmount > MAX_FRAME) {
62
+ entry.ws.close(1013, 'backpressure');
63
+ } else {
64
+ entry.ws.send(Buffer.from(msg.data, 'base64'), { binary: msg.frameType === 2 });
65
+ }
66
+ } else if (msg.type === 'port-ws-close') {
67
+ clearTimeout(entry.timer);
68
+ if (entry.ws.readyState === WebSocket.OPEN) entry.ws.close(validClose(msg.closeCode) ? msg.closeCode : 1000);
69
+ else entry.ws.terminate();
70
+ }
71
+ return true;
72
+ }
73
+ return {
74
+ handle,
75
+ closeAll() {
76
+ for (const { ws, timer } of sockets.values()) { clearTimeout(timer); ws.terminate(); }
77
+ sockets.clear();
78
+ },
79
+ };
80
+ }
File without changes
@@ -0,0 +1,31 @@
1
+ /** Slice a loaded transcript for mobile/hub transfers. */
2
+
3
+ export function parseHistoryQuery(query = {}) {
4
+ let tail = parseInt(query.tail ?? '0', 10);
5
+ if (Number.isNaN(tail) || tail < 0) tail = 0;
6
+ if (tail > 50_000) tail = 50_000;
7
+ let before = parseInt(query.before ?? '', 10);
8
+ if (Number.isNaN(before) || before < 0) before = null;
9
+ return { tail, before };
10
+ }
11
+
12
+ /**
13
+ * @param {unknown[]} messages
14
+ * @param {{ tail?: number, before?: number|null }} opts
15
+ */
16
+ export function sliceHistoryMessages(messages, { tail = 0, before = null } = {}) {
17
+ const all = Array.isArray(messages) ? messages : [];
18
+ const total = all.length;
19
+ let end = total;
20
+ if (before != null) end = Math.min(before, total);
21
+ if (end < 0) end = 0;
22
+ let start = 0;
23
+ if (tail > 0 && end - start > tail) start = end - tail;
24
+ return {
25
+ messages: all.slice(start, end),
26
+ total,
27
+ truncated: start > 0,
28
+ oldestIndex: start,
29
+ tail: end - start,
30
+ };
31
+ }