crewforth 3.1.0-rc.5 → 3.1.0-rc.6

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 (55) hide show
  1. package/CHANGELOG.md +40 -12
  2. package/README.md +1 -1
  3. package/kit/AGENT_TEMPLATE.md +41 -21
  4. package/kit/CLAUDE.md +9 -7
  5. package/kit/agents/crew-backend-expert.md +4 -1
  6. package/kit/agents/crew-commit-agent.md +4 -1
  7. package/kit/agents/crew-database-expert.md +4 -1
  8. package/kit/agents/crew-devops-expert.md +4 -1
  9. package/kit/agents/crew-frontend-expert.md +4 -1
  10. package/kit/agents/crew-performance-expert.md +4 -1
  11. package/kit/agents/crew-planner.md +10 -1
  12. package/kit/agents/crew-privacy-agent.md +4 -1
  13. package/kit/agents/crew-review-agent.md +4 -1
  14. package/kit/agents/crew-security-expert.md +4 -1
  15. package/kit/agents/crew-session-manager.md +4 -1
  16. package/kit/agents/crew-test-expert.md +4 -1
  17. package/kit/eval/smoke-test.sh +744 -18
  18. package/kit/hooks/agent-outcome.sh +416 -0
  19. package/kit/hooks/guard-agent-model.sh +487 -60
  20. package/kit/hooks/guard-bash.sh +26 -1
  21. package/kit/hooks/guard-schedule.sh +7 -2
  22. package/kit/hooks/guard-write.sh +31 -0
  23. package/kit/hooks/prompt-approval.sh +55 -5
  24. package/kit/settings.json +43 -0
  25. package/kit/skills/crew-loosen/SKILL.md +28 -0
  26. package/kit/studio/server/hooks/studio-gate.sh +90 -4
  27. package/kit/studio/server/hooks/studio-host.mjs +92 -0
  28. package/kit/studio/server/index.js +85 -6
  29. package/kit/studio/server/lib/agent-origin.js +44 -0
  30. package/kit/studio/server/lib/graph.js +45 -7
  31. package/kit/studio/server/lib/model-outcomes.js +287 -0
  32. package/kit/studio/server/lib/palette.js +5 -18
  33. package/kit/studio/server/lib/permissions.js +260 -17
  34. package/kit/studio/server/lib/session.js +41 -1
  35. package/kit/studio/server/lib/usage.js +8 -1
  36. package/kit/studio/web/app.js +203 -32
  37. package/kit/studio/web/approvals.js +53 -2
  38. package/kit/studio/web/ask.js +137 -0
  39. package/kit/studio/web/canvas.js +91 -42
  40. package/kit/studio/web/chat.js +11 -1
  41. package/kit/studio/web/convo.js +9 -0
  42. package/kit/studio/web/dock.js +30 -9
  43. package/kit/studio/web/graph-plan.js +29 -9
  44. package/kit/studio/web/icons/builtin.svg +1 -1
  45. package/kit/studio/web/index.html +11 -4
  46. package/kit/studio/web/keys.js +29 -0
  47. package/kit/studio/web/list-plan.js +8 -3
  48. package/kit/studio/web/list.js +27 -5
  49. package/kit/studio/web/models-plan.js +167 -0
  50. package/kit/studio/web/models.js +127 -0
  51. package/kit/studio/web/names.js +30 -0
  52. package/kit/studio/web/style.css +85 -9
  53. package/kit/studio/web/timeline-plan.js +8 -0
  54. package/kit/studio/web/timeline.js +26 -7
  55. package/package.json +1 -1
@@ -23,8 +23,12 @@ import { NewSession } from './newsession.js';
23
23
  import { Timeline } from './timeline.js';
24
24
  import { List } from './list.js';
25
25
  import { defaultView, narrowWarning } from './list-plan.js';
26
+ import { Models } from './models.js';
27
+ import { cardOf, cardLines, workSteps } from './models-plan.js';
26
28
  import { fitLevel, runsOver } from './toolbar-fit.js';
27
29
  import { roleName, shownType, modelFamily } from './names.js';
30
+ import { shortcutOf } from './keys.js';
31
+ import { SESSION_MODES } from './convo.js';
28
32
  import { fmtDuration, fmtTokens, tiles, timeLine, rightNow, skillsOf, delegatedBy, reportOf, backgroundLines, modelLines, TABS as INSPECTOR_TABS, TAB_WORD } from './inspect.js';
29
33
 
30
34
  const FLEET_POLL_MS = 2000;
