crewforth 3.5.0-rc.1 → 3.5.0-rc.2

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 (41) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/VERSION +1 -1
  4. package/kit/CLAUDE.md +1 -1
  5. package/kit/eval/lib/statusline.sh +22 -5
  6. package/kit/eval/lib/todo.awk +167 -0
  7. package/kit/eval/lib/todo.sh +120 -0
  8. package/kit/eval/smoke-test.sh +60 -2
  9. package/kit/hooks/prompt-approval.sh +5 -5
  10. package/kit/skills/crew-plan/SKILL.md +2 -1
  11. package/kit/skills/crew-todo/SKILL.md +31 -0
  12. package/kit/skills/spec-planning/SKILL.md +9 -2
  13. package/kit/studio/web/agent-spend.js +32 -0
  14. package/kit/studio/web/app.js +245 -236
  15. package/kit/studio/web/approvals.js +14 -13
  16. package/kit/studio/web/ask.js +10 -9
  17. package/kit/studio/web/canvas.js +56 -54
  18. package/kit/studio/web/chat.js +41 -41
  19. package/kit/studio/web/convo.js +21 -18
  20. package/kit/studio/web/dock.js +20 -19
  21. package/kit/studio/web/graph-plan.js +62 -22
  22. package/kit/studio/web/i18n-tr.js +760 -0
  23. package/kit/studio/web/i18n.js +79 -0
  24. package/kit/studio/web/index.html +1 -0
  25. package/kit/studio/web/inspect.js +27 -24
  26. package/kit/studio/web/list-plan.js +16 -15
  27. package/kit/studio/web/list.js +6 -5
  28. package/kit/studio/web/liveness.js +11 -9
  29. package/kit/studio/web/map-plan.js +33 -29
  30. package/kit/studio/web/map.js +74 -73
  31. package/kit/studio/web/models-plan.js +47 -42
  32. package/kit/studio/web/models.js +21 -20
  33. package/kit/studio/web/names.js +13 -10
  34. package/kit/studio/web/nav.js +34 -33
  35. package/kit/studio/web/newsession.js +25 -24
  36. package/kit/studio/web/style.css +2 -0
  37. package/kit/studio/web/theme.js +5 -2
  38. package/kit/studio/web/timeline-plan.js +19 -18
  39. package/kit/studio/web/timeline.js +23 -22
  40. package/kit/studio/web/usage.js +36 -32
  41. package/package.json +1 -1
@@ -17,6 +17,7 @@ import {
17
17
  import { liveness, offlineNote, staleServerNote } from './liveness.js';
18
18
  import { attention, settle, AUTO } from './graph-plan.js';
19
19
  import { summaryRows, usageLine, costNote } from './usage.js';
20
+ import { agentSpendRows } from './agent-spend.js';
20
21
  import { ServerClock, queue, settled, terminalWait, OUTCOME_WORD } from './approvals.js';
21
22
  import { Dock } from './dock.js';
22
23
  import { NewSession } from './newsession.js';
@@ -32,6 +33,7 @@ import { shortcutOf } from './keys.js';
32
33
  import { qrSvg } from './qr.js';
33
34
  import { SESSION_MODES } from './convo.js';
34
35
  import { fmtDuration, fmtTokens, tiles, timeLine, rightNow, skillsOf, delegatedBy, reportOf, backgroundLines, modelLines, TABS as INSPECTOR_TABS, TAB_WORD } from './inspect.js';
36
+ import { t, numLocale, translateDom, initLang } from './i18n.js';
35
37
 
36
38
  const FLEET_POLL_MS = 2000;
37
39
  const SESSION_POLL_MS = 5000;
@@ -129,7 +131,7 @@ async function statusError(r) {
129
131
  let b = null; try { b = await r.json(); } catch { /* not JSON */ }
130
132
  if (b?.expired) showExpired();
131
133
  }
132
- if (r.status === 403 && transcriptsHidden) return new Error('Transcripts are hidden on this link');
134
+ if (r.status === 403 && transcriptsHidden) return new Error(t('Transcripts are hidden on this link'));
133
135
  return new Error(`HTTP ${r.status}`);
134
136
  }
135
137
 
@@ -139,8 +141,8 @@ function showExpired() {
139
141
  box.id = 'expired';
140
142
  box.className = 'expired';
141
143
  box.setAttribute('role', 'alert');
142
- const h = document.createElement('h1'); h.textContent = 'Session expired';
143
- const p = document.createElement('p'); p.textContent = 'Viewer links last eight hours. Ask for a new one on the machine running Studio.';
144
+ const h = document.createElement('h1'); h.textContent = t('Session expired');
145
+ const p = document.createElement('p'); p.textContent = t('Viewer links last eight hours. Ask for a new one on the machine running Studio.');
144
146
  box.append(h, p);
145
147
  document.body.append(box);
146
148
  }
@@ -161,6 +163,9 @@ let themeStorage = { getItem: () => null, setItem: () => {} };
161
163
  try { themeStorage = localStorage; } catch { /* blocked storage */ }
162
164
  const relabelTheme = initTheme(document.documentElement, el.theme, themeStorage, () => systemLight.matches);
163
165
  systemLight.addEventListener('change', relabelTheme);
166
+ // The language: index.html is written in English and put into the chosen one here, before anything reads it.
167
+ translateDom(document.body);
168
+ initLang(document.getElementById('lang'), themeStorage, () => location.reload());
164
169
 
165
170
  /* --------------------------------------------------------------- panels
166
171
  Three columns and two dividers. Each width is the user's, remembered, and
@@ -256,7 +261,7 @@ const inspectorSplit = document.createElement('div');
256
261
  inspectorSplit.className = 'resizer inspector-split';
257
262
  inspectorSplit.setAttribute('role', 'separator');
258
263
  inspectorSplit.setAttribute('aria-orientation', 'vertical');
259
- inspectorSplit.setAttribute('aria-label', 'Resize inspector');
264
+ inspectorSplit.setAttribute('aria-label', t('Resize inspector'));
260
265
  inspectorSplit.tabIndex = 0;
261
266
  dragPanel(inspectorSplit, 'inspector', 'right');
262
267
 
@@ -271,7 +276,7 @@ async function toggleFullscreen() {
271
276
  else await document.documentElement.requestFullscreen();
272
277
  } catch (e) {
273
278
  // Denied by policy, or unsupported. Say so rather than appear inert.
274
- el.foot.textContent = `full screen refused: ${e?.message ?? e}`;
279
+ el.foot.textContent = t('full screen refused: {reason}', { reason: e?.message ?? e });
275
280
  }
276
281
  }
277
282
 
@@ -280,16 +285,16 @@ el.fullscreen.addEventListener('click', toggleFullscreen);
280
285
  document.addEventListener('fullscreenchange', () => {
281
286
  const on = Boolean(document.fullscreenElement);
282
287
  el.fullscreen.classList.toggle('on', on);
283
- el.fullscreen.title = on ? 'Leave full screen (f or Esc)' : 'Full screen (f)';
284
- el.fullscreen.setAttribute('aria-label', on ? 'Leave full screen' : 'Full screen');
288
+ el.fullscreen.title = on ? t('Leave full screen (f or Esc)') : t('Full screen (f)');
289
+ el.fullscreen.setAttribute('aria-label', on ? t('Leave full screen') : t('Full screen'));
285
290
  canvas.fitIfUntouched();
286
291
  });
287
292
 
288
293
  // `f` toggles it, unless something is being typed into.
289
294
  document.addEventListener('keydown', (e) => {
290
295
  if (e.key !== 'f' || e.metaKey || e.ctrlKey || e.altKey) return;
291
- const t = e.target;
292
- if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
296
+ const tg = e.target;
297
+ if (tg && (tg.tagName === 'INPUT' || tg.tagName === 'TEXTAREA' || tg.isContentEditable)) return;
293
298
  e.preventDefault();
294
299
  toggleFullscreen();
295
300
  });
@@ -406,24 +411,23 @@ async function openOwned(sessionId) {
406
411
  async function continueHere(button, from = current) {
407
412
  if (!from) return;
408
413
  button.disabled = true;
409
- button.textContent = 'Continuing…';
414
+ button.textContent = t('Continuing…');
410
415
  try {
411
416
  const r = await chat.start({ cwd: projectsData?.cwd ?? null, permissionMode: 'plan', resume: from });
412
417
  if (!r.ok) {
413
- el.foot.textContent = `could not continue: ${r.reason}`;
418
+ el.foot.textContent = t('could not continue: {reason}', { reason: r.reason });
414
419
  } else {
415
420
  ownedIds.add(r.session.sessionId);
416
421
  // Two genuinely different outcomes, so say which one happened rather
417
422
  // than printing a sentence that is true either way.
418
423
  el.foot.textContent = r.session.forked
419
- ? `${from.slice(0, 8)} is open in another process, so this is a copy — `
420
- + `messages here do not reach it.`
421
- : `continued ${from.slice(0, 8)} itself — same session, same transcript.`;
424
+ ? t('{id} is open in another process, so this is a copy — messages here do not reach it.', { id: from.slice(0, 8) })
425
+ : t('continued {id} itself — same session, same transcript.', { id: from.slice(0, 8) });
422
426
  pollOwned();
423
427
  }
424
428
  } finally {
425
429
  button.disabled = false;
426
- button.textContent = 'Continue here';
430
+ button.textContent = t('Continue here');
427
431
  }
428
432
  }
429
433
 
@@ -459,13 +463,13 @@ el.newSession.addEventListener('click', () => {
459
463
 
460
464
  /* The toolbar says what the canvas is doing. It is painted from the canvas's own
461
465
  state, so a choice restored from storage shows without being re-made. */
462
- const GROUP_WORD = { run: 'Workflow run', type: 'Agent type', parent: 'Parent', order: 'Order', none: 'None' };
463
- const DENSITY_WORD = { auto: 'Auto', comfortable: 'Comfortable', compact: 'Compact' };
466
+ const GROUP_WORD = { run: t('Workflow run'), type: t('Agent type'), parent: t('Parent'), order: t('Order'), none: t('None') };
467
+ const DENSITY_WORD = { auto: t('Auto'), comfortable: t('Comfortable'), compact: t('Compact') };
464
468
  const SHOW = {
465
- all: { word: 'All', statuses: null },
466
- attention: { word: 'Needs attention', statuses: ['failed', 'killed', 'stopped'], waiting: true },
467
- running: { word: 'Running', statuses: ['running', 'starting'] },
468
- failed: { word: 'Failed', statuses: ['failed', 'killed', 'stopped'] },
469
+ all: { word: t('All'), statuses: null },
470
+ attention: { word: t('Needs attention'), statuses: ['failed', 'killed', 'stopped'], waiting: true },
471
+ running: { word: t('Running'), statuses: ['running', 'starting'] },
472
+ failed: { word: t('Failed'), statuses: ['failed', 'killed', 'stopped'] },
469
473
  };
470
474
  let show = 'all';
