codehere 0.5.0-alpha.5 → 0.6.0-alpha.1

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 (34) hide show
  1. package/README.md +18 -8
  2. package/dist/application/use-cases/generate-narration.js +111 -0
  3. package/dist/application/use-cases/generate-thread-title.js +58 -4
  4. package/dist/application/use-cases/narrator-prompt-template.js +158 -0
  5. package/dist/index.js +75 -6
  6. package/dist/infrastructure/cost/budget-config.js +113 -0
  7. package/dist/infrastructure/cost/spend-aggregator.js +147 -0
  8. package/dist/infrastructure/hooks/claude-sdk-hook.js +280 -4
  9. package/dist/infrastructure/hooks/hook-mode.js +71 -0
  10. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +43 -0
  11. package/dist/infrastructure/hooks/pre-prompt-scan.js +122 -0
  12. package/dist/infrastructure/orchestration/aider-adapter.js +28 -0
  13. package/dist/infrastructure/orchestration/artifacts-extractor.js +128 -8
  14. package/dist/infrastructure/orchestration/claude-code-adapter.js +1 -0
  15. package/dist/infrastructure/orchestration/codex-adapter.js +14 -0
  16. package/dist/infrastructure/orchestration/failover-classifier.js +151 -0
  17. package/dist/infrastructure/orchestration/failover-policy.js +106 -0
  18. package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
  19. package/dist/infrastructure/orchestration/opencode-adapter.js +100 -18
  20. package/dist/infrastructure/orchestration/progress-aggregator.js +141 -6
  21. package/dist/infrastructure/orchestration/session-manager.js +117 -10
  22. package/dist/infrastructure/orchestration/session-persistence.js +28 -0
  23. package/dist/infrastructure/protocols/mcp-server-impl.js +564 -13
  24. package/dist/infrastructure/protocols/ui-page.js +2728 -245
  25. package/dist/infrastructure/security/permissions-config.js +234 -0
  26. package/dist/infrastructure/storage/project-thread-mirror.js +379 -24
  27. package/dist/infrastructure/storage/project-thread-reader.js +23 -1
  28. package/dist/infrastructure/storage/project-thread-title-backfill.js +28 -0
  29. package/dist/presentation/cli/commands/budget-command.js +79 -0
  30. package/dist/presentation/cli/commands/narrator-command.js +101 -0
  31. package/dist/presentation/cli/commands/permission-command.js +116 -0
  32. package/dist/presentation/cli/commands/scan-tool-command.js +36 -7
  33. package/dist/utils/typo-guard.js +77 -17
  34. package/package.json +1 -1
@@ -9,7 +9,7 @@
9
9
  * ~/code/codehere-landing/app/globals.css (not guessed): HSL tokens matching
10
10
  * the marketing site, keyframes (shimmer, badge-glow, beam-pulse, fade-up),
11
11
  * and utility classes (.glass-card, .gradient-text-shimmer, .glow-line,
12
- * .hero-beam, .card-lights).
12
+ * .hero-beam).
13
13
  */
14
14
  const SHARED_STYLES = `
15
15
  :root {
@@ -31,16 +31,27 @@ const SHARED_STYLES = `
31
31
  --allow: #28c840;
32
32
  --block: #ff5f57;
33
33
  --warn: #febc2e;
34
+ /* Per-agent identity slots — Pattern 10, Real-Q1 LOCKED 2026-05-06.
35
+ Codehere-only earth-tone palette. NOT vendor-influenced.
36
+ CVD-verified (Brettel-Viénot-Mollon): closest pair distance 45.1
37
+ under deuteranopia, 47.4 under protanopia. Slots are owned;
38
+ agent→slot mapping is metadata. Per docs/design/AGENTIC_UX.md §3. */
39
+ --agent-1: #a04a30; /* deep-coral → claude-code */
40
+ --agent-2: #d4a040; /* amber → codex */
41
+ --agent-3: #5a7a9b; /* slate-blue → aider */
42
+ --agent-4: #7a4a6c; /* plum → gemini */
43
+ --agent-5: #9aa550; /* olive → opencode */
44
+ --agent-unknown: rgba(255,255,255,0.18);
34
45
  --radius: 8px;
35
46
  --sans: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
36
47
  --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
37
48
  }
38
49
 
39
- /* Keyframes */
40
- @keyframes shimmer {
41
- 0% { background-position: -200% center; }
42
- 100% { background-position: 200% center; }
43
- }
50
+ /* Keyframes — Tier 1 polish #4 (May 6 2026): removed shimmer + beam-pulse
51
+ keyframes. Their consumers (.gradient-text-shimmer, .hero-beam) were
52
+ stripped from the dashboard surface per Open-Agents production-grade
53
+ reference + PRD §8.2 permanent-anti-patterns rule. fade-up + badge-glow
54
+ remain (still used by session card animations + running-state pill). */
44
55
  @keyframes badge-glow {
45
56
  0%, 100% { box-shadow: 0 0 0 rgba(40,200,64,0); }
46
57
  50% { box-shadow: 0 0 10px rgba(40,200,64,0.5); }
@@ -49,24 +60,6 @@ const SHARED_STYLES = `
49
60
  from { opacity: 0; transform: translateY(12px); filter: blur(2px); }
50
61
  to { opacity: 1; transform: translateY(0); filter: blur(0); }
51
62
  }
52
- @keyframes beam-pulse {
53
- 0%, 100% { opacity: 0.35; transform: translateX(-50%) scale(1); }
54
- 50% { opacity: 0.55; transform: translateX(-50%) scale(1.04); }
55
- }
56
-
57
- /* Text gradient (landing's hero motif) */
58
- .gradient-text-shimmer {
59
- background: linear-gradient(
60
- 90deg,
61
- #ff9a76 0%, #f5c6b0 20%, #ff9a76 35%,
62
- #4a90e2 50%, #8ab8ee 65%, #4a90e2 80%, #ff9a76 100%
63
- );
64
- background-size: 200% auto;
65
- -webkit-background-clip: text;
66
- background-clip: text;
67
- -webkit-text-fill-color: transparent;
68
- animation: shimmer 6s linear infinite;
69
- }
70
63
 
71
64
  /* Glass card — landing's .glass-card with hover lift */
72
65
  .glass-card {
@@ -89,43 +82,224 @@ const SHARED_STYLES = `
89
82
  margin: 18px 0;
90
83
  }
91
84
 
