crewforth 3.4.0 → 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 (182) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/README.md +3 -2
  3. package/VERSION +1 -1
  4. package/adopt.sh +40 -5
  5. package/bin/cli.js +138 -2
  6. package/kit/AGENT_TEMPLATE.md +15 -0
  7. package/kit/CLAUDE.md +50 -18
  8. package/kit/agents/crew-backend-expert.md +4 -3
  9. package/kit/agents/crew-commit-agent.md +2 -0
  10. package/kit/agents/crew-database-expert.md +2 -1
  11. package/kit/agents/crew-devops-expert.md +1 -0
  12. package/kit/agents/crew-docs-agent.md +3 -1
  13. package/kit/agents/crew-frontend-expert.md +2 -0
  14. package/kit/agents/crew-performance-expert.md +7 -2
  15. package/kit/agents/crew-planner.md +15 -3
  16. package/kit/agents/crew-privacy-agent.md +2 -0
  17. package/kit/agents/crew-review-agent.md +8 -1
  18. package/kit/agents/crew-security-expert.md +4 -3
  19. package/kit/agents/crew-test-expert.md +6 -2
  20. package/kit/eval/doctor.sh +201 -1
  21. package/kit/eval/gate-report.sh +28 -0
  22. package/kit/eval/golden-holdout.txt +2 -0
  23. package/kit/eval/golden-routing.txt +17 -0
  24. package/kit/eval/lib/crew-map/contracts.mjs +77 -0
  25. package/kit/eval/lib/crew-map/crew-map.sh +2 -1
  26. package/kit/eval/lib/crew-map/detect.mjs +2 -1
  27. package/kit/eval/lib/crew-map/narrative.mjs +14 -3
  28. package/kit/eval/lib/crew-map/project.mjs +45 -6
  29. package/kit/eval/lib/crew-map/scan.mjs +153 -14
  30. package/kit/eval/lib/findings/findings.schema.json +11 -1
  31. package/kit/eval/lib/findings/findings.sh +24 -1
  32. package/kit/eval/lib/hook-inject.sh +159 -0
  33. package/kit/eval/lib/lockfiles.sh +71 -0
  34. package/kit/eval/lib/payload-check.sh +29 -0
  35. package/kit/eval/lib/preview.sh +3 -3
  36. package/kit/eval/lib/review-bench/bench.mjs +14 -2
  37. package/kit/eval/lib/review-prepass/prepass.mjs +32 -3
  38. package/kit/eval/lib/settings-json.awk +18 -0
  39. package/kit/eval/lib/statusline.sh +22 -5
  40. package/kit/eval/lib/todo.awk +167 -0
  41. package/kit/eval/lib/todo.sh +120 -0
  42. package/kit/eval/lib/untrusted-text.awk +38 -0
  43. package/kit/eval/scan-skill.sh +67 -20
  44. package/kit/eval/smoke-test.sh +1759 -41
  45. package/kit/eval/utilization.sh +77 -3
  46. package/kit/hooks/agent-outcome.sh +9 -1
  47. package/kit/hooks/board-sync.sh +49 -3
  48. package/kit/hooks/board.sh +8 -8
  49. package/kit/hooks/branch-fresh.sh +101 -0
  50. package/kit/hooks/floor-blocklist.txt +19 -1
  51. package/kit/hooks/guard-bash.sh +92 -8
  52. package/kit/hooks/guard-question.sh +2 -1
  53. package/kit/hooks/loop-guard.sh +445 -0
  54. package/kit/hooks/map-stale.sh +437 -0
  55. package/kit/hooks/pre-commit +48 -7
  56. package/kit/hooks/precompact-snapshot.sh +102 -6
  57. package/kit/hooks/prompt-approval.sh +50 -14
  58. package/kit/hooks/route-hint.sh +36 -7
  59. package/kit/hooks/secret-blocklist.txt +55 -1
  60. package/kit/hooks/session-guard.sh +45 -15
  61. package/kit/hooks/session-rehydrate.sh +34 -11
  62. package/kit/hooks/session-stats.sh +169 -8
  63. package/kit/hooks/session-update-check.sh +5 -1
  64. package/kit/hooks/skill-trust.sh +23 -14
  65. package/kit/hooks/turn-check.sh +190 -0
  66. package/kit/owned-blobs.tsv +4 -4
  67. package/kit/settings.json +64 -0
  68. package/kit/skills/a11y/SKILL.md +50 -3
  69. package/kit/skills/a11y/references/recipes.md +41 -0
  70. package/kit/skills/adr/SKILL.md +14 -2
  71. package/kit/skills/api-design/SKILL.md +6 -1
  72. package/kit/skills/api-design/references/idempotency.md +39 -0
  73. package/kit/skills/brainstorm/SKILL.md +4 -2
  74. package/kit/skills/brainstorm/references/asking.md +47 -0
  75. package/kit/skills/ci-pipeline/SKILL.md +11 -1
  76. package/kit/skills/ci-pipeline/references/agent-job.md +33 -0
  77. package/kit/skills/commit-message/SKILL.md +9 -3
  78. package/kit/skills/confidence-check/SKILL.md +9 -7
  79. package/kit/skills/confidence-check/references/source-check.md +37 -0
  80. package/kit/skills/crew-approve/SKILL.md +3 -0
  81. package/kit/skills/crew-audit/SKILL.md +63 -0
  82. package/kit/skills/crew-board/SKILL.md +2 -2
  83. package/kit/skills/crew-code-review/SKILL.md +27 -4
  84. package/kit/skills/crew-code-review/references/panel-mode.md +9 -9
  85. package/kit/skills/crew-code-review/references/simplify.md +28 -0
  86. package/kit/skills/crew-doctor/SKILL.md +3 -2
  87. package/kit/skills/crew-gates/SKILL.md +3 -0
  88. package/kit/skills/crew-gates/references/enforcers.md +30 -0
  89. package/kit/skills/crew-interview/SKILL.md +42 -0
  90. package/kit/skills/crew-map/SKILL.md +26 -6
  91. package/kit/skills/crew-plan/SKILL.md +10 -2
  92. package/kit/skills/crew-pr/SKILL.md +57 -0
  93. package/kit/skills/crew-pr/references/checks.md +35 -0
  94. package/kit/skills/crew-pr/references/threads.md +23 -0
  95. package/kit/skills/crew-review/SKILL.md +34 -1
  96. package/kit/skills/crew-ship/SKILL.md +5 -2
  97. package/kit/skills/crew-skill/SKILL.md +26 -1
  98. package/kit/skills/crew-todo/SKILL.md +31 -0
  99. package/kit/skills/crew-update/SKILL.md +4 -1
  100. package/kit/skills/db-migration/SKILL.md +15 -0
  101. package/kit/skills/db-migration/references/expand-contract.md +52 -0
  102. package/kit/skills/dependency-audit/SKILL.md +34 -1
  103. package/kit/skills/dependency-audit/scripts/record.sh +37 -0
  104. package/kit/skills/dependency-upgrade/SKILL.md +29 -1
  105. package/kit/skills/deploy/SKILL.md +18 -0
  106. package/kit/skills/deploy/references/topology-a.md +7 -0
  107. package/kit/skills/deploy/references/topology-b.md +2 -2
  108. package/kit/skills/docs-writer/SKILL.md +37 -2
  109. package/kit/skills/eval-grader/SKILL.md +9 -0
  110. package/kit/skills/eval-grader/references/method.md +26 -0
  111. package/kit/skills/frontend/SKILL.md +5 -2
  112. package/kit/skills/frontend/references/from-design.md +29 -0
  113. package/kit/skills/frontend/references/page-metadata.md +17 -0
  114. package/kit/skills/frontend/references/verify-contract.md +18 -1
  115. package/kit/skills/frontend/references/worst-data.md +35 -0
  116. package/kit/skills/frontend-design/SKILL.md +20 -4
  117. package/kit/skills/frontend-design/references/design-review.md +21 -0
  118. package/kit/skills/frontend-design/references/detection.md +44 -0
  119. package/kit/skills/frontend-design/references/motion.md +54 -0
  120. package/kit/skills/frontend-design/references/ui-copy.md +35 -0
  121. package/kit/skills/frontend-rn-expo/SKILL.md +3 -0
  122. package/kit/skills/handoff/SKILL.md +28 -3
  123. package/kit/skills/handoff/references/cold-reader.md +13 -6
  124. package/kit/skills/i18n-integrity/SKILL.md +3 -0
  125. package/kit/skills/iterate/SKILL.md +7 -2
  126. package/kit/skills/mcp-builder/SKILL.md +21 -4
  127. package/kit/skills/mcp-builder/references/building.md +74 -13
  128. package/kit/skills/mcp-builder/references/cli-for-agents.md +26 -0
  129. package/kit/skills/observability/SKILL.md +13 -1
  130. package/kit/skills/performance/SKILL.md +25 -1
  131. package/kit/skills/performance/references/bottlenecks.md +11 -0
  132. package/kit/skills/performance/references/web-vitals.md +31 -0
  133. package/kit/skills/red-team/SKILL.md +25 -3
  134. package/kit/skills/reflect/SKILL.md +7 -2
  135. package/kit/skills/release/SKILL.md +13 -0
  136. package/kit/skills/security-scan/SKILL.md +6 -2
  137. package/kit/skills/security-scan/references/ai-agents.md +16 -0
  138. package/kit/skills/security-scan/references/fronts.md +59 -2
  139. package/kit/skills/security-scan/references/reporting.md +13 -0
  140. package/kit/skills/security-scan/references/verify.md +9 -0
  141. package/kit/skills/sonarqube-check/SKILL.md +32 -2
  142. package/kit/skills/sonarqube-check/references/ratchet.md +32 -0
  143. package/kit/skills/spec-planning/SKILL.md +32 -8
  144. package/kit/skills/systematic-debugging/SKILL.md +15 -7
  145. package/kit/skills/systematic-debugging/references/techniques.md +5 -0
  146. package/kit/skills/teamboard/SKILL.md +3 -3
  147. package/kit/skills/testing/SKILL.md +18 -3
  148. package/kit/skills/testing/references/browser-headless.md +21 -0
  149. package/kit/skills/token-budget/SKILL.md +26 -5
  150. package/kit/skills/worktree/SKILL.md +13 -5
  151. package/kit/studio/server/lib/session.js +23 -2
  152. package/kit/studio/server/lib/worktree-changes.js +64 -0
  153. package/kit/studio/web/agent-spend.js +32 -0
  154. package/kit/studio/web/app.js +245 -236
  155. package/kit/studio/web/approvals.js +14 -13
  156. package/kit/studio/web/ask.js +10 -9
  157. package/kit/studio/web/canvas.js +56 -54
  158. package/kit/studio/web/chat.js +41 -41
  159. package/kit/studio/web/convo.js +33 -16
  160. package/kit/studio/web/dock.js +20 -19
  161. package/kit/studio/web/graph-plan.js +62 -22
  162. package/kit/studio/web/i18n-tr.js +760 -0
  163. package/kit/studio/web/i18n.js +79 -0
  164. package/kit/studio/web/index.html +1 -0
  165. package/kit/studio/web/inspect.js +27 -24
  166. package/kit/studio/web/list-plan.js +16 -15
  167. package/kit/studio/web/list.js +6 -5
  168. package/kit/studio/web/liveness.js +11 -9
  169. package/kit/studio/web/map-plan.js +86 -36
  170. package/kit/studio/web/map.js +85 -72
  171. package/kit/studio/web/models-plan.js +47 -42
  172. package/kit/studio/web/models.js +21 -20
  173. package/kit/studio/web/names.js +13 -10
  174. package/kit/studio/web/nav.js +34 -33
  175. package/kit/studio/web/newsession.js +25 -24
  176. package/kit/studio/web/style.css +2 -0
  177. package/kit/studio/web/theme.js +5 -2
  178. package/kit/studio/web/timeline-plan.js +19 -18
  179. package/kit/studio/web/timeline.js +23 -22
  180. package/kit/studio/web/usage.js +36 -32
  181. package/package.json +5 -4
  182. package/start.sh +11 -2
