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
package/site/app/js/backend.js
CHANGED
|
@@ -1,621 +1,602 @@
|
|
|
1
|
-
// agentgui backend client. Same-origin by default. Talks to:
|
|
2
|
-
// - HTTP: /health, /v1/history/* (served by ccsniff)
|
|
3
|
-
// - WS : /sync (JSON envelope: requests {m, r, p}; replies {r, d|e};
|
|
4
|
-
// broadcasts {type, sessionId, ...})
|
|
5
|
-
// No external acptoapi dependency. Chat + agent listing flow over the WS.
|
|
6
|
-
|
|
7
|
-
import { encode, decode } from './codec.js';
|
|
8
|
-
|
|
9
|
-
const KEY = 'agentgui.backend';
|
|
10
|
-
const DEFAULT_BACKEND = '';
|
|
11
|
-
|
|
12
|
-
function authToken() {
|
|
13
|
-
try { return (typeof window !== 'undefined' && window.__WS_TOKEN) || ''; } catch { return ''; }
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
// Set when a 401 is received mid-session so the app can show a reconnect banner.
|
|
17
|
-
let _sessionExpired = false;
|
|
18
|
-
const _sessionExpiredListeners = new Set();
|
|
19
|
-
export function onSessionExpired(fn) { _sessionExpiredListeners.add(fn); return () => _sessionExpiredListeners.delete(fn); }
|
|
20
|
-
function emitSessionExpired() {
|
|
21
|
-
if (_sessionExpired) return; // emit only once per session
|
|
22
|
-
_sessionExpired = true;
|
|
23
|
-
for (const fn of _sessionExpiredListeners) { try { fn(); } catch {} }
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
async function authedFetch(url, opts = {}) {
|
|
27
|
-
// Thread the agentgui token via the ?token= query param (exactly like the WS,
|
|
28
|
-
// EventSource, and image/download URLs) rather than an `Authorization: Bearer`
|
|
29
|
-
// header. A Bearer header OVERWRITES the browser's cached HTTP Basic Auth
|
|
30
|
-
// credentials, so behind an nginx `auth_basic` proxy (e.g. the boxone /gm
|
|
31
|
-
// deploy) every app fetch is rejected with 401 at the proxy before it ever
|
|
32
|
-
// reaches agentgui - the page HTML/JS load (browser sends Basic creds) but
|
|
33
|
-
// /health, /v1/history/*, and /api/* all 401. The query param coexists with
|
|
34
|
-
// Basic auth; agentgui accepts ?token= on every HTTP route. credentials are
|
|
35
|
-
// kept same-origin so the agentgui_token cookie also flows.
|
|
36
|
-
const r = await fetch(withToken(url), { credentials: 'same-origin', ...opts });
|
|
37
|
-
if (r.status === 401) emitSessionExpired();
|
|
38
|
-
return r;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function withToken(url) {
|
|
42
|
-
const tok = authToken();
|
|
43
|
-
if (!tok) return url;
|
|
44
|
-
return url + (url.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(tok);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function lsGet(k) { try { return localStorage.getItem(k); } catch { return null; } }
|
|
48
|
-
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch {} }
|
|
49
|
-
|
|
50
|
-
export function getBackend() {
|
|
51
|
-
const u = new URL(location.href);
|
|
52
|
-
const fromQs = u.searchParams.get('backend');
|
|
53
|
-
if (fromQs) {
|
|
54
|
-
// Only accept same-origin backends from the query string. A cross-origin
|
|
55
|
-
// ?backend= would receive the ?token= auth credential on every request,
|
|
56
|
-
// which is a credential-theft vector. Reject silently and fall through to
|
|
57
|
-
// the stored/default value.
|
|
58
|
-
let accepted = false;
|
|
59
|
-
try {
|
|
60
|
-
const parsed = new URL(fromQs, location.href);
|
|
61
|
-
const sameOrigin = parsed.origin === location.origin;
|
|
62
|
-
const isLocalDev = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
|
|
63
|
-
accepted = sameOrigin || isLocalDev;
|
|
64
|
-
} catch {}
|
|
65
|
-
if (accepted) { lsSet(KEY, fromQs); return fromQs; }
|
|
66
|
-
}
|
|
67
|
-
// Same-origin default: honour the server-injected router prefix so HTTP
|
|
68
|
-
// calls (/health, /v1/history/*, /api/*) stay under the proxied path
|
|
69
|
-
// (e.g. /gm) instead of hitting the site root, where a path-routing proxy
|
|
70
|
-
// (nginx Basic auth) challenges and the browser re-prompts for a password.
|
|
71
|
-
const bu = (typeof window !== 'undefined' && window.__BASE_URL) || '';
|
|
72
|
-
return lsGet(KEY) || DEFAULT_BACKEND || String(bu).replace(/\/+$/, '');
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function setBackend(url) { lsSet(KEY, url); }
|
|
76
|
-
|
|
77
|
-
export async function probeBackend(base) {
|
|
78
|
-
try {
|
|
79
|
-
const r = await authedFetch(base + '/health', { method: 'GET' });
|
|
80
|
-
if (!r.ok) return { ok: false, status: r.status };
|
|
81
|
-
return { ok: true, info: await r.json() };
|
|
82
|
-
} catch (e) {
|
|
83
|
-
return { ok: false, error: e.message };
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// ---------- Filesystem listing (HTTP, served by lib/http-handler.js) ----------
|
|
88
|
-
|
|
89
|
-
// List a directory for the Files / folder-browser view. Returns
|
|
90
|
-
// { path, segments, entries:[{name,type,size,modified,path}], roots }.
|
|
91
|
-
// An empty path lands on the server's first allow-root. The server confines
|
|
92
|
-
// every path to an allowlist root, so a 403 means the path is out of bounds.
|
|
93
|
-
export async function listDir(base, dirPath) {
|
|
94
|
-
// A path SEGMENT (encoded as %2F for its own slashes) is silently mangled by
|
|
95
|
-
// some reverse-proxy configs that normalize proxy_pass URIs - a nginx
|
|
96
|
-
// decode-then-reencode of the request path collapses %2F back into a literal
|
|
97
|
-
// slash before forwarding, so the app receives extra path segments instead
|
|
98
|
-
// of one opaque one, loses its leading slash on strip, and 403s every
|
|
99
|
-
// absolute-path listing that isn't the bare root. A query param is never
|
|
100
|
-
// subject to that URI-path normalization, so it survives any proxy in front
|
|
101
|
-
// of this app untouched.
|
|
102
|
-
const qs = dirPath ? '?dir=' + encodeURIComponent(dirPath) : '';
|
|
103
|
-
const r = await authedFetch(base + '/api/list' + qs);
|
|
104
|
-
const j = await r.json().catch(() => ({}));
|
|
105
|
-
if (!r.ok) {
|
|
106
|
-
// Carry the HTTP status so the app can map 403/404 to plain copy (W9).
|
|
107
|
-
const e = new Error(j.error || ('list: ' + r.status));
|
|
108
|
-
e.status = r.status;
|
|
109
|
-
throw e;
|
|
110
|
-
}
|
|
111
|
-
return j;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Fetch raw text/code file bytes for the Files preview pane. The server
|
|
115
|
-
// confines the path to the same allowlist roots as listDir and caps the size;
|
|
116
|
-
// a 403 means out-of-bounds or unsupported type, 404 missing. Returns
|
|
117
|
-
// { content, truncated }.
|
|
118
|
-
export async function readFile(base, filePath) {
|
|
119
|
-
// Query param, not a path segment - see listDir()'s comment above for why:
|
|
120
|
-
// a reverse proxy's URI normalization can silently collapse an encoded %2F
|
|
121
|
-
// path-segment slash back into a literal slash before forwarding.
|
|
122
|
-
const r = await authedFetch(base + '/api/file?path=' + encodeURIComponent(filePath));
|
|
123
|
-
if (!r.ok) {
|
|
124
|
-
const t = await r.text().catch(() => '');
|
|
125
|
-
throw new Error(t || ('file: ' + r.status));
|
|
126
|
-
}
|
|
127
|
-
const content = await r.text();
|
|
128
|
-
return { content, truncated: r.headers.get('X-File-Truncated') === '1' };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Same-origin URL for an image file (served by /api/image, token-threaded).
|
|
132
|
-
export function imageUrl(base, filePath) {
|
|
133
|
-
return withToken(base + '/api/image?path=' + encodeURIComponent(filePath));
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Same-origin download URL (served by /api/download, attachment disposition,
|
|
137
|
-
// confined + token-threaded). Used by the Files row download action.
|
|
138
|
-
export function downloadUrl(base, filePath) {
|
|
139
|
-
return withToken(base + '/api/download?path=' + encodeURIComponent(filePath));
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// ---------- File mutations (confined server endpoints) ----------
|
|
143
|
-
|
|
144
|
-
// Shared shape: each throws an Error carrying .status so the app maps
|
|
145
|
-
// 403/404/409/413 to plain human copy instead of the raw server string.
|
|
146
|
-
async function mutateJSON(base, route, body) {
|
|
147
|
-
const r = await authedFetch(base + route, {
|
|
148
|
-
method: 'POST',
|
|
149
|
-
headers: { 'Content-Type': 'application/json' },
|
|
150
|
-
body: JSON.stringify(body),
|
|
151
|
-
});
|
|
152
|
-
const j = await r.json().catch(() => ({}));
|
|
153
|
-
if (!r.ok) { const e = new Error(j.error || (route + ': ' + r.status)); e.status = r.status; throw e; }
|
|
154
|
-
return j;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Stat a path (GET /api/stat?path=<encoded>) for cwd validation. Returns {ok, dir}.
|
|
158
|
-
// Throws an Error carrying .status (403 outside roots, 404 missing).
|
|
159
|
-
export async function statPath(base, p) {
|
|
160
|
-
// Query param, not a path segment - see the comment in listDir() above for
|
|
161
|
-
// why: a reverse proxy's URI normalization can silently collapse an encoded
|
|
162
|
-
// %2F path-segment slash back into a literal slash before forwarding.
|
|
163
|
-
const r = await authedFetch(base + '/api/stat?path=' + encodeURIComponent(p));
|
|
164
|
-
const j = await r.json().catch(() => ({}));
|
|
165
|
-
if (!r.ok) { const e = new Error(j.error || ('stat: ' + r.status)); e.status = r.status; throw e; }
|
|
166
|
-
return j;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export function renameEntry(base, filePath, newName) { return mutateJSON(base, '/api/rename', { path: filePath, newName }); }
|
|
170
|
-
// deleteEntry's server response now includes trashId - the delete is a soft
|
|
171
|
-
// move-to-trash, not a permanent unlink, so the caller can offer an undo.
|
|
172
|
-
export function deleteEntry(base, filePath, recursive) { return mutateJSON(base, '/api/delete', { path: filePath, recursive: !!recursive }); }
|
|
173
|
-
export function restoreEntry(base, trashId) { return mutateJSON(base, '/api/restore', { trashId }); }
|
|
174
|
-
export function makeDir(base, dirPath, name) { return mutateJSON(base, '/api/mkdir', { dir: dirPath, name }); }
|
|
175
|
-
export function moveEntry(base, filePath, destDir, overwrite) { return mutateJSON(base, '/api/move', { path: filePath, destDir, overwrite: !!overwrite }); }
|
|
176
|
-
|
|
177
|
-
// Upload a File/Blob as raw bytes (PUT /api/upload-file). onProgress is not
|
|
178
|
-
// available via fetch streaming everywhere, so progress is per-file (done or
|
|
179
|
-
// not) - the kit UploadProgress shows per-file rows, which this satisfies.
|
|
180
|
-
export async function uploadFile(base, dirPath, file, overwrite) {
|
|
181
|
-
const qs = '?dir=' + encodeURIComponent(dirPath) + '&name=' + encodeURIComponent(file.name) + (overwrite ? '&overwrite=1' : '');
|
|
182
|
-
const r = await authedFetch(base + '/api/upload-file' + qs, { method: 'PUT', body: file });
|
|
183
|
-
const j = await r.json().catch(() => ({}));
|
|
184
|
-
if (!r.ok) { const e = new Error(j.error || ('upload: ' + r.status)); e.status = r.status; throw e; }
|
|
185
|
-
return j;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// ---------- History (HTTP, served by ccsniff) ----------
|
|
189
|
-
|
|
190
|
-
export async function listSessions(base) {
|
|
191
|
-
const r = await authedFetch(base + '/v1/history/sessions');
|
|
192
|
-
if (!r.ok) throw new Error('sessions: ' + r.status);
|
|
193
|
-
const j = await r.json();
|
|
194
|
-
return Array.isArray(j) ? j : (j.sessions || []);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
export async function getSessionEvents(base, sid) {
|
|
198
|
-
const r = await authedFetch(base + '/v1/history/sessions/' + encodeURIComponent(sid) + '/events');
|
|
199
|
-
if (!r.ok) throw new Error('events: ' + r.status);
|
|
200
|
-
const j = await r.json();
|
|
201
|
-
return j.events || [];
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export async function searchHistory(base, q, limit = 50) {
|
|
205
|
-
const r = await authedFetch(base + '/v1/history/search?q=' + encodeURIComponent(q) + '&limit=' + limit);
|
|
206
|
-
if (!r.ok) throw new Error('search: ' + r.status);
|
|
207
|
-
return r.json();
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Native EventSource auto-reconnects on its own schedule with no cap, unlike
|
|
211
|
-
// the WS client's explicit exponential backoff below - a flaky proxy can spin
|
|
212
|
-
// it into a tight reconnect loop. Wrap the lifecycle with the same manual
|
|
213
|
-
// close+reopen-on-timer backoff (start 500ms, double, cap 30000ms, reset on a
|
|
214
|
-
// successful connection) so both realtime transports behave consistently.
|
|
215
|
-
// Returns a thin handle exposing addEventListener('error', ...)/close(), the
|
|
216
|
-
// same surface callers already use against a raw EventSource.
|
|
217
|
-
export function streamHistory(base, onEvent) {
|
|
218
|
-
const EVENT_KINDS = ['hello', 'event', 'error', 'start', 'complete', 'conversation'];
|
|
219
|
-
let es = null;
|
|
220
|
-
let closed = false;
|
|
221
|
-
let attempts = 0;
|
|
222
|
-
let retryTimer = null;
|
|
223
|
-
const errorListeners = new Set();
|
|
224
|
-
|
|
225
|
-
function connect() {
|
|
226
|
-
if (closed) return;
|
|
227
|
-
es = new EventSource(withToken(base + '/v1/history/stream'));
|
|
228
|
-
for (const k of EVENT_KINDS) {
|
|
229
|
-
es.addEventListener(k, ev => {
|
|
230
|
-
if (k === 'hello') { attempts = 0; } // reset backoff on a live connection
|
|
231
|
-
let data; try { data = JSON.parse(ev.data); } catch { data = null; }
|
|
232
|
-
onEvent(k, data);
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
es.addEventListener('error', () => {
|
|
236
|
-
if (closed) return;
|
|
237
|
-
try { es.close(); } catch {}
|
|
238
|
-
for (const fn of errorListeners) { try { fn(); } catch {} }
|
|
239
|
-
const delay = Math.min(30000, 500 * Math.pow(2, attempts));
|
|
240
|
-
attempts++;
|
|
241
|
-
retryTimer = setTimeout(() => { retryTimer = null; connect(); }, delay);
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
connect();
|
|
245
|
-
|
|
246
|
-
return {
|
|
247
|
-
addEventListener(kind, fn) { if (kind === 'error') errorListeners.add(fn); },
|
|
248
|
-
close() {
|
|
249
|
-
closed = true;
|
|
250
|
-
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
|
251
|
-
if (es) { try { es.close(); } catch {} }
|
|
252
|
-
},
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// ---------- WebSocket client (/sync) ----------
|
|
257
|
-
|
|
258
|
-
const SYNC_PATH = '/sync';
|
|
259
|
-
let _ws = null;
|
|
260
|
-
let _wsReady = null; // Promise that resolves when ws is OPEN
|
|
261
|
-
let _nextReqId = 1;
|
|
262
|
-
const _pending = new Map(); // requestId -> { resolve, reject }
|
|
263
|
-
const _sessionListeners = new Map(); // sessionId -> Set<(event)=>void>
|
|
264
|
-
const _statusListeners = new Set(); // fn(state) where state in 'open'|'closed'|'error'|'reconnecting'
|
|
265
|
-
let _reconnectAttempts = 0;
|
|
266
|
-
let _reconnectTimer = null;
|
|
267
|
-
let _wsBaseHint = ''; // base remembered for reconnect
|
|
268
|
-
|
|
269
|
-
export function onWsStatus(fn) { _statusListeners.add(fn); return () => _statusListeners.delete(fn); }
|
|
270
|
-
function emitStatus(s) { for (const fn of _statusListeners) { try { fn(s); } catch {} } }
|
|
271
|
-
|
|
272
|
-
function scheduleReconnect() {
|
|
273
|
-
if (_reconnectTimer) return;
|
|
274
|
-
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
|
275
|
-
// Wait for online before retrying.
|
|
276
|
-
const onOnline = () => { window.removeEventListener('online', onOnline); _reconnectAttempts = 0; ensureWs(_wsBaseHint).catch(() => {}); };
|
|
277
|
-
window.addEventListener('online', onOnline);
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
const delay = Math.min(30000, 500 * Math.pow(2, _reconnectAttempts));
|
|
281
|
-
_reconnectAttempts++;
|
|
282
|
-
emitStatus('reconnecting');
|
|
283
|
-
_reconnectTimer = setTimeout(() => {
|
|
284
|
-
_reconnectTimer = null;
|
|
285
|
-
ensureWs(_wsBaseHint).catch(() => {});
|
|
286
|
-
}, delay);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function wsUrl(base) {
|
|
290
|
-
let proto, host, prefix = '';
|
|
291
|
-
if (base) {
|
|
292
|
-
try {
|
|
293
|
-
const u = new URL(base);
|
|
294
|
-
proto = u.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
295
|
-
host = u.host;
|
|
296
|
-
// A custom backend URL may carry a routing path prefix (e.g. https://host/gm).
|
|
297
|
-
prefix = u.pathname.replace(/\/+$/, '');
|
|
298
|
-
} catch {}
|
|
299
|
-
}
|
|
300
|
-
if (!host) {
|
|
301
|
-
proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
302
|
-
host = location.host;
|
|
303
|
-
// Same-origin: honour the server-injected router prefix so the socket is
|
|
304
|
-
// routed correctly behind a path-routing proxy (e.g. nginx /gm/ -> 9897).
|
|
305
|
-
const bu = (typeof window !== 'undefined' && window.__BASE_URL) || '';
|
|
306
|
-
prefix = String(bu).replace(/\/+$/, '');
|
|
307
|
-
}
|
|
308
|
-
const tok = authToken();
|
|
309
|
-
return proto + '//' + host + prefix + SYNC_PATH + (tok ? '?token=' + encodeURIComponent(tok) : '');
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function ensureWs(base) {
|
|
313
|
-
_wsBaseHint = base || _wsBaseHint;
|
|
314
|
-
if (_ws && _ws.readyState === 1) return _wsReady;
|
|
315
|
-
if (_ws && _ws.readyState === 0) return _wsReady;
|
|
316
|
-
_ws = new WebSocket(wsUrl(_wsBaseHint));
|
|
317
|
-
_wsReady = new Promise((resolve, reject) => {
|
|
318
|
-
_ws.addEventListener('open', () => {
|
|
319
|
-
_reconnectAttempts = 0;
|
|
320
|
-
emitStatus('open');
|
|
321
|
-
// Re-subscribe any session listeners that survived the disconnect.
|
|
322
|
-
for (const sid of _sessionListeners.keys()) {
|
|
323
|
-
try { _ws.send(encode({ m: 'conversation.subscribe', r: _nextReqId++, p: { sessionId: sid } })); } catch {}
|
|
324
|
-
}
|
|
325
|
-
// Reload the agent list after reconnect so the picker reflects reality.
|
|
326
|
-
emitStatus('reloadAgents');
|
|
327
|
-
resolve(_ws);
|
|
328
|
-
});
|
|
329
|
-
// The error Event has no .message; reject with a real Error so callers log
|
|
330
|
-
// a usable reason instead of "undefined" (e.g. "agents fetch failed: ...").
|
|
331
|
-
_ws.addEventListener('error', () => { emitStatus('error'); reject(new Error('WebSocket connection failed')); });
|
|
332
|
-
_ws.addEventListener('close', () => {
|
|
333
|
-
emitStatus('closed');
|
|
334
|
-
for (const [, p] of _pending) p.reject(new Error('Connection lost - please reconnect'));
|
|
335
|
-
_pending.clear();
|
|
336
|
-
_ws = null;
|
|
337
|
-
_wsReady = null;
|
|
338
|
-
// Auto-reconnect if there are listeners or callers will retry.
|
|
339
|
-
if (_sessionListeners.size > 0 || _statusListeners.size > 0) scheduleReconnect();
|
|
340
|
-
});
|
|
341
|
-
_ws.addEventListener('message', (ev) => {
|
|
342
|
-
let msg;
|
|
343
|
-
try {
|
|
344
|
-
// Server sends text frames (JSON via codec). ev.data is string.
|
|
345
|
-
msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : decode(ev.data);
|
|
346
|
-
} catch { return; }
|
|
347
|
-
// Reply to a prior request?
|
|
348
|
-
if (msg && msg.r !== undefined && (msg.d !== undefined || msg.e !== undefined)) {
|
|
349
|
-
const p = _pending.get(msg.r);
|
|
350
|
-
if (!p) return;
|
|
351
|
-
_pending.delete(msg.r);
|
|
352
|
-
if (msg.e) p.reject(new Error(msg.e.m || ('ws error ' + msg.e.c)));
|
|
353
|
-
else p.resolve(msg.d);
|
|
354
|
-
return;
|
|
355
|
-
}
|
|
356
|
-
// Unsolicited broadcast - route by sessionId to subscribers.
|
|
357
|
-
// Server may send a single event or a batch (array) per ws-optimizer.
|
|
358
|
-
const items = Array.isArray(msg) ? msg : [msg];
|
|
359
|
-
for (const item of items) {
|
|
360
|
-
const sid = item?.sessionId;
|
|
361
|
-
if (!sid) continue;
|
|
362
|
-
const subs = _sessionListeners.get(sid);
|
|
363
|
-
if (!subs) continue;
|
|
364
|
-
for (const fn of subs) { try { fn(item); } catch {} }
|
|
365
|
-
}
|
|
366
|
-
});
|
|
367
|
-
});
|
|
368
|
-
return _wsReady;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function wsCall(base, method, params, timeoutMs = 15000) {
|
|
372
|
-
return ensureWs(base).then(() => new Promise((resolve, reject) => {
|
|
373
|
-
const r = _nextReqId++;
|
|
374
|
-
const timer = setTimeout(() => {
|
|
375
|
-
if (_pending.delete(r)) reject(new Error(method + ' timed out'));
|
|
376
|
-
}, timeoutMs);
|
|
377
|
-
_pending.set(r, {
|
|
378
|
-
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
379
|
-
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
380
|
-
});
|
|
381
|
-
_ws.send(encode({ m: method, r, p: params || {} }));
|
|
382
|
-
}));
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function addSessionListener(sessionId, fn) {
|
|
386
|
-
if (!_sessionListeners.has(sessionId)) _sessionListeners.set(sessionId, new Set());
|
|
387
|
-
_sessionListeners.get(sessionId).add(fn);
|
|
388
|
-
return () => {
|
|
389
|
-
const s = _sessionListeners.get(sessionId);
|
|
390
|
-
if (s) { s.delete(fn); if (s.size === 0) _sessionListeners.delete(sessionId); }
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// ---------- Agents / models (WS) ----------
|
|
395
|
-
|
|
396
|
-
export async function listAgents(base) {
|
|
397
|
-
const { agents } = await wsCall(base, 'agents.list', {});
|
|
398
|
-
return agents || [];
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// Server's home/STARTUP_CWD - fetched once so "use default" can show what it
|
|
402
|
-
// actually resolves to (a tooltip) before the user clicks it, instead of only
|
|
403
|
-
// after.
|
|
404
|
-
export async function getHome(base) {
|
|
405
|
-
return wsCall(base, 'home', {});
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
export async function listActiveChats(base) {
|
|
409
|
-
try { const { sessions } = await wsCall(base, 'chat.active', {}); return sessions || []; }
|
|
410
|
-
catch { return []; }
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
export async function cancelChat(base, sessionId) {
|
|
414
|
-
try {
|
|
415
|
-
return await wsCall(base, 'chat.cancel', { sessionId });
|
|
416
|
-
} catch (e) {
|
|
417
|
-
// Surface a distinct error type so the app can show a toast instead of
|
|
418
|
-
// silently swallowing the failure (user clicked stop but nothing happened).
|
|
419
|
-
const err = new Error(e.message || 'stop request not delivered');
|
|
420
|
-
err.cancelFailed = true;
|
|
421
|
-
throw err;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
export async function restartAcpAgent(base, agentId) {
|
|
426
|
-
try { return await wsCall(base, 'acp.restart', { id: agentId }); }
|
|
427
|
-
catch { return { ok: false }; }
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
export async function listAgentModels(base, agentId) {
|
|
431
|
-
if (!agentId) return [];
|
|
432
|
-
try {
|
|
433
|
-
const { models } = await wsCall(base, 'agents.models', { id: agentId });
|
|
434
|
-
return models || [];
|
|
435
|
-
} catch { return []; }
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
export async function
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
export async function
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
//
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
//
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
if (s === '
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
await new Promise(r => { resolveWait = r; });
|
|
604
|
-
continue;
|
|
605
|
-
}
|
|
606
|
-
yield queue.shift();
|
|
607
|
-
}
|
|
608
|
-
if (errored) {
|
|
609
|
-
// Sanitize raw server strings: strip internal stack traces / JSON blobs
|
|
610
|
-
// and map common cases to plain human copy.
|
|
611
|
-
let displayError = (typeof errored === 'string' ? errored : 'streaming error').trim();
|
|
612
|
-
if (/^\{/.test(displayError) || displayError.length > 300) displayError = 'An error occurred while streaming the response.';
|
|
613
|
-
yield { type: 'error', error: displayError };
|
|
614
|
-
}
|
|
615
|
-
} finally {
|
|
616
|
-
unsub();
|
|
617
|
-
if (graceTimer) { clearTimeout(graceTimer); graceTimer = null; }
|
|
618
|
-
if (typeof unsubWs === 'function') unsubWs();
|
|
619
|
-
if (signal) signal.removeEventListener?.('abort', onAbort);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
1
|
+
// agentgui backend client. Same-origin by default. Talks to:
|
|
2
|
+
// - HTTP: /health, /v1/history/* (served by ccsniff)
|
|
3
|
+
// - WS : /sync (JSON envelope: requests {m, r, p}; replies {r, d|e};
|
|
4
|
+
// broadcasts {type, sessionId, ...})
|
|
5
|
+
// No external acptoapi dependency. Chat + agent listing flow over the WS.
|
|
6
|
+
|
|
7
|
+
import { encode, decode } from './codec.js';
|
|
8
|
+
|
|
9
|
+
const KEY = 'agentgui.backend';
|
|
10
|
+
const DEFAULT_BACKEND = '';
|
|
11
|
+
|
|
12
|
+
function authToken() {
|
|
13
|
+
try { return (typeof window !== 'undefined' && window.__WS_TOKEN) || ''; } catch { return ''; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Set when a 401 is received mid-session so the app can show a reconnect banner.
|
|
17
|
+
let _sessionExpired = false;
|
|
18
|
+
const _sessionExpiredListeners = new Set();
|
|
19
|
+
export function onSessionExpired(fn) { _sessionExpiredListeners.add(fn); return () => _sessionExpiredListeners.delete(fn); }
|
|
20
|
+
function emitSessionExpired() {
|
|
21
|
+
if (_sessionExpired) return; // emit only once per session
|
|
22
|
+
_sessionExpired = true;
|
|
23
|
+
for (const fn of _sessionExpiredListeners) { try { fn(); } catch {} }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function authedFetch(url, opts = {}) {
|
|
27
|
+
// Thread the agentgui token via the ?token= query param (exactly like the WS,
|
|
28
|
+
// EventSource, and image/download URLs) rather than an `Authorization: Bearer`
|
|
29
|
+
// header. A Bearer header OVERWRITES the browser's cached HTTP Basic Auth
|
|
30
|
+
// credentials, so behind an nginx `auth_basic` proxy (e.g. the boxone /gm
|
|
31
|
+
// deploy) every app fetch is rejected with 401 at the proxy before it ever
|
|
32
|
+
// reaches agentgui - the page HTML/JS load (browser sends Basic creds) but
|
|
33
|
+
// /health, /v1/history/*, and /api/* all 401. The query param coexists with
|
|
34
|
+
// Basic auth; agentgui accepts ?token= on every HTTP route. credentials are
|
|
35
|
+
// kept same-origin so the agentgui_token cookie also flows.
|
|
36
|
+
const r = await fetch(withToken(url), { credentials: 'same-origin', ...opts });
|
|
37
|
+
if (r.status === 401) emitSessionExpired();
|
|
38
|
+
return r;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function withToken(url) {
|
|
42
|
+
const tok = authToken();
|
|
43
|
+
if (!tok) return url;
|
|
44
|
+
return url + (url.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(tok);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function lsGet(k) { try { return localStorage.getItem(k); } catch { return null; } }
|
|
48
|
+
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch {} }
|
|
49
|
+
|
|
50
|
+
export function getBackend() {
|
|
51
|
+
const u = new URL(location.href);
|
|
52
|
+
const fromQs = u.searchParams.get('backend');
|
|
53
|
+
if (fromQs) {
|
|
54
|
+
// Only accept same-origin backends from the query string. A cross-origin
|
|
55
|
+
// ?backend= would receive the ?token= auth credential on every request,
|
|
56
|
+
// which is a credential-theft vector. Reject silently and fall through to
|
|
57
|
+
// the stored/default value.
|
|
58
|
+
let accepted = false;
|
|
59
|
+
try {
|
|
60
|
+
const parsed = new URL(fromQs, location.href);
|
|
61
|
+
const sameOrigin = parsed.origin === location.origin;
|
|
62
|
+
const isLocalDev = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
|
|
63
|
+
accepted = sameOrigin || isLocalDev;
|
|
64
|
+
} catch {}
|
|
65
|
+
if (accepted) { lsSet(KEY, fromQs); return fromQs; }
|
|
66
|
+
}
|
|
67
|
+
// Same-origin default: honour the server-injected router prefix so HTTP
|
|
68
|
+
// calls (/health, /v1/history/*, /api/*) stay under the proxied path
|
|
69
|
+
// (e.g. /gm) instead of hitting the site root, where a path-routing proxy
|
|
70
|
+
// (nginx Basic auth) challenges and the browser re-prompts for a password.
|
|
71
|
+
const bu = (typeof window !== 'undefined' && window.__BASE_URL) || '';
|
|
72
|
+
return lsGet(KEY) || DEFAULT_BACKEND || String(bu).replace(/\/+$/, '');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function setBackend(url) { lsSet(KEY, url); }
|
|
76
|
+
|
|
77
|
+
export async function probeBackend(base) {
|
|
78
|
+
try {
|
|
79
|
+
const r = await authedFetch(base + '/health', { method: 'GET' });
|
|
80
|
+
if (!r.ok) return { ok: false, status: r.status };
|
|
81
|
+
return { ok: true, info: await r.json() };
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return { ok: false, error: e.message };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------- Filesystem listing (HTTP, served by lib/http-handler.js) ----------
|
|
88
|
+
|
|
89
|
+
// List a directory for the Files / folder-browser view. Returns
|
|
90
|
+
// { path, segments, entries:[{name,type,size,modified,path}], roots }.
|
|
91
|
+
// An empty path lands on the server's first allow-root. The server confines
|
|
92
|
+
// every path to an allowlist root, so a 403 means the path is out of bounds.
|
|
93
|
+
export async function listDir(base, dirPath) {
|
|
94
|
+
// A path SEGMENT (encoded as %2F for its own slashes) is silently mangled by
|
|
95
|
+
// some reverse-proxy configs that normalize proxy_pass URIs - a nginx
|
|
96
|
+
// decode-then-reencode of the request path collapses %2F back into a literal
|
|
97
|
+
// slash before forwarding, so the app receives extra path segments instead
|
|
98
|
+
// of one opaque one, loses its leading slash on strip, and 403s every
|
|
99
|
+
// absolute-path listing that isn't the bare root. A query param is never
|
|
100
|
+
// subject to that URI-path normalization, so it survives any proxy in front
|
|
101
|
+
// of this app untouched.
|
|
102
|
+
const qs = dirPath ? '?dir=' + encodeURIComponent(dirPath) : '';
|
|
103
|
+
const r = await authedFetch(base + '/api/list' + qs);
|
|
104
|
+
const j = await r.json().catch(() => ({}));
|
|
105
|
+
if (!r.ok) {
|
|
106
|
+
// Carry the HTTP status so the app can map 403/404 to plain copy (W9).
|
|
107
|
+
const e = new Error(j.error || ('list: ' + r.status));
|
|
108
|
+
e.status = r.status;
|
|
109
|
+
throw e;
|
|
110
|
+
}
|
|
111
|
+
return j;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Fetch raw text/code file bytes for the Files preview pane. The server
|
|
115
|
+
// confines the path to the same allowlist roots as listDir and caps the size;
|
|
116
|
+
// a 403 means out-of-bounds or unsupported type, 404 missing. Returns
|
|
117
|
+
// { content, truncated }.
|
|
118
|
+
export async function readFile(base, filePath) {
|
|
119
|
+
// Query param, not a path segment - see listDir()'s comment above for why:
|
|
120
|
+
// a reverse proxy's URI normalization can silently collapse an encoded %2F
|
|
121
|
+
// path-segment slash back into a literal slash before forwarding.
|
|
122
|
+
const r = await authedFetch(base + '/api/file?path=' + encodeURIComponent(filePath));
|
|
123
|
+
if (!r.ok) {
|
|
124
|
+
const t = await r.text().catch(() => '');
|
|
125
|
+
throw new Error(t || ('file: ' + r.status));
|
|
126
|
+
}
|
|
127
|
+
const content = await r.text();
|
|
128
|
+
return { content, truncated: r.headers.get('X-File-Truncated') === '1' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Same-origin URL for an image file (served by /api/image, token-threaded).
|
|
132
|
+
export function imageUrl(base, filePath) {
|
|
133
|
+
return withToken(base + '/api/image?path=' + encodeURIComponent(filePath));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Same-origin download URL (served by /api/download, attachment disposition,
|
|
137
|
+
// confined + token-threaded). Used by the Files row download action.
|
|
138
|
+
export function downloadUrl(base, filePath) {
|
|
139
|
+
return withToken(base + '/api/download?path=' + encodeURIComponent(filePath));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------- File mutations (confined server endpoints) ----------
|
|
143
|
+
|
|
144
|
+
// Shared shape: each throws an Error carrying .status so the app maps
|
|
145
|
+
// 403/404/409/413 to plain human copy instead of the raw server string.
|
|
146
|
+
async function mutateJSON(base, route, body) {
|
|
147
|
+
const r = await authedFetch(base + route, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: { 'Content-Type': 'application/json' },
|
|
150
|
+
body: JSON.stringify(body),
|
|
151
|
+
});
|
|
152
|
+
const j = await r.json().catch(() => ({}));
|
|
153
|
+
if (!r.ok) { const e = new Error(j.error || (route + ': ' + r.status)); e.status = r.status; throw e; }
|
|
154
|
+
return j;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Stat a path (GET /api/stat?path=<encoded>) for cwd validation. Returns {ok, dir}.
|
|
158
|
+
// Throws an Error carrying .status (403 outside roots, 404 missing).
|
|
159
|
+
export async function statPath(base, p) {
|
|
160
|
+
// Query param, not a path segment - see the comment in listDir() above for
|
|
161
|
+
// why: a reverse proxy's URI normalization can silently collapse an encoded
|
|
162
|
+
// %2F path-segment slash back into a literal slash before forwarding.
|
|
163
|
+
const r = await authedFetch(base + '/api/stat?path=' + encodeURIComponent(p));
|
|
164
|
+
const j = await r.json().catch(() => ({}));
|
|
165
|
+
if (!r.ok) { const e = new Error(j.error || ('stat: ' + r.status)); e.status = r.status; throw e; }
|
|
166
|
+
return j;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function renameEntry(base, filePath, newName) { return mutateJSON(base, '/api/rename', { path: filePath, newName }); }
|
|
170
|
+
// deleteEntry's server response now includes trashId - the delete is a soft
|
|
171
|
+
// move-to-trash, not a permanent unlink, so the caller can offer an undo.
|
|
172
|
+
export function deleteEntry(base, filePath, recursive) { return mutateJSON(base, '/api/delete', { path: filePath, recursive: !!recursive }); }
|
|
173
|
+
export function restoreEntry(base, trashId) { return mutateJSON(base, '/api/restore', { trashId }); }
|
|
174
|
+
export function makeDir(base, dirPath, name) { return mutateJSON(base, '/api/mkdir', { dir: dirPath, name }); }
|
|
175
|
+
export function moveEntry(base, filePath, destDir, overwrite) { return mutateJSON(base, '/api/move', { path: filePath, destDir, overwrite: !!overwrite }); }
|
|
176
|
+
|
|
177
|
+
// Upload a File/Blob as raw bytes (PUT /api/upload-file). onProgress is not
|
|
178
|
+
// available via fetch streaming everywhere, so progress is per-file (done or
|
|
179
|
+
// not) - the kit UploadProgress shows per-file rows, which this satisfies.
|
|
180
|
+
export async function uploadFile(base, dirPath, file, overwrite) {
|
|
181
|
+
const qs = '?dir=' + encodeURIComponent(dirPath) + '&name=' + encodeURIComponent(file.name) + (overwrite ? '&overwrite=1' : '');
|
|
182
|
+
const r = await authedFetch(base + '/api/upload-file' + qs, { method: 'PUT', body: file });
|
|
183
|
+
const j = await r.json().catch(() => ({}));
|
|
184
|
+
if (!r.ok) { const e = new Error(j.error || ('upload: ' + r.status)); e.status = r.status; throw e; }
|
|
185
|
+
return j;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------- History (HTTP, served by ccsniff) ----------
|
|
189
|
+
|
|
190
|
+
export async function listSessions(base) {
|
|
191
|
+
const r = await authedFetch(base + '/v1/history/sessions');
|
|
192
|
+
if (!r.ok) throw new Error('sessions: ' + r.status);
|
|
193
|
+
const j = await r.json();
|
|
194
|
+
return Array.isArray(j) ? j : (j.sessions || []);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function getSessionEvents(base, sid) {
|
|
198
|
+
const r = await authedFetch(base + '/v1/history/sessions/' + encodeURIComponent(sid) + '/events');
|
|
199
|
+
if (!r.ok) throw new Error('events: ' + r.status);
|
|
200
|
+
const j = await r.json();
|
|
201
|
+
return j.events || [];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function searchHistory(base, q, limit = 50) {
|
|
205
|
+
const r = await authedFetch(base + '/v1/history/search?q=' + encodeURIComponent(q) + '&limit=' + limit);
|
|
206
|
+
if (!r.ok) throw new Error('search: ' + r.status);
|
|
207
|
+
return r.json();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Native EventSource auto-reconnects on its own schedule with no cap, unlike
|
|
211
|
+
// the WS client's explicit exponential backoff below - a flaky proxy can spin
|
|
212
|
+
// it into a tight reconnect loop. Wrap the lifecycle with the same manual
|
|
213
|
+
// close+reopen-on-timer backoff (start 500ms, double, cap 30000ms, reset on a
|
|
214
|
+
// successful connection) so both realtime transports behave consistently.
|
|
215
|
+
// Returns a thin handle exposing addEventListener('error', ...)/close(), the
|
|
216
|
+
// same surface callers already use against a raw EventSource.
|
|
217
|
+
export function streamHistory(base, onEvent) {
|
|
218
|
+
const EVENT_KINDS = ['hello', 'event', 'error', 'start', 'complete', 'conversation'];
|
|
219
|
+
let es = null;
|
|
220
|
+
let closed = false;
|
|
221
|
+
let attempts = 0;
|
|
222
|
+
let retryTimer = null;
|
|
223
|
+
const errorListeners = new Set();
|
|
224
|
+
|
|
225
|
+
function connect() {
|
|
226
|
+
if (closed) return;
|
|
227
|
+
es = new EventSource(withToken(base + '/v1/history/stream'));
|
|
228
|
+
for (const k of EVENT_KINDS) {
|
|
229
|
+
es.addEventListener(k, ev => {
|
|
230
|
+
if (k === 'hello') { attempts = 0; } // reset backoff on a live connection
|
|
231
|
+
let data; try { data = JSON.parse(ev.data); } catch { data = null; }
|
|
232
|
+
onEvent(k, data);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
es.addEventListener('error', () => {
|
|
236
|
+
if (closed) return;
|
|
237
|
+
try { es.close(); } catch {}
|
|
238
|
+
for (const fn of errorListeners) { try { fn(); } catch {} }
|
|
239
|
+
const delay = Math.min(30000, 500 * Math.pow(2, attempts));
|
|
240
|
+
attempts++;
|
|
241
|
+
retryTimer = setTimeout(() => { retryTimer = null; connect(); }, delay);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
connect();
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
addEventListener(kind, fn) { if (kind === 'error') errorListeners.add(fn); },
|
|
248
|
+
close() {
|
|
249
|
+
closed = true;
|
|
250
|
+
if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; }
|
|
251
|
+
if (es) { try { es.close(); } catch {} }
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------- WebSocket client (/sync) ----------
|
|
257
|
+
|
|
258
|
+
const SYNC_PATH = '/sync';
|
|
259
|
+
let _ws = null;
|
|
260
|
+
let _wsReady = null; // Promise that resolves when ws is OPEN
|
|
261
|
+
let _nextReqId = 1;
|
|
262
|
+
const _pending = new Map(); // requestId -> { resolve, reject }
|
|
263
|
+
const _sessionListeners = new Map(); // sessionId -> Set<(event)=>void>
|
|
264
|
+
const _statusListeners = new Set(); // fn(state) where state in 'open'|'closed'|'error'|'reconnecting'
|
|
265
|
+
let _reconnectAttempts = 0;
|
|
266
|
+
let _reconnectTimer = null;
|
|
267
|
+
let _wsBaseHint = ''; // base remembered for reconnect
|
|
268
|
+
|
|
269
|
+
export function onWsStatus(fn) { _statusListeners.add(fn); return () => _statusListeners.delete(fn); }
|
|
270
|
+
function emitStatus(s) { for (const fn of _statusListeners) { try { fn(s); } catch {} } }
|
|
271
|
+
|
|
272
|
+
function scheduleReconnect() {
|
|
273
|
+
if (_reconnectTimer) return;
|
|
274
|
+
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
|
275
|
+
// Wait for online before retrying.
|
|
276
|
+
const onOnline = () => { window.removeEventListener('online', onOnline); _reconnectAttempts = 0; ensureWs(_wsBaseHint).catch(() => {}); };
|
|
277
|
+
window.addEventListener('online', onOnline);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const delay = Math.min(30000, 500 * Math.pow(2, _reconnectAttempts));
|
|
281
|
+
_reconnectAttempts++;
|
|
282
|
+
emitStatus('reconnecting');
|
|
283
|
+
_reconnectTimer = setTimeout(() => {
|
|
284
|
+
_reconnectTimer = null;
|
|
285
|
+
ensureWs(_wsBaseHint).catch(() => {});
|
|
286
|
+
}, delay);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function wsUrl(base) {
|
|
290
|
+
let proto, host, prefix = '';
|
|
291
|
+
if (base) {
|
|
292
|
+
try {
|
|
293
|
+
const u = new URL(base);
|
|
294
|
+
proto = u.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
295
|
+
host = u.host;
|
|
296
|
+
// A custom backend URL may carry a routing path prefix (e.g. https://host/gm).
|
|
297
|
+
prefix = u.pathname.replace(/\/+$/, '');
|
|
298
|
+
} catch {}
|
|
299
|
+
}
|
|
300
|
+
if (!host) {
|
|
301
|
+
proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
302
|
+
host = location.host;
|
|
303
|
+
// Same-origin: honour the server-injected router prefix so the socket is
|
|
304
|
+
// routed correctly behind a path-routing proxy (e.g. nginx /gm/ -> 9897).
|
|
305
|
+
const bu = (typeof window !== 'undefined' && window.__BASE_URL) || '';
|
|
306
|
+
prefix = String(bu).replace(/\/+$/, '');
|
|
307
|
+
}
|
|
308
|
+
const tok = authToken();
|
|
309
|
+
return proto + '//' + host + prefix + SYNC_PATH + (tok ? '?token=' + encodeURIComponent(tok) : '');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function ensureWs(base) {
|
|
313
|
+
_wsBaseHint = base || _wsBaseHint;
|
|
314
|
+
if (_ws && _ws.readyState === 1) return _wsReady;
|
|
315
|
+
if (_ws && _ws.readyState === 0) return _wsReady;
|
|
316
|
+
_ws = new WebSocket(wsUrl(_wsBaseHint));
|
|
317
|
+
_wsReady = new Promise((resolve, reject) => {
|
|
318
|
+
_ws.addEventListener('open', () => {
|
|
319
|
+
_reconnectAttempts = 0;
|
|
320
|
+
emitStatus('open');
|
|
321
|
+
// Re-subscribe any session listeners that survived the disconnect.
|
|
322
|
+
for (const sid of _sessionListeners.keys()) {
|
|
323
|
+
try { _ws.send(encode({ m: 'conversation.subscribe', r: _nextReqId++, p: { sessionId: sid } })); } catch {}
|
|
324
|
+
}
|
|
325
|
+
// Reload the agent list after reconnect so the picker reflects reality.
|
|
326
|
+
emitStatus('reloadAgents');
|
|
327
|
+
resolve(_ws);
|
|
328
|
+
});
|
|
329
|
+
// The error Event has no .message; reject with a real Error so callers log
|
|
330
|
+
// a usable reason instead of "undefined" (e.g. "agents fetch failed: ...").
|
|
331
|
+
_ws.addEventListener('error', () => { emitStatus('error'); reject(new Error('WebSocket connection failed')); });
|
|
332
|
+
_ws.addEventListener('close', () => {
|
|
333
|
+
emitStatus('closed');
|
|
334
|
+
for (const [, p] of _pending) p.reject(new Error('Connection lost - please reconnect'));
|
|
335
|
+
_pending.clear();
|
|
336
|
+
_ws = null;
|
|
337
|
+
_wsReady = null;
|
|
338
|
+
// Auto-reconnect if there are listeners or callers will retry.
|
|
339
|
+
if (_sessionListeners.size > 0 || _statusListeners.size > 0) scheduleReconnect();
|
|
340
|
+
});
|
|
341
|
+
_ws.addEventListener('message', (ev) => {
|
|
342
|
+
let msg;
|
|
343
|
+
try {
|
|
344
|
+
// Server sends text frames (JSON via codec). ev.data is string.
|
|
345
|
+
msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : decode(ev.data);
|
|
346
|
+
} catch { return; }
|
|
347
|
+
// Reply to a prior request?
|
|
348
|
+
if (msg && msg.r !== undefined && (msg.d !== undefined || msg.e !== undefined)) {
|
|
349
|
+
const p = _pending.get(msg.r);
|
|
350
|
+
if (!p) return;
|
|
351
|
+
_pending.delete(msg.r);
|
|
352
|
+
if (msg.e) p.reject(new Error(msg.e.m || ('ws error ' + msg.e.c)));
|
|
353
|
+
else p.resolve(msg.d);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
// Unsolicited broadcast - route by sessionId to subscribers.
|
|
357
|
+
// Server may send a single event or a batch (array) per ws-optimizer.
|
|
358
|
+
const items = Array.isArray(msg) ? msg : [msg];
|
|
359
|
+
for (const item of items) {
|
|
360
|
+
const sid = item?.sessionId;
|
|
361
|
+
if (!sid) continue;
|
|
362
|
+
const subs = _sessionListeners.get(sid);
|
|
363
|
+
if (!subs) continue;
|
|
364
|
+
for (const fn of subs) { try { fn(item); } catch {} }
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
return _wsReady;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function wsCall(base, method, params, timeoutMs = 15000) {
|
|
372
|
+
return ensureWs(base).then(() => new Promise((resolve, reject) => {
|
|
373
|
+
const r = _nextReqId++;
|
|
374
|
+
const timer = setTimeout(() => {
|
|
375
|
+
if (_pending.delete(r)) reject(new Error(method + ' timed out'));
|
|
376
|
+
}, timeoutMs);
|
|
377
|
+
_pending.set(r, {
|
|
378
|
+
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
379
|
+
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
380
|
+
});
|
|
381
|
+
_ws.send(encode({ m: method, r, p: params || {} }));
|
|
382
|
+
}));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function addSessionListener(sessionId, fn) {
|
|
386
|
+
if (!_sessionListeners.has(sessionId)) _sessionListeners.set(sessionId, new Set());
|
|
387
|
+
_sessionListeners.get(sessionId).add(fn);
|
|
388
|
+
return () => {
|
|
389
|
+
const s = _sessionListeners.get(sessionId);
|
|
390
|
+
if (s) { s.delete(fn); if (s.size === 0) _sessionListeners.delete(sessionId); }
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ---------- Agents / models (WS) ----------
|
|
395
|
+
|
|
396
|
+
export async function listAgents(base) {
|
|
397
|
+
const { agents } = await wsCall(base, 'agents.list', {});
|
|
398
|
+
return agents || [];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Server's home/STARTUP_CWD - fetched once so "use default" can show what it
|
|
402
|
+
// actually resolves to (a tooltip) before the user clicks it, instead of only
|
|
403
|
+
// after.
|
|
404
|
+
export async function getHome(base) {
|
|
405
|
+
return wsCall(base, 'home', {});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export async function listActiveChats(base) {
|
|
409
|
+
try { const { sessions } = await wsCall(base, 'chat.active', {}); return sessions || []; }
|
|
410
|
+
catch { return []; }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export async function cancelChat(base, sessionId) {
|
|
414
|
+
try {
|
|
415
|
+
return await wsCall(base, 'chat.cancel', { sessionId });
|
|
416
|
+
} catch (e) {
|
|
417
|
+
// Surface a distinct error type so the app can show a toast instead of
|
|
418
|
+
// silently swallowing the failure (user clicked stop but nothing happened).
|
|
419
|
+
const err = new Error(e.message || 'stop request not delivered');
|
|
420
|
+
err.cancelFailed = true;
|
|
421
|
+
throw err;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export async function restartAcpAgent(base, agentId) {
|
|
426
|
+
try { return await wsCall(base, 'acp.restart', { id: agentId }); }
|
|
427
|
+
catch { return { ok: false }; }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export async function listAgentModels(base, agentId) {
|
|
431
|
+
if (!agentId) return [];
|
|
432
|
+
try {
|
|
433
|
+
const { models } = await wsCall(base, 'agents.models', { id: agentId });
|
|
434
|
+
return models || [];
|
|
435
|
+
} catch { return []; }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---------- Git / worktrees (WS) ----------
|
|
439
|
+
|
|
440
|
+
export async function gitStatus(base, { cwd } = {}) {
|
|
441
|
+
const { files } = await wsCall(base, 'git.status', { cwd });
|
|
442
|
+
return files || [];
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export async function gitDiff(base, { cwd, file, staged } = {}) {
|
|
446
|
+
return wsCall(base, 'git.diff', { cwd, file, staged });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export async function gitLog(base, { cwd, limit } = {}) {
|
|
450
|
+
const { commits } = await wsCall(base, 'git.log', { cwd, limit });
|
|
451
|
+
return commits || [];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export async function worktreeList(base, { cwd } = {}) {
|
|
455
|
+
const { worktrees } = await wsCall(base, 'worktree.list', { cwd });
|
|
456
|
+
return worktrees || [];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export async function worktreeCreate(base, { cwd, path, branch, newBranch } = {}) {
|
|
460
|
+
return wsCall(base, 'worktree.create', { cwd, path, branch, newBranch });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export async function worktreeRemove(base, { cwd, path, force } = {}) {
|
|
464
|
+
return wsCall(base, 'worktree.remove', { cwd, path, force });
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ---------- Streaming chat (WS) ----------
|
|
468
|
+
//
|
|
469
|
+
// Yields events of shape:
|
|
470
|
+
// { type: 'text', text: '...' } - assistant text deltas
|
|
471
|
+
// { type: 'tool', block: {...} } - tool_use blocks
|
|
472
|
+
// { type: 'result', block: {...} } - terminal result block
|
|
473
|
+
// { type: 'error', error: '...' }
|
|
474
|
+
//
|
|
475
|
+
// Caller signature kept compatible with the previous HTTP/SSE impl.
|
|
476
|
+
export async function* streamChat(base, { model, messages, signal, agentId, resumeSid, cwd }) {
|
|
477
|
+
// The last user message is the prompt; agentgui's claude-runner spawns the
|
|
478
|
+
// agent for a single prompt. Multi-turn continuity is the agent's own resume.
|
|
479
|
+
const last = messages[messages.length - 1];
|
|
480
|
+
const content = last?.content || '';
|
|
481
|
+
if (!content) return;
|
|
482
|
+
|
|
483
|
+
const resolvedAgentId = agentId || 'claude-code';
|
|
484
|
+
const resolvedModel = model || undefined;
|
|
485
|
+
|
|
486
|
+
// Queue events here; the async iterator pulls from it.
|
|
487
|
+
const queue = [];
|
|
488
|
+
let resolveWait = null;
|
|
489
|
+
let done = false;
|
|
490
|
+
let errored = null;
|
|
491
|
+
const push = (ev) => { queue.push(ev); if (resolveWait) { resolveWait(); resolveWait = null; } };
|
|
492
|
+
|
|
493
|
+
// Kick off the chat on the server.
|
|
494
|
+
let started;
|
|
495
|
+
try {
|
|
496
|
+
started = await wsCall(base, 'chat.sendMessage', { content, agentId: resolvedAgentId, model: resolvedModel, resumeSid, cwd });
|
|
497
|
+
} catch (e) {
|
|
498
|
+
yield { type: 'error', error: e.message };
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const sessionId = started?.sessionId;
|
|
502
|
+
if (!sessionId) { yield { type: 'error', error: 'no sessionId from server' }; return; }
|
|
503
|
+
|
|
504
|
+
const finish = () => { done = true; if (resolveWait) { resolveWait(); resolveWait = null; } };
|
|
505
|
+
|
|
506
|
+
const unsub = addSessionListener(sessionId, (ev) => {
|
|
507
|
+
if (ev.type === 'streaming_session') {
|
|
508
|
+
// claude's real session id - surface it so the host can --resume this
|
|
509
|
+
// conversation on the next turn (multi-turn continuity).
|
|
510
|
+
if (ev.claudeSessionId) push({ type: 'session', sessionId: ev.claudeSessionId });
|
|
511
|
+
} else if (ev.type === 'streaming_progress') {
|
|
512
|
+
const block = ev.block;
|
|
513
|
+
if (block?.type === 'text' && block.text) push({ type: 'text', text: block.text });
|
|
514
|
+
else if (block?.type === 'thinking' && block.thinking) push({ type: 'thinking', text: block.thinking });
|
|
515
|
+
else if (block?.type === 'tool_use') push({ type: 'tool', block });
|
|
516
|
+
else if (block?.type === 'tool_result') push({ type: 'tool_result', block });
|
|
517
|
+
else if (block?.type === 'result') push({ type: 'result', block });
|
|
518
|
+
} else if (ev.type === 'streaming_complete') {
|
|
519
|
+
finish();
|
|
520
|
+
} else if (ev.type === 'streaming_cancelled') {
|
|
521
|
+
push({ type: 'cancelled' });
|
|
522
|
+
finish();
|
|
523
|
+
} else if (ev.type === 'streaming_error') {
|
|
524
|
+
// A remote stop (another tab / dashboard stop-all) is not a failure: the
|
|
525
|
+
// server marks it cancelled; surface it as a distinct event so the app
|
|
526
|
+
// can label the turn 'stopped' instead of a normal finish or error red.
|
|
527
|
+
if (ev.cancelled || ev.error === 'cancelled') {
|
|
528
|
+
push({ type: 'cancelled' });
|
|
529
|
+
} else {
|
|
530
|
+
errored = ev.error || 'streaming error';
|
|
531
|
+
}
|
|
532
|
+
finish();
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// If the websocket drops mid-stream, streaming_complete will never arrive.
|
|
537
|
+
// Don't fail immediately: the client auto-reconnects (and the open handler
|
|
538
|
+
// re-subscribes this session's listeners), so give it a ~12s grace window.
|
|
539
|
+
// Only if the socket is still down when the timer expires do we surface the
|
|
540
|
+
// error and end the iterator instead of hanging forever.
|
|
541
|
+
const WS_GRACE_MS = 12000;
|
|
542
|
+
let graceTimer = null;
|
|
543
|
+
const onWs = (s) => {
|
|
544
|
+
if (done) return;
|
|
545
|
+
if (s === 'open') {
|
|
546
|
+
// Reconnected in time - the open handler already re-subscribed us.
|
|
547
|
+
if (graceTimer) { clearTimeout(graceTimer); graceTimer = null; }
|
|
548
|
+
// streaming_complete is fire-and-forget with no replay: if the turn
|
|
549
|
+
// finished while the socket was down, no terminal frame will ever
|
|
550
|
+
// arrive. Verify the session is still alive server-side; if it is
|
|
551
|
+
// gone, settle the iterator with a plain incomplete marker instead of
|
|
552
|
+
// hanging busy forever.
|
|
553
|
+
wsCall(base, 'chat.active', {}).then((d) => {
|
|
554
|
+
if (done) return;
|
|
555
|
+
const sessions = (d && d.sessions) || [];
|
|
556
|
+
const alive = Array.isArray(sessions) && sessions.some(x => x && x.sessionId === sessionId);
|
|
557
|
+
if (!alive) {
|
|
558
|
+
errored = errored || 'connection dropped mid-turn - the response may be incomplete; events were not replayed';
|
|
559
|
+
finish();
|
|
560
|
+
}
|
|
561
|
+
}).catch(() => {});
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if ((s === 'closed' || s === 'error') && !graceTimer) {
|
|
565
|
+
graceTimer = setTimeout(() => {
|
|
566
|
+
graceTimer = null;
|
|
567
|
+
if (!done) { errored = errored || 'connection lost during stream'; finish(); }
|
|
568
|
+
}, WS_GRACE_MS);
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
const unsubWs = onWsStatus ? onWsStatus(onWs) : null;
|
|
572
|
+
|
|
573
|
+
// Wire AbortSignal to chat.cancel - and end the iterator immediately so the
|
|
574
|
+
// caller's busy state clears even if the server never emits a final event.
|
|
575
|
+
const onAbort = () => { wsCall(base, 'chat.cancel', { sessionId }).catch(() => {}); finish(); };
|
|
576
|
+
if (signal) {
|
|
577
|
+
if (signal.aborted) onAbort();
|
|
578
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
try {
|
|
582
|
+
while (!done || queue.length > 0) {
|
|
583
|
+
if (queue.length === 0) {
|
|
584
|
+
await new Promise(r => { resolveWait = r; });
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
yield queue.shift();
|
|
588
|
+
}
|
|
589
|
+
if (errored) {
|
|
590
|
+
// Sanitize raw server strings: strip internal stack traces / JSON blobs
|
|
591
|
+
// and map common cases to plain human copy.
|
|
592
|
+
let displayError = (typeof errored === 'string' ? errored : 'streaming error').trim();
|
|
593
|
+
if (/^\{/.test(displayError) || displayError.length > 300) displayError = 'An error occurred while streaming the response.';
|
|
594
|
+
yield { type: 'error', error: displayError };
|
|
595
|
+
}
|
|
596
|
+
} finally {
|
|
597
|
+
unsub();
|
|
598
|
+
if (graceTimer) { clearTimeout(graceTimer); graceTimer = null; }
|
|
599
|
+
if (typeof unsubWs === 'function') unsubWs();
|
|
600
|
+
if (signal) signal.removeEventListener?.('abort', onAbort);
|
|
601
|
+
}
|
|
602
|
+
}
|