@oharness/server 0.7.1 → 0.8.0

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.
Files changed (61) hide show
  1. package/dist/browser-pane.d.ts +48 -0
  2. package/dist/browser-pane.js +185 -0
  3. package/dist/browser-pane.js.map +1 -0
  4. package/dist/i18n.js +132 -0
  5. package/dist/i18n.js.map +1 -1
  6. package/dist/main.js +20 -0
  7. package/dist/main.js.map +1 -1
  8. package/dist/page/client/account.d.ts +8 -0
  9. package/dist/page/client/account.js +1895 -0
  10. package/dist/page/client/account.js.map +1 -0
  11. package/dist/page/client/artifacts.d.ts +7 -0
  12. package/dist/page/client/artifacts.js +767 -0
  13. package/dist/page/client/artifacts.js.map +1 -0
  14. package/dist/page/client/base.d.ts +8 -0
  15. package/dist/page/client/base.js +400 -0
  16. package/dist/page/client/base.js.map +1 -0
  17. package/dist/page/client/browser.d.ts +21 -0
  18. package/dist/page/client/browser.js +904 -0
  19. package/dist/page/client/browser.js.map +1 -0
  20. package/dist/page/client/composer.d.ts +8 -0
  21. package/dist/page/client/composer.js +1694 -0
  22. package/dist/page/client/composer.js.map +1 -0
  23. package/dist/page/client/dialogs.d.ts +7 -0
  24. package/dist/page/client/dialogs.js +724 -0
  25. package/dist/page/client/dialogs.js.map +1 -0
  26. package/dist/page/client/events.d.ts +7 -0
  27. package/dist/page/client/events.js +288 -0
  28. package/dist/page/client/events.js.map +1 -0
  29. package/dist/page/client/index.d.ts +16 -0
  30. package/dist/page/client/index.js +46 -0
  31. package/dist/page/client/index.js.map +1 -0
  32. package/dist/page/client/panels.d.ts +7 -0
  33. package/dist/page/client/panels.js +659 -0
  34. package/dist/page/client/panels.js.map +1 -0
  35. package/dist/page/client/pickers.d.ts +7 -0
  36. package/dist/page/client/pickers.js +624 -0
  37. package/dist/page/client/pickers.js.map +1 -0
  38. package/dist/page/client/routines.d.ts +6 -0
  39. package/dist/page/client/routines.js +910 -0
  40. package/dist/page/client/routines.js.map +1 -0
  41. package/dist/page/client/shell.d.ts +6 -0
  42. package/dist/page/client/shell.js +690 -0
  43. package/dist/page/client/shell.js.map +1 -0
  44. package/dist/page/client/state.d.ts +7 -0
  45. package/dist/page/client/state.js +1275 -0
  46. package/dist/page/client/state.js.map +1 -0
  47. package/dist/page/client/transcript.d.ts +8 -0
  48. package/dist/page/client/transcript.js +699 -0
  49. package/dist/page/client/transcript.js.map +1 -0
  50. package/dist/page/client/usage.d.ts +7 -0
  51. package/dist/page/client/usage.js +954 -0
  52. package/dist/page/client/usage.js.map +1 -0
  53. package/dist/page/style.d.ts +1 -0
  54. package/dist/page/style.js +2793 -0
  55. package/dist/page/style.js.map +1 -0
  56. package/dist/page.js +62 -13871
  57. package/dist/page.js.map +1 -1
  58. package/dist/server.d.ts +24 -0
  59. package/dist/server.js +349 -22
  60. package/dist/server.js.map +1 -1
  61. package/package.json +2 -2