@@ -6,6 +6,7 @@
6
6
  // Crewforth's command for it and lowers nothing. Then the normal work that ran above the table's model: said, with
7
7
  // the record's evidence where there is some, and not acted on either.
8
8
  import { classRow, suggestionText, aboveText, emptyNote, totalsLine, costLine, SOURCE, LOWER_RULE, ABOVE_HEAD, ABOVE_RULE } from './models-plan.js';
9
+ import { t } from './i18n.js';
9
10
 
10
11
  function mk(tag, cls, text) {
11
12
  const n = document.createElement(tag);
@@ -14,7 +15,7 @@ function mk(tag, cls, text) {
14
15
  return n;
15
16
  }
16
17
 
17
- const COLUMNS = [['Agent · change', ''], ['Risk', ''], ['Model', ''], ['Tasks', 'mv-num'], ['First try', 'mv-num'], ['Escalated', 'mv-num'], ['Est. cost', 'mv-num']];
18
+ const COLUMNS = [[t('Agent · change'), ''], [t('Risk'), ''], [t('Model'), ''], [t('Tasks'), 'mv-num'], [t('First try'), 'mv-num'], [t('Escalated'), 'mv-num'], [t('Est. cost'), 'mv-num']];
18
19
 
19
20
  export class Models {
20
21
  /** @param hooks.onCopy(text) put the command on the clipboard */
@@ -40,8 +41,8 @@ export class Models {
40
41
  const source = mk('span', 'sub', SOURCE);
41
42
  source.title = [totalsLine(d), costLine(this.costs)].filter(Boolean).join(' \u00b7 ');
42
43
  // Lines the hooks could not have written are not counted, and that is said where the count is.
43
- if (d.dropped) source.textContent = `${SOURCE} \u00b7 ${d.dropped} ${d.dropped === 1 ? 'line' : 'lines'} not read`;
44
- head.append(mk('h2', 'mv-title', 'Which model each kind of task needs'), mk('span', 'row-fill'), source);
44
+ if (d.dropped) source.textContent = `${SOURCE} \u00b7 ${d.dropped === 1 ? t('1 line not read') : t('{n} lines not read', { n: d.dropped })}`;
45
+ head.append(mk('h2', 'mv-title', t('Which model each kind of task needs')), mk('span', 'row-fill'), source);
45
46
  pane.append(head);
46
47
 
47
48
  const scroll = mk('div', 'mv-scroll');
@@ -64,7 +65,7 @@ export class Models {
64
65
  risk.append(tag);
65
66
  const model = mk('td');
66
67
  const ran = mk('span', null, r.models);
67
- ran.title = r.ranOn ? `Ran on: ${r.ranOn}` : '';
68
+ ran.title = r.ranOn ? t('Ran on: {model}', { model: r.ranOn }) : '';
68
69
  model.append(ran);
69
70
  if (r.floorTag) {
70
71
  const raised = mk('span', 'mv-tag', r.floorTag);
@@ -74,7 +75,7 @@ export class Models {
74
75
  raisedNotes.push({ tag: r.floorTag, text: `${r.who} · ${r.change}: ${r.raisedWhy}` });
75
76
  }
76
77
  const first = mk('td', 'mv-num');
77
- first.title = [r.firstTryOf, r.after, r.notVerified ? `Not verified: ${r.notVerified}` : null].filter(Boolean).join('\n');
78
+ first.title = [r.firstTryOf, r.after, r.notVerified ? t('Not verified: {what}', { what: r.notVerified }) : null].filter(Boolean).join('\n');
78
79
  first.append(mk('span', null, r.firstTry));
79
80
  if (r.rate !== null) {
80
81
  const meter = mk('span', 'mv-meter');
@@ -89,7 +90,7 @@ export class Models {
89
90
  const cost = mk('td', 'mv-num', r.cost);
90
91
  cost.title = r.costNote;
91
92
  if (r.partial) cost.dataset.partial = 'true';
92
- if (r.advisor) { const t = mk('span', 'mv-tag', 'advisor'); t.dataset.kind = 'advisor'; t.title = r.advisor; cost.append(t); }
93
+ if (r.advisor) { const tag = mk('span', 'mv-tag', t('advisor')); tag.dataset.kind = 'advisor'; tag.title = r.advisor; cost.append(tag); }
93
94
  tr.append(who, risk, model, mk('td', 'mv-num', r.tasks), first, mk('td', 'mv-num', r.escalated), cost);
94
95
  tbody.append(tr);
95
96
  }
@@ -99,19 +100,19 @@ export class Models {
99
100
 
100
101
  if (d.suggestions?.length) {
101
102
  const box = mk('section', 'mv-suggest');
102
- box.append(mk('h3', 'mv-suggest-h', 'Suggestion · needs your approval'));
103
+ box.append(mk('h3', 'mv-suggest-h', t('Suggestion · needs your approval')));
103
104
  for (const s of d.suggestions) {
104
- const t = suggestionText(s);
105
- box.append(mk('p', 'mv-suggest-text', `${t.title} ${t.says}`));
105
+ const s2 = suggestionText(s);
106
+ box.append(mk('p', 'mv-suggest-text', `${s2.title} ${s2.says}`));
106
107
  const row = mk('div', 'mv-suggest-row');
107
- row.append(mk('span', 'sub', `${LOWER_RULE} ${t.how}`), mk('span', 'row-fill'));
108
- if (t.command) {
108
+ row.append(mk('span', 'sub', `${LOWER_RULE} ${s2.how}`), mk('span', 'row-fill'));
109
+ if (s2.command) {
109
110
  // It copies the words and does nothing else: the hold is lowered where the user sends them.
110
- const b = mk('button', 'btn sm', 'Copy command');
111
+ const b = mk('button', 'btn sm', t('Copy command'));
111
112
  b.type = 'button';
112
- b.title = 'Copies the command. Nothing is lowered until you send it in your own session.';
113
- b.addEventListener('click', () => this.hooks.onCopy?.(t.command));
114
- row.append(mk('code', 'mv-cmd', t.command), b);
113
+ b.title = t('Copies the command. Nothing is lowered until you send it in your own session.');
114
+ b.addEventListener('click', () => this.hooks.onCopy?.(s2.command));
115
+ row.append(mk('code', 'mv-cmd', s2.command), b);
115
116
  }
116
117
  box.append(row);
117
118
  }
@@ -121,12 +122,12 @@ export class Models {
121
122
  const box = mk('section', 'mv-above');
122
123
  box.append(mk('h3', 'mv-suggest-h', ABOVE_HEAD));
123
124
  for (const a of d.above) {
124
- const t = aboveText(a, this.costs?.measured ? (this.costs.above?.[a.key] ?? null) : null);
125
- box.append(mk('p', 'mv-suggest-text', `${t.title} ${t.says}`));
126
- const v = mk('p', 'mv-above-verdict', t.verdict);
127
- v.dataset.measured = String(t.measured);
125
+ const at = aboveText(a, this.costs?.measured ? (this.costs.above?.[a.key] ?? null) : null);
126
+ box.append(mk('p', 'mv-suggest-text', `${at.title} ${at.says}`));
127
+ const v = mk('p', 'mv-above-verdict', at.verdict);
128
+ v.dataset.measured = String(at.measured);
128
129
  box.append(v);
129
- if (t.costs) box.append(mk('p', 'sub', t.costs));
130
+ if (at.costs) box.append(mk('p', 'sub', at.costs));
130
131
  }
131
132
  box.append(mk('p', 'sub', ABOVE_RULE));
132
133
  pane.append(box);
@@ -6,6 +6,8 @@
6
6
  // anywhere but on screen. Keys, ids and comparisons use the type.
7
7
 
8
8
  // A word that is not written the way capitalising its first letter would write it.
9
+ import { t } from './i18n.js';
10
+
9
11
  const WORD = { devops: 'DevOps' };
10
12
 
11
13
  /** The type as the transcript has it; an agent with none is said to have none. */
@@ -27,7 +29,7 @@ export function roleName(type) {
27
29
  }
28
30
 
29
31
  /** The name a node is drawn with. */
30
- export const shownType = (n) => roleName(typeOf(n));
32
+ export const shownType = (n) => (n?.agentType ? roleName(n.agentType) : t('unknown agent'));
31
33
 
32
34
  /** What hovering an agent says: the type as it is declared, and its task in full, since both are cut on screen. */
33
35
  /**
@@ -39,12 +41,13 @@ export const shownType = (n) => roleName(typeOf(n));
39
41
  export function originOf(type, origins) {
40
42
  if (!origins || typeof type !== 'string' || !type) return null;
41
43
  const at = type.indexOf(':');
42
- if (at > 0 && at < type.length - 1) return `Defined in the plugin \u201c${type.slice(0, at)}\u201d`;
43
- if (origins.project && Object.hasOwn(origins.project, type)) return `Defined in this project: .claude/agents/${origins.project[type]}`;
44
- if (origins.user && Object.hasOwn(origins.user, type)) return `Defined in your own agents: ~/.claude/agents/${origins.user[type]}`;
45
- const unread = [origins.project ? null : 'the project', origins.user ? null : 'your own'].filter(Boolean);
46
- return unread.length === 2 ? 'Where it is defined was not read: no agents directory in the project or your own'
47
- : `No definition file found${unread.length ? ` (${unread[0]} agents directory was not read)` : ''}: built in to Claude Code, or defined where Studio does not look`;
44
+ if (at > 0 && at < type.length - 1) return t('Defined in the plugin \u201c{plugin}\u201d', { plugin: type.slice(0, at) });
45
+ if (origins.project && Object.hasOwn(origins.project, type)) return t('Defined in this project: .claude/agents/{file}', { file: origins.project[type] });
46
+ if (origins.user && Object.hasOwn(origins.user, type)) return t('Defined in your own agents: ~/.claude/agents/{file}', { file: origins.user[type] });
47
+ if (!origins.project && !origins.user) return t('Where it is defined was not read: no agents directory in the project or your own');
48
+ if (!origins.project) return t('No definition file found (the project agents directory was not read): built in to Claude Code, or defined where Studio does not look');
49
+ if (!origins.user) return t('No definition file found (your own agents directory was not read): built in to Claude Code, or defined where Studio does not look');
50
+ return t('No definition file found: built in to Claude Code, or defined where Studio does not look');
48
51
  }
49
52
 
50
53
  export const hoverOf = (n) => [n?.agentType ?? null, n?.description ?? null].filter(Boolean).join('\n');
@@ -96,9 +99,9 @@ export function modelOf(n) {
96
99
  const differs = Boolean(n?.model && n?.modelAsked)
97
100
  && (modelFamily(n.model) !== modelFamily(n.modelAsked) || (n.modelAsked.includes('-') && n.modelAsked !== n.model));
98
101
  const title = [
99
- `Ran on: ${n?.model ?? 'not recorded'}`,
100
- `Asked for: ${n?.modelAsked ?? 'no model named (the agent\'s default)'}`,
101
- differs ? 'The two differ.' : null,
102
+ t('Ran on: {model}', { model: n?.model ?? t('not recorded') }),
103
+ t('Asked for: {model}', { model: n?.modelAsked ?? t('no model named (the agent\'s default)') }),
104
+ differs ? t('The two differ.') : null,
102
105
  ].filter(Boolean).join('\n');
103
106
  return { ran, asked, differs, title };
104
107
  }
@@ -8,19 +8,21 @@
8
8
  // status nobody recognises keeps its own word and gets no colour; a branch that could not be read leaves the line
9
9
  // without one; a project that cannot be told to update by a command gets a hint instead of a command.
10
10
 
11
+ import { t } from './i18n.js';
12
+
11
13
  /* ------------------------------------------------------------- status --- */
12
14
 
13
15
  // The words the design uses, and the token each is drawn in. `tone` names a status-* token; null is the hollow
14
16
  // ring of something that is over.
15
17
  const AGENT_STATUS = {
16
- running: { word: 'running', tone: 'busy' },
17
- starting: { word: 'starting', tone: 'busy' },
18
- done: { word: 'done', tone: 'good' },
19
- failed: { word: 'failed', tone: 'fail' },
20
- killed: { word: 'killed', tone: 'fail' },
21
- stopped: { word: 'stopped', tone: 'fail' },
22
- stale: { word: 'stale', tone: 'idle' },
23
- ended: { word: 'ended', tone: null },
18
+ running: { word: t('running'), tone: 'busy' },
19
+ starting: { word: t('starting'), tone: 'busy' },
20
+ done: { word: t('done'), tone: 'good' },
21
+ failed: { word: t('failed'), tone: 'fail' },
22
+ killed: { word: t('killed'), tone: 'fail' },
23
+ stopped: { word: t('stopped'), tone: 'fail' },
24
+ stale: { word: t('stale'), tone: 'idle' },
25
+ ended: { word: t('ended'), tone: null },
24
26
  };
25
27
  const CHIP_ORDER = ['running', 'starting', 'stale', 'done', 'failed', 'killed', 'stopped', 'ended'];
26
28
 
@@ -29,7 +31,7 @@ export function agentStatus(status) {
29
31
  const known = AGENT_STATUS[status];
30
32
  return known
31
33
  ? { word: known.word, tone: known.tone, known: true }
32
- : { word: String(status ?? 'unknown'), tone: null, known: false };
34
+ : { word: String(status ?? t('unknown')), tone: null, known: false };
33
35
  }
34
36
 
35
37
  /**
@@ -55,9 +57,9 @@ export function summaryChips(byStatus) {
55
57
  // A session, as the machine reports it. `claude agents` knows three states; a session it does not list is not
56
58
  // running anywhere on this machine.
57
59
  const SESSION_STATUS = {
58
- busy: { word: 'Running', tone: 'busy' },
59
- waiting: { word: 'Needs you', tone: 'waiting' },
60
- idle: { word: 'Idle', tone: 'idle' },
60
+ busy: { word: t('Running'), tone: 'busy' },
61
+ waiting: { word: t('Needs you'), tone: 'waiting' },
62
+ idle: { word: t('Idle'), tone: 'idle' },
61
63
  };
62
64
 
63
65
  /**
@@ -67,7 +69,7 @@ const SESSION_STATUS = {
67
69
  export function sessionStatus(sessionId, fleet) {
68
70
  if (!fleet || fleet.measured === false) return { key: 'unmeasured', word: null, tone: null, known: false };
69
71
  const row = (fleet.sessions ?? []).find((s) => s.sessionId === sessionId && s.local !== false);
70
- if (!row) return { key: 'ended', word: 'Ended', tone: null, known: true };
72
+ if (!row) return { key: 'ended', word: t('Ended'), tone: null, known: true };
71
73
  const known = SESSION_STATUS[row.status];
72
74
  if (!known) return { key: String(row.status), word: String(row.status), tone: null, known: false };
73
75
  return { key: row.status, word: known.word, tone: known.tone, known: true, waitingFor: row.waitingFor ?? null };
@@ -95,9 +97,9 @@ export function sessionName(sn, labels) {
95
97
  export function sessionSub(sn, status) {
96
98
  const parts = [];
97
99
  if (sn.branch) parts.push(sn.branch);
98
- if (status?.key === 'waiting' && status.waitingFor) parts.push(`Needs you · ${status.waitingFor}`);
100
+ if (status?.key === 'waiting' && status.waitingFor) parts.push(`${t('Needs you')} · ${status.waitingFor}`);
99
101
  else if (status?.word) parts.push(status.word);
100
- if (sn.agentCount > 0) parts.push(`${sn.agentCount} ${sn.agentCount === 1 ? 'agent' : 'agents'}`);
102
+ if (sn.agentCount > 0) parts.push(sn.agentCount === 1 ? t('{n} agent', { n: 1 }) : t('{n} agents', { n: sn.agentCount }));
101
103
  return parts.join(' · ');
102
104
  }
103
105
 
@@ -105,20 +107,20 @@ export function sessionSub(sn, status) {
105
107
  export function ago(thenMs, nowMs) {
106
108
  if (typeof thenMs !== 'number' || !Number.isFinite(thenMs)) return '';
107
109
  const s = Math.max(0, Math.round((nowMs - thenMs) / 1000));
108
- if (s < 60) return 'now';
109
- if (s < 3600) return `${Math.floor(s / 60)}m`;
110
- if (s < 86400) return `${Math.floor(s / 3600)}h`;
111
- return `${Math.floor(s / 86400)}d`;
110
+ if (s < 60) return t('now');
111
+ if (s < 3600) return t('{n}m', { n: Math.floor(s / 60) });
112
+ if (s < 86400) return t('{n}h', { n: Math.floor(s / 3600) });
113
+ return t('{n}d', { n: Math.floor(s / 86400) });
112
114
  }
113
115
 
114
116
  /** "seen just now", "seen 3 min ago", "seen 2 h ago" — for a fact recorded earlier, not read now. */
115
117
  export function seenAgo(thenMs, nowMs) {
116
- if (typeof thenMs !== 'number' || !Number.isFinite(thenMs)) return 'seen at an unrecorded time';
118
+ if (typeof thenMs !== 'number' || !Number.isFinite(thenMs)) return t('seen at an unrecorded time');
117
119
  const s = Math.max(0, Math.round((nowMs - thenMs) / 1000));
118
- if (s < 60) return 'seen just now';
119
- if (s < 3600) return `seen ${Math.floor(s / 60)} min ago`;
120
- if (s < 86400) return `seen ${Math.floor(s / 3600)} h ago`;
121
- return `seen ${Math.floor(s / 86400)} d ago`;
120
+ if (s < 60) return t('seen just now');
121
+ if (s < 3600) return t('seen {n} min ago', { n: Math.floor(s / 60) });
122
+ if (s < 86400) return t('seen {n} h ago', { n: Math.floor(s / 3600) });
123
+ return t('seen {n} d ago', { n: Math.floor(s / 86400) });
122
124
  }
123
125
 
124
126
  /* ------------------------------------------------------------- search --- */
@@ -161,29 +163,28 @@ export function highlight(text, query) {
161
163
  export function badgeFor(kit) {
162
164
  if (!kit || !kit.installed) {
163
165
  return {
164
- text: 'no Crewforth',
166
+ text: t('no Crewforth'),
165
167
  tone: 'none',
166
168
  copy: null,
167
- title: 'No Crewforth files in this project. A plugin install leaves none, so this cannot tell the two '
168
- + 'apart; to update one, run /crew-update in the project.',
169
+ title: t('No Crewforth files in this project. A plugin install leaves none, so this cannot tell the two apart; to update one, run /crew-update in the project.'),
169
170
  };
170
171
  }
171
172
  const command = typeof kit.updateCommand === 'string' && kit.updateCommand ? kit.updateCommand : null;
172
- const how = command ? `click to copy: ${command}` : 'run /crew-update in this project';
173
+ const how = command ? t('click to copy: {command}', { command }) : t('run /crew-update in this project');
173
174
  if (kit.compared === false) {
174
175
  return {
175
176
  text: `${kit.version} ?`,
176
177
  tone: 'unknown',
177
178
  copy: null,
178
- title: `Crewforth ${kit.version} — not compared: ${kit.reason ?? 'no reason given'}`,
179
+ title: t('Crewforth {version} — not compared: {reason}', { version: kit.version, reason: kit.reason ?? t('no reason given') }),
179
180
  };
180
181
  }
181
182
  if (kit.outdated) {
182
183
  return {
183
- text: `${kit.version} · update`,
184
+ text: `${kit.version} · ${t('update')}`,
184
185
  tone: 'old',
185
186
  copy: command,
186
- title: `Crewforth ${kit.version} is behind ${kit.latest} — ${how}`,
187
+ title: t('Crewforth {version} is behind {latest} — {how}', { version: kit.version, latest: kit.latest, how }),
187
188
  };
188
189
  }
189
190
  if (kit.ahead) {
@@ -191,10 +192,10 @@ export function badgeFor(kit) {
191
192
  text: kit.version,
192
193
  tone: 'ahead',
193
194
  copy: null,
194
- title: `Crewforth ${kit.version} is ahead of the published ${kit.latest}`,
195
+ title: t('Crewforth {version} is ahead of the published {latest}', { version: kit.version, latest: kit.latest }),
195
196
  };
196
197
  }
197
- return { text: kit.version, tone: 'current', copy: null, title: `Crewforth ${kit.version} — current` };
198
+ return { text: kit.version, tone: 'current', copy: null, title: t('Crewforth {version} — current', { version: kit.version }) };
198
199
  }
199
200
 
200
201
  /* --------------------------------------------------------------- live --- */
@@ -3,6 +3,7 @@
3
3
  // The choices are the server's. The projects are the ones it listed; the modes are the ones it offers, and this
4
4
  // file can describe a mode but cannot add one. Starting is guarded by a one-shot: two clicks start one session.
5
5
  import { oneShot } from './convo.js';
6
+ import { t, tParts } from './i18n.js';
6
7
 
7
8
  /**
8
9
  * What each permission mode means for a session started HERE.
@@ -13,9 +14,9 @@ import { oneShot } from './convo.js';
13
14
  * of repeating a difference that only exists in a terminal.
14
15
  */
15
16
  export const MODE_TEXT = {
16
- plan: { word: 'Plan', says: 'Reads and plans. Cannot change files.' },
17
- acceptEdits: { word: 'Accept edits', says: 'Edits files and runs commands, each one after you allow it in the approval dock.' },
18
- default: { word: 'Default', says: 'The same in Studio: every call waits for you. It differs from Accept edits only in a terminal.' },
17
+ plan: { word: t('Plan'), says: t('Reads and plans. Cannot change files.') },
18
+ acceptEdits: { word: t('Accept edits'), says: t('Edits files and runs commands, each one after you allow it in the approval dock.') },
19
+ default: { word: t('Default'), says: t('The same in Studio: every call waits for you. It differs from Accept edits only in a terminal.') },
19
20
  };
20
21
 
21
22
  const PREFERRED = 'plan';
@@ -42,7 +43,7 @@ export function projectChoices(projects, preferKey = null) {
42
43
  cwd: p.cwd,
43
44
  label: p.label ?? p.cwd,
44
45
  branch: p.branch ?? null,
45
- kit: p.kit?.installed ? (p.kit.version ?? 'installed') : null,
46
+ kit: p.kit?.installed ? (p.kit.version ?? t('installed')) : null,
46
47
  initial: p === initial,
47
48
  }));
48
49
  }
@@ -82,16 +83,16 @@ export class NewSession {
82
83
  const head = mk('div', 'np-head');
83
84
  const close = mk('button', 'btn icon sm ghost');
84
85
  close.type = 'button';
85
- close.setAttribute('aria-label', 'Close');
86
+ close.setAttribute('aria-label', t('Close'));
86
87
  close.innerHTML = '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>';
87
88
  close.addEventListener('click', () => this.close());
88
- head.append(mk('h2', 'np-title', 'New session'), mk('span', 'row-fill'), close);
89
+ head.append(mk('h2', 'np-title', t('New session')), mk('span', 'row-fill'), close);
89
90
 
90
91
  const form = mk('form', 'np-body');
91
92
  form.addEventListener('submit', (e) => { e.preventDefault(); this.submit(); });
92
93
 
93
94
  const projWrap = mk('div', 'np-field');
94
- const projLabel = mk('label', 'np-label', 'Project');
95
+ const projLabel = mk('label', 'np-label', t('Project'));
95
96
  projLabel.htmlFor = 'np-project';
96
97
  this.projectEl = mk('select', 'np-select');
97
98
  this.projectEl.id = 'np-project';
@@ -102,20 +103,20 @@ export class NewSession {
102
103
  this.modesEl = mk('fieldset', 'np-modes');
103
104
 
104
105
  const firstWrap = mk('div', 'np-field');
105
- const firstLabel = mk('label', 'np-label', 'First message');
106
+ const firstLabel = mk('label', 'np-label', t('First message'));
106
107
  firstLabel.htmlFor = 'np-first';
107
108
  this.firstEl = mk('textarea', 'np-first');
108
109
  this.firstEl.id = 'np-first';
109
110
  this.firstEl.rows = 5;
110
- this.firstEl.placeholder = 'What should this session do?';
111
+ this.firstEl.placeholder = t('What should this session do?');
111
112
  firstWrap.append(firstLabel, this.firstEl,
112
- mk('span', 'sub', 'Optional. You can also start empty and write in the conversation panel.'));
113
+ mk('span', 'sub', t('Optional. You can also start empty and write in the conversation panel.')));
113
114
 
114
115
  // What starting here means, said before the button: a closed panel is a refusal.
115
116
  const note = mk('div', 'np-note');
116
117
  note.innerHTML = '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M8 7.5v3.5M8 5h.01"/></svg>';
117
118
  note.append(mk('span', 'sub',
118
- 'Every tool call in this session waits for your approval here in Studio. If Studio is closed, the request is denied.'));
119
+ t('Every tool call in this session waits for your approval here in Studio. If Studio is closed, the request is denied.')));
119
120
 
120
121
  this.errorEl = mk('div', 'np-error');
121
122
  this.errorEl.setAttribute('role', 'alert');
@@ -124,10 +125,10 @@ export class NewSession {
124
125
  form.append(projWrap, this.modesEl, firstWrap, note, this.errorEl);
125
126
 
126
127
  const foot = mk('div', 'np-foot');
127
- const cancel = mk('button', 'btn', 'Cancel');
128
+ const cancel = mk('button', 'btn', t('Cancel'));
128
129
  cancel.type = 'button';
129
130
  cancel.addEventListener('click', () => this.close());
130
- this.startEl = mk('button', 'btn primary', 'Start session');
131
+ this.startEl = mk('button', 'btn primary', t('Start session'));
131
132
  this.startEl.type = 'button';
132
133
  this.startEl.addEventListener('click', () => this.submit());
133
134
  foot.append(cancel, this.startEl);
@@ -164,7 +165,7 @@ export class NewSession {
164
165
 
165
166
  paint() {
166
167
  this.projectEl.replaceChildren(...this.projects.map((p) => {
167
- const o = mk('option', null, p.kit ? `${p.label} · Crewforth ${p.kit}` : `${p.label} · no Crewforth`);
168
+ const o = mk('option', null, p.kit ? `${p.label} · Crewforth ${p.kit}` : `${p.label} · ${t('no Crewforth')}`);
168
169
  o.value = p.key;
169
170
  o.selected = p.initial;
170
171
  return o;
@@ -179,17 +180,17 @@ export class NewSession {
179
180
  paintRunsIn() {
180
181
  const r = runsIn(this.project, this.shorten);
181
182
  if (!r) {
182
- this.runsEl.textContent = 'No project on this machine to start a session in.';
183
+ this.runsEl.textContent = t('No project on this machine to start a session in.');
183
184
  return;
184
185
  }
185
- const parts = ['Runs in ', mk('code', null, r.path)];
186
- if (r.branch) parts.push(' on branch ', mk('code', null, r.branch));
187
- parts.push('.');
188
- this.runsEl.replaceChildren(...parts);
186
+ const path = mk('code', null, r.path);
187
+ this.runsEl.replaceChildren(...(r.branch
188
+ ? tParts('Runs in {path} on branch {branch}.', { path, branch: mk('code', null, r.branch) })
189
+ : tParts('Runs in {path}.', { path })));
189
190
  }
190
191
 
191
192
  paintModes() {
192
- const legend = mk('legend', 'np-label', 'Permission mode');
193
+ const legend = mk('legend', 'np-label', t('Permission mode'));
193
194
  const rows = this.modes.map((m) => {
194
195
  const row = mk('label', 'np-mode');
195
196
  const radio = mk('input');
@@ -201,19 +202,19 @@ export class NewSession {
201
202
  row.classList.toggle('on', m.mode === this.mode);
202
203
  const text = mk('span', 'np-mode-text');
203
204
  const word = mk('span', 'np-mode-word', m.word);
204
- if (m.initial) word.append(mk('span', 'sub', ' · default'));
205
+ if (m.initial) word.append(mk('span', 'sub', ` · ${t('default')}`));
205
206
  text.append(word);
206
207
  if (m.says) text.append(mk('span', 'sub', m.says));
207
208
  row.append(radio, text);
208
209
  return row;
209
210
  });
210
- this.modesEl.replaceChildren(legend, ...rows, mk('span', 'sub', 'Modes that skip Crewforth\'s gates are not offered.'));
211
+ this.modesEl.replaceChildren(legend, ...rows, mk('span', 'sub', t('Modes that skip Crewforth\'s gates are not offered.')));
211
212
  }
212
213
 
213
214
  paintStart() {
214
215
  const busy = this.start.spent();
215
216
  this.startEl.disabled = busy || !this.project || !this.mode;
216
- this.startEl.textContent = busy ? 'Starting…' : 'Start session';
217
+ this.startEl.textContent = busy ? t('Starting…') : t('Start session');
217
218
  }
218
219
 
219
220
  async submit() {
@@ -226,7 +227,7 @@ export class NewSession {
226
227
  if (out?.ok) {
227
228
  this.close();
228
229
  } else {
229
- this.errorEl.textContent = `Could not start a session — ${out?.reason ?? 'no reason given'}`;
230
+ this.errorEl.textContent = t('Could not start a session — {reason}', { reason: out?.reason ?? t('no reason given') });
230
231
  this.errorEl.hidden = false;
231
232
  this.paintStart();
232
233
  }
@@ -176,6 +176,8 @@ body {
176
176
  .btn.icon:hover { color: var(--ink); }
177
177
  .btn.sm { height: 30px; padding: 0 10px; font-size: 12px; }
178
178
  .btn.icon.sm { width: 30px; padding: 0; }
179
+ /* The language button names the language it switches to (EN, TR) in letters, where its neighbours draw an icon. */
180
+ .lang-btn { font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.04em; }
179
181
  /* The one filled control on the page. */
180
182
  .btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
181
183
  .btn.primary:hover { background: color-mix(in srgb, var(--accent) 88%, var(--ink)); }
@@ -6,6 +6,8 @@
6
6
  //
7
7
  // The choice is remembered per browser. Storage that refuses (private mode, a blocked origin) costs the memory and
8
8
  // not the click: the theme still switches for this page.
9
+ import { t } from './i18n.js';
10
+
9
11
  export const THEME_KEY = 'crewforth-studio-theme';
10
12
 
11
13
  const chosen = (v) => (v === 'light' || v === 'dark' ? v : null);
@@ -28,8 +30,9 @@ export function initTheme(root, button, ls, systemLight) {
28
30
 
29
31
  const label = () => {
30
32
  const to = effectiveTheme(root.dataset.theme, systemLight()) === 'light' ? 'dark' : 'light';
31
- button.setAttribute('aria-label', `Switch to ${to} theme`);
32
- button.title = `Switch to ${to} theme`;
33
+ const say = to === 'light' ? t('Switch to light theme') : t('Switch to dark theme');
34
+ button.setAttribute('aria-label', say);
35
+ button.title = say;
33
36
  };
34
37
  label();
35
38
 
@@ -10,6 +10,7 @@
10
10
  // its whole length — the transcript says how it ended, not when it started going wrong.
11
11
  import { stateOf, tally } from './graph-plan.js';
12
12
  import { typeOf, shownType } from './names.js';
13
+ import { t } from './i18n.js';
13
14
 
14
15
  export const RANGES = [
15
16
  { key: '5m', ms: 5 * 60_000 },
@@ -67,14 +68,14 @@ export function segments(n, approvals, now) {
67
68
 
68
69
  /** What the right of a row says about one agent. */
69
70
  export function agentSub(n, waiting) {
70
- if (waiting?.has(n.id)) return { text: 'needs you', tone: 'wait' };
71
+ if (waiting?.has(n.id)) return { text: t('needs you'), tone: 'wait' };
71
72
  const s = stateOf(n.status);
72
73
  if (s === 'failed') return { text: n.status, tone: 'fail' };
73
- if (s === 'live') return { text: 'running', tone: null };
74
- if (s === 'waking') return { text: 'starting', tone: null };
75
- if (s === 'done') return { text: 'done', tone: null };
74
+ if (s === 'live') return { text: t('running'), tone: null };
75
+ if (s === 'waking') return { text: t('starting'), tone: null };
76
+ if (s === 'done') return { text: t('done'), tone: null };
76
77
  // A status this panel has no word for keeps its own.
77
- return { text: String(n.status ?? 'unknown'), tone: null };
78
+ return { text: String(n.status ?? t('unknown')), tone: null };
78
79
  }
79
80
 
80
81
  /** "12 done · 2 running" for several agents, in the order a reader looks for them. */
@@ -82,8 +83,8 @@ export function countsSub(members, waiting) {
82
83
  const need = members.filter((m) => waiting?.has(m.id)).length;
83
84
  const counts = tally(members.filter((m) => !waiting?.has(m.id)));
84
85
  const parts = [];
85
- if (need) parts.push(`${need} need you`);
86
- const word = { running: 'running', starting: 'starting', done: 'done', failed: 'failed', killed: 'killed', stopped: 'stopped', ended: 'ended', stale: 'stale' };
86
+ if (need) parts.push(t('{n} need you', { n: need }));
87
+ const word = { running: t('running'), starting: t('starting'), done: t('done'), failed: t('failed'), killed: t('killed'), stopped: t('stopped'), ended: t('ended'), stale: t('stale') };
87
88
  for (const k of ['failed', 'killed', 'stopped', 'running', 'starting', 'done', 'ended', 'stale']) {
88
89
  if (counts[k]) parts.push(`${counts[k]} ${word[k]}`);
89
90
  }
@@ -124,15 +125,15 @@ export function groupsOf(nodes, grouping) {
124
125
  if (grouping === 'type') return { id: `type:${typeOf(n)}`, label: shownType(n), real: typeOf(n) };
125
126
  if (grouping === 'parent') {
126
127
  const p = byId.get(n.parentId);
127
- if (!p || p.kind === 'session') return { id: 'direct', label: 'Session · direct' };
128
- return { id: `parent:${p.id}`, label: p.kind === 'workflow' ? (p.workflowId ?? p.id) : `under ${shownType(p)}` };
128
+ if (!p || p.kind === 'session') return { id: 'direct', label: t('Session · direct') };
129
+ return { id: `parent:${p.id}`, label: p.kind === 'workflow' ? (p.workflowId ?? p.id) : t('under {type}', { type: shownType(p) }) };
129
130
  }
130
131
  if (grouping === 'none') return { id: 'all', label: null };
131
132
  // `order`: the agents called in one assistant message are a wave, and a wave is a group.
132
- if (grouping === 'order') return n.wave != null ? { id: `wave:${n.wave}`, label: `Wave ${n.wave}` } : { id: 'wave:none', label: 'Not called by the session' };
133
+ if (grouping === 'order') return n.wave != null ? { id: `wave:${n.wave}`, label: t('Wave {n}', { n: n.wave }) } : { id: 'wave:none', label: t('Not called by the session') };
133
134
  // `run`: a workflow run is a group; everything the session started itself is another.
134
135
  if (n.workflow) return { id: `run:${n.workflow}`, label: String(n.workflow) };
135
- return { id: 'direct', label: 'Session · direct' };
136
+ return { id: 'direct', label: t('Session · direct') };
136
137
  };
137
138
  const groups = new Map();
138
139
  for (const n of agents) {
@@ -178,7 +179,7 @@ export function rows(nodes, opts = {}) {
178
179
  id: g.id,
179
180
  label: g.label,
180
181
  folded: isFolded,
181
- sub: `${g.members.length} ${g.members.length === 1 ? 'agent' : 'agents'}${countsSub(g.members, waiting) ? ` · ${countsSub(g.members, waiting)}` : ''}`,
182
+ sub: `${g.members.length === 1 ? t('{n} agent', { n: 1 }) : t('{n} agents', { n: g.members.length })}${countsSub(g.members, waiting) ? ` · ${countsSub(g.members, waiting)}` : ''}`,
182
183
  // Folded, the header carries its members' bars, so a group that is out of sight is not out of time.
183
184
  lanes: isFolded ? lanes(g.members.flatMap(bar)) : [],
184
185
  });
@@ -290,18 +291,18 @@ export function stepRange(key, by) {
290
291
  * @param owned the session's summary when Studio started it, else null
291
292
  */
292
293
  export function waitNote(owned) {
293
- if (!owned) return { measured: false, text: 'Not measured: this session\'s approvals are not seen by Studio.' };
294
- if (owned.gated === false) return { measured: false, text: 'Not measured: this session runs without the approval gate.' };
295
- return { measured: true, text: `Waiting periods since Studio started this session at ${clock(owned.startedAt)}.` };
294
+ if (!owned) return { measured: false, text: t('Not measured: this session\'s approvals are not seen by Studio.') };
295
+ if (owned.gated === false) return { measured: false, text: t('Not measured: this session runs without the approval gate.') };
296
+ return { measured: true, text: t('Waiting periods since Studio started this session at {time}.', { time: clock(owned.startedAt) }) };
296
297
  }
297
298
 
298
299
  /** "14:01 → 14:09 · 8m 12s · 31 tool calls · 57k tokens", with only the parts that were read. */
299
300
  export function factsOf(n, now, fmt) {
300
301
  const parts = [];
301
302
  const end = n.kind === 'agent' ? endOf(n, now) : n.endedAt;
302
- if (n.startedAt != null) parts.push(isLive(n) || end == null ? `${clock(n.startedAt)} → now` : `${clock(n.startedAt)} → ${clock(end)}`);
303
+ if (n.startedAt != null) parts.push(isLive(n) || end == null ? `${clock(n.startedAt)} → ${t('now')}` : `${clock(n.startedAt)} → ${clock(end)}`);
303
304
  if (n.startedAt != null && end != null) parts.push(fmt.duration(end - n.startedAt));
304
- if (n.toolCount != null) parts.push(`${n.toolCount} tool ${n.toolCount === 1 ? 'call' : 'calls'}`);
305
- if (n.tokens != null) parts.push(`${fmt.tokens(n.tokens)} tokens`);
305
+ if (n.toolCount != null) parts.push(n.toolCount === 1 ? t('{n} tool call', { n: 1 }) : t('{n} tool calls', { n: n.toolCount }));
306
+ if (n.tokens != null) parts.push(t('{n} tokens', { n: fmt.tokens(n.tokens) }));
306
307
  return parts.join(' · ');
307
308
  }