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.

Files changed (170) hide show
  1. package/.gm/browser-config.json +1 -0
  2. package/.gm/disciplines/agentgui/mem-1779277005143-0-1352.json +1 -0
  3. package/.gm/disciplines/agentgui/memories/.flat-export-done +0 -0
  4. package/.gm/disciplines/agentgui/memories/mem-c07fa3b0836edbe2-1352.md +8 -0
  5. package/.gm/gm.db +0 -0
  6. package/.gm/memories/.flat-export-done +1 -0
  7. package/.gm/memories/mem-0131ff4869d86085-370.md +10 -0
  8. package/.gm/memories/mem-04049d11cb8c1661-2135.md +8 -0
  9. package/.gm/memories/mem-069ff10372ed1770-1375.md +8 -0
  10. package/.gm/memories/mem-08a4a9e050a4deff-3048.md +8 -0
  11. package/.gm/memories/mem-09597ee0df9c9224-965.md +8 -0
  12. package/.gm/memories/mem-0993d156f571b619-1059.md +8 -0
  13. package/.gm/memories/mem-0b681bf2340c8df1-3170.md +8 -0
  14. package/.gm/memories/mem-0c444abde86202f6-1681.md +8 -0
  15. package/.gm/memories/mem-0d1408fa048b9923-1159.md +8 -0
  16. package/.gm/memories/mem-0dad5d82fec829ba-887.md +8 -0
  17. package/.gm/memories/mem-10a776effb7ce3a6-935.md +8 -0
  18. package/.gm/memories/mem-10d264b4f8e66fb4-1492.md +8 -0
  19. package/.gm/memories/mem-118c35ac67541d98-2909.md +8 -0
  20. package/.gm/memories/mem-119cf70dd19aa0a5-2225.md +8 -0
  21. package/.gm/memories/mem-15a07cd761b337c4-4320.md +8 -0
  22. package/.gm/memories/mem-15c73aca528535cb-2183.md +8 -0
  23. package/.gm/memories/mem-166f52796185e0e7-1487.md +8 -0
  24. package/.gm/memories/mem-1a148cf9c3126df5-3963.md +8 -0
  25. package/.gm/memories/mem-1a459001058f618e-567.md +8 -0
  26. package/.gm/memories/mem-1af3e13f7618e668-2146.md +8 -0
  27. package/.gm/memories/mem-1fd31d6873e9c164-794.md +8 -0
  28. package/.gm/memories/mem-21f551be092fd77d-2340.md +8 -0
  29. package/.gm/memories/mem-231ff5de003f20f0-586.md +8 -0
  30. package/.gm/memories/mem-2567393d3adb03ab-347.md +8 -0
  31. package/.gm/memories/mem-27faf41cdd050c80-1261.md +8 -0
  32. package/.gm/memories/mem-28889e43e8ce64cc-3663.md +8 -0
  33. package/.gm/memories/mem-2f4fd06515abf439-2562.md +8 -0
  34. package/.gm/memories/mem-34a749430303c5f7-544.md +8 -0
  35. package/.gm/memories/mem-3602d11d7bce9446-1057.md +8 -0
  36. package/.gm/memories/mem-38b7e24dbcba2f3e-639.md +8 -0
  37. package/.gm/memories/mem-39a7cc2f887a90b1-1079.md +8 -0
  38. package/.gm/memories/mem-40d6169c8759061d-806.md +8 -0
  39. package/.gm/memories/mem-4420e6d00e5c8632-2318.md +8 -0
  40. package/.gm/memories/mem-447e5a54ddc9357a-654.md +8 -0
  41. package/.gm/memories/mem-455b3beb4afabe14-591.md +8 -0
  42. package/.gm/memories/mem-4845ee594759151c-560.md +8 -0
  43. package/.gm/memories/mem-4a1254c60b868b88-374.md +8 -0
  44. package/.gm/memories/mem-4bafafa47a0e4b6c-3237.md +8 -0
  45. package/.gm/memories/mem-4c06c45b4d31bc60-1202.md +8 -0
  46. package/.gm/memories/mem-4dfac801d0378448-3676.md +8 -0
  47. package/.gm/memories/mem-4ecd3b713e384170-122.md +10 -0
  48. package/.gm/memories/mem-51d8992a3c6f6c9c-1073.md +8 -0
  49. package/.gm/memories/mem-539eee7e8c6c10ee-2557.md +8 -0
  50. package/.gm/memories/mem-559b8b2abc4b1091-1665.md +8 -0
  51. package/.gm/memories/mem-59dd73a463e54948-871.md +8 -0
  52. package/.gm/memories/mem-5b99b882ffe1a3c2-2262.md +8 -0
  53. package/.gm/memories/mem-5feb252a5244149c-1791.md +8 -0
  54. package/.gm/memories/mem-6224f8d185797f2b-1047.md +8 -0
  55. package/.gm/memories/mem-6434ee8261a6a6a2-3126.md +8 -0
  56. package/.gm/memories/mem-66b0947b946f3437-1155.md +8 -0
  57. package/.gm/memories/mem-6bdf783a1f6be7c2-1062.md +8 -0
  58. package/.gm/memories/mem-6d49f08bebac54c9-744.md +8 -0
  59. package/.gm/memories/mem-70d96acb736af2d7-685.md +8 -0
  60. package/.gm/memories/mem-70de7d5e71fcd439-2680.md +8 -0
  61. package/.gm/memories/mem-713614f12d7029da-1577.md +8 -0
  62. package/.gm/memories/mem-720d76c88e64f6ee-1967.md +8 -0
  63. package/.gm/memories/mem-7535f34f430fc196-1340.md +8 -0
  64. package/.gm/memories/mem-861a588d2773681a-1311.md +8 -0
  65. package/.gm/memories/mem-8af7980a92e09959-891.md +8 -0
  66. package/.gm/memories/mem-8b438ef4c609b7af-401.md +8 -0
  67. package/.gm/memories/mem-8f59884039787ef3-743.md +8 -0
  68. package/.gm/memories/mem-92b199c2024ee1aa-2513.md +8 -0
  69. package/.gm/memories/mem-9329105b05b9c0f6-324.md +10 -0
  70. package/.gm/memories/mem-94f30cec3c283f97-782.md +10 -0
  71. package/.gm/memories/mem-956d4813319f65a3-1710.md +8 -0
  72. package/.gm/memories/mem-96fffbb6615e031b-1263.md +8 -0
  73. package/.gm/memories/mem-993ab31f7a10aefa-718.md +8 -0
  74. package/.gm/memories/mem-a3e440db285f655a-995.md +8 -0
  75. package/.gm/memories/mem-a52933cd6495e6f5-2624.md +8 -0
  76. package/.gm/memories/mem-a86426ac44138c96-1606.md +8 -0
  77. package/.gm/memories/mem-a95b1d49d71133ce-1007.md +8 -0
  78. package/.gm/memories/mem-a9968a186368b022-1150.md +8 -0
  79. package/.gm/memories/mem-aaeae38d8eb01809-1076.md +8 -0
  80. package/.gm/memories/mem-ade01d9c02677c55-1557.md +8 -0
  81. package/.gm/memories/mem-b06b8c85b81663e6-1250.md +8 -0
  82. package/.gm/memories/mem-b451e5e17e555202-1490.md +8 -0
  83. package/.gm/memories/mem-b64ad00dcb6f909e-937.md +8 -0
  84. package/.gm/memories/mem-b7d6018ceca55b6f-2220.md +8 -0
  85. package/.gm/memories/mem-bdf466854032cac9-1509.md +8 -0
  86. package/.gm/memories/mem-c0482039e6ab4c50-2047.md +8 -0
  87. package/.gm/memories/mem-c4aaec5c4e6f80c2-920.md +8 -0
  88. package/.gm/memories/mem-c66976ddacb4fbe8-1454.md +8 -0
  89. package/.gm/memories/mem-c7c5bc263850c332-1844.md +8 -0
  90. package/.gm/memories/mem-cda639547932c796-713.md +8 -0
  91. package/.gm/memories/mem-cdf04ff6a6a0aa6f-671.md +8 -0
  92. package/.gm/memories/mem-cf8472c84d9e04c4-1883.md +8 -0
  93. package/.gm/memories/mem-cfd4d894370c63c3-1288.md +8 -0
  94. package/.gm/memories/mem-d060480435f92eec-1146.md +8 -0
  95. package/.gm/memories/mem-d347c921b79e6d44-1099.md +8 -0
  96. package/.gm/memories/mem-d3c8ea6c861bfbb8-1457.md +8 -0
  97. package/.gm/memories/mem-d5e928c552cd87f9-214.md +10 -0
  98. package/.gm/memories/mem-d63bc3b8a0269844-406.md +10 -0
  99. package/.gm/memories/mem-d9c19257abc2fa5e-1061.md +8 -0
  100. package/.gm/memories/mem-d9f26358a7cd3bb8-866.md +8 -0
  101. package/.gm/memories/mem-da1c91acf63d81d3-863.md +8 -0
  102. package/.gm/memories/mem-dc1ac3b04d22317e-664.md +8 -0
  103. package/.gm/memories/mem-dceeae20bc56d407-711.md +8 -0
  104. package/.gm/memories/mem-dd98b21c0896dc04-1557.md +8 -0
  105. package/.gm/memories/mem-df74674eec7745ac-178.md +10 -0
  106. package/.gm/memories/mem-dfe3b466fd5d1199-802.md +8 -0
  107. package/.gm/memories/mem-e0f9286aed2d5a06-1116.md +8 -0
  108. package/.gm/memories/mem-e1950b064099b7be-1895.md +8 -0
  109. package/.gm/memories/mem-e1d5431ae2542872-961.md +8 -0
  110. package/.gm/memories/mem-e490a181de6469fc-487.md +8 -0
  111. package/.gm/memories/mem-e72c6efbb27326fb-392.md +8 -0
  112. package/.gm/memories/mem-e943b58115a02b4c-593.md +8 -0
  113. package/.gm/memories/mem-e9bc5f9ed6edbcf7-1580.md +8 -0
  114. package/.gm/memories/mem-eae1e383927854fb-2649.md +8 -0
  115. package/.gm/memories/mem-ebbe6c0694c526f0-1392.md +8 -0
  116. package/.gm/memories/mem-ebf85becc23410b0-1633.md +8 -0
  117. package/.gm/memories/mem-ee0596fa17c7102e-362.md +10 -0
  118. package/.gm/memories/mem-ee4450fbc311b583-1361.md +8 -0
  119. package/.gm/memories/mem-ee8858162518f144-1252.md +8 -0
  120. package/.gm/memories/mem-f3297e82df622649-1166.md +8 -0
  121. package/.gm/memories/mem-f3f317c083d979ee-1210.md +8 -0
  122. package/.gm/memories/mem-f714a86465fba158-2041.md +8 -0
  123. package/.gm/memories/mem-f786bc1626d92845-204.md +10 -0
  124. package/.gm/memories/mem-f9f0dc8895b21e18-1217.md +8 -0
  125. package/.gm/memories/mem-fb3b2a7395800041-525.md +8 -0
  126. package/.gm/memories/mem-fc5753f92ee7d654-966.md +8 -0
  127. package/.gm/memories/mem-fe3faa1652f07239-480.md +8 -0
  128. package/.gm/memories/mem-ff8669e527e5c427-2788.md +8 -0
  129. package/.gm/mutables.yml +53 -0
  130. package/.gm/prd.yml +3342 -1
  131. package/AGENTS.md +122 -122
  132. package/UX_OPTIMIZATION_SUMMARY.md +238 -0
  133. package/agentgui-after.png +0 -0
  134. package/agentgui-current.png +0 -0
  135. package/agentgui-final.png +0 -0
  136. package/agentgui-nobrand.png +0 -0
  137. package/agentgui-now.png +0 -0
  138. package/agentgui-v2.png +0 -0
  139. package/agentgui-v4.png +0 -0
  140. package/bash.exe.stackdump +28 -0
  141. package/database.js +110 -109
  142. package/design-reference.png +0 -0
  143. package/lib/acp-sdk-manager.js +9 -42
  144. package/lib/claude-runner-acp.js +155 -213
  145. package/lib/claude-runner-agents.js +157 -170
  146. package/lib/http-handler.js +931 -400
  147. package/lib/ws-handlers-util.js +466 -11
  148. package/package.json +1 -1
  149. package/site/app/js/app.js +4741 -4221
  150. package/site/app/js/backend.js +602 -621
  151. package/site/app/vendor/anentrypoint-design/247420.css +13673 -14799
  152. package/site/app/vendor/anentrypoint-design/247420.js +449 -735
  153. package/.gm/.embed-generation.code_chunks +0 -1
  154. package/.gm/.embed-generation.git_commit_vectors +0 -1
  155. package/.gm/.embed-generation.memories +0 -1
  156. package/.gm/.embed-generation.rssearch_vectors +0 -1
  157. package/lib/acp-http-protocol.js +0 -95
  158. package/lib/http-routes/mutations.js +0 -199
  159. package/lib/http-routes/reads.js +0 -212
  160. package/lib/http-routes/shared.js +0 -225
  161. package/lib/ws-handlers/agents.js +0 -137
  162. package/lib/ws-handlers/chat.js +0 -157
  163. package/lib/ws-handlers/git.js +0 -159
  164. package/lib/ws-handlers/misc.js +0 -36
  165. package/lib/ws-handlers/shared.js +0 -47
  166. package/lib/ws-handlers/terminal-state.js +0 -16
  167. package/site/app/js/chat-persistence.js +0 -123
  168. package/site/app/js/hash-routing.js +0 -62
  169. package/site/app/js/history.js +0 -435
  170. package/site/app/js/shortcuts.js +0 -128
