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.
- package/README.md +17 -1
- package/bin/edge-ctl.js +151 -4
- package/client.js +8 -2
- package/dist/addon-fit.min.js +0 -0
- package/dist/app.0.2.31.min.js +1308 -0
- package/dist/index.html +2 -2
- package/dist/style.0.2.31.min.css +2012 -0
- package/dist/xterm.min.css +0 -0
- package/dist/xterm.min.js +0 -0
- package/gateway.js +0 -0
- package/package.json +2 -2
- package/public/addon-fit.min.js +0 -0
- package/public/agent-panel.js +47 -20
- package/public/api.js +1 -1
- package/public/app.js +5 -5
- package/public/base.css +0 -0
- package/public/cache.js +19 -0
- package/public/chat.js +80 -19
- package/public/files.js +26 -10
- package/public/git.js +2 -2
- package/public/icons/icon-180.png +0 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/icon-maskable-512.png +0 -0
- package/public/index.html +2 -2
- package/public/lib.js +0 -0
- package/public/manifest.webmanifest +0 -0
- package/public/markdown.js +16 -0
- package/public/math.js +145 -7
- package/public/mobile.js +0 -0
- package/public/notifications.js +0 -0
- package/public/offline.html +0 -0
- package/public/pwa.js +1 -1
- package/public/router.js +0 -0
- package/public/scroll-follow.js +78 -0
- package/public/settings.js +41 -0
- package/public/shell/boot.js +0 -0
- package/public/shell/dashboard.js +0 -0
- package/public/shell/hub.js +0 -0
- package/public/shell/layout.js +0 -0
- package/public/shell/notes.js +0 -0
- package/public/shell/session-context.js +0 -0
- package/public/shell/session-list.js +49 -16
- package/public/shell/session-nav.js +66 -18
- package/public/shell/templates.js +0 -0
- package/public/shell/util.js +0 -0
- package/public/shell.js +0 -0
- package/public/state.js +7 -7
- package/public/style.css +5 -0
- package/public/sw.js +10 -5
- package/public/terminal.js +0 -0
- package/public/wake-lock.js +0 -0
- package/public/workspace-path.js +61 -0
- package/public/xterm.min.css +0 -0
- package/public/xterm.min.js +0 -0
- package/src/agent-quota.js +228 -0
- package/src/agents/claude.js +0 -0
- package/src/agents/codex.js +2 -0
- package/src/agents/cursor.js +2 -1
- package/src/agents/gemini.js +2 -1
- package/src/agents/grok.js +6 -1
- package/src/agents/meta-agent.js +7 -4
- package/src/agents/models.js +21 -12
- package/src/agents/sessions.js +133 -69
- package/src/agents/stream-batch.js +0 -0
- package/src/app.js +80 -36
- package/src/auto-update.js +2 -0
- package/src/collab.js +3 -0
- package/src/platform.js +133 -0
- package/src/project-api-path.js +14 -0
- package/src/runtime/approvals.js +0 -0
- package/src/runtime/chat-broadcast.js +12 -0
- package/src/runtime/outbox.js +0 -0
- package/src/runtime/port-websocket.js +80 -0
- package/src/runtime/protocol.js +0 -0
- package/dist/app.0.2.28.min.js +0 -1220
- package/dist/style.0.2.28.min.css +0 -2007
package/src/agents/sessions.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
67
|
-
for (const dir of
|
|
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
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 (!
|
|
312
|
+
if (!containedPath(base, resolved)) throw new Error('Path traversal');
|
|
290
313
|
return resolved;
|
|
291
314
|
}
|
|
292
315
|
|
|
293
316
|
function expandPath(p) {
|
|
294
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
341
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
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
|
|
2207
|
-
|
|
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 = [
|
|
2249
|
-
if (
|
|
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(
|
|
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
|
|
package/src/auto-update.js
CHANGED
|
@@ -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
|
});
|