471
475
  const tbValue = {
@@ -479,15 +483,15 @@ function paintToolbar(st) {
479
483
  // The grouping is one choice for both views: the Timeline's rows follow the graph's.
480
484
  timeline.setGroup(st.group);
481
485
  // When Auto has turned the cards into chips the button says so: the picture did not fit this pane as cards.
482
- tbValue.density.textContent = st.density === 'auto' && st.drawnAs === 'compact' ? 'Auto \u00b7 compact' : (DENSITY_WORD[st.density] ?? st.density);
486
+ tbValue.density.textContent = st.density === 'auto' && st.drawnAs === 'compact' ? t('Auto \u00b7 compact') : (DENSITY_WORD[st.density] ?? st.density);
483
487
  // Auto is a choice the canvas makes; say which one it made, and why.
484
488
  el.tbDensity.title = st.density === 'auto' && st.drawnAs
485
- ? `Auto: ${st.drawnAs}${st.stacked ? ', agents of one type folded together' : ''}. `
486
- + `Cards stay cards while the picture fits this pane at ${Math.round(AUTO.readable * 100)}% or larger; choose Comfortable to keep them anyway.`
489
+ ? `${st.stacked ? t('Auto: {drawn}, agents of one type folded together.', { drawn: (DENSITY_WORD[st.drawnAs] ?? st.drawnAs).toLocaleLowerCase(numLocale()) }) : t('Auto: {drawn}.', { drawn: (DENSITY_WORD[st.drawnAs] ?? st.drawnAs).toLocaleLowerCase(numLocale()) })} `
490
+ + t('Cards stay cards while the picture fits this pane at {pct}% or larger; choose Comfortable to keep them anyway.', { pct: Math.round(AUTO.readable * 100) })
487
491
  : '';
488
492
  const pct = `${Math.round(st.zoom * 100)}%`;
489
493
  el.tbZoom.textContent = pct;
490
- el.tbZoom.setAttribute('aria-label', `Zoom ${pct}. Back to 100%`);
494
+ el.tbZoom.setAttribute('aria-label', t('Zoom {pct}. Back to 100%', { pct }));
491
495
  // Enabled only while a card has been moved: with none there is nothing to reset.
492
496
  if (el.tbReset) el.tbReset.disabled = !(st?.moved > 0);
493
497
  paintFoldButtons();
@@ -497,8 +501,8 @@ function paintToolbar(st) {
497
501
  Each is enabled only while it has something left to do there, so the pair always says what is true. */
498
502
  const foldTarget = () => (view === 'timeline' ? timeline : view === 'list' ? list : null);
499
503
  function foldState() {
500
- const t = foldTarget();
501
- if (t) return t.foldState();
504
+ const ft = foldTarget();
505
+ if (ft) return ft.foldState();
502
506
  const st = canvas.state();
503
507
  return { groups: st.groups, canExpand: st.canExpand, canFold: st.canFold };
504
508
  }
@@ -537,11 +541,11 @@ const canvas = new Canvas(document.getElementById('canvas'), {
537
541
  const items = [];
538
542
  if (n.kind === 'agent') {
539
543
  items.push(st.focus
540
- ? { label: 'Show the whole graph', run: () => canvas.setFocus(null) }
541
- : { label: 'Focus on this branch', run: () => canvas.setFocus(n.id) });
542
- items.push({ label: 'Copy agent id', run: () => copyText(n.id, 'Copied the agent id') });
544
+ ? { label: t('Show the whole graph'), run: () => canvas.setFocus(null) }
545
+ : { label: t('Focus on this branch'), run: () => canvas.setFocus(n.id) });
546
+ items.push({ label: t('Copy agent id'), run: () => copyText(n.id, t('Copied the agent id')) });
543
547
  }
544
- if (at.pinned) items.push({ label: 'Reset layout', run: () => resetLayout() });
548
+ if (at.pinned) items.push({ label: t('Reset layout'), run: () => resetLayout() });
545
549
  if (!items.length) return;
546
550
  openMenu({ getBoundingClientRect: () => ({ left: at.x, bottom: at.y }) }, items);
547
551
  },
@@ -581,8 +585,8 @@ el.tbShow.addEventListener('click', (e) => {
581
585
  const items = Object.entries(SHOW).map(([key, w]) => ({ label: w.word, checked: show === key, run: () => { setShow(key); setShowModel(showModel); } }));
582
586
  const families = [...new Set((lastNodes ?? []).filter((n) => n.kind === 'agent').map((n) => modelFamily(n.model)).filter(Boolean))].sort();
583
587
  if (families.length) {
584
- items.push({ note: 'Model' }, { label: 'Every model', checked: showModel === null, run: () => setShowModel(null) },
585
- ...families.map((f) => ({ label: `Only ${f[0].toUpperCase()}${f.slice(1)}`, checked: showModel === f, run: () => setShowModel(f) })));
588
+ items.push({ note: t('Model') }, { label: t('Every model'), checked: showModel === null, run: () => setShowModel(null) },
589
+ ...families.map((f) => ({ label: t('Only {model}', { model: `${f[0].toUpperCase()}${f.slice(1)}` }), checked: showModel === f, run: () => setShowModel(f) })));
586
590
  }
587
591
  openMenu(el.tbShow, items);
588
592
  });
@@ -623,15 +627,15 @@ el.tbOptions.addEventListener('click', (e) => {
623
627
  const pick = (words, now, set) => Object.entries(words).map(([key, w]) => ({
624
628
  label: typeof w === 'string' ? w : w.word, checked: now === key, run: () => set(key),
625
629
  }));
626
- const items = view === 'list' ? [] : [{ note: 'Group' }, ...pick(GROUP_WORD, st.group, (g) => canvas.setGroup(g))];
627
- if (view === 'graph') items.push({ note: 'Density' }, ...pick(DENSITY_WORD, st.density, (d) => canvas.setDensity(d)));
628
- items.push({ note: 'Show' }, ...pick(SHOW, show, (key) => setShow(key)));
630
+ const items = view === 'list' ? [] : [{ note: t('Group') }, ...pick(GROUP_WORD, st.group, (g) => canvas.setGroup(g))];
631
+ if (view === 'graph') items.push({ note: t('Density') }, ...pick(DENSITY_WORD, st.density, (d) => canvas.setDensity(d)));
632
+ items.push({ note: t('Show') }, ...pick(SHOW, show, (key) => setShow(key)));
629
633
  if (view === 'graph' && st.groups > 0) {
630
- items.push({ note: 'Groups' }, { label: 'Expand all', run: () => canvas.expandAll() }, { label: 'Fold all', run: () => canvas.foldAll() });
634
+ items.push({ note: t('Groups') }, { label: t('Expand all'), run: () => canvas.expandAll() }, { label: t('Fold all'), run: () => canvas.foldAll() });
631
635
  }
632
636
  if (view === 'graph') {
633
637
  const open = canvas.spendOpen;
634
- items.push({ note: 'Session summary' }, { label: open ? 'Hide time, tokens and cost' : 'Show time, tokens and cost', run: () => canvas.showSpend(!open) });
638
+ items.push({ note: t('Session summary') }, { label: open ? t('Hide time, tokens and cost') : t('Show time, tokens and cost'), run: () => canvas.showSpend(!open) });
635
639
  }
636
640
  openMenu(el.tbOptions, items);
637
641
  });
@@ -649,14 +653,14 @@ function resetLayout() {
649
653
  const target = zoomed();
650
654
  const was = target.resetLayout();
651
655
  if (!was) return;
652
- toast('Layout reset', { label: 'Undo', run: () => { if (target.restoreLayout(was)) toast('Layout restored'); } });
656
+ toast(t('Layout reset'), { label: t('Undo'), run: () => { if (target.restoreLayout(was)) toast(t('Layout restored')); } });
653
657
  if (target === mapView) paintZoom(mapView.zoom, mapView.moved);
654
658
  }
655
659
  /** The zoom and Reset as the map has them: the graph's go through paintToolbar. */
656
660
  function paintZoom(k, moved) {
657
661
  const pct = `${Math.round(k * 100)}%`;
658
662
  el.tbZoom.textContent = pct;
659
- el.tbZoom.setAttribute('aria-label', `Zoom ${pct}. Back to 100%`);
663
+ el.tbZoom.setAttribute('aria-label', t('Zoom {pct}. Back to 100%', { pct }));
660
664
  if (el.tbReset) el.tbReset.disabled = !(moved > 0);
661
665
  }
662
666
  el.tbReset.addEventListener('click', resetLayout);
@@ -675,7 +679,7 @@ let modelsData = null;
675
679
  let modelsFor = null;
676
680
  let modelsAt = 0;
677
681
  const models = new Models(el.models, {
678
- 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}`)),
682
+ onCopy: (text) => navigator.clipboard.writeText(text).then(() => toast(t('Copied — send it in your own session to lower the hold')), () => toast(t('Not copied — select it: {text}', { text }))),
679
683
  });
680
684
  let modelsBusy = false;
681
685
  async function loadModels(force = false) {
@@ -727,8 +731,8 @@ let mapBusy = false;
727
731
  let mapCompare = null;
728
732
  const mapView = new MapView(el.map, {
729
733
  onCopy: (text, what) => navigator.clipboard.writeText(text).then(
730
- () => toast(what === 'path' ? `Copied ${text}` : 'Copied — paste it into a session as a task'),
731
- () => toast(`Not copied — select it: ${text}`)),
734
+ () => toast(what === 'path' ? t('Copied {text}', { text }) : t('Copied — paste it into a session as a task')),
735
+ () => toast(t('Not copied — select it: {text}', { text }))),
732
736
  onCompare: (cwd) => { mapCompare = cwd; loadMap(true); },
733
737
  onZoom: (k) => { if (view === 'map') paintZoom(k, mapView.moved); },
734
738
  });
@@ -765,18 +769,21 @@ list.now = () => serverNow();
765
769
  // Under 640px there is no room for the graph beside anything: the List is what a phone opens on.
766
770
  const phoneBand = window.matchMedia('(max-width: 639px)');
767
771
 
772
+ /** A range key ("15m", "1h", "all") as the toolbar says it, in the panel's language. */
773
+ const rangeWord = (r) => (r === 'all' ? t('all')
774
+ : String(r).replace(/^(\d+)([mh])$/, (_, n, u) => (u === 'm' ? t('{n}m', { n }) : t('{n}h', { n }))));
768
775
  function paintTimelineBar(st) {
769
- el.tbRange.textContent = st.range;
770
- el.tbRange.setAttribute('aria-label', `Time shown: ${st.range === 'all' ? 'the whole session' : st.range}`);
776
+ el.tbRange.textContent = rangeWord(st.range);
777
+ el.tbRange.setAttribute('aria-label', t('Time shown: {range}', { range: st.range === 'all' ? t('the whole session') : rangeWord(st.range) }));
771
778
  el.tbRangeIn.disabled = !st.canZoomIn;
772
779
  el.tbRangeOut.disabled = !st.canZoomOut;
773
780
  // The range is in the buttons' own names too: on a narrow toolbar the label between them is not shown.
774
- el.tbRangeIn.setAttribute('aria-label', `Show less time (now ${st.range})`);
775
- el.tbRangeOut.setAttribute('aria-label', `Show more time (now ${st.range})`);
781
+ el.tbRangeIn.setAttribute('aria-label', t('Show less time (now {range})', { range: rangeWord(st.range) }));
782
+ el.tbRangeOut.setAttribute('aria-label', t('Show more time (now {range})', { range: rangeWord(st.range) }));
776
783
  el.tbFollow.setAttribute('aria-checked', String(st.follow));
777
784
  // There is no "now" to follow in a session that has ended.
778
785
  el.tbFollow.disabled = !st.live;
779
- el.tbFollow.title = st.live ? 'Keep the view at now' : 'This session has ended: there is no now to follow';
786
+ el.tbFollow.title = st.live ? t('Keep the view at now') : t('This session has ended: there is no now to follow');
780
787
  }
781
788
 
782
789
  /**
@@ -864,7 +871,7 @@ getJson('/api/palette')
864
871
  // to be said out loud, or the panel is drawing "not measured" as a fact about the agents.
865
872
  if (p && p.measured === false) {
866
873
  document.body.dataset.paletteMeasured = 'false';
867
- el.foot.textContent = `agent identity Not measured — ${p.reason}`;
874
+ el.foot.textContent = t('Agent identity not measured — {reason}', { reason: p.reason });
868
875
  }
869
876
  })
870
877
  .catch(() => { /* every agent is drawn with the common mark, which is what is known */ });
@@ -915,14 +922,14 @@ function metaRows(n) {
915
922
  const rows = [];
916
923
  const row = (k, v) => { if (v != null && v !== '') rows.push([k, String(v)]); };
917
924
  if (n.kind === 'session') {
918
- row('session', n.sessionId); row('cwd', n.cwd); row('branch', n.gitBranch);
919
- row('model', n.model); row('turns', n.turns);
920
- row('context', n.tokens?.toLocaleString()); row('cli', n.version);
925
+ row(t('session'), n.sessionId); row('cwd', n.cwd); row(t('branch'), n.gitBranch);
926
+ row(t('model'), n.model); row(t('turns'), n.turns);
927
+ row(t('context'), n.tokens?.toLocaleString(numLocale())); row('cli', n.version);
921
928
  } else {
922
- row('type', n.agentType ?? 'unknown'); row('status', n.status);
923
- row('depth', n.spawnDepth); row('model', n.model);
924
- row('turns', n.turns); row('last tool', n.lastTool);
925
- row('agent id', n.id);
929
+ row(t('type'), n.agentType ?? t('unknown')); row(t('status'), n.status);
930
+ row(t('depth'), n.spawnDepth); row(t('model'), n.model);
931
+ row(t('turns'), n.turns); row(t('last tool'), n.lastTool);
932
+ row(t('agent id'), n.id);
926
933
  }
927
934
  const dl = document.createElement('dl');
928
935
  for (const [k, v] of rows) {
@@ -942,12 +949,12 @@ function isection(title, ...children) {
942
949
 
943
950
  /** The agent transcript, as far as it has been read: null when it is there to use. */
944
951
  function detailGap(n, detail) {
945
- if (!detail || detail.loading) return node('div', 'ihint', 'Reading the agent transcript…');
952
+ if (!detail || detail.loading) return node('div', 'ihint', t('Reading the agent transcript…'));
946
953
  if (detail.measured !== false) return null;
947
954
  const hint = node('div', 'ihint');
948
- hint.append(node('strong', null, 'Not measured'), node('div', null, detail.reason || 'no reason given'));
955
+ hint.append(node('strong', null, t('Not measured')), node('div', null, detail.reason || t('no reason given')));
949
956
  if (detail.transient) {
950
- const retry = node('button', 'btn sm', 'Retry');
957
+ const retry = node('button', 'btn sm', t('Retry'));
951
958
  retry.type = 'button';
952
959
  retry.addEventListener('click', () => {
953
960
  detailCache.delete(`${n.id}:${n.status ?? '?'}`);
@@ -974,18 +981,18 @@ function paintInspector() {
974
981
  const head = node('div', 'ihead');
975
982
  const close = node('button', 'btn icon sm ghost');
976
983
  close.type = 'button';
977
- close.setAttribute('aria-label', 'Close');
978
- close.title = 'Close (Esc)';
984
+ close.setAttribute('aria-label', t('Close'));
985
+ close.title = t('Close (Esc)');
979
986
  close.append(icon(ICON.close));
980
987
  close.addEventListener('click', () => { canvas.clearSelection(); showInspector(null); });
981
988
  // On a phone the inspector is a page of its own, and the way out of a page is back.
982
989
  const back = node('button', 'btn icon sm ghost ihead-back');
983
990
  back.type = 'button';
984
- back.setAttribute('aria-label', 'Back to the list');
991
+ back.setAttribute('aria-label', t('Back to the list'));
985
992
  back.append(icon(ICON.back));
986
993
  back.addEventListener('click', () => { canvas.clearSelection(); showInspector(null); });
987
994
  close.classList.add('ihead-close');
988
- const name = isSession ? 'Session' : n.kind === 'workflow' ? (n.workflowId ?? 'Workflow run') : shownType(n);
995
+ const name = isSession ? t('Session') : n.kind === 'workflow' ? (n.workflowId ?? t('Workflow run')) : shownType(n);
989
996
  const iname = node('h3', 'iname', name);
990
997
  if (n.kind === 'agent') iname.title = n.agentType ?? '';
991
998
  head.append(back, canvas.tileFor(n), iname, close);
@@ -993,14 +1000,14 @@ function paintInspector() {
993
1000
  const meta = node('div', 'imeta');
994
1001
  const pill = node('span', 'pill');
995
1002
  // A state nobody read is said, not left as a blank pill.
996
- pill.append(dot(st.tone), node('span', null, st.word ?? 'State not measured'));
1003
+ pill.append(dot(st.tone), node('span', null, st.word ?? t('State not measured')));
997
1004
  meta.append(pill, node('span', 'sub', timeLine(n, serverNow())));
998
1005
  // The role is the name; the type as it is declared is said here, where there is room for it.
999
1006
  if (n.kind === 'agent' && n.agentType) meta.append(node('code', 'ireal', n.agentType));
1000
1007
 
1001
1008
  const task = node('div', 'itask', isSession
1002
1009
  ? (sessionLabel(current) ?? n.sessionId ?? '')
1003
- : n.kind === 'workflow' ? `${n.members ?? 0} agents` : (n.description ?? ''));
1010
+ : n.kind === 'workflow' ? (n.members === 1 ? t('{n} agent', { n: 1 }) : t('{n} agents', { n: n.members ?? 0 })) : (n.description ?? ''));
1004
1011
 
1005
1012
  const tabs = node('div', 'itabs');
1006
1013
  tabs.setAttribute('role', 'tablist');
@@ -1022,18 +1029,18 @@ function paintInspector() {
1022
1029
  else paintAgentOverview(body, n, detail);
1023
1030
 
1024
1031
  const foot = node('div', 'ifoot');
1025
- const open = node('button', 'btn', 'Open conversation');
1032
+ const open = node('button', 'btn', t('Open conversation'));
1026
1033
  open.type = 'button';
1027
1034
  open.addEventListener('click', () => { if (current) openConversation(current); });
1028
1035
  const file = transcriptPathOf(n, detail);
1029
1036
  const copy = node('button', 'btn icon');
1030
1037
  copy.type = 'button';
1031
1038
  copy.append(icon(ICON.copy));
1032
- copy.setAttribute('aria-label', 'Copy the transcript path');
1039
+ copy.setAttribute('aria-label', t('Copy the transcript path'));
1033
1040
  // A path nobody handed over is not built from parts here: the button says it has none.
1034
1041
  copy.disabled = !file;
1035
- copy.title = file ? `Copy the transcript path\n${file}` : 'Transcript path not read yet';
1036
- copy.addEventListener('click', () => copyText(file, 'Copied'));
1042
+ copy.title = file ? `${t('Copy the transcript path')}\n${file}` : t('Transcript path not read yet');
1043
+ copy.addEventListener('click', () => copyText(file, t('Copied')));
1037
1044
  foot.append(open, copy);
1038
1045
 
1039
1046
  el.inspector.replaceChildren(inspectorSplit, head, meta, task, tabs, body, foot);
@@ -1041,12 +1048,12 @@ function paintInspector() {
1041
1048
 
1042
1049
  function paintAgentOverview(body, n, detail) {
1043
1050
  const grid = node('div', 'itiles');
1044
- for (const t of tiles(n, serverNow())) {
1051
+ for (const tl of tiles(n, serverNow())) {
1045
1052
  const tile = node('div', 'itile');
1046
- const value = node('strong', null, t.value);
1047
- if (!t.measured) { tile.classList.add('unmeasured'); tile.title = `Not measured — ${t.why}`; }
1048
- if (t.bad) value.classList.add('bad');
1049
- tile.append(node('span', 'itile-k', t.label), value);
1053
+ const value = node('strong', null, tl.value);
1054
+ if (!tl.measured) { tile.classList.add('unmeasured'); tile.title = t('Not measured — {reason}', { reason: tl.why }); }
1055
+ if (tl.bad) value.classList.add('bad');
1056
+ tile.append(node('span', 'itile-k', tl.label), value);
1050
1057
  grid.append(tile);
1051
1058
  }
1052
1059
  body.append(grid);
@@ -1062,14 +1069,14 @@ function paintAgentOverview(body, n, detail) {
1062
1069
  const dl = node('dl', 'icard');
1063
1070
  for (const l of lines) dl.append(node('dt', null, l.label), node('dd', null, l.text));
1064
1071
  // Why the gate called it critical, in its own words (`files ... is on a critical path: ...` or `change: ...`).
1065
- if (work.riskWhy) dl.append(node('dt', null, 'why critical'), node('dd', null, work.riskWhy));
1066
- const sec = isection('Task card', dl);
1072
+ if (work.riskWhy) dl.append(node('dt', null, t('why critical')), node('dd', null, work.riskWhy));
1073
+ const sec = isection(t('Task card'), dl);
1067
1074
  if (work.risk) { const tag = node('span', 'mv-tag', work.risk); tag.dataset.risk = work.risk; sec.children[0].append(tag); }
1068
1075
  body.append(sec);
1069
1076
  }
1070
1077
  // Not a crew agent: no card and no line, and that is said, so the empty space is not read as a record lost.
1071
1078
  const outside = !row && !lines.length ? outsideRouting(n.agentType) : null;
1072
- if (outside) body.append(isection('Model routing', node('p', 'istep-note', outside)));
1079
+ if (outside) body.append(isection(t('Model routing'), node('p', 'istep-note', outside)));
1073
1080
  if (row || lines.length) {
1074
1081
  const ol = node('ol', 'isteps');
1075
1082
  work.steps.forEach((st, i) => {
@@ -1080,7 +1087,7 @@ function paintAgentOverview(body, n, detail) {
1080
1087
  li.append(node('span', 'istep-n', String(i + 1)), says);
1081
1088
  ol.append(li);
1082
1089
  });
1083
- const sec = isection('Model and verify', ...(work.steps.length ? [ol] : []), ...(work.note ? [node('p', 'istep-note', work.note)] : []));
1090
+ const sec = isection(t('Model and verify'), ...(work.steps.length ? [ol] : []), ...(work.note ? [node('p', 'istep-note', work.note)] : []));
1084
1091
  body.append(sec);
1085
1092
  }
1086
1093
  }
@@ -1093,14 +1100,14 @@ function paintAgentOverview(body, n, detail) {
1093
1100
  const dd = node('dd', null, m.text);
1094
1101
  if (m.note) dd.title = m.note;
1095
1102
  if (m.goto) {
1096
- const go = node('button', 'ilink', 'show');
1103
+ const go = node('button', 'ilink', t('show'));
1097
1104
  go.type = 'button';
1098
1105
  go.addEventListener('click', () => goTo(m.goto));
1099
1106
  dd.append(' ', go);
1100
1107
  }
1101
1108
  dl.append(node('dt', null, m.label), dd);
1102
1109
  }
1103
- body.append(isection('Model', dl));
1110
+ body.append(isection(t('Model'), dl));
1104
1111
  }
1105
1112
 
1106
1113
  const gap = n.kind === 'agent' ? detailGap(n, detail) : null;
@@ -1119,30 +1126,30 @@ function paintAgentOverview(body, n, detail) {
1119
1126
  for (const s of skills) {
1120
1127
  const chip = node('button', 'ichip', s);
1121
1128
  chip.type = 'button';
1122
- chip.title = 'Copy the skill name';
1123
- chip.addEventListener('click', () => copyText(s, `Copied ${s}`));
1129
+ chip.title = t('Copy the skill name');
1130
+ chip.addEventListener('click', () => copyText(s, t('Copied {text}', { text: s })));
1124
1131
  chips.append(chip);
1125
1132
  }
1126
- body.append(isection('Skills applied',
1127
- gap ?? (skills.length ? chips : node('div', 'ihint', 'None — this agent invoked no skill.'))));
1133
+ body.append(isection(t('Skills applied'),
1134
+ gap ?? (skills.length ? chips : node('div', 'ihint', t('None — this agent invoked no skill.')))));
1128
1135
 
1129
1136
  const by = delegatedBy(n, [...canvas.nodes.values()]);
1130
1137
  if (by) {
1131
1138
  const link = node('button', 'ilink', by.text);
1132
1139
  link.type = 'button';
1133
1140
  link.addEventListener('click', () => canvas.focus(by.id));
1134
- body.append(isection('Delegated by', link));
1141
+ body.append(isection(t('Delegated by'), link));
1135
1142
  }
1136
1143
 
1137
- if (gap) { body.append(isection('Report', node('div', 'ihint', 'Reading the agent transcript…'))); return; }
1144
+ if (gap) { body.append(isection(t('Report'), node('div', 'ihint', t('Reading the agent transcript…')))); return; }
1138
1145
  const rep = reportOf(n, read);
1139
- if (!rep.present) { body.append(isection('Report', node('div', 'ihint', rep.text))); return; }
1146
+ if (!rep.present) { body.append(isection(t('Report'), node('div', 'ihint', rep.text))); return; }
1140
1147
  const md = node('div', 'md');
1141
1148
  md.innerHTML = renderMarkdown(rep.text);
1142
- const copy = node('button', 'btn sm ghost', 'Copy');
1149
+ const copy = node('button', 'btn sm ghost', t('Copy'));
1143
1150
  copy.type = 'button';
1144
- copy.addEventListener('click', () => copyText(rep.text, 'Copied the report'));
1145
- const sec = isection('Report', md);
1151
+ copy.addEventListener('click', () => copyText(rep.text, t('Copied the report')));
1152
+ const sec = isection(t('Report'), md);
1146
1153
  sec.querySelector('.isec-h').append(copy);
1147
1154
  body.append(sec);
1148
1155
  }
@@ -1152,13 +1159,13 @@ function paintSessionOverview(body, n) {
1152
1159
  // Said in the place the missing controls would have been: there is no approval dock for this session, and
1153
1160
  // no way to write to it, and this is why.
1154
1161
  const banner = node('div', 'ibanner');
1155
- banner.append(node('p', null, 'This session was not started here, so Studio can read it but not write to it.'));
1162
+ banner.append(node('p', null, t('This session was not started here, so Studio can read it but not write to it.')));
1156
1163
  const acts = node('div', 'ibanner-acts');
1157
- const cont = node('button', 'btn sm primary', 'Continue here');
1164
+ const cont = node('button', 'btn sm primary', t('Continue here'));
1158
1165
  cont.type = 'button';
1159
- cont.title = 'Start a new session holding a copy of this conversation. It does not write into the original.';
1166
+ cont.title = t('Start a new session holding a copy of this conversation. It does not write into the original.');
1160
1167
  cont.addEventListener('click', () => continueHere(cont));
1161
- const term = node('button', 'btn sm', 'Open in terminal');
1168
+ const term = node('button', 'btn sm', t('Open in terminal'));
1162
1169
  term.type = 'button';
1163
1170
  term.addEventListener('click', (e) => { e.stopPropagation(); offerTerminal(term, current); });
1164
1171
  acts.append(cont, term);
@@ -1178,29 +1185,29 @@ function paintSessionOverview(body, n) {
1178
1185
  if (c.age) row.append(node('span', 'sub', c.age));
1179
1186
  return row;
1180
1187
  });
1181
- const box = isection('In the background', ...rows);
1188
+ const box = isection(t('In the background'), ...rows);
1182
1189
  body.append(box);
1183
1190
  }
1184
1191
 
1185
1192
  const b = kitData?.board;
1186
1193
  let board;
1187
- if (!kitData) board = node('div', 'ihint', 'Reading Crewforth…');
1194
+ if (!kitData) board = node('div', 'ihint', t('Reading Crewforth…'));
1188
1195
  else if (!b?.measured) board = unmeasured(b?.reason);
1189
1196
  else if (!b.present) board = node('div', 'ihint', b.text);
1190
1197
  else board = node('pre', 'md-code', b.text);
1191
- body.append(isection('Board', board));
1198
+ body.append(isection(t('Board'), board));
1192
1199
  }
1193
1200
 
1194
1201
  /** The agent's own transcript, read-only: what it was asked, what it did, what it said. */
1195
1202
  function paintTranscript(body, n, detail) {
1196
- const full = node('button', 'btn sm', 'Open full conversation');
1203
+ const full = node('button', 'btn sm', t('Open full conversation'));
1197
1204
  full.type = 'button';
1198
1205
  full.addEventListener('click', () => { if (current) openConversation(current); });
1199
1206
 
1200
1207
  if (n.kind !== 'agent') {
1201
1208
  body.append(node('div', 'ihint', n.kind === 'session'
1202
- ? 'The session\'s conversation is in the conversation panel.'
1203
- : 'A workflow run has no transcript of its own; its agents do.'), full);
1209
+ ? t('The session\'s conversation is in the conversation panel.')
1210
+ : t('A workflow run has no transcript of its own; its agents do.')), full);
1204
1211
  return;
1205
1212
  }
1206
1213
  const gap = detailGap(n, detail);
@@ -1208,21 +1215,21 @@ function paintTranscript(body, n, detail) {
1208
1215
 
1209
1216
  const prompt = node('div', 'md');
1210
1217
  prompt.innerHTML = renderMarkdown(detail.prompt ?? '');
1211
- body.append(isection('Asked', detail.prompt ? prompt : node('div', 'ihint', 'No prompt recorded.')));
1218
+ body.append(isection(t('Asked'), detail.prompt ? prompt : node('div', 'ihint', t('No prompt recorded.'))));
1212
1219
 
1213
1220
  const list = node('div', 'itimeline');
1214
- if (!detail.timeline?.length) list.append(node('div', 'ihint', 'No tool calls recorded.'));
1221
+ if (!detail.timeline?.length) list.append(node('div', 'ihint', t('No tool calls recorded.')));
1215
1222
  for (const [i, step] of (detail.timeline ?? []).entries()) {
1216
1223
  const r = node('div', 'istep');
1217
1224
  r.append(node('span', 'istep-n', String(i + 1)), node('span', 'istep-tool', step.name), node('span', 'istep-label', step.label ?? ''));
1218
1225
  list.append(r);
1219
1226
  }
1220
- body.append(isection('Did', list));
1227
+ body.append(isection(t('Did'), list));
1221
1228
 
1222
1229
  const said = node('div', 'isaid');
1223
- for (const t of detail.narration ?? []) {
1230
+ for (const text of detail.narration ?? []) {
1224
1231
  const md = node('div', 'md md-narration');
1225
- md.innerHTML = renderMarkdown(t);
1232
+ md.innerHTML = renderMarkdown(text);
1226
1233
  said.append(md);
1227
1234
  }
1228
1235
  const rep = reportOf(n, detail);
@@ -1233,7 +1240,7 @@ function paintTranscript(body, n, detail) {
1233
1240
  } else {
1234
1241
  said.append(node('div', 'ihint', rep.text));
1235
1242
  }
1236
- body.append(isection('Said', said), full);
1243
+ body.append(isection(t('Said'), said), full);
1237
1244
  }
1238
1245
 
1239
1246
  /* ------------------------------------------------------------------ kit
@@ -1258,24 +1265,24 @@ async function loadKit(sessionId) {
1258
1265
 
1259
1266
  function unmeasured(reason) {
1260
1267
  const d = node('div', 'ihint');
1261
- d.append(node('strong', null, 'Not measured'));
1262
- d.append(node('div', null, reason || 'no reason given'));
1263
- d.append(node('div', 'why', 'Which is not the same as nothing having happened.'));
1268
+ d.append(node('strong', null, t('Not measured')));
1269
+ d.append(node('div', null, reason || t('no reason given')));
1270
+ d.append(node('div', 'why', t('Which is not the same as nothing having happened.')));
1264
1271
  return d;
1265
1272
  }
1266
1273
 
1267
1274
  function paintStats(body) {
1268
- if (!kitData) { body.append(node('div', 'ihint', 'Reading Crewforth…')); return; }
1275
+ if (!kitData) { body.append(node('div', 'ihint', t('Reading Crewforth…'))); return; }
1269
1276
  const s = kitData.stats;
1270
1277
  if (!s?.measured) {
1271
1278
  const reason = s?.reason ?? kitData.reason ?? '';
1272
1279
  const box = node('div', 'ihint');
1273
- box.append(node('strong', null, 'Not measured'), node('div', null, reason || 'no reason given'),
1274
- node('div', 'why', 'This is not a zero: there is nothing to read here.'));
1280
+ box.append(node('strong', null, t('Not measured')), node('div', null, reason || t('no reason given')),
1281
+ node('div', 'why', t('This is not a zero: there is nothing to read here.')));
1275
1282
  // The script that measures this comes with a full install. That is said only when its absence is the
1276
1283
  // reason, and no command is typed here: the page offers commands the server gave it, and it gave none.
1277
1284
  if (/session-stats\.sh is not present|not installed/.test(reason)) {
1278
- box.append(node('div', 'why', 'A full install of Crewforth in this project adds it.'));
1285
+ box.append(node('div', 'why', t('A full install of Crewforth in this project adds it.')));
1279
1286
  }
1280
1287
  body.append(box);
1281
1288
  return;
@@ -1283,12 +1290,12 @@ function paintStats(body) {
1283
1290
  const dl = document.createElement('dl');
1284
1291
  for (const [k, v] of Object.entries(s.metrics)) {
1285
1292
  const dt = node('dt', null, k.replace(/_/g, ' '));
1286
- const dd = node('dd', null, v.toLocaleString());
1293
+ const dd = node('dd', null, v.toLocaleString(numLocale()));
1287
1294
  if (v > 0 && /runaway|errors|interrupts/.test(k)) dd.classList.add('bad');
1288
1295
  dl.append(dt, dd);
1289
1296
  }
1290
1297
  body.append(dl);
1291
- body.append(node('div', 'ihint', 'session-stats.sh --raw, over this transcript.'));
1298
+ body.append(node('div', 'ihint', t('session-stats.sh --raw, over this transcript.')));
1292
1299
  }
1293
1300
 
1294
1301
  async function revokeAllowance(tool, button) {
@@ -1304,13 +1311,13 @@ async function revokeAllowance(tool, button) {
1304
1311
  res = { ok: false, reason: e.message };
1305
1312
  }
1306
1313
  if (!res.ok) {
1307
- toast(`Not revoked — ${res.reason ?? 'no reason given'}`);
1314
+ toast(t('Not revoked — {reason}', { reason: res.reason ?? t('no reason given') }));
1308
1315
  button.disabled = false;
1309
1316
  return;
1310
1317
  }
1311
1318
  const sn = owned.get(sessionId);
1312
1319
  if (sn) owned.set(sessionId, { ...sn, alwaysAllowed: res.always ?? [] });
1313
- toast(res.revoked ? `${tool} asks again from the next call` : `${tool} was already asking`);
1320
+ toast(res.revoked ? t('{tool} asks again from the next call', { tool }) : t('{tool} was already asking', { tool }));
1314
1321
  if (inspectorNode) paintInspector();
1315
1322
  }
1316
1323
 
@@ -1323,24 +1330,24 @@ function paintGates(body) {
1323
1330
  const list = node('div', 'iallow');
1324
1331
  for (const tool of allowed) {
1325
1332
  const row = node('div', 'iallow-row');
1326
- const revoke = node('button', 'btn sm', 'Revoke');
1333
+ const revoke = node('button', 'btn sm', t('Revoke'));
1327
1334
  revoke.type = 'button';
1328
- revoke.setAttribute('aria-label', `Revoke ${tool}`);
1335
+ revoke.setAttribute('aria-label', t('Revoke {name}', { name: tool }));
1329
1336
  revoke.addEventListener('click', () => revokeAllowance(tool, revoke));
1330
- row.append(node('span', 'iallow-tool', tool), node('span', 'sub', 'not asked about here'), node('span', 'row-fill'), revoke);
1337
+ row.append(node('span', 'iallow-tool', tool), node('span', 'sub', t('not asked about here')), node('span', 'row-fill'), revoke);
1331
1338
  list.append(row);
1332
1339
  }
1333
1340
  // What a session allowance is and is not: the dock stops asking, and nothing more. A call Claude Code
1334
1341
  // would itself have asked a person about is still refused, because nobody saw it.
1335
- if (allowed.length) list.append(node('div', 'ihint', 'Studio does not ask about these again. Claude Code\'s own checks still apply to each call.'));
1336
- body.append(isection('Allowed for this session',
1337
- allowed.length ? list : node('div', 'ihint', 'Nothing — every tool call asks first.')));
1342
+ if (allowed.length) list.append(node('div', 'ihint', t('Studio does not ask about these again. Claude Code\'s own checks still apply to each call.')));
1343
+ body.append(isection(t('Allowed for this session'),
1344
+ allowed.length ? list : node('div', 'ihint', t('Nothing — every tool call asks first.'))));
1338
1345
  } else if (mine) {
1339
- body.append(isection('Allowed for this session',
1340
- node('div', 'ihint', 'This session runs without the approval gate, so nothing asks and nothing was allowed here.')));
1346
+ body.append(isection(t('Allowed for this session'),
1347
+ node('div', 'ihint', t('This session runs without the approval gate, so nothing asks and nothing was allowed here.'))));
1341
1348
  } else {
1342
- body.append(isection('Allowed for this session',
1343
- node('div', 'ihint', 'This session was not started here, so its tool calls do not wait for an answer in Studio.')));
1349
+ body.append(isection(t('Allowed for this session'),
1350
+ node('div', 'ihint', t('This session was not started here, so its tool calls do not wait for an answer in Studio.'))));
1344
1351
  }
1345
1352
 
1346
1353
  // Happened: what was seen with a time on it. The answers given to requests come from this page; the hook
@@ -1351,7 +1358,7 @@ function paintGates(body) {
1351
1358
  const rows = [];
1352
1359
  for (const a of answers.slice(-12)) {
1353
1360
  const row = node('div', 'gate-row');
1354
- row.append(node('span', 'gate-when', new Date(a.at - clock.offset).toLocaleTimeString()));
1361
+ row.append(node('span', 'gate-when', new Date(a.at - clock.offset).toLocaleTimeString(numLocale())));
1355
1362
  row.append(node('span', 'gate-name', `${a.toolName}${a.agentType ? ` · ${roleName(a.agentType)}` : ''}`));
1356
1363
  const v = node('span', 'gate-verdict', OUTCOME_WORD[a.outcome]);
1357
1364
  v.dataset.verdict = a.outcome.startsWith('allowed') ? 'ALLOW' : a.outcome === 'answered-elsewhere' ? 'ASK' : 'BLOCK';
@@ -1360,7 +1367,7 @@ function paintGates(body) {
1360
1367
  }
1361
1368
  for (const e of live.slice(-12)) {
1362
1369
  const row = node('div', 'gate-row');
1363
- row.append(node('span', 'gate-when', new Date(e.at - clock.offset).toLocaleTimeString()));
1370
+ row.append(node('span', 'gate-when', new Date(e.at - clock.offset).toLocaleTimeString(numLocale())));
1364
1371
  row.append(node('span', 'gate-name', e.name ?? e.event ?? 'hook'));
1365
1372
  const v = node('span', 'gate-verdict', e.phase === 'started' ? 'ran' : (e.exitCode === 2 ? 'blocked' : e.outcome ?? 'done'));
1366
1373
  v.dataset.verdict = e.exitCode === 2 ? 'BLOCK' : 'ALLOW';
@@ -1369,18 +1376,18 @@ function paintGates(body) {
1369
1376
  }
1370
1377
  // One list, newest first: both kinds carry the server's time.
1371
1378
  rows.sort((x, y) => y.at - x.at);
1372
- body.append(isection('Happened', ...rows.map((r) => r.row)));
1379
+ body.append(isection(t('Happened'), ...rows.map((r) => r.row)));
1373
1380
  }
1374
1381
 
1375
- if (!kitData) { body.append(node('div', 'ihint', 'Reading Crewforth…')); return; }
1382
+ if (!kitData) { body.append(node('div', 'ihint', t('Reading Crewforth…'))); return; }
1376
1383
  const log = kitData.log;
1377
1384
  const rep = kitData.report;
1378
1385
 
1379
1386
  const observed = [];
1380
1387
  if (rep?.measured) {
1381
1388
  const head = node('div', 'kit-sum');
1382
- head.append(node('span', 'cv-bit', `${rep.rules} rules`));
1383
- head.append(node('span', 'cv-bit', `${(rep.decisions ?? 0).toLocaleString()} decisions`));
1389
+ head.append(node('span', 'cv-bit', t('{n} rules', { n: rep.rules })));
1390
+ head.append(node('span', 'cv-bit', t('{n} decisions', { n: (rep.decisions ?? 0).toLocaleString(numLocale()) })));
1384
1391
  observed.push(head);
1385
1392
  } else {
1386
1393
  observed.push(unmeasured(rep?.reason ?? kitData.reason));
@@ -1388,20 +1395,20 @@ function paintGates(body) {
1388
1395
 
1389
1396
  if (!log?.measured) {
1390
1397
  observed.push(unmeasured(log?.reason ?? kitData.reason));
1391
- body.append(isection('Observed', ...observed));
1398
+ body.append(isection(t('Observed'), ...observed));
1392
1399
  return;
1393
1400
  }
1394
1401
 
1395
1402
  // The distinction is the point: this file has no timestamp column, so these
1396
1403
  // are decisions found in the log, not decisions seen happening.
1397
1404
  observed.push(node('div', 'ihint',
1398
- `${log.total.toLocaleString()} in the tail of gate-log.tsv${log.truncated ? ' (truncated)' : ''} · `
1399
- + 'no timestamps in this format, so these are what the log holds, not when they ran'
1400
- + (log.commandsRecorded ? '' : ' · commands not recorded (CREW_GATE_LOG_CMD=1 records them)')));
1405
+ `${t('{n} in the tail of gate-log.tsv', { n: log.total.toLocaleString(numLocale()) })}${log.truncated ? ` ${t('(truncated)')}` : ''} · `
1406
+ + t('no timestamps in this format, so these are what the log holds, not when they ran')
1407
+ + (log.commandsRecorded ? '' : ` · ${t('commands not recorded (CREW_GATE_LOG_CMD=1 records them)')}`)));
1401
1408
 
1402
1409
  const counts = node('div', 'kit-sum');
1403
1410
  for (const [k, v] of Object.entries(log.counts ?? {})) {
1404
- const b = node('span', 'cv-bit', `${v.toLocaleString()} ${k}`);
1411
+ const b = node('span', 'cv-bit', `${v.toLocaleString(numLocale())} ${k}`);
1405
1412
  b.dataset.verdict = k;
1406
1413
  counts.append(b);
1407
1414
  }
@@ -1416,7 +1423,7 @@ function paintGates(body) {
1416
1423
  if (e.section) row.append(node('span', 'gate-sec', e.section));
1417
1424
  observed.push(row);
1418
1425
  }
1419
- body.append(isection('Observed', ...observed));
1426
+ body.append(isection(t('Observed'), ...observed));
1420
1427
  }
1421
1428
 
1422
1429
  /* ------------------------------------------------------------ navigator
@@ -1512,7 +1519,7 @@ async function copyText(text, said) {
1512
1519
  toast(said);
1513
1520
  } catch {
1514
1521
  // A page without clipboard access still has to hand the text over.
1515
- toast(`Could not copy — ${text}`);
1522
+ toast(t('Could not copy — {text}', { text }));
1516
1523
  }
1517
1524
  }
1518
1525
 
@@ -1598,15 +1605,15 @@ async function offerTerminal(anchor, sessionId) {
1598
1605
  try {
1599
1606
  plan = (await getJson(`/api/session/${encodeURIComponent(sessionId)}/terminal`)).plan;
1600
1607
  } catch (e) {
1601
- toast(`Could not read the terminal command — ${e.message}`);
1608
+ toast(t('Could not read the terminal command — {reason}', { reason: e.message }));
1602
1609
  return;
1603
1610
  }
1604
- if (!plan) { toast('Not measured — this session recorded no working directory to open a terminal in.'); return; }
1611
+ if (!plan) { toast(t('Not measured — this session recorded no working directory to open a terminal in.')); return; }
1605
1612
  openMenu(anchor, [
1606
- { note: `This will run in ${plan.via ?? 'a terminal'}:` },
1613
+ { note: t('This will run in {where}:', { where: plan.via ?? t('a terminal') }) },
1607
1614
  { note: plan.line },
1608
1615
  {
1609
- label: 'Open terminal',
1616
+ label: t('Open terminal'),
1610
1617
  primary: true,
1611
1618
  run: async () => {
1612
1619
  try {
@@ -1614,13 +1621,13 @@ async function offerTerminal(anchor, sessionId) {
1614
1621
  method: 'POST', headers: { ...writeHeaders, 'content-type': 'application/json' }, body: '{}',
1615
1622
  });
1616
1623
  const out = await r.json();
1617
- toast(out.ok ? `Opened in ${plan.via ?? 'a terminal'}` : `Could not open a terminal — ${out.reason ?? 'no reason given'}`);
1624
+ toast(out.ok ? t('Opened in {where}', { where: plan.via ?? t('a terminal') }) : t('Could not open a terminal — {reason}', { reason: out.reason ?? t('no reason given') }));
1618
1625
  } catch (e) {
1619
- toast(`Could not open a terminal — ${e.message}`);
1626
+ toast(t('Could not open a terminal — {reason}', { reason: e.message }));
1620
1627
  }
1621
1628
  },
1622
1629
  },
1623
- { label: 'Cancel', run: () => {} },
1630
+ { label: t('Cancel'), run: () => {} },
1624
1631
  ]);
1625
1632
  }
1626
1633
 
@@ -1652,8 +1659,8 @@ function renderSessions(data) {
1652
1659
  if (!data.measured) {
1653
1660
  renderNote(el.sessions, {
1654
1661
  kind: 'unmeasured',
1655
- title: 'No transcripts here',
1656
- body: 'Nothing was read.',
1662
+ title: t('No transcripts here'),
1663
+ body: t('Nothing was read.'),
1657
1664
  why: data.reason,
1658
1665
  });
1659
1666
  return;
@@ -1680,8 +1687,8 @@ function paintFirstRun() {
1680
1687
  // with no transcripts this panel knows of no project to start it in.
1681
1688
  if (first && !el.firstRun.firstChild) {
1682
1689
  el.firstRun.append(
1683
- node('strong', null, 'No Claude Code sessions yet'),
1684
- node('p', null, 'Studio reads the sessions Claude Code saves on this machine. Start one in a terminal in any project and it shows up here within a few seconds.'),
1690
+ node('strong', null, t('No Claude Code sessions yet')),
1691
+ node('p', null, t('Studio reads the sessions Claude Code saves on this machine. Start one in a terminal in any project and it shows up here within a few seconds.')),
1685
1692
  );
1686
1693
  }
1687
1694
  el.firstRun.hidden = !first;
@@ -1704,11 +1711,11 @@ function mapRow(p, latest) {
1704
1711
  row.tabIndex = 0;
1705
1712
  row.setAttribute('role', 'treeitem');
1706
1713
  row.dataset.map = p.key;
1707
- const words = node('span', 'map-word', 'Map');
1714
+ const words = node('span', 'map-word', t('Map'));
1708
1715
  row.append(icon(ICON.map), words, node('span', 'row-fill'));
1709
- if (p.map?.findings) { const n = node('span', 'map-count', String(p.map.findings)); n.title = `${p.map.findings} finding(s) on the map`; row.append(n); }
1710
- else if (!p.map) row.append(node('span', 'sub', 'no map'));
1711
- row.title = p.map ? 'Open the project map' : 'This project has no .crewforth/crew-map.json yet';
1716
+ if (p.map?.findings) { const n = node('span', 'map-count', String(p.map.findings)); n.title = t('{n} finding(s) on the map', { n: p.map.findings }); row.append(n); }
1717
+ else if (!p.map) row.append(node('span', 'sub', t('no map')));
1718
+ row.title = p.map ? t('Open the project map') : t('This project has no .crewforth/crew-map.json yet');
1712
1719
  const go = () => { selectSession(latest.sessionId); setView('map'); };
1713
1720
  row.addEventListener('click', go);
1714
1721
  row.addEventListener('keydown', (e) => { if (e.target === row && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); go(); } });
@@ -1728,13 +1735,13 @@ function sessionRow(project, sn) {
1728
1735
  const top = node('span', 'srow-top');
1729
1736
  const d = dot(status.tone);
1730
1737
  // The word travels with the colour; where there is no status to give, say why.
1731
- d.title = status.key === 'unmeasured' ? 'Status not measured — the session list could not be read' : (status.word ?? '');
1738
+ d.title = status.key === 'unmeasured' ? t('Status not measured — the session list could not be read') : (status.word ?? '');
1732
1739
  top.append(d);
1733
1740
 
1734
1741
  if (renaming === sn.sessionId) {
1735
1742
  const input = node('input', 'srow-rename');
1736
1743
  input.value = name;
1737
- input.setAttribute('aria-label', `Rename ${name}`);
1744
+ input.setAttribute('aria-label', t('Rename {name}', { name }));
1738
1745
  const done = (save) => {
1739
1746
  if (renaming !== sn.sessionId) return;
1740
1747
  renaming = null;
@@ -1759,18 +1766,18 @@ function sessionRow(project, sn) {
1759
1766
 
1760
1767
  const more = node('button', 'row-more');
1761
1768
  more.type = 'button';
1762
- more.setAttribute('aria-label', `More for ${name}`);
1769
+ more.setAttribute('aria-label', t('More for {name}', { name }));
1763
1770
  more.setAttribute('aria-haspopup', 'menu');
1764
1771
  more.append(icon(ICON.more));
1765
1772
  more.addEventListener('click', (e) => {
1766
1773
  e.stopPropagation();
1767
1774
  openMenu(more, [
1768
- { label: 'Rename', run: () => { renaming = sn.sessionId; paintProjects(); } },
1769
- { label: 'Copy session id', run: () => copyText(sn.sessionId, 'Copied the session id') },
1770
- { label: 'Open in terminal', run: () => offerTerminal(more, sn.sessionId) },
1775
+ { label: t('Rename'), run: () => { renaming = sn.sessionId; paintProjects(); } },
1776
+ { label: t('Copy session id'), run: () => copyText(sn.sessionId, t('Copied the session id')) },
1777
+ { label: t('Open in terminal'), run: () => offerTerminal(more, sn.sessionId) },
1771
1778
  hiddenIds.has(sn.sessionId)
1772
- ? { label: 'Show in list', run: () => { setHidden(sn.sessionId, false); paintProjects(); } }
1773
- : { label: 'Hide from list', run: () => { setHidden(sn.sessionId, true); paintProjects(); } },
1779
+ ? { label: t('Show in list'), run: () => { setHidden(sn.sessionId, false); paintProjects(); } }
1780
+ : { label: t('Hide from list'), run: () => { setHidden(sn.sessionId, true); paintProjects(); } },
1774
1781
  ]);
1775
1782
  });
1776
1783
  top.append(more);
@@ -1883,7 +1890,7 @@ function paintProjects() {
1883
1890
  if (p.local !== false && p.exists && sessions.length) group.append(mapRow(p, sessions[0]));
1884
1891
  for (const sn of sessions) group.append(sessionRow(p, sn));
1885
1892
  if (p.total > p.sessions.length) {
1886
- group.append(node('div', 'nav-line', `${p.total - p.sessions.length} older session(s) not listed`));
1893
+ group.append(node('div', 'nav-line', t('{n} older session(s) not listed', { n: p.total - p.sessions.length })));
1887
1894
  }
1888
1895
  }
1889
1896
  frag.append(group);
@@ -1891,13 +1898,13 @@ function paintProjects() {
1891
1898
 
1892
1899
  if (!shown) {
1893
1900
  renderNote(el.sessions, filterText
1894
- ? { title: 'No match', body: `Nothing here is called “${filterText}”.` }
1895
- : { title: 'No projects', body: 'Measured — no transcript was found on this machine.' });
1901
+ ? { title: t('No match'), body: t('Nothing here is called “{query}”.', { query: filterText }) }
1902
+ : { title: t('No projects'), body: t('Measured — no transcript was found on this machine.') });
1896
1903
  return;
1897
1904
  }
1898
1905
 
1899
1906
  for (const o of (data.origins ?? []).filter((x) => x.ok === false)) {
1900
- frag.append(node('div', 'nav-line bad', `${o.name} unreachable — ${o.reason}`));
1907
+ frag.append(node('div', 'nav-line bad', t('{name} unreachable — {reason}', { name: o.name, reason: o.reason })));
1901
1908
  }
1902
1909
  const lineWith = (text, label, run) => {
1903
1910
  const line = node('div', 'nav-line');
@@ -1910,18 +1917,18 @@ function paintProjects() {
1910
1917
  };
1911
1918
  if (hiddenIds.size && !filterText) {
1912
1919
  frag.append(lineWith(
1913
- showHidden ? `${hiddenIds.size} hidden session(s) shown` : `${hiddenCount} session(s) hidden from this list`,
1914
- showHidden ? 'hide' : 'show',
1920
+ showHidden ? t('{n} hidden session(s) shown', { n: hiddenIds.size }) : t('{n} session(s) hidden from this list', { n: hiddenCount }),
1921
+ showHidden ? t('hide') : t('show'),
1915
1922
  () => { showHidden = !showHidden; paintProjects(); },
1916
1923
  ));
1917
1924
  }
1918
1925
  if (missing > 0 && !filterText) {
1919
1926
  frag.append(lineWith(
1920
- `${missing} project(s) hidden — their directories no longer exist`, 'show',
1927
+ t('{n} project(s) hidden — their directories no longer exist', { n: missing }), t('show'),
1921
1928
  () => { showMissing = true; paintProjects(); },
1922
1929
  ));
1923
1930
  } else if (showMissing && !filterText) {
1924
- frag.append(lineWith('Showing projects whose directories no longer exist', 'hide',
1931
+ frag.append(lineWith(t('Showing projects whose directories no longer exist'), t('hide'),
1925
1932
  () => { showMissing = false; paintProjects(); }));
1926
1933
  }
1927
1934
  el.sessions.replaceChildren(frag);
@@ -1953,12 +1960,12 @@ function paintLive() {
1953
1960
  if (!data.measured) {
1954
1961
  el.liveCount.hidden = false;
1955
1962
  el.liveCount.textContent = '?';
1956
- el.liveCount.title = 'Not measured';
1963
+ el.liveCount.title = t('Not measured');
1957
1964
  renderNote(el.fleet, {
1958
1965
  kind: 'unmeasured',
1959
- title: 'Not measured',
1960
- body: 'This is not the same as "nothing is running" — nothing was read.',
1961
- why: data.reason || 'no reason reported',
1966
+ title: t('Not measured'),
1967
+ body: t('This is not the same as "nothing is running" — nothing was read.'),
1968
+ why: data.reason || t('no reason reported'),
1962
1969
  });
1963
1970
  return;
1964
1971
  }
@@ -1966,7 +1973,7 @@ function paintLive() {
1966
1973
  const live = liveSessions(data);
1967
1974
  el.liveCount.hidden = live.length === 0;
1968
1975
  el.liveCount.textContent = String(live.length);
1969
- el.liveCount.title = `${live.length} working or waiting`;
1976
+ el.liveCount.title = t('{n} working or waiting', { n: live.length });
1970
1977
 
1971
1978
  const rows = live.filter((s) => !filterText
1972
1979
  || [s.name, s.cwd, s.sessionId, s.origin].some((v) => typeof v === 'string' && v.toLowerCase().includes(filterText)));
@@ -1974,15 +1981,15 @@ function paintLive() {
1974
1981
  if (!live.length) {
1975
1982
  const open = (data.sessions ?? []).length;
1976
1983
  renderNote(el.fleet, {
1977
- title: 'No sessions running',
1984
+ title: t('No sessions running'),
1978
1985
  body: open
1979
- ? `Measured — ${open} open on this machine, none of them working or waiting.`
1980
- : 'Measured — the machine has none open.',
1986
+ ? t('Measured — {n} open on this machine, none of them working or waiting.', { n: open })
1987
+ : t('Measured — the machine has none open.'),
1981
1988
  });
1982
1989
  return;
1983
1990
  }
1984
1991
  if (!rows.length) {
1985
- renderNote(el.fleet, { title: 'No match', body: `No live session is called “${filterText}”.` });
1992
+ renderNote(el.fleet, { title: t('No match'), body: t('No live session is called “{query}”.', { query: filterText }) });
1986
1993
  return;
1987
1994
  }
1988
1995
 
@@ -1997,21 +2004,21 @@ function paintLive() {
1997
2004
  top.append(node('span', 'row-fill'), node('span', 'sub', ago(s.startedAt, Date.now())));
1998
2005
  row.append(top);
1999
2006
  const where = known?.project.label ?? shortPath(s.cwd);
2000
- const said = status.key === 'waiting' && s.waitingFor ? `Needs you · ${s.waitingFor}` : (status.word ?? s.status);
2007
+ const said = status.key === 'waiting' && s.waitingFor ? `${t('Needs you')} · ${s.waitingFor}` : (status.word ?? s.status);
2001
2008
  row.append(marked([where, said].filter(Boolean).join(' · '), 'sub srow-sub'));
2002
2009
 
2003
2010
  if (s.local !== false && s.sessionId) {
2004
2011
  row.tabIndex = 0;
2005
2012
  row.dataset.id = s.sessionId;
2006
2013
  row.setAttribute('aria-current', String(s.sessionId === current));
2007
- row.title = `Open ${name}`;
2014
+ row.title = t('Open {name}', { name });
2008
2015
  const open = () => selectSession(s.sessionId);
2009
2016
  row.addEventListener('click', open);
2010
2017
  row.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } });
2011
2018
  } else {
2012
2019
  // A session on another machine has no transcript here to open.
2013
2020
  row.classList.add('remote');
2014
- row.title = `${name} — on ${s.origin}. Its transcript is on that machine.`;
2021
+ row.title = t('{name} — on {origin}. Its transcript is on that machine.', { name, origin: s.origin });
2015
2022
  }
2016
2023
  return row;
2017
2024
  }));
@@ -2023,16 +2030,16 @@ function machineRow(m) {
2023
2030
  top.append(icon(ICON.machine), node('span', 'mname', m.name), node('span', 'row-fill'));
2024
2031
  if (m.kind === 'snapshot') {
2025
2032
  // Recorded by a session, not asked just now: the label says which.
2026
- top.append(node('span', 'badge', 'snapshot'));
2033
+ top.append(node('span', 'badge', t('snapshot')));
2027
2034
  row.append(top);
2028
2035
  const seen = seenAgo(m.seenAt, Date.now());
2029
2036
  row.append(node('div', 'sub srow-sub', [m.status, m.note, seen].filter(Boolean).join(' · ')));
2030
- row.title = `${m.name} — on another machine. Its transcript lives there, so the panel can list it but not draw it.`;
2037
+ row.title = t('{name} — on another machine. Its transcript lives there, so the panel can list it but not draw it.', { name: m.name });
2031
2038
  } else {
2032
- top.append(node('span', 'badge', 'peer'));
2039
+ top.append(node('span', 'badge', t('peer')));
2033
2040
  row.append(top);
2034
2041
  const sub = node('div', `sub srow-sub${m.ok ? '' : ' bad'}`,
2035
- m.ok ? `${m.sessions ?? 0} session(s) · asked just now` : `unreachable — ${m.reason ?? 'no reason given'}`);
2042
+ m.ok ? t('{n} session(s) · asked just now', { n: m.sessions ?? 0 }) : t('unreachable — {reason}', { reason: m.reason ?? t('no reason given') }));
2036
2043
  row.append(sub);
2037
2044
  }
2038
2045
  return row;
@@ -2046,7 +2053,7 @@ function paintMachines() {
2046
2053
  if (self) {
2047
2054
  const row = node('div', 'srow remote');
2048
2055
  const top = node('span', 'srow-top');
2049
- top.append(icon(ICON.machine), node('span', 'mname', self.name), node('span', 'row-fill'), node('span', 'badge', 'this machine'));
2056
+ top.append(icon(ICON.machine), node('span', 'mname', self.name), node('span', 'row-fill'), node('span', 'badge', t('this machine')));
2050
2057
  row.append(top);
2051
2058
  frag.append(row);
2052
2059
  }
@@ -2058,10 +2065,10 @@ function paintMachines() {
2058
2065
  const note = node('div', 'nav-note');
2059
2066
  renderNote(note, {
2060
2067
  kind: m.rosterMeasured ? '' : 'unmeasured',
2061
- title: m.rosterMeasured ? 'None reachable' : 'Not measured',
2068
+ title: m.rosterMeasured ? t('None reachable') : t('Not measured'),
2062
2069
  body: m.rosterMeasured
2063
- ? 'A connected session looked and found no machines besides this one.'
2064
- : 'No session here has recorded a list of other machines yet.',
2070
+ ? t('A connected session looked and found no machines besides this one.')
2071
+ : t('No session here has recorded a list of other machines yet.'),
2065
2072
  why: m.rosterMeasured ? null : m.rosterReason,
2066
2073
  });
2067
2074
  frag.append(note);
@@ -2104,7 +2111,7 @@ async function remoteWrite(method, path, body) {
2104
2111
  }
2105
2112
 
2106
2113
  /** A time today or tomorrow as the clock on the wall says it, e.g. 23:40. */
2107
- const clockTime = (ms) => (Number.isFinite(ms) ? new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '—');
2114
+ const clockTime = (ms) => (Number.isFinite(ms) ? new Date(ms).toLocaleTimeString(numLocale(), { hour: '2-digit', minute: '2-digit' }) : '—');
2108
2115
 
2109
2116
  let showTranscripts = false; // the owner's choice for the next viewer: transcripts stay hidden unless ticked
2110
2117
 
@@ -2112,7 +2119,7 @@ async function addViewer(button) {
2112
2119
  button.disabled = true;
2113
2120
  const res = await remoteWrite('POST', '/api/remote', { transcripts: showTranscripts });
2114
2121
  if (!res.ok) {
2115
- toast(`No viewer added — ${res.reason ?? 'no reason given'}`);
2122
+ toast(t('No viewer added — {reason}', { reason: res.reason ?? t('no reason given') }));
2116
2123
  button.disabled = false;
2117
2124
  return;
2118
2125
  }
@@ -2129,45 +2136,45 @@ async function revokeViewer(v, button) {
2129
2136
  button.disabled = true;
2130
2137
  const res = await remoteWrite('DELETE', `/api/remote/${encodeURIComponent(v.id)}`);
2131
2138
  if (!res.ok) {
2132
- toast(`Not revoked — ${res.reason ?? 'no reason given'}`);
2139
+ toast(t('Not revoked — {reason}', { reason: res.reason ?? t('no reason given') }));
2133
2140
  button.disabled = false;
2134
2141
  return;
2135
2142
  }
2136
2143
  remote = { ...(remote ?? {}), viewers: res.viewers ?? [] };
2137
2144
  if (handed?.id === v.id) handed = null;
2138
- toast(res.revoked ? `${v.label} can no longer read this panel` : `${v.label} was already revoked`);
2145
+ toast(res.revoked ? t('{name} can no longer read this panel', { name: v.label }) : t('{name} was already revoked', { name: v.label }));
2139
2146
  paintMachines();
2140
2147
  }
2141
2148
 
2142
2149
  function viewerRow(v) {
2143
2150
  const row = node('div', 'srow remote');
2144
2151
  const top = node('span', 'srow-top');
2145
- const revoke = node('button', 'btn sm ghost', 'Revoke');
2152
+ const revoke = node('button', 'btn sm ghost', t('Revoke'));
2146
2153
  revoke.type = 'button';
2147
- revoke.setAttribute('aria-label', `Revoke ${v.label}`);
2154
+ revoke.setAttribute('aria-label', t('Revoke {name}', { name: v.label }));
2148
2155
  revoke.addEventListener('click', () => revokeViewer(v, revoke));
2149
2156
  top.append(icon(ICON.machine), node('span', 'mname', v.label), node('span', 'row-fill'), revoke);
2150
2157
  row.append(top);
2151
2158
  const seen = v.lastSeenAt
2152
- ? `${seenAgo(v.lastSeenAt, Date.now())} · ${v.requests} request${v.requests === 1 ? '' : 's'}${v.open ? ' · watching now' : ''}`
2153
- : 'link not opened yet';
2159
+ ? `${seenAgo(v.lastSeenAt, Date.now())} · ${v.requests === 1 ? t('{n} request', { n: 1 }) : t('{n} requests', { n: v.requests })}${v.open ? ` · ${t('watching now')}` : ''}`
2160
+ : t('link not opened yet');
2154
2161
  row.append(node('div', 'sub srow-sub', seen));
2155
- row.append(node('div', 'sub srow-sub', `until ${clockTime(v.expiresAt)} · transcripts ${v.transcripts ? 'shown' : 'hidden'}${v.addresses?.length ? ` · from ${v.addresses.join(', ')}` : ''}`));
2162
+ row.append(node('div', 'sub srow-sub', `${t('until {time}', { time: clockTime(v.expiresAt) })} · ${v.transcripts ? t('transcripts shown') : t('transcripts hidden')}${v.addresses?.length ? ` · ${t('from {where}', { where: v.addresses.join(', ') })}` : ''}`));
2156
2163
  return row;
2157
2164
  }
2158
2165
 
2159
2166
  function remoteSection() {
2160
2167
  const box = node('div', 'nav-foot rv');
2161
2168
  const head = node('div', 'rv-head');
2162
- const add = node('button', 'btn sm', 'Add viewer');
2169
+ const add = node('button', 'btn sm', t('Add viewer'));
2163
2170
  add.type = 'button';
2164
2171
  const list = remote?.viewers ?? [];
2165
2172
  if (remote?.max != null && list.length >= remote.max) {
2166
2173
  add.disabled = true;
2167
- add.title = `${remote.max} viewers already; revoke one first`;
2174
+ add.title = t('{n} viewers already; revoke one first', { n: remote.max });
2168
2175
  }
2169
2176
  add.addEventListener('click', () => addViewer(add));
2170
- head.append(node('span', 'rv-title', 'Remote viewers'), node('span', 'row-fill'), add);
2177
+ head.append(node('span', 'rv-title', t('Remote viewers')), node('span', 'row-fill'), add);
2171
2178
  box.append(head);
2172
2179
 
2173
2180
  // Transcripts stay hidden on a new link unless this is ticked before Add viewer.
@@ -2175,24 +2182,26 @@ function remoteSection() {
2175
2182
  const tick = document.createElement('input');
2176
2183
  tick.type = 'checkbox'; tick.checked = showTranscripts;
2177
2184
  tick.addEventListener('change', () => { showTranscripts = tick.checked; });
2178
- opt.append(tick, document.createTextNode(' Show transcripts on the next link'));
2185
+ opt.append(tick, document.createTextNode(` ${t('Show transcripts on the next link')}`));
2179
2186
  box.append(opt);
2180
2187
 
2181
2188
  if (handed) {
2182
2189
  const give = node('div', 'rv-give');
2183
2190
  give.append(node('div', 'sub rv-say', handed.wifi
2184
- ? `Link for ${handed.label}, shown once. Scan it with a phone on the same Wi-Fi. It lasts until ${clockTime(handed.expiresAt)}; transcripts ${handed.transcripts ? 'shown' : 'hidden'}.`
2185
- : `Link for ${handed.label}, shown once. No Wi-Fi address was found${handed.lanErrors.length ? ` (${handed.lanErrors.join('; ')})` : ''}, so it opens on this machine only.`));
2191
+ ? t('Link for {name}, shown once. Scan it with a phone on the same Wi-Fi. It lasts until {time}; {transcripts}.', { name: handed.label, time: clockTime(handed.expiresAt), transcripts: handed.transcripts ? t('transcripts shown') : t('transcripts hidden') })
2192
+ : handed.lanErrors.length
2193
+ ? t('Link for {name}, shown once. No Wi-Fi address was found ({errors}), so it opens on this machine only.', { name: handed.label, errors: handed.lanErrors.join('; ') })
2194
+ : t('Link for {name}, shown once. No Wi-Fi address was found, so it opens on this machine only.', { name: handed.label })));
2186
2195
  if (handed.wifi) {
2187
2196
  const code = node('div', 'rv-qr');
2188
2197
  code.innerHTML = qrSvg(handed.wifi, { px: 3 });
2189
2198
  give.append(code);
2190
2199
  }
2191
2200
  const url = node('code', 'rv-url', handed.url);
2192
- const copy = node('button', 'btn sm', 'Copy link');
2201
+ const copy = node('button', 'btn sm', t('Copy link'));
2193
2202
  copy.type = 'button';
2194
- copy.addEventListener('click', () => copyText(handed.url, 'Link copied'));
2195
- const done = node('button', 'btn sm ghost', 'Done');
2203
+ copy.addEventListener('click', () => copyText(handed.url, t('Link copied')));
2204
+ const done = node('button', 'btn sm ghost', t('Done'));
2196
2205
  done.type = 'button';
2197
2206
  done.addEventListener('click', () => { handed = null; paintMachines(); });
2198
2207
  const acts = node('div', 'rv-acts');
@@ -2204,7 +2213,7 @@ function remoteSection() {
2204
2213
  for (const v of list) box.append(viewerRow(v));
2205
2214
  if (!list.length) {
2206
2215
  box.append(node('div', 'sub rv-say',
2207
- 'A viewer watches this panel from a phone on the same Wi-Fi and changes nothing. Its link lasts eight hours. With no viewer, the server listens on 127.0.0.1 only.'));
2216
+ t('A viewer watches this panel from a phone on the same Wi-Fi and changes nothing. Its link lasts eight hours. With no viewer, the server listens on 127.0.0.1 only.')));
2208
2217
  }
2209
2218
  return box;
2210
2219
  }
@@ -2215,11 +2224,11 @@ function paintOtherMachines() {
2215
2224
  const others = [...m.peers, ...m.remote];
2216
2225
  el.otherMachines.hidden = navTab !== 'projects' || others.length === 0;
2217
2226
  if (el.otherMachines.hidden) return;
2218
- const label = node('button', 'nav-foot-label', 'Other machines');
2227
+ const label = node('button', 'nav-foot-label', t('Other machines'));
2219
2228
  label.type = 'button';
2220
2229
  label.addEventListener('click', () => setTab('machines'));
2221
2230
  const rows = others.slice(0, 2).map(machineRow);
2222
- const more = others.length > 2 ? [node('div', 'nav-line', `${others.length - 2} more in Machines`)] : [];
2231
+ const more = others.length > 2 ? [node('div', 'nav-line', t('{n} more in Machines', { n: others.length - 2 }))] : [];
2223
2232
  el.otherMachines.replaceChildren(label, ...rows, ...more);
2224
2233
  }
2225
2234
 
@@ -2236,13 +2245,13 @@ function paintCrumb() {
2236
2245
  if (!found) { el.crumb.replaceChildren(); return; }
2237
2246
  const proj = node('button', 'crumb-project', found.project.label);
2238
2247
  proj.type = 'button';
2239
- proj.title = `Show ${found.project.label} in the navigator`;
2248
+ proj.title = t('Show {name} in the navigator', { name: found.project.label });
2240
2249
  proj.addEventListener('click', () => showProject(found.project.key));
2241
2250
  const sep = icon(ICON.chevron);
2242
2251
  sep.classList.add('crumb-sep');
2243
2252
  const name = node('span', 'crumb-session', sessionName(found.session, labels.all()));
2244
2253
  // The branch is on the session's row and on its card; here it is one hover away.
2245
- name.title = found.session.branch ? `on ${found.session.branch}` : '';
2254
+ name.title = found.session.branch ? t('on {branch}', { branch: found.session.branch }) : '';
2246
2255
  el.crumb.replaceChildren(proj, sep, name);
2247
2256
  fitBar();
2248
2257
  }
@@ -2268,7 +2277,7 @@ function paintSummary(stats, extra = []) {
2268
2277
  const b = node('button', 'pill chip');
2269
2278
  b.type = 'button';
2270
2279
  b.setAttribute('aria-pressed', String(statusFilter === c.status));
2271
- b.title = statusFilter === c.status ? 'Show every agent' : `Show only ${c.text.replace(/^\d+ /, '')}`;
2280
+ b.title = statusFilter === c.status ? t('Show every agent') : t('Show only {what}', { what: c.text.replace(/^\d+ /, '') });
2272
2281
  b.append(dot(c.tone), document.createTextNode(c.text));
2273
2282
  b.addEventListener('click', () => toggle(c));
2274
2283
  return b;
@@ -2282,7 +2291,7 @@ function paintSummary(stats, extra = []) {
2282
2291
  const words = [...chips.map((c) => c.text), ...extra].join(', ');
2283
2292
  const b = node('button', 'pill chip chip-compact');
2284
2293
  b.type = 'button';
2285
- b.setAttribute('aria-label', `Session summary: ${words}. Open details`);
2294
+ b.setAttribute('aria-label', t('Session summary: {words}. Open details', { words }));
2286
2295
  b.setAttribute('aria-haspopup', 'menu');
2287
2296
  b.setAttribute('aria-pressed', String(Boolean(statusFilter)));
2288
2297
  b.title = words;
@@ -2312,10 +2321,10 @@ window.addEventListener('resize', fitBar);
2312
2321
  function setStatusFilter(status, repaint = true) {
2313
2322
  statusFilter = status;
2314
2323
  show = 'all';
2315
- tbValue.show.textContent = status ? 'One status' : SHOW.all.word;
2324
+ tbValue.show.textContent = status ? t('One status') : SHOW.all.word;
2316
2325
  canvas.setFilter(status ? [status] : null);
2317
2326
  timeline.setFilter(status ? [status] : null);
2318
- if (repaint) paintSummary(lastStats, lastStats?.malformed ? [`${lastStats.malformed} malformed`] : []);
2327
+ if (repaint) paintSummary(lastStats, lastStats?.malformed ? [t('{n} malformed', { n: lastStats.malformed })] : []);
2319
2328
  }
2320
2329
 
2321
2330
  /** The Show menu: the same filter the chips are, by what the viewer is looking for rather than by one status. */
@@ -2325,7 +2334,7 @@ function setShow(key) {
2325
2334
  tbValue.show.textContent = SHOW[show].word;
2326
2335
  canvas.setFilter(SHOW[show].statuses, { keepWaiting: Boolean(SHOW[show].waiting) });
2327
2336
  timeline.setFilter(SHOW[show].statuses, { keepWaiting: Boolean(SHOW[show].waiting) });
2328
- paintSummary(lastStats, lastStats?.malformed ? [`${lastStats.malformed} malformed`] : []);
2337
+ paintSummary(lastStats, lastStats?.malformed ? [t('{n} malformed', { n: lastStats.malformed })] : []);
2329
2338
  }
2330
2339
 
2331
2340
  /* ------------------------------------------------------ needs attention */
@@ -2352,14 +2361,14 @@ function paintAttention(nodes) {
2352
2361
  return b;
2353
2362
  });
2354
2363
  if (list.length > MAX) {
2355
- const more = node('button', 'att', `+${list.length - MAX} more`);
2364
+ const more = node('button', 'att', t('+{n} more', { n: list.length - MAX }));
2356
2365
  more.type = 'button';
2357
2366
  more.addEventListener('click', () => setShow('attention'));
2358
2367
  chips.push(more);
2359
2368
  }
2360
2369
  el.attention.replaceChildren(
2361
- node('span', 'attention-label', 'Needs attention'), ...chips,
2362
- node('span', 'row-fill'), node('span', 'sub attention-hint', 'Click one to go to it'),
2370
+ node('span', 'attention-label', t('Needs attention')), ...chips,
2371
+ node('span', 'row-fill'), node('span', 'sub attention-hint', t('Click one to go to it')),
2363
2372
  );
2364
2373
  }
2365
2374
 
@@ -2440,9 +2449,9 @@ function selectSession(sessionId) {
2440
2449
  // used to share EventSource's 'error' event, so "no such session" was shown
2441
2450
  // as "reconnecting" and the reason was thrown away.
2442
2451
  source.addEventListener('fault', (e) => {
2443
- let reason = 'unknown';
2452
+ let reason = t('unknown');
2444
2453
  try { reason = JSON.parse(e.data).reason ?? reason; } catch { /* keep default */ }
2445
- el.foot.textContent = `stream fault: ${reason}`;
2454
+ el.foot.textContent = t('stream fault: {reason}', { reason });
2446
2455
  source.close();
2447
2456
  source = null;
2448
2457
  });
@@ -2452,7 +2461,7 @@ function selectSession(sessionId) {
2452
2461
  // does not decide Offline.
2453
2462
  source.onerror = () => {
2454
2463
  if (!source) return; // already closed by a fault
2455
- el.foot.textContent = 'stream dropped — reconnecting';
2464
+ el.foot.textContent = t('stream dropped — reconnecting');
2456
2465
  };
2457
2466
  }
2458
2467
 
@@ -2581,7 +2590,7 @@ function sessionIsLive() {
2581
2590
  /** The session card's state: waiting on the viewer outranks what the fleet says, as it does for an agent. */
2582
2591
  function sessionStateNow() {
2583
2592
  const own = waitingNow.some((r) => r.sessionId === current && !r.agentId);
2584
- if (own) return { key: 'waiting', word: 'Needs you', tone: 'waiting', known: true };
2593
+ if (own) return { key: 'waiting', word: t('Needs you'), tone: 'waiting', known: true };
2585
2594
  return sessionStatus(current, fleetData);
2586
2595
  }
2587
2596
 
@@ -2639,7 +2648,7 @@ async function setSessionMode(sessionId, mode) {
2639
2648
  } catch (e) {
2640
2649
  res = { ok: false, reason: e.message };
2641
2650
  }
2642
- if (!res.ok) toast(`Mode not changed — ${res.reason ?? 'no reason given'}`);
2651
+ if (!res.ok) toast(t('Mode not changed — {reason}', { reason: res.reason ?? t('no reason given') }));
2643
2652
  else pollOwned();
2644
2653
  }
2645
2654
 
@@ -2660,7 +2669,7 @@ async function decideRequest(item, verdict, extra = {}) {
2660
2669
  decided.delete(item.key);
2661
2670
  dock.release(item.key);
2662
2671
  list.release(item.key);
2663
- toast(`Decision not recorded — ${res.reason ?? 'no reason given'}`);
2672
+ toast(t('Decision not recorded — {reason}', { reason: res.reason ?? t('no reason given') }));
2664
2673
  return;
2665
2674
  }
2666
2675
  answered.add(item.key);
@@ -2716,7 +2725,7 @@ chat.onPermissions = () => pollOwned();
2716
2725
 
2717
2726
  let nextPollAt = null;
2718
2727
  let restartNote = null; // staleServerNote(/api/health): the server runs older code than the page
2719
- const stageNote = { text: node('span', 'stage-note-text'), retry: node('span', 'sub'), button: node('button', 'btn sm', 'Retry now') };
2728
+ const stageNote = { text: node('span', 'stage-note-text'), retry: node('span', 'sub'), button: node('button', 'btn sm', t('Retry now')) };
2720
2729
  stageNote.button.type = 'button';
2721
2730
  stageNote.button.addEventListener('click', () => retryNow());
2722
2731
  el.stageNote.append(stageNote.text, stageNote.retry, node('span', 'row-fill'), stageNote.button);
@@ -2748,7 +2757,7 @@ function paintStageNote() {
2748
2757
  /** The summary box, from the session on screen. A live session's time is counted to now, so this runs on the tick. */
2749
2758
  function paintSpend() {
2750
2759
  const usage = lastNodes?.find((n) => n.kind === 'session')?.usage ?? null;
2751
- canvas.setSpend(summaryRows(usage, { live: lastLive === true, now: serverNow() }));
2760
+ canvas.setSpend(summaryRows(usage, { live: lastLive === true, now: serverNow(), agentRows: agentSpendRows(lastNodes) }));
2752
2761
  }
2753
2762
 
2754
2763
  function showGraph() {
@@ -2779,7 +2788,7 @@ function showGraph() {
2779
2788
  // other 243 failed. A count of records that could not be read stays beside
2780
2789
  // them: it is not a status, and it is not nothing.
2781
2790
  const s = g.stats ?? {};
2782
- paintSummary(s, s.malformed ? [`${s.malformed} malformed`] : []);
2791
+ paintSummary(s, s.malformed ? [t('{n} malformed', { n: s.malformed })] : []);
2783
2792
  el.foot.textContent = '';
2784
2793
  }
2785
2794
 
@@ -2799,7 +2808,7 @@ function paintTerminalWait() {
2799
2808
  what.title = w.what ?? '';
2800
2809
  el.terminalWait.replaceChildren(
2801
2810
  node('span', 'terminal-wait-word', `${w.text}${w.what ? ':' : ''}`), what,
2802
- node('span', 'sub terminal-wait-note', 'Studio answers only for sessions started here'),
2811
+ node('span', 'sub terminal-wait-note', t('Studio answers only for sessions started here')),
2803
2812
  );
2804
2813
  }
2805
2814
  canvas.fitIfUntouched();
@@ -2820,7 +2829,7 @@ const afterSummary = el.summary.nextElementSibling;
2820
2829
  function setDrawer(open) {
2821
2830
  shell.classList.toggle('side-open', open);
2822
2831
  el.menuBtn.setAttribute('aria-expanded', String(open));
2823
- el.menuBtn.setAttribute('aria-label', open ? 'Close the navigator' : 'Open the navigator');
2832
+ el.menuBtn.setAttribute('aria-label', open ? t('Close the navigator') : t('Open the navigator'));
2824
2833
  }
2825
2834
  el.menuBtn.addEventListener('click', () => setDrawer(!shell.classList.contains('side-open')));
2826
2835
  // Choosing a session in the drawer is why it was opened; it closes behind the choice.
@@ -2853,7 +2862,7 @@ async function pollFleet() {
2853
2862
  try {
2854
2863
  renderFleet(await getJson('/api/fleet'));
2855
2864
  } catch (e) {
2856
- renderNote(el.fleet, { kind: 'unmeasured', title: 'Server unreachable', why: e.message });
2865
+ renderNote(el.fleet, { kind: 'unmeasured', title: t('Server unreachable'), why: e.message });
2857
2866
  }
2858
2867
  }
2859
2868
 
@@ -2883,8 +2892,8 @@ getJson('/api/health').then((h) => {
2883
2892
  const badge = document.getElementById('role');
2884
2893
  badge.hidden = role !== 'viewer';
2885
2894
  if (transcriptsHidden) {
2886
- badge.textContent = 'Read-only · transcripts hidden';
2887
- badge.title = 'This link reads the panel and changes nothing; no transcript text is sent to it';
2895
+ badge.textContent = t('Read-only · transcripts hidden');
2896
+ badge.title = t('This link reads the panel and changes nothing; no transcript text is sent to it');
2888
2897
  }
2889
2898
  if (role === 'owner') pollRemote(); else paintMachines();
2890
2899
  }).catch(() => { /* the Live indicator says the server is unreachable */ });