@@ -67,11 +71,14 @@ const el = {
67
71
  tbZoom: document.getElementById('tb-zoom'),
68
72
  tbZoomIn: document.getElementById('tb-zoom-in'),
69
73
  tbFit: document.getElementById('tb-fit'),
74
+ tbReset: document.getElementById('tb-reset'),
70
75
  canvasEl: document.getElementById('canvas'),
71
76
  timeline: document.getElementById('timeline'),
72
77
  viewGraph: document.getElementById('view-graph'),
73
78
  viewTimeline: document.getElementById('view-timeline'),
74
79
  viewList: document.getElementById('view-list'),
80
+ viewModels: document.getElementById('view-models'),
81
+ models: document.getElementById('models'),
75
82
  list: document.getElementById('list'),
76
83
  firstRun: document.getElementById('first-run'),
77
84
  stageNote: document.getElementById('stage-note'),
@@ -289,6 +296,10 @@ const chat = new Chat(el.chat, {
289
296
  (outcomes.get(sessionId) ?? []).some((o) => o.toolUseId === toolUseId && o.outcome.startsWith('allowed'))
290
297
  || (owned.get(sessionId)?.alwaysAllowed ?? []).includes(toolName),
291
298
  onContinue: (pane, button) => continueHere(button, pane.id),
299
+ // The mode in the strip: the three a session can be started in, and the one it is in now is marked.
300
+ onMode: (pane, button, now) => openMenu(button, SESSION_MODES.map((m) => ({
301
+ label: m.label, title: m.title, checked: m.mode === now, run: () => setSessionMode(pane.id, m.mode),
302
+ }))),
292
303
  onTerminal: (pane, button) => offerTerminal(button, pane.id),
293
304
  },
294
305
  });
@@ -438,17 +449,37 @@ function paintToolbar(st) {
438
449
  tbValue.group.textContent = GROUP_WORD[st.group] ?? st.group;
439
450
  // The grouping is one choice for both views: the Timeline's rows follow the graph's.
440
451
  timeline.setGroup(st.group);
441
- tbValue.density.textContent = DENSITY_WORD[st.density] ?? st.density;
452
+ // When Auto has turned the cards into chips the button says so: the picture did not fit this pane as cards.
453
+ tbValue.density.textContent = st.density === 'auto' && st.drawnAs === 'compact' ? 'Auto \u00b7 compact' : (DENSITY_WORD[st.density] ?? st.density);
442
454
  // Auto is a choice the canvas makes; say which one it made, and why.
443
455
  el.tbDensity.title = st.density === 'auto' && st.drawnAs
444
456
  ? `Auto: ${st.drawnAs}${st.stacked ? ', agents of one type folded together' : ''}. `
445
- + `Cards stay cards while the picture is no taller than ${AUTO.tallest}px.`
457
+ + `Cards stay cards while the picture fits this pane at ${Math.round(AUTO.readable * 100)}% or larger; choose Comfortable to keep them anyway.`
446
458
  : '';
447
459
  const pct = `${Math.round(st.zoom * 100)}%`;
448
460
  el.tbZoom.textContent = pct;
449
461
  el.tbZoom.setAttribute('aria-label', `Zoom ${pct}. Back to 100%`);
450
- el.tbExpand.disabled = st.groups === 0;
451
- el.tbFold.disabled = st.groups === 0;
462
+ // Enabled only while a card has been moved: with none there is nothing to reset.
463
+ if (el.tbReset) el.tbReset.disabled = !(st?.moved > 0);
464
+ paintFoldButtons();
465
+ }
466
+
467
+ /* Expand all and Fold all act on the view that is open: the graph's groups, the Timeline's, the List's sections.
468
+ Each is enabled only while it has something left to do there, so the pair always says what is true. */
469
+ const foldTarget = () => (view === 'timeline' ? timeline : view === 'list' ? list : null);
470
+ function foldState() {
471
+ const t = foldTarget();
472
+ if (t) return t.foldState();
473
+ const st = canvas.state();
474
+ return { groups: st.groups, canExpand: st.canExpand, canFold: st.canFold };
475
+ }
476
+ function paintFoldButtons() {
477
+ // The canvas reports its state while it is being built, before the view and the other two views exist; a
478
+ // `let` or `const` that has not been reached yet throws on any read, `typeof` included.
479
+ let s;
480
+ try { s = foldState(); } catch { return; }
481
+ el.tbExpand.disabled = !s.canExpand;
482
+ el.tbFold.disabled = !s.canFold;
452
483
  }
453
484
 
454
485
  // Made before the canvas: the canvas reports its state while it is being built, and the toolbar passes the
@@ -461,7 +492,8 @@ const timeline = new Timeline(el.timeline, {
461
492
  onSelect: (n) => { if (n?.kind === 'agent') loadDetail(n.id, n.status); },
462
493
  onShowOnGraph: (n) => { setView('graph', n.id); },
463
494
  onOpenConversation: () => { if (current) openConversation(current); },
464
- onChange: paintTimelineBar,
495
+ // A group folded or opened by hand changes what Expand all and Fold all have left to do, at once.
496
+ onChange: (st) => { paintTimelineBar(st); paintFoldButtons(); },
465
497
  fmt: { duration: fmtDuration, tokens: fmtTokens },
466
498
  });
467
499
  // Bars are placed on the server's clock: the timestamps they are drawn from are that machine's.
@@ -480,7 +512,7 @@ const canvas = new Canvas(document.getElementById('canvas'), {
480
512
  : { label: 'Focus on this branch', run: () => canvas.setFocus(n.id) });
481
513
  items.push({ label: 'Copy agent id', run: () => copyText(n.id, 'Copied the agent id') });
482
514
  }
483
- if (at.pinned) items.push({ label: 'Reset layout', run: () => canvas.resetLayout() });
515
+ if (at.pinned) items.push({ label: 'Reset layout', run: () => resetLayout() });
484
516
  if (!items.length) return;