@@ -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
- // Composed model-availability view for the Models tab (ModelsConfig
439
- // component): per-agent CLI availability + models, in the shape ModelsConfig
440
- // expects ({timestamp, providers, sampler, summary}). See lib/ws-handlers-
441
- // util.js models.availability for what each field really means in agentgui
442
- // (no probed per-mode matrix like freddie - one real 'cli' mode per model).
443
- export async function getModelsAvailability(base) {
444
- return wsCall(base, 'models.availability', {});
445
- }
446
-
447
- // Provider API-key configs (masked) for the Models tab's provider auth info -
448
- // backs the existing Settings 'keys' surface, reused here as-is.
449
- export async function getAuthConfigs(base) {
450
- return wsCall(base, 'auth.configs', {});
451
- }
452
-
453
- export async function saveAuthConfig(base, providerId, apiKey, defaultModel) {
454
- return wsCall(base, 'auth.save', { providerId, apiKey, defaultModel });
455
- }
456
-
457
- // ---------- Git / worktrees (WS) ----------
458
-
459
- export async function gitStatus(base, { cwd } = {}) {
460
- const { files } = await wsCall(base, 'git.status', { cwd });
461
- return files || [];
462
- }
463
-
464
- export async function gitDiff(base, { cwd, file, staged } = {}) {
465
- return wsCall(base, 'git.diff', { cwd, file, staged });
466
- }
467
-
468
- export async function gitLog(base, { cwd, limit } = {}) {
469
- const { commits } = await wsCall(base, 'git.log', { cwd, limit });
470
- return commits || [];
471
- }
472
-
473
- export async function worktreeList(base, { cwd } = {}) {
474
- const { worktrees } = await wsCall(base, 'worktree.list', { cwd });
475
- return worktrees || [];
476
- }
477
-
478
- export async function worktreeCreate(base, { cwd, path, branch, newBranch } = {}) {
479
- return wsCall(base, 'worktree.create', { cwd, path, branch, newBranch });
480
- }
481
-
482
- export async function worktreeRemove(base, { cwd, path, force } = {}) {
483
- return wsCall(base, 'worktree.remove', { cwd, path, force });
484
- }
485
-
486
- // ---------- Streaming chat (WS) ----------
487
- //
488
- // Yields events of shape:
489
- // { type: 'text', text: '...' } - assistant text deltas
490
- // { type: 'tool', block: {...} } - tool_use blocks
491
- // { type: 'result', block: {...} } - terminal result block
492
- // { type: 'error', error: '...' }
493
- //
494
- // Caller signature kept compatible with the previous HTTP/SSE impl.
495
- export async function* streamChat(base, { model, messages, signal, agentId, resumeSid, cwd }) {
496
- // The last user message is the prompt; agentgui's claude-runner spawns the
497
- // agent for a single prompt. Multi-turn continuity is the agent's own resume.
498
- const last = messages[messages.length - 1];
499
- const content = last?.content || '';
500
- if (!content) return;
501
-
502
- const resolvedAgentId = agentId || 'claude-code';
503
- const resolvedModel = model || undefined;
504
-
505
- // Queue events here; the async iterator pulls from it.
506
- const queue = [];
507
- let resolveWait = null;
508
- let done = false;
509
- let errored = null;
510
- const push = (ev) => { queue.push(ev); if (resolveWait) { resolveWait(); resolveWait = null; } };
511
-
512
- // Kick off the chat on the server.
513
- let started;
514
- try {
515
- started = await wsCall(base, 'chat.sendMessage', { content, agentId: resolvedAgentId, model: resolvedModel, resumeSid, cwd });
516
- } catch (e) {
517
- yield { type: 'error', error: e.message };
518
- return;
519
- }
520
- const sessionId = started?.sessionId;
521
- if (!sessionId) { yield { type: 'error', error: 'no sessionId from server' }; return; }
522
-
523
- const finish = () => { done = true; if (resolveWait) { resolveWait(); resolveWait = null; } };
524
-
525
- const unsub = addSessionListener(sessionId, (ev) => {
526
- if (ev.type === 'streaming_session') {
527
- // claude's real session id - surface it so the host can --resume this
528
- // conversation on the next turn (multi-turn continuity).
529
- if (ev.claudeSessionId) push({ type: 'session', sessionId: ev.claudeSessionId });
530
- } else if (ev.type === 'streaming_progress') {
531
- const block = ev.block;
532
- if (block?.type === 'text' && block.text) push({ type: 'text', text: block.text });
533
- else if (block?.type === 'thinking' && block.thinking) push({ type: 'thinking', text: block.thinking });
534
- else if (block?.type === 'tool_use') push({ type: 'tool', block });
535
- else if (block?.type === 'tool_result') push({ type: 'tool_result', block });
536
- else if (block?.type === 'result') push({ type: 'result', block });
537
- } else if (ev.type === 'streaming_complete') {
538
- finish();
539
- } else if (ev.type === 'streaming_cancelled') {
540
- push({ type: 'cancelled' });
541
- finish();
542
- } else if (ev.type === 'streaming_error') {
543
- // A remote stop (another tab / dashboard stop-all) is not a failure: the
544
- // server marks it cancelled; surface it as a distinct event so the app
545
- // can label the turn 'stopped' instead of a normal finish or error red.
546
- if (ev.cancelled || ev.error === 'cancelled') {
547
- push({ type: 'cancelled' });
548
- } else {
549
- errored = ev.error || 'streaming error';
550
- }
551
- finish();
552
- }
553
- });
554
-
555
- // If the websocket drops mid-stream, streaming_complete will never arrive.
556
- // Don't fail immediately: the client auto-reconnects (and the open handler
557
- // re-subscribes this session's listeners), so give it a ~12s grace window.
558
- // Only if the socket is still down when the timer expires do we surface the
559
- // error and end the iterator instead of hanging forever.
560
- const WS_GRACE_MS = 12000;
561
- let graceTimer = null;
562
- const onWs = (s) => {
563
- if (done) return;
564
- if (s === 'open') {
565
- // Reconnected in time - the open handler already re-subscribed us.
566
- if (graceTimer) { clearTimeout(graceTimer); graceTimer = null; }
567
- // streaming_complete is fire-and-forget with no replay: if the turn
568
- // finished while the socket was down, no terminal frame will ever
569
- // arrive. Verify the session is still alive server-side; if it is
570
- // gone, settle the iterator with a plain incomplete marker instead of
571
- // hanging busy forever.
572
- wsCall(base, 'chat.active', {}).then((d) => {
573
- if (done) return;
574
- const sessions = (d && d.sessions) || [];
575
- const alive = Array.isArray(sessions) && sessions.some(x => x && x.sessionId === sessionId);
576
- if (!alive) {
577
- errored = errored || 'connection dropped mid-turn - the response may be incomplete; events were not replayed';
578
- finish();
579
- }
580
- }).catch(() => {});
581
- return;
582
- }
583
- if ((s === 'closed' || s === 'error') && !graceTimer) {
584
- graceTimer = setTimeout(() => {
585
- graceTimer = null;
586
- if (!done) { errored = errored || 'connection lost during stream'; finish(); }
587
- }, WS_GRACE_MS);
588
- }
589
- };
590
- const unsubWs = onWsStatus ? onWsStatus(onWs) : null;
591
-
592
- // Wire AbortSignal to chat.cancel - and end the iterator immediately so the
593
- // caller's busy state clears even if the server never emits a final event.
594
- const onAbort = () => { wsCall(base, 'chat.cancel', { sessionId }).catch(() => {}); finish(); };
595
- if (signal) {
596
- if (signal.aborted) onAbort();
597
- else signal.addEventListener('abort', onAbort, { once: true });
598
- }
599
-
600
- try {
601
- while (!done || queue.length > 0) {
602
- if (queue.length === 0) {
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
+ }