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.
- package/README.md +17 -1
- package/bin/edge-ctl.js +151 -4
- package/client.js +29 -4
- package/dist/addon-fit.min.js +0 -0
- package/dist/app.0.2.30.min.js +1308 -0
- package/dist/index.html +2 -2
- package/dist/style.0.2.30.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 +54 -22
- package/public/api.js +8 -5
- package/public/app.js +7 -5
- package/public/base.css +0 -0
- package/public/cache.js +19 -0
- package/public/chat.js +110 -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/notes.js +316 -0
- package/public/notifications.js +0 -0
- package/public/offline.html +0 -0
- package/public/pwa.js +1 -1
- package/public/router.js +4 -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 +79 -21
- package/public/shell/session-nav.js +70 -21
- package/public/shell/templates.js +3 -0
- package/public/shell/util.js +0 -0
- package/public/shell.js +15 -2
- package/public/state.js +7 -7
- package/public/style.css +161 -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 +22 -1
- package/src/agents/meta-agent.js +7 -4
- package/src/agents/models.js +21 -12
- package/src/agents/sessions.js +270 -81
- package/src/agents/stream-batch.js +0 -0
- package/src/app.js +111 -51
- 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/src/session-history.js +31 -0
- package/dist/app.0.2.27.min.js +0 -1182
- 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
|
|
66
|
-
for (const dir of
|
|
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
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 (!
|
|
312
|
+
if (!containedPath(base, resolved)) throw new Error('Path traversal');
|
|
289
313
|
return resolved;
|
|
290
314
|
}
|
|
291
315
|
|
|
292
316
|
function expandPath(p) {
|
|
293
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
340
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
1380
|
-
|
|
1381
|
-
|
|
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
|
|
1407
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
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
|
|
2191
|
-
|
|
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 = [
|
|
2233
|
-
if (
|
|
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(
|
|
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
|
|
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
|
});
|
package/src/platform.js
ADDED
|
@@ -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
|
+
}
|
package/src/runtime/approvals.js
CHANGED
|
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
|
+
}
|
package/src/runtime/outbox.js
CHANGED
|
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
|
+
}
|
package/src/runtime/protocol.js
CHANGED
|
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
|
+
}
|