485
517
  openMenu({ getBoundingClientRect: () => ({ left: at.x, bottom: at.y }) }, items);
486
518
  },
@@ -574,13 +606,21 @@ el.tbOptions.addEventListener('click', (e) => {
574
606
  }
575
607
  openMenu(el.tbOptions, items);
576
608
  });
577
- el.tbExpand.addEventListener('click', () => canvas.expandAll());
578
- el.tbFold.addEventListener('click', () => canvas.foldAll());
609
+ el.tbExpand.addEventListener('click', () => { (foldTarget() ?? canvas).expandAll(); paintFoldButtons(); });
610
+ el.tbFold.addEventListener('click', () => { (foldTarget() ?? canvas).foldAll(); paintFoldButtons(); });
579
611
  el.tbZoomOut.addEventListener('click', () => canvas.zoomBy(1 / 1.2));
580
612
  el.tbZoomIn.addEventListener('click', () => canvas.zoomBy(1.2));
581
613
  el.tbZoom.addEventListener('click', () => canvas.zoomTo(1));
582
614
  el.tbFit.addEventListener('click', () => canvas.fit());
583
615
 
616
+ /* Reset layout: the cards moved by hand go back where the layout puts them, and for a moment that can be undone. */
617
+ function resetLayout() {
618
+ const was = canvas.resetLayout();
619
+ if (!was) return;
620
+ toast('Layout reset', { label: 'Undo', run: () => { if (canvas.restoreLayout(was)) toast('Layout restored'); } });
621
+ }
622
+ el.tbReset.addEventListener('click', resetLayout);
623
+
584
624
  /* --------------------------------------------------------------- views
585
625
  The same session two ways: where its agents are, and when they were. The choice of what is selected, how it is
586
626
  grouped and what is shown is one choice, kept across the switch. */
@@ -589,13 +629,65 @@ let view = 'graph';
589
629
 
590
630
  // The List: the same agents by what they need from the reader. A waiting request is a card with its own three
591
631
  // answers there, so the dock stands down while the List is the view.
632
+ /* Which model did which class of work in this project (Crewforth's own record), and the holds that could be lowered.
633
+ Read only: lowering a hold is a Crewforth command the user runs in their own session, never a request from here. */
634
+ let modelsData = null;
635
+ let modelsFor = null;
636
+ let modelsAt = 0;
637
+ const models = new Models(el.models, {
638
+ onCopy: (text) => navigator.clipboard.writeText(text).then(() => toast('Copied — send it in your own session to lower the hold'), () => toast(`Not copied — select it: ${text}`)),
639
+ });
640
+ let modelsBusy = false;
641
+ async function loadModels(force = false) {
642
+ if (!current) return;
643
+ // One request at a time: a slow answer is not asked for again by the next tick, and an answer for a session the
644
+ // viewer has left is dropped.
645
+ if (modelsBusy) return;
646
+ if (!force && modelsFor === current && Date.now() - modelsAt < MODELS_POLL_MS) return;
647
+ const asked = current;
648
+ modelsAt = Date.now();
649
+ modelsBusy = true;
650
+ let data;
651
+ try { data = await getJson(`/api/models?session=${encodeURIComponent(asked)}`); } catch (e) { data = { measured: false, reason: e.message, classes: [], suggestions: [], byAgent: {} }; }
652
+ finally { modelsBusy = false; }
653
+ if (asked !== current) return;
654
+ const changed = JSON.stringify(data) !== JSON.stringify(modelsData) || modelsFor !== asked;
655
+ modelsFor = asked;
656
+ modelsData = data;
657
+ if (!changed) return;
658
+ models.setData(data);
659
+ if (inspectorNode?.kind === 'agent') paintInspector();
660
+ }
661
+ /* What the classes cost is read from the project's transcripts, which is the slow part: asked for apart, once
662
+ when the view opens and then once a minute while it stays open, and never twice at once. */
663
+ let costsBusy = false;
664
+ let costsFor = null;
665
+ let costsAt = 0;
666
+ async function loadCosts(force = false) {
667
+ if (!current || costsBusy) return;
668
+ if (!force && costsFor === current && Date.now() - costsAt < COSTS_POLL_MS) return;
669
+ const asked = current;
670
+ costsAt = Date.now();
671
+ costsBusy = true;
672
+ let costs = null;
673
+ try { costs = await getJson(`/api/models/cost?session=${encodeURIComponent(asked)}`); } catch { /* the cells keep saying they are being read */ }
674
+ finally { costsBusy = false; }
675
+ if (asked !== current) return;
676
+ costsFor = asked;
677
+ models.setCosts(costs);
678
+ }
679
+ const COSTS_POLL_MS = 60000;
680
+ const MODELS_POLL_MS = 5000;
681
+
592
682
  const list = new List(el.list, {
593
683
  tileOf: (n) => canvas.tileFor(n),
594
684
  dimmed: (n) => canvas.dimmed(n),
595
685
  // A row leads to the agent's detail: the inspector, which on a phone is a page of its own.
596
686
  onSelect: (n) => { list.select(n.id); canvas.select(n.id); },
597
- onDecide: (item, verdict) => decideRequest(item, verdict),
687
+ onDecide: (item, verdict, extra) => decideRequest(item, verdict, extra),
598
688
  onLocate: (item) => { if (item.sessionId !== current) selectSession(item.sessionId); },
689
+ // A section folded or opened by hand changes what Expand all and Fold all have left to do, at once.
690
+ onFold: () => paintFoldButtons(),
599
691
  });
