agentgui 1.0.1126 → 1.0.1127
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.
Potentially problematic release.
This version of agentgui might be problematic. Click here for more details.
- package/.gm/browser-config.json +1 -0
- package/.gm/disciplines/agentgui/mem-1779277005143-0-1352.json +1 -0
- package/.gm/disciplines/agentgui/memories/.flat-export-done +0 -0
- package/.gm/disciplines/agentgui/memories/mem-c07fa3b0836edbe2-1352.md +8 -0
- package/.gm/gm.db +0 -0
- package/.gm/memories/.flat-export-done +1 -0
- package/.gm/memories/mem-0131ff4869d86085-370.md +10 -0
- package/.gm/memories/mem-04049d11cb8c1661-2135.md +8 -0
- package/.gm/memories/mem-069ff10372ed1770-1375.md +8 -0
- package/.gm/memories/mem-08a4a9e050a4deff-3048.md +8 -0
- package/.gm/memories/mem-09597ee0df9c9224-965.md +8 -0
- package/.gm/memories/mem-0993d156f571b619-1059.md +8 -0
- package/.gm/memories/mem-0b681bf2340c8df1-3170.md +8 -0
- package/.gm/memories/mem-0c444abde86202f6-1681.md +8 -0
- package/.gm/memories/mem-0d1408fa048b9923-1159.md +8 -0
- package/.gm/memories/mem-0dad5d82fec829ba-887.md +8 -0
- package/.gm/memories/mem-10a776effb7ce3a6-935.md +8 -0
- package/.gm/memories/mem-10d264b4f8e66fb4-1492.md +8 -0
- package/.gm/memories/mem-118c35ac67541d98-2909.md +8 -0
- package/.gm/memories/mem-119cf70dd19aa0a5-2225.md +8 -0
- package/.gm/memories/mem-15a07cd761b337c4-4320.md +8 -0
- package/.gm/memories/mem-15c73aca528535cb-2183.md +8 -0
- package/.gm/memories/mem-166f52796185e0e7-1487.md +8 -0
- package/.gm/memories/mem-1a148cf9c3126df5-3963.md +8 -0
- package/.gm/memories/mem-1a459001058f618e-567.md +8 -0
- package/.gm/memories/mem-1af3e13f7618e668-2146.md +8 -0
- package/.gm/memories/mem-1fd31d6873e9c164-794.md +8 -0
- package/.gm/memories/mem-21f551be092fd77d-2340.md +8 -0
- package/.gm/memories/mem-231ff5de003f20f0-586.md +8 -0
- package/.gm/memories/mem-2567393d3adb03ab-347.md +8 -0
- package/.gm/memories/mem-27faf41cdd050c80-1261.md +8 -0
- package/.gm/memories/mem-28889e43e8ce64cc-3663.md +8 -0
- package/.gm/memories/mem-2f4fd06515abf439-2562.md +8 -0
- package/.gm/memories/mem-34a749430303c5f7-544.md +8 -0
- package/.gm/memories/mem-3602d11d7bce9446-1057.md +8 -0
- package/.gm/memories/mem-38b7e24dbcba2f3e-639.md +8 -0
- package/.gm/memories/mem-39a7cc2f887a90b1-1079.md +8 -0
- package/.gm/memories/mem-40d6169c8759061d-806.md +8 -0
- package/.gm/memories/mem-4420e6d00e5c8632-2318.md +8 -0
- package/.gm/memories/mem-447e5a54ddc9357a-654.md +8 -0
- package/.gm/memories/mem-455b3beb4afabe14-591.md +8 -0
- package/.gm/memories/mem-4845ee594759151c-560.md +8 -0
- package/.gm/memories/mem-4a1254c60b868b88-374.md +8 -0
- package/.gm/memories/mem-4bafafa47a0e4b6c-3237.md +8 -0
- package/.gm/memories/mem-4c06c45b4d31bc60-1202.md +8 -0
- package/.gm/memories/mem-4dfac801d0378448-3676.md +8 -0
- package/.gm/memories/mem-4ecd3b713e384170-122.md +10 -0
- package/.gm/memories/mem-51d8992a3c6f6c9c-1073.md +8 -0
- package/.gm/memories/mem-539eee7e8c6c10ee-2557.md +8 -0
- package/.gm/memories/mem-559b8b2abc4b1091-1665.md +8 -0
- package/.gm/memories/mem-59dd73a463e54948-871.md +8 -0
- package/.gm/memories/mem-5b99b882ffe1a3c2-2262.md +8 -0
- package/.gm/memories/mem-5feb252a5244149c-1791.md +8 -0
- package/.gm/memories/mem-6224f8d185797f2b-1047.md +8 -0
- package/.gm/memories/mem-6434ee8261a6a6a2-3126.md +8 -0
- package/.gm/memories/mem-66b0947b946f3437-1155.md +8 -0
- package/.gm/memories/mem-6bdf783a1f6be7c2-1062.md +8 -0
- package/.gm/memories/mem-6d49f08bebac54c9-744.md +8 -0
- package/.gm/memories/mem-70d96acb736af2d7-685.md +8 -0
- package/.gm/memories/mem-70de7d5e71fcd439-2680.md +8 -0
- package/.gm/memories/mem-713614f12d7029da-1577.md +8 -0
- package/.gm/memories/mem-720d76c88e64f6ee-1967.md +8 -0
- package/.gm/memories/mem-7535f34f430fc196-1340.md +8 -0
- package/.gm/memories/mem-861a588d2773681a-1311.md +8 -0
- package/.gm/memories/mem-8af7980a92e09959-891.md +8 -0
- package/.gm/memories/mem-8b438ef4c609b7af-401.md +8 -0
- package/.gm/memories/mem-8f59884039787ef3-743.md +8 -0
- package/.gm/memories/mem-92b199c2024ee1aa-2513.md +8 -0
- package/.gm/memories/mem-9329105b05b9c0f6-324.md +10 -0
- package/.gm/memories/mem-94f30cec3c283f97-782.md +10 -0
- package/.gm/memories/mem-956d4813319f65a3-1710.md +8 -0
- package/.gm/memories/mem-96fffbb6615e031b-1263.md +8 -0
- package/.gm/memories/mem-993ab31f7a10aefa-718.md +8 -0
- package/.gm/memories/mem-a3e440db285f655a-995.md +8 -0
- package/.gm/memories/mem-a52933cd6495e6f5-2624.md +8 -0
- package/.gm/memories/mem-a86426ac44138c96-1606.md +8 -0
- package/.gm/memories/mem-a95b1d49d71133ce-1007.md +8 -0
- package/.gm/memories/mem-a9968a186368b022-1150.md +8 -0
- package/.gm/memories/mem-aaeae38d8eb01809-1076.md +8 -0
- package/.gm/memories/mem-ade01d9c02677c55-1557.md +8 -0
- package/.gm/memories/mem-b06b8c85b81663e6-1250.md +8 -0
- package/.gm/memories/mem-b451e5e17e555202-1490.md +8 -0
- package/.gm/memories/mem-b64ad00dcb6f909e-937.md +8 -0
- package/.gm/memories/mem-b7d6018ceca55b6f-2220.md +8 -0
- package/.gm/memories/mem-bdf466854032cac9-1509.md +8 -0
- package/.gm/memories/mem-c0482039e6ab4c50-2047.md +8 -0
- package/.gm/memories/mem-c4aaec5c4e6f80c2-920.md +8 -0
- package/.gm/memories/mem-c66976ddacb4fbe8-1454.md +8 -0
- package/.gm/memories/mem-c7c5bc263850c332-1844.md +8 -0
- package/.gm/memories/mem-cda639547932c796-713.md +8 -0
- package/.gm/memories/mem-cdf04ff6a6a0aa6f-671.md +8 -0
- package/.gm/memories/mem-cf8472c84d9e04c4-1883.md +8 -0
- package/.gm/memories/mem-cfd4d894370c63c3-1288.md +8 -0
- package/.gm/memories/mem-d060480435f92eec-1146.md +8 -0
- package/.gm/memories/mem-d347c921b79e6d44-1099.md +8 -0
- package/.gm/memories/mem-d3c8ea6c861bfbb8-1457.md +8 -0
- package/.gm/memories/mem-d5e928c552cd87f9-214.md +10 -0
- package/.gm/memories/mem-d63bc3b8a0269844-406.md +10 -0
- package/.gm/memories/mem-d9c19257abc2fa5e-1061.md +8 -0
- package/.gm/memories/mem-d9f26358a7cd3bb8-866.md +8 -0
- package/.gm/memories/mem-da1c91acf63d81d3-863.md +8 -0
- package/.gm/memories/mem-dc1ac3b04d22317e-664.md +8 -0
- package/.gm/memories/mem-dceeae20bc56d407-711.md +8 -0
- package/.gm/memories/mem-dd98b21c0896dc04-1557.md +8 -0
- package/.gm/memories/mem-df74674eec7745ac-178.md +10 -0
- package/.gm/memories/mem-dfe3b466fd5d1199-802.md +8 -0
- package/.gm/memories/mem-e0f9286aed2d5a06-1116.md +8 -0
- package/.gm/memories/mem-e1950b064099b7be-1895.md +8 -0
- package/.gm/memories/mem-e1d5431ae2542872-961.md +8 -0
- package/.gm/memories/mem-e490a181de6469fc-487.md +8 -0
- package/.gm/memories/mem-e72c6efbb27326fb-392.md +8 -0
- package/.gm/memories/mem-e943b58115a02b4c-593.md +8 -0
- package/.gm/memories/mem-e9bc5f9ed6edbcf7-1580.md +8 -0
- package/.gm/memories/mem-eae1e383927854fb-2649.md +8 -0
- package/.gm/memories/mem-ebbe6c0694c526f0-1392.md +8 -0
- package/.gm/memories/mem-ebf85becc23410b0-1633.md +8 -0
- package/.gm/memories/mem-ee0596fa17c7102e-362.md +10 -0
- package/.gm/memories/mem-ee4450fbc311b583-1361.md +8 -0
- package/.gm/memories/mem-ee8858162518f144-1252.md +8 -0
- package/.gm/memories/mem-f3297e82df622649-1166.md +8 -0
- package/.gm/memories/mem-f3f317c083d979ee-1210.md +8 -0
- package/.gm/memories/mem-f714a86465fba158-2041.md +8 -0
- package/.gm/memories/mem-f786bc1626d92845-204.md +10 -0
- package/.gm/memories/mem-f9f0dc8895b21e18-1217.md +8 -0
- package/.gm/memories/mem-fb3b2a7395800041-525.md +8 -0
- package/.gm/memories/mem-fc5753f92ee7d654-966.md +8 -0
- package/.gm/memories/mem-fe3faa1652f07239-480.md +8 -0
- package/.gm/memories/mem-ff8669e527e5c427-2788.md +8 -0
- package/.gm/mutables.yml +53 -0
- package/.gm/prd.yml +3342 -1
- package/AGENTS.md +122 -122
- package/UX_OPTIMIZATION_SUMMARY.md +238 -0
- package/agentgui-after.png +0 -0
- package/agentgui-current.png +0 -0
- package/agentgui-final.png +0 -0
- package/agentgui-nobrand.png +0 -0
- package/agentgui-now.png +0 -0
- package/agentgui-v2.png +0 -0
- package/agentgui-v4.png +0 -0
- package/bash.exe.stackdump +28 -0
- package/database.js +110 -109
- package/design-reference.png +0 -0
- package/lib/acp-sdk-manager.js +9 -42
- package/lib/claude-runner-acp.js +155 -213
- package/lib/claude-runner-agents.js +157 -170
- package/lib/http-handler.js +931 -400
- package/lib/ws-handlers-util.js +466 -11
- package/package.json +1 -1
- package/site/app/js/app.js +4741 -4221
- package/site/app/js/backend.js +602 -621
- package/site/app/vendor/anentrypoint-design/247420.css +13673 -14799
- package/site/app/vendor/anentrypoint-design/247420.js +449 -735
- package/.gm/.embed-generation.code_chunks +0 -1
- package/.gm/.embed-generation.git_commit_vectors +0 -1
- package/.gm/.embed-generation.memories +0 -1
- package/.gm/.embed-generation.rssearch_vectors +0 -1
- package/lib/acp-http-protocol.js +0 -95
- package/lib/http-routes/mutations.js +0 -199
- package/lib/http-routes/reads.js +0 -212
- package/lib/http-routes/shared.js +0 -225
- package/lib/ws-handlers/agents.js +0 -137
- package/lib/ws-handlers/chat.js +0 -157
- package/lib/ws-handlers/git.js +0 -159
- package/lib/ws-handlers/misc.js +0 -36
- package/lib/ws-handlers/shared.js +0 -47
- package/lib/ws-handlers/terminal-state.js +0 -16
- package/site/app/js/chat-persistence.js +0 -123
- package/site/app/js/hash-routing.js +0 -62
- package/site/app/js/history.js +0 -435
- package/site/app/js/shortcuts.js +0 -128
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import os from 'os';
|
|
4
|
-
import crypto from 'crypto';
|
|
5
|
-
|
|
6
|
-
// Module-level platform constant — never changes at runtime.
|
|
7
|
-
export const IS_WINDOWS = os.platform() === 'win32';
|
|
8
|
-
|
|
9
|
-
// Confine a requested filesystem path to an allowlist of roots. Two layers:
|
|
10
|
-
// 1. normalize + resolved-prefix check on the LEXICAL path (blocks `../`
|
|
11
|
-
// traversal, which path.normalize collapses so a literal `..` test is a
|
|
12
|
-
// no-op);
|
|
13
|
-
// 2. fs.realpathSync + the SAME prefix check on the REAL path, so a symlink
|
|
14
|
-
// that lives inside an allowed root but points outside it cannot escape
|
|
15
|
-
// (the lexical path passes layer 1, the resolved target fails layer 2).
|
|
16
|
-
// Returns { ok, realPath, reason }. realPath is the symlink-resolved absolute
|
|
17
|
-
// path to stat/read; callers use it, never the raw input. A non-existent path
|
|
18
|
-
// has no realpath yet, so it fails closed with reason 'not found'.
|
|
19
|
-
export function confineToRoots(inputPath, allowRoots) {
|
|
20
|
-
const norms = allowRoots.map(r => path.normalize(r));
|
|
21
|
-
const expanded = inputPath && inputPath.startsWith('~') ? inputPath.replace('~', os.homedir()) : inputPath;
|
|
22
|
-
const normalizedPath = path.normalize(expanded || '');
|
|
23
|
-
const isAbsolute = IS_WINDOWS ? /^[A-Za-z]:[\\/]/.test(normalizedPath) : normalizedPath.startsWith('/');
|
|
24
|
-
const within = (p) => {
|
|
25
|
-
const np = IS_WINDOWS ? p.toLowerCase() : p;
|
|
26
|
-
return norms.some(root => {
|
|
27
|
-
const r = IS_WINDOWS ? root.toLowerCase() : root;
|
|
28
|
-
return np === r || np.startsWith(r + path.sep);
|
|
29
|
-
});
|
|
30
|
-
};
|
|
31
|
-
if (!isAbsolute || !within(normalizedPath)) return { ok: false, reason: 'path outside allowed roots' };
|
|
32
|
-
// realpath the target and re-confine, defeating symlink escape (TOCTOU/link
|
|
33
|
-
// traversal). If realpath throws (missing path / broken link) fail closed.
|
|
34
|
-
let realPath;
|
|
35
|
-
try { realPath = fs.realpathSync(normalizedPath); }
|
|
36
|
-
catch (e) { return { ok: false, reason: e && e.code === 'ENOENT' ? 'not found' : 'cannot resolve path', code: e && e.code }; }
|
|
37
|
-
if (!within(realPath)) return { ok: false, reason: 'symlink target outside allowed roots' };
|
|
38
|
-
return { ok: true, realPath };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Mask ?token=VALUE in a URL string before logging so credentials never
|
|
42
|
-
// appear in server logs or error messages.
|
|
43
|
-
export function maskToken(url) {
|
|
44
|
-
if (typeof url !== 'string') return url;
|
|
45
|
-
return url.replace(/([?&]token=)[^&]*/gi, '$1***');
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// Secret-bearing basenames that must never be readable through ANY confined
|
|
49
|
-
// raw-bytes route (preview or download), even when they sit inside an allowed
|
|
50
|
-
// root: dotfiles, env/key/cert material, and credential stores. One const so
|
|
51
|
-
// /api/file and /api/download can never drift apart.
|
|
52
|
-
export const SECRET_RE = /(^\.|\.(env|pem|key|crt|p12|pfx)$|secret|credential|\.npmrc$|\.netrc$)/i;
|
|
53
|
-
|
|
54
|
-
// The allowlist the Files surface operates within: server cwd + Claude
|
|
55
|
-
// projects dir, widened via FS_ROOTS (path-separated). One construction so
|
|
56
|
-
// /api/list,file,download and the mutation routes can never drift apart.
|
|
57
|
-
export function fsAllowRoots() {
|
|
58
|
-
const roots = [
|
|
59
|
-
process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
|
|
60
|
-
];
|
|
61
|
-
// The server cwd is only exposed when PASSWORD is set (the witnessed
|
|
62
|
-
// localhost-PASSWORD deploy lists the repo tree) or FS_ALLOW_CWD=1 is opted
|
|
63
|
-
// in. An open no-PASSWORD deploy must NOT expose the whole server tree.
|
|
64
|
-
if (process.env.PASSWORD || process.env.FS_ALLOW_CWD === '1') {
|
|
65
|
-
roots.push(process.env.STARTUP_CWD || process.cwd());
|
|
66
|
-
}
|
|
67
|
-
if (process.env.FS_ROOTS) roots.push(...process.env.FS_ROOTS.split(path.delimiter));
|
|
68
|
-
return roots.map(r => path.normalize(r));
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// A new file/dir name must be a single path component: no separators, no
|
|
72
|
-
// traversal, no NTFS alternate-data-stream colon, no reserved Windows device
|
|
73
|
-
// names, no trailing dot/space (Windows silently strips them, aliasing two
|
|
74
|
-
// names onto one entry). Returns the trimmed name or null when inadmissible.
|
|
75
|
-
export function sanitizeEntryName(name) {
|
|
76
|
-
if (typeof name !== 'string') return null;
|
|
77
|
-
const n = name.trim();
|
|
78
|
-
if (!n || n.length > 255) return null;
|
|
79
|
-
if (/[\\/:*?"<>|\x00-\x1f]/.test(n)) return null;
|
|
80
|
-
if (n === '.' || n === '..') return null;
|
|
81
|
-
if (/[. ]$/.test(n)) return null;
|
|
82
|
-
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i.test(n)) return null;
|
|
83
|
-
return n;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Every confined filesystem route accepts its target path via a `?path=`/
|
|
87
|
-
// `?dir=` query param, preferred over a path SEGMENT. A reverse proxy's
|
|
88
|
-
// proxy_pass URI normalization can decode-then-reencode the request path,
|
|
89
|
-
// collapsing an encoded %2F segment slash back into a literal '/' before
|
|
90
|
-
// forwarding - the app then sees extra path segments instead of one opaque
|
|
91
|
-
// one, strips what it thinks is a leading '/', and silently turns an absolute
|
|
92
|
-
// path into a relative one that fails confinement even for genuinely
|
|
93
|
-
// accessible directories/files. A query param is untouched by that
|
|
94
|
-
// normalization on any proxy in front of this app. `legacyPrefix` is the old
|
|
95
|
-
// `/api/xxx/<path>` route prefix, still accepted for any caller not yet
|
|
96
|
-
// updated to the query-param form.
|
|
97
|
-
export function resolveConfinedPath(req, routePath, queryKey, legacyPrefix) {
|
|
98
|
-
const url = new URL(req.url, 'http://x');
|
|
99
|
-
const qVal = url.searchParams.get(queryKey);
|
|
100
|
-
if (qVal != null) return qVal;
|
|
101
|
-
const raw = routePath.split('?')[0].slice(legacyPrefix.length).replace(/^\//, '');
|
|
102
|
-
return raw ? decodeURIComponent(raw) : '';
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Map a Node.js filesystem error code to a safe human-readable string that
|
|
106
|
-
// does not disclose host paths or internal stack context. Used everywhere an
|
|
107
|
-
// err.message would otherwise be returned to the client.
|
|
108
|
-
export function safeErrMsg(err) {
|
|
109
|
-
if (!err) return 'unknown error';
|
|
110
|
-
switch (err.code) {
|
|
111
|
-
case 'ENOENT': return 'file not found';
|
|
112
|
-
case 'EACCES': return 'permission denied';
|
|
113
|
-
case 'EPERM': return 'operation not permitted';
|
|
114
|
-
case 'EISDIR': return 'path is a directory';
|
|
115
|
-
case 'ENOTDIR': return 'path is not a directory';
|
|
116
|
-
case 'ENOTEMPTY': return 'directory is not empty';
|
|
117
|
-
case 'EEXIST': return 'file already exists';
|
|
118
|
-
case 'EXDEV': return 'cannot move across drives';
|
|
119
|
-
case 'EMFILE': return 'too many open files';
|
|
120
|
-
case 'ENOSPC': return 'no space left on device';
|
|
121
|
-
default: return 'operation failed';
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Read a request body with a hard size cap; resolves a Buffer or rejects with
|
|
126
|
-
// .code='TOO_LARGE' so the caller can answer 413 without buffering the rest.
|
|
127
|
-
// Stops accumulating further chunks (req.pause()) but does NOT destroy the
|
|
128
|
-
// socket - a destroyed request cannot carry the caller's 413 response back to
|
|
129
|
-
// the client, which instead sees a raw ECONNRESET with zero information
|
|
130
|
-
// (live-witnessed: fetch() reported only "fetch failed", no status code).
|
|
131
|
-
// The still-open connection is closed normally once the 413 response is sent.
|
|
132
|
-
export function readBody(req, maxBytes) {
|
|
133
|
-
return new Promise((resolve, reject) => {
|
|
134
|
-
const chunks = []; let total = 0; let tooLarge = false;
|
|
135
|
-
req.on('data', (c) => {
|
|
136
|
-
if (tooLarge) return;
|
|
137
|
-
total += c.length;
|
|
138
|
-
if (total > maxBytes) {
|
|
139
|
-
tooLarge = true;
|
|
140
|
-
req.pause();
|
|
141
|
-
const e = new Error('body too large'); e.code = 'TOO_LARGE';
|
|
142
|
-
reject(e);
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
chunks.push(c);
|
|
146
|
-
});
|
|
147
|
-
req.on('end', () => { if (!tooLarge) resolve(Buffer.concat(chunks)); });
|
|
148
|
-
req.on('error', reject);
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// True when the resolved path IS one of the allowlist roots - the roots
|
|
153
|
-
// themselves are never mutation targets (rename/delete of a root would orphan
|
|
154
|
-
// the whole surface).
|
|
155
|
-
export function isAllowRoot(realPath, allowRoots) {
|
|
156
|
-
const p = IS_WINDOWS ? realPath.toLowerCase() : realPath;
|
|
157
|
-
return allowRoots.some(r => (IS_WINDOWS ? r.toLowerCase() : r) === p);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// --- Soft-delete (trash) -----------------------------------------------
|
|
161
|
-
// /api/delete moves entries into <root>/.agentgui-trash/<trashId>__<name>
|
|
162
|
-
// instead of unlinking, giving a short undo window. In-memory index only
|
|
163
|
-
// (server restart forfeits the undo window - acceptable since the retention
|
|
164
|
-
// window itself is short and this is a convenience net on top of, not a
|
|
165
|
-
// replacement for, the pre-delete ConfirmDialog). Purged after
|
|
166
|
-
// TRASH_RETENTION_MS or when trashIndex grows past TRASH_MAX_ENTRIES
|
|
167
|
-
// (oldest-first), so a long-running server's trash dir can't grow unbounded.
|
|
168
|
-
const TRASH_DIR_NAME = '.agentgui-trash';
|
|
169
|
-
const TRASH_RETENTION_MS = parseInt(process.env.AGENTGUI_TRASH_RETENTION_MS || '', 10) || 10 * 60 * 1000;
|
|
170
|
-
const TRASH_MAX_ENTRIES = 200;
|
|
171
|
-
const trashIndex = new Map(); // trashId -> { trashPath, originalPath, root, deletedAt }
|
|
172
|
-
|
|
173
|
-
function purgeExpiredTrash() {
|
|
174
|
-
const now = Date.now();
|
|
175
|
-
for (const [id, info] of trashIndex) {
|
|
176
|
-
if (now - info.deletedAt > TRASH_RETENTION_MS) {
|
|
177
|
-
try { fs.rmSync(info.trashPath, { recursive: true, force: true }); } catch { /* already gone */ }
|
|
178
|
-
trashIndex.delete(id);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
if (trashIndex.size > TRASH_MAX_ENTRIES) {
|
|
182
|
-
const sorted = [...trashIndex.entries()].sort((a, b) => a[1].deletedAt - b[1].deletedAt);
|
|
183
|
-
for (const [id, info] of sorted.slice(0, trashIndex.size - TRASH_MAX_ENTRIES)) {
|
|
184
|
-
try { fs.rmSync(info.trashPath, { recursive: true, force: true }); } catch {}
|
|
185
|
-
trashIndex.delete(id);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Which allowed root a confined realPath lives under - the trash dir sits
|
|
191
|
-
// alongside it (still inside the SAME root, so confineToRoots covers the
|
|
192
|
-
// trash path too - no new unconfined surface).
|
|
193
|
-
function rootFor(realPath, allowRoots) {
|
|
194
|
-
const p = IS_WINDOWS ? realPath.toLowerCase() : realPath;
|
|
195
|
-
return allowRoots.find(r => { const rr = IS_WINDOWS ? r.toLowerCase() : r; return p === rr || p.startsWith(rr + path.sep); });
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
export function moveToTrash(realPath, allowRoots) {
|
|
199
|
-
purgeExpiredTrash();
|
|
200
|
-
const root = rootFor(realPath, allowRoots);
|
|
201
|
-
if (!root) { const e = new Error('not confined to an allowed root'); e.code = 'EACCES'; throw e; }
|
|
202
|
-
const trashDir = path.join(root, TRASH_DIR_NAME);
|
|
203
|
-
fs.mkdirSync(trashDir, { recursive: true });
|
|
204
|
-
const trashId = crypto.randomBytes(8).toString('hex');
|
|
205
|
-
const base = path.basename(realPath);
|
|
206
|
-
const trashPath = path.join(trashDir, trashId + '__' + base);
|
|
207
|
-
fs.renameSync(realPath, trashPath);
|
|
208
|
-
trashIndex.set(trashId, { trashPath, originalPath: realPath, root, deletedAt: Date.now() });
|
|
209
|
-
return { trashId };
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
export function restoreFromTrash(trashId, allowRoots) {
|
|
213
|
-
const info = trashIndex.get(trashId);
|
|
214
|
-
if (!info) { const e = new Error('nothing to restore - the undo window has expired or this was already restored'); e.code = 'NOT_FOUND'; throw e; }
|
|
215
|
-
// Re-confine the ORIGINAL path at restore time (not trust the cached one
|
|
216
|
-
// blindly) - the allowlist itself doesn't change at runtime, but this keeps
|
|
217
|
-
// restore honoring the exact same confinement contract every other route does.
|
|
218
|
-
const conf = confineToRoots(info.originalPath, allowRoots);
|
|
219
|
-
if (!conf.ok && conf.reason !== 'not found') { const e = new Error('restore target is no longer inside an accessible folder'); e.code = 'CONFLICT'; throw e; }
|
|
220
|
-
if (fs.existsSync(info.originalPath)) { const e = new Error('a file already exists at the original location'); e.code = 'CONFLICT'; throw e; }
|
|
221
|
-
fs.mkdirSync(path.dirname(info.originalPath), { recursive: true });
|
|
222
|
-
fs.renameSync(info.trashPath, info.originalPath);
|
|
223
|
-
trashIndex.delete(trashId);
|
|
224
|
-
return { path: info.originalPath };
|
|
225
|
-
}
|
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
import { registry } from '../claude-runner-agents.js';
|
|
2
|
-
import { restart as restartAcpAgent } from '../acp-sdk-manager.js';
|
|
3
|
-
import { err } from './shared.js';
|
|
4
|
-
import { spawnSync } from 'child_process';
|
|
5
|
-
|
|
6
|
-
const SUB_AGENT_MAP = {
|
|
7
|
-
'opencode': [{ id: 'gm-oc', name: 'GM OpenCode' }], 'cli-opencode': [{ id: 'gm-oc', name: 'GM OpenCode' }],
|
|
8
|
-
'gemini': [{ id: 'gm-gc', name: 'GM Gemini' }], 'cli-gemini': [{ id: 'gm-gc', name: 'GM Gemini' }],
|
|
9
|
-
'kilo': [{ id: 'gm-kilo', name: 'GM Kilo' }], 'cli-kilo': [{ id: 'gm-kilo', name: 'GM Kilo' }],
|
|
10
|
-
'codex': [], 'cli-codex': []
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
export function register(router, deps) {
|
|
14
|
-
const { getProviderConfigs, getModelsForAgent } = deps;
|
|
15
|
-
|
|
16
|
-
// --- agents.list: enumerate registered ACP agents + claude-code ---
|
|
17
|
-
router.handle('agents.list', () => {
|
|
18
|
-
const agents = registry.list().map(a => ({
|
|
19
|
-
id: a.id,
|
|
20
|
-
name: a.name,
|
|
21
|
-
protocol: a.protocol,
|
|
22
|
-
supportsStdin: !!a.supportsStdin,
|
|
23
|
-
features: a.supportedFeatures || [],
|
|
24
|
-
available: registry.isAvailable(a.id),
|
|
25
|
-
npxInstallable: !!a.npxPackage,
|
|
26
|
-
// The CLI binary name a manual (non-npx) install would need - lets the
|
|
27
|
-
// settings panel say what to install instead of just "not installed".
|
|
28
|
-
cmd: a.command || null,
|
|
29
|
-
}));
|
|
30
|
-
return { agents };
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
// --- agents.models: model choices for a given agent ---
|
|
34
|
-
router.handle('agents.models', async (p) => {
|
|
35
|
-
const id = p?.id || p?.agentId;
|
|
36
|
-
if (!id) err(400, 'agent id required');
|
|
37
|
-
if (id === 'claude-code') {
|
|
38
|
-
return { models: [
|
|
39
|
-
{ id: 'sonnet', name: 'Claude Sonnet (latest)' },
|
|
40
|
-
{ id: 'opus', name: 'Claude Opus (latest)' },
|
|
41
|
-
{ id: 'haiku', name: 'Claude Haiku (latest)' },
|
|
42
|
-
] };
|
|
43
|
-
}
|
|
44
|
-
// Other agents: discover their models via getModelsForAgent (queries the
|
|
45
|
-
// running ACP server). Fail closed to an empty list on any error or when
|
|
46
|
-
// the dep isn't a function.
|
|
47
|
-
if (typeof getModelsForAgent === 'function') {
|
|
48
|
-
try {
|
|
49
|
-
const raw = await getModelsForAgent(id);
|
|
50
|
-
const list = Array.isArray(raw) ? raw : (raw?.models || []);
|
|
51
|
-
return { models: list.map(m => ({ id: m.id, name: m.name || m.label || m.id })) };
|
|
52
|
-
} catch { return { models: [] }; }
|
|
53
|
-
}
|
|
54
|
-
return { models: [] };
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
router.handle('acp.restart', async (p) => {
|
|
58
|
-
if (!p.id) err(400, 'Missing agent id');
|
|
59
|
-
const ok = await restartAcpAgent(p.id);
|
|
60
|
-
return { ok: !!ok };
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
router.handle('agent.subagents', async (p) => {
|
|
64
|
-
if (!p.id) err(400, 'Missing agent id');
|
|
65
|
-
if (p.id === 'claude-code' || p.id === 'cli-claude') {
|
|
66
|
-
const spawnEnv = { ...process.env }; delete spawnEnv.CLAUDECODE;
|
|
67
|
-
const result = spawnSync('claude', ['agents', 'list'], { encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'], env: spawnEnv });
|
|
68
|
-
if (result.status !== 0 || !result.stdout) return { subAgents: [] };
|
|
69
|
-
const agents = result.stdout.trim().split('\n').filter(l => l.trim()).map(l => l.match(/^ (\S+)\s+·/)).filter(Boolean).map(m => ({ id: m[1], name: m[1] }));
|
|
70
|
-
return { subAgents: agents };
|
|
71
|
-
}
|
|
72
|
-
return { subAgents: SUB_AGENT_MAP[p.id] || [] };
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
// --- models.availability: composes agentgui's REAL model surface into the
|
|
76
|
-
// ModelsConfig component's shape (design SDK's ModelsConfig.js). agentgui
|
|
77
|
-
// has no freddie-style probed-availability matrix (no /v1/models round-trip
|
|
78
|
-
// per mode) - what it actually has is: discovered agent CLIs (registry,
|
|
79
|
-
// same data agents.list already exposes), each agent's model choices
|
|
80
|
-
// (agents.models' getModelsForAgent), and per-provider API-key presence
|
|
81
|
-
// (auth.configs' getProviderConfigs). This handler is a read-only
|
|
82
|
-
// composition of those three real sources, not a new probe and not fake
|
|
83
|
-
// data - "usable_in_any_mode" is derived from registry.isAvailable(agentId)
|
|
84
|
-
// (the CLI binary was actually found on this server), and "key_present"
|
|
85
|
-
// comes straight from getProviderConfigs' filesystem check.
|
|
86
|
-
router.handle('models.availability', async () => {
|
|
87
|
-
const agents = registry.list();
|
|
88
|
-
const providerConfigs = typeof getProviderConfigs === 'function' ? getProviderConfigs() : {};
|
|
89
|
-
const providers = [];
|
|
90
|
-
for (const a of agents) {
|
|
91
|
-
const available = registry.isAvailable(a.id);
|
|
92
|
-
let models = [];
|
|
93
|
-
if (a.id === 'claude-code') {
|
|
94
|
-
models = [
|
|
95
|
-
{ id: 'sonnet', name: 'Claude Sonnet (latest)' },
|
|
96
|
-
{ id: 'opus', name: 'Claude Opus (latest)' },
|
|
97
|
-
{ id: 'haiku', name: 'Claude Haiku (latest)' },
|
|
98
|
-
];
|
|
99
|
-
} else if (typeof getModelsForAgent === 'function') {
|
|
100
|
-
try {
|
|
101
|
-
const raw = await getModelsForAgent(a.id);
|
|
102
|
-
const list = Array.isArray(raw) ? raw : (raw?.models || []);
|
|
103
|
-
models = list.map((m) => ({ id: m.id, name: m.name || m.label || m.id }));
|
|
104
|
-
} catch { models = []; }
|
|
105
|
-
}
|
|
106
|
-
// Map onto agentgui's one real signal per model: is the agent CLI itself
|
|
107
|
-
// available on this server (registry.isAvailable). agentgui does not
|
|
108
|
-
// probe individual (model, mode) cells the way freddie's matrix does,
|
|
109
|
-
// so every model under an available agent reports the same single
|
|
110
|
-
// 'cli' mode rather than fabricating per-mode probe results.
|
|
111
|
-
providers.push({
|
|
112
|
-
id: a.id,
|
|
113
|
-
key_present: !!(providerConfigs[a.id] && providerConfigs[a.id].hasKey) || available,
|
|
114
|
-
discovery_error: available ? null : ((a.name || a.id) + ' CLI not found on this server'),
|
|
115
|
-
models: models.map((m) => ({
|
|
116
|
-
id: m.id,
|
|
117
|
-
name: m.name,
|
|
118
|
-
discovered_via: a.protocol || 'cli',
|
|
119
|
-
modes: { cli: { ok: !!available, skipped: !available, reason: available ? undefined : 'agent_not_installed' } },
|
|
120
|
-
usable_in_any_mode: !!available,
|
|
121
|
-
})),
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
const totalModels = providers.reduce((n, p) => n + p.models.length, 0);
|
|
125
|
-
const usableModels = providers.reduce((n, p) => n + p.models.filter((m) => m.usable_in_any_mode).length, 0);
|
|
126
|
-
return {
|
|
127
|
-
timestamp: new Date().toISOString(),
|
|
128
|
-
providers,
|
|
129
|
-
sampler: [],
|
|
130
|
-
summary: {
|
|
131
|
-
total_providers: providers.length,
|
|
132
|
-
total_models: totalModels,
|
|
133
|
-
usable_in_any_mode: usableModels,
|
|
134
|
-
},
|
|
135
|
-
};
|
|
136
|
-
});
|
|
137
|
-
}
|
package/lib/ws-handlers/chat.js
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
import crypto from 'crypto';
|
|
2
|
-
import { runClaudeWithStreaming } from '../claude-runner-run.js';
|
|
3
|
-
import { registry } from '../claude-runner-agents.js';
|
|
4
|
-
import { confineToRoots, fsAllowRoots } from '../http-handler.js';
|
|
5
|
-
import { err } from './shared.js';
|
|
6
|
-
import { recordTerminal, getTerminal } from './terminal-state.js';
|
|
7
|
-
|
|
8
|
-
export function register(router, deps) {
|
|
9
|
-
const { wsOptimizer, broadcastSync, STARTUP_CWD, subscriptionIndex, activeChats } = deps;
|
|
10
|
-
|
|
11
|
-
// --- conversation.subscribe: register this ws for sessionId broadcasts ---
|
|
12
|
-
router.handle('conversation.subscribe', (p, ws) => {
|
|
13
|
-
const sid = p?.sessionId;
|
|
14
|
-
if (!sid || typeof sid !== 'string') err(400, 'sessionId required');
|
|
15
|
-
if (!subscriptionIndex.has(sid)) subscriptionIndex.set(sid, new Set());
|
|
16
|
-
subscriptionIndex.get(sid).add(ws);
|
|
17
|
-
ws.subscriptions = ws.subscriptions || new Set();
|
|
18
|
-
ws.subscriptions.add(sid);
|
|
19
|
-
// Replay a buffered terminal frame (complete/error/cancelled) so a client
|
|
20
|
-
// that re-subscribes after a ws drop learns the turn already ended.
|
|
21
|
-
const term = getTerminal(sid);
|
|
22
|
-
if (term) { try { wsOptimizer.sendToClient(ws, { ...term, replayed: true }); } catch {} }
|
|
23
|
-
return { subscribed: true, sessionId: sid, replayedTerminal: !!term };
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
// --- chat.sendMessage: start a one-shot streaming chat with an agent.
|
|
27
|
-
// Bypasses the gutted db-queries layer entirely; calls runClaudeWithStreaming
|
|
28
|
-
// directly and broadcasts streaming_* events scoped to an ephemeral sessionId.
|
|
29
|
-
router.handle('chat.sendMessage', async (p, ws) => {
|
|
30
|
-
let content = (p?.content || '').toString();
|
|
31
|
-
if (!content) err(400, 'content required');
|
|
32
|
-
const agentId = p?.agentId || 'claude-code';
|
|
33
|
-
// For non-resume agents (not claude-code which uses --resume), prepend prior
|
|
34
|
-
// conversation turns so the agent has context. claude-code handles multi-turn
|
|
35
|
-
// natively via resumeSessionId; direct runners (agy, etc.) get a preamble.
|
|
36
|
-
const priorMessages = Array.isArray(p?.messages) ? p.messages.filter(m => m?.role && m?.content) : [];
|
|
37
|
-
if (agentId !== 'claude-code' && !p?.resumeSid && !p?.resumeSessionId && priorMessages.length > 0) {
|
|
38
|
-
const preamble = priorMessages.map(m => (m.role === 'user' ? 'User: ' : 'Assistant: ') + (m.content || '').trim()).join('\n\n');
|
|
39
|
-
content = '[Prior conversation]\n' + preamble + '\n\n[Current message]\n' + content;
|
|
40
|
-
}
|
|
41
|
-
const model = p?.model || undefined;
|
|
42
|
-
const subAgent = p?.subAgent || undefined;
|
|
43
|
-
const cwd = p?.cwd || STARTUP_CWD;
|
|
44
|
-
const resumeSessionId = p?.resumeSid || p?.resumeSessionId || undefined;
|
|
45
|
-
if (!registry.has(agentId)) err(404, `Unknown agentId: ${agentId}`);
|
|
46
|
-
// A client-supplied cwd must be confined to the SAME allowlist as the Files
|
|
47
|
-
// routes (fsAllowRoots) - an unconfined cwd would let a client spawn the
|
|
48
|
-
// agent CLI anywhere on disk. Use the realpath-resolved value as the spawn
|
|
49
|
-
// cwd (defeats symlink escape, same as the HTTP file routes). No p.cwd ->
|
|
50
|
-
// STARTUP_CWD, which is itself an allowed root, so it needs no check.
|
|
51
|
-
let spawnCwd = STARTUP_CWD;
|
|
52
|
-
if (p?.cwd) {
|
|
53
|
-
const conf = confineToRoots(cwd, fsAllowRoots());
|
|
54
|
-
if (!conf.ok) err(conf.reason === 'not found' ? 400 : 403, `cwd outside allowed roots: ${cwd}`);
|
|
55
|
-
spawnCwd = conf.realPath;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const sessionId = 'chat-' + crypto.randomBytes(8).toString('hex');
|
|
59
|
-
// Auto-subscribe the originating ws so it receives its own broadcasts.
|
|
60
|
-
if (!subscriptionIndex.has(sessionId)) subscriptionIndex.set(sessionId, new Set());
|
|
61
|
-
subscriptionIndex.get(sessionId).add(ws);
|
|
62
|
-
ws.subscriptions = ws.subscriptions || new Set();
|
|
63
|
-
ws.subscriptions.add(sessionId);
|
|
64
|
-
|
|
65
|
-
const ctrl = { aborted: false, proc: null, agentId, model, cwd: spawnCwd, startedAt: Date.now() };
|
|
66
|
-
activeChats.set(sessionId, ctrl);
|
|
67
|
-
// Push-driven hint so clients refresh active-session state without
|
|
68
|
-
// waiting for the 3s chat.active poll (finding 48).
|
|
69
|
-
broadcastSync({ type: 'chat_active_changed', reason: 'started', sessionId, agentId, timestamp: Date.now() });
|
|
70
|
-
|
|
71
|
-
// Fire-and-forget. Errors broadcast as streaming_error.
|
|
72
|
-
(async () => {
|
|
73
|
-
let eventCount = 0;
|
|
74
|
-
broadcastSync({ type: 'streaming_start', sessionId, agentId, timestamp: Date.now() });
|
|
75
|
-
let claudeSessionBroadcast = false;
|
|
76
|
-
const onEvent = (parsed) => {
|
|
77
|
-
eventCount++;
|
|
78
|
-
// Surface claude's REAL session id (from the stream) once, so the client
|
|
79
|
-
// can --resume this conversation on its next turn. The ephemeral
|
|
80
|
-
// 'chat-...' sessionId is not a claude session id and cannot be resumed.
|
|
81
|
-
if (!claudeSessionBroadcast && parsed?.session_id) {
|
|
82
|
-
claudeSessionBroadcast = true;
|
|
83
|
-
ctrl.claudeSessionId = parsed.session_id;
|
|
84
|
-
broadcastSync({ type: 'streaming_session', sessionId, claudeSessionId: parsed.session_id, agentId, timestamp: Date.now() });
|
|
85
|
-
}
|
|
86
|
-
if (parsed?.type === 'assistant' && parsed.message?.content) {
|
|
87
|
-
for (const block of parsed.message.content) {
|
|
88
|
-
broadcastSync({ type: 'streaming_progress', sessionId, block, blockRole: 'assistant', seq: eventCount, timestamp: Date.now() });
|
|
89
|
-
}
|
|
90
|
-
} else if (parsed?.type === 'user' && parsed.message?.content) {
|
|
91
|
-
const blocks = Array.isArray(parsed.message.content) ? parsed.message.content : [];
|
|
92
|
-
for (const block of blocks) {
|
|
93
|
-
if (block?.type === 'tool_result') {
|
|
94
|
-
broadcastSync({ type: 'streaming_progress', sessionId, block, blockRole: 'tool_result', seq: eventCount, timestamp: Date.now() });
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
} else if (parsed?.type === 'result') {
|
|
98
|
-
const block = { type: 'result', result: parsed.result, subtype: parsed.subtype, duration_ms: parsed.duration_ms, total_cost_usd: parsed.total_cost_usd, is_error: !!parsed.is_error };
|
|
99
|
-
broadcastSync({ type: 'streaming_progress', sessionId, block, blockRole: 'result', seq: eventCount, isResult: true, timestamp: Date.now() });
|
|
100
|
-
}
|
|
101
|
-
};
|
|
102
|
-
try {
|
|
103
|
-
const config = {
|
|
104
|
-
verbose: true, outputFormat: 'stream-json', timeout: 1800000, print: true,
|
|
105
|
-
model, subAgent, onEvent, resumeSessionId,
|
|
106
|
-
onPid: () => {}, onProcess: (proc) => { ctrl.proc = proc; },
|
|
107
|
-
};
|
|
108
|
-
await runClaudeWithStreaming(content, spawnCwd, agentId, config);
|
|
109
|
-
if (!ctrl.aborted) {
|
|
110
|
-
const ev = { type: 'streaming_complete', sessionId, claudeSessionId: ctrl.claudeSessionId || null, agentId, eventCount, timestamp: Date.now() };
|
|
111
|
-
recordTerminal(sessionId, ev);
|
|
112
|
-
broadcastSync(ev);
|
|
113
|
-
}
|
|
114
|
-
} catch (e) {
|
|
115
|
-
if (!ctrl.aborted) {
|
|
116
|
-
const ev = { type: 'streaming_error', sessionId, claudeSessionId: ctrl.claudeSessionId || null, agentId, error: e.message || String(e), recoverable: false, timestamp: Date.now() };
|
|
117
|
-
recordTerminal(sessionId, ev);
|
|
118
|
-
broadcastSync(ev);
|
|
119
|
-
}
|
|
120
|
-
} finally {
|
|
121
|
-
activeChats.delete(sessionId);
|
|
122
|
-
broadcastSync({ type: 'chat_active_changed', reason: 'ended', sessionId, agentId, timestamp: Date.now() });
|
|
123
|
-
}
|
|
124
|
-
})();
|
|
125
|
-
|
|
126
|
-
return { sessionId, started: true };
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
// --- chat.active: list in-flight chats started via this server ---
|
|
130
|
-
router.handle('chat.active', () => {
|
|
131
|
-
const sessions = [];
|
|
132
|
-
for (const [sid, c] of activeChats) {
|
|
133
|
-
sessions.push({ sessionId: sid, claudeSessionId: c.claudeSessionId || null, agentId: c.agentId || null, model: c.model || null, cwd: c.cwd || null, startedAt: c.startedAt || null, pid: c.proc?.pid || null });
|
|
134
|
-
}
|
|
135
|
-
return { sessions };
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
// --- chat.cancel: abort an in-flight chat ---
|
|
139
|
-
router.handle('chat.cancel', (p) => {
|
|
140
|
-
const sid = p?.sessionId;
|
|
141
|
-
if (!sid) err(400, 'sessionId required');
|
|
142
|
-
const ctrl = activeChats.get(sid);
|
|
143
|
-
if (!ctrl) return { cancelled: false, reason: 'not-found' };
|
|
144
|
-
ctrl.aborted = true;
|
|
145
|
-
// Broadcast the terminal 'cancelled' frame BEFORE killing the proc so a
|
|
146
|
-
// remote cancellation (other tab, dashboard stop-all) does not read as a
|
|
147
|
-
// normal completion (finding 44). streaming_cancelled is in BROADCAST_TYPES
|
|
148
|
-
// so every connected client sees it; it is also buffered for re-subscribers.
|
|
149
|
-
const ev = { type: 'streaming_cancelled', sessionId: sid, claudeSessionId: ctrl.claudeSessionId || null, agentId: ctrl.agentId || null, cancelled: true, timestamp: Date.now() };
|
|
150
|
-
recordTerminal(sid, ev);
|
|
151
|
-
broadcastSync(ev);
|
|
152
|
-
try { ctrl.proc?.kill?.(); } catch {}
|
|
153
|
-
activeChats.delete(sid);
|
|
154
|
-
broadcastSync({ type: 'chat_active_changed', reason: 'cancelled', sessionId: sid, agentId: ctrl.agentId || null, timestamp: Date.now() });
|
|
155
|
-
return { cancelled: true };
|
|
156
|
-
});
|
|
157
|
-
}
|