92
- /* Per-card traffic lights — mini terminal frame */
93
- .card-lights {
85
+ /* ── Per-agent identity badge — Pattern 10 ─────────────────────────────────
86
+ Foundation primitive for multi-vendor visibility. 18×18 squircle with
87
+ 2-letter initials, vendor color from --agent-N slot. 6px dot variant
88
+ for sidebar presence. Per docs/design/AGENTIC_UX.md §3 + Pattern 10.
89
+ Real-Q1 LOCKED: codehere-only palette; no vendor brand colors. */
90
+ /* Pattern 10 — codehere-styled badge frame. 22×22 squircle, codehere's
91
+ slot palette as background, geometric mark inside via currentColor.
92
+ Frame is codehere's identity; mark is a distinctive geometry per agent. */
93
+ .agent-badge {
94
94
  display: inline-flex;
95
- gap: 5px;
96
- margin-right: 10px;
95
+ align-items: center;
96
+ justify-content: center;
97
+ width: 22px; height: 22px;
98
+ border-radius: 5px;
99
+ overflow: hidden;
100
+ color: rgba(255,255,255,0.95);
101
+ flex: 0 0 22px;
102
+ user-select: none;
103
+ background: var(--agent-unknown);
104
+ }
105
+ .agent-badge svg { display: block; width: 14px; height: 14px; }
106
+ .agent-badge[data-slot="0"] { background: var(--accent); color: rgba(0,0,0,0.85); }
107
+ .agent-badge[data-slot="1"] { background: var(--agent-1); }
108
+ .agent-badge[data-slot="2"] { background: var(--agent-2); color: rgba(0,0,0,0.85); }
109
+ .agent-badge[data-slot="3"] { background: var(--agent-3); }
110
+ .agent-badge[data-slot="4"] { background: var(--agent-4); }
111
+ .agent-badge[data-slot="5"] { background: var(--agent-5); color: rgba(0,0,0,0.85); }
112
+ .agent-badge[data-slot=""],
113
+ .agent-badge[data-slot="unknown"],
114
+ .agent-badge:not([data-slot]) { background: var(--agent-unknown); color: var(--fg-50); }
115
+
116
+ /* 6px presence-dot variant for sidebar — same slot tokens, no text */
117
+ .agent-presence-dot {
118
+ display: inline-block;
119
+ width: 6px; height: 6px;
120
+ border-radius: 50%;
121
+ flex: 0 0 6px;
97
122
  vertical-align: middle;
98
123
  }
99
- .card-lights span {
100
- width: 9px; height: 9px; border-radius: 50%;
101
- display: inline-block;
124
+ .agent-presence-dot[data-slot="1"] { background: var(--agent-1); }
125
+ .agent-presence-dot[data-slot="2"] { background: var(--agent-2); }
126
+ .agent-presence-dot[data-slot="3"] { background: var(--agent-3); }
127
+ .agent-presence-dot[data-slot="4"] { background: var(--agent-4); }
128
+ .agent-presence-dot[data-slot="5"] { background: var(--agent-5); }
129
+ .agent-presence-dot[data-slot=""],
130
+ .agent-presence-dot[data-slot="unknown"],
131
+ .agent-presence-dot:not([data-slot]) { background: var(--agent-unknown); }
132
+
133
+ /* Pattern 4 — sidebar presence-stack. Up to 3 vendor dots per project,
134
+ 6px each, overlapping by 3px so a stack of 3 is ~12px wide. Most-
135
+ recent agent renders ON TOP (last in DOM = highest z via flex order
136
+ reverse). Empty stack reserves the same width to prevent layout
137
+ shift between projects with vs without recent activity. */
138
+ .presence-stack {
139
+ display: inline-flex;
140
+ flex-direction: row-reverse; /* most-recent on top */
141
+ align-items: center;
142
+ margin-right: 6px;
143
+ min-width: 12px;
144
+ flex-shrink: 0;
145
+ }
146
+ .presence-stack .agent-presence-dot {
147
+ margin-left: -3px; /* overlap */
148
+ border: 1.5px solid var(--bg-1, #0a0a0a); /* halo against sidebar bg */
149
+ box-sizing: content-box;
150
+ }
151
+ .presence-stack .agent-presence-dot:first-child {
152
+ /* No left-overlap on the leftmost (= oldest) dot — its left edge sets
153
+ the stack's start. Pre-fix it was clipped by the negative margin. */
154
+ margin-left: 0;
155
+ }
156
+ .presence-stack-empty { width: 12px; }
157
+
158
+ /* Pattern 13 — micro-activity verb (rotating whimsy / tool-prefixed verb).
159
+ Sits below the agent byline on a running session; tells the user the
160
+ agent is actively working. Hidden when status != running. */
161
+ .micro-activity {
162
+ font-family: var(--mono); font-size: 11px;
163
+ color: var(--fg-50);
164
+ margin-top: 4px;
165
+ opacity: 0;
166
+ transition: opacity 200ms ease, color 200ms ease;
167
+ min-height: 14px;
168
+ }
169
+ .micro-activity.live { opacity: 1; }
170
+ .micro-activity.tool { color: var(--accent); font-weight: 500; }
171
+
172
+ /* Pattern 2 — tool-use trace verb-color semantic. The first word of each
173
+ step's label is the tool kind; the rule colors it per semantic meaning:
174
+ read/grep/glob = muted (no mutation), write/edit = green (file change),
175
+ bash = brand peach (execution = highest-trust action). Same target
176
+ text stays muted so the verb pops at glance. */
177
+ .steps-mini .step .verb {
178
+ font-weight: 500;
179
+ margin-right: 4px;
180
+ text-transform: lowercase;
181
+ letter-spacing: 0.02em;
182
+ }
183
+ .steps-mini .step[data-tool="read"] .verb,
184
+ .steps-mini .step[data-tool="grep"] .verb,
185
+ .steps-mini .step[data-tool="glob"] .verb,
186
+ .steps-mini .step[data-tool="list"] .verb { color: var(--fg-50); }
187
+ .steps-mini .step[data-tool="write"] .verb,
188
+ .steps-mini .step[data-tool="edit"] .verb { color: var(--allow); }
189
+ .steps-mini .step[data-tool="bash"] .verb { color: var(--accent); }
190
+
191
+ /* P22 / Track B #8 — bash output preview under step. Last 4 lines,
192
+ muted body so the step label stays primary. ANSI is stripped at
193
+ the aggregator. */
194
+ .steps-mini .step .step-output {
195
+ margin: 4px 0 6px 22px;
196
+ padding: 6px 8px;
197
+ border-left: 2px solid var(--border-soft);
198
+ color: var(--fg-50);
199
+ font-family: var(--mono); font-size: 10.5px;
200
+ line-height: 1.45;
201
+ white-space: pre-wrap; word-break: break-word;
202
+ background: rgba(0,0,0,0.15);
203
+ border-radius: 0 4px 4px 0;
204
+ max-height: 80px; overflow: hidden;
205
+ }
206
+ .steps-mini .step.failed .step-output {
207
+ border-left-color: rgba(255,95,87,0.4);
208
+ color: var(--block);
209
+ }
210
+ .steps-mini .step[data-tool="fetch"] .verb,
211
+ .steps-mini .step[data-tool="search"] .verb { color: var(--fg-50); }
212
+ .steps-mini .step[data-tool="task"] .verb { color: var(--accent); }
213
+ /* Pattern 15 — subagent dispatch indicator. Task-kind steps render as
214
+ a small panel with an indented left bar so the dispatch reads as
215
+ "delegating to a sub-agent" rather than a normal step. The label
216
+ keeps the verb-color but the row gets visual weight to signal a
217
+ boundary crossing (parent → child execution context). */
218
+ .steps-mini .step[data-tool="task"] {
219
+ border-left: 2px solid var(--accent);
220
+ background: var(--accent-wash);
221
+ padding: 4px 8px;
222
+ margin: 2px 0 2px 4px;
223
+ border-radius: 0 4px 4px 0;
224
+ align-items: center;
225
+ }
226
+ .steps-mini .step[data-tool="task"] .verb::after {
227
+ content: ' ⤳';
228
+ margin-left: 4px;
229
+ color: var(--fg-50);
230
+ font-weight: 400;
231
+ }
232
+ .steps-mini .step .target {
233
+ color: var(--fg-50);
234
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
235
+ max-width: 280px;
102
236
  }
103
- .card-lights .r { background: var(--block); }
104
- .card-lights .y { background: var(--warn); }
105
- .card-lights .g { background: var(--allow); }
106
237
 
107
- /* Ambient hero beam (subtle) */
108
- .hero-beam {
109
- position: absolute;
110
- inset: 0;
111
- overflow: hidden;
112
- pointer-events: none;
113
- z-index: 0;
114
- }
115
- .hero-beam::before {
116
- content: '';
117
- position: absolute;
118
- top: -20%;
119
- left: 50%;
120
- transform: translateX(-50%);
121
- width: 75%;
122
- height: 55%;
123
- background: radial-gradient(ellipse at center,
124
- rgba(255,154,118,0.08) 0%,
125
- rgba(74,144,226,0.04) 40%,
126
- transparent 70%);
127
- filter: blur(50px);
128
- animation: beam-pulse 12s ease-in-out infinite;
238
+ /* Pattern 9 — recovery affordance panel. Renders below the status pill
239
+ when a session ends in a recoverable failure state. Cause-specific
240
+ border color (yellow for failover-eligible, red for needs-human-action).
241
+ Per docs/design/AGENTIC_UX.md Pattern 9. */
242
+ .recovery-affordance {
243
+ margin-top: 10px;
244
+ padding: 10px 12px;
245
+ border: 1px dashed var(--warn);
246
+ border-radius: 8px;
247
+ background: rgba(254,188,46,0.04);
248
+ font-family: var(--sans); font-size: 12px;
249
+ }
250
+ .recovery-affordance[data-cause="auth_invalid"],
251
+ .recovery-affordance[data-cause="auth_missing"],
252
+ .recovery-affordance[data-cause="crashed"] { border-color: var(--block); background: rgba(255,95,87,0.04); }
253
+ .recovery-affordance .ra-msg { color: var(--fg); margin: 0 0 8px; line-height: 1.45; }
254
+ .recovery-affordance .ra-msg b { color: var(--fg); font-weight: 600; }
255
+ .recovery-affordance .ra-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
256
+ .recovery-affordance button.ra-primary {
257
+ display: inline-flex; align-items: center; gap: 6px;
258
+ padding: 5px 12px;
259
+ border: 1px solid var(--accent);
260
+ background: var(--accent-wash);
261
+ border-radius: 6px;
262
+ font-family: var(--mono); font-size: 11px; color: var(--fg);
263
+ cursor: pointer;
264
+ }
265
+ .recovery-affordance button.ra-primary:hover { background: rgba(255,154,118,0.16); }
266
+ .recovery-affordance button.ra-primary[disabled] { opacity: 0.55; cursor: progress; }
267
+ .recovery-affordance button.ra-secondary {
268
+ background: transparent; border: none;
269
+ color: var(--fg-50); font-family: var(--mono); font-size: 11px;
270
+ cursor: pointer; padding: 5px 8px;
271
+ }
272
+ .recovery-affordance button.ra-secondary:hover { color: var(--fg); }
273
+
274
+ /* Pattern 1 — tool-progress live chip. At-a-glance count + dot-pattern
275
+ progress + elapsed time. Lives above the micro-activity verb so the
276
+ eye reads: chip (numbers) → verb (in-progress action) → step trace. */
277
+ .tool-progress-chip {
278
+ display: inline-flex; align-items: center; gap: 6px;
279
+ font-family: var(--mono); font-size: 11px; color: var(--fg-50);
280
+ padding: 3px 9px;
281
+ background: rgba(255,255,255,0.03);
282
+ border: 1px solid var(--border-soft);
283
+ border-radius: 999px;
284
+ margin-top: 4px;
285
+ transition: opacity 180ms ease, border-color 180ms ease;
286
+ }
287
+ .tool-progress-chip[data-state="hidden"] { display: none; }
288
+ .tool-progress-chip[data-state="running"] {
289
+ border-color: var(--accent);
290
+ animation: tp-pulse 2.4s ease-in-out infinite;
291
+ }
292
+ .tool-progress-chip[data-state="paused"] { border-color: var(--warn); }
293
+ .tool-progress-chip[data-state="complete"] { border-color: var(--allow); }
294
+ .tool-progress-chip[data-state="failed"] { border-color: var(--block); color: var(--block); }
295
+ .tool-progress-chip .tp-sep { color: var(--fg-20); user-select: none; }
296
+ .tool-progress-chip .tp-dots { letter-spacing: 0.06em; color: var(--accent); }
297
+ .tool-progress-chip[data-state="failed"] .tp-dots { color: var(--block); }
298
+ .tool-progress-chip[data-state="complete"] .tp-dots { color: var(--allow); }
299
+ .tool-progress-chip .tp-elapsed { color: var(--fg-40); }
300
+ @keyframes tp-pulse {
301
+ 0%, 100% { border-color: var(--border-soft); }
302
+ 50% { border-color: var(--accent); }
129
303
  }
130
304
 
131
305
  /* Running-state pill animation */
@@ -202,6 +376,9 @@ const SHARED_STYLES = `
202
376
  .status-banner .stat { display: inline-flex; align-items: center; gap: 6px; }
203
377
  .status-banner .stat .label { color: var(--fg-40); text-transform: uppercase; letter-spacing: 0.1em; font-size: 10px; }
204
378
  .status-banner .stat .value { color: var(--fg); font-weight: 600; }
379
+ /* Pattern 8 ride-along revert: daemon-live is actionable signal ("daemon
380
+ is alive RIGHT NOW") not passive status. f830183 demoted this to fg
381
+ neutral; restoring to green so the heartbeat reads at glance. */
205
382
  .status-banner .stat .value.live { color: var(--allow); }
206
383
  .status-banner .stat.blocked .value.nonzero { color: var(--block); }
207
384
  .status-banner .sep { color: var(--fg-20); user-select: none; }
@@ -285,21 +462,38 @@ const SHARED_STYLES = `
285
462
  gap: 12px;
286
463
  color: var(--fg-70);
287
464
  }
465
+ .final-report .fr-file .file-icon {
466
+ display: inline-block; flex: 0 0 12px;
467
+ width: 12px; height: 12px; opacity: 0.55;
468
+ margin-right: 6px;
469
+ }
470
+ .final-report .fr-file[data-mutation="write"] .file-icon { color: var(--allow); opacity: 1; }
471
+ .final-report .fr-file[data-mutation="edit"] .file-icon { color: var(--allow); opacity: 1; }
472
+ .final-report .fr-file[data-mutation="delete"] .file-icon { color: var(--block); opacity: 1; }
288
473
  .final-report .fr-file .path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
289
474
  .final-report .fr-file .lines { color: var(--fg-50); white-space: nowrap; }
290
475
  .final-report .fr-file .lines .add { color: var(--allow); }
291
476
  .final-report .fr-file .lines .rem { color: var(--block); }
292
- .final-report .fr-file .trust {
477
+ /* Pattern 11 · Trust score badge — standalone, reusable. Compact pill
478
+ "<score> · <grade>" with semantic background. Drop-in replacement
479
+ for the inline trust rendering previously embedded in the file
480
+ chip (P7); now any surface that has a trust score can render the
481
+ same pill via trustBadgeHTML(score). Per AGENTIC_UX.md Pattern 11.
482
+ Color split: A/B (≥75) = ok, C/D (40-74) = warn, F (<40) = block.
483
+ This corrects the prior P7 inline rendering which used a 60-cutoff
484
+ for warn (putting D-grade in red instead of yellow per spec). */
485
+ .trust-badge {
293
486
  display: inline-block;
294
487
  padding: 1px 7px; border-radius: 999px;
295
488
  font-size: 10px; font-weight: 600;
296
489
  border: 1px solid transparent;
297
490
  min-width: 28px; text-align: center;
491
+ font-family: var(--mono);
298
492
  }
299
- .final-report .fr-file .trust.good { color: var(--allow); background: rgba(40,200,64,0.08); border-color: rgba(40,200,64,0.22); }
300
- .final-report .fr-file .trust.warn { color: var(--warn); background: rgba(254,188,46,0.08); border-color: rgba(254,188,46,0.25); }
301
- .final-report .fr-file .trust.bad { color: var(--block); background: rgba(255,95,87,0.08); border-color: rgba(255,95,87,0.25); }
302
- .final-report .fr-file .trust.none { color: var(--fg-40); background: transparent; border-color: var(--border-soft); }
493
+ .trust-badge.good { color: var(--allow); background: rgba(40,200,64,0.08); border-color: rgba(40,200,64,0.22); }
494
+ .trust-badge.warn { color: var(--warn); background: rgba(254,188,46,0.08); border-color: rgba(254,188,46,0.25); }
495
+ .trust-badge.bad { color: var(--block); background: rgba(255,95,87,0.08); border-color: rgba(255,95,87,0.25); }
496
+ .trust-badge.none { color: var(--fg-40); background: transparent; border-color: var(--border-soft); }
303
497
  .final-report .fr-tests, .final-report .fr-none {
304
498
  margin-top: 8px; padding-top: 8px;
305
499
  border-top: 1px solid var(--border-soft);
@@ -333,6 +527,427 @@ const SHARED_STYLES = `
333
527
  }
334
528
  .floating-nav a.nav-link:hover { color: var(--fg); }
335
529
  .floating-nav a.nav-link.active { color: var(--fg); }
530
+ /* Wiring to codehere.uk — external link styled as the same nav-link
531
+ family but with an opacity tell so the user knows it leaves the app. */
532
+ .floating-nav a.nav-external { opacity: 0.65; }
533
+ .floating-nav a.nav-external:hover { opacity: 1; color: var(--accent); }
534
+
535
+ /* Pattern 20 — hook-mode indicator pill in the floating nav. Surfaces
536
+ codehere's safety posture (block / advisory / log-only / off) so the
537
+ user can tell at a glance what the hook is currently doing. Read-only
538
+ for now — clicking opens nothing. Mode switching ships separately
539
+ through codehere hook install/uninstall so the safety surface
540
+ re-validates instead of mutating from the UI. Per AGENTIC_UX.md
541
+ Pattern 20. */
542
+ .mode-indicator {
543
+ display: inline-flex; align-items: center; gap: 6px;
544
+ font-family: var(--mono); font-size: 11px; font-weight: 500;
545
+ padding: 3px 10px; border-radius: 999px;
546
+ border: 1px solid var(--border);
547
+ background: rgba(255,255,255,0.03);
548
+ color: var(--fg-50);
549
+ cursor: help;
550
+ user-select: none;
551
+ }
552
+ .mode-indicator .mi-dot {
553
+ width: 7px; height: 7px; border-radius: 50%;
554
+ background: currentColor;
555
+ flex-shrink: 0;
556
+ }
557
+ .mode-indicator[data-mode="block"] { color: var(--block); border-color: rgba(255,95,87,0.35); background: rgba(255,95,87,0.06); }
558
+ .mode-indicator[data-mode="advisory"] { color: var(--warn); border-color: rgba(254,188,46,0.35); background: rgba(254,188,46,0.06); }
559
+ .mode-indicator[data-mode="log-only"] { color: var(--fg-50); border-color: var(--border); }
560
+ .mode-indicator[data-mode="off"] { color: var(--fg-30); border-style: dashed; }
561
+ /* Loading state — neutral pill until first /api/hook/mode resolves. */
562
+ .mode-indicator[data-mode=""] { opacity: 0.4; }
563
+ `;
564
+ // Shared client-JS for the per-agent identity badge. Interpolated into
565
+ // every page's IIFE (TASK_HTML, THREAD_HTML, HISTORY_HTML if it gains
566
+ // agent rendering). Single source of truth so brand SVGs stay consistent
567
+ // across pages — no copy-paste drift between dashboard / threads / history.
568
+ const AGENT_BADGE_JS = `
569
+ // Per-agent label shown next to badge / in tooltips / in dropdowns.
570
+ function agentLabel(id) {
571
+ var labels = { 'claude-code': 'Claude Code', 'codex': 'Codex', 'aider': 'Aider', 'gemini': 'Gemini', 'opencode': 'OpenCode', 'conductor': 'codehere · Conductor', 'kiro': 'Kiro' };
572
+ return labels[id] || id || 'agent';
573
+ }
574
+
575
+ // Distinctive geometric marks per adapter — codehere-authored (not vendor
576
+ // brand reproductions). Each shape evokes its agent's domain so users
577
+ // glance-identify which agent ran a task without me reproducing brand SVG
578
+ // path data verbatim. currentColor lets the badge's contrast handling
579
+ // (light text on dark slots, dark text on slots 2 + 5) carry through.
580
+ var AGENT_ICONS = {
581
+ // Claude — 8-spoke burst (spark / generation)
582
+ 'claude-code':
583
+ '<svg viewBox="0 0 24 24" aria-hidden="true"><g stroke="currentColor" stroke-width="1.8" stroke-linecap="round" fill="none">' +
584
+ '<line x1="12" y1="4" x2="12" y2="20"/><line x1="4" y1="12" x2="20" y2="12"/>' +
585
+ '<line x1="6.3" y1="6.3" x2="17.7" y2="17.7"/><line x1="17.7" y1="6.3" x2="6.3" y2="17.7"/></g></svg>',
586
+ // Codex — concentric ring (orb)
587
+ 'codex':
588
+ '<svg viewBox="0 0 24 24" aria-hidden="true">' +
589
+ '<circle cx="12" cy="12" r="7" stroke="currentColor" stroke-width="1.8" fill="none"/>' +
590
+ '<circle cx="12" cy="12" r="2.2" fill="currentColor"/></svg>',
591
+ // Aider — equilateral triangle (forward / pair)
592
+ 'aider':
593
+ '<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor">' +
594
+ '<path d="M12 4 L20.5 19 L3.5 19 Z"/></svg>',
595
+ // Gemini — 4-point star / diamond
596
+ 'gemini':
597
+ '<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor">' +
598
+ '<path d="M12 2 L14.2 9.8 L22 12 L14.2 14.2 L12 22 L9.8 14.2 L2 12 L9.8 9.8 Z"/></svg>',
599
+ // OpenCode — angle brackets
600
+ 'opencode':
601
+ '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">' +
602
+ '<path d="M9 6 L3 12 L9 18 M15 6 L21 12 L15 18"/></svg>',
603
+ // Conductor — codehere router; lightning shape on brand peach
604
+ 'conductor':
605
+ '<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor">' +
606
+ '<path d="M13 2 L4 14 L11 14 L9 22 L18 10 L11 10 L13 2 Z"/></svg>'
607
+ };
608
+ var UNKNOWN_AGENT_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="6"/></svg>';
609
+
610
+ var AGENT_SLOTS = {
611
+ 'conductor': '0',
612
+ 'claude-code': '1',
613
+ 'codex': '2',
614
+ 'aider': '3',
615
+ 'gemini': '4',
616
+ 'opencode': '5'
617
+ };
618
+ function agentSlot(id) { return AGENT_SLOTS[id] || 'unknown'; }
619
+ function agentIconSVG(id) { return AGENT_ICONS[id] || UNKNOWN_AGENT_ICON; }
620
+ function escapeAgentTitle(s) {
621
+ return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
622
+ return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
623
+ });
624
+ }
625
+ function agentBadgeHTML(id) {
626
+ return '<span class="agent-badge" data-slot="' + agentSlot(id) + '" title="' + escapeAgentTitle(agentLabel(id)) + '">' + agentIconSVG(id) + '</span>';
627
+ }
628
+ function agentPresenceDotHTML(id) {
629
+ return '<span class="agent-presence-dot" data-slot="' + agentSlot(id) + '" title="' + escapeAgentTitle(agentLabel(id)) + '"></span>';
630
+ }
631
+
632
+ // Pattern 13 — micro-activity verb rotation. Codehere-authored vocabulary
633
+ // describing the WORK ("Reading the code…") not the WORKER ("I'm thinking").
634
+ // Same pool fires for every adapter — cross-vendor coherence per
635
+ // claim_market_position v4. Ban list (cute mascot, magic/cooking
636
+ // metaphors, anatomy theatrics, overly technical, first-person, speed
637
+ // promises) lives in docs/design/AGENTIC_UX.md Pattern 13.
638
+ var MICRO_VERBS = [
639
+ 'Working through it…', 'Pulling the thread…', 'Connecting the dots…',
640
+ 'Taking notes…', 'Sorting it out…', 'Mapping things…',
641
+ 'Tracking it down…', 'Putting it together…', 'Checking the work…',
642
+ 'Lining things up…', 'Reading the code…', 'Tracing the path…',
643
+ 'Piecing it together…', 'Walking the repo…'
644
+ ];
645
+
646
+ // Pick a phrase NOT recently shown so the user doesn't see the same
647
+ // word twice within a 30s window (pool of 14, max 6 swaps per 30s at
648
+ // 5s cadence; keeping last 5 in a queue is sufficient).
649
+ function pickWhimsyVerb(recentQueue) {
650
+ var pool = [];
651
+ for (var i = 0; i < MICRO_VERBS.length; i++) {
652
+ if (recentQueue.indexOf(MICRO_VERBS[i]) === -1) pool.push(MICRO_VERBS[i]);
653
+ }
654
+ if (pool.length === 0) pool = MICRO_VERBS;
655
+ return pool[Math.floor(Math.random() * pool.length)];
656
+ }
657
+
658
+ // Start a whimsy rotator on a target element. Returns a stop() function
659
+ // the caller invokes when the session exits or transitions to a tool-
660
+ // prefixed state. Cadence: 4-6s random; ensures the verb feels alive
661
+ // without strobing.
662
+ // Pattern 2 — derive a tool-kind tag from a step label so the renderer
663
+ // can apply the verb-color semantic. Adapters emit free-form labels
664
+ // ("Reading lib/foo.ts" / "Running bun test" / "grep auth patterns"),
665
+ // so we match the leading word against known tool families.
666
+ function inferToolKind(label) {
667
+ var s = String(label || '').trim().toLowerCase();
668
+ if (!s) return 'unknown';
669
+ var first = s.split(' ')[0].replace(/[:.,]+$/, '');
670
+ if (first === 'read' || first === 'reading' || first === 'cat') return 'read';
671
+ if (first === 'write' || first === 'writing' || first === 'create') return 'write';
672
+ if (first === 'edit' || first === 'editing' || first === 'multiedit' || first === 'patch') return 'edit';
673
+ if (first === 'bash' || first === 'run' || first === 'running' || first === 'shell' || first === 'exec') return 'bash';
674
+ if (first === 'grep' || first === 'searching' || first === 'search') return 'grep';
675
+ if (first === 'glob' || first === 'find') return 'glob';
676
+ if (first === 'ls' || first === 'list' || first === 'listing') return 'list';
677
+ if (first === 'fetch' || first === 'fetching' || first === 'webfetch' || first === 'curl' || first === 'wget') return 'fetch';
678
+ if (first === 'websearch') return 'search';
679
+ if (first === 'task' || first === 'dispatch' || first === 'dispatching' || first === 'subagent') return 'task';
680
+ return 'unknown';
681
+ }
682
+ // Split a step label into (verb, target) for verb-color rendering. The
683
+ // verb is the first word; everything after is the target. Falls back to
684
+ // (label, '') when the label is a single word.
685
+ function splitStepLabel(label) {
686
+ var s = String(label || '').trim();
687
+ if (!s) return { verb: '', target: '' };
688
+ var space = s.indexOf(' ');
689
+ if (space === -1) return { verb: s, target: '' };
690
+ return { verb: s.slice(0, space), target: s.slice(space + 1) };
691
+ }
692
+
693
+ // Pattern 14 — per-tool target truncation. Smart truncation beats CSS
694
+ // ellipsis because it preserves the meaningful chunk per tool kind: a
695
+ // file path's last 2 segments stay readable, a bash command keeps its
696
+ // binary + last arg, a URL keeps its domain.
697
+ function truncateTarget(toolKind, target) {
698
+ var s = String(target == null ? '' : target).trim();
699
+ if (!s) return '';
700
+
701
+ if (toolKind === 'read' || toolKind === 'write' || toolKind === 'edit' || toolKind === 'list') {
702
+ // File path: show last 2 segments, prefix with "…/" when shortened.
703
+ var parts = s.split('/').filter(function(p) { return p.length > 0; });
704
+ if (parts.length <= 2) return s;
705
+ return '…/' + parts.slice(-2).join('/');
706
+ }
707
+
708
+ if (toolKind === 'bash') {
709
+ // Bash command: keep binary + final arg if total > 40 chars.
710
+ if (s.length <= 40) return s;
711
+ var spaceIdx = s.indexOf(' ');
712
+ if (spaceIdx === -1) return s.slice(0, 37) + '…';
713
+ var binary = s.slice(0, spaceIdx);
714
+ var lastSpace = s.lastIndexOf(' ');
715
+ var lastArg = s.slice(lastSpace + 1);
716
+ // Compose: "binary … lastArg" within budget. Cap each piece at 18.
717
+ var budget = 40;
718
+ var b = binary.length > 18 ? binary.slice(0, 17) + '…' : binary;
719
+ var l = lastArg.length > 18 ? '…' + lastArg.slice(-17) : lastArg;
720
+ var composed = b + ' … ' + l;
721
+ if (composed.length <= budget) return composed;
722
+ // Last resort: hard mid-truncate
723
+ return s.slice(0, 19) + '…' + s.slice(-19);
724
+ }
725
+
726
+ if (toolKind === 'fetch') {
727
+ // URL: domain + first path segment. Strip protocol; truncate the
728
+ // tail at 30 chars after the domain. (String-strip instead of regex
729
+ // because backslashes get stripped by the outer template literal.)
730
+ var stripped = s;
731
+ if (stripped.indexOf('https://') === 0) stripped = stripped.slice(8);
732
+ else if (stripped.indexOf('http://') === 0) stripped = stripped.slice(7);
733
+ var slash = stripped.indexOf('/');
734
+ if (slash === -1) return stripped;
735
+ var domain = stripped.slice(0, slash);
736
+ var path = stripped.slice(slash);
737
+ if (path.length > 30) path = path.slice(0, 27) + '…';
738
+ return domain + path;
739
+ }
740
+
741
+ if (toolKind === 'grep' || toolKind === 'search' || toolKind === 'glob') {
742
+ // Search pattern: 30 chars verbatim then ellipsis. Patterns are
743
+ // user-meaningful even when truncated mid-token; preserve as-is.
744
+ if (s.length <= 30) return s;
745
+ return s.slice(0, 29) + '…';
746
+ }
747
+
748
+ // Unknown tool family: hard 40-char cap with ellipsis at the end.
749
+ if (s.length <= 40) return s;
750
+ return s.slice(0, 39) + '…';
751
+ }
752
+
753
+ // Pattern 20 — hook-mode indicator wiring. Hits /api/hook/mode on
754
+ // load + every 30s; renders a colored pill in the floating nav with
755
+ // mode-specific color, dot, and tooltip. Self-installing — runs once
756
+ // per page if a #mode-indicator element exists. Failures degrade
757
+ // silently (mode pill stays in the loading state).
758
+ var MODE_LABELS = {
759
+ // Plain-English labels (claim_user_persona) BUT honest about scope —
760
+ // codehere is a SECOND screen, not the primary safety system. Prior
761
+ // labels ("Safety on") implied codehere IS the safety system. It's
762
+ // not — the agent vendor (Claude / Codex / etc.) has its own safety
763
+ // stack. Codehere's job is defense-in-depth + the cross-vendor
764
+ // audit trail. Labels now say "Codehere · X" so the scope is clear.
765
+ 'block': { label: 'Codehere · blocking', tip: 'Codehere screens every tool call against its pattern bank and blocks risky ones. Runs ON TOP of the agent vendor own safety; second screen, not primary.' },
766
+ 'advisory': { label: 'Codehere · warn', tip: 'Codehere warns about risky tool calls but does not block. Defense-in-depth on top of the agent vendor own safety.' },
767
+ 'log-only': { label: 'Codehere · recording',tip: 'Codehere records every tool call to the local audit log but does not intervene. Useful for cross-vendor audit trail without blocking.' },
768
+ 'off': { label: 'Codehere · off', tip: 'No codehere guardrail. The agent vendor own safety is still active. Run codehere hook install to add codehere extra screen.' },
769
+ };
770
+ function paintModeIndicator(mode) {
771
+ var el = document.getElementById('mode-indicator');
772
+ if (!el) return;
773
+ var spec = MODE_LABELS[mode] || MODE_LABELS['off'];
774
+ el.dataset.mode = mode;
775
+ el.title = spec.tip;
776
+ var labelEl = el.querySelector('.mi-label');
777
+ if (labelEl) labelEl.textContent = spec.label;
778
+ }
779
+ function pollHookMode() {
780
+ fetch('/api/hook/mode')
781
+ .then(function(r) { return r.ok ? r.json() : null; })
782
+ .then(function(data) { if (data && data.mode) paintModeIndicator(data.mode); })
783
+ .catch(function() { /* leave loading state — next poll may succeed */ });
784
+ }
785
+ if (typeof document !== 'undefined' && document.getElementById('mode-indicator')) {
786
+ pollHookMode();
787
+ setInterval(pollHookMode, 30000);
788
+ }
789
+
790
+ // Pattern 11 — trust score badge HTML. Returns a pill of shape
791
+ // "<score> · <grade>" with semantic class. Thresholds match
792
+ // trust-scorer.ts; color split per AGENTIC_UX.md Pattern 11
793
+ // (A/B=ok ≥75, C/D=warn ≥40, F=block <40, n/a=muted).
794
+ // Caller decides container; this just emits the standalone pill.
795
+ function trustBadgeHTML(score) {
796
+ if (typeof score !== 'number' || isNaN(score)) {
797
+ return '<span class="trust-badge none" title="Trust score">—</span>';
798
+ }
799
+ var s = Math.max(0, Math.min(100, Math.round(score)));
800
+ var grade =
801
+ s >= 90 ? 'A' :
802
+ s >= 75 ? 'B' :
803
+ s >= 60 ? 'C' :
804
+ s >= 40 ? 'D' : 'F';
805
+ var cls =
806
+ s >= 75 ? 'good' :
807
+ s >= 40 ? 'warn' : 'bad';
808
+ return '<span class="trust-badge ' + cls + '" title="Trust score · ' + grade + '">' +
809
+ s + ' · ' + grade +
810
+ '</span>';
811
+ }
812
+
813
+ // Pattern 3 — cwd tail. Returns the basename of an absolute path so the
814
+ // session card byline reads "claude-code · codehere · 09:01" instead of
815
+ // a 60-char absolute path that wraps. Strips a trailing slash, then
816
+ // returns the last non-empty segment. Empty / weird input: empty string.
817
+ function cwdTail(cwd) {
818
+ var s = String(cwd == null ? '' : cwd).trim();
819
+ if (!s) return '';
820
+ while (s.length > 1 && s.charAt(s.length - 1) === '/') s = s.slice(0, -1);
821
+ var slash = s.lastIndexOf('/');
822
+ if (slash === -1) return s;
823
+ return s.slice(slash + 1);
824
+ }
825
+
826
+ // Pattern 3 — file-edit count. Walks steps, collects unique target paths
827
+ // for file-mutating tools (write/edit), returns size of the set. Read-
828
+ // only tools (read/grep/list/glob) intentionally excluded — "files
829
+ // touched" in the footer means files the agent CHANGED, not files it
830
+ // looked at, which matches the user's mental model.
831
+ function countTouchedFiles(steps) {
832
+ if (!Array.isArray(steps) || steps.length === 0) return 0;
833
+ var seen = Object.create(null);
834
+ var n = 0;
835
+ for (var i = 0; i < steps.length; i++) {
836
+ var st = steps[i];
837
+ if (!st) continue;
838
+ var kind = inferToolKind(st.tool || '');
839
+ if (kind !== 'write' && kind !== 'edit') continue;
840
+ var parts = splitStepLabel(String(st.label || ''));
841
+ var t = parts.target;
842
+ if (!t || seen[t]) continue;
843
+ seen[t] = true;
844
+ n++;
845
+ }
846
+ return n;
847
+ }
848
+
849
+ // Pattern 9 — failover chain mirrors failover-policy.ts DEFAULT_FAILOVER_CHAIN.
850
+ // Client-side lookup so the recovery affordance can render without a server
851
+ // round-trip; the actual spawn still hits /api/task on click.
852
+ var FAILOVER_CHAIN = ['claude-code', 'codex', 'aider', 'opencode', 'gemini'];
853
+ function pickFailoverTarget(currentAgent) {
854
+ var idx = FAILOVER_CHAIN.indexOf(currentAgent);
855
+ if (idx === -1) return FAILOVER_CHAIN[0]; // unknown current → start of chain
856
+ return FAILOVER_CHAIN[(idx + 1) % FAILOVER_CHAIN.length];
857
+ }
858
+
859
+ // Pattern 9 — cause-specific copy + action labels per failure code. Auth
860
+ // codes get a "Sign in" CTA (not failover-eligible per failover-policy);
861
+ // crashed/refused get the cross-vendor retry button. Falls back to a
862
+ // generic retry message for unknown codes.
863
+ function recoveryCopy(code, currentAgent, nextAgent) {
864
+ var nextLabel = agentLabel(nextAgent);
865
+ var curLabel = agentLabel(currentAgent);
866
+ if (code === 'rate_limit') {
867
+ return {
868
+ msg: '<b>' + escapeAgentTitle(curLabel) + '</b> is rate-limited. <b>' + escapeAgentTitle(nextLabel) + '</b> is available with the same prompt.',
869
+ primary: 'Retry with ' + nextLabel,
870
+ secondary: 'Wait it out',
871
+ canFailover: true
872
+ };
873
+ }
874
+ if (code === 'quota_exceeded') {
875
+ return {
876
+ msg: 'You hit <b>' + escapeAgentTitle(curLabel) + '</b>’s quota. <b>' + escapeAgentTitle(nextLabel) + '</b> can take this prompt.',
877
+ primary: 'Switch to ' + nextLabel,
878
+ secondary: 'Manage billing',
879
+ canFailover: true
880
+ };
881
+ }
882
+ if (code === 'credit_low') {
883
+ return {
884
+ msg: '<b>' + escapeAgentTitle(curLabel) + '</b> credits are running low. Top up or switch agents.',
885
+ primary: 'Switch to ' + nextLabel,
886
+ secondary: 'Top up ' + curLabel,
887
+ canFailover: true
888
+ };
889
+ }
890
+ if (code === 'auth_invalid' || code === 'auth_missing') {
891
+ return {
892
+ msg: '<b>' + escapeAgentTitle(curLabel) + '</b> needs sign-in. Switching agents won’t fix the underlying auth issue.',
893
+ primary: 'Open setup for ' + curLabel,
894
+ secondary: 'Try ' + nextLabel + ' instead',
895
+ canFailover: false // auth fix takes precedence over failover
896
+ };
897
+ }
898
+ if (code === 'crashed') {
899
+ return {
900
+ msg: '<b>' + escapeAgentTitle(curLabel) + '</b> crashed. <b>' + escapeAgentTitle(nextLabel) + '</b> can pick up where it left off.',
901
+ primary: 'Retry with ' + nextLabel,
902
+ secondary: 'View error',
903
+ canFailover: true
904
+ };
905
+ }
906
+ return {
907
+ msg: 'Something went wrong. <b>' + escapeAgentTitle(nextLabel) + '</b> can take this prompt.',
908
+ primary: 'Try ' + nextLabel,
909
+ secondary: 'Dismiss',
910
+ canFailover: true
911
+ };
912
+ }
913
+
914
+ // Build the recovery-affordance markup. Returns an HTMLElement ready to
915
+ // append to the session card. Caller wires the click handlers.
916
+ function buildRecoveryAffordance(code, currentAgent, nextAgent) {
917
+ var copy = recoveryCopy(code, currentAgent, nextAgent);
918
+ var div = document.createElement('div');
919
+ div.className = 'recovery-affordance';
920
+ div.dataset.cause = String(code || '');
921
+ div.innerHTML =
922
+ '<p class="ra-msg">' + copy.msg + '</p>' +
923
+ '<div class="ra-actions">' +
924
+ '<button class="ra-primary" type="button">' + (copy.canFailover ? agentBadgeHTML(nextAgent) + ' ' : '') + escapeAgentTitle(copy.primary) + '</button>' +
925
+ '<button class="ra-secondary" type="button">' + escapeAgentTitle(copy.secondary) + '</button>' +
926
+ '</div>';
927
+ return { el: div, copy: copy };
928
+ }
929
+
930
+ function startWhimsyRotator(el) {
931
+ if (!el) return function() {};
932
+ var recent = [];
933
+ function tick() {
934
+ var verb = pickWhimsyVerb(recent);
935
+ recent.push(verb);
936
+ if (recent.length > 5) recent.shift();
937
+ el.textContent = verb;
938
+ }
939
+ tick();
940
+ var timer;
941
+ function schedule() {
942
+ var delay = 4000 + Math.random() * 2000;
943
+ timer = setTimeout(function() { tick(); schedule(); }, delay);
944
+ }
945
+ schedule();
946
+ return function stop() {
947
+ if (timer) clearTimeout(timer);
948
+ timer = null;
949
+ };
950
+ }
336
951
  `;
337
952
  // ── /history — audit feed (what we shipped earlier in this session) ────────────
338
953
  export const HISTORY_HTML = `<!doctype html>
@@ -355,7 +970,7 @@ ${SHARED_STYLES}
355
970
  -webkit-font-smoothing: antialiased;
356
971
  }
357
972
 
358
- /* ── Header (marketing-hero motif: traffic-light dots) ─────────────── */
973
+ /* ── Header ─────────────────────────────────────────────────────────── */
359
974
  header {
360
975
  position: sticky; top: 0; z-index: 10;
361
976
  background: rgba(0,0,0,0.75);
@@ -366,15 +981,6 @@ ${SHARED_STYLES}
366
981
  display: flex; align-items: center; justify-content: space-between;
367
982
  }
368
983
  header .brand { display: flex; align-items: center; gap: 14px; }
369
- header .lights { display: inline-flex; gap: 6px; }
370
- header .lights span {
371
- width: 11px; height: 11px; border-radius: 50%;
372
- background: var(--bg-2);
373
- border: 1px solid var(--border);
374
- }
375
- header .lights .r { background: var(--block); border-color: transparent; }
376
- header .lights .y { background: var(--warn); border-color: transparent; }
377
- header .lights .g { background: var(--allow); border-color: transparent; }
378
984
  header .title {
379
985
  font-family: var(--mono);
380
986
  font-size: 13px; font-weight: 600;
@@ -390,7 +996,7 @@ ${SHARED_STYLES}
390
996
  display: inline-block; width: 6px; height: 6px; border-radius: 50%;
391
997
  background: var(--fg-30); margin-right: 6px; vertical-align: middle;
392
998
  }
393
- header .meta .dot.live { background: var(--allow); box-shadow: 0 0 8px rgba(40,200,64,0.6); }
999
+ header .meta .dot.live { background: var(--accent); }
394
1000
  header .eyebrow {
395
1001
  display: inline-block;
396
1002
  font-family: var(--mono);
@@ -436,24 +1042,110 @@ ${SHARED_STYLES}
436
1042
 
437
1043
  /* ── Feed ──────────────────────────────────────────────────────────── */
438
1044
  main { padding-bottom: 48px; }
1045
+ /* Bug A5 — filter chip bar above the feed. Lets the user narrow the
1046
+ wall-of-text down to outcomes (allow/block/override) or specific
1047
+ tool families (Bash/Edit/Write/Read). Active chip glows with the
1048
+ accent border. */
1049
+ .feed-filters {
1050
+ display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
1051
+ margin: 0 0 12px;
1052
+ padding: 0 0 8px;
1053
+ border-bottom: 1px solid var(--border-soft);
1054
+ }
1055
+ .feed-filters .filter-chip {
1056
+ background: transparent;
1057
+ border: 1px solid var(--border);
1058
+ border-radius: 999px;
1059
+ color: var(--fg-50);
1060
+ cursor: pointer;
1061
+ font-family: var(--mono); font-size: 10.5px;
1062
+ padding: 3px 9px;
1063
+ transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
1064
+ }
1065
+ .feed-filters .filter-chip:hover { color: var(--fg); border-color: var(--fg-30); }
1066
+ .feed-filters .filter-chip.active {
1067
+ color: var(--accent);
1068
+ border-color: var(--accent);
1069
+ background: var(--accent-wash);
1070
+ }
1071
+ .feed-filters .filter-chip-clear { color: var(--fg-30); }
1072
+ .feed-filters .filter-chip-clear:hover { color: var(--block); border-color: var(--block); }
1073
+ .feed-filters .filter-sep { color: var(--fg-20); user-select: none; padding: 0 2px; }
1074
+ /* Hide rows whose data-outcome or data-tool doesn't match the active chips. */
1075
+ #feed.filtered li:not(.feed-match) { display: none; }
1076
+
439
1077
  #feed { list-style: none; margin: 0; padding: 0; }
1078
+ /* Row layout — flex, not grid. Earlier grid had a fixed 5-column
1079
+ template that stopped matching reality after seq + hash + agent
1080
+ badge got added; outcome pill landed in a 1fr cell and stretched.
1081
+ Flex with explicit ordering + a right-side ref cluster keeps the
1082
+ primary signal (badge / outcome / tool / summary) on the left. */
440
1083
  #feed li {
441
- padding: 11px 24px;
1084
+ padding: 10px 24px;
442
1085
  border-bottom: 1px solid var(--border-soft);
443
- display: grid;
444
- grid-template-columns: 88px 64px 80px 1fr 70px;
445
- gap: 14px;
446
- align-items: baseline;
1086
+ display: flex;
1087
+ align-items: center;
1088
+ gap: 12px;
447
1089
  transition: background 120ms ease;
448
1090
  }
449
1091
  #feed li:hover { background: rgba(255,255,255,0.02); }
450
- #feed li .ts { color: var(--fg-40); font-family: var(--mono); font-size: 11px; }
1092
+ /* Seq + hash are FORENSIC-ONLY — both hidden by default per
1093
+ claim_user_persona HIGH (non-technical users primary). The
1094
+ "Advanced" toggle reveals BOTH columns together; without it
1095
+ /history is the simple agent feed a non-technical user expects. */
1096
+ #feed li .seq {
1097
+ order: 100;
1098
+ display: none;
1099
+ color: var(--fg-30); font-family: var(--mono); font-size: 10px;
1100
+ min-width: 42px; text-align: right;
1101
+ user-select: all;
1102
+ }
1103
+ #feed li .hash {
1104
+ order: 101;
1105
+ display: none;
1106
+ color: var(--fg-30); font-family: var(--mono); font-size: 10px;
1107
+ min-width: 60px;
1108
+ user-select: all;
1109
+ }
1110
+ body.advanced-on #feed li .seq,
1111
+ body.advanced-on #feed li .hash { display: inline-block; }
1112
+ /* Advanced toggle button — same visual shape as filter chips but its
1113
+ own .active state managed by setAdvanced (not syncChipUI). */
1114
+ .advanced-toggle {
1115
+ font-family: var(--mono); font-size: 11px;
1116
+ padding: 4px 12px; border-radius: 999px;
1117
+ background: rgba(255,255,255,0.03);
1118
+ color: var(--fg-50);
1119
+ border: 1px solid var(--border);
1120
+ cursor: pointer;
1121
+ transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
1122
+ }
1123
+ .advanced-toggle:hover { color: var(--fg); border-color: var(--fg-30); }
1124
+ .advanced-toggle.active {
1125
+ background: var(--accent-wash);
1126
+ border-color: var(--accent);
1127
+ color: var(--accent);
1128
+ }
1129
+ /* Primary row order (left-to-right):
1130
+ agent badge (16px) — outcome pill — tool (60px) — summary (1fr) — score (50px) — seq — hash
1131
+ Flex order: badge=1, outcome=2, tool=3, summary=4 (flex:1), score=5,
1132
+ seq=100, hash=101 (right-side ref cluster). */
1133
+ #feed li .agent-badge { order: 1; flex: 0 0 16px; }
1134
+ #feed li .ts {
1135
+ order: 0; /* leftmost — chronological anchor */
1136
+ color: var(--fg-40); font-family: var(--mono); font-size: 11px;
1137
+ flex: 0 0 56px;
1138
+ white-space: nowrap;
1139
+ }
451
1140
  #feed li .outcome {
452
- font-family: var(--mono); font-size: 11px; font-weight: 600;
453
- text-transform: uppercase; letter-spacing: 0.06em;
454
- padding: 2px 8px; border-radius: 999px;
1141
+ order: 2; flex: 0 0 auto;
1142
+ display: inline-block;
1143
+ font-family: var(--mono); font-size: 10.5px; font-weight: 600;
1144
+ text-transform: lowercase; letter-spacing: 0.04em;
1145
+ padding: 2px 10px; border-radius: 999px;
455
1146
  text-align: center;
456
1147
  border: 1px solid transparent;
1148
+ min-width: 56px;
457
1149
  }
458
1150
  #feed li .outcome.allow {
459
1151
  color: var(--allow);
@@ -465,21 +1157,79 @@ ${SHARED_STYLES}
465
1157
  background: rgba(255,95,87,0.10);
466
1158
  border-color: rgba(255,95,87,0.30);
467
1159
  }
1160
+ #feed li .outcome.override {
1161
+ color: var(--warn);
1162
+ background: rgba(254,188,46,0.08);
1163
+ border-color: rgba(254,188,46,0.25);
1164
+ }
468
1165
  #feed li .tool {
1166
+ order: 3; flex: 0 0 auto;
469
1167
  color: var(--accent);
470
- font-family: var(--mono); font-size: 12px;
1168
+ font-family: var(--mono); font-size: 11.5px;
1169
+ min-width: 60px;
471
1170
  }
472
1171
  #feed li .summary {
1172
+ order: 4; flex: 1 1 auto;
473
1173
  color: var(--fg-70);
474
- font-family: var(--mono); font-size: 12px;
1174
+ font-family: var(--mono); font-size: 11.5px;
475
1175
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1176
+ min-width: 0; /* allows ellipsis to engage in a flex child */
476
1177
  }
477
1178
  #feed li .score {
1179
+ order: 5; flex: 0 0 50px;
478
1180
  text-align: right;
479
- font-family: var(--mono); font-size: 11px;
1181
+ font-family: var(--mono); font-size: 10.5px;
480
1182
  color: var(--warn);
481
1183
  }
482
1184
  #feed li .score.zero { color: var(--fg-30); }
1185
+ #feed li .score.zero { color: var(--fg-30); }
1186
+
1187
+ /* P12 slice — tamper-evidence banner above the feed. Red, prominent,
1188
+ dismissible by reload (no manual close — chain breaks are sticky
1189
+ until verifyAuditChain returns valid=true again). */
1190
+ .tamper-banner {
1191
+ display: flex; align-items: center; gap: 10px;
1192
+ padding: 10px 14px; margin: 12px 0;
1193
+ border: 1px solid rgba(255,95,87,0.5);
1194
+ background: rgba(255,95,87,0.08);
1195
+ border-radius: 8px;
1196
+ color: var(--block);
1197
+ font-family: var(--mono); font-size: 12px;
1198
+ }
1199
+ /* Anomaly mode (seq_gap only — likely daemon restart, not tampering)
1200
+ gets warn-yellow not block-red. Reserves alarming red for the
1201
+ can't-be-benign case. */
1202
+ .tamper-banner[data-severity="anomaly"] {
1203
+ border-color: rgba(254,188,46,0.5);
1204
+ background: rgba(254,188,46,0.08);
1205
+ color: var(--warn);
1206
+ }
1207
+ /* Hide tamper banner entirely from non-technical users by default —
1208
+ it requires forensic vocabulary ("chain anomaly", "hash mismatch")
1209
+ to interpret. Surfaced when the Advanced toggle is on. */
1210
+ .tamper-banner { display: none !important; }
1211
+ body.advanced-on .tamper-banner[data-visible="true"] { display: flex !important; }
1212
+
1213
+ /* claim_user_persona — the More-filters group is hidden by default;
1214
+ body.more-filters-on reveals it. */
1215
+ .feed-filters.more-filters {
1216
+ display: none;
1217
+ padding-top: 0;
1218
+ margin-top: 8px;
1219
+ flex-wrap: wrap;
1220
+ gap: 6px;
1221
+ align-items: center;
1222
+ }
1223
+ body.more-filters-on .feed-filters.more-filters { display: flex; }
1224
+ .feed-filters .filter-label {
1225
+ font-family: var(--mono); font-size: 10.5px;
1226
+ color: var(--fg-40);
1227
+ margin-right: 2px;
1228
+ }
1229
+ .feed-filters .filter-spacer { flex: 1; }
1230
+ .tamper-banner .tb-icon { font-size: 14px; }
1231
+ .tamper-banner .tb-text { font-weight: 600; }
1232
+ .tamper-banner .tb-detail { color: var(--fg-70); font-size: 11px; }
483
1233
 
484
1234
  /* ── Empty state ───────────────────────────────────────────────────── */
485
1235
  #empty {
@@ -515,12 +1265,16 @@ ${SHARED_STYLES}
515
1265
  <a href="/" class="nav-link">Tasks</a>
516
1266
  <a href="/threads" class="nav-link">Threads</a>
517
1267
  <a href="/history" class="nav-link active">History</a>
518
- <span style="color:var(--fg-40);font-family:var(--mono);font-size:11px;">
519
- <span id="live-dot" class="dot"></span><span id="conn">connecting</span>
520
- </span>
1268
+ <a href="https://codehere.uk" class="nav-link nav-external" target="_blank" rel="noopener" title="Open codehere.uk">Docs ↗</a>
1269
+ <span class="mode-indicator" data-mode="" id="mode-indicator" title="Loading safety-hook mode…"><span class="mi-dot"></span><span class="mi-label">…</span></span>
521
1270
  </nav>
522
1271
 
523
- <div class="tagline" style="margin-top:90px">One feed. Every agent. Every action.</div>
1272
+ <div class="tagline" style="margin-top:90px">
1273
+ One feed. Every agent. Every action.
1274
+ <span class="conn-inline" style="margin-left:12px;font-family:var(--mono);font-size:10px;color:var(--fg-40);">
1275
+ <span id="live-dot" class="dot"></span><span id="conn">connecting</span>
1276
+ </span>
1277
+ </div>
524
1278
 
525
1279
  <div class="status">
526
1280
  <div class="stat">
@@ -548,6 +1302,50 @@ ${SHARED_STYLES}
548
1302
 
549
1303
  <div class="section-label">Audit feed · Live</div>
550
1304
  <main>
1305
+ <!-- Bug A5 — filter chip bar. Wall-of-text feed with no grouping/filter
1306
+ made the page hard to scan; chips toggle visibility per outcome
1307
+ (allow/block/override) and per tool family. State lives in URL hash
1308
+ so reload preserves the active filter. -->
1309
+ <!-- claim_user_persona HIGH — non-technical users primary. Default
1310
+ bar: outcome filters only (4 buttons). Per-tool + per-agent chips
1311
+ behind the .more-filters group; .advanced-toggle gates seq + hash.
1312
+ Reduces default surface count from 14 to 4 while keeping power
1313
+ features one click away for technical users. -->
1314
+ <div id="feed-filters" class="feed-filters" role="tablist">
1315
+ <button class="filter-chip active" data-filter="outcome" data-value="all" type="button">All</button>
1316
+ <button class="filter-chip" data-filter="outcome" data-value="block" type="button">Blocked</button>
1317
+ <button class="filter-chip" data-filter="outcome" data-value="override" type="button">Overridden</button>
1318
+ <button class="filter-chip filter-chip-clear" data-filter="clear" type="button">Clear</button>
1319
+ <span class="filter-spacer"></span>
1320
+ <button class="advanced-toggle" id="more-filters-toggle" type="button" title="Show per-tool, per-agent, and forensic filters">More ▾</button>
1321
+ <button class="advanced-toggle" id="advanced-toggle" type="button" title="Show chain seq + hash columns (forensic)">Advanced</button>
1322
+ </div>
1323
+ <!-- Disclosure group — hidden by default. body.more-filters-on
1324
+ reveals the row. -->
1325
+ <div id="more-filters" class="feed-filters more-filters" role="tablist">
1326
+ <span class="filter-label">Tool:</span>
1327
+ <button class="filter-chip" data-filter="tool" data-value="Bash" type="button">Bash</button>
1328
+ <button class="filter-chip" data-filter="tool" data-value="Edit" type="button">Edit</button>
1329
+ <button class="filter-chip" data-filter="tool" data-value="Write" type="button">Write</button>
1330
+ <button class="filter-chip" data-filter="tool" data-value="Read" type="button">Read</button>
1331
+ <button class="filter-chip" data-filter="tool" data-value="pre_prompt" type="button" title="Pre-spawn safety scans">Pre-prompt</button>
1332
+ <span class="filter-sep">·</span>
1333
+ <span class="filter-label">Agent:</span>
1334
+ <button class="filter-chip" data-filter="agent" data-value="claude-code" type="button">Claude Code</button>
1335
+ <button class="filter-chip" data-filter="agent" data-value="codex" type="button">Codex</button>
1336
+ <button class="filter-chip" data-filter="agent" data-value="aider" type="button">Aider</button>
1337
+ <button class="filter-chip" data-filter="agent" data-value="gemini" type="button">Gemini</button>
1338
+ <button class="filter-chip" data-filter="agent" data-value="conductor" type="button">Conductor</button>
1339
+ </div>
1340
+ <!-- P12 slice — tamper-evidence banner. Shown only when /api/audit/verify
1341
+ returns valid=false. Hash-chain integrity is the load-bearing
1342
+ differentiator for codehere's audit log; surfacing chain breaks is
1343
+ the user-visible payoff. -->
1344
+ <div id="tamper-banner" class="tamper-banner" style="display:none">
1345
+ <span class="tb-icon">⚠</span>
1346
+ <span class="tb-text">Audit log tampering detected.</span>
1347
+ <span class="tb-detail" id="tb-detail"></span>
1348
+ </div>
551
1349
  <ul id="feed"></ul>
552
1350
  <div id="empty">
553
1351
  <div class="pill">No tool calls yet</div>
@@ -558,13 +1356,19 @@ ${SHARED_STYLES}
558
1356
  </main>
559
1357
 
560
1358
  <script>
1359
+ ${AGENT_BADGE_JS}
1360
+
561
1361
  (function () {
562
1362
  const feed = document.getElementById('feed');
563
1363
  const empty = document.getElementById('empty');
564
- const countEl = document.getElementById('count');
1364
+ // UX-simplification iter 3 (May 7): dropped countEl reference — the
1365
+ // #count element doesn't exist in HISTORY markup. countEl.textContent
1366
+ // threw TypeError on every render, eating the next line that hides
1367
+ // the empty-state. Result: visible "No tool calls yet" message AND
1368
+ // 50 visible feed entries simultaneously. Latent bug since the
1369
+ // history view shipped.
565
1370
  const connEl = document.getElementById('conn');
566
1371
  const dot = document.getElementById('live-dot');
567
- let count = 0;
568
1372
 
569
1373
  function shortPath(p) {
570
1374
  const parts = String(p).split('/');
@@ -607,24 +1411,130 @@ ${SHARED_STYLES}
607
1411
  function render(entry) {
608
1412
  const ts = new Date(entry.timestamp || Date.now()).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
609
1413
  const blocked = Boolean(entry.blocked);
610
- const outcome = blocked ? 'block' : 'allow';
1414
+ // Bug A5 — derive a 3-way outcome (allow / block / override) and stamp
1415
+ // it as a data-attribute so the filter chips can hide non-matching rows
1416
+ // without re-rendering the feed.
1417
+ const isOverride = Boolean(entry.overrideReason);
1418
+ const outcome = isOverride ? 'override' : (blocked ? 'block' : 'allow');
611
1419
  const tool = String(entry.toolName || entry.tool_name || '—');
612
1420
  const score = entry.riskScore ?? entry.risk_score ?? 0;
613
1421
  const summary = summarize(entry);
1422
+ // Cross-vendor audit-mirror (#110 slice 2-4) writes the originating
1423
+ // agent inside toolInput as JSON. Pull it for the per-row badge so
1424
+ // users can tell at a glance whether codex / aider / gemini / etc.
1425
+ // ran each call. Falls back to no-badge when toolInput isn't JSON
1426
+ // (legacy entries pre-mirror — Claude PreToolUse rows).
1427
+ let originAgent = '';
1428
+ try {
1429
+ const parsed = typeof entry.toolInput === 'string' ? JSON.parse(entry.toolInput) : entry.toolInput;
1430
+ if (parsed && typeof parsed === 'object' && typeof parsed.agent === 'string') {
1431
+ originAgent = parsed.agent;
1432
+ }
1433
+ } catch { /* not JSON — leave blank */ }
1434
+
1435
+ // P12 follow-up — surface the chain seq number per row. Forensic
1436
+ // value: a reviewer can refer to a specific entry by its seq + the
1437
+ // hash banner up top makes it easy to verify integrity. Rows with
1438
+ // no seq (legacy entries) get a muted dash.
1439
+ const seq = (typeof entry.seq === 'number') ? entry.seq : null;
1440
+ const entryHash = (typeof entry.entryHash === 'string') ? entry.entryHash : null;
614
1441
 
615
1442
  const li = document.createElement('li');
1443
+ li.dataset.outcome = outcome;
1444
+ li.dataset.tool = tool;
1445
+ if (originAgent) li.dataset.agent = originAgent;
1446
+ if (seq !== null) li.dataset.seq = String(seq);
1447
+ if (entryHash) li.dataset.hash = entryHash;
1448
+ // P12 advanced — short hash (8 chars) revealed by the Advanced toggle.
1449
+ // Stored on the row regardless; CSS hides it by default.
1450
+ const shortHash = entryHash ? entryHash.slice(0, 8) : '—';
616
1451
  li.innerHTML =
1452
+ '<span class="seq" title="Chain sequence number">' + (seq !== null ? '#' + seq : '—') + '</span>' +
1453
+ '<span class="hash" title="' + (entryHash ? 'entryHash: ' + entryHash : 'no chain hash') + '">' + shortHash + '</span>' +
617
1454
  '<span class="ts">' + escape(ts) + '</span>' +
1455
+ (originAgent ? agentBadgeHTML(originAgent) : '') +
618
1456
  '<span class="outcome ' + outcome + '">' + outcome + '</span>' +
619
1457
  '<span class="tool">' + escape(tool) + '</span>' +
620
1458
  '<span class="summary" title="' + escape(summary) + '">' + escape(summary) + '</span>' +
621
1459
  '<span class="score ' + (score ? '' : 'zero') + '">' + (score ? score : '—') + '</span>';
622
1460
  feed.insertBefore(li, feed.firstChild);
623
- count += 1;
624
- countEl.textContent = String(count);
1461
+ applyFilterToRow(li);
625
1462
  empty.style.display = 'none';
626
1463
  }
627
1464
 
1465
+ // Bug A5 — filter state. Active outcome filter and active tool filter
1466
+ // (each null means "no filter"). Persisted in the URL hash so a
1467
+ // bookmarkable view is achievable + reload preserves the filter.
1468
+ const filterState = { outcome: null, tool: null };
1469
+ function readFilterFromHash() {
1470
+ const hash = (location.hash || '').replace(/^#/, '');
1471
+ if (!hash) return;
1472
+ const params = new URLSearchParams(hash);
1473
+ const o = params.get('outcome');
1474
+ const t = params.get('tool');
1475
+ if (o && o !== 'all') filterState.outcome = o;
1476
+ if (t) filterState.tool = t;
1477
+ }
1478
+ function writeFilterToHash() {
1479
+ const params = new URLSearchParams();
1480
+ if (filterState.outcome) params.set('outcome', filterState.outcome);
1481
+ if (filterState.tool) params.set('tool', filterState.tool);
1482
+ const next = params.toString();
1483
+ location.hash = next ? '#' + next : '';
1484
+ }
1485
+ function rowMatches(li) {
1486
+ const o = li.dataset.outcome || '';
1487
+ const t = li.dataset.tool || '';
1488
+ if (filterState.outcome && o !== filterState.outcome) return false;
1489
+ if (filterState.tool && t !== filterState.tool) return false;
1490
+ return true;
1491
+ }
1492
+ function applyFilterToRow(li) {
1493
+ if (rowMatches(li)) li.classList.add('feed-match');
1494
+ else li.classList.remove('feed-match');
1495
+ }
1496
+ function applyFilterToAllRows() {
1497
+ const active = !!(filterState.outcome || filterState.tool);
1498
+ feed.classList.toggle('filtered', active);
1499
+ Array.prototype.forEach.call(feed.querySelectorAll('li'), applyFilterToRow);
1500
+ }
1501
+ function syncChipUI() {
1502
+ const chips = document.querySelectorAll('#feed-filters .filter-chip');
1503
+ Array.prototype.forEach.call(chips, function(chip) {
1504
+ const fk = chip.dataset.filter;
1505
+ const fv = chip.dataset.value;
1506
+ if (fk === 'clear') return;
1507
+ let isActive = false;
1508
+ if (fk === 'outcome') {
1509
+ isActive = (fv === 'all' && !filterState.outcome) || (fv !== 'all' && filterState.outcome === fv);
1510
+ } else if (fk === 'tool') {
1511
+ isActive = filterState.tool === fv;
1512
+ }
1513
+ chip.classList.toggle('active', isActive);
1514
+ });
1515
+ }
1516
+ document.addEventListener('click', function(ev) {
1517
+ const chip = ev.target && ev.target.closest && ev.target.closest('.filter-chip');
1518
+ if (!chip) return;
1519
+ const fk = chip.dataset.filter;
1520
+ const fv = chip.dataset.value;
1521
+ if (fk === 'clear') {
1522
+ filterState.outcome = null;
1523
+ filterState.tool = null;
1524
+ } else if (fk === 'outcome') {
1525
+ filterState.outcome = fv === 'all' ? null : fv;
1526
+ } else if (fk === 'tool') {
1527
+ // Toggle: clicking an already-active tool chip clears the tool filter.
1528
+ filterState.tool = filterState.tool === fv ? null : fv;
1529
+ }
1530
+ writeFilterToHash();
1531
+ syncChipUI();
1532
+ applyFilterToAllRows();
1533
+ });
1534
+ // Init from URL hash
1535
+ readFilterFromHash();
1536
+ syncChipUI();
1537
+
628
1538
  // ── Status strip (answers C/A/B/D) ───────────────────────────────────
629
1539
  const events = []; // accumulate for rolling-window stats
630
1540
  const HOUR_MS = 60 * 60 * 1000;
@@ -684,6 +1594,91 @@ ${SHARED_STYLES}
684
1594
 
685
1595
  // Keep "Xs/Xm ago" fresh even when no new events arrive
686
1596
  setInterval(() => { if (events.length) recomputeStats(); }, 1000);
1597
+
1598
+ // P12 slice — poll /api/audit/verify every 60s; show the tamper
1599
+ // banner if the hash chain breaks. First poll runs at load.
1600
+ function pollAuditChain() {
1601
+ fetch('/api/audit/verify')
1602
+ .then(r => r.ok ? r.json() : null)
1603
+ .then(d => {
1604
+ if (!d) return;
1605
+ const banner = document.getElementById('tamper-banner');
1606
+ const detail = document.getElementById('tb-detail');
1607
+ if (!banner) return;
1608
+ if (d.valid) {
1609
+ banner.dataset.visible = 'false';
1610
+ return;
1611
+ }
1612
+ // claim_user_persona HIGH — banner hidden from non-technical
1613
+ // users (CSS gates on body.advanced-on). data-visible flag
1614
+ // tells CSS whether to show when advanced toggle flips on.
1615
+ // Severity-driven copy below stays for technical readers.
1616
+ const errs = Array.isArray(d.errors) ? d.errors : [];
1617
+ const hasHashMismatch = errs.some(function(e) { return e.reason === 'hash_mismatch' || e.reason === 'parse_failure'; });
1618
+ const titleEl = banner.querySelector('.tb-text');
1619
+ const iconEl = banner.querySelector('.tb-icon');
1620
+ if (hasHashMismatch) {
1621
+ banner.dataset.severity = 'tamper';
1622
+ if (titleEl) titleEl.textContent = 'Audit log tampering detected.';
1623
+ if (iconEl) iconEl.textContent = '⚠';
1624
+ } else {
1625
+ banner.dataset.severity = 'anomaly';
1626
+ if (titleEl) titleEl.textContent = 'Audit chain anomaly (likely a daemon restart).';
1627
+ if (iconEl) iconEl.textContent = 'ℹ';
1628
+ }
1629
+ banner.dataset.visible = 'true';
1630
+ if (detail && errs.length > 0) {
1631
+ const first = errs[0];
1632
+ detail.textContent = '· ' + (errs.length === 1
1633
+ ? 'line ' + first.lineNumber + ': ' + first.reason
1634
+ : errs.length + ' issues (first at line ' + first.lineNumber + ')');
1635
+ }
1636
+ })
1637
+ .catch(() => { /* leave banner state */ });
1638
+ }
1639
+ pollAuditChain();
1640
+ setInterval(pollAuditChain, 60000);
1641
+
1642
+ // P12 advanced toggle — flip body.advanced-on. Persisted via the URL
1643
+ // hash (?advanced=1) so reload preserves. Click toggles back off.
1644
+ const advancedToggle = document.getElementById('advanced-toggle');
1645
+ function setAdvanced(on) {
1646
+ document.body.classList.toggle('advanced-on', !!on);
1647
+ if (advancedToggle) advancedToggle.classList.toggle('active', !!on);
1648
+ try {
1649
+ const params = new URLSearchParams((location.hash || '').replace(/^#/, ''));
1650
+ if (on) params.set('advanced', '1'); else params.delete('advanced');
1651
+ const next = params.toString();
1652
+ location.hash = next ? '#' + next : '';
1653
+ } catch { /* hash write best-effort */ }
1654
+ }
1655
+ // Hash-on-load read
1656
+ try {
1657
+ const params = new URLSearchParams((location.hash || '').replace(/^#/, ''));
1658
+ if (params.get('advanced') === '1') setAdvanced(true);
1659
+ if (params.get('more') === '1') setMoreFilters(true);
1660
+ } catch { /* ignore */ }
1661
+ if (advancedToggle) advancedToggle.addEventListener('click', function() {
1662
+ setAdvanced(!document.body.classList.contains('advanced-on'));
1663
+ });
1664
+
1665
+ // claim_user_persona HIGH — More-filters disclosure for the per-tool
1666
+ // + per-agent chips. Default closed so non-technical users see 4
1667
+ // chips instead of 14.
1668
+ const moreToggle = document.getElementById('more-filters-toggle');
1669
+ function setMoreFilters(on) {
1670
+ document.body.classList.toggle('more-filters-on', !!on);
1671
+ if (moreToggle) moreToggle.classList.toggle('active', !!on);
1672
+ try {
1673
+ const params = new URLSearchParams((location.hash || '').replace(/^#/, ''));
1674
+ if (on) params.set('more', '1'); else params.delete('more');
1675
+ const next = params.toString();
1676
+ location.hash = next ? '#' + next : '';
1677
+ } catch { /* hash write best-effort */ }
1678
+ }
1679
+ if (moreToggle) moreToggle.addEventListener('click', function() {
1680
+ setMoreFilters(!document.body.classList.contains('more-filters-on'));
1681
+ });
687
1682
  })();
688
1683
  </script>
689
1684
  </body>
@@ -708,11 +1703,6 @@ ${SHARED_STYLES}
708
1703
  display: flex; align-items: center; justify-content: space-between;
709
1704
  }
710
1705
  header .brand { display: flex; align-items: center; gap: 14px; }
711
- header .lights { display: inline-flex; gap: 6px; }
712
- header .lights span { width: 11px; height: 11px; border-radius: 50%; border: 1px solid transparent; }
713
- header .lights .r { background: var(--block); }
714
- header .lights .y { background: var(--warn); }
715
- header .lights .g { background: var(--allow); }
716
1706
  header .title { font-family: var(--mono); font-size: 13px; font-weight: 600; }
717
1707
  header .title .muted { color: var(--fg-40); font-weight: 400; margin-left: 6px; }
718
1708
  header nav { display: flex; gap: 14px; font-family: var(--mono); font-size: 11px; }
@@ -792,6 +1782,20 @@ ${SHARED_STYLES}
792
1782
  color: var(--fg-30); font-size: 10.5px;
793
1783
  padding: 8px; text-align: center;
794
1784
  }
1785
+ /* Pencil rename button — was missing from TASK_HTML's style block, so the
1786
+ SVG rendered without sizing constraints and showed as a white square on
1787
+ the active project row. Same rules as THREAD_HTML's copy. */
1788
+ .channel-rename {
1789
+ background: transparent; border: none; cursor: pointer;
1790
+ color: var(--fg-30); padding: 4px;
1791
+ transition: color 0.12s ease;
1792
+ flex: 0 0 auto;
1793
+ display: inline-flex; align-items: center; justify-content: center;
1794
+ outline: none;
1795
+ }
1796
+ .channel-rename svg { display: block; width: 14px; height: 14px; }
1797
+ .channel-rename:hover, .channel-rename:focus { color: var(--fg); background: transparent; }
1798
+ .channel-rename:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
795
1799
 
796
1800
  /* ── Readiness banner (F1a) ─────────────────────────────────────────── */
797
1801
  .readiness {
@@ -827,6 +1831,61 @@ ${SHARED_STYLES}
827
1831
  }
828
1832
  .readiness .r-action button:hover { color: var(--fg); border-color: var(--fg-30); }
829
1833
 
1834
+ /* v0.6 S2 — first-time onboarding card. Quiet authority: peach
1835
+ accent border + subtle glass, doesn't compete with the textarea
1836
+ below. Removed once dismissed OR once any session exists. */
1837
+ .onboarding-card {
1838
+ margin: 30px 0 18px; padding: 24px 28px;
1839
+ border: 1px solid rgba(255,154,118,0.25);
1840
+ background: linear-gradient(135deg, rgba(255,154,118,0.04) 0%, rgba(74,144,226,0.03) 100%);
1841
+ border-radius: 12px;
1842
+ position: relative; z-index: 1;
1843
+ }
1844
+ .onboarding-card .ob-head {
1845
+ display: flex; align-items: flex-start; justify-content: space-between;
1846
+ margin-bottom: 6px;
1847
+ }
1848
+ .onboarding-card .ob-title {
1849
+ font-family: var(--sans); font-size: 18px; font-weight: 700;
1850
+ color: var(--fg);
1851
+ background: linear-gradient(135deg, #ff9a76 0%, #4a90e2 100%);
1852
+ -webkit-background-clip: text; background-clip: text;
1853
+ -webkit-text-fill-color: transparent;
1854
+ }
1855
+ .onboarding-card .ob-close {
1856
+ background: transparent; border: none; cursor: pointer;
1857
+ color: var(--fg-40); font-size: 22px; line-height: 1;
1858
+ padding: 0 4px;
1859
+ transition: color 0.15s ease;
1860
+ }
1861
+ .onboarding-card .ob-close:hover { color: var(--fg); }
1862
+ .onboarding-card .ob-blurb {
1863
+ font-family: var(--sans); font-size: 13px; line-height: 1.55;
1864
+ color: var(--fg-70); margin-bottom: 16px; max-width: 640px;
1865
+ }
1866
+ .onboarding-card .ob-steps {
1867
+ list-style: none; padding: 0; margin: 0;
1868
+ display: flex; flex-direction: column; gap: 10px;
1869
+ }
1870
+ .onboarding-card .ob-steps li {
1871
+ display: flex; gap: 12px; align-items: flex-start;
1872
+ }
1873
+ .onboarding-card .ob-num {
1874
+ flex: 0 0 22px; height: 22px; line-height: 22px;
1875
+ border-radius: 50%; text-align: center;
1876
+ background: rgba(255,154,118,0.15);
1877
+ color: var(--accent);
1878
+ font-family: var(--mono); font-size: 11px; font-weight: 600;
1879
+ }
1880
+ .onboarding-card .ob-step-title {
1881
+ font-family: var(--sans); font-size: 13px; font-weight: 600;
1882
+ color: var(--fg); margin-bottom: 2px;
1883
+ }
1884
+ .onboarding-card .ob-step-body {
1885
+ font-family: var(--sans); font-size: 12px; line-height: 1.5;
1886
+ color: var(--fg-50);
1887
+ }
1888
+
830
1889
  .intake { padding: 36px 0 24px; position: relative; z-index: 1; }
831
1890
  .intake h1 {
832
1891
  margin: 0 0 6px;
@@ -845,6 +1904,47 @@ ${SHARED_STYLES}
845
1904
  resize: vertical; outline: none;
846
1905
  }
847
1906
  .intake textarea:focus { border-color: rgba(255,154,118,0.4); }
1907
+
1908
+ /* Pattern 5 · Composer + cost row. Sits between the textarea and the
1909
+ intake-row so the user sees: who's going to run this · where ·
1910
+ how much of the context window the prompt consumes · est. cost.
1911
+ Live-updates on every keystroke + agent/cwd change.
1912
+ Per docs/design/AGENTIC_UX.md Pattern 5. */
1913
+ .composer-meta {
1914
+ display: flex; gap: 10px; align-items: center;
1915
+ margin-top: 6px; padding: 0 4px;
1916
+ font-family: var(--mono); font-size: 11px; color: var(--fg-50);
1917
+ min-height: 18px;
1918
+ }
1919
+ .composer-meta .cm-pill {
1920
+ display: inline-flex; gap: 4px; align-items: center;
1921
+ padding: 1px 8px; border-radius: 999px;
1922
+ border: 1px solid var(--border-soft);
1923
+ background: rgba(255,255,255,0.02);
1924
+ }
1925
+ .composer-meta .cm-pill b { color: var(--fg-70); font-weight: 500; }
1926
+ .composer-meta .cm-sep { color: var(--fg-20); user-select: none; }
1927
+ .composer-meta .cm-budget[data-state="warn"] { color: var(--warn); border-color: rgba(254,188,46,0.3); background: rgba(254,188,46,0.05); }
1928
+ .composer-meta .cm-budget[data-state="block"] { color: var(--block); border-color: rgba(255,95,87,0.3); background: rgba(255,95,87,0.06); }
1929
+ .composer-meta .cm-cost.free { color: var(--allow); border-color: rgba(40,200,64,0.22); background: rgba(40,200,64,0.06); }
1930
+ .composer-meta .cm-cost.paid { color: var(--fg-70); }
1931
+ .composer-meta .cm-spend { color: var(--fg-70); cursor: help; }
1932
+ .composer-meta .cm-spend b { color: var(--fg-90); }
1933
+ .composer-meta .cm-spend[data-state="warn"] { color: var(--warn); border-color: rgba(254,188,46,0.3); background: rgba(254,188,46,0.05); }
1934
+ .composer-meta .cm-spend[data-state="warn"] b{ color: var(--warn); }
1935
+ .composer-meta .cm-spend[data-state="block"] { color: var(--block); border-color: rgba(255,95,87,0.3); background: rgba(255,95,87,0.06); }
1936
+ .composer-meta .cm-spend[data-state="block"] b{ color: var(--block); }
1937
+ .composer-meta .cm-spacer { flex: 1; }
1938
+ .composer-meta .cm-hint {
1939
+ color: var(--fg-30); font-size: 10.5px;
1940
+ margin-left: 4px;
1941
+ }
1942
+ /* On narrow viewports the hint hides — agent + budget pills stay,
1943
+ keyboard hint becomes discoverable via tooltip on the textarea. */
1944
+ @media (max-width: 720px) {
1945
+ .composer-meta .cm-hint { display: none; }
1946
+ }
1947
+
848
1948
  .intake-row { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
849
1949
  .intake select, .intake button, .intake input.cwd {
850
1950
  font-family: var(--mono); font-size: 12px;
@@ -865,6 +1965,17 @@ ${SHARED_STYLES}
865
1965
  }
866
1966
  .intake button.spawn:hover { filter: brightness(1.05); }
867
1967
  .intake button.spawn[disabled] { opacity: 0.45; cursor: default; }
1968
+ /* v0.6.x Slack-shape spike — alternative submit that routes the prompt as a
1969
+ reply to the most-recent task in the current cwd, exercising the existing
1970
+ appendMessage path. Lets a single intake feel like an ongoing conversation
1971
+ when the user wants continuity, while preserving "Assign" as the explicit
1972
+ new-task path. Disabled when no thread exists for the current cwd. */
1973
+ .intake button.continue-thread {
1974
+ background: rgba(255,154,118,0.10); color: var(--accent); font-weight: 600;
1975
+ border-color: rgba(255,154,118,0.3);
1976
+ }
1977
+ .intake button.continue-thread:hover:not([disabled]) { background: rgba(255,154,118,0.18); }
1978
+ .intake button.continue-thread[disabled] { opacity: 0.4; cursor: default; }
868
1979
  .intake .hint { margin-left: auto; color: var(--fg-40); font-family: var(--mono); font-size: 11px; }
869
1980
 
870
1981
  /* ── Setup panel (onboarding — appears when a non-ready agent is picked) ───
@@ -992,7 +2103,7 @@ ${SHARED_STYLES}
992
2103
  }
993
2104
  .session-head {
994
2105
  display: flex; align-items: center; justify-content: space-between;
995
- padding: 12px 16px;
2106
+ padding: 14px 20px;
996
2107
  cursor: pointer;
997
2108
  user-select: none;
998
2109
  }
@@ -1010,20 +2121,79 @@ ${SHARED_STYLES}
1010
2121
  .session-head .meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
1011
2122
  .session-head .prompt { font-family: var(--mono); font-size: 12px; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 620px; }
1012
2123
  .session-head .sub { font-family: var(--mono); font-size: 11px; color: var(--fg-40); }
2124
+ /* May 6 2026 design polish (Tier 1 #2): drop uppercase + heavy letter-spacing
2125
+ per Open-Agents production-grade reference. Status pills now read as
2126
+ human labels (Sentence case) not all-caps shouts. Killed demoted from
2127
+ yellow-warn to muted neutral — it's just "stopped", not an alert. */
1013
2128
  .session-head .status-pill {
1014
- font-family: var(--mono); font-size: 10px; font-weight: 600;
1015
- text-transform: uppercase; letter-spacing: 0.08em;
2129
+ font-family: var(--mono); font-size: 10.5px; font-weight: 500;
1016
2130
  padding: 3px 10px; border-radius: 999px;
1017
2131
  border: 1px solid transparent;
1018
2132
  }
1019
- .status-pill.running { color: var(--allow); background: rgba(40,200,64,0.08); border-color: rgba(40,200,64,0.25); }
1020
- .status-pill.completed { color: var(--fg-50); background: rgba(255,255,255,0.04); border-color: var(--border); }
1021
- .status-pill.killed { color: var(--warn); background: rgba(254,188,46,0.08); border-color: rgba(254,188,46,0.25); }
1022
- .status-pill.error { color: var(--block); background: rgba(255,95,87,0.10); border-color: rgba(255,95,87,0.30); }
2133
+ /* Pattern 8 · Status badge taxonomy — locked palette per Track A #2.
2134
+ 7 owned states; semantic colors reflect actionable meaning.
2135
+ Per docs/design/AGENTIC_UX.md Pattern 8. */
2136
+ .status-pill.running { color: var(--accent); background: var(--accent-wash); border-color: rgba(255,154,118,0.25); }
2137
+ .status-pill.completed { color: var(--allow); background: rgba(40,200,64,0.10); border-color: rgba(40,200,64,0.20); }
2138
+ .status-pill.awaiting { color: var(--accent); background: transparent; border-color: var(--accent); border-style: dashed; }
2139
+ .status-pill.blocked { color: var(--block); background: rgba(255,95,87,0.08); border-color: rgba(255,95,87,0.25); }
2140
+ .status-pill.failed,
2141
+ .status-pill.error { color: var(--block); background: rgba(255,95,87,0.10); border-color: rgba(255,95,87,0.30); }
2142
+ .status-pill.killed { color: var(--fg-50); background: rgba(255,255,255,0.04); border-color: var(--border); }
1023
2143
  .status-pill.observed-pill { color: var(--fg-50); background: rgba(255,255,255,0.03); border-color: var(--border); font-weight: 400; }
1024
2144
 
2145
+ /* Pattern 6 · macro states — wired to actual adapter error codes
2146
+ emitted by failover-classifier.ts + per-adapter ERROR_PATTERNS.
2147
+ yellow (--warn) = recoverable (failover-eligible per failover-classifier);
2148
+ red (--block) = needs human action (auth, billing, model-pick).
2149
+ Per docs/design/AGENTIC_UX.md Pattern 6. */
2150
+ .status-pill.rate-limit,
2151
+ .status-pill.quota-exceeded,
2152
+ .status-pill.credit-low { color: var(--warn); background: rgba(254,188,46,0.08); border-color: rgba(254,188,46,0.25); }
2153
+ .status-pill.auth-invalid,
2154
+ .status-pill.auth-missing,
2155
+ .status-pill.model-unavailable,
2156
+ .status-pill.crashed { color: var(--block); background: rgba(255,95,87,0.10); border-color: rgba(255,95,87,0.30); }
2157
+ .status-pill.thinking,
2158
+ .status-pill.running-buffered { color: var(--fg-50); background: rgba(255,255,255,0.03); border-color: var(--border); }
2159
+
2160
+ /* Persisted sessions = your own past tasks replayed from disk.
2161
+ Faded but VISIBLE. The detected-process variant (.observed) is
2162
+ diagnostic — hidden by default per claim_user_persona. */
2163
+ .session.persisted { opacity: 0.88; }
2164
+ .session.persisted .session-head { border-bottom: none; }
1025
2165
  .session.observed { opacity: 0.88; }
1026
2166
  .session.observed .session-head { border-bottom: none; }
2167
+ /* claim_user_persona HIGH — detected-process rows ("Claude Code ·
2168
+ pid 30531") are diagnostic, not user-facing. Hidden by default;
2169
+ advanced toggle reveals. The user's OWN spawned + persisted
2170
+ sessions still show — only externally-detected runners are gated. */
2171
+ .session.observed { display: none; }
2172
+ body.advanced-on .session.observed { display: block; }
2173
+
2174
+ /* claim_user_persona — session list limit. Default shows the 5
2175
+ most-recent cards; "Show all" reveals the rest. .show-all-on body
2176
+ class lifts the cap. CSS-only via :nth-child so attachSession
2177
+ prepend semantics stay simple. */
2178
+ #sessions > .session:nth-child(n+6) { display: none; }
2179
+ body.show-all-sessions #sessions > .session:nth-child(n+6) { display: block; }
2180
+ /* When advanced is also off, observed rows beyond cap stay hidden. */
2181
+ body.show-all-sessions #sessions > .session.observed { display: none; }
2182
+ body.show-all-sessions.advanced-on #sessions > .session.observed { display: block; }
2183
+ .show-more-sessions {
2184
+ display: none; /* shown via JS when count > 5 */
2185
+ margin: 14px auto;
2186
+ padding: 8px 16px;
2187
+ background: transparent;
2188
+ color: var(--fg-50);
2189
+ border: 1px solid var(--border);
2190
+ border-radius: 999px;
2191
+ font-family: var(--mono); font-size: 11px;
2192
+ cursor: pointer;
2193
+ transition: color 0.15s ease, border-color 0.15s ease;
2194
+ }
2195
+ .show-more-sessions:hover { color: var(--fg); border-color: var(--fg-30); }
2196
+ body.show-all-sessions .show-more-sessions { display: none; }
1027
2197
 
1028
2198
  .session-controls { display: flex; gap: 8px; }
1029
2199
  .session-controls button {
@@ -1034,8 +2204,139 @@ ${SHARED_STYLES}
1034
2204
  }
1035
2205
  .session-controls button:hover { color: var(--fg); border-color: var(--fg-30); }
1036
2206
  .session-controls button.kill:hover { color: var(--block); border-color: rgba(255,95,87,0.4); }
2207
+ /* Pattern 19 — interrupt affordance state machine (ready / confirming / killing).
2208
+ Confirming state turns the button red so the second press registers
2209
+ as intentional. 3s window then reverts to ready. */
2210
+ .session-controls button.kill[data-state="confirming"] {
2211
+ color: var(--block);
2212
+ border-color: var(--block);
2213
+ background: rgba(255,95,87,0.08);
2214
+ font-weight: 600;
2215
+ animation: kill-confirm-pulse 0.6s ease-in-out infinite alternate;
2216
+ }
2217
+ .session-controls button.kill[data-state="killing"] {
2218
+ color: var(--fg-50);
2219
+ cursor: progress;
2220
+ opacity: 0.7;
2221
+ }
2222
+ @keyframes kill-confirm-pulse {
2223
+ from { box-shadow: 0 0 0 rgba(255,95,87,0); }
2224
+ to { box-shadow: 0 0 8px rgba(255,95,87,0.4); }
2225
+ }
2226
+
2227
+ .session-body { padding: 0 20px 16px; }
2228
+
2229
+ /* P18 slice 4 — permission gate panel. Renders inside the session
2230
+ card when the per-tool hook blocked something. Per the dogfood-
2231
+ learning + overconfidence-guard: framed as "Codehere noticed",
2232
+ not "security violation". Approve button is the most prominent
2233
+ because false-positives are the common case (e.g. /etc/shadow
2234
+ in a docstring). Keep-blocking is the rare-but-real branch. */
2235
+ .permission-gate {
2236
+ margin: 10px 0 8px;
2237
+ padding: 12px 14px;
2238
+ border: 1px solid rgba(254,188,46,0.35);
2239
+ background: rgba(254,188,46,0.05);
2240
+ border-radius: 8px;
2241
+ }
2242
+ .permission-gate .pg-label {
2243
+ font-family: var(--mono); font-size: 9.5px;
2244
+ color: var(--warn);
2245
+ text-transform: uppercase; letter-spacing: 0.14em;
2246
+ margin-bottom: 4px;
2247
+ }
2248
+ .permission-gate .pg-title {
2249
+ font-family: var(--sans); font-size: 13px;
2250
+ color: var(--fg);
2251
+ margin-bottom: 6px;
2252
+ }
2253
+ .permission-gate .pg-detail {
2254
+ font-family: var(--mono); font-size: 11px;
2255
+ color: var(--fg-70);
2256
+ background: rgba(0,0,0,0.2);
2257
+ padding: 6px 10px; border-radius: 4px;
2258
+ margin-bottom: 10px;
2259
+ word-break: break-all;
2260
+ }
2261
+ .permission-gate .pg-actions {
2262
+ display: flex; gap: 8px; align-items: center;
2263
+ }
2264
+ .permission-gate button {
2265
+ font-family: var(--mono); font-size: 11px;
2266
+ padding: 6px 12px; border-radius: 6px;
2267
+ cursor: pointer;
2268
+ border: 1px solid var(--border);
2269
+ background: transparent;
2270
+ color: var(--fg-70);
2271
+ }
2272
+ .permission-gate button.pg-approve {
2273
+ color: var(--accent);
2274
+ border-color: var(--accent);
2275
+ background: var(--accent-wash);
2276
+ font-weight: 600;
2277
+ }
2278
+ .permission-gate button.pg-approve:hover { filter: brightness(1.1); }
2279
+ .permission-gate button.pg-keep:hover { color: var(--fg); border-color: var(--fg-30); }
2280
+ .permission-gate.approved {
2281
+ border-color: rgba(40,200,64,0.3);
2282
+ background: rgba(40,200,64,0.05);
2283
+ }
2284
+ .permission-gate.approved .pg-label { color: var(--allow); }
2285
+ .permission-gate.approved .pg-actions { display: none; }
2286
+ .permission-gate .pg-status {
2287
+ display: none;
2288
+ font-family: var(--mono); font-size: 10.5px;
2289
+ color: var(--allow);
2290
+ margin-top: 2px;
2291
+ }
2292
+ .permission-gate.approved .pg-status { display: block; }
2293
+
2294
+ /* v0.6 M3 — PM narrator block. Quiet style — visible but doesn't
2295
+ compete with primary content. Italic body to signal "this is
2296
+ narration, not authoritative" per overconfidence-guard. */
2297
+ .narrator-block {
2298
+ margin: 10px 0 8px;
2299
+ padding: 10px 14px;
2300
+ border-left: 2px solid var(--accent);
2301
+ background: rgba(255,154,118,0.04);
2302
+ border-radius: 0 8px 8px 0;
2303
+ }
2304
+ .narrator-block .narrator-label {
2305
+ font-family: var(--mono); font-size: 9.5px;
2306
+ color: var(--fg-30);
2307
+ text-transform: uppercase; letter-spacing: 0.14em;
2308
+ margin-bottom: 4px;
2309
+ }
2310
+ .narrator-block .narrator-text {
2311
+ font-family: var(--sans);
2312
+ font-size: 13px; line-height: 1.45;
2313
+ color: var(--fg-70);
2314
+ font-style: italic;
2315
+ }
2316
+ .narrator-block .narrator-meta {
2317
+ font-family: var(--mono); font-size: 10px;
2318
+ color: var(--fg-30);
2319
+ margin-top: 4px;
2320
+ }
2321
+
2322
+ /* Pattern 3 · Session card footer — elapsed · tools · files. Renders
2323
+ beneath the body when the session has at least one step. Closes the
2324
+ card visually + surfaces the same counts as the live tool-progress
2325
+ chip but in a settled, summary form. Cost intentionally absent here
2326
+ — that's P5's composer surface, not the per-card footer (per
2327
+ AGENTIC_UX.md Pattern 3: "elapsed · cost · tool count · file count"
2328
+ with cost-router not yet wired). */
2329
+ .session-foot {
2330
+ display: flex; gap: 14px; align-items: center;
2331
+ padding: 10px 20px 12px;
2332
+ border-top: 1px solid var(--border-soft);
2333
+ font-family: var(--mono); font-size: 11px; color: var(--fg-50);
2334
+ }
2335
+ .session-foot[data-empty="true"] { display: none; }
2336
+ .session-foot .sf-item { display: inline-flex; gap: 4px; align-items: center; }
2337
+ .session-foot .sf-item b { color: var(--fg-70); font-weight: 600; }
2338
+ .session-foot .sf-sep { color: var(--fg-20); user-select: none; }
1037
2339
 
1038
- .session-body { padding: 0 16px 14px; }
1039
2340
  .event-log {
1040
2341
  margin: 10px 0 0;
1041
2342
  max-height: 360px; overflow-y: auto;
@@ -1199,31 +2500,78 @@ ${SHARED_STYLES}
1199
2500
  <a href="/" class="nav-link active">Tasks</a>
1200
2501
  <a href="/threads" class="nav-link">Threads</a>
1201
2502
  <a href="/history" class="nav-link">History</a>
2503
+ <a href="https://codehere.uk" class="nav-link nav-external" target="_blank" rel="noopener" title="Open codehere.uk">Docs ↗</a>
2504
+ <span class="mode-indicator" data-mode="" id="mode-indicator" title="Loading safety-hook mode…"><span class="mi-dot"></span><span class="mi-label">…</span></span>
1202
2505
  </nav>
1203
2506
 
1204
2507
  <div class="shell slack-shape">
1205
2508
 
1206
2509
  <aside class="channels-pane">
1207
- <div class="pane-head"><span>Projects</span><span class="pane-count" id="channels-count">1</span></div>
1208
- <div id="channels-list">
1209
- <div class="channel active" data-channel="current">
1210
- <span class="channel-glyph">#</span>
1211
- <div style="flex:1;min-width:0">
1212
- <div class="channel-name" id="current-channel-name">project</div>
1213
- <div class="channel-cwd-hint" id="current-channel-cwd">~</div>
1214
- </div>
1215
- </div>
1216
- </div>
1217
- <!-- intentionally empty: multi-project sidebar arrives in v0.6.x -->
2510
+ <div class="pane-head"><span>Projects</span><span class="pane-count" id="channels-count">0</span></div>
2511
+ <div id="channels-list"><div class="pane-empty">Loading…</div></div>
1218
2512
  </aside>
1219
2513
 
1220
2514
  <section class="thread-pane">
1221
2515
 
1222
- <div class="hero-beam"></div>
2516
+ <!-- v0.6 S2 — first-time onboarding card. Hidden once the user
2517
+ dismisses (localStorage flag codehere.onboarded=1) OR once any
2518
+ session exists in the dashboard. Per claim_user_persona HIGH:
2519
+ non-technical users primary; the first 5 minutes is load-bearing
2520
+ (feedback_first_5_minutes_is_load_bearing.md). This card guides
2521
+ those 5 minutes: pick an agent → type a task → watch the report. -->
2522
+ <div class="onboarding-card" id="onboarding-card" style="display:none">
2523
+ <div class="ob-head">
2524
+ <div class="ob-title">Welcome to codehere.</div>
2525
+ <button type="button" class="ob-close" id="ob-close" aria-label="Dismiss">×</button>
2526
+ </div>
2527
+ <div class="ob-blurb">A PM for your AI coding agents. Delegate a task in plain English; codehere picks an agent, watches every action, and reports back.</div>
2528
+ <ol class="ob-steps">
2529
+ <li>
2530
+ <span class="ob-num">1</span>
2531
+ <div>
2532
+ <div class="ob-step-title">Pick an agent — or let codehere pick</div>
2533
+ <div class="ob-step-body">Auto-pick is the default and works for most tasks. Specialists (Claude Code, Codex, Aider, Gemini, OpenCode) are below.</div>
2534
+ </div>
2535
+ </li>
2536
+ <li>
2537
+ <span class="ob-num">2</span>
2538
+ <div>
2539
+ <div class="ob-step-title">Type what needs doing</div>
2540
+ <div class="ob-step-body">Plain English. Try: "Show me what this repo does in 3 bullets" or "Run the tests and tell me what failed."</div>
2541
+ </div>
2542
+ </li>
2543
+ <li>
2544
+ <span class="ob-num">3</span>
2545
+ <div>
2546
+ <div class="ob-step-title">Watch the Final Report</div>
2547
+ <div class="ob-step-body">When the agent finishes, codehere generates a plain-English summary of what changed, what was tested, and what to look at next.</div>
2548
+ </div>
2549
+ </li>
2550
+ </ol>
2551
+ </div>
1223
2552
 
1224
2553
  <div class="intake">
1225
- <h1>What needs <span class="gradient-text-shimmer">doing</span>?</h1>
1226
- <textarea id="prompt" placeholder="Describe a task. codehere picks the right agent and wraps it in a safety hook. Or pick one yourself below."></textarea>
2554
+ <textarea id="prompt" placeholder="What needs doing? Describe a task — codehere picks the right agent."></textarea>
2555
+ <!-- Pattern 5 · Composer meta line. UX-simplification iter (May 7):
2556
+ dropped the cm-cwd pill — the cwd input below is the authoritative
2557
+ editable surface; duplicating it as a non-editable pill caused
2558
+ desync (pill stayed "—" while input showed the daemon-restored
2559
+ cwd). Folded the keyboard-shortcut hint inline-left so the prior
2560
+ second .intake-row could be deleted. -->
2561
+ <div class="composer-meta" id="composer-meta">
2562
+ <span class="cm-pill cm-agent" id="cm-agent"><b>Auto</b></span>
2563
+ <span class="cm-sep">·</span>
2564
+ <span class="cm-pill cm-budget" id="cm-budget" data-state="ok"><b>0%</b><span>used</span></span>
2565
+ <span class="cm-sep">·</span>
2566
+ <!-- v0.6 S3 slice 1 — today's estimated spend across every completed
2567
+ session in ~/.codehere/sessions.jsonl. Hidden when zero so a
2568
+ fresh-install dashboard isn't cluttered with "$0.00 today". Tooltip
2569
+ points to vendor dashboards as the authoritative number. -->
2570
+ <span class="cm-pill cm-spend" id="cm-spend" style="display:none" title="Estimated. Vendor dashboards are authoritative."><b>$0.00</b><span>today</span></span>
2571
+ <span class="cm-hint">⌘+Enter to start · ⌘+Shift+Enter to reply to last task</span>
2572
+ <span class="cm-spacer"></span>
2573
+ <span class="cm-pill cm-cost" id="cm-cost" style="display:none"></span>
2574
+ </div>
1227
2575
  <div class="starter-prompts" id="starter-prompts">
1228
2576
  <span class="sp-label">TRY</span>
1229
2577
  <button type="button" data-prompt="Read the README and summarize what this project is in 3 short bullets.">Summarize this repo</button>
@@ -1231,14 +2579,12 @@ ${SHARED_STYLES}
1231
2579
  <button type="button" data-prompt="Run the tests and tell me what (if anything) is failing.">Run the tests</button>
1232
2580
  </div>
1233
2581
  <div class="intake-row">
1234
- <select id="agent" title="Auto-pick agent · codehere routes. Override below."><option value="">Auto-pick agent</option></select>
2582
+ <select id="agent" title="codehere picks the right tool unless you override"><option value="">Auto-pick</option></select>
1235
2583
  <input type="text" id="cwd" class="cwd" placeholder="folder" spellcheck="false" />
1236
- <button class="spawn" id="spawn">Assign</button>
2584
+ <button class="continue-thread" id="continue-thread" type="button" disabled title="Send as a reply to the most recent task in this folder (Slack-shape spike)">Continue thread</button>
2585
+ <button class="spawn" id="spawn">Assign new</button>
1237
2586
  </div>
1238
2587
  <div class="setup-panel" id="setup-panel" data-state=""></div>
1239
- <div class="intake-row" style="margin-top:6px">
1240
- <span class="hint" style="margin-left:auto">⌘ + Enter</span>
1241
- </div>
1242
2588
  </div>
1243
2589
 
1244
2590
  <!-- Apr 29 — UX overhaul: readiness banner + stats demoted below intake.
@@ -1246,22 +2592,28 @@ ${SHARED_STYLES}
1246
2592
  Goal: textarea is the focal point above the fold; chrome subordinates. -->
1247
2593
  <div class="readiness" id="readiness" style="position:relative;z-index:1"></div>
1248
2594
 
2595
+ <!-- May 6 2026 design polish (Tier 1 #3) + dogfood Bug A3 fix:
2596
+ The prior 4-stat row mixed two definitions of "agents" (the AGENTS
2597
+ right-pane shows installed-and-ready agents; "agents on machine" stat
2598
+ showed F1d-detected running processes — empty in practice) producing
2599
+ a contradiction visible to users (e.g. "0 agents on machine" while the
2600
+ right pane shows 6). Plus "blocked" was almost always 0 and "avg trust
2601
+ 100" had no signal. Per Open-Agents production-grade reference and
2602
+ claim_user_persona (HIGH non-technical-first): subtract dashboard noise.
2603
+ Keep only the one stat that varies meaningfully — actions today —
2604
+ rendered as a quiet muted line. The other IDs stay in the DOM (hidden)
2605
+ so existing setText calls in updateBanner don't NPE. -->
1249
2606
  <div class="stats-line empty" id="audit-summary" style="position:relative;z-index:1">
1250
2607
  <span><b id="as-actions">0</b> actions today</span>
1251
- <span class="sep">·</span>
1252
- <span><b id="sb-detected">0</b> agents on machine</span>
1253
- <span class="sep">·</span>
1254
- <span class="blocked"><b id="as-blocked">0</b> blocked</span>
1255
- <span class="sep">·</span>
1256
- <span>avg trust <b id="as-trust">—</b></span>
1257
- <!-- Hidden but kept in DOM so existing JS that updates these IDs doesn't error -->
1258
- <span style="display:none"><span id="sb-running">0</span><span id="sb-blocked">0</span><span id="sb-last">—</span><span id="as-agents">0</span></span>
1259
2608
  </div>
1260
2609
 
1261
- <div class="glow-line" style="position:relative;z-index:1"></div>
1262
-
2610
+ <!-- UX-simplification iter 3 (May 7): dropped the .glow-line peach
2611
+ divider here. The .section-label "Sessions" below already provides
2612
+ visual separation; an extra 1px gradient line was decoration not
2613
+ information. -->
1263
2614
  <div class="section-label" style="position:relative;z-index:1">Sessions</div>
1264
2615
  <div id="sessions" style="position:relative;z-index:1"></div>
2616
+ <button class="show-more-sessions" id="show-more-sessions" type="button">Show all sessions</button>
1265
2617
  <div id="empty">
1266
2618
  <div class="pill">No sessions yet. Assign one above.</div>
1267
2619
  </div>
@@ -1287,6 +2639,170 @@ ${SHARED_STYLES}
1287
2639
  const emptyEl = document.getElementById('empty');
1288
2640
  const sessions = new Map(); // sessionId → { card, logEl, unsubscribeSse }
1289
2641
 
2642
+ // v0.6 S2 — first-time onboarding card. Show ONLY when:
2643
+ // (a) localStorage flag codehere.onboarded is unset, AND
2644
+ // (b) no sessions yet (empty-state).
2645
+ // Hide when user dismisses (sets flag) OR when first session attaches.
2646
+ var obCard = document.getElementById('onboarding-card');
2647
+ var obClose = document.getElementById('ob-close');
2648
+ var ONBOARDED_KEY = 'codehere.onboarded';
2649
+ function isOnboarded() {
2650
+ try { return window.localStorage.getItem(ONBOARDED_KEY) === '1'; }
2651
+ catch (_) { return false; }
2652
+ }
2653
+ function markOnboarded() {
2654
+ try { window.localStorage.setItem(ONBOARDED_KEY, '1'); }
2655
+ catch (_) { /* private mode: persist for the session via the var below */ }
2656
+ }
2657
+ function maybeShowOnboarding() {
2658
+ if (!obCard) return;
2659
+ if (isOnboarded()) { obCard.style.display = 'none'; return; }
2660
+ obCard.style.display = '';
2661
+ }
2662
+ function dismissOnboarding() {
2663
+ if (!obCard) return;
2664
+ obCard.style.display = 'none';
2665
+ markOnboarded();
2666
+ }
2667
+ if (obClose) obClose.addEventListener('click', dismissOnboarding);
2668
+ // Render decision runs immediately + after first sessions fetch
2669
+ // (which sets sessions Map). Hide if any sessions already exist
2670
+ // (returning user — onboarding doesn't help).
2671
+ maybeShowOnboarding();
2672
+
2673
+ // Pattern 5 — composer meta line wiring. Live-updates: agent label,
2674
+ // context-budget %, cost preview. Bound below to the input surfaces
2675
+ // (prompt textarea, agent select). UX-simplification iter (May 7):
2676
+ // dropped cm-cwd pill — cwd input below is the authoritative surface.
2677
+ const cmAgentEl = document.getElementById('cm-agent');
2678
+ const cmBudgetEl = document.getElementById('cm-budget');
2679
+ const cmCostEl = document.getElementById('cm-cost');
2680
+ // v0.6 S3 slice 1 — today's spend chip. Lives next to the context-budget
2681
+ // chip; hidden until the first non-zero spend lands so a fresh-install
2682
+ // dashboard isn't cluttered. Polled, not pushed (events would need
2683
+ // server-side fanout).
2684
+ const cmSpendEl = document.getElementById('cm-spend');
2685
+ function refreshSpendToday() {
2686
+ if (!cmSpendEl) return;
2687
+ fetch('/api/spend/today')
2688
+ .then(function(r) { return r.ok ? r.json() : null; })
2689
+ .then(function(d) {
2690
+ if (!d || typeof d.totalUsd !== 'number') return;
2691
+ // Show the chip when EITHER spend > 0 OR a budget is set (so the
2692
+ // user sees "$0.00 / $5.00" right after \`codehere budget set 5\`).
2693
+ var budget = d.budget || {};
2694
+ var hasBudget = budget.dailyUsd !== null && typeof budget.dailyUsd === 'number';
2695
+ if (d.totalUsd <= 0 && d.sessionCount === 0 && !hasBudget) {
2696
+ cmSpendEl.style.display = 'none';
2697
+ return;
2698
+ }
2699
+ cmSpendEl.style.display = 'inline-flex';
2700
+ var b = cmSpendEl.querySelector('b');
2701
+ if (b) {
2702
+ var dollars = d.totalUsd;
2703
+ var spendStr = dollars >= 0.01 ? '$' + dollars.toFixed(2) : '$' + dollars.toFixed(3);
2704
+ // When a budget is set, render "$X.XX / $Y" so the ceiling is visible.
2705
+ b.textContent = hasBudget
2706
+ ? spendStr + ' / $' + budget.dailyUsd.toFixed(2)
2707
+ : spendStr;
2708
+ }
2709
+ // Color state — warn at warnAtPct, block when over.
2710
+ var state = 'ok';
2711
+ if (hasBudget) {
2712
+ if (budget.over) state = 'block';
2713
+ else if (typeof budget.pctUsed === 'number' && budget.pctUsed >= (budget.warnAtPct || 70)) state = 'warn';
2714
+ }
2715
+ cmSpendEl.dataset.state = state;
2716
+ // Tooltip: per-agent breakdown + budget context if set.
2717
+ var lines = ['Estimated spend today across ' + d.sessionCount + ' completed session' + (d.sessionCount === 1 ? '' : 's') + ':'];
2718
+ var keys = Object.keys(d.byAgent || {});
2719
+ keys.sort(function(a, b) { return (d.byAgent[b].usd || 0) - (d.byAgent[a].usd || 0); });
2720
+ for (var i = 0; i < keys.length; i++) {
2721
+ var k = keys[i];
2722
+ var v = d.byAgent[k];
2723
+ lines.push(' ' + k + ': $' + (v.usd >= 0.01 ? v.usd.toFixed(2) : v.usd.toFixed(3)) + ' (' + v.sessions + 'x)');
2724
+ }
2725
+ if (hasBudget) {
2726
+ lines.push('');
2727
+ lines.push('Daily budget: $' + budget.dailyUsd.toFixed(2) + ' (' + (budget.pctUsed || 0) + '% used)');
2728
+ if (budget.over) {
2729
+ lines.push('OVER BUDGET — paid spawns will be refused. Use a free agent (gemini, opencode) or run: codehere budget set <higher-amount>');
2730
+ }
2731
+ }
2732
+ lines.push('Vendor dashboards are authoritative — these are tier-ceiling estimates.');
2733
+ cmSpendEl.title = lines.join('\\n');
2734
+ })
2735
+ .catch(function() { /* skip */ });
2736
+ }
2737
+ // Refresh on dashboard load + every 60s (sessions complete async, no point
2738
+ // polling faster than per-minute).
2739
+ refreshSpendToday();
2740
+ setInterval(refreshSpendToday, 60_000);
2741
+
2742
+ // Free-tier agents per feedback_adapters_must_have_free_tier.md. Cost
2743
+ // preview hides for these (the user pays nothing per call). Conductor
2744
+ // routes to one of the specialists, so its cost mirrors the picked one.
2745
+ const FREE_TIER_AGENTS = new Set(['gemini', 'opencode']);
2746
+
2747
+ // Per-agent rough cost estimate ($ per 1K input + 1K output tokens combined).
2748
+ // These are CEILINGS for ballpark ("~$0.0X likely") not precise — adapter
2749
+ // doesn't surface model selection, so the heuristic uses a balanced-tier
2750
+ // assumption. Better to under-promise; users care about scale-of-magnitude.
2751
+ const COST_PER_KTOK = {
2752
+ 'claude-code': 0.018, // sonnet-tier balanced
2753
+ 'codex': 0.020, // gpt-5-codex balanced
2754
+ 'aider': 0.018, // typically sonnet
2755
+ 'conductor': 0.018, // routes to claude-code by default
2756
+ };
2757
+
2758
+ function syncComposerMeta() {
2759
+ if (!cmAgentEl || !cmBudgetEl) return;
2760
+ // Resolve agent label. Empty value = Conductor-Auto routing.
2761
+ var pickedId = (agentSelect && agentSelect.value) || '';
2762
+ var label = pickedId
2763
+ ? agentLabel(pickedId)
2764
+ : 'Auto';
2765
+ cmAgentEl.querySelector('b').textContent = label;
2766
+ // Context budget — char-count / 800K (≈ 200K-token Sonnet budget).
2767
+ // Cap at 100. --warn at 70, --block at 90 per spec.
2768
+ var chars = (promptEl && promptEl.value && promptEl.value.length) || 0;
2769
+ var pct = Math.min(100, Math.round((chars / 8000)));
2770
+ var budgetState = pct >= 90 ? 'block' : pct >= 70 ? 'warn' : 'ok';
2771
+ cmBudgetEl.dataset.state = budgetState;
2772
+ cmBudgetEl.querySelector('b').textContent = pct + '%';
2773
+ // Cost preview (paid agents only). Auto resolves to the conductor, so
2774
+ // pick the cheapest balanced-tier estimate when no agent is set.
2775
+ if (!cmCostEl) return;
2776
+ var effective = pickedId || 'conductor';
2777
+ if (FREE_TIER_AGENTS.has(effective)) {
2778
+ cmCostEl.style.display = 'inline-flex';
2779
+ cmCostEl.className = 'cm-pill cm-cost free';
2780
+ cmCostEl.innerHTML = '<b>free tier</b>';
2781
+ cmCostEl.title = effective + ' has a free tier — no per-call billing';
2782
+ } else if (COST_PER_KTOK[effective] && chars > 20) {
2783
+ // Rough heuristic: input tokens ≈ chars/4, output ~2× input for code
2784
+ // tasks; clamp to a sensible floor. Show 2 decimals when > 0.01,
2785
+ // 3 decimals otherwise so 4¢ doesn't render as "$0.04".
2786
+ var inputTok = Math.ceil(chars / 4);
2787
+ var outputTok = Math.max(500, Math.min(3000, inputTok * 2));
2788
+ var totalK = (inputTok + outputTok) / 1000;
2789
+ var dollars = totalK * COST_PER_KTOK[effective];
2790
+ var dollarsStr = dollars >= 0.01
2791
+ ? '~$' + (Math.round(dollars * 100) / 100).toFixed(2)
2792
+ : '~$' + (Math.round(dollars * 1000) / 1000).toFixed(3);
2793
+ cmCostEl.style.display = 'inline-flex';
2794
+ cmCostEl.className = 'cm-pill cm-cost paid';
2795
+ cmCostEl.innerHTML = '<b>' + dollarsStr + '</b><span> est.</span>';
2796
+ cmCostEl.title = 'Estimate: ' + inputTok + ' input + ~' + outputTok + ' output tokens at balanced-tier rates';
2797
+ } else {
2798
+ cmCostEl.style.display = 'none';
2799
+ }
2800
+ }
2801
+ if (promptEl) promptEl.addEventListener('input', syncComposerMeta);
2802
+ if (agentSelect) agentSelect.addEventListener('change', syncComposerMeta);
2803
+ // Initial paint.
2804
+ syncComposerMeta();
2805
+
1290
2806
  // E3 · Populate agent dropdown from per-agent readiness.
1291
2807
  // Setup-aware: shows every registered agent with a state glyph (not just
1292
2808
  // installed ones). Selecting a non-ready agent unfolds the setup panel with
@@ -1317,14 +2833,30 @@ ${SHARED_STYLES}
1317
2833
  const sorted = [...agentReadiness].sort((a, b) => (weight[a.state] ?? 99) - (weight[b.state] ?? 99));
1318
2834
  const autoState = autoReadiness ? autoReadiness.state : 'ready';
1319
2835
  const autoGlyph = autoState === 'ready' ? '⚡' : (STATE_GLYPH[autoState] || '⚠');
1320
- const autoLabel = autoState === 'ready' ? 'Auto · codehere routes' : 'Auto · install an agent first';
2836
+ const autoLabel = autoState === 'ready' ? 'Auto-pick (codehere picks for you)' : 'Auto-pick (install an agent first)';
1321
2837
  const autoOption = '<option value="" selected>' + autoGlyph + ' ' + autoLabel + '</option>';
1322
- const specialistOptions = sorted.map(a =>
1323
- '<option value="' + escape(a.id) + '">' +
1324
- (STATE_GLYPH[a.state] || '·') + ' ' + escape(a.label) +
1325
- '</option>'
1326
- ).join('');
1327
- agentSelect.innerHTML = autoOption + specialistOptions;
2838
+ const specialistOptions = sorted.map(a => {
2839
+ // v0.6 S5 — adapter description surfaces as the option's hover title so
2840
+ // non-technical users get a plain-English "when to use me" hint without
2841
+ // having to read docs. Falls back to label-only when adapter has none.
2842
+ const titleAttr = a.description
2843
+ ? ' title="' + escape(a.description) + '"'
2844
+ : '';
2845
+ return '<option value="' + escape(a.id) + '"' + titleAttr + '>' +
2846
+ (STATE_GLYPH[a.state] || '·') + ' ' + escape(a.label) +
2847
+ '</option>';
2848
+ }).join('');
2849
+ // M5 slice 2 — fan-out option. Sends the same prompt to every
2850
+ // installed specialist in parallel via /api/task/batch. Per
2851
+ // claim_codehere_shape v5 + Decision 22 (every coding agent a
2852
+ // first-class member), cross-check spawning is the v1.0 value
2853
+ // prop. Hidden when fewer than 2 specialists are ready (no
2854
+ // parallel value with one). Capped at 5 by the batch endpoint.
2855
+ const readyCount = sorted.filter(a => a.state === 'ready').length;
2856
+ const fanOutOption = readyCount >= 2
2857
+ ? '<option value="*">⚡⚡ Send to every installed agent (' + readyCount + ' in parallel)</option>'
2858
+ : '';
2859
+ agentSelect.innerHTML = autoOption + fanOutOption + specialistOptions;
1328
2860
  // Trigger the panel render for whatever is selected first
1329
2861
  updateSetupPanel();
1330
2862
  }
@@ -1518,6 +3050,7 @@ ${SHARED_STYLES}
1518
3050
  (a.state || 'unknown');
1519
3051
  return '<div class="member" data-agent-id="' + escape(a.id) + '" title="' + escape(stateTitle) + '">' +
1520
3052
  '<span class="member-presence ' + cls + '">' + glyph + '</span>' +
3053
+ agentBadgeHTML(a.id) + ' ' +
1521
3054
  '<span class="member-label">' + escape(a.label || a.id) + '</span>' +
1522
3055
  '</div>';
1523
3056
  }).join('');
@@ -1551,22 +3084,147 @@ ${SHARED_STYLES}
1551
3084
  // daemon already knows where it was launched and that's usually the right cwd.
1552
3085
  const savedCwd = localStorage.getItem('codehere:cwd');
1553
3086
  if (savedCwd) cwdInput.value = savedCwd;
3087
+
3088
+ // Track daemon cwd for active-channel highlight in the projects sidebar.
3089
+ // Refreshed on every /api/readiness call; multi-project sidebar reads it.
3090
+ let daemonCwdForActive = null;
3091
+
1554
3092
  fetch('/api/readiness').then(r => r.json()).then(r => {
1555
3093
  const daemonCwd = r && r.detected && r.detected.daemonCwd;
1556
3094
  if (daemonCwd) {
1557
3095
  cwdInput.placeholder = daemonCwd;
1558
3096
  if (!cwdInput.value.trim()) cwdInput.value = daemonCwd; // auto-fill when nothing saved
1559
- // Apr 29 — Slack-shape channels pane: surface the daemon cwd as the
1560
- // current "channel" name + path. Multi-project ships in a later release.
1561
- const nameEl = document.getElementById('current-channel-name');
1562
- const cwdEl = document.getElementById('current-channel-cwd');
1563
- if (nameEl) {
1564
- const parts = String(daemonCwd).split('/').filter(Boolean);
1565
- nameEl.textContent = parts[parts.length - 1] || 'project';
1566
- }
1567
- if (cwdEl) cwdEl.textContent = daemonCwd;
3097
+ daemonCwdForActive = daemonCwd;
3098
+ loadDashboardProjects(); // re-render sidebar now that we know which project is "active"
1568
3099
  }
1569
3100
  }).catch(() => { /* placeholder unchanged */ });
3101
+
3102
+ // Multi-project sidebar — bug A1 fix (May 5 2026). Pre-fix: sidebar showed
3103
+ // a single hardcoded "current" channel that was un-clickable; users hit it
3104
+ // expecting to switch projects. Post-fix: real /api/projects load, click
3105
+ // navigates to /threads?project=<slug> (where the actual thread surface
3106
+ // lives), pencil rename works inline, active state matches daemon cwd.
3107
+ function shortPathDash(p) {
3108
+ if (!p) return '';
3109
+ const home = (window.location && window.location.hostname) ? '~' : '~';
3110
+ if (p.length <= 32) return p;
3111
+ const parts = p.split('/').filter(Boolean);
3112
+ if (parts.length <= 2) return p;
3113
+ return '…/' + parts.slice(-2).join('/');
3114
+ }
3115
+ function escapeHtmlDash(s) {
3116
+ if (s == null) return '';
3117
+ return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
3118
+ }
3119
+ function renderDashboardProjects(list) {
3120
+ const wrap = document.getElementById('channels-list');
3121
+ const countEl = document.getElementById('channels-count');
3122
+ if (!wrap) return;
3123
+ if (countEl) countEl.textContent = list.length;
3124
+ if (list.length === 0) {
3125
+ wrap.innerHTML = '<div class="pane-empty">No projects yet</div>';
3126
+ return;
3127
+ }
3128
+ wrap.innerHTML = list.map(p => {
3129
+ const isActive = (daemonCwdForActive && p.cwd === daemonCwdForActive) ? ' active' : '';
3130
+ const name = escapeHtmlDash(p.title || '(untitled)');
3131
+ const cwdHint = escapeHtmlDash(shortPathDash(p.cwd));
3132
+ // Pattern 4 — multi-agent presence stack. Up to 3 dots (most-recent
3133
+ // first, deduped). Empty span when no agents in the thread (older
3134
+ // pre-mirror projects). agentPresenceDotHTML lives in AGENT_BADGE_JS;
3135
+ // each dot is 6px, vendor-slot color, no letter.
3136
+ const agents = Array.isArray(p.recentAgents) ? p.recentAgents : (p.lastAgent ? [p.lastAgent] : []);
3137
+ const presenceStack = agents.length > 0
3138
+ ? '<span class="presence-stack" title="Recent agents: ' + escapeHtmlDash(agents.join(', ')) + '">' +
3139
+ agents.map(a => agentPresenceDotHTML(a)).join('') +
3140
+ '</span>'
3141
+ : '<span class="presence-stack presence-stack-empty"></span>';
3142
+ return '<div class="channel' + isActive + '" data-slug="' + escapeHtmlDash(p.slug) + '" title="Open project thread">' +
3143
+ '<span class="channel-glyph">#</span>' +
3144
+ '<div style="flex:1;min-width:0">' +
3145
+ '<div class="channel-name">' + name + '</div>' +
3146
+ '<div class="channel-cwd-hint">' + cwdHint + '</div>' +
3147
+ '</div>' +
3148
+ presenceStack +
3149
+ '<button class="channel-rename" data-slug="' + escapeHtmlDash(p.slug) + '" title="Rename project" aria-label="Rename project"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg></button>' +
3150
+ '</div>';
3151
+ }).join('');
3152
+ wrap.querySelectorAll('.channel').forEach(el => {
3153
+ el.addEventListener('click', (ev) => {
3154
+ if (ev.target && ev.target.classList && ev.target.classList.contains('channel-rename')) return;
3155
+ const slug = el.getAttribute('data-slug');
3156
+ if (slug) window.location.href = '/threads?project=' + encodeURIComponent(slug);
3157
+ });
3158
+ });
3159
+ wrap.querySelectorAll('.channel-rename').forEach(btn => {
3160
+ btn.addEventListener('click', (ev) => {
3161
+ ev.stopPropagation();
3162
+ const slug = btn.getAttribute('data-slug');
3163
+ if (!slug) return;
3164
+ const channel = btn.closest('.channel');
3165
+ const project = list.find(p => p.slug === slug);
3166
+ const current = (project && project.title) || '';
3167
+ startInlineRename(channel, slug, current, () => loadDashboardProjects());
3168
+ });
3169
+ });
3170
+ }
3171
+ // Inline edit-in-place — replaces prior window.prompt() dialog. Click pencil →
3172
+ // .channel-name turns into an input pre-filled with the title; Enter saves,
3173
+ // Esc cancels, blur saves. Validation errors flash the border red instead
3174
+ // of a window.alert. Per Open-Agents production-grade UX reference.
3175
+ function startInlineRename(channelEl, slug, currentTitle, onSaveSuccess) {
3176
+ if (!channelEl) return;
3177
+ const nameEl = channelEl.querySelector('.channel-name');
3178
+ if (!nameEl || nameEl.dataset.editing === '1') return;
3179
+ nameEl.dataset.editing = '1';
3180
+ const originalText = nameEl.textContent;
3181
+ const input = document.createElement('input');
3182
+ input.type = 'text';
3183
+ input.value = currentTitle;
3184
+ input.maxLength = 100;
3185
+ input.className = 'channel-rename-input';
3186
+ nameEl.textContent = '';
3187
+ nameEl.appendChild(input);
3188
+ setTimeout(() => { input.focus(); input.select(); }, 0);
3189
+ let cleanedUp = false;
3190
+ function cleanup(restoreText) {
3191
+ if (cleanedUp) return;
3192
+ cleanedUp = true;
3193
+ try { nameEl.removeChild(input); } catch (_) { /* already removed */ }
3194
+ nameEl.textContent = restoreText;
3195
+ delete nameEl.dataset.editing;
3196
+ }
3197
+ function commit() {
3198
+ if (cleanedUp) return;
3199
+ const trimmed = input.value.trim();
3200
+ if (!trimmed || trimmed === currentTitle) { cleanup(originalText); return; }
3201
+ fetch('/api/projects/' + encodeURIComponent(slug) + '/rename', {
3202
+ method: 'POST',
3203
+ headers: { 'Content-Type': 'application/json' },
3204
+ body: JSON.stringify({ title: trimmed }),
3205
+ })
3206
+ .then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e.error || 'rename_failed')))
3207
+ .then(() => { cleanup(trimmed); onSaveSuccess(); })
3208
+ .catch(() => {
3209
+ input.classList.remove('error');
3210
+ // Force reflow so animation re-fires
3211
+ void input.offsetWidth;
3212
+ input.classList.add('error');
3213
+ });
3214
+ }
3215
+ input.addEventListener('keydown', (ev) => {
3216
+ if (ev.key === 'Enter') { ev.preventDefault(); commit(); }
3217
+ else if (ev.key === 'Escape') { ev.preventDefault(); cleanup(originalText); }
3218
+ });
3219
+ input.addEventListener('blur', () => { if (!cleanedUp) commit(); });
3220
+ }
3221
+ function loadDashboardProjects() {
3222
+ fetch('/api/projects')
3223
+ .then(r => r.ok ? r.json() : Promise.reject(r.status))
3224
+ .then(data => renderDashboardProjects(data.projects || []))
3225
+ .catch(() => { /* leave loading state */ });
3226
+ }
3227
+ loadDashboardProjects();
1570
3228
  cwdInput.addEventListener('change', () => {
1571
3229
  localStorage.setItem('codehere:cwd', cwdInput.value.trim());
1572
3230
  });
@@ -1758,15 +3416,53 @@ ${SHARED_STYLES}
1758
3416
  logEl.scrollTop = logEl.scrollHeight;
1759
3417
  }
1760
3418
 
3419
+ // Status display labels: keep the underlying status string ('killed', 'error')
3420
+ // as the data-layer truth, but map to a calmer human label. May 6 2026 polish
3421
+ // per Open-Agents reference + claim_user_persona (non-technical readers
3422
+ // don't think of their work as "KILLED" — they think "I stopped it").
3423
+ // Pattern 6 · macro state → user-visible copy. Plain English; non-technical-
3424
+ // first per claim_user_persona HIGH. Each state's CSS class is the same
3425
+ // string with hyphens (e.g. status === 'rate_limit' → class="status-pill
3426
+ // rate-limit"); CSS classes can't contain underscores cleanly, so the
3427
+ // wire-format state has underscores and the class form has hyphens.
3428
+ // Per docs/design/AGENTIC_UX.md Pattern 6.
3429
+ var STATE_LABELS = {
3430
+ 'thinking': 'Thinking',
3431
+ 'running': 'Running',
3432
+ 'running_buffered': 'Running (buffered)',
3433
+ 'completed': 'Done',
3434
+ 'awaiting': 'Waiting on you',
3435
+ 'blocked': 'Blocked',
3436
+ 'killed': 'Stopped',
3437
+ 'crashed': 'Crashed',
3438
+ 'error': 'Failed',
3439
+ 'failed': 'Failed',
3440
+ 'rate_limit': 'Rate-limited',
3441
+ 'quota_exceeded': 'Quota exceeded',
3442
+ 'credit_low': 'Credits low',
3443
+ 'auth_invalid': 'Sign-in needed',
3444
+ 'auth_missing': 'Sign-in needed',
3445
+ 'model_unavailable':'Model unavailable'
3446
+ };
3447
+ function statusLabel(status) {
3448
+ if (!status) return 'Unknown';
3449
+ if (STATE_LABELS[status]) return STATE_LABELS[status];
3450
+ return status.charAt(0).toUpperCase() + status.slice(1);
3451
+ }
3452
+ // Convert wire-format state ('rate_limit') to CSS class form ('rate-limit').
3453
+ function statusClass(status) {
3454
+ return String(status || '').replace(/_/g, '-');
3455
+ }
1761
3456
  function updateStatus(pillEl, status) {
1762
- pillEl.className = 'status-pill ' + status;
1763
- pillEl.textContent = status;
3457
+ pillEl.className = 'status-pill ' + statusClass(status);
3458
+ pillEl.textContent = statusLabel(status);
1764
3459
  }
1765
3460
 
1766
- function agentLabel(id) {
1767
- const labels = { 'claude-code': 'Claude Code', 'codex': 'Codex', 'aider': 'Aider', 'kiro': 'Kiro' };
1768
- return labels[id] || id || 'agent';
1769
- }
3461
+ // Per-agent badge helpers (agentLabel, AGENT_SLOTS, AGENT_ICONS,
3462
+ // agentBadgeHTML, agentPresenceDotHTML) interpolated from AGENT_BADGE_JS
3463
+ // — single source of truth shared with THREAD_HTML and any other page
3464
+ // that needs vendor-attribution rendering. Per Pattern 10.
3465
+ ${AGENT_BADGE_JS}
1770
3466
 
1771
3467
  function attachSession(session) {
1772
3468
  const card = document.createElement('div');
@@ -1781,17 +3477,20 @@ ${SHARED_STYLES}
1781
3477
  '<div class="session-head">' +
1782
3478
  '<div class="meta">' +
1783
3479
  '<div class="prompt" title="' + escape(session.prompt) + '">' +
1784
- '<span class="card-lights"><span class="r"></span><span class="y"></span><span class="g"></span></span>' +
1785
3480
  escape(session.prompt) +
1786
3481
  '<span class="chevron">▸</span>' +
1787
3482
  '</div>' +
1788
- '<div class="sub">' + liveDot + escape(agentLabel(session.agent)) + ' · ' + new Date(session.startedAt).toLocaleTimeString() +
3483
+ '<div class="sub">' + liveDot + agentBadgeHTML(session.agent) + ' ' + escape(agentLabel(session.agent)) +
3484
+ (session.cwd ? ' · <span class="sub-cwd" title="' + escape(session.cwd) + '">' + escape(cwdTail(session.cwd)) + '</span>' : '') +
3485
+ ' · ' + new Date(session.startedAt).toLocaleTimeString() +
1789
3486
  '<span class="safety-pill" style="display:none">' +
1790
3487
  '<span class="shield">🛡</span> ' +
1791
3488
  '<span class="safe">0</span> safe' +
1792
3489
  '<span class="blocked-portion" style="display:none"> · <span class="blocked">0</span> blocked</span>' +
1793
3490
  '</span>' +
1794
3491
  '</div>' +
3492
+ '<div class="tool-progress-chip" data-state="hidden"></div>' +
3493
+ '<div class="micro-activity"></div>' +
1795
3494
  '<div class="steps-mini"></div>' +
1796
3495
  '</div>' +
1797
3496
  '<div class="session-controls">' +
@@ -1799,7 +3498,7 @@ ${SHARED_STYLES}
1799
3498
  '<button class="view-thread active" data-view="thread">Thread</button>' +
1800
3499
  '<button class="view-raw" data-view="raw">Log</button>' +
1801
3500
  '</div>' +
1802
- '<span class="status-pill ' + session.status + runningClass + '">' + session.status + '</span>' +
3501
+ '<span class="status-pill ' + session.status + runningClass + '">' + statusLabel(session.status) + '</span>' +
1803
3502
  '<button class="kill">Kill</button>' +
1804
3503
  '</div>' +
1805
3504
  '</div>' +
@@ -1810,6 +3509,23 @@ ${SHARED_STYLES}
1810
3509
  // so on completed sessions the Final Report appeared above the user's
1811
3510
  // seed prompt, breaking chronological order in thread view.
1812
3511
  '<div class="thread-messages"></div>' +
3512
+ // P18 slice 4 — permission-gate stack. Empty container; panels
3513
+ // get inserted by the per-session blocks poll when the per-tool
3514
+ // hook fires for this session. Each block becomes one panel with
3515
+ // an "Approve in this folder" button.
3516
+ '<div class="permission-gates"></div>' +
3517
+ // v0.6 M3 — PM narrator block. Hidden until first narration
3518
+ // arrives; renders one paragraph plain-English of what the
3519
+ // agent is doing right now. Per claim_user_persona HIGH:
3520
+ // non-technical users read this, not the tool calls.
3521
+ // Per feedback_overconfidence_guard.md: framed as "narrator"
3522
+ // not authoritative truth — narration is an LLM call, may
3523
+ // hallucinate.
3524
+ '<div class="narrator-block" style="display:none">' +
3525
+ '<div class="narrator-label">narrator</div>' +
3526
+ '<div class="narrator-text"></div>' +
3527
+ '<div class="narrator-meta"></div>' +
3528
+ '</div>' +
1813
3529
  '<div class="final-report" style="display:none"></div>' +
1814
3530
  // Iter 12 — reply surface now functional; replaced the prior dead
1815
3531
  // placeholder. Active for adapters with canAppendMessage=true
@@ -1821,16 +3537,291 @@ ${SHARED_STYLES}
1821
3537
  '<div class="reply-status" style="display:none"></div>' +
1822
3538
  '</div>' +
1823
3539
  '<div class="event-log"></div>' +
3540
+ '</div>' +
3541
+ // Pattern 3 — session footer. Hidden until populated (data-empty=true);
3542
+ // renderToolProgress flips it to data-empty=false once steps appear.
3543
+ '<div class="session-foot" data-empty="true">' +
3544
+ '<span class="sf-item sf-elapsed"><b>0s</b><span>elapsed</span></span>' +
3545
+ '<span class="sf-sep">·</span>' +
3546
+ '<span class="sf-item sf-tools"><b>0</b><span>tools</span></span>' +
3547
+ '<span class="sf-sep">·</span>' +
3548
+ '<span class="sf-item sf-files"><b>0</b><span>files</span></span>' +
1824
3549
  '</div>';
1825
3550
 
1826
3551
  sessionsEl.prepend(card);
1827
3552
  emptyEl.style.display = 'none';
3553
+ // v0.6 S2 — first session attached means user has done step 1+2;
3554
+ // dismiss the onboarding card. They can re-open by clearing local
3555
+ // storage, but for the typical flow this is "I'm using it now".
3556
+ dismissOnboarding();
1828
3557
 
1829
3558
  const logEl = card.querySelector('.event-log');
1830
3559
  const pillEl = card.querySelector('.status-pill');
1831
3560
  const killBtn = card.querySelector('.kill');
1832
3561
  const headEl = card.querySelector('.session-head');
1833
3562
  const stepsEl = card.querySelector('.steps-mini');
3563
+ const microActivityEl = card.querySelector('.micro-activity');
3564
+ const toolProgressEl = card.querySelector('.tool-progress-chip');
3565
+ // Pattern 1 — tool-progress live chip. Renders "12 tools | 3/4 ●●●○ | Ns".
3566
+ // Sources from the same codehere:steps synthetic event renderSteps reads.
3567
+ function fmtElapsed(ms) {
3568
+ var s = Math.max(0, Math.floor(ms / 1000));
3569
+ if (s < 60) return s + 's';
3570
+ var m = Math.floor(s / 60);
3571
+ var rem = s % 60;
3572
+ if (m < 60) return rem ? m + 'm ' + rem + 's' : m + 'm';
3573
+ var h = Math.floor(m / 60);
3574
+ return h + 'h ' + (m % 60) + 'm';
3575
+ }
3576
+ function renderToolProgress(steps) {
3577
+ if (!toolProgressEl) return;
3578
+ if (!Array.isArray(steps) || steps.length === 0) {
3579
+ toolProgressEl.dataset.state = 'hidden';
3580
+ toolProgressEl.innerHTML = '';
3581
+ return;
3582
+ }
3583
+ // Compute counts + state.
3584
+ var done = 0, active = 0, failed = 0, pending = 0;
3585
+ for (var i = 0; i < steps.length; i++) {
3586
+ var st = String(steps[i].state || '');
3587
+ if (st === 'done') done++;
3588
+ else if (st === 'active') active++;
3589
+ else if (st === 'failed') failed++;
3590
+ else pending++;
3591
+ }
3592
+ var total = steps.length;
3593
+ var chipState;
3594
+ if (failed > 0) chipState = 'failed';
3595
+ else if (session.status !== 'running') chipState = (done === total ? 'complete' : 'paused');
3596
+ else if (active > 0) chipState = 'running';
3597
+ else chipState = 'paused';
3598
+
3599
+ // Dot pattern — last 6 steps, oldest first. ● = done, ◐ = active,
3600
+ // ○ = pending, ✗ = failed. Cap at 6 so the chip doesn't grow.
3601
+ var tail = steps.slice(-6);
3602
+ var dots = tail.map(function(s) {
3603
+ var st = String(s.state || '');
3604
+ if (st === 'done') return '●';
3605
+ if (st === 'active') return '◐';
3606
+ if (st === 'failed') return '✗';
3607
+ return '○';
3608
+ }).join('');
3609
+
3610
+ var elapsed = fmtElapsed(Date.now() - session.startedAt);
3611
+ toolProgressEl.dataset.state = chipState;
3612
+ toolProgressEl.innerHTML =
3613
+ '<span>' + total + ' tool' + (total === 1 ? '' : 's') + '</span>' +
3614
+ '<span class="tp-sep">|</span>' +
3615
+ '<span>' + done + '/' + total + ' <span class="tp-dots">' + dots + '</span></span>' +
3616
+ '<span class="tp-sep">|</span>' +
3617
+ '<span class="tp-elapsed">' + elapsed + '</span>';
3618
+
3619
+ // Pattern 3 — keep the footer in sync with the same step data the
3620
+ // chip reads. Cheaper than a separate event subscription and stays
3621
+ // structurally consistent with the live counts above.
3622
+ renderSessionFoot(steps);
3623
+ }
3624
+ // Pattern 3 — session-foot: elapsed · tools · files. Reveals on first
3625
+ // step; updates counts on every renderSteps call. Read-only — no
3626
+ // click affordance (rec'y / file-list belong elsewhere).
3627
+ var sessionFootEl = card.querySelector('.session-foot');
3628
+ function renderSessionFoot(steps) {
3629
+ if (!sessionFootEl) return;
3630
+ if (!Array.isArray(steps) || steps.length === 0) {
3631
+ sessionFootEl.dataset.empty = 'true';
3632
+ return;
3633
+ }
3634
+ sessionFootEl.dataset.empty = 'false';
3635
+ var elapsed = session.completedAt
3636
+ ? fmtElapsed(session.completedAt - session.startedAt)
3637
+ : fmtElapsed(Date.now() - session.startedAt);
3638
+ var elapsedB = sessionFootEl.querySelector('.sf-elapsed b');
3639
+ var toolsB = sessionFootEl.querySelector('.sf-tools b');
3640
+ var filesB = sessionFootEl.querySelector('.sf-files b');
3641
+ if (elapsedB) elapsedB.textContent = elapsed;
3642
+ if (toolsB) toolsB.textContent = String(steps.length);
3643
+ if (filesB) filesB.textContent = String(countTouchedFiles(steps));
3644
+ }
3645
+ // Tick elapsed display every second while running so the chip feels live.
3646
+ var elapsedTicker = null;
3647
+ function startElapsedTick() {
3648
+ if (elapsedTicker) return;
3649
+ elapsedTicker = setInterval(function() {
3650
+ if (!toolProgressEl || toolProgressEl.dataset.state === 'hidden') return;
3651
+ var elEl = toolProgressEl.querySelector('.tp-elapsed');
3652
+ if (elEl) elEl.textContent = fmtElapsed(Date.now() - session.startedAt);
3653
+ }, 1000);
3654
+ }
3655
+ function stopElapsedTick() {
3656
+ if (elapsedTicker) { clearInterval(elapsedTicker); elapsedTicker = null; }
3657
+ }
3658
+ if (session.status === 'running') startElapsedTick();
3659
+
3660
+ // v0.6 M3 — PM narrator polling. Backend generates a one-paragraph
3661
+ // mid-task summary every 30s while the session runs; frontend polls
3662
+ // /api/task/:id/narration and renders it in the narrator-block.
3663
+ // Per claim_user_persona: non-technical users read narration, not
3664
+ // tool calls. Per overconfidence-guard: framed as "narrator", not
3665
+ // authoritative truth.
3666
+ var narrTicker = null;
3667
+ var lastNarrAt = 0;
3668
+ function pollNarration() {
3669
+ fetch('/api/task/' + session.sessionId + '/narration')
3670
+ .then(function(r) { return r.ok ? r.json() : null; })
3671
+ .then(function(d) {
3672
+ if (!d || !d.narration) return;
3673
+ var n = d.narration;
3674
+ if (n.generatedAt <= lastNarrAt) return; // unchanged
3675
+ lastNarrAt = n.generatedAt;
3676
+ var block = card.querySelector('.narrator-block');
3677
+ if (!block) return;
3678
+ var textEl = block.querySelector('.narrator-text');
3679
+ var metaEl = block.querySelector('.narrator-meta');
3680
+ if (textEl) textEl.textContent = n.summary;
3681
+ if (metaEl) {
3682
+ var ago = Math.max(0, Math.floor((Date.now() - n.generatedAt) / 1000));
3683
+ metaEl.textContent = 'after ' + n.stepCountAtSnapshot + ' steps · ' + ago + 's ago · may be inaccurate';
3684
+ }
3685
+ block.style.display = 'block';
3686
+ })
3687
+ .catch(function() { /* skip */ });
3688
+ }
3689
+ function startNarrationTick() {
3690
+ if (narrTicker) return;
3691
+ pollNarration(); // immediate
3692
+ narrTicker = setInterval(pollNarration, 30000);
3693
+ }
3694
+ function stopNarrationTick() {
3695
+ if (narrTicker) { clearInterval(narrTicker); narrTicker = null; }
3696
+ }
3697
+ if (session.status === 'running') startNarrationTick();
3698
+
3699
+ // P18 slice 4 — poll /api/task/:id/blocks for hook-blocks against
3700
+ // this session. Render one .permission-gate panel per unique block.
3701
+ // De-dup by hash of category + matched + timestamp.
3702
+ var blockTicker = null;
3703
+ var seenBlockKeys = Object.create(null);
3704
+ function escapeHtmlInline(s) {
3705
+ return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
3706
+ return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
3707
+ });
3708
+ }
3709
+ function renderBlockGate(block) {
3710
+ // The block entry shape comes from the audit log; toolInput may be
3711
+ // a JSON string with {agent, command, allowedByPermissions, ...}
3712
+ var key = (block.timestamp || '') + '|' + (block.toolName || '') + '|' + (block.findings || 0);
3713
+ if (seenBlockKeys[key]) return;
3714
+ seenBlockKeys[key] = true;
3715
+ var gateContainer = card.querySelector('.permission-gates');
3716
+ if (!gateContainer) return;
3717
+ // Pull a useful matched-text + cwd out of the block. The audit
3718
+ // entry surfaces them in toolInput JSON shape.
3719
+ var pattern = '';
3720
+ var cwd = '';
3721
+ try {
3722
+ var parsedInput = typeof block.toolInput === 'string' ? JSON.parse(block.toolInput) : block.toolInput;
3723
+ if (parsedInput && typeof parsedInput === 'object') {
3724
+ // command (Bash) or file_path (Edit/Write) — best-effort
3725
+ pattern = String(parsedInput.command || parsedInput.file_path || '');
3726
+ cwd = String(parsedInput.cwd || '');
3727
+ }
3728
+ } catch (_) { /* not JSON */ }
3729
+ var truncatedPattern = pattern.length > 200 ? pattern.slice(0, 200) + '…' : pattern;
3730
+ var gate = document.createElement('div');
3731
+ gate.className = 'permission-gate';
3732
+ gate.innerHTML =
3733
+ '<div class="pg-label">codehere noticed a pattern</div>' +
3734
+ '<div class="pg-title">Is this expected here? You can approve it for this folder so future runs do not stop on the same pattern.</div>' +
3735
+ '<div class="pg-detail">' + escapeHtmlInline(truncatedPattern || '(no command captured)') + '</div>' +
3736
+ '<div class="pg-actions">' +
3737
+ '<button type="button" class="pg-approve">Approve in this folder</button>' +
3738
+ '<button type="button" class="pg-keep">Keep blocking</button>' +
3739
+ '</div>' +
3740
+ '<div class="pg-status">Approved · added to .codehere/permissions.yaml</div>';
3741
+ gateContainer.appendChild(gate);
3742
+ gate.querySelector('.pg-keep').addEventListener('click', function() {
3743
+ gate.remove();
3744
+ });
3745
+ gate.querySelector('.pg-approve').addEventListener('click', function() {
3746
+ var btn = gate.querySelector('.pg-approve');
3747
+ btn.disabled = true;
3748
+ btn.textContent = '…';
3749
+ // Approve the EXACT pattern that triggered the block; reason
3750
+ // captures the block's matched-finding context for the audit.
3751
+ // Fall back to a substring of the command if no finding text.
3752
+ var approvePattern = pattern;
3753
+ var reasonSuffix = block.findings ? (block.findings + ' finding' + (block.findings === 1 ? '' : 's') + ' caught at this site') : 'caught by hook';
3754
+ var reason = 'Approved via dashboard panel · ' + reasonSuffix;
3755
+ if (!cwd) {
3756
+ alert('Cannot approve: no cwd captured in the audit entry. Use codehere permission allow on the CLI.');
3757
+ btn.disabled = false; btn.textContent = 'Approve in this folder';
3758
+ return;
3759
+ }
3760
+ fetch('/api/permissions', {
3761
+ method: 'POST',
3762
+ headers: { 'Content-Type': 'application/json' },
3763
+ body: JSON.stringify({ cwd: cwd, pattern: approvePattern, reason: reason }),
3764
+ })
3765
+ .then(function(r) { return r.ok ? r.json() : Promise.reject(r); })
3766
+ .then(function() {
3767
+ gate.classList.add('approved');
3768
+ })
3769
+ .catch(function() {
3770
+ btn.disabled = false; btn.textContent = 'Approve in this folder';
3771
+ alert('Approve failed — try the CLI: codehere permission allow ' + JSON.stringify(approvePattern));
3772
+ });
3773
+ });
3774
+ }
3775
+ function pollBlocks() {
3776
+ fetch('/api/task/' + session.sessionId + '/blocks')
3777
+ .then(function(r) { return r.ok ? r.json() : null; })
3778
+ .then(function(d) {
3779
+ if (!d || !Array.isArray(d.blocks)) return;
3780
+ for (var i = 0; i < d.blocks.length; i++) renderBlockGate(d.blocks[i]);
3781
+ })
3782
+ .catch(function() { /* skip */ });
3783
+ }
3784
+ function startBlockTick() {
3785
+ if (blockTicker) return;
3786
+ pollBlocks();
3787
+ blockTicker = setInterval(pollBlocks, 30000);
3788
+ }
3789
+ function stopBlockTick() {
3790
+ if (blockTicker) { clearInterval(blockTicker); blockTicker = null; }
3791
+ }
3792
+ if (session.status === 'running') startBlockTick();
3793
+
3794
+ // Pattern 13 — start whimsy rotator only while status === 'running' and
3795
+ // there's no active tool step. stopWhimsy() runs on exit (or on every
3796
+ // active-step swap; we restart it when the active step ends).
3797
+ var stopWhimsy = null;
3798
+ var lastMode = ''; // 'whimsy' | 'tool' | ''
3799
+ function setMicroState(mode, label) {
3800
+ if (!microActivityEl) return;
3801
+ if (mode === '') {
3802
+ if (stopWhimsy) { stopWhimsy(); stopWhimsy = null; }
3803
+ microActivityEl.classList.remove('live', 'tool');
3804
+ microActivityEl.textContent = '';
3805
+ lastMode = '';
3806
+ return;
3807
+ }
3808
+ if (mode === 'tool') {
3809
+ if (stopWhimsy) { stopWhimsy(); stopWhimsy = null; }
3810
+ microActivityEl.classList.add('live', 'tool');
3811
+ microActivityEl.textContent = label || 'Working…';
3812
+ lastMode = 'tool';
3813
+ return;
3814
+ }
3815
+ // mode === 'whimsy'
3816
+ microActivityEl.classList.add('live');
3817
+ microActivityEl.classList.remove('tool');
3818
+ if (lastMode !== 'whimsy') {
3819
+ if (stopWhimsy) stopWhimsy();
3820
+ stopWhimsy = startWhimsyRotator(microActivityEl);
3821
+ lastMode = 'whimsy';
3822
+ }
3823
+ }
3824
+ if (session.status === 'running') setMicroState('whimsy');
1834
3825
  const finalReportEl = card.querySelector('.final-report');
1835
3826
  const threadEl = card.querySelector('.thread-messages');
1836
3827
  const bodyEl = card.querySelector('.session-body');
@@ -2063,8 +4054,12 @@ ${SHARED_STYLES}
2063
4054
  // steps with pass/active/fail glyphs; hide the rest behind "+ N more" so
2064
4055
  // the card stays compact even on a 20-step task.
2065
4056
  function renderSteps(steps) {
4057
+ // Pattern 1 — update the tool-progress chip on every step snapshot.
4058
+ renderToolProgress(steps);
2066
4059
  if (!Array.isArray(steps) || steps.length === 0) {
2067
4060
  stepsEl.innerHTML = '';
4061
+ // Pattern 13: no steps + still running → keep whimsy rotation alive.
4062
+ if (session.status === 'running' && lastMode !== 'whimsy') setMicroState('whimsy');
2068
4063
  return;
2069
4064
  }
2070
4065
  const tail = steps.slice(-3);
@@ -2074,9 +4069,60 @@ ${SHARED_STYLES}
2074
4069
  tail.map(s => {
2075
4070
  const st = String(s.state || 'pending');
2076
4071
  const g = glyph[st] || '·';
2077
- return '<div class="step ' + st + '"><span class="ico">' + g + '</span><span>' + escape(String(s.label || '')) + '</span></div>';
4072
+ // Pattern 2 — derive tool kind + split label so the verb-color
4073
+ // semantic CSS applies (read/grep muted, write/edit green, bash
4074
+ // peach). Falls back to a single .verb span when label is one word.
4075
+ // Pattern 14 — smart-truncate the target per tool kind so the
4076
+ // meaningful chunk survives the trace's narrow column.
4077
+ const kind = inferToolKind(s.label);
4078
+ const parts = splitStepLabel(String(s.label || ''));
4079
+ const truncatedTarget = truncateTarget(kind, parts.target);
4080
+ const labelHtml = truncatedTarget
4081
+ ? '<span class="verb">' + escape(parts.verb) + '</span><span class="target">' + escape(truncatedTarget) + '</span>'
4082
+ : '<span class="verb">' + escape(parts.verb) + '</span>';
4083
+ // P22 / Track B #8 — bash output preview. Render last 4
4084
+ // lines of step.output under the step header. Only shows
4085
+ // for done/failed steps (active steps don't have output yet).
4086
+ // Note: split-on-newline uses fromCharCode(10) to dodge the
4087
+ // outer TS template literal eating the backslash-n.
4088
+ var outputHtml = '';
4089
+ if (s.output && (st === 'done' || st === 'failed')) {
4090
+ var lines = String(s.output).split(String.fromCharCode(10));
4091
+ var tailLines = lines.slice(-4);
4092
+ outputHtml = '<div class="step-output">' +
4093
+ tailLines.map(function(ln) { return escape(ln); }).join('<br>') +
4094
+ '</div>';
4095
+ }
4096
+ return '<div class="step ' + st + '" data-tool="' + escape(kind) + '"><span class="ico">' + g + '</span>' + labelHtml + outputHtml + '</div>';
2078
4097
  }).join('') +
2079
4098
  (more > 0 ? '<div class="more">+ ' + more + ' earlier</div>' : '');
4099
+
4100
+ // Pattern 13: tool-prefixed verb takes over while a step is active.
4101
+ // Most-recent active step's label becomes the micro-activity text;
4102
+ // when no step is active we fall back to whimsy rotation.
4103
+ // Pattern 17: if the active step's elapsed > 5s, append a "· Ns"
4104
+ // suffix so the user can see how long this single tool has been
4105
+ // running. Simple slice — doesn't yet upgrade to the bar / ESC hint
4106
+ // at 15-30s thresholds; that's deferred until the affordance
4107
+ // demands it in real dogfood.
4108
+ const active = steps.filter(s => String(s.state || '') === 'active').slice(-1)[0];
4109
+ if (active && session.status === 'running') {
4110
+ let label = String(active.label || 'Working on it…');
4111
+ const startedAt = Number(active.startedAt || 0);
4112
+ if (startedAt > 0) {
4113
+ const elapsedSec = Math.floor((Date.now() - startedAt) / 1000);
4114
+ if (elapsedSec >= 5) {
4115
+ // Use fmtElapsed for consistency with the chip's elapsed format.
4116
+ label += ' · ' + fmtElapsed(Date.now() - startedAt);
4117
+ if (elapsedSec >= 30) label += ' · ESC to cancel';
4118
+ }
4119
+ }
4120
+ setMicroState('tool', label);
4121
+ } else if (session.status === 'running') {
4122
+ setMicroState('whimsy');
4123
+ } else {
4124
+ setMicroState('');
4125
+ }
2080
4126
  }
2081
4127
 
2082
4128
  // Layer 3: render the final report block when the task completes.
@@ -2085,13 +4131,22 @@ ${SHARED_STYLES}
2085
4131
  // Iter 12 — prose summary (3 paragraphs, plain English) prepended above
2086
4132
  // the artifact list. Non-technical users see prose first; raw artifact
2087
4133
  // detail (files/tests/tokens) below as supporting context.
2088
- function renderArtifacts(artifacts, finalReport, status) {
4134
+ function renderArtifacts(artifacts, finalReport, status, completedAt) {
2089
4135
  // Show the block if either prose summary OR artifacts exist OR if a
2090
4136
  // completed task is still generating its report (placeholder state).
2091
4137
  const isCompleted = status === 'completed' || status === 'killed' || status === 'error';
2092
4138
  const hasContent = artifacts || finalReport;
2093
- const isAwaitingReport = isCompleted && !finalReport;
2094
- if (!hasContent && !isAwaitingReport) { finalReportEl.style.display = 'none'; return; }
4139
+ // v0.6.x — bound the "Generating report…" placeholder to the actual
4140
+ // generation window. Final Report typically returns within 30s; after
4141
+ // 90s post-completion with no report, the report either failed silently
4142
+ // or the daemon was restarted (in-memory finalReportBySession Map gets
4143
+ // wiped). Either way, "Generating report…" forever is dishonest.
4144
+ const REPORT_WAIT_BUDGET_MS = 90_000;
4145
+ const sinceCompleted = completedAt ? Date.now() - completedAt : 0;
4146
+ const stillWaitingForReport = isCompleted && !finalReport && sinceCompleted < REPORT_WAIT_BUDGET_MS;
4147
+ const reportTimedOut = isCompleted && !finalReport && sinceCompleted >= REPORT_WAIT_BUDGET_MS;
4148
+ const isAwaitingReport = stillWaitingForReport;
4149
+ if (!hasContent && !isAwaitingReport && !reportTimedOut) { finalReportEl.style.display = 'none'; return; }
2095
4150
  finalReportEl.style.display = 'block';
2096
4151
  const files = Array.isArray(artifacts?.files) ? artifacts.files : [];
2097
4152
  const tests = artifacts?.tests;
@@ -2106,22 +4161,38 @@ ${SHARED_STYLES}
2106
4161
  paragraphs.map(p => '<p>' + escape(p.trim()) + '</p>').join('') +
2107
4162
  '</div>';
2108
4163
  } else if (isAwaitingReport) {
2109
- // Placeholder — task done, report still generating (or provider failed silently).
2110
- // After ~30s with no report, the placeholder persists. Iter 13 will replace
2111
- // with explicit "report unavailable — start FCC proxy or set ANTHROPIC_API_KEY".
2112
4164
  html += '<div class="fr-summary fr-summary-pending"><p>Generating report…</p></div>';
4165
+ } else if (reportTimedOut) {
4166
+ // Honest message instead of perpetual "Generating report…". Most
4167
+ // common cause: daemon restarted (Final Report is in-memory only,
4168
+ // doesn't persist to disk yet — v0.6.x cleanup item). Less common:
4169
+ // the report provider (claude-free / claude) failed silently.
4170
+ html += '<div class="fr-summary fr-summary-pending"><p>Final Report unavailable for this session. (Most often this means the daemon restarted — reports aren\\'t persisted yet. Re-spawn to get a fresh one.)</p></div>';
2113
4171
  }
2114
4172
  if (files.length > 0) {
4173
+ // Pattern 7 — file chip with mutation tint, trust grade letter,
4174
+ // diff stats. Mutation kind derives from added/removed counts so
4175
+ // the file-icon color signals "create / edit / delete" at glance.
4176
+ const FILE_ICON_SVG = '<svg class="file-icon" viewBox="0 0 14 14" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"><path d="M3 1 L9 1 L11.5 3.5 L11.5 13 L3 13 Z"/><path d="M9 1 L9 3.5 L11.5 3.5"/></svg>';
4177
+ const trustGrade = (n) => n >= 90 ? 'A' : n >= 75 ? 'B' : n >= 60 ? 'C' : n >= 40 ? 'D' : 'F';
2115
4178
  html += '<div class="fr-files">' + files.map(f => {
2116
- const lines = '<span class="add">+' + (f.added || 0) + '</span> <span class="rem">−' + (f.removed || 0) + '</span>';
2117
- const trustCls = typeof f.trust === 'number'
2118
- ? (f.trust >= 80 ? 'good' : f.trust >= 60 ? 'warn' : 'bad')
2119
- : 'none';
2120
- const trustLabel = typeof f.trust === 'number' ? String(f.trust) : '—';
2121
- return '<div class="fr-file">' +
4179
+ const added = Number(f.added || 0);
4180
+ const removed = Number(f.removed || 0);
4181
+ let mutation;
4182
+ if (added > 0 && removed === 0) mutation = 'write';
4183
+ else if (added === 0 && removed > 0) mutation = 'delete';
4184
+ else mutation = 'edit';
4185
+ const lines = '<span class="add">+' + added + '</span> <span class="rem">−' + removed + '</span>';
4186
+ // Pattern 11 — file chip uses the standalone trust-badge helper
4187
+ // instead of inline class-picking. Single source of truth for
4188
+ // grade-cutoff + color-split. (Note: this CORRECTS the prior
4189
+ // 60-cutoff which put D-grade trust in red; spec puts D in
4190
+ // warn-yellow.)
4191
+ return '<div class="fr-file" data-mutation="' + escape(mutation) + '">' +
4192
+ FILE_ICON_SVG +
2122
4193
  '<span class="path" title="' + escape(String(f.path)) + '">' + escape(String(f.path)) + '</span>' +
2123
4194
  '<span class="lines">' + lines + '</span>' +
2124
- '<span class="trust ' + trustCls + '" title="Trust score">' + trustLabel + '</span>' +
4195
+ trustBadgeHTML(typeof f.trust === 'number' ? f.trust : null) +
2125
4196
  '</div>';
2126
4197
  }).join('') + '</div>';
2127
4198
  } else {
@@ -2147,13 +4218,16 @@ ${SHARED_STYLES}
2147
4218
  // Render Final Report block if there's anything to show (prose summary,
2148
4219
  // artifacts, or "generating…" placeholder for a freshly-completed session).
2149
4220
  if (session.artifacts || session.finalReport || session.status === 'completed' || session.status === 'killed' || session.status === 'error') {
2150
- renderArtifacts(session.artifacts, session.finalReport, session.status);
4221
+ renderArtifacts(session.artifacts, session.finalReport, session.status, session.completedAt);
2151
4222
  }
2152
4223
 
2153
4224
  // Persisted sessions have no live event stream and no subprocess to kill.
2154
4225
  if (isPersisted) {
2155
4226
  killBtn.style.display = 'none';
2156
- card.classList.add('observed'); // reuse the faded styling
4227
+ // Use .persisted (visible, faded). Pre-fix this added .observed
4228
+ // which then collided with the detected-process hide rule from
4229
+ // claim_user_persona work — your own past sessions disappeared.
4230
+ card.classList.add('persisted');
2157
4231
  return;
2158
4232
  }
2159
4233
 
@@ -2164,13 +4238,59 @@ ${SHARED_STYLES}
2164
4238
 
2165
4239
  // Layer 2/3 synthetic events — handle before renderEvent (so they don't leak into the raw log)
2166
4240
  if (event.type === 'codehere:steps') { renderSteps(event.data?.steps); return; }
2167
- if (event.type === 'codehere:artifacts') { renderArtifacts(event.data, undefined, session.status); return; }
4241
+ if (event.type === 'codehere:artifacts') { renderArtifacts(event.data, undefined, session.status, session.completedAt); return; }
2168
4242
 
2169
- // Any structured error flips the status pill immediately — don't wait for
2170
- // the subprocess to exit. Many CLIs print a logical error to stdout and
2171
- // then exit 0, which would otherwise paint the card green.
4243
+ // Pattern 6 · codehere:error events carry a structured code that maps
4244
+ // 1:1 to a macro state. rate_limit / quota_exceeded / credit_low /
4245
+ // auth_invalid / model_unavailable each get their own pill copy +
4246
+ // color so users can see WHY the agent stopped, not just THAT it did.
4247
+ // Falls back to 'error' (red "Failed") for unknown codes.
2172
4248
  if (event.type === 'codehere:error') {
2173
- updateStatus(pillEl, 'error');
4249
+ var code = event.data && event.data.code;
4250
+ var state = code && STATE_LABELS[code] ? code : 'error';
4251
+ updateStatus(pillEl, state);
4252
+ // Pattern 9 — surface a recovery affordance below the session
4253
+ // header for known failure codes. Idempotent: if already shown,
4254
+ // skip (sessions can emit codehere:error multiple times).
4255
+ if (code && !card.querySelector('.recovery-affordance')) {
4256
+ var nextAgent = pickFailoverTarget(session.agent);
4257
+ var built = buildRecoveryAffordance(code, session.agent, nextAgent);
4258
+ // Insert between session-head and session-body so the affordance
4259
+ // sits at the top of the card body (most-prominent position).
4260
+ var bodyEl = card.querySelector('.session-body');
4261
+ if (bodyEl) bodyEl.parentNode.insertBefore(built.el, bodyEl);
4262
+ else card.appendChild(built.el);
4263
+ built.el.querySelector('.ra-secondary').addEventListener('click', function() {
4264
+ built.el.remove();
4265
+ });
4266
+ built.el.querySelector('.ra-primary').addEventListener('click', async function() {
4267
+ if (!built.copy.canFailover) {
4268
+ // Auth/setup path — open the agents pane (already in DOM)
4269
+ // instead of spawning a new session with the same broken auth.
4270
+ built.el.remove();
4271
+ return;
4272
+ }
4273
+ var primary = built.el.querySelector('.ra-primary');
4274
+ primary.disabled = true;
4275
+ try {
4276
+ await fetch('/api/task', {
4277
+ method: 'POST',
4278
+ headers: { 'Content-Type': 'application/json' },
4279
+ body: JSON.stringify({
4280
+ prompt: session.prompt,
4281
+ agent: nextAgent,
4282
+ // Reuse the current intake cwd — the failover spawns
4283
+ // against whichever repo the user is currently scoped
4284
+ // to. Acceptable trade-off vs. a per-session lookup.
4285
+ cwd: (cwdInput && cwdInput.value && cwdInput.value.trim()) || ''
4286
+ })
4287
+ });
4288
+ built.el.remove();
4289
+ } catch {
4290
+ primary.disabled = false;
4291
+ }
4292
+ });
4293
+ }
2174
4294
  // fall through — renderEvent renders the red banner in the log
2175
4295
  }
2176
4296
 
@@ -2184,22 +4304,75 @@ ${SHARED_STYLES}
2184
4304
  killBtn.disabled = true; killBtn.style.opacity = '0.4';
2185
4305
  const dot = card.querySelector('.sub .live-dot');
2186
4306
  if (dot) dot.remove();
4307
+ setMicroState(''); // Pattern 13 — stop rotator on session exit
4308
+ stopElapsedTick(); // Pattern 1 — stop the chip's elapsed ticker
4309
+ stopNarrationTick(); // v0.6 M3 — stop the narrator poll on exit
4310
+ // Final narration fetch — backend may emit one last summary
4311
+ // after the exit event; give it a beat then poll once more.
4312
+ setTimeout(pollNarration, 2000);
4313
+ stopBlockTick(); // P18 slice 4 — stop the per-session block poll
4314
+ // Final blocks fetch — agent may have run one last hook-blocked
4315
+ // tool call right before exit; surface it.
4316
+ setTimeout(pollBlocks, 2000);
4317
+ // Final chip render with the terminal status so it shows the last
4318
+ // dot pattern + final elapsed value in the chip's complete/failed state.
4319
+ if (Array.isArray(session.steps)) renderToolProgress(session.steps);
2187
4320
  es.close();
2188
4321
  }
2189
4322
  if (event.type === 'error') {
4323
+ setMicroState(''); // Pattern 13
2190
4324
  updateStatus(pillEl, 'error');
2191
4325
  }
2192
4326
  } catch { /* skip */ }
2193
4327
  };
2194
4328
  es.onerror = () => { /* server may have closed after exit; fine */ };
2195
4329
 
2196
- killBtn.addEventListener('click', async (ev) => {
2197
- ev.stopPropagation();
2198
- killBtn.disabled = true; killBtn.textContent = '...';
4330
+ // Pattern 19 — interrupt affordance with 2-press confirm + ESC shortcut.
4331
+ // First press flips the button to a "press again to confirm" red state
4332
+ // for 3s; second press during that window actually kills. ESC while the
4333
+ // card is focused triggers the same flow. Prevents accidental kills.
4334
+ var killConfirmTimer = null;
4335
+ function armKillConfirm() {
4336
+ killBtn.dataset.state = 'confirming';
4337
+ killBtn.textContent = 'Confirm?';
4338
+ if (killConfirmTimer) clearTimeout(killConfirmTimer);
4339
+ killConfirmTimer = setTimeout(function() {
4340
+ killBtn.dataset.state = 'ready';
4341
+ killBtn.textContent = 'Kill';
4342
+ killConfirmTimer = null;
4343
+ }, 3000);
4344
+ }
4345
+ async function performKill() {
4346
+ if (killConfirmTimer) { clearTimeout(killConfirmTimer); killConfirmTimer = null; }
4347
+ killBtn.disabled = true;
4348
+ killBtn.dataset.state = 'killing';
4349
+ killBtn.textContent = '…';
2199
4350
  try {
2200
4351
  await fetch('/api/task/' + session.sessionId + '/kill', { method: 'POST' });
2201
4352
  } catch { /* ignore */ }
4353
+ killBtn.dataset.state = 'ready';
2202
4354
  killBtn.textContent = 'Kill';
4355
+ killBtn.disabled = false;
4356
+ }
4357
+ killBtn.dataset.state = 'ready';
4358
+ killBtn.addEventListener('click', function(ev) {
4359
+ ev.stopPropagation();
4360
+ if (killBtn.dataset.state === 'confirming') {
4361
+ performKill();
4362
+ } else {
4363
+ armKillConfirm();
4364
+ }
4365
+ });
4366
+ // ESC keyboard shortcut — fires when the session card has focus within.
4367
+ card.addEventListener('keydown', function(ev) {
4368
+ if (ev.key !== 'Escape') return;
4369
+ if (killBtn.disabled) return;
4370
+ ev.preventDefault();
4371
+ if (killBtn.dataset.state === 'confirming') {
4372
+ performKill();
4373
+ } else {
4374
+ armKillConfirm();
4375
+ }
2203
4376
  });
2204
4377
 
2205
4378
  // Iter 12 — expose renderArtifacts so the polling loop can refresh the
@@ -2213,8 +4386,14 @@ ${SHARED_STYLES}
2213
4386
  if (!prompt) return;
2214
4387
  // Apr 29: empty value = "Auto" option = backend defaults to conductor
2215
4388
  // (the supervisor routes). Specialists are explicit overrides.
4389
+ // M5 slice 2: agent === '*' = fan-out (every installed specialist in
4390
+ // parallel via /api/task/batch).
2216
4391
  const agent = agentSelect.value;
2217
4392
  const cwd = cwdInput.value.trim();
4393
+ if (agent === '*') {
4394
+ await assignParallel(prompt, cwd);
4395
+ return;
4396
+ }
2218
4397
  spawnBtn.disabled = true; spawnBtn.textContent = '...';
2219
4398
  try {
2220
4399
  const res = await fetch('/api/task', {
@@ -2223,9 +4402,77 @@ ${SHARED_STYLES}
2223
4402
  body: JSON.stringify({ prompt, agent: agent || undefined, cwd: cwd || undefined }),
2224
4403
  });
2225
4404
  const data = await res.json();
2226
- if (!res.ok) { alert('Failed: ' + (data.error || res.status)); return; }
4405
+ if (!res.ok) {
4406
+ // v0.6 S3 slice 2 — friendly message on daily-budget refusal so
4407
+ // non-technical users see the WHY + the next move instead of a
4408
+ // raw error code.
4409
+ if (res.status === 429 && data.error === 'daily_budget_exhausted') {
4410
+ alert(data.message || 'Daily budget reached.');
4411
+ } else {
4412
+ alert('Failed: ' + (data.error || res.status));
4413
+ }
4414
+ return;
4415
+ }
2227
4416
  attachSession({ sessionId: data.sessionId, prompt, startedAt: data.startedAt, status: data.status, agent: data.agent || agent || 'conductor', canAppendMessage: data.canAppendMessage === true });
2228
4417
  promptEl.value = '';
4418
+ // Spend chip refreshes on its own 60s tick; nudge it now since a
4419
+ // spawn means new spend is imminent.
4420
+ refreshSpendToday();
4421
+ } catch (e) {
4422
+ alert('Network error: ' + (e && e.message ? e.message : e));
4423
+ } finally {
4424
+ spawnBtn.disabled = false; spawnBtn.textContent = 'Assign task';
4425
+ }
4426
+ }
4427
+
4428
+ // M5 slice 2 — fan-out spawn. Sends the same prompt to every installed
4429
+ // ready specialist in parallel via /api/task/batch. Cap of 5 enforced
4430
+ // by the backend; if the user has more than 5 ready specialists we
4431
+ // take the first 5 in the readiness order (sort already favors ready).
4432
+ async function assignParallel(prompt, cwd) {
4433
+ const readySpecialists = (agentReadiness || [])
4434
+ .filter(a => a.state === 'ready')
4435
+ .map(a => a.id)
4436
+ .slice(0, 5);
4437
+ if (readySpecialists.length < 2) {
4438
+ alert('Fan-out needs at least 2 ready agents. Currently ready: ' + readySpecialists.length);
4439
+ return;
4440
+ }
4441
+ spawnBtn.disabled = true; spawnBtn.textContent = '...';
4442
+ try {
4443
+ const res = await fetch('/api/task/batch', {
4444
+ method: 'POST',
4445
+ headers: { 'Content-Type': 'application/json' },
4446
+ body: JSON.stringify({
4447
+ tasks: readySpecialists.map(agentId => ({
4448
+ prompt,
4449
+ agent: agentId,
4450
+ cwd: cwd || undefined,
4451
+ })),
4452
+ }),
4453
+ });
4454
+ const data = await res.json();
4455
+ if (!res.ok) { alert('Batch failed: ' + (data.error || res.status)); return; }
4456
+ // Attach a card per spawned task. Per-task errors get surfaced
4457
+ // via alert listing the failed agents — successful spawns still
4458
+ // attach.
4459
+ const failed = [];
4460
+ for (const t of (data.tasks || [])) {
4461
+ if (t.sessionId) {
4462
+ attachSession({
4463
+ sessionId: t.sessionId,
4464
+ prompt,
4465
+ startedAt: t.startedAt,
4466
+ status: t.status,
4467
+ agent: t.agent,
4468
+ canAppendMessage: false, // batch doesn't surface this; default conservative
4469
+ });
4470
+ } else if (t.error) {
4471
+ failed.push(t.agent + ': ' + (t.reason || t.error));
4472
+ }
4473
+ }
4474
+ if (failed.length > 0) alert(failed.length + ' of ' + (data.tasks || []).length + ' fan-out spawns failed: ' + failed.join('; '));
4475
+ promptEl.value = '';
2229
4476
  } catch (e) {
2230
4477
  alert('Network error: ' + (e && e.message ? e.message : e));
2231
4478
  } finally {
@@ -2234,10 +4481,95 @@ ${SHARED_STYLES}
2234
4481
  }
2235
4482
 
2236
4483
  spawnBtn.addEventListener('click', assignTask);
4484
+
4485
+ // v0.6.x Slack-shape spike — Continue thread button + Cmd+Shift+Enter.
4486
+ // Routes the prompt as a reply to the most recent in-memory session for
4487
+ // the current cwd via the existing /api/task/:id/message endpoint.
4488
+ // Disabled when no eligible session exists; auto-refresh state every 5s
4489
+ // alongside the existing tasks polling.
4490
+ const continueBtn = document.getElementById('continue-thread');
4491
+ let continueTargetSessionId = null;
4492
+
4493
+ function findContinueTarget(spawnedList, cwd) {
4494
+ if (!cwd) return null;
4495
+ const eligible = (spawnedList || []).filter(s => s.cwd === cwd && s.canAppendMessage === true);
4496
+ if (eligible.length === 0) return null;
4497
+ eligible.sort((a, b) => b.startedAt - a.startedAt);
4498
+ return eligible[0];
4499
+ }
4500
+
4501
+ function refreshContinueAvailability() {
4502
+ const cwd = cwdInput.value.trim();
4503
+ if (!cwd) {
4504
+ continueTargetSessionId = null;
4505
+ continueBtn.disabled = true;
4506
+ continueBtn.title = 'Set a folder first to continue its thread';
4507
+ return;
4508
+ }
4509
+ fetch('/api/tasks').then(r => r.json()).then(d => {
4510
+ const target = findContinueTarget(d.spawned, cwd);
4511
+ if (target) {
4512
+ continueTargetSessionId = target.sessionId;
4513
+ continueBtn.disabled = false;
4514
+ const promptPreview = (target.prompt || '').slice(0, 40);
4515
+ continueBtn.title = 'Reply to last task in this folder: "' + promptPreview + (target.prompt && target.prompt.length > 40 ? '…' : '') + '" (' + target.agent + ')';
4516
+ } else {
4517
+ continueTargetSessionId = null;
4518
+ continueBtn.disabled = true;
4519
+ continueBtn.title = 'No live session for this folder. Use Assign new instead.';
4520
+ }
4521
+ }).catch(() => {});
4522
+ }
4523
+
4524
+ async function continueThread() {
4525
+ const prompt = promptEl.value.trim();
4526
+ if (!prompt) return;
4527
+ if (!continueTargetSessionId) { alert('No live session for this folder. Use Assign new instead.'); return; }
4528
+ const targetId = continueTargetSessionId;
4529
+ continueBtn.disabled = true;
4530
+ const originalText = continueBtn.textContent;
4531
+ continueBtn.textContent = '...';
4532
+ try {
4533
+ const res = await fetch('/api/task/' + encodeURIComponent(targetId) + '/message', {
4534
+ method: 'POST',
4535
+ headers: { 'Content-Type': 'application/json' },
4536
+ body: JSON.stringify({ text: prompt }),
4537
+ });
4538
+ const data = await res.json();
4539
+ if (!res.ok) { alert('Failed: ' + (data.error || res.status)); return; }
4540
+ promptEl.value = '';
4541
+ // Scroll the existing card into view; existing 5s polling refreshes content.
4542
+ const card = document.querySelector('[data-session-id="' + targetId + '"]');
4543
+ if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
4544
+ } catch (e) {
4545
+ alert('Network error: ' + (e && e.message ? e.message : e));
4546
+ } finally {
4547
+ continueBtn.disabled = false;
4548
+ continueBtn.textContent = originalText;
4549
+ // Re-check availability (target may have settled differently)
4550
+ refreshContinueAvailability();
4551
+ }
4552
+ }
4553
+
4554
+ continueBtn.addEventListener('click', continueThread);
4555
+ cwdInput.addEventListener('input', refreshContinueAvailability);
4556
+ cwdInput.addEventListener('change', refreshContinueAvailability);
4557
+
2237
4558
  promptEl.addEventListener('keydown', (e) => {
2238
- if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); assignTask(); }
4559
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
4560
+ e.preventDefault();
4561
+ if (e.shiftKey) {
4562
+ continueThread();
4563
+ } else {
4564
+ assignTask();
4565
+ }
4566
+ }
2239
4567
  });
2240
4568
 
4569
+ // Refresh continue-target availability alongside the existing tasks polling
4570
+ setInterval(refreshContinueAvailability, 5000);
4571
+ refreshContinueAvailability();
4572
+
2241
4573
  // ── Observed sessions (F1d) ──────────────────────────────────────────
2242
4574
  function shortId(id) { return String(id || '').slice(0, 8); }
2243
4575
 
@@ -2264,8 +4596,7 @@ ${SHARED_STYLES}
2264
4596
  '<div class="session-head">' +
2265
4597
  '<div class="meta">' +
2266
4598
  '<div class="prompt">' +
2267
- '<span class="card-lights"><span class="r"></span><span class="y"></span><span class="g"></span></span>' +
2268
- escape(a.label) + ' · ' + escape(a.tty) + ' · pid ' + a.pid +
4599
+ agentBadgeHTML(a.id || a.label) + ' ' + escape(a.label) + ' · ' + escape(a.tty) + ' · pid ' + a.pid +
2269
4600
  '</div>' +
2270
4601
  '<div class="sub">running ' + escape(uptime) + '</div>' +
2271
4602
  '</div>' +
@@ -2295,9 +4626,10 @@ ${SHARED_STYLES}
2295
4626
  if (!hasAny) { auditRow.classList.add('empty'); return; }
2296
4627
  auditRow.classList.remove('empty');
2297
4628
  document.getElementById('as-actions').textContent = String(d.actions || 0);
2298
- document.getElementById('as-blocked').textContent = String(d.blocked || 0);
2299
- document.getElementById('as-trust').textContent = d.avgTrust == null ? '—' : String(d.avgTrust);
2300
- document.getElementById('as-agents').textContent = String(d.agents || 0);
4629
+ // UX-simplification iter 2 (May 7): dropped writes to hidden as-blocked /
4630
+ // as-trust / as-agents spans — they were updated but never displayed.
4631
+ // The mode-indicator pill in the nav now carries the safety-posture
4632
+ // signal those hidden values were once meant to surface.
2301
4633
  }
2302
4634
  function refreshAudit() {
2303
4635
  fetch('/api/audit/summary').then(r => r.json()).then(updateAudit).catch(() => { /* fine */ });
@@ -2305,39 +4637,18 @@ ${SHARED_STYLES}
2305
4637
  refreshAudit();
2306
4638
  setInterval(refreshAudit, 30000); // audit log isn't hot; 30s is plenty
2307
4639
 
2308
- // Apr 29 — UX overhaul: status-banner merged into the single stats-line
2309
- // (id="audit-summary"). updateBanner still updates the agents-on-machine
2310
- // count + hidden running/blocked/last-edit counters; updateAudit controls
2311
- // visibility of the whole row via the .empty class.
2312
- let latestLastEditAt = null;
2313
- function agoStr(ms) {
2314
- if (!ms) return '—';
2315
- const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
2316
- if (s < 60) return s + 's ago';
2317
- if (s < 3600) return Math.floor(s / 60) + 'm ago';
2318
- if (s < 86400) return Math.floor(s / 3600) + 'h ago';
2319
- return Math.floor(s / 86400) + 'd ago';
2320
- }
4640
+ // Apr 29 status-banner is now the single stats-line above ("X actions
4641
+ // today"). UX-simplification iter 2 (May 7) dropped the hidden setText
4642
+ // counters + the per-second timer that updated sb-last for a value
4643
+ // that was never displayed. updateBanner still toggles row visibility
4644
+ // when there's any session activity.
2321
4645
  function updateBanner(d) {
2322
4646
  const spawned = d.spawned || d.sessions || [];
2323
- const running = spawned.filter(s => s.status === 'running').length;
2324
- const blocked = spawned.filter(s => s.status === 'error').length;
2325
4647
  const detected = (d.activeAgents || []).length;
2326
- const setText = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = String(val); };
2327
- setText('sb-running', running);
2328
- setText('sb-detected', detected);
2329
- setText('sb-blocked', blocked);
2330
- latestLastEditAt = d.lastEditAt || null;
2331
- setText('sb-last', agoStr(latestLastEditAt));
2332
- // Reveal the unified stats-line once we have anything to show.
2333
- if (running + detected + blocked + spawned.length > 0) {
4648
+ if (spawned.length + detected > 0) {
2334
4649
  auditRow.classList.remove('empty');
2335
4650
  }
2336
4651
  }
2337
- setInterval(() => {
2338
- const el = document.getElementById('sb-last');
2339
- if (el && latestLastEditAt) el.textContent = agoStr(latestLastEditAt);
2340
- }, 1000);
2341
4652
 
2342
4653
  function loadSessions() {
2343
4654
  fetch('/api/tasks').then(r => r.json()).then(d => {
@@ -2351,8 +4662,28 @@ ${SHARED_STYLES}
2351
4662
  }
2352
4663
  pruneStaleAgents(seen);
2353
4664
  updateBanner(d);
4665
+ updateShowMoreButton();
2354
4666
  }).catch(() => { /* fine */ });
2355
4667
  }
4668
+ // claim_user_persona — show "Show all sessions (N more)" button only
4669
+ // when there are more than 5 spawned sessions. Detected-process rows
4670
+ // don't count (they're already hidden by default).
4671
+ function updateShowMoreButton() {
4672
+ const btn = document.getElementById('show-more-sessions');
4673
+ if (!btn) return;
4674
+ const allSessions = sessionsEl.querySelectorAll('.session:not(.observed)');
4675
+ if (allSessions.length > 5 && !document.body.classList.contains('show-all-sessions')) {
4676
+ btn.style.display = 'block';
4677
+ btn.textContent = 'Show all sessions (' + (allSessions.length - 5) + ' more)';
4678
+ } else {
4679
+ btn.style.display = 'none';
4680
+ }
4681
+ }
4682
+ const showMoreBtn = document.getElementById('show-more-sessions');
4683
+ if (showMoreBtn) showMoreBtn.addEventListener('click', function() {
4684
+ document.body.classList.add('show-all-sessions');
4685
+ updateShowMoreButton();
4686
+ });
2356
4687
  loadSessions();
2357
4688
  // Refresh detected-agents list + banner every 5s. Iter 12 — also patches
2358
4689
  // existing session cards with new finalReport prose when it arrives async.
@@ -2371,7 +4702,7 @@ ${SHARED_STYLES}
2371
4702
  for (const s of (d.spawned || d.sessions || [])) {
2372
4703
  const entry = sessions.get(s.sessionId);
2373
4704
  if (entry && entry.renderArtifacts) {
2374
- entry.renderArtifacts(s.artifacts, s.finalReport, s.status);
4705
+ entry.renderArtifacts(s.artifacts, s.finalReport, s.status, s.completedAt);
2375
4706
  }
2376
4707
  }
2377
4708
  }).catch(() => { /* fine */ });
@@ -2381,7 +4712,17 @@ ${SHARED_STYLES}
2381
4712
  const readinessEl = document.getElementById('readiness');
2382
4713
 
2383
4714
  function renderReadiness(report) {
2384
- if (!report || report.state === 'ready') {
4715
+ // Hide the banner unless the state is genuinely actionable. May 5 2026
4716
+ // design pass: the prior api_key_shadow state surfaced a yellow warning
4717
+ // banner with shell-grep jargon for an environment condition that ISNT
4718
+ // a problem -- codehere already uses subscription auth and ignores the
4719
+ // ANTHROPIC_API_KEY env var by design. Per claim_user_persona (HIGH
4720
+ // non-technical-first) + the Open-Agents production-grade design
4721
+ // reference, only show the banner when the user can / must act on it
4722
+ // (needs_install, needs_auth, billed_key_required). Other states stay
4723
+ // observable in /api/readiness for tooling.
4724
+ const ACTIONABLE_STATES = new Set(['needs_install', 'needs_auth', 'billed_key_required']);
4725
+ if (!report || !ACTIONABLE_STATES.has(report.state)) {
2385
4726
  readinessEl.style.display = 'none';
2386
4727
  return;
2387
4728
  }
@@ -2440,24 +4781,10 @@ ${SHARED_STYLES}
2440
4781
  html, body { height: 100%; }
2441
4782
  body { margin: 0; background: var(--bg); color: var(--fg); font-family: var(--sans); font-size: 13px; line-height: 1.55; -webkit-font-smoothing: antialiased; }
2442
4783
 
2443
- /* ── Floating nav (matches dashboard exactly) ───────────────────────── */
2444
- .floating-nav {
2445
- position: fixed; top: 16px; left: 50%; transform: translateX(-50%);
2446
- z-index: 100;
2447
- display: flex; align-items: center; gap: 4px;
2448
- padding: 6px 10px;
2449
- background: rgba(20,20,22,0.85); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
2450
- border: 1px solid var(--border);
2451
- border-radius: 999px;
2452
- font-family: var(--mono); font-size: 11.5px;
2453
- }
2454
- .floating-nav .logo { color: var(--accent); padding: 4px 10px; text-decoration: none; }
2455
- .floating-nav .nav-link {
2456
- color: var(--fg-50); padding: 4px 10px; border-radius: 999px;
2457
- text-decoration: none;
2458
- }
2459
- .floating-nav .nav-link:hover { color: var(--fg); background: rgba(255,255,255,0.05); }
2460
- .floating-nav .nav-link.active { color: var(--fg); background: rgba(255,154,118,0.10); }
4784
+ /* Floating nav inherits from SHARED_STYLES (no override here — keeps the
4785
+ active-state look identical to / and /history. Founder caught
4786
+ 2026-05-03 that the local override gave /threads a different orange-bg
4787
+ active state, fragmenting the design system across pages.) */
2461
4788
 
2462
4789
  /* ── Slack-shape shell (3-column — channels | thread | members) ─────── */
2463
4790
  .shell.slack-shape {
@@ -2517,6 +4844,46 @@ ${SHARED_STYLES}
2517
4844
  margin-top: 4px; padding-left: 22px;
2518
4845
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
2519
4846
  }
4847
+ /* Pencil affordance for project rename. Always visible at subtle opacity;
4848
+ brightens only when the user hovers the pencil itself. NO background
4849
+ box at any state. Inline SVG so it renders crisp at any size and
4850
+ inherits color from CSS. */
4851
+ .channel-rename {
4852
+ background: transparent; border: none; cursor: pointer;
4853
+ color: var(--fg-30); padding: 4px;
4854
+ transition: color 0.12s ease;
4855
+ flex: 0 0 auto;
4856
+ display: inline-flex; align-items: center; justify-content: center;
4857
+ }
4858
+ .channel-rename svg { display: block; width: 14px; height: 14px; }
4859
+ .channel-rename:hover { color: var(--fg); background: transparent; }
4860
+
4861
+ /* Inline edit-in-place for project rename. May 6 2026: replaces the
4862
+ prior window.prompt() browser dialog (cheap UX; not enterprise-grade
4863
+ per Open-Agents reference). Input matches channel-name typography so
4864
+ the swap feels seamless. */
4865
+ .channel-rename-input {
4866
+ background: transparent;
4867
+ border: 1px solid var(--accent);
4868
+ border-radius: 3px;
4869
+ color: var(--fg);
4870
+ font: inherit;
4871
+ font-weight: 500;
4872
+ padding: 1px 4px;
4873
+ margin: -2px -5px;
4874
+ width: calc(100% + 10px);
4875
+ outline: none;
4876
+ box-sizing: border-box;
4877
+ }
4878
+ .channel-rename-input.error {
4879
+ border-color: var(--block);
4880
+ animation: rename-shake 0.25s ease;
4881
+ }
4882
+ @keyframes rename-shake {
4883
+ 0%, 100% { transform: translateX(0); }
4884
+ 25% { transform: translateX(-2px); }
4885
+ 75% { transform: translateX(2px); }
4886
+ }
2520
4887
  .pane-empty {
2521
4888
  color: var(--fg-30); font-size: 10.5px;
2522
4889
  padding: 8px; text-align: center;
@@ -2566,8 +4933,11 @@ ${SHARED_STYLES}
2566
4933
  font-family: var(--mono); font-size: 10px; padding: 2px 8px; border-radius: 10px;
2567
4934
  text-transform: uppercase; letter-spacing: 0.05em;
2568
4935
  }
2569
- .task .status.completed { background: rgba(40, 200, 64, 0.10); color: var(--allow); border: 1px solid rgba(40, 200, 64, 0.18); }
2570
- .task .status.running { background: rgba(255, 200, 0, 0.10); color: var(--warn); border: 1px solid rgba(255, 200, 0, 0.18); }
4936
+ /* Pattern 8 ride-along revert: f830183 demoted .completed to muted neutral.
4937
+ Restoring green — actionable success is a load-bearing semantic signal
4938
+ in agentic UX. */
4939
+ .task .status.completed { background: rgba(40,200,64,0.10); color: var(--allow); border: 1px solid rgba(40,200,64,0.20); }
4940
+ .task .status.running { background: var(--accent-wash); color: var(--accent); border: 1px solid rgba(255,154,118,0.25); }
2571
4941
  .task .status.error, .task .status.failed { background: rgba(220, 60, 60, 0.10); color: var(--block); border: 1px solid rgba(220, 60, 60, 0.18); }
2572
4942
  .task .status.abandoned { background: rgba(255,255,255,0.04); color: var(--fg-40); border: 1px solid var(--border); }
2573
4943
  .task .ts { font-family: var(--mono); font-size: 11px; color: var(--fg-40); margin-left: auto; }
@@ -2630,6 +5000,8 @@ ${SHARED_STYLES}
2630
5000
  <a href="/" class="nav-link">Tasks</a>
2631
5001
  <a href="/threads" class="nav-link active">Threads</a>
2632
5002
  <a href="/history" class="nav-link">History</a>
5003
+ <a href="https://codehere.uk" class="nav-link nav-external" target="_blank" rel="noopener" title="Open codehere.uk">Docs ↗</a>
5004
+ <span class="mode-indicator" data-mode="" id="mode-indicator" title="Loading safety-hook mode…"><span class="mi-dot"></span><span class="mi-label">…</span></span>
2633
5005
  </nav>
2634
5006
 
2635
5007
  <div class="shell slack-shape">
@@ -2683,6 +5055,9 @@ ${SHARED_STYLES}
2683
5055
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
2684
5056
  .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
2685
5057
  }
5058
+ // Per-agent badge helpers shared with TASK_HTML / HISTORY_HTML — single
5059
+ // source of truth in AGENT_BADGE_JS at the top of this module.
5060
+ ${AGENT_BADGE_JS}
2686
5061
  function timeAgo(ts) {
2687
5062
  if (!ts) return '';
2688
5063
  const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
@@ -2700,7 +5075,12 @@ ${SHARED_STYLES}
2700
5075
  if (ms == null) return '';
2701
5076
  if (ms < 1000) return ms + 'ms';
2702
5077
  if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
2703
- return Math.floor(ms / 60000) + 'm ' + Math.floor((ms % 60000) / 1000) + 's';
5078
+ if (ms < 3600000) return Math.floor(ms / 60000) + 'm ' + Math.floor((ms % 60000) / 1000) + 's';
5079
+ if (ms < 86400000) return Math.floor(ms / 3600000) + 'h ' + Math.floor((ms % 3600000) / 60000) + 'm';
5080
+ // Days-scale: this is almost always a stale-running session that
5081
+ // got reconciled days later. Show "Nd" — actual elapsed runtime is
5082
+ // unknowable so don't pretend "108h 6m" carries meaning.
5083
+ return Math.floor(ms / 86400000) + 'd';
2704
5084
  }
2705
5085
  function shortPath(p) {
2706
5086
  if (!p) return '~';
@@ -2791,9 +5171,26 @@ ${SHARED_STYLES}
2791
5171
  return;
2792
5172
  }
2793
5173
 
2794
- html += '<div class="section-label">Thread · ' + thread.tasks.length + ' turn' + (thread.tasks.length === 1 ? '' : 's') + '</div>';
5174
+ // v0.6 — render-cap to keep huge projects (685+ turns on the founder's
5175
+ // dev repo) from blowing the page to ~80,000px tall. "Show all" toggle
5176
+ // mirrors the dashboard 5-card cap pattern. Chronological order
5177
+ // preserved (Slack-shape: oldest first, recent at bottom).
5178
+ const RENDER_CAP = 25;
5179
+ const totalTurns = thread.tasks.length;
5180
+ const showAll = container.dataset.showAllTurns === 'true';
5181
+ const renderedTurns = showAll ? thread.tasks : thread.tasks.slice(-RENDER_CAP);
5182
+ const isCapped = !showAll && totalTurns > RENDER_CAP;
5183
+
5184
+ if (isCapped) {
5185
+ html += '<div class="section-label" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">' +
5186
+ '<span>Thread · showing <b>' + renderedTurns.length + '</b> most recent of ' + totalTurns + ' turns</span>' +
5187
+ '<button type="button" class="show-all-turns" style="font-size:10.5px;padding:3px 8px;border-radius:4px;border:1px solid var(--border-soft);background:transparent;color:var(--fg-70);cursor:pointer;font-family:inherit">Show all ' + totalTurns + '</button>' +
5188
+ '</div>';
5189
+ } else {
5190
+ html += '<div class="section-label">Thread · ' + totalTurns + ' turn' + (totalTurns === 1 ? '' : 's') + '</div>';
5191
+ }
2795
5192
 
2796
- for (const t of thread.tasks) {
5193
+ for (const t of renderedTurns) {
2797
5194
  const status = (t.status || '').toLowerCase();
2798
5195
  const stepsHtml = (t.steps && t.steps.length)
2799
5196
  ? '<div class="steps">' + t.steps.map(s => '<div class="step ' + escapeHtml((s.state||'').toLowerCase()) + '">' + escapeHtml(s.label || '') + '</div>').join('') + '</div>'
@@ -2804,7 +5201,8 @@ ${SHARED_STYLES}
2804
5201
 
2805
5202
  html += '<div class="task">' +
2806
5203
  '<div class="row1">' +
2807
- '<span class="agent">' + escapeHtml(t.agent || 'unknown') + '</span>' +
5204
+ agentBadgeHTML(t.agent || 'unknown') +
5205
+ ' <span class="agent-name">' + escapeHtml(agentLabel(t.agent || 'unknown')) + '</span>' +
2808
5206
  '<span class="status ' + escapeHtml(status) + '">' + escapeHtml(status || 'unknown') + '</span>' +
2809
5207
  '<span class="ts">' + escapeHtml(fmtTime(t.startedAt)) + ' · ' + escapeHtml(timeAgo(t.startedAt)) + '</span>' +
2810
5208
  '</div>' +
@@ -2814,6 +5212,16 @@ ${SHARED_STYLES}
2814
5212
  '</div>';
2815
5213
  }
2816
5214
  container.innerHTML = html;
5215
+ // Wire the "Show all N turns" toggle (rendered only when capped). Sets
5216
+ // the dataset flag and re-runs renderThread with the same data so the
5217
+ // cap branch falls through.
5218
+ const showAllBtn = container.querySelector('.show-all-turns');
5219
+ if (showAllBtn) {
5220
+ showAllBtn.addEventListener('click', () => {
5221
+ container.dataset.showAllTurns = 'true';
5222
+ renderThread(thread);
5223
+ });
5224
+ }
2817
5225
  const lastTask = thread.tasks[thread.tasks.length - 1];
2818
5226
  setReplyState(lastTask.sessionId, lastTask.agent);
2819
5227
  }
@@ -2828,16 +5236,21 @@ ${SHARED_STYLES}
2828
5236
  const active = p.slug === currentSlug ? ' active' : '';
2829
5237
  const name = escapeHtml(p.title || '(untitled)');
2830
5238
  const cwdHint = escapeHtml(shortPath(p.cwd));
5239
+ // Pencil affordance — visible on hover via CSS .channel:hover .channel-rename.
5240
+ // Triggers prompt() for v1 simplicity; inline input UX is a future iteration.
2831
5241
  return '<div class="channel' + active + '" data-slug="' + escapeHtml(p.slug) + '">' +
2832
5242
  '<span class="channel-glyph">#</span>' +
2833
5243
  '<div style="flex:1;min-width:0">' +
2834
5244
  '<div class="channel-name">' + name + '</div>' +
2835
5245
  '<div class="channel-cwd-hint">' + cwdHint + '</div>' +
2836
5246
  '</div>' +
5247
+ '<button class="channel-rename" data-slug="' + escapeHtml(p.slug) + '" title="Rename project" aria-label="Rename project"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg></button>' +
2837
5248
  '</div>';
2838
5249
  }).join('');
2839
5250
  projectsList.querySelectorAll('.channel').forEach(el => {
2840
- el.addEventListener('click', () => {
5251
+ el.addEventListener('click', (ev) => {
5252
+ // Skip channel-switch when the click was the rename button — that has its own handler.
5253
+ if (ev.target && (ev.target).classList && (ev.target).classList.contains('channel-rename')) return;
2841
5254
  const slug = el.getAttribute('data-slug');
2842
5255
  if (!slug || slug === currentSlug) return;
2843
5256
  currentSlug = slug;
@@ -2848,6 +5261,72 @@ ${SHARED_STYLES}
2848
5261
  history.replaceState(null, '', url.toString());
2849
5262
  });
2850
5263
  });
5264
+ projectsList.querySelectorAll('.channel-rename').forEach(btn => {
5265
+ btn.addEventListener('click', (ev) => {
5266
+ ev.stopPropagation(); // don't trigger channel-switch
5267
+ const slug = btn.getAttribute('data-slug');
5268
+ if (!slug) return;
5269
+ const channel = btn.closest('.channel');
5270
+ const project = list.find(p => p.slug === slug);
5271
+ const current = (project && project.title) || '';
5272
+ startInlineRename(channel, slug, current, () => {
5273
+ loadProjects();
5274
+ // Renamed project may be the visible one — refresh the main pane
5275
+ // header immediately instead of waiting for the 5s poll. Without
5276
+ // this, the sidebar updates but the open thread keeps the old title.
5277
+ if (slug === currentSlug) loadThread(slug);
5278
+ });
5279
+ });
5280
+ });
5281
+ }
5282
+ // Inline edit-in-place — replaces prior window.prompt() dialog. Click pencil →
5283
+ // .channel-name turns into an input pre-filled with the title; Enter saves,
5284
+ // Esc cancels, blur saves. Validation errors flash the border red instead
5285
+ // of a window.alert. Per Open-Agents production-grade UX reference.
5286
+ function startInlineRename(channelEl, slug, currentTitle, onSaveSuccess) {
5287
+ if (!channelEl) return;
5288
+ const nameEl = channelEl.querySelector('.channel-name');
5289
+ if (!nameEl || nameEl.dataset.editing === '1') return;
5290
+ nameEl.dataset.editing = '1';
5291
+ const originalText = nameEl.textContent;
5292
+ const input = document.createElement('input');
5293
+ input.type = 'text';
5294
+ input.value = currentTitle;
5295
+ input.maxLength = 100;
5296
+ input.className = 'channel-rename-input';
5297
+ nameEl.textContent = '';
5298
+ nameEl.appendChild(input);
5299
+ setTimeout(() => { input.focus(); input.select(); }, 0);
5300
+ let cleanedUp = false;
5301
+ function cleanup(restoreText) {
5302
+ if (cleanedUp) return;
5303
+ cleanedUp = true;
5304
+ try { nameEl.removeChild(input); } catch (_) { /* already removed */ }
5305
+ nameEl.textContent = restoreText;
5306
+ delete nameEl.dataset.editing;
5307
+ }
5308
+ function commit() {
5309
+ if (cleanedUp) return;
5310
+ const trimmed = input.value.trim();
5311
+ if (!trimmed || trimmed === currentTitle) { cleanup(originalText); return; }
5312
+ fetch('/api/projects/' + encodeURIComponent(slug) + '/rename', {
5313
+ method: 'POST',
5314
+ headers: { 'Content-Type': 'application/json' },
5315
+ body: JSON.stringify({ title: trimmed }),
5316
+ })
5317
+ .then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e.error || 'rename_failed')))
5318
+ .then(() => { cleanup(trimmed); onSaveSuccess(); })
5319
+ .catch(() => {
5320
+ input.classList.remove('error');
5321
+ void input.offsetWidth;
5322
+ input.classList.add('error');
5323
+ });
5324
+ }
5325
+ input.addEventListener('keydown', (ev) => {
5326
+ if (ev.key === 'Enter') { ev.preventDefault(); commit(); }
5327
+ else if (ev.key === 'Escape') { ev.preventDefault(); cleanup(originalText); }
5328
+ });
5329
+ input.addEventListener('blur', () => { if (!cleanedUp) commit(); });
2851
5330
  }
2852
5331
 
2853
5332
  function presenceClass(state) {
@@ -2869,6 +5348,10 @@ ${SHARED_STYLES}
2869
5348
 
2870
5349
  function loadThread(slug) {
2871
5350
  if (!slug) { container.innerHTML = '<div class="empty"><div class="pill">No project selected</div></div>'; setReplyState(null, null); return; }
5351
+ // Reset the show-all-turns flag on every project switch so a user
5352
+ // moving from a 686-turn project to a 5-turn one doesn't carry the
5353
+ // toggled state across.
5354
+ delete container.dataset.showAllTurns;
2872
5355
  fetch('/api/projects/' + encodeURIComponent(slug))
2873
5356
  .then(r => r.ok ? r.json() : Promise.reject(r.status))
2874
5357
  .then(data => renderThread(data.thread))