600
692
  list.now = () => serverNow();
601
693
 
@@ -621,13 +713,15 @@ function paintTimelineBar(st) {
621
713
  */
622
714
  function setView(next, select = null, remember = true) {
623
715
  const was = view;
624
- view = ['graph', 'timeline', 'list'].includes(next) ? next : 'graph';
716
+ view = ['graph', 'timeline', 'list', 'models'].includes(next) ? next : 'graph';
625
717
  // A view the window opened on by default is not a choice, and is not remembered as one.
626
718
  if (remember) store.set('crewforth-studio-view', view);
627
719
  const carried = select ?? (was === 'timeline' ? timeline.selected : was === 'list' ? list.selected : canvas.selected) ?? null;
628
720
  el.canvasEl.hidden = view !== 'graph';
629
721
  el.timeline.hidden = view !== 'timeline';
630
722
  el.list.hidden = view !== 'list';
723
+ el.models.hidden = view !== 'models';
724
+ el.viewModels.setAttribute('aria-selected', String(view === 'models'));
631
725
  el.viewGraph.setAttribute('aria-selected', String(view === 'graph'));
632
726
  el.viewTimeline.setAttribute('aria-selected', String(view === 'timeline'));
633
727
  el.viewList.setAttribute('aria-selected', String(view === 'list'));
@@ -635,6 +729,7 @@ function setView(next, select = null, remember = true) {
635
729
  for (const c of document.querySelectorAll('.toolbar [data-view]')) c.hidden = !c.dataset.view.split(' ').includes(view);
636
730
  queueFit();
637
731
  el.stage.dataset.view = view;
732
+ paintFoldButtons();
638
733
  // The List's cards carry the answers; the dock would be the same requests a second time.
639
734
  dock.setSuppressed(view === 'list');
640
735
  measureDock();
@@ -650,6 +745,12 @@ function setView(next, select = null, remember = true) {
650
745
  } else if (view === 'list') {
651
746
  list.select(carried && canvas.nodes.get(carried)?.kind === 'agent' ? carried : null);
652
747
  list.render();
748
+ } else if (view === 'models') {
749
+ // The record is the project's, not the session's: it is read when the view opens and while it stays open.
750
+ models.render();
751
+ if (costsFor !== current) models.setCosts(null);
752
+ loadModels(true);
753
+ loadCosts(costsFor !== current);
653
754
  } else {
654
755
  canvas.fitIfUntouched();
655
756
  if (carried && canvas.nodes.has(carried)) canvas.focus(carried);
@@ -666,24 +767,26 @@ function goTo(agentId) {
666
767
  el.viewGraph.addEventListener('click', () => setView('graph'));
667
768
  el.viewTimeline.addEventListener('click', () => setView('timeline'));
668
769
  el.viewList.addEventListener('click', () => setView('list'));
770
+ el.viewModels.addEventListener('click', () => setView('models'));
669
771
  el.tbFollow.addEventListener('click', () => timeline.setFollow(el.tbFollow.getAttribute('aria-checked') !== 'true'));
670
772
  // "Out" is more time on screen, "in" is less: the same way round as the graph's zoom.
671
773
  el.tbRangeOut.addEventListener('click', () => timeline.zoom(1));
672
774
  el.tbRangeIn.addEventListener('click', () => timeline.zoom(-1));
673
775
  if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnimationFrame(() => timeline.render())).observe(el.timeline);
776
+ // The canvas's pane changing size changes what fits in it, and with that what Auto density draws.
777
+ if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnimationFrame(() => canvas.fitIfUntouched())).observe(el.canvasEl);
674
778
 
675
779
  getJson('/api/palette')
676
780
  .then((p) => {
677
781
  canvas.setPalette(p);
678
- // An unread palette makes every agent "unrecognised", and twelve of those
679
- // read like twelve strangers. Which one it is has to be said out loud, or
680
- // the panel is drawing "not measured" as a fact about the agents.
782
+ // With Crewforth's agents unread, a `crew-…` agent cannot be told from one that only carries the name. That has
783
+ // to be said out loud, or the panel is drawing "not measured" as a fact about the agents.
681
784
  if (p && p.measured === false) {
682
785
  document.body.dataset.paletteMeasured = 'false';
683
786
  el.foot.textContent = `agent identity Not measured — ${p.reason}`;
684
787
  }
685
788
  })
686
- .catch(() => { /* every agent is drawn as unrecognised, which is what is known */ });
789
+ .catch(() => { /* every agent is drawn with the common mark, which is what is known */ });
687
790
 
688
791
  let inspectorTab = 'overview';
689
792
  let inspectorNode = null;
@@ -867,6 +970,36 @@ function paintAgentOverview(body, n, detail) {
867
970
  }
868
971
  body.append(grid);
869
972
 
973
+ // The task card the work started with, and how Crewforth recorded it going: the calls of this card, oldest
974
+ // first, each with what it ran on and what its verify command said; the last is this agent's.
975
+ if (n.kind === 'agent') {
976
+ if (modelsFor !== current) loadModels();
977
+ const row = modelsFor === current ? (modelsData?.byAgent?.[n.id] ?? null) : null;
978
+ const lines = cardLines(cardOf(detail?.prompt ?? null), row);
979
+ const work = workSteps(row);
980
+ if (lines.length) {
981
+ const dl = node('dl', 'icard');
982
+ for (const l of lines) dl.append(node('dt', null, l.label), node('dd', null, l.text));
983
+ const sec = isection('Task card', dl);
984
+ if (work.risk) { const tag = node('span', 'mv-tag', work.risk); tag.dataset.risk = work.risk; sec.children[0].append(tag); }
985
+ body.append(sec);
986
+ }
987
+ // No card and no line: an agent that is not a crew agent, and nothing is said about it here.
988
+ if (row || lines.length) {
989
+ const ol = node('ol', 'isteps');
990
+ work.steps.forEach((st, i) => {
991
+ const li = node('li');
992
+ const says = node('span', 'istep-says', st.text);
993
+ if (st.verify) { says.append(' \u00b7 verify'); const v = node('span', 'mv-tag', st.verify); v.dataset.tone = st.tone; says.append(v); }
994
+ if (st.escalated) { const e = node('span', 'mv-tag', 'escalated'); e.dataset.kind = 'escalated'; says.append(e); }
995
+ li.append(node('span', 'istep-n', String(i + 1)), says);
996
+ ol.append(li);
997
+ });
998
+ const sec = isection('Model and verify', ...(work.steps.length ? [ol] : []), ...(work.note ? [node('p', 'istep-note', work.note)] : []));
999
+ body.append(sec);
1000
+ }
1001
+ }
1002
+
870
1003
  // What it ran on and what its call asked for, and what the model gate or a low-confidence report did to it.
871
1004
  const models = modelLines(n, lastNodes);
872
1005
  if (models.length) {
@@ -1270,11 +1403,20 @@ function marked(text, cls) {
1270
1403
  /* A short line that says something happened and goes away. Not a dialog: it
1271
1404
  takes no focus and needs no answer. */
1272
1405
  let toastTimer = null;
1273
- function toast(text) {
1274
- el.toast.textContent = text;
1406
+ function toast(text, action = null) {
1407
+ el.toast.replaceChildren(document.createTextNode(text));
1408
+ // One thing that can be done about what just happened, while the line is up: "Undo".
1409
+ if (action) {
1410
+ const b = document.createElement('button');
1411
+ b.type = 'button';
1412
+ b.className = 'toast-act';
1413
+ b.textContent = action.label;
1414
+ b.addEventListener('click', () => { el.toast.hidden = true; action.run(); });
1415
+ el.toast.append(' \u00b7 ', b);
1416
+ }
1275
1417
  el.toast.hidden = false;
1276
1418
  clearTimeout(toastTimer);
1277
- toastTimer = setTimeout(() => { el.toast.hidden = true; }, 3200);
1419
+ toastTimer = setTimeout(() => { el.toast.hidden = true; }, action ? 6000 : 3200);
1278
1420
  }
1279
1421
 
1280
1422
  async function copyText(text, said) {
@@ -1987,10 +2129,19 @@ function stepAttention(by) {
1987
2129
  // reading panel appear on every click there made choosing expensive. The
1988
2130
  // conversation is opened from the session node on the canvas, which is the
1989
2131
  // thing that represents it.
2132
+ // Where this session's agents are defined. Asked once per session; an answer for a session no longer shown is dropped.
2133
+ async function loadOrigins(sessionId) {
2134
+ canvas.setOrigins(null);
2135
+ let o = null;
2136
+ try { o = await getJson(`/api/agents?session=${encodeURIComponent(sessionId)}`); } catch { /* the mark says whose it is without it */ }
2137
+ if (current === sessionId) canvas.setOrigins(o?.measured ? o : null);
2138
+ }
2139
+
1990
2140
  function selectSession(sessionId) {
1991
2141
  if (current === sessionId) return;
1992
2142
  current = sessionId;
1993
2143
  canvas.setSession(sessionId);
2144
+ loadOrigins(sessionId);
1994
2145
  timeline.setSession(sessionId);
1995
2146
  list.setSession(sessionId);
1996
2147
  timeline.setFilter(null);
@@ -2075,7 +2226,7 @@ function paintPulse() {
2075
2226
  pulse.detail.textContent = l.detail ? `· ${l.detail}` : '';
2076
2227
  }
2077
2228
  paintPulse();
2078
- setInterval(() => { paintPulse(); dock.tick(); timeline.tick(); list.tick(); paintStageNote(); }, 1000);
2229
+ setInterval(() => { paintPulse(); dock.tick(); timeline.tick(); list.tick(); paintStageNote(); if (view === 'models') { loadModels(); loadCosts(); } }, 1000);
2079
2230
 
2080
2231
  /* -------------------------------------------------------------- home, keys */
2081
2232
 
@@ -2107,28 +2258,33 @@ document.addEventListener('keydown', (e) => {
2107
2258
  if (!el.inspector.hidden) { canvas.clearSelection(); showInspector(null); }
2108
2259
  return;
2109
2260
  }
2110
- if (e.metaKey || e.ctrlKey) return;
2111
- const t = e.target;
2112
- if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
2113
- if (e.key === '/') {
2261
+ // A letter is a shortcut only when it is not being typed into a field and no modifier is held: Cmd+R is the
2262
+ // browser's reload, not "reset layout".
2263
+ const key = shortcutOf(e);
2264
+ if (key === null) return;
2265
+ if (key === '/') {
2114
2266
  e.preventDefault();
2115
2267
  setSideHidden(false);
2116
2268
  el.filter.focus();
2117
- } else if (e.key === '[') {
2269
+ } else if (key === '[') {
2118
2270
  setSideHidden(true);
2119
- } else if (e.key === ']') {
2271
+ } else if (key === ']') {
2120
2272
  setSideHidden(false);
2121
- } else if (e.key === 'g') {
2273
+ } else if (key === 'g') {
2122
2274
  setView('graph');
2123
- } else if (e.key === 't') {
2275
+ } else if (key === 't') {
2124
2276
  setView('timeline');
2125
- } else if (e.key === 'l') {
2277
+ } else if (key === 'l') {
2126
2278
  setView('list');
2127
- } else if (e.key === 'j') {
2279
+ } else if (key === 'r') {
2280
+ if (view === 'graph') resetLayout();
2281
+ } else if (key === 'm') {
2282
+ setView('models');
2283
+ } else if (key === 'j') {
2128
2284
  stepAttention(1);
2129
- } else if (e.key === 'k') {
2285
+ } else if (key === 'k') {
2130
2286
  stepAttention(-1);
2131
- } else if (e.key === '?') {
2287
+ } else if (key === '?') {
2132
2288
  canvas.showLegend(true);
2133
2289
  }
2134
2290
  });
@@ -2225,13 +2381,27 @@ function paintApprovals() {
2225
2381
  if (inspectorNode && inspectorTab === 'gates') paintInspector();
2226
2382
  }
2227
2383
 
2228
- async function decideRequest(item, verdict) {
2384
+ /** Ask for a session started here to be put in another mode. The strip changes when Claude Code says it has. */
2385
+ async function setSessionMode(sessionId, mode) {
2386
+ let res;
2387
+ try {
2388
+ const r = await fetch(api(`/api/owned/${encodeURIComponent(sessionId)}/mode`),
2389
+ { method: 'POST', headers: { 'content-type': 'application/json', ...writeHeaders }, body: JSON.stringify({ mode }) });
2390
+ res = await r.json();
2391
+ } catch (e) {
2392
+ res = { ok: false, reason: e.message };
2393
+ }
2394
+ if (!res.ok) toast(`Mode not changed — ${res.reason ?? 'no reason given'}`);
2395
+ else pollOwned();
2396
+ }
2397
+
2398
+ async function decideRequest(item, verdict, extra = {}) {
2229
2399
  decided.set(item.key, verdict);
2230
2400
  let res;
2231
2401
  try {
2232
2402
  const r = await fetch(
2233
2403
  api(`/api/owned/${encodeURIComponent(item.sessionId)}/permissions/${encodeURIComponent(item.toolUseId)}`),
2234
- { method: 'POST', headers: { 'content-type': 'application/json', ...writeHeaders }, body: JSON.stringify({ verdict }) },
2404
+ { method: 'POST', headers: { 'content-type': 'application/json', ...writeHeaders }, body: JSON.stringify({ verdict, ...extra }) },
2235
2405
  );
2236
2406
  res = await r.json();
2237
2407
  } catch (e) {
@@ -2315,6 +2485,7 @@ function paintStageNote() {
2315
2485
  // The fleet can say a session ended, or came back, with no transcript changing.
2316
2486
  if (lastGraph && sessionIsLive() !== lastLive) showGraph();
2317
2487
  else if (lastLive === true) paintSpend();
2488
+ paintFoldButtons();
2318
2489
  paintTerminalWait();
2319
2490
  }
2320
2491
 
@@ -23,6 +23,54 @@ export class ServerClock {
23
23
  now(localNow) { return localNow + this.offset; }
24
24
  }
25
25
 
26
+ // A plan is approved into one of these; the words are the buttons'.
27
+ export const PLAN_CHOICES = [
28
+ { mode: 'acceptEdits', label: 'Approve \u00b7 accept edits', title: 'Approve the plan and go on in acceptEdits: file edits in the project are accepted without asking; every call is still shown here first.' },
29
+ { mode: 'default', label: 'Approve \u00b7 ask each time', title: 'Approve the plan and go on in default: each call is asked about here.' },
30
+ ];
31
+
32
+ /**
33
+ * What a request asks of the viewer when the answer is not allow-or-deny.
34
+ * { kind: 'questions', questions: [{ question, header, options: [{ label, description }], multiSelect }] }
35
+ * { kind: 'plan', plan }
36
+ * Null for an ordinary tool call. Only what the tool's own input holds; nothing is added to it.
37
+ */
38
+ export function askOf(r) {
39
+ const input = r?.input && typeof r.input === 'object' ? r.input : {};
40
+ if (r?.toolName === 'AskUserQuestion' && Array.isArray(input.questions)) {
41
+ return {
42
+ kind: 'questions',
43
+ questions: input.questions.filter((q) => typeof q?.question === 'string').map((q) => ({
44
+ question: q.question,
45
+ header: typeof q.header === 'string' ? q.header : '',
46
+ multiSelect: q.multiSelect === true,
47
+ options: (Array.isArray(q.options) ? q.options : []).filter((o) => typeof o?.label === 'string')
48
+ .map((o) => ({ label: o.label, description: typeof o.description === 'string' ? o.description : '' })),
49
+ })),
50
+ };
51
+ }
52
+ if (r?.toolName === 'ExitPlanMode') return { kind: 'plan', plan: typeof input.plan === 'string' ? input.plan : '' };
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * The answers a form holds, as the tool takes them: question text -> the chosen label, a list of labels for a
58
+ * question that takes several, or the viewer's own text. Null while any question has no answer.
59
+ * @param picked Map question -> Set of labels · @param typed Map question -> free text
60
+ */
61
+ export function answersOf(ask, picked, typed) {
62
+ const out = {};
63
+ for (const q of ask?.questions ?? []) {
64
+ const own = (typed?.get(q.question) ?? '').trim();
65
+ const labels = [...(picked?.get(q.question) ?? [])];
66
+ if (own && !q.multiSelect) out[q.question] = own;
67
+ else if (q.multiSelect && (labels.length || own)) out[q.question] = own ? [...labels, own] : labels;
68
+ else if (!q.multiSelect && labels.length) out[q.question] = labels[0];
69
+ else return null;
70
+ }
71
+ return out;
72
+ }
73
+
26
74
  /**
27
75
  * Every request waiting in every session started here, oldest first: that is the order they will time out in.
28
76
  *
@@ -45,7 +93,10 @@ export function queue(sessions, nameOf = () => null) {
45
93
  agentType: r.agentType ?? null,
46
94
  askedAt: r.askedAt,
47
95
  // Null when the server did not say. The dock then shows no countdown rather than one it made up.
48
- waitSeconds: typeof s.gateWaitSeconds === 'number' && s.gateWaitSeconds > 0 ? s.gateWaitSeconds : null,
96
+ waitSeconds: typeof r.waitSeconds === 'number' && r.waitSeconds > 0 ? r.waitSeconds
97
+ : (typeof s.gateWaitSeconds === 'number' && s.gateWaitSeconds > 0 ? s.gateWaitSeconds : null),
98
+ // What a question asks and what a plan says: the two requests that are not answered with allow or deny.
99
+ ask: askOf(r),
49
100
  });
50
101
  }
51
102
  }
@@ -125,7 +176,7 @@ export function settled(before, after, decided, serverNowMs, slackMs = 2500) {
125
176
  const verdict = decided?.get(r.key) ?? null;
126
177
  let outcome;
127
178
  if (verdict === 'deny') outcome = 'denied';
128
- else if (verdict === 'allow') outcome = 'allowed';
179
+ else if (verdict === 'allow' || verdict === 'answer' || verdict === 'plan') outcome = 'allowed';
129
180
  else if (verdict === 'always') outcome = 'allowed-session';
130
181
  else if (r.waitSeconds != null && serverNowMs >= r.askedAt + r.waitSeconds * 1000 - slackMs) outcome = 'timed-out';
131
182
  else outcome = 'answered-elsewhere';
@@ -0,0 +1,137 @@
1
+ // The two requests that are not answered with allow or deny: a question the model asks, and a plan it wants
2
+ // approved. This file draws the form for each and hands back what the viewer chose.
3
+ //
4
+ // A form is built once for a request and kept while the request waits. The dock and the List redraw around it every
5
+ // second; a form rebuilt on each of those would lose what was picked, and what was being typed.
6
+ import { answersOf, PLAN_CHOICES } from './approvals.js';
7
+
8
+ function mk(tag, cls, text) {
9
+ const n = document.createElement(tag);
10
+ if (cls) n.className = cls;
11
+ if (text != null) n.textContent = text;
12
+ return n;
13
+ }
14
+
15
+ export class AskForms {
16
+ /** @param onDecide (item, verdict, extra) => void — 'answer' with { answers }, 'plan' with { mode }, or 'deny' */
17
+ constructor(onDecide) {
18
+ this.onDecide = onDecide ?? (() => {});
19
+ this.forms = new Map(); // request key -> { el, sent(on) }
20
+ }
21
+
22
+ /** The form for a request, the same element every time it is asked for. Null for an ordinary tool call. */
23
+ get(item) {
24
+ if (!item?.ask) return null;
25
+ let f = this.forms.get(item.key);
26
+ if (!f) {
27
+ f = item.ask.kind === 'plan' ? this.#plan(item) : this.#questions(item);
28
+ this.forms.set(item.key, f);
29
+ }
30
+ return f.el;
31
+ }
32
+
33
+ /** An answer was sent, or could not be delivered: the form's controls go off, or come back. */
34
+ setSent(key, on) { this.forms.get(key)?.sent(on); }
35
+
36
+ /** Forget the forms of requests that are no longer waiting. */
37
+ keep(keys) {
38
+ const alive = new Set(keys);
39
+ for (const k of [...this.forms.keys()]) if (!alive.has(k)) this.forms.delete(k);
40
+ }
41
+
42
+ #questions(item) {
43
+ const el = mk('div', 'ask');
44
+ const picked = new Map(); // question -> Set of labels
45
+ const typed = new Map(); // question -> the viewer's own text
46
+ const controls = [];
47
+ const send = mk('button', 'btn sm primary', 'Send answers');
48
+ const refresh = () => { send.disabled = send.dataset.sent === '1' || answersOf(item.ask, picked, typed) === null; };
49
+
50
+ for (const q of item.ask.questions) {
51
+ picked.set(q.question, new Set());
52
+ const box = mk('div', 'ask-q');
53
+ const head = mk('div', 'ask-head');
54
+ if (q.header) head.append(mk('span', 'ask-tag', q.header));
55
+ head.append(mk('span', 'ask-text', q.question));
56
+ if (q.multiSelect) head.append(mk('span', 'sub', 'choose any'));
57
+ const opts = mk('div', 'ask-opts');
58
+ opts.setAttribute('role', q.multiSelect ? 'group' : 'radiogroup');
59
+ const buttons = [];
60
+ for (const o of q.options) {
61
+ const b = mk('button', 'ask-opt', o.label);
62
+ b.type = 'button';
63
+ b.title = o.description;
64
+ b.setAttribute('role', q.multiSelect ? 'checkbox' : 'radio');
65
+ b.setAttribute('aria-checked', 'false');
66
+ b.addEventListener('click', () => {
67
+ const set = picked.get(q.question);
68
+ if (q.multiSelect) { if (set.has(o.label)) set.delete(o.label); else set.add(o.label); } else {
69
+ // One answer: choosing an option takes the place of anything typed, and of the option chosen before.
70
+ set.clear(); set.add(o.label);
71
+ own.value = ''; typed.delete(q.question);
72
+ }
73
+ for (const x of buttons) x.el.setAttribute('aria-checked', String(set.has(x.label)));
74
+ refresh();
75
+ });
76
+ buttons.push({ el: b, label: o.label });
77
+ controls.push(b);
78
+ opts.append(b);
79
+ }
80
+ // The model's options do not always hold the answer: the viewer's own words are an answer too.
81
+ const own = mk('input', 'ask-own');
82
+ own.type = 'text';
83
+ own.placeholder = q.multiSelect ? 'Something else, as well…' : 'Something else…';
84
+ own.setAttribute('aria-label', `Your own answer to: ${q.question}`);
85
+ own.addEventListener('input', () => {
86
+ typed.set(q.question, own.value);
87
+ if (!q.multiSelect && own.value.trim()) {
88
+ picked.get(q.question).clear();
89
+ for (const x of buttons) x.el.setAttribute('aria-checked', 'false');
90
+ }
91
+ refresh();
92
+ });
93
+ controls.push(own);
94
+ box.append(head, opts, own);
95
+ el.append(box);
96
+ }
97
+
98
+ const acts = mk('div', 'ask-acts');
99
+ const dismiss = mk('button', 'btn sm', 'Dismiss');
100
+ dismiss.type = 'button';
101
+ dismiss.title = 'Refuse the question. The model is told nobody answered and may ask in words instead.';
102
+ send.type = 'button';
103
+ send.addEventListener('click', () => {
104
+ const answers = answersOf(item.ask, picked, typed);
105
+ if (answers) this.onDecide(item, 'answer', { answers });
106
+ });
107
+ dismiss.addEventListener('click', () => this.onDecide(item, 'deny', {}));
108
+ controls.push(dismiss);
109
+ acts.append(dismiss, send);
110
+ el.append(acts);
111
+ refresh();
112
+ return { el, sent: (on) => { send.dataset.sent = on ? '1' : '0'; for (const c of controls) c.disabled = on; refresh(); } };
113
+ }
114
+
115
+ #plan(item) {
116
+ const el = mk('div', 'ask');
117
+ const plan = mk('pre', 'ask-plan', item.ask.plan || 'The plan was not included in the request.');
118
+ plan.tabIndex = 0;
119
+ const acts = mk('div', 'ask-acts');
120
+ const keep = mk('button', 'btn sm', 'Keep planning');
121
+ keep.type = 'button';
122
+ keep.title = 'Refuse to leave plan mode. The model stays in it and goes on planning.';
123
+ keep.addEventListener('click', () => this.onDecide(item, 'deny', {}));
124
+ const controls = [keep];
125
+ acts.append(keep);
126
+ PLAN_CHOICES.forEach((c, i) => {
127
+ const b = mk('button', i === PLAN_CHOICES.length - 1 ? 'btn sm primary' : 'btn sm', c.label);
128
+ b.type = 'button';
129
+ b.title = c.title;
130
+ b.addEventListener('click', () => this.onDecide(item, 'plan', { mode: c.mode }));
131
+ controls.push(b);
132
+ acts.append(b);
133
+ });
134
+ el.append(plan, acts);
135
+ return { el, sent: (on) => { for (const c of controls) c.disabled = on; } };
136
+ }
137
+ }