@@ -0,0 +1,910 @@
1
+ /**
2
+ * The routines page, the settings page, and the permission rules page.
3
+ *
4
+ * One part of the client script; see ./index.ts for how the parts join.
5
+ */
6
+ export const ROUTINES = String.raw `
7
+ // --- routines and settings -------------------------------------------------
8
+
9
+ /**
10
+ * Start a routine by describing it.
11
+ *
12
+ * A fresh session with the sentence begun, rather than a form: "every weekday
13
+ * morning, summarise yesterday's commits" is how people say it, and the agent
14
+ * turns that into a cron and a prompt that stands on its own. A form asked for
15
+ * five cron fields to express the same thing.
16
+ */
17
+ async function newRoutine() {
18
+ await switchTo({ action: "new" });
19
+ input.value = t("routines.starter");
20
+ paintMirror();
21
+ input.focus();
22
+ input.setSelectionRange(input.value.length, input.value.length);
23
+ }
24
+
25
+ /** One run as a line: when, how it went, and what it cost. */
26
+ function runSummary(run) {
27
+ const when = new Date(run.startedAt).toLocaleString();
28
+ if (run.skipped) return t("routines.skipped", { when });
29
+ const parts = [t(run.ok ? "routines.lastOk" : "routines.lastFailed", { when })];
30
+ if (run.catchUp) parts.push(t("routines.catchUp"));
31
+ parts.push(t("routines.turns", { n: run.turns }));
32
+ if (run.costUsd) parts.push("$" + run.costUsd.toFixed(4));
33
+ return parts.join(" · ");
34
+ }
35
+
36
+ /**
37
+ * A routine's past runs, fetched when opened.
38
+ *
39
+ * Skipped times are listed with the runs, with why: a routine that "didn't
40
+ * run" is the question this answers, and the answer is usually that the
41
+ * previous run was still going. A run opens its session — the whole of what
42
+ * the agent did, not the last paragraph of it.
43
+ */
44
+ function routineHistory(routine) {
45
+ const box = el("details", "routine-history");
46
+ box.appendChild(el("summary", "muted small", t("routines.history")));
47
+ const list = el("div", "routine-runs");
48
+ box.appendChild(list);
49
+ box.addEventListener("toggle", () => {
50
+ if (!box.open || list.dataset.loaded) return;
51
+ list.dataset.loaded = "1";
52
+ list.appendChild(el("div", "muted small", t("routines.loading")));
53
+ void getJson("/routine/history?name=" + encodeURIComponent(routine.name)).then((body) => {
54
+ list.replaceChildren();
55
+ const runs = (body && body.runs) || [];
56
+ if (!runs.length) return void list.appendChild(el("div", "muted small", t("routines.noRuns")));
57
+ for (const run of runs) {
58
+ const line = el(run.sessionId ? "button" : "div", "routine-run small" + (run.ok || run.skipped ? "" : " failed"));
59
+ line.appendChild(el("span", null, runSummary(run)));
60
+ if (run.skipped && run.reason === "still-running") {
61
+ line.appendChild(el("span", "muted", t("routines.stillRunning")));
62
+ }
63
+ if (run.sessionId) {
64
+ line.type = "button";
65
+ line.addEventListener("click", () => void openSession(run.sessionId, routine.cwd));
66
+ }
67
+ list.appendChild(line);
68
+ }
69
+ });
70
+ });
71
+ return box;
72
+ }
73
+
74
+ /**
75
+ * Scheduled runs, read from the scheduler rather than kept here.
76
+ *
77
+ * A panel with its own idea of the schedule list drifts from what actually
78
+ * fires, and the drift is invisible until a routine the user thought was
79
+ * running turns out never to have existed.
80
+ */
81
+ async function showRoutines() {
82
+ const body = await getJson("/routines");
83
+ panelBody.replaceChildren();
84
+ if (body.error) return void panelBody.appendChild(panelError(body.error));
85
+ const routines = body.routines;
86
+
87
+ const panel = el("div", "panel");
88
+ const top = el("div", "row-head");
89
+ top.appendChild(el("h2", null, t("routines.title")));
90
+ const addButton = el("button", "ghost", t("routines.add"));
91
+ addButton.type = "button";
92
+ top.appendChild(addButton);
93
+ panel.appendChild(top);
94
+
95
+ addButton.addEventListener("click", () => void newRoutine());
96
+
97
+ if (!routines || !routines.length) {
98
+ panel.appendChild(el("p", "muted", t("routines.none")));
99
+ panel.appendChild(el("p", "muted", t("routines.empty")));
100
+ } else {
101
+ for (const routine of routines) {
102
+ const row = el("div", "row-card");
103
+ const head = el("div", "row-head");
104
+ head.appendChild(el("span", "row-title", routine.name));
105
+ head.appendChild(el("span", "mono row-cron", routine.cron));
106
+ if (!routine.enabled) head.appendChild(el("span", "tag", t("routines.paused")));
107
+ // Where it runs. A routine is made in one project and runs there, from
108
+ // whichever project is open when its time comes, so the card says which.
109
+ if (routine.cwd) {
110
+ const where = el("span", "muted small routine-where", projectName(routine.cwd));
111
+ where.title = routine.cwd;
112
+ head.appendChild(where);
113
+ }
114
+
115
+ const run = el("button", "ghost", t("routines.run"));
116
+ run.type = "button";
117
+ run.addEventListener("click", () => {
118
+ run.disabled = true;
119
+ run.textContent = t("routines.running");
120
+ void api("/routine", { name: routine.name }).then((result) => {
121
+ pushNotice(result);
122
+ void showRoutines();
123
+ });
124
+ });
125
+ head.appendChild(run);
126
+ if (routine.removable) {
127
+ // Paused rather than deleted: the prompt someone got right is kept,
128
+ // and a routine that should not run this week is one switch away from
129
+ // running again.
130
+ const toggle = el("button", "switch");
131
+ toggle.type = "button";
132
+ toggle.setAttribute("role", "switch");
133
+ toggle.setAttribute("aria-checked", routine.enabled ? "true" : "false");
134
+ const label = routine.enabled ? t("routines.pause") : t("routines.resume");
135
+ toggle.setAttribute("aria-label", label);
136
+ toggle.title = label;
137
+ toggle.addEventListener("click", () => {
138
+ toggle.disabled = true;
139
+ void api("/routine/enabled", { name: routine.name, enabled: !routine.enabled }).then((result) => {
140
+ pushNotice(result);
141
+ void showRoutines();
142
+ });
143
+ });
144
+ head.appendChild(toggle);
145
+
146
+ const remove = el("button", "ghost", t("routines.remove"));
147
+ remove.type = "button";
148
+ remove.addEventListener("click", () => {
149
+ if (!confirm(t("routines.removeConfirm", { name: routine.name }))) return;
150
+ remove.disabled = true;
151
+ void api("/routine/remove", { name: routine.name }).then((result) => {
152
+ pushNotice(result);
153
+ void showRoutines();
154
+ });
155
+ });
156
+ head.appendChild(remove);
157
+ }
158
+ row.appendChild(head);
159
+
160
+ const bits = [];
161
+ if (routine.enabled && routine.nextRunAt) {
162
+ bits.push(t("routines.next", { when: new Date(routine.nextRunAt).toLocaleString() }));
163
+ }
164
+ if (routine.lastRun) bits.push(runSummary(routine.lastRun));
165
+ if (bits.length) row.appendChild(el("div", "muted small", bits.join(" · ")));
166
+ if (routine.lastRun && routine.lastRun.error) {
167
+ row.appendChild(el("div", "err small", routine.lastRun.error));
168
+ }
169
+ row.appendChild(routineHistory(routine));
170
+ panel.appendChild(row);
171
+ }
172
+ }
173
+ panelBody.appendChild(panel);
174
+ }
175
+
176
+ /**
177
+ * Everything the app decided for you, one line each.
178
+ *
179
+ * The page used to print every row in full: the value, the paragraph
180
+ * explaining it, and for the write policy a list of forty cache paths. Read
181
+ * top to bottom that is a wall of prose in which the three settings anybody
182
+ * ever changes are indistinguishable from the reference material around them.
183
+ *
184
+ * So each row is a disclosure now. The name and the current value stay on the
185
+ * line — that is what someone came to check — and the reasoning, the long
186
+ * paths and the lists wait behind a click. The controls stay on the summary,
187
+ * because a switch you have to open a row to reach is a switch that has been
188
+ * hidden rather than tidied.
189
+ */
190
+ async function showSettings() {
191
+ const settings = await getJson("/settings");
192
+ panelBody.replaceChildren();
193
+ if (settings.error) return void panelBody.appendChild(panelError(settings.error));
194
+
195
+ const panel = el("div", "panel");
196
+ panel.appendChild(el("h2", null, t("settings.title")));
197
+
198
+ // Rows are grouped by what they are about. Each group is filled as the page
199
+ // is built and drawn at the end under its heading, and only when it has rows.
200
+ const groups = { workspace: el("div"), sync: el("div"), memory: el("div"), activity: el("div"), advanced: el("div") };
201
+ let target = groups.workspace;
202
+
203
+ // A click on a button inside a summary toggles the disclosure as well as
204
+ // running the button, so every action on a row gets this. Without it a
205
+ // switch would flip and open the row underneath it, which reads as the page
206
+ // doing something you did not ask for.
207
+ const keepShut = (node) => {
208
+ node.addEventListener("click", (e) => {
209
+ e.preventDefault();
210
+ e.stopPropagation();
211
+ });
212
+ return node;
213
+ };
214
+
215
+ // Variadic since sync brought the first row with two things to do: a switch
216
+ // and a "do it now". Returns the body so a caller with more to say than a
217
+ // hint — the roots editor, the audit trail — can put it there.
218
+ const field = (label, value, hint, ...actions) => {
219
+ const row = el("details", "setting");
220
+ const head = el("summary");
221
+ head.appendChild(el("span", "chev"));
222
+ head.appendChild(el("div", "setting-name", label));
223
+ // The line is one line: a value that does not fit is cut with an ellipsis
224
+ // rather than wrapped, and the whole of it is printed again inside, so
225
+ // nothing is only available on hover. A value that fits gets no tooltip —
226
+ // one that repeats the text under the pointer is just something in the way.
227
+ const long = !!value && (value.indexOf("\n") >= 0 || value.length > 56);
228
+ const shown = el("div", "setting-value mono", value || "");
229
+ if (long) shown.title = value;
230
+ head.appendChild(shown);
231
+ for (const action of actions) if (action) head.appendChild(keepShut(action));
232
+ row.appendChild(head);
233
+
234
+ const body = el("div", "setting-body");
235
+ if (long) body.appendChild(el("div", "mono small", value));
236
+ if (hint) body.appendChild(el("div", "muted small", hint));
237
+ row.appendChild(body);
238
+ target.appendChild(row);
239
+ return body;
240
+ };
241
+
242
+ /**
243
+ * What a door row shows when it is opened, read the first time it is.
244
+ *
245
+ * Every row here that leads to a page is opened by somebody who wants a
246
+ * look before they decide to go there, and a row whose whole body was a
247
+ * sentence and a button made them go there to find out whether it was worth
248
+ * going. So each one carries the shape of its page: the figures, the first
249
+ * few rules, the last few decisions.
250
+ *
251
+ * On first open, not on paint. Two of these walk every session on disk, and
252
+ * the rows are on screen whether or not anybody cares — which is why the
253
+ * totals were left off them in the first place.
254
+ */
255
+ const preview = (body, load) => {
256
+ const box = el("div");
257
+ body.appendChild(box);
258
+ const row = body.parentNode;
259
+ let loaded = false;
260
+ row.addEventListener("toggle", () => {
261
+ if (!row.open || loaded) return;
262
+ loaded = true;
263
+ box.appendChild(el("div", "muted small", t("preview.loading")));
264
+ void load(box).then(
265
+ () => {},
266
+ (err) => { box.replaceChildren(el("div", "muted small", String(err && err.message || err))); },
267
+ );
268
+ });
269
+ return box;
270
+ };
271
+
272
+ // A labelled block inside an opened row: what the label names, printed as
273
+ // typed. Used for the permission rules and the settings files, both of
274
+ // which are lists someone reads once and closes again.
275
+ const listing = (label, text) => {
276
+ const group = el("div", "setting-list");
277
+ group.appendChild(el("div", "muted small", label));
278
+ const list = el("div", "mono small");
279
+ list.textContent = text;
280
+ group.appendChild(list);
281
+ return group;
282
+ };
283
+
284
+ /**
285
+ * A switch for one boolean the user owns.
286
+ *
287
+ * role="switch" with aria-checked rather than a checkbox, because the
288
+ * element also has to carry a label a screen reader can read and a checkbox
289
+ * here would need one built around it for no gain.
290
+ */
291
+ const toggle = (on, label, onChange, disabled) => {
292
+ const button = el("button", "switch");
293
+ button.type = "button";
294
+ button.setAttribute("role", "switch");
295
+ button.setAttribute("aria-checked", on ? "true" : "false");
296
+ button.setAttribute("aria-label", label);
297
+ button.title = label;
298
+ if (disabled) button.disabled = true;
299
+ button.addEventListener("click", () => {
300
+ button.disabled = true;
301
+ onChange(!on);
302
+ });
303
+ return button;
304
+ };
305
+
306
+ // The working directory is the one setting on this page that is a choice
307
+ // rather than a report, so it gets the same picker the sidebar uses — the
308
+ // list of known projects, plus a folder chooser for one that is not on it.
309
+ const change = el("button", "ghost", t("settings.change"));
310
+ change.type = "button";
311
+ change.addEventListener("click", openProjectPicker);
312
+ field(t("settings.cwd"), settings.cwd || "—", t("settings.cwdHint"), change);
313
+ permissionsField(settings);
314
+
315
+ // A door, not a report: the numbers live on a page of their own, and the row
316
+ // here carries no total of its own because working one out means walking
317
+ // every session on disk — a cost this page would then pay on every paint,
318
+ // for a figure most visits are not here for.
319
+ target = groups.activity;
320
+ const openUsage = el("button", "ghost", t("usage.open"));
321
+ openUsage.type = "button";
322
+ openUsage.addEventListener("click", () => {
323
+ setNav("usage");
324
+ void showUsage();
325
+ });
326
+ const usageRow = field(t("usage.title"), t("usage.value"), t("usage.hint"), openUsage);
327
+ preview(usageRow, async (box) => {
328
+ const body = await getJson("/usage?range=30d");
329
+ box.replaceChildren();
330
+ if (body.error) return void box.appendChild(el("div", "muted small", body.error));
331
+ const totals = body.totals || {};
332
+ const sent = (totals.input || 0) + (totals.cacheRead || 0) + (totals.cacheWrite || 0);
333
+ const moved = sent + (totals.output || 0);
334
+ const facts = [
335
+ "$" + (totals.costUsd || 0).toFixed(2),
336
+ t("usage.tokens", { n: formatTokens(moved) }),
337
+ ];
338
+ if (totals.cacheRead) facts.push(t("usage.cached", { pct: Math.round((totals.cacheRead / sent) * 100) }));
339
+ facts.push(totals.sessions === 1 ? t("usage.overSession") : t("usage.overSessions", { n: totals.sessions || 0 }));
340
+ // The default window, said out loud: the figures on the page move with
341
+ // its picker, and a preview that named no window would be read as all time.
342
+ box.appendChild(el("div", "mono small", facts.join(" · ")));
343
+ box.appendChild(el("div", "muted small", t("usage.range.30d")));
344
+ });
345
+
346
+ /*
347
+ * Memory sits here rather than in the sidebar, and only when a plugin has
348
+ * actually registered it: a row for a page that does not exist would be a
349
+ * dead end, and the memory plugin can be switched off.
350
+ */
351
+ target = groups.memory;
352
+ for (const page of pluginPages.values()) {
353
+ const open = el("button", "ghost", t("settings.memoryOpen"));
354
+ open.type = "button";
355
+ open.addEventListener("click", () => { setNav("nav-plugin-" + page.id); void showMemoryPage(page.id); });
356
+ const row = field(t("settings.memory"), "", t("settings.memoryHint"), open);
357
+ const id = page.id;
358
+ preview(row, async (box) => {
359
+ const result = await getJson("/plugin-page?id=" + encodeURIComponent(id)
360
+ + "&session=" + encodeURIComponent(activeId || ""));
361
+ box.replaceChildren();
362
+ if (result.error && !result.entries) {
363
+ return void box.appendChild(el("div", "muted small", result.error));
364
+ }
365
+ // The same state the page opens with, worked out the same way — one
366
+ // sentence rather than the card, because the card is the page.
367
+ const state = !result.signedIn ? "local"
368
+ : result.accountChanged ? "account"
369
+ : !result.enabled ? "off"
370
+ : result.syncing ? "syncing"
371
+ : (result.errors && result.errors.length) || result.error ? "error" : "on";
372
+ box.appendChild(el("div", "small", t("memory.state." + state)));
373
+ const entries = result.entries || [];
374
+ const project = entries.filter((entry) => entry.scope !== "user").length;
375
+ box.appendChild(el("div", "muted small", result.userAvailable
376
+ ? t("memory.countsScoped", { project, user: entries.length - project })
377
+ : t("memory.counts", { count: entries.length })));
378
+ });
379
+ }
380
+
381
+ target = groups.activity;
382
+ auditField(settings.audit || { on: true, source: "user" });
383
+ target = groups.advanced;
384
+ filesField(settings.settingsPaths);
385
+
386
+ /**
387
+ * What runs, what stops to ask, what is refused, and where writes may land
388
+ * — one row, and a page behind it.
389
+ *
390
+ * These were two rows called Rules and Writes, which is two names for one
391
+ * subject: both are the permission policy, both are read after something
392
+ * surprising happened, and neither is a thing anybody sets from here. The
393
+ * row answers "is anything set?" from its closed line; the lists, and the
394
+ * one editor among them, are on the page.
395
+ */
396
+ function permissionsField(settings) {
397
+ const workspace = settings.workspace || {};
398
+ const rules = settings.permissions || {};
399
+ const roots = (workspace.writableRoots || []).length;
400
+
401
+ const summary = [workspace.mode || "—"];
402
+ if (workspace.mode === "workspace-write") summary.push(roots + " roots");
403
+ for (const kind of ["allow", "ask", "deny"]) {
404
+ if (rules[kind] && rules[kind].length) summary.push(kind + " " + rules[kind].length);
405
+ }
406
+ const session = (rules.sessionAllow || []).length;
407
+ if (session) summary.push(t("settings.sessionApproved") + " " + session);
408
+
409
+ const open = el("button", "ghost", t("perms.open"));
410
+ open.type = "button";
411
+ open.addEventListener("click", () => { setNav("permissions"); void showPermissions(); });
412
+ const body = field(t("perms.title"), summary.join(" · "), t("perms.hint"), open);
413
+
414
+ /*
415
+ * No read behind this one: everything here came with the settings the row
416
+ * was built from, so it is all free and is shown at once.
417
+ *
418
+ * The mode and the roots lead, because they are the part that is always
419
+ * there. A project with no rules of its own is the common case, and this
420
+ * row answered it with one sentence saying nothing was set — while the
421
+ * page behind it had a mode and fifty-two directories to show.
422
+ */
423
+ const shown = 4;
424
+ body.appendChild(el("div", "mono small", [
425
+ workspace.mode || "—",
426
+ workspace.mode === "workspace-write" ? t("perms.rootsCount", { n: roots }) : "",
427
+ ].filter(Boolean).join(" · ")));
428
+ const chosen = settings.configuredRoots || [];
429
+ const rootList = chosen.length ? chosen : (workspace.writableRoots || []);
430
+ if (workspace.mode === "workspace-write" && rootList.length) {
431
+ body.appendChild(listing(
432
+ chosen.length ? t("perms.mine") : t("perms.writes"),
433
+ rootList.slice(0, shown).join("\n")));
434
+ }
435
+
436
+ let hidden = 0;
437
+ let ruled = false;
438
+ for (const kind of ["allow", "ask", "deny"]) {
439
+ const list = rules[kind] || [];
440
+ if (!list.length) continue;
441
+ ruled = true;
442
+ body.appendChild(listing(kind + " · " + list.length, list.slice(0, shown).join("\n")));
443
+ hidden += Math.max(0, list.length - shown);
444
+ }
445
+ if (!ruled) body.appendChild(el("div", "muted small", t("perms.noRules")));
446
+ if (hidden) body.appendChild(el("div", "muted small", t("perms.more", { n: hidden })));
447
+ }
448
+
449
+ /**
450
+ * Whether permission decisions are being written down, and what they say.
451
+ *
452
+ * A switch, because it is a state someone owns rather than a report — but
453
+ * only when they own it. A repository that turned the trail off in its own
454
+ * config has decided this for everyone working in it, and a control offered
455
+ * over that would appear to work and revert on the next launch, so it is
456
+ * shown disabled with the reason underneath instead of hidden.
457
+ *
458
+ * The trail comes with the row rather than behind a second button inside
459
+ * it: opening the row is already the gesture for "tell me more about this",
460
+ * and a "Show trail" you had to find after that was one click too many for
461
+ * the only thing anybody opens this row to see.
462
+ */
463
+ function auditField(audit) {
464
+ const mine = audit.source !== "project";
465
+
466
+ /*
467
+ * A door beside the switch.
468
+ *
469
+ * The row answers the question it is passed on the way to something else
470
+ * — is this on, and what did the session I am in just get allowed to do —
471
+ * and the page behind it answers the one the trail is actually opened
472
+ * for: when was this ever allowed, which is a question a single session
473
+ * rarely holds the answer to. Leaving that to the export meant answering
474
+ * it by reading JSON lines by hand.
475
+ */
476
+ const open = el("button", "ghost", t("settings.auditOpen"));
477
+ open.type = "button";
478
+ open.addEventListener("click", () => {
479
+ auditScope = "session";
480
+ auditFilter = "all";
481
+ setNav("audit");
482
+ void showAudit();
483
+ });
484
+
485
+ const body = field(
486
+ t("settings.audit"),
487
+ t(audit.on ? "settings.auditOnValue" : "settings.auditOffValue"),
488
+ mine ? t(audit.on ? "settings.auditOn" : "settings.auditOff") : t("settings.auditProject"),
489
+ open,
490
+ toggle(audit.on, t("settings.audit"), (next) => {
491
+ void api("/audit", { enabled: next }).then((result) => {
492
+ if (result && result.error) addNotice(result.error, "err");
493
+ void showSettings();
494
+ });
495
+ }, !mine),
496
+ );
497
+
498
+ // The extra copy, when the config named one. Not a control: nobody typed
499
+ // that path into this page, and a switch here that deleted it would turn
500
+ // "stop for now" into "forget where my collector reads from".
501
+ if (audit.file) {
502
+ body.appendChild(el("div", "muted small", t("settings.auditFile", { path: audit.file })));
503
+ }
504
+
505
+ preview(body, async (box) => {
506
+ const result = await getJson("/audit");
507
+ box.replaceChildren();
508
+ if (result && result.error) return void box.appendChild(panelError(result.error));
509
+ const rows = auditRows(result.entries);
510
+ if (!rows.length) {
511
+ box.appendChild(el("div", "muted small", t("settings.auditEmpty")));
512
+ /*
513
+ * An empty session is not an empty machine, and the row said only the
514
+ * first of those — which reads as a trail that records nothing.
515
+ *
516
+ * The wider read happens only here, in the one case where the cheap
517
+ * one came back with nothing: it is a file read per session, and
518
+ * paying it to decorate a row that already had something to show
519
+ * would be the cost this preview is lazy to avoid.
520
+ */
521
+ const everywhere = await getJson("/audit?session=all");
522
+ if (everywhere.error) return;
523
+ const all = auditRows(everywhere.entries);
524
+ if (all.length) {
525
+ box.appendChild(el("div", "muted small", t("audit.elsewhere", { n: all.length })));
526
+ }
527
+ return;
528
+ }
529
+ // The most recent handful, and a count of what is behind them. The row
530
+ // used to print two hundred lines into a box inside a disclosure inside
531
+ // a scrolling page, and stop dead at the two hundredth with a number
532
+ // and nowhere to go; the rest of them are what the page is for.
533
+ box.appendChild(auditList(rows.slice(0, AUDIT_PREVIEW), false));
534
+ if (rows.length > AUDIT_PREVIEW) {
535
+ box.appendChild(el("div", "muted small",
536
+ t("settings.auditMore", { n: rows.length - AUDIT_PREVIEW })));
537
+ }
538
+ });
539
+
540
+ /*
541
+ * Taking the record away with you.
542
+ *
543
+ * Plain links rather than buttons that fetch and build a blob: the server
544
+ * already names the file and hands it over, and a download that the page
545
+ * assembles in memory is one the browser cannot resume, cannot stream, and
546
+ * shows in no download list.
547
+ *
548
+ * Two scopes, because they answer different questions. This session is
549
+ * what somebody is looking at when they open the row; everything is what
550
+ * "send me the audit" means, and building it out of one export per session
551
+ * is not a thing anybody should be asked to do by hand.
552
+ */
553
+ body.appendChild(auditExports(activeId));
554
+ }
555
+
556
+ /**
557
+ * Where these settings were read from — three paths, one row.
558
+ *
559
+ * Nothing here is a choice, and the three of them were three cards of grey
560
+ * absolute path apiece. The closed line says which layers exist, which is
561
+ * the whole of what most visits need; the paths are for the visit where
562
+ * something is set and you cannot see where from.
563
+ */
564
+ function filesField(paths) {
565
+ if (!paths) return;
566
+ const scopes = ["user", "project", "local"].filter((scope) => paths[scope]);
567
+ if (!scopes.length) return;
568
+ const body = field(t("settings.files"), scopes.join(" · "), t("settings.filesHint"));
569
+ for (const scope of scopes) body.appendChild(listing(scope, paths[scope]));
570
+ }
571
+
572
+ /**
573
+ * The write policy, without the wall of text.
574
+ *
575
+ * This row used to print every allowed root inline, and on a machine with
576
+ * plugins installed that is dozens of cache directories — a page of grey
577
+ * paths that buried every setting under it and answered a question nobody
578
+ * asked in that much detail.
579
+ *
580
+ * Collapsed to the shape of the answer instead: the mode and how many
581
+ * roots. The list is one click away, and it comes split, because the two
582
+ * halves are not the same kind of thing. What the config named is a choice
583
+ * and can be changed here. What the plugins contributed is derived and is
584
+ * rebuilt at launch, so a control over it would be a control that silently
585
+ * reverts.
586
+ */
587
+ // Sync, above analytics, because it is the one on this page that decides
588
+ // where the work goes rather than whether a counter is incremented.
589
+ target = groups.sync;
590
+ const sync = await getJson("/sync");
591
+ if (sync && !sync.error) {
592
+ // Three states, not two. Off and "on but signed out" both send nothing,
593
+ // and a switch is the fix for only one of them — so the hint says which,
594
+ // and the button that would do nothing is not offered.
595
+ const value = sync.on
596
+ ? "on — mirroring to " + (sync.url || "the sync service")
597
+ : sync.reason === "no-credential"
598
+ ? "on, but not signed in — nothing is being sent"
599
+ : sync.reason === "excluded"
600
+ ? "off for this project"
601
+ : "off";
602
+ const hint = sync.on
603
+ ? "Transcripts and artifacts, including revisions you make by hand in a canvas, are mirrored to your account."
604
+ : sync.reason === "no-credential"
605
+ ? "Sign in to start mirroring. Until then everything stays on this machine."
606
+ : sync.reason === "excluded"
607
+ ? "Listed under sync.exclude in the config. Nothing from this project leaves the machine."
608
+ : "Everything stays on this machine.";
609
+
610
+ const actions = [];
611
+
612
+ // Shown even when it cannot run, and disabled with the reason above it.
613
+ // Hiding it was worse: someone looking for "sync now" found nothing and
614
+ // had no way to tell whether the app has it at all.
615
+ const now = el("button", "ghost", "Sync now");
616
+ now.type = "button";
617
+ now.disabled = !sync.on;
618
+ now.addEventListener("click", () => {
619
+ now.disabled = true;
620
+ now.textContent = "syncing…";
621
+ void api("/sync/now").then((body) => {
622
+ now.disabled = false;
623
+ now.textContent = "Sync now";
624
+ // Said out loud either way: the whole point of the button is being
625
+ // told it happened, and a silent success is indistinguishable from a
626
+ // click that missed.
627
+ if (body && body.error) addNotice("could not sync: " + body.error, "err");
628
+ else addNotice("pushed everything waiting");
629
+ });
630
+ });
631
+ actions.push(now);
632
+
633
+ // The switch reads the setting, not the outcome: someone who has not
634
+ // signed in has still left sync on, and flipping it off under them
635
+ // because it cannot reach anything yet would be answering a question they
636
+ // did not ask.
637
+ if (sync.reason !== "excluded") {
638
+ actions.push(
639
+ toggle(sync.reason !== "disabled", "Sync to your account", (next) => {
640
+ void api("/sync", { enabled: next }).then(showSettings);
641
+ }),
642
+ );
643
+ }
644
+
645
+ field("Sync", value, hint, ...actions);
646
+ }
647
+
648
+ // Last, and only when this build could actually send something. A switch
649
+ // that does nothing is worse than no switch: it invites someone to believe
650
+ // they turned something off that was never on.
651
+ target = groups.activity;
652
+ const analytics = await getJson("/analytics");
653
+ if (analytics && !analytics.error && analytics.available) {
654
+ field(
655
+ t("settings.analytics"),
656
+ t(analytics.enabled ? "settings.analyticsOn" : "settings.analyticsOff"),
657
+ t("settings.analyticsHint"),
658
+ toggle(analytics.enabled, t("settings.analytics"), (next) => {
659
+ void api("/analytics", { enabled: next }).then(showSettings);
660
+ }),
661
+ );
662
+ }
663
+ // Only in the desktop app: keeping a computer awake is an Electron call, and
664
+ // a switch a browser tab cannot honour would be one that lies.
665
+ const keepAwake = await getJson("/keep-awake");
666
+ if (keepAwake && !keepAwake.error && keepAwake.available) {
667
+ target = groups.workspace;
668
+ field(
669
+ t("settings.keepAwake"),
670
+ t(keepAwake.enabled ? "settings.keepAwakeOn" : "settings.keepAwakeOff"),
671
+ t("settings.keepAwakeHint"),
672
+ toggle(keepAwake.enabled, t("settings.keepAwake"), (next) => {
673
+ void api("/keep-awake", { enabled: next }).then(showSettings);
674
+ }),
675
+ );
676
+ }
677
+ // Which browsers the agent may use. Three choices, so a segmented control
678
+ // rather than a switch; absent when the browser tools are off altogether,
679
+ // since then there is nothing for it to move.
680
+ const browserModes = ["auto", "builtin", "extension"];
681
+ if (browserModes.includes(settings.browserMode)) {
682
+ target = groups.workspace;
683
+ const seg = el("div", "seg setting-seg");
684
+ seg.setAttribute("role", "radiogroup");
685
+ seg.setAttribute("aria-label", t("settings.browserMode"));
686
+ for (const mode of browserModes) {
687
+ const chosen = mode === settings.browserMode;
688
+ const button = el("button", "seg-item" + (chosen ? " on" : ""), t("settings.browserMode." + mode));
689
+ button.type = "button";
690
+ button.setAttribute("role", "radio");
691
+ button.setAttribute("aria-checked", chosen ? "true" : "false");
692
+ button.addEventListener("click", () => {
693
+ if (chosen) return;
694
+ seg.querySelectorAll("button").forEach((b) => (b.disabled = true));
695
+ void api("/settings/browser-mode", { mode }).then(showSettings);
696
+ });
697
+ seg.appendChild(button);
698
+ }
699
+ const body = field(t("settings.browserMode"), "", t("settings.browserModeHint." + settings.browserMode), seg);
700
+
701
+ // Your own browser is reached through the extension, so the row says
702
+ // whether it is reachable now, and where to get it when it is not.
703
+ const ext = settings.browserExtension || {};
704
+ const status = el("div", "setting-status small");
705
+ status.appendChild(el("span", "status-dot" + (ext.connected ? " ok" : "")));
706
+ status.appendChild(document.createTextNode(t(ext.connected ? "settings.extensionConnected" : "settings.extensionMissing")));
707
+ body.appendChild(status);
708
+ body.appendChild(extensionHelp(ext));
709
+ }
710
+ const sections = [
711
+ ["settings.section.workspace", [groups.workspace]],
712
+ ["settings.section.sync", [groups.sync, groups.memory]],
713
+ ["settings.section.activity", [groups.activity]],
714
+ ["settings.section.advanced", [groups.advanced]],
715
+ ];
716
+ for (const [title, parts] of sections) {
717
+ if (!parts.some((part) => part.childNodes.length)) continue;
718
+ panel.appendChild(el("div", "section-title", t(title)));
719
+ for (const part of parts) panel.appendChild(part);
720
+ }
721
+ panelBody.appendChild(panel);
722
+ }
723
+
724
+ /**
725
+ * Where the money went, and on which model.
726
+ *
727
+ * A page rather than a row in Settings, because the question it answers has
728
+ * more than one shape: what has this cost, what is it costing per model, and
729
+ * which sessions were the expensive ones. The line under the composer answers
730
+ * the first for the session in front of you; this answers the rest for the
731
+ * machine.
732
+ *
733
+ * Everything here is read back from the transcripts on disk — nothing is
734
+ * counted twice, nothing survives a session that was deleted, and a run
735
+ * started from the terminal is in the totals the same as one started here.
736
+ */
737
+ /**
738
+ * The permission policy, on a page.
739
+ *
740
+ * Everything the Settings row summarises: the mode, the rules in each of the
741
+ * three kinds, what was approved for this session only, and the writable
742
+ * roots — the one editable thing here, which is why the page exists rather
743
+ * than a taller row. A textarea inside a disclosure inside a scrolling page
744
+ * was an editor nobody could see the whole of while typing in it.
745
+ */
746
+ async function showPermissions() {
747
+ const settings = await getJson("/settings");
748
+ if (shownPanel !== "permissions") return;
749
+ panelBody.replaceChildren();
750
+ if (settings.error) return void panelBody.appendChild(panelError(settings.error));
751
+
752
+ const workspace = settings.workspace || {};
753
+ const rules = settings.permissions || {};
754
+
755
+ const panel = el("div", "panel");
756
+ const head = el("div", "row-head");
757
+ head.appendChild(el("h2", null, t("perms.title")));
758
+ const back = el("button", "ghost", t("usage.back"));
759
+ back.type = "button";
760
+ back.addEventListener("click", () => {
761
+ setNav("nav-settings");
762
+ void showSettings();
763
+ });
764
+ head.appendChild(back);
765
+ panel.appendChild(head);
766
+ panelBody.appendChild(panel);
767
+
768
+ // A labelled block: what the label names, printed as typed.
769
+ const listing = (label, text, faint) => {
770
+ const group = el("div", "setting-list");
771
+ group.appendChild(el("div", "muted small", label));
772
+ const list = el("div", "mono small" + (faint ? " scroll-box" : ""));
773
+ if (faint) list.style.opacity = ".7";
774
+ list.textContent = text;
775
+ group.appendChild(list);
776
+ return group;
777
+ };
778
+
779
+ const card = (title) => {
780
+ panel.appendChild(el("h3", "section-title", title));
781
+ const box = el("section", "row-card perms-card");
782
+ panel.appendChild(box);
783
+ return box;
784
+ };
785
+
786
+ // The mode first, because it decides what the rules below are exceptions to.
787
+ // Drawn on the scale it sits on, so "workspace-write" reads as the middle
788
+ // of three rather than as a word to look up.
789
+ const modes = ["read-only", "workspace-write", "full-access"];
790
+ const modeCard = card(t("perms.mode"));
791
+ const modeRow = el("div", "perm-mode");
792
+ const current = workspace.mode || "";
793
+ modeRow.appendChild(el("span", "perm-mode-name " + current, current || "—"));
794
+ if (modes.indexOf(current) >= 0) {
795
+ const steps = el("div", "perm-mode-steps");
796
+ for (const mode of modes) steps.appendChild(el("span", "perm-mode-step" + (mode === current ? " on" : ""), mode));
797
+ modeRow.appendChild(steps);
798
+ }
799
+ modeCard.appendChild(modeRow);
800
+ if (modes.indexOf(current) >= 0) modeCard.appendChild(el("div", "muted small", t("perms.modeHint." + current)));
801
+
802
+ const rulesCard = card(t("settings.rules"));
803
+ rulesCard.appendChild(el("div", "muted small", t("settings.rulesHint")));
804
+ const group = (kind, title, items) => {
805
+ const box = el("div", "perm-group");
806
+ const head = el("div", "perm-group-head " + kind, title);
807
+ head.appendChild(el("span", "n", String(items.length)));
808
+ box.appendChild(head);
809
+ const chips = el("div", "perm-chips");
810
+ for (const item of items) {
811
+ const chip = el("span", "perm-chip " + kind, item);
812
+ chip.title = item;
813
+ chips.appendChild(chip);
814
+ }
815
+ box.appendChild(chips);
816
+ rulesCard.appendChild(box);
817
+ };
818
+ let any = false;
819
+ for (const kind of ["allow", "ask", "deny"]) {
820
+ if (!rules[kind] || !rules[kind].length) continue;
821
+ any = true;
822
+ group(kind, t("perms.kind." + kind), rules[kind]);
823
+ }
824
+ const session = rules.sessionAllow || [];
825
+ if (session.length) {
826
+ any = true;
827
+ group("session", t("settings.sessionApproved") + " — " + t("settings.notPersisted"), session);
828
+ }
829
+ if (!any) rulesCard.appendChild(el("div", "perm-empty", t("perms.noRules")));
830
+
831
+ // The roots, and only where they mean anything: in every other mode the
832
+ // list is not consulted, and an editor for it would be a control that does
833
+ // nothing until a setting somewhere else changes.
834
+ if (workspace.mode === "workspace-write") {
835
+ const all = workspace.writableRoots || [];
836
+ const chosen = settings.configuredRoots || [];
837
+ const derived = all.filter((root) => chosen.indexOf(root) < 0);
838
+
839
+ const writes = card(t("perms.writes"));
840
+ writes.previousSibling.appendChild(el("span", "perm-count", String(all.length)));
841
+ writes.appendChild(el("div", "muted small", t("perms.writesHint", { n: all.length })));
842
+
843
+ // Yours, and editable. A textarea rather than a list with buttons because
844
+ // the thing being edited is a handful of paths, and typing one is what
845
+ // someone came here to do.
846
+ const mine = el("div", "setting-list");
847
+ mine.appendChild(el("div", "muted small", t("perms.mine")));
848
+ writes.appendChild(mine);
849
+
850
+ const box = el("textarea", "boxed");
851
+ box.value = chosen.join("\n");
852
+ box.rows = Math.max(3, chosen.length + 1);
853
+ box.spellcheck = false;
854
+ box.style.width = "100%";
855
+ box.placeholder = t("perms.placeholder");
856
+ mine.appendChild(box);
857
+
858
+ const save = el("button", "ghost", t("perms.save"));
859
+ save.type = "button";
860
+ save.addEventListener("click", () => {
861
+ save.disabled = true;
862
+ const roots = box.value.split("\n").map((line) => line.trim()).filter(Boolean);
863
+ void api("/settings/writable-roots", { roots }).then((result) => {
864
+ save.disabled = false;
865
+ if (result && result.error) return void addNotice(result.error, "err");
866
+ // Said out loud, because the list above will go on showing the old
867
+ // one until then and someone would reasonably read that as a failure.
868
+ addNotice(t("perms.saved"));
869
+ });
870
+ });
871
+ mine.appendChild(save);
872
+
873
+ if (derived.length) {
874
+ // "more" only reads right when there were some above it. With nothing
875
+ // configured — the common case — every one of them is derived.
876
+ const group = el("div", "setting-list");
877
+ group.appendChild(el("div", "muted small", chosen.length
878
+ ? t("perms.derivedMore", { n: derived.length })
879
+ : t("perms.derivedAll", { n: derived.length })));
880
+ // The last segment is what tells fifty plugin paths apart; the long
881
+ // shared prefix is dimmed and gives way first when the line is short.
882
+ const list = el("div", "perm-roots");
883
+ for (const root of derived) {
884
+ const cut = root.replace(/[\\/]+$/, "").search(/[^\\/]+$/);
885
+ const line = el("div", "perm-root");
886
+ line.title = root;
887
+ const dir = el("span", "dir");
888
+ dir.appendChild(el("bdi", null, cut > 0 ? root.slice(0, cut) : ""));
889
+ line.appendChild(dir);
890
+ line.appendChild(el("span", "base", cut > 0 ? root.slice(cut) : root));
891
+ list.appendChild(line);
892
+ }
893
+ group.appendChild(list);
894
+ writes.appendChild(group);
895
+ }
896
+ }
897
+ }
898
+
899
+ /*
900
+ * ---- the permission trail -------------------------------------------------
901
+ *
902
+ * Two readers of one record. The Settings row shows the last few decisions of
903
+ * the session someone is in; the page shows every session on this machine,
904
+ * filtered by how each decision ended. They share the pairing and the line
905
+ * below rather than each having a copy — the pairing is the part that was
906
+ * wrong before it existed, and one of them would have kept being wrong.
907
+ */
908
+
909
+ /** How much of the trail the Settings row shows before it points at the page. */`;
910
+ //# sourceMappingURL=routines.js.map