agentgui 1.0.1062 → 1.0.1064

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.gm/prd.yml CHANGED
@@ -3106,18 +3106,6 @@
3106
3106
  - id: docstudio-cue-session-group-eyebrow
3107
3107
  subject: Verify ConversationList's session-group headers (Today/Yesterday/etc) use the --tr-label token + --fg-3 tone consistently, matching docstudio's uppercase letter-spaced low-opacity eyebrow label idiom
3108
3108
  status: pending
3109
- - id: gc-delete-undo-trash
3110
- subject: No undo-after-delete (soft-delete/trash) anywhere in the confined delete surface
3111
- status: pending
3112
- - id: gc-search-hit-hash-anchor
3113
- subject: Search-hit event anchor (focusEventI/focusEventTs) not carried in the hash - reload/Back loses highlighted line
3114
- status: pending
3115
- - id: gc-settings-section-scrollspy
3116
- subject: Settings section anchor is deep-link-in only - scrolling never updates state.settingsSection or the URL
3117
- status: pending
3118
- - id: gc-context-budget-affordance
3119
- subject: No context-size/token-budget affordance - only turn count and dollar cost shown, never remaining context headroom
3120
- status: pending
3121
3109
  - id: gc-expanded-body-highlight
3122
3110
  subject: Search-result highlighting inside expanded event bodies never highlights the matched query term
3123
3111
  status: pending
@@ -146,6 +146,73 @@ function isAllowRoot(realPath, allowRoots) {
146
146
  return allowRoots.some(r => (IS_WINDOWS ? r.toLowerCase() : r) === p);
147
147
  }
148
148
 
149
+ // --- Soft-delete (trash) -----------------------------------------------
150
+ // /api/delete moves entries into <root>/.agentgui-trash/<trashId>__<name>
151
+ // instead of unlinking, giving a short undo window. In-memory index only
152
+ // (server restart forfeits the undo window - acceptable since the retention
153
+ // window itself is short and this is a convenience net on top of, not a
154
+ // replacement for, the pre-delete ConfirmDialog). Purged after
155
+ // TRASH_RETENTION_MS or when trashIndex grows past TRASH_MAX_ENTRIES
156
+ // (oldest-first), so a long-running server's trash dir can't grow unbounded.
157
+ const TRASH_DIR_NAME = '.agentgui-trash';
158
+ const TRASH_RETENTION_MS = parseInt(process.env.AGENTGUI_TRASH_RETENTION_MS || '', 10) || 10 * 60 * 1000;
159
+ const TRASH_MAX_ENTRIES = 200;
160
+ const trashIndex = new Map(); // trashId -> { trashPath, originalPath, root, deletedAt }
161
+
162
+ function purgeExpiredTrash() {
163
+ const now = Date.now();
164
+ for (const [id, info] of trashIndex) {
165
+ if (now - info.deletedAt > TRASH_RETENTION_MS) {
166
+ try { fs.rmSync(info.trashPath, { recursive: true, force: true }); } catch { /* already gone */ }
167
+ trashIndex.delete(id);
168
+ }
169
+ }
170
+ if (trashIndex.size > TRASH_MAX_ENTRIES) {
171
+ const sorted = [...trashIndex.entries()].sort((a, b) => a[1].deletedAt - b[1].deletedAt);
172
+ for (const [id, info] of sorted.slice(0, trashIndex.size - TRASH_MAX_ENTRIES)) {
173
+ try { fs.rmSync(info.trashPath, { recursive: true, force: true }); } catch {}
174
+ trashIndex.delete(id);
175
+ }
176
+ }
177
+ }
178
+
179
+ // Which allowed root a confined realPath lives under - the trash dir sits
180
+ // alongside it (still inside the SAME root, so confineToRoots covers the
181
+ // trash path too - no new unconfined surface).
182
+ function rootFor(realPath, allowRoots) {
183
+ const p = IS_WINDOWS ? realPath.toLowerCase() : realPath;
184
+ return allowRoots.find(r => { const rr = IS_WINDOWS ? r.toLowerCase() : r; return p === rr || p.startsWith(rr + path.sep); });
185
+ }
186
+
187
+ function moveToTrash(realPath, allowRoots) {
188
+ purgeExpiredTrash();
189
+ const root = rootFor(realPath, allowRoots);
190
+ if (!root) { const e = new Error('not confined to an allowed root'); e.code = 'EACCES'; throw e; }
191
+ const trashDir = path.join(root, TRASH_DIR_NAME);
192
+ fs.mkdirSync(trashDir, { recursive: true });
193
+ const trashId = crypto.randomBytes(8).toString('hex');
194
+ const base = path.basename(realPath);
195
+ const trashPath = path.join(trashDir, trashId + '__' + base);
196
+ fs.renameSync(realPath, trashPath);
197
+ trashIndex.set(trashId, { trashPath, originalPath: realPath, root, deletedAt: Date.now() });
198
+ return { trashId };
199
+ }
200
+
201
+ function restoreFromTrash(trashId, allowRoots) {
202
+ const info = trashIndex.get(trashId);
203
+ if (!info) { const e = new Error('nothing to restore - the undo window has expired or this was already restored'); e.code = 'NOT_FOUND'; throw e; }
204
+ // Re-confine the ORIGINAL path at restore time (not trust the cached one
205
+ // blindly) - the allowlist itself doesn't change at runtime, but this keeps
206
+ // restore honoring the exact same confinement contract every other route does.
207
+ const conf = confineToRoots(info.originalPath, allowRoots);
208
+ if (!conf.ok && conf.reason !== 'not found') { const e = new Error('restore target is no longer inside an accessible folder'); e.code = 'CONFLICT'; throw e; }
209
+ if (fs.existsSync(info.originalPath)) { const e = new Error('a file already exists at the original location'); e.code = 'CONFLICT'; throw e; }
210
+ fs.mkdirSync(path.dirname(info.originalPath), { recursive: true });
211
+ fs.renameSync(info.trashPath, info.originalPath);
212
+ trashIndex.delete(trashId);
213
+ return { path: info.originalPath };
214
+ }
215
+
149
216
  export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, getWss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap, routes, PORT }) {
150
217
  // Warn operators when CORS_ORIGIN=* is combined with no PASSWORD: any
151
218
  // cross-origin page can make credentialless fetch() calls to all /api/*
@@ -690,13 +757,18 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
690
757
  if (isAllowRoot(conf.realPath, allowRoots)) { sendJSON(req, res, 403, { error: 'forbidden: cannot delete an allowed root' }); return; }
691
758
  try {
692
759
  const st = fs.lstatSync(conf.realPath);
693
- if (st.isDirectory()) {
694
- if (body.recursive === true) fs.rmSync(conf.realPath, { recursive: true });
695
- else fs.rmdirSync(conf.realPath); // throws ENOTEMPTY unless empty
696
- } else {
697
- fs.unlinkSync(conf.realPath);
760
+ // Soft-delete: move into a confined per-root .agentgui-trash/ instead
761
+ // of unlinking, so the only safety net isn't confirm-before (the
762
+ // pre-existing ConfirmDialog) but also undo-after, matching an
763
+ // fsbrowse-grade file manager. A non-empty directory without
764
+ // recursive=true still throws ENOTEMPTY BEFORE any move happens
765
+ // (checked via a dry probe) to keep that existing guard's semantics.
766
+ if (st.isDirectory() && body.recursive !== true) {
767
+ const dryEntries = fs.readdirSync(conf.realPath);
768
+ if (dryEntries.length) { sendJSON(req, res, 409, { error: 'directory is not empty' }); return; }
698
769
  }
699
- sendJSON(req, res, 200, { ok: true });
770
+ const trashInfo = moveToTrash(conf.realPath, allowRoots);
771
+ sendJSON(req, res, 200, { ok: true, trashId: trashInfo.trashId });
700
772
  } catch (err) {
701
773
  const code = err.code === 'ENOTEMPTY' ? 409 : (err.code === 'EACCES' || err.code === 'EPERM' ? 403 : (err.code === 'ENOENT' ? 404 : 400));
702
774
  sendJSON(req, res, code, { error: err.code === 'ENOTEMPTY' ? 'directory is not empty' : err.message });
@@ -704,6 +776,22 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
704
776
  return;
705
777
  }
706
778
 
779
+ // POST /api/restore {trashId} -> {ok, path}. Undoes a /api/delete within
780
+ // its retention window (trashRetentionMs, default 10 minutes) by moving
781
+ // the entry back from .agentgui-trash/ to its original confined path.
782
+ if (routePath.split('?')[0] === '/api/restore' && req.method === 'POST') {
783
+ let body;
784
+ try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
785
+ catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
786
+ try {
787
+ const restored = restoreFromTrash(String(body.trashId || ''), fsAllowRoots());
788
+ sendJSON(req, res, 200, { ok: true, path: restored.path });
789
+ } catch (err) {
790
+ sendJSON(req, res, err.code === 'NOT_FOUND' ? 404 : (err.code === 'CONFLICT' ? 409 : 400), { error: err.message });
791
+ }
792
+ return;
793
+ }
794
+
707
795
  // POST /api/mkdir {dir, name} -> {ok, path}. dir must exist inside roots.
708
796
  if (routePath.split('?')[0] === '/api/mkdir' && req.method === 'POST') {
709
797
  let body;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.1062",
3
+ "version": "1.0.1064",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -51,7 +51,7 @@ const ARM_RESET_MS = 4000;
51
51
 
52
52
  // Full routable param set. Every view-defining piece of state round-trips
53
53
  // through the hash so reload and Back/forward restore the exact view.
54
- const HASH_KEYS = ['tab', 'sid', 'dir', 'file', 'q', 'project', 'section', 'filter', 'lsort', 'lfilter', 'lerr'];
54
+ const HASH_KEYS = ['tab', 'sid', 'dir', 'file', 'q', 'project', 'section', 'filter', 'lsort', 'lfilter', 'lerr', 'ets'];
55
55
  function readHash() {
56
56
  const hash = location.hash || '';
57
57
  const out = {};
@@ -80,6 +80,11 @@ function buildHash() {
80
80
  const q = (state.searchQ || '').trim();
81
81
  if (q.length >= 2) parts.push('q=' + encodeURIComponent(q));
82
82
  if (state.projectFilter) parts.push('project=' + encodeURIComponent(state.projectFilter));
83
+ // A session opened from a search hit carries the matched event's
84
+ // timestamp so reload/Back reproduces the same scrolled+flashed position
85
+ // a live click gives - without this, the anchor only existed in memory
86
+ // and a search-hit URL degraded to just the bare session on reload.
87
+ if (state._focusEventTs != null) parts.push('ets=' + encodeURIComponent(state._focusEventTs));
83
88
  }
84
89
  if (tab === 'settings' && state.settingsSection) parts.push('section=' + encodeURIComponent(state.settingsSection));
85
90
  if (tab === 'live') {
@@ -822,7 +827,14 @@ function sessionsColumn() {
822
827
  onNew: () => { navTo('chat'); newChat(); },
823
828
  onSelect: (s) => {
824
829
  if (state.tab === 'chat') resumeInChat({ sid: s.sid });
825
- else loadSession(s.sid, { focusEventI: s._focusEventI, focusEventTs: s._focusEventTs });
830
+ else {
831
+ // Persist the anchor so buildHash() can carry it into the URL -
832
+ // without this, reload/Back only ever restored the bare session,
833
+ // losing the matched event's scroll+flash position.
834
+ state._focusEventTs = s._focusEventTs ?? null;
835
+ loadSession(s.sid, { focusEventI: s._focusEventI, focusEventTs: s._focusEventTs });
836
+ writeHash({ push: true });
837
+ }
826
838
  },
827
839
  loading: state.searchBusy,
828
840
  error: state.searchHits.error || null,
@@ -1168,10 +1180,13 @@ async function runFileMutation(fn, doneMsg, patch) {
1168
1180
  if (!d || d.busy) return;
1169
1181
  d.busy = true; d.error = null; render();
1170
1182
  try {
1171
- await fn();
1183
+ const result = await fn();
1172
1184
  state.files.dialog = null;
1173
1185
  restoreFileDialogFocus(d._trigger);
1174
1186
  announce(doneMsg);
1187
+ // A soft-delete's trashId (if this mutation was a delete) rides the
1188
+ // return value straight to the undo-toast caller.
1189
+ if (result && result.trashId) offerUndoDelete(result.trashId, doneMsg);
1175
1190
  if (patch) {
1176
1191
  // Patch the visible list immediately, matching the bulk-delete/move
1177
1192
  // pattern, instead of stalling the dialog on a second full round-trip.
@@ -1188,6 +1203,36 @@ async function runFileMutation(fn, doneMsg, patch) {
1188
1203
  d.busy = false; d.error = fileMutationCopy(e); render();
1189
1204
  }
1190
1205
  }
1206
+ // Delete is a soft-delete server-side (moved to a confined .agentgui-trash/,
1207
+ // see lib/http-handler.js) - offer a real undo action within the retention
1208
+ // window instead of the ConfirmDialog's pre-delete confirm being the ONLY
1209
+ // safety net. One active undo toast at a time (the most recent delete wins;
1210
+ // an in-flight bulk-delete calls this per-entry, each replacing the last -
1211
+ // acceptable since restoring the single most recent one is still strictly
1212
+ // better than no undo at all, and stacking N toasts for a bulk op would be
1213
+ // its own UX problem).
1214
+ const UNDO_DELETE_WINDOW_MS = 10000;
1215
+ let _undoDeleteTimer = null;
1216
+ function offerUndoDelete(trashId, doneMsg) {
1217
+ clearTimeout(_undoDeleteTimer);
1218
+ state.files.undoDelete = { trashId, doneMsg };
1219
+ render();
1220
+ _undoDeleteTimer = setTimeout(() => { state.files.undoDelete = null; render(); }, UNDO_DELETE_WINDOW_MS);
1221
+ }
1222
+ async function undoDelete() {
1223
+ const u = state.files.undoDelete;
1224
+ if (!u) return;
1225
+ clearTimeout(_undoDeleteTimer);
1226
+ state.files.undoDelete = null;
1227
+ try {
1228
+ await B.restoreEntry(state.backend, u.trashId);
1229
+ announce('restored');
1230
+ await loadDir(state.files.path, { fromHash: true });
1231
+ } catch (e) {
1232
+ announce('could not restore: ' + fileMutationCopy(e));
1233
+ render();
1234
+ }
1235
+ }
1191
1236
  // Upload a FileList into the current directory; per-file rows feed the kit
1192
1237
  // UploadProgress (done/error per file - fetch has no chunk progress).
1193
1238
  async function uploadFiles(fileList) {
@@ -1322,9 +1367,12 @@ function fileDialog() {
1322
1367
  const isDir = d.file.type === 'dir';
1323
1368
  return ConfirmDialog({
1324
1369
  title: 'Delete ' + d.file.name,
1370
+ // Delete is soft (moved to trash, undoable for a short window right
1371
+ // after) - the copy no longer overclaims permanence the way an actual
1372
+ // unlink would warrant.
1325
1373
  message: isDir
1326
- ? 'Delete this folder and everything inside it? This cannot be undone.'
1327
- : 'Delete this file? This cannot be undone.',
1374
+ ? 'Delete this folder and everything inside it? You can undo this for a few seconds after.'
1375
+ : 'Delete this file? You can undo this for a few seconds after.',
1328
1376
  error: d.error || null, busy: !!d.busy,
1329
1377
  confirmLabel: d.busy ? 'deleting…' : 'delete', cancelLabel: 'cancel', destructive: true,
1330
1378
  onCancel: closeFileDialog,
@@ -1647,6 +1695,10 @@ function filesMain() {
1647
1695
  : body;
1648
1696
  return [
1649
1697
  offlineBanner(),
1698
+ f.undoDelete ? Alert({ key: 'undodel', kind: 'info', title: 'Deleted',
1699
+ children: [
1700
+ h('span', { key: 'udtxt' }, (f.undoDelete.doneMsg || 'Deleted') + ' - undo within a few seconds. '),
1701
+ Btn({ key: 'udbtn', onClick: () => undoDelete(), children: 'undo' })] }) : null,
1650
1702
  PageHeader({ compact: true, dense: true, title: 'Files', lede: 'Browse and manage files in the allowed folders.' }),
1651
1703
  // One vertical beat (.ds-files-stack gap) for the whole command stack -
1652
1704
  // the bands used to butt edge-to-edge while the header gap was 24px.
@@ -3927,6 +3979,10 @@ async function loadSession(sid, { focusEventI = null, focusEventTs = null, fromH
3927
3979
  return;
3928
3980
  }
3929
3981
  state.selectedSid = sid;
3982
+ // A plain (non-search-hit) session open must not carry a stale event
3983
+ // anchor forward into the URL - only reset it when this call ISN'T itself
3984
+ // the one supplying a fresh focusEventTs.
3985
+ if (focusEventTs == null) state._focusEventTs = null;
3930
3986
  state.events = [];
3931
3987
  state.events._seen = new Set(); // O(1) dedupe by event index
3932
3988
  state.eventsLoaded = false;
@@ -4092,9 +4148,11 @@ async function init() {
4092
4148
  } else if (hp.sid) {
4093
4149
  if (hp.q) state.searchQ = hp.q;
4094
4150
  if (hp.project) state.projectFilter = hp.project;
4151
+ const bootFocusTs = hp.ets != null ? Number(hp.ets) : null;
4152
+ if (bootFocusTs != null && !Number.isNaN(bootFocusTs)) state._focusEventTs = bootFocusTs;
4095
4153
  navTo('history', { push: false });
4096
4154
  await refreshHistory();
4097
- await loadSession(hp.sid, { fromHash: true });
4155
+ await loadSession(hp.sid, { fromHash: true, focusEventTs: bootFocusTs != null && !Number.isNaN(bootFocusTs) ? bootFocusTs : undefined });
4098
4156
  if (state.searchQ.trim().length >= 2) runSearch();
4099
4157
  } else if (bootTab !== state.tab) {
4100
4158
  // Files deep-link: restore the directory the URL names (reload keeps
@@ -4119,6 +4177,7 @@ async function init() {
4119
4177
  }
4120
4178
 
4121
4179
  registerWsStatusOnce();
4180
+ registerSettingsScrollSpyOnce();
4122
4181
  startActivePolling(); // surface running chats on any tab, not just history
4123
4182
  startRelTimeTick();
4124
4183
  startLiveTick(); // 1s elapsed advance on the live dashboard
@@ -4177,6 +4236,44 @@ function focusSettingsSection(id) {
4177
4236
  el.addEventListener('blur', clear);
4178
4237
  });
4179
4238
  }
4239
+ const SETTINGS_SECTION_IDS = ['backend', 'server', 'agents', 'appearance', 'keyboard', 'data'];
4240
+ // Settings was deep-link-IN only: focusSettingsSection() (a section= URL param
4241
+ // or the ?-overlay jump) set state.settingsSection, but manually scrolling
4242
+ // never updated it back - so the URL/state silently went stale the instant a
4243
+ // user scrolled by hand, and Back could never step BETWEEN panels the way it
4244
+ // does for every other tab. A lightweight scroll-position scrollspy (not a
4245
+ // full IntersectionObserver - the settings scroll region is small/short-lived
4246
+ // enough that a debounced scroll-position check is simpler and avoids the
4247
+ // observer-lifecycle bookkeeping) keeps state.settingsSection (and the URL)
4248
+ // honest while the user scrolls, registered once like the WS/session-expired
4249
+ // listeners above.
4250
+ const debouncedSettingsScrollSpy = debounce(() => {
4251
+ if (state.tab !== 'settings') return;
4252
+ const region = document.querySelector('#agentgui-main');
4253
+ if (!region) return;
4254
+ const regionTop = region.getBoundingClientRect().top;
4255
+ let current = null;
4256
+ for (const id of SETTINGS_SECTION_IDS) {
4257
+ const el = document.getElementById(id);
4258
+ if (!el) continue;
4259
+ // The section whose top has scrolled past the region's own top edge
4260
+ // (with a little slack for the sticky header) is the "current" one -
4261
+ // same heuristic every scrollspy implementation uses.
4262
+ if (el.getBoundingClientRect().top - regionTop <= 80) current = id;
4263
+ }
4264
+ if (current && current !== state.settingsSection) {
4265
+ state.settingsSection = current;
4266
+ writeHash({ push: false }); // passive sync, not a Back-able step - matches search text/filter's replaceState treatment
4267
+ }
4268
+ }, 150);
4269
+ let settingsScrollSpyRegistered = false;
4270
+ function registerSettingsScrollSpyOnce() {
4271
+ if (settingsScrollSpyRegistered) return;
4272
+ settingsScrollSpyRegistered = true;
4273
+ document.addEventListener('scroll', (e) => {
4274
+ if (e.target && e.target.id === 'agentgui-main') debouncedSettingsScrollSpy();
4275
+ }, true); // capture: #agentgui-main itself is the scrolling element, not a bubling target
4276
+ }
4180
4277
 
4181
4278
  // Browser Back/forward: diff the FULL hash param set against state and re-sync
4182
4279
  // each piece. Everything here runs with writeHash:false / fromHash:true so the
@@ -4189,7 +4286,11 @@ window.addEventListener('popstate', () => {
4189
4286
  // anything else - or a bare sid - opens it in history).
4190
4287
  if (hp.sid && hp.sid !== state.selectedSid) {
4191
4288
  if (tab === 'chat') resumeInChat({ sid: hp.sid }, { fromHash: true });
4192
- else loadSession(hp.sid, { fromHash: true });
4289
+ else {
4290
+ const popFocusTs = hp.ets != null ? Number(hp.ets) : null;
4291
+ state._focusEventTs = (popFocusTs != null && !Number.isNaN(popFocusTs)) ? popFocusTs : null;
4292
+ loadSession(hp.sid, { fromHash: true, focusEventTs: (popFocusTs != null && !Number.isNaN(popFocusTs)) ? popFocusTs : undefined });
4293
+ }
4193
4294
  } else if (!hp.sid && state.selectedSid && tab === 'history') {
4194
4295
  state.selectedSid = null;
4195
4296
  state.events = [];
@@ -167,7 +167,10 @@ export async function statPath(base, p) {
167
167
  }
168
168
 
169
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.
170
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 }); }
171
174
  export function makeDir(base, dirPath, name) { return mutateJSON(base, '/api/mkdir', { dir: dirPath, name }); }
172
175
  export function moveEntry(base, filePath, destDir, overwrite) { return mutateJSON(base, '/api/move', { path: filePath, destDir, overwrite: !!overwrite }); }
173
176