agentgui 1.0.1090 → 1.0.1091
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/lib/ws-handlers-util.js +122 -1
- package/package.json +1 -1
- package/site/app/js/app.js +74 -0
- package/site/app/js/backend.js +24 -0
package/lib/ws-handlers-util.js
CHANGED
|
@@ -2,7 +2,7 @@ import fs from 'fs';
|
|
|
2
2
|
import os from 'os';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import crypto from 'crypto';
|
|
5
|
-
import { execSync, spawnSync } from 'child_process';
|
|
5
|
+
import { execSync, spawnSync, execFile } from 'child_process';
|
|
6
6
|
import { runClaudeWithStreaming } from './claude-runner-run.js';
|
|
7
7
|
import { registry } from './claude-runner-agents.js';
|
|
8
8
|
import { confineToRoots, fsAllowRoots } from './http-handler.js';
|
|
@@ -10,6 +10,49 @@ import { restart as restartAcpAgent } from './acp-sdk-manager.js';
|
|
|
10
10
|
|
|
11
11
|
function err(code, message) { const e = new Error(message); e.code = code; throw e; }
|
|
12
12
|
|
|
13
|
+
// Promisified execFile — array-args form only, never a shell string, so a
|
|
14
|
+
// crafted branch/path/file value cannot be interpreted as a shell command.
|
|
15
|
+
function execFileP(cmd, args, opts) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
execFile(cmd, args, { encoding: 'utf-8', timeout: 30000, maxBuffer: 20 * 1024 * 1024, ...opts }, (e, stdout, stderr) => {
|
|
18
|
+
if (e) { e.stdout = stdout; e.stderr = stderr; reject(e); return; }
|
|
19
|
+
resolve({ stdout, stderr });
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Resolve + confine a client-supplied cwd to the same allowlist the file
|
|
25
|
+
// explorer / chat.sendMessage use (fsAllowRoots via confineToRoots). Falls
|
|
26
|
+
// back to STARTUP_CWD (itself an allowed root) when no cwd is given.
|
|
27
|
+
function resolveGitCwd(p, STARTUP_CWD) {
|
|
28
|
+
if (!p?.cwd) return STARTUP_CWD;
|
|
29
|
+
const conf = confineToRoots(p.cwd, fsAllowRoots());
|
|
30
|
+
if (!conf.ok) err(conf.reason === 'not found' ? 400 : 403, `cwd outside allowed roots: ${p.cwd}`);
|
|
31
|
+
return conf.realPath;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A relative in-repo file path: no leading '-' (flag injection), no shell
|
|
35
|
+
// metacharacters, no absolute path (git resolves it relative to cwd already).
|
|
36
|
+
const SAFE_REL_PATH_RE = /^[^\0]{1,4096}$/;
|
|
37
|
+
function assertSafeRelPath(v, label) {
|
|
38
|
+
if (typeof v !== 'string' || !v.length) err(400, `${label} required`);
|
|
39
|
+
if (v.startsWith('-')) err(400, `${label} must not start with '-'`);
|
|
40
|
+
if (/[\0]/.test(v)) err(400, `${label} contains invalid characters`);
|
|
41
|
+
if (!SAFE_REL_PATH_RE.test(v)) err(400, `${label} invalid`);
|
|
42
|
+
return v;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A git branch/ref name — conservative allowlist (git's own ref-format is
|
|
46
|
+
// more permissive, but this blocks every shell-metacharacter and flag-
|
|
47
|
+
// injection vector while still covering normal branch names).
|
|
48
|
+
const SAFE_BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9_.\/-]{0,199}$/;
|
|
49
|
+
function assertSafeBranch(v, label) {
|
|
50
|
+
if (typeof v !== 'string' || !v.length) err(400, `${label} required`);
|
|
51
|
+
if (v.startsWith('-') || v.includes('..') || v.includes('//')) err(400, `${label} invalid`);
|
|
52
|
+
if (!SAFE_BRANCH_RE.test(v)) err(400, `${label} invalid`);
|
|
53
|
+
return v;
|
|
54
|
+
}
|
|
55
|
+
|
|
13
56
|
const SUB_AGENT_MAP = {
|
|
14
57
|
'opencode': [{ id: 'gm-oc', name: 'GM OpenCode' }], 'cli-opencode': [{ id: 'gm-oc', name: 'GM OpenCode' }],
|
|
15
58
|
'gemini': [{ id: 'gm-gc', name: 'GM Gemini' }], 'cli-gemini': [{ id: 'gm-gc', name: 'GM Gemini' }],
|
|
@@ -281,6 +324,84 @@ export function register(router, deps) {
|
|
|
281
324
|
} catch (e) { err(500, e.message); }
|
|
282
325
|
});
|
|
283
326
|
|
|
327
|
+
// --- git.diff: unified diff for a single file or the whole working tree.
|
|
328
|
+
// p.cwd (optional) is confined via confineToRoots/fsAllowRoots, same as
|
|
329
|
+
// chat.sendMessage; p.file (optional) is validated to reject flags/shell
|
|
330
|
+
// metacharacters. execFile array-args form only, never a shell string.
|
|
331
|
+
router.handle('git.diff', async (p) => {
|
|
332
|
+
const cwd = resolveGitCwd(p, STARTUP_CWD);
|
|
333
|
+
const args = ['diff', '--no-color'];
|
|
334
|
+
if (p?.staged) args.push('--staged');
|
|
335
|
+
if (p?.file) args.push('--', assertSafeRelPath(p.file, 'file'));
|
|
336
|
+
try {
|
|
337
|
+
const { stdout } = await execFileP('git', args, { cwd });
|
|
338
|
+
return { diff: stdout, file: p?.file || null, staged: !!p?.staged };
|
|
339
|
+
} catch (e) { err(500, (e.stderr || e.message || 'git diff failed').trim()); }
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// --- git.log: recent commit list, limit param (default 20, capped 200).
|
|
343
|
+
router.handle('git.log', async (p) => {
|
|
344
|
+
const cwd = resolveGitCwd(p, STARTUP_CWD);
|
|
345
|
+
const limit = Math.max(1, Math.min(200, parseInt(p?.limit, 10) || 20));
|
|
346
|
+
const FIELD_SEP = '\x1f';
|
|
347
|
+
const REC_SEP = '\x1e';
|
|
348
|
+
const args = ['log', `-n${limit}`, `--pretty=format:%H${FIELD_SEP}%an${FIELD_SEP}%ad${FIELD_SEP}%s${REC_SEP}`, '--date=iso-strict'];
|
|
349
|
+
try {
|
|
350
|
+
const { stdout } = await execFileP('git', args, { cwd });
|
|
351
|
+
const commits = stdout.split(REC_SEP).map(s => s.trim()).filter(Boolean).map(rec => {
|
|
352
|
+
const [hash, author, date, ...rest] = rec.split(FIELD_SEP);
|
|
353
|
+
return { hash, author, date, subject: rest.join(FIELD_SEP) };
|
|
354
|
+
});
|
|
355
|
+
return { commits };
|
|
356
|
+
} catch (e) { err(500, (e.stderr || e.message || 'git log failed').trim()); }
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// --- worktree.list / create / remove ---
|
|
360
|
+
router.handle('worktree.list', async (p) => {
|
|
361
|
+
const cwd = resolveGitCwd(p, STARTUP_CWD);
|
|
362
|
+
try {
|
|
363
|
+
const { stdout } = await execFileP('git', ['worktree', 'list', '--porcelain'], { cwd });
|
|
364
|
+
// Parse the porcelain block format: records separated by blank lines,
|
|
365
|
+
// each line "key value" or a bare flag ("bare", "detached", "locked").
|
|
366
|
+
const worktrees = [];
|
|
367
|
+
let cur = null;
|
|
368
|
+
for (const line of stdout.split('\n')) {
|
|
369
|
+
if (!line.trim()) { if (cur) { worktrees.push(cur); cur = null; } continue; }
|
|
370
|
+
if (!cur) cur = {};
|
|
371
|
+
const sp = line.indexOf(' ');
|
|
372
|
+
if (sp === -1) { cur[line] = true; continue; }
|
|
373
|
+
cur[line.slice(0, sp)] = line.slice(sp + 1);
|
|
374
|
+
}
|
|
375
|
+
if (cur) worktrees.push(cur);
|
|
376
|
+
return { worktrees: worktrees.map(w => ({ path: w.worktree || null, head: w.HEAD || null, branch: w.branch || null, bare: !!w.bare, detached: !!w.detached, locked: !!w.locked })) };
|
|
377
|
+
} catch (e) { err(500, (e.stderr || e.message || 'git worktree list failed').trim()); }
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
router.handle('worktree.create', async (p) => {
|
|
381
|
+
const cwd = resolveGitCwd(p, STARTUP_CWD);
|
|
382
|
+
const wtPath = assertSafeRelPath(p?.path, 'path');
|
|
383
|
+
const args = ['worktree', 'add'];
|
|
384
|
+
if (p?.newBranch) { args.push('-b', assertSafeBranch(p.newBranch, 'newBranch')); }
|
|
385
|
+
args.push(wtPath);
|
|
386
|
+
if (p?.branch) args.push(assertSafeBranch(p.branch, 'branch'));
|
|
387
|
+
try {
|
|
388
|
+
const { stdout, stderr } = await execFileP('git', args, { cwd });
|
|
389
|
+
return { ok: true, output: (stdout || stderr || '').trim(), path: wtPath };
|
|
390
|
+
} catch (e) { err(500, (e.stderr || e.message || 'git worktree add failed').trim()); }
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
router.handle('worktree.remove', async (p) => {
|
|
394
|
+
const cwd = resolveGitCwd(p, STARTUP_CWD);
|
|
395
|
+
const wtPath = assertSafeRelPath(p?.path, 'path');
|
|
396
|
+
const args = ['worktree', 'remove'];
|
|
397
|
+
if (p?.force) args.push('--force');
|
|
398
|
+
args.push(wtPath);
|
|
399
|
+
try {
|
|
400
|
+
const { stdout, stderr } = await execFileP('git', args, { cwd });
|
|
401
|
+
return { ok: true, output: (stdout || stderr || '').trim() };
|
|
402
|
+
} catch (e) { err(500, (e.stderr || e.message || 'git worktree remove failed').trim()); }
|
|
403
|
+
});
|
|
404
|
+
|
|
284
405
|
router.handle('auth.configs', () => getProviderConfigs());
|
|
285
406
|
|
|
286
407
|
router.handle('auth.save', (p) => {
|
package/package.json
CHANGED
package/site/app/js/app.js
CHANGED
|
@@ -43,6 +43,10 @@ const state = {
|
|
|
43
43
|
activeTimer: null,
|
|
44
44
|
eventsLimit: 300, // how many of the most-recent events to render; grows via "load older"
|
|
45
45
|
files: { path: '', segments: [], entries: [], roots: [], loading: false, error: null, preview: null, sort: 'name', sortDir: 'asc', filter: '' },
|
|
46
|
+
// Minimal git/worktree panel state. The richer visual component
|
|
47
|
+
// (GitDiffView/WorktreeSwitcher) lives in anentrypoint-design; this is a
|
|
48
|
+
// plain list/text render until the vendored SDK bundle picks those up.
|
|
49
|
+
git: { loading: false, error: null, diff: '', commits: [], worktrees: [], file: '' },
|
|
46
50
|
};
|
|
47
51
|
|
|
48
52
|
// Two-step arm controls auto-reset after this delay so an accidental first click
|
|
@@ -641,6 +645,8 @@ function view() {
|
|
|
641
645
|
crumbLeaf = 'files';
|
|
642
646
|
} else if (state.tab === 'live') {
|
|
643
647
|
crumbLeaf = 'live · ' + ((state.active && state.active.length) || 0);
|
|
648
|
+
} else if (state.tab === 'git') {
|
|
649
|
+
crumbLeaf = 'git';
|
|
644
650
|
} else if (state.tab === 'settings') {
|
|
645
651
|
// Same word as the rail item - location chrome must not fork vocabulary.
|
|
646
652
|
crumbLeaf = 'settings';
|
|
@@ -725,6 +731,7 @@ function workspaceRail() {
|
|
|
725
731
|
{ key: 'history', label: 'History', icon: 'thread', active: state.tab === 'history', onClick: () => navTo('history') },
|
|
726
732
|
{ key: 'files', label: 'Files', icon: 'folder', active: state.tab === 'files', onClick: () => navTo('files') },
|
|
727
733
|
{ key: 'live', label: 'Live', icon: 'activity', active: state.tab === 'live', count: liveCount || null, rail: liveHasError ? 'flame' : undefined, onClick: () => navTo('live') },
|
|
734
|
+
{ key: 'git', label: 'Git', icon: 'branch', active: state.tab === 'git', onClick: () => navTo('git') },
|
|
728
735
|
{ key: 'settings', label: 'Settings', icon: 'settings', active: state.tab === 'settings', onClick: () => navTo('settings') },
|
|
729
736
|
];
|
|
730
737
|
return WorkspaceRail({
|
|
@@ -928,9 +935,76 @@ function mainContent() {
|
|
|
928
935
|
if (state.tab === 'history') return historyMain();
|
|
929
936
|
if (state.tab === 'files') return filesMain();
|
|
930
937
|
if (state.tab === 'live') return liveMain();
|
|
938
|
+
if (state.tab === 'git') return gitMain();
|
|
931
939
|
return settingsMain();
|
|
932
940
|
}
|
|
933
941
|
|
|
942
|
+
// --- git (diff/log + worktree list) ---
|
|
943
|
+
// Minimal panel: loads git.log + worktree.list on entry, git.diff on demand.
|
|
944
|
+
// A richer visual component (GitDiffView/WorktreeSwitcher) is being built in
|
|
945
|
+
// anentrypoint-design in parallel; swap this for those exports once the
|
|
946
|
+
// vendored SDK bundle (site/app/vendor/anentrypoint-design) is rebuilt to
|
|
947
|
+
// include them - it does not yet (checked: no GitDiffView/WorktreeSwitcher
|
|
948
|
+
// in the vendored 247420.js as of this change).
|
|
949
|
+
async function loadGitPanel() {
|
|
950
|
+
state.git.loading = true; state.git.error = null; render();
|
|
951
|
+
try {
|
|
952
|
+
const [commits, worktrees] = await Promise.all([
|
|
953
|
+
B.gitLog(state.backend, { limit: 20 }),
|
|
954
|
+
B.worktreeList(state.backend, {}),
|
|
955
|
+
]);
|
|
956
|
+
state.git.commits = commits;
|
|
957
|
+
state.git.worktrees = worktrees;
|
|
958
|
+
} catch (e) {
|
|
959
|
+
state.git.error = e.message || 'Could not load git status.';
|
|
960
|
+
}
|
|
961
|
+
state.git.loading = false; render();
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
async function loadGitDiff(file) {
|
|
965
|
+
state.git.diffLoading = true; state.git.diffError = null; render();
|
|
966
|
+
try {
|
|
967
|
+
const { diff } = await B.gitDiff(state.backend, { file: file || undefined });
|
|
968
|
+
state.git.diff = diff || '(no changes)';
|
|
969
|
+
state.git.file = file || '';
|
|
970
|
+
} catch (e) {
|
|
971
|
+
state.git.diffError = e.message || 'Could not load diff.';
|
|
972
|
+
}
|
|
973
|
+
state.git.diffLoading = false; render();
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function gitMain() {
|
|
977
|
+
if (!state.git.loading && !state.git.commits.length && !state.git.worktrees.length && !state.git.error) loadGitPanel();
|
|
978
|
+
const g = state.git;
|
|
979
|
+
return [
|
|
980
|
+
PageHeader({ compact: true, dense: true, title: 'Git', lede: 'Working-tree diff, recent commits, and worktrees.' }),
|
|
981
|
+
g.error ? Alert({ key: 'git-err', kind: 'error', title: 'Git error', children: g.error }) : null,
|
|
982
|
+
Row({ key: 'git-actions', children: [
|
|
983
|
+
Btn({ key: 'refresh', onClick: () => loadGitPanel(), children: g.loading ? 'loading…' : 'refresh' }),
|
|
984
|
+
Btn({ key: 'diff-all', onClick: () => loadGitDiff(''), children: g.diffLoading ? 'loading diff…' : 'load full diff' }),
|
|
985
|
+
] }),
|
|
986
|
+
Panel({ id: 'git-diff', title: g.file ? ('diff: ' + g.file) : 'diff', children:
|
|
987
|
+
g.diffError ? h('p', { key: 'de', class: 't-meta field-error' }, g.diffError)
|
|
988
|
+
: h('pre', { key: 'dp', class: 'git-diff-pre' }, g.diff || '(no diff loaded)') }),
|
|
989
|
+
Panel({ id: 'git-log', title: 'recent commits', children:
|
|
990
|
+
!g.commits.length ? h('p', { key: 'nc', class: 't-meta' }, g.loading ? 'loading…' : 'no commits')
|
|
991
|
+
: h('ul', { key: 'cl', class: 'git-commit-list' }, g.commits.map(c =>
|
|
992
|
+
h('li', { key: c.hash }, [
|
|
993
|
+
h('code', { key: 'h' }, (c.hash || '').slice(0, 8)),
|
|
994
|
+
' ' + (c.subject || ''),
|
|
995
|
+
h('span', { key: 'm', class: 't-meta' }, ' — ' + (c.author || '') + ' · ' + (c.date || '')),
|
|
996
|
+
Btn({ key: 'view', small: true, onClick: () => loadGitDiff(''), children: 'view' }),
|
|
997
|
+
]))) }),
|
|
998
|
+
Panel({ id: 'git-worktrees', title: 'worktrees', children:
|
|
999
|
+
!g.worktrees.length ? h('p', { key: 'nw', class: 't-meta' }, g.loading ? 'loading…' : 'no worktrees')
|
|
1000
|
+
: h('ul', { key: 'wl', class: 'git-worktree-list' }, g.worktrees.map((w, i) =>
|
|
1001
|
+
h('li', { key: w.path || i }, [
|
|
1002
|
+
h('code', { key: 'p' }, w.path || ''),
|
|
1003
|
+
' ' + (w.branch || (w.detached ? '(detached)' : '')),
|
|
1004
|
+
]))) }),
|
|
1005
|
+
];
|
|
1006
|
+
}
|
|
1007
|
+
|
|
934
1008
|
// --- files (folder browser) ---
|
|
935
1009
|
async function loadDir(dirPath, { fromHash = false } = {}) {
|
|
936
1010
|
state.files = state.files || {};
|
package/site/app/js/backend.js
CHANGED
|
@@ -428,6 +428,30 @@ export async function listAgentModels(base, agentId) {
|
|
|
428
428
|
} catch { return []; }
|
|
429
429
|
}
|
|
430
430
|
|
|
431
|
+
// ---------- Git / worktrees (WS) ----------
|
|
432
|
+
|
|
433
|
+
export async function gitDiff(base, { cwd, file, staged } = {}) {
|
|
434
|
+
return wsCall(base, 'git.diff', { cwd, file, staged });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export async function gitLog(base, { cwd, limit } = {}) {
|
|
438
|
+
const { commits } = await wsCall(base, 'git.log', { cwd, limit });
|
|
439
|
+
return commits || [];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export async function worktreeList(base, { cwd } = {}) {
|
|
443
|
+
const { worktrees } = await wsCall(base, 'worktree.list', { cwd });
|
|
444
|
+
return worktrees || [];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export async function worktreeCreate(base, { cwd, path, branch, newBranch } = {}) {
|
|
448
|
+
return wsCall(base, 'worktree.create', { cwd, path, branch, newBranch });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export async function worktreeRemove(base, { cwd, path, force } = {}) {
|
|
452
|
+
return wsCall(base, 'worktree.remove', { cwd, path, force });
|
|
453
|
+
}
|
|
454
|
+
|
|
431
455
|
// ---------- Streaming chat (WS) ----------
|
|
432
456
|
//
|
|
433
457
|
// Yields events of shape:
|