codehere 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +5 -3
  2. package/dist/application/use-cases/domain-trust.js +110 -0
  3. package/dist/application/use-cases/generate-final-report.js +84 -5
  4. package/dist/application/use-cases/generate-narration.js +20 -3
  5. package/dist/application/use-cases/generate-thread-title.js +4 -0
  6. package/dist/application/use-cases/thread-export.js +155 -0
  7. package/dist/application/use-cases/verify-claim.js +274 -0
  8. package/dist/application/use-cases/write-pass.js +277 -0
  9. package/dist/index.js +218 -19
  10. package/dist/infrastructure/completion/bash-completion.js +2 -2
  11. package/dist/infrastructure/config/config-manager.js +1 -1
  12. package/dist/infrastructure/context/adapters/drawio-source.js +157 -0
  13. package/dist/infrastructure/context/adapters/figma-source.js +163 -0
  14. package/dist/infrastructure/context/adapters/github-issue-source.js +159 -0
  15. package/dist/infrastructure/context/adapters/linear-source.js +190 -0
  16. package/dist/infrastructure/context/context-resolver.js +144 -0
  17. package/dist/infrastructure/context/context-source.js +114 -0
  18. package/dist/infrastructure/context/oauth-flow.js +207 -0
  19. package/dist/infrastructure/context/oauth-store.js +119 -0
  20. package/dist/infrastructure/context/registry-bootstrap.js +99 -0
  21. package/dist/infrastructure/context/write-pass-resolver.js +106 -0
  22. package/dist/infrastructure/cost/budget-config.js +1 -1
  23. package/dist/infrastructure/cost/spend-aggregator.js +5 -5
  24. package/dist/infrastructure/hooks/claude-sdk-hook.js +79 -59
  25. package/dist/infrastructure/hooks/session-analyzer.js +65 -8
  26. package/dist/infrastructure/observability/execution-tracer.js +0 -5
  27. package/dist/infrastructure/orchestration/aider-adapter.js +45 -17
  28. package/dist/infrastructure/orchestration/artifacts-extractor.js +39 -5
  29. package/dist/infrastructure/orchestration/cassette-recorder.js +120 -0
  30. package/dist/infrastructure/orchestration/claude-code-adapter.js +33 -4
  31. package/dist/infrastructure/orchestration/codex-adapter.js +157 -19
  32. package/dist/infrastructure/orchestration/context-handoff.js +4 -3
  33. package/dist/infrastructure/orchestration/control/codex-control-surface.js +99 -0
  34. package/dist/infrastructure/orchestration/control/control-surface.js +15 -0
  35. package/dist/infrastructure/orchestration/gemini-adapter.js +15 -17
  36. package/dist/infrastructure/orchestration/graphify-context.js +6 -13
  37. package/dist/infrastructure/orchestration/memory-injector.js +11 -16
  38. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +233 -0
  39. package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +73 -0
  40. package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +83 -0
  41. package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +37 -0
  42. package/dist/infrastructure/orchestration/opencode-adapter.js +23 -3
  43. package/dist/infrastructure/orchestration/progress-aggregator.js +178 -5
  44. package/dist/infrastructure/orchestration/session-manager.js +142 -29
  45. package/dist/infrastructure/orchestration/session-persistence.js +139 -30
  46. package/dist/infrastructure/orchestration/thread-key-store.js +127 -0
  47. package/dist/infrastructure/orchestration/write-pass-store.js +46 -0
  48. package/dist/infrastructure/protocols/cloud-sync.js +10 -11
  49. package/dist/infrastructure/protocols/mcp-server-impl.js +1295 -17
  50. package/dist/infrastructure/protocols/thread-phase.js +105 -0
  51. package/dist/infrastructure/protocols/ui-page-v2.js +3768 -75
  52. package/dist/infrastructure/protocols/ui-page.js +12 -3299
  53. package/dist/infrastructure/protocols/verify-page.js +941 -0
  54. package/dist/infrastructure/security/trust-scorer.js +76 -1
  55. package/dist/infrastructure/storage/cross-tool-agents.js +20 -3
  56. package/dist/infrastructure/storage/project-thread-migration.js +14 -9
  57. package/dist/infrastructure/storage/project-thread-reader.js +11 -1
  58. package/dist/infrastructure/storage/project-thread-title-backfill.js +7 -3
  59. package/dist/monitoring.js +1 -1
  60. package/dist/presentation/cli/commands/audit-command.js +126 -0
  61. package/dist/presentation/cli/commands/budget-command.js +2 -2
  62. package/dist/presentation/cli/commands/config-command.js +2 -2
  63. package/dist/presentation/cli/commands/context-source-command.js +198 -0
  64. package/dist/presentation/cli/commands/help-command.js +51 -1
  65. package/dist/presentation/cli/commands/init-command.js +5 -4
  66. package/dist/presentation/cli/commands/memory-command.js +29 -15
  67. package/dist/presentation/cli/commands/protocols-builder.js +2 -2
  68. package/dist/presentation/cli/commands/repo-map-builder.js +4 -4
  69. package/dist/presentation/cli/commands/report-command.js +6 -4
  70. package/dist/presentation/cli/commands/setup-command.js +71 -18
  71. package/dist/presentation/cli/commands/status-command.js +0 -4
  72. package/dist/presentation/cli/commands/thread-command.js +288 -0
  73. package/dist/presentation/cli/commands/verify-command.js +233 -0
  74. package/dist/presentation/cli/error-display.js +1 -1
  75. package/dist/presentation/cli/repl.js +52 -2
  76. package/dist/presentation/cli/task-deliverable.js +127 -0
  77. package/dist/testing/cassette.js +125 -0
  78. package/dist/testing/replay-adapter.js +166 -0
  79. package/dist/ui.js +54 -29
  80. package/dist/utils/typo-guard.js +1 -1
  81. package/package.json +4 -2
@@ -1380,10 +1380,17 @@ ${AGENT_BADGE_JS}
1380
1380
  if (entry.matched) return String(entry.matched);
1381
1381
 
1382
1382
  const raw = entry.toolInput;
1383
- if (!raw) return String(entry.toolName || 'tool call');
1383
+ if (!raw) return '';
1384
1384
 
1385
1385
  try {
1386
1386
  const input = typeof raw === 'string' ? JSON.parse(raw) : raw;
1387
+ // W-PASS audit entries (v0.9 S6) use target/method/reason/passId fields.
1388
+ // Without this branch summarize() falls back to entry.toolName and the
1389
+ // row template renders "WritePass.intent WritePass.intent" twice.
1390
+ if (input.target && input.method) {
1391
+ const reason = input.reason ? ' · ' + String(input.reason).slice(0, 80) : '';
1392
+ return String(input.method) + ' ' + String(input.target) + reason;
1393
+ }
1387
1394
  if (input.command) return String(input.command).replace(/\\n/g, ' ').slice(0, 140);
1388
1395
  if (input.file_path) return shortPath(String(input.file_path));
1389
1396
  if (Array.isArray(input.edits) && input.edits[0] && input.edits[0].file_path) {
@@ -1401,7 +1408,9 @@ ${AGENT_BADGE_JS}
1401
1408
  const cmd = s.match(/"command"\\s*:\\s*"([^"]{0,140})"/);
1402
1409
  if (cmd) return cmd[1].replace(/\\\\n/g, ' ');
1403
1410
 
1404
- return String(entry.toolName || 'tool call');
1411
+ // No meaningful summary derivable. Return empty so the row template
1412
+ // can hide the summary span instead of duplicating the tool name.
1413
+ return '';
1405
1414
  }
1406
1415
 
1407
1416
  function escape(s) {
@@ -1455,7 +1464,7 @@ ${AGENT_BADGE_JS}
1455
1464
  (originAgent ? agentBadgeHTML(originAgent) : '') +
1456
1465
  '<span class="outcome ' + outcome + '">' + outcome + '</span>' +
1457
1466
  '<span class="tool">' + escape(tool) + '</span>' +
1458
- '<span class="summary" title="' + escape(summary) + '">' + escape(summary) + '</span>' +
1467
+ (summary ? '<span class="summary" title="' + escape(summary) + '">' + escape(summary) + '</span>' : '') +
1459
1468
  '<span class="score ' + (score ? '' : 'zero') + '">' + (score ? score : '—') + '</span>';
1460
1469
  feed.insertBefore(li, feed.firstChild);
1461
1470
  applyFilterToRow(li);
@@ -1697,3302 +1706,6 @@ ${AGENT_BADGE_JS}
1697
1706
  </script>
1698
1707
  </body>
1699
1708
  </html>`;
1700
- // ── / — task intake + active session view (v0.3 F1) ───────────────────────────
1701
- export const TASK_HTML = `<!doctype html>
1702
- <html lang="en">
1703
- <head>
1704
- <meta charset="utf-8" />
1705
- <meta name="viewport" content="width=device-width,initial-scale=1" />
1706
- <title>codehere</title>
1707
- <style>
1708
- ${SHARED_STYLES}
1709
- * { box-sizing: border-box; }
1710
- body { margin: 0; background: var(--bg); color: var(--fg); font-family: var(--sans); font-size: 13px; line-height: 1.55; -webkit-font-smoothing: antialiased; }
1711
-
1712
- header {
1713
- position: sticky; top: 0; z-index: 10;
1714
- background: rgba(0,0,0,0.75); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
1715
- border-bottom: 1px solid var(--border);
1716
- padding: 14px 24px;
1717
- display: flex; align-items: center; justify-content: space-between;
1718
- }
1719
- header .brand { display: flex; align-items: center; gap: 14px; }
1720
- header .title { font-family: var(--mono); font-size: 13px; font-weight: 600; }
1721
- header .title .muted { color: var(--fg-40); font-weight: 400; margin-left: 6px; }
1722
- header nav { display: flex; gap: 14px; font-family: var(--mono); font-size: 11px; }
1723
- header nav a { color: var(--fg-50); text-decoration: none; padding: 4px 8px; border-radius: 4px; }
1724
- header nav a:hover { color: var(--fg); background: rgba(255,255,255,0.04); }
1725
- header nav a.active { color: var(--fg); background: rgba(255,255,255,0.06); }
1726
-
1727
- .shell { max-width: 960px; margin: 0 auto; padding: 90px 24px 24px; position: relative; }
1728
-
1729
- /* ── Apr 29 — Slack-shape 3-pane layout ──────────────────────────────────
1730
- The app surface restructures from single-column dashboard to
1731
- channels (left) + threads (center) + members (right). Single
1732
- channel = current cwd; members = available agents from readiness API.
1733
- Multi-channel + live presence dots from F1d detection are a later release. */
1734
- .shell.slack-shape {
1735
- max-width: 1440px;
1736
- display: grid;
1737
- grid-template-columns: 200px 1fr 220px;
1738
- gap: 16px;
1739
- align-items: start;
1740
- }
1741
- @media (max-width: 1100px) {
1742
- .shell.slack-shape { grid-template-columns: 1fr; }
1743
- .shell.slack-shape .channels-pane, .shell.slack-shape .members-pane {
1744
- position: static; order: 99;
1745
- }
1746
- }
1747
- .channels-pane, .members-pane {
1748
- background: rgba(255,255,255,0.025);
1749
- border: 1px solid var(--border-soft);
1750
- border-radius: 10px;
1751
- padding: 14px 12px;
1752
- position: sticky; top: 88px;
1753
- font-family: var(--mono);
1754
- z-index: 1;
1755
- }
1756
- .pane-head {
1757
- font-size: 9.5px; letter-spacing: 0.16em;
1758
- color: var(--fg-30); margin-bottom: 10px;
1759
- text-transform: uppercase;
1760
- display: flex; align-items: center; justify-content: space-between;
1761
- }
1762
- .pane-head .pane-count { color: var(--fg-50); }
1763
- .pane-add {
1764
- background: transparent; border: 1px dashed var(--border);
1765
- color: var(--fg-50); font-family: var(--mono); font-size: 10px;
1766
- padding: 4px 8px; border-radius: 6px; cursor: pointer;
1767
- width: 100%; margin-top: 8px;
1768
- }
1769
- .pane-add:hover { color: var(--fg); border-color: var(--fg-30); border-style: solid; }
1770
- .channel, .member {
1771
- display: flex; align-items: center; gap: 8px;
1772
- padding: 6px 8px; border-radius: 6px;
1773
- font-size: 11.5px;
1774
- cursor: pointer;
1775
- color: var(--fg-70);
1776
- border: 1px solid transparent;
1777
- }
1778
- .channel:hover, .member:hover { background: rgba(255,255,255,0.04); color: var(--fg); }
1779
- .channel.active { background: rgba(255,154,118,0.08); color: var(--fg); border-color: rgba(255,154,118,0.25); }
1780
- .channel-glyph, .member-presence {
1781
- color: var(--fg-40); flex: 0 0 14px; text-align: center;
1782
- }
1783
- .member-presence.ready { color: var(--allow); }
1784
- .member-presence.warn { color: var(--warn); }
1785
- .member-presence.offline { color: var(--fg-30); }
1786
- .channel-name, .member-label {
1787
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1788
- flex: 1; min-width: 0;
1789
- }
1790
- .member-cwd-hint, .channel-cwd-hint {
1791
- font-size: 9.5px; color: var(--fg-30);
1792
- margin-top: 4px; padding-left: 22px;
1793
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1794
- }
1795
- .pane-empty {
1796
- color: var(--fg-30); font-size: 10.5px;
1797
- padding: 8px; text-align: center;
1798
- }
1799
- /* Pencil rename button — was missing from TASK_HTML's style block, so the
1800
- SVG rendered without sizing constraints and showed as a white square on
1801
- the active project row. Same rules as THREAD_HTML's copy. */
1802
- .channel-rename {
1803
- background: transparent; border: none; cursor: pointer;
1804
- color: var(--fg-30); padding: 4px;
1805
- transition: color 0.12s ease;
1806
- flex: 0 0 auto;
1807
- display: inline-flex; align-items: center; justify-content: center;
1808
- outline: none;
1809
- }
1810
- .channel-rename svg { display: block; width: 14px; height: 14px; }
1811
- .channel-rename:hover, .channel-rename:focus { color: var(--fg); background: transparent; }
1812
- .channel-rename:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
1813
-
1814
- /* ── Readiness banner (F1a) ─────────────────────────────────────────── */
1815
- .readiness {
1816
- margin: 12px 0 0;
1817
- padding: 14px 16px;
1818
- border-radius: 10px;
1819
- border: 1px solid var(--border);
1820
- background: var(--bg-1);
1821
- display: none;
1822
- }
1823
- .readiness[data-severity="warn"] {
1824
- border-color: rgba(254,188,46,0.35);
1825
- background: rgba(254,188,46,0.05);
1826
- }
1827
- .readiness[data-severity="blocker"] {
1828
- border-color: rgba(255,95,87,0.4);
1829
- background: rgba(255,95,87,0.06);
1830
- }
1831
- .readiness .r-title {
1832
- font-family: var(--mono); font-size: 12px; font-weight: 600; color: var(--fg);
1833
- margin-bottom: 4px;
1834
- }
1835
- .readiness[data-severity="warn"] .r-title { color: var(--warn); }
1836
- .readiness[data-severity="blocker"] .r-title { color: var(--block); }
1837
- .readiness .r-detail { font-family: var(--mono); font-size: 11px; color: var(--fg-70); line-height: 1.5; }
1838
- .readiness .r-action { margin-top: 8px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
1839
- .readiness .r-action code { background: rgba(255,255,255,0.06); padding: 3px 8px; border-radius: 4px; font-family: var(--mono); font-size: 11px; }
1840
- .readiness .r-action a { color: var(--accent); font-family: var(--mono); font-size: 11px; text-decoration: none; }
1841
- .readiness .r-action a:hover { text-decoration: underline; }
1842
- .readiness .r-action button {
1843
- background: transparent; color: var(--fg-70); font-family: var(--mono); font-size: 11px;
1844
- border: 1px solid var(--border); border-radius: 6px; padding: 4px 10px; cursor: pointer;
1845
- }
1846
- .readiness .r-action button:hover { color: var(--fg); border-color: var(--fg-30); }
1847
-
1848
- /* v0.6.x P0 — failover offer banner. Amber stripe: "Agent A rate-limited,
1849
- switch to Agent B?" with one-click dismiss + accept buttons. */
1850
- .failover-banner {
1851
- margin: 14px 0 0; padding: 14px 20px;
1852
- border: 1px solid rgba(254,188,46,0.3);
1853
- background: rgba(254,188,46,0.06);
1854
- border-radius: 10px;
1855
- font-size: 13px; line-height: 1.5;
1856
- display: flex; align-items: center; justify-content: space-between;
1857
- gap: 12px; flex-wrap: wrap;
1858
- }
1859
- .failover-banner .fb-msg { flex: 1; color: var(--fg-70); min-width: 200px; }
1860
- .failover-banner .fb-msg b { color: var(--fg); font-weight: 600; }
1861
- .failover-banner .fb-actions { display: flex; gap: 8px; flex-shrink: 0; }
1862
- .failover-banner .fb-actions button {
1863
- font-family: var(--mono); font-size: 11px;
1864
- padding: 5px 14px; border-radius: 6px; cursor: pointer;
1865
- border: 1px solid var(--border); background: transparent;
1866
- color: var(--fg-70); transition: color 0.15s, border-color 0.15s;
1867
- }
1868
- .failover-banner .fb-actions button:hover { color: var(--fg); border-color: var(--fg-30); }
1869
- .failover-banner .fb-actions button.fb-accept {
1870
- background: rgba(254,188,46,0.12); border-color: rgba(254,188,46,0.4);
1871
- color: var(--warn); font-weight: 600;
1872
- }
1873
- .failover-banner .fb-actions button.fb-accept:hover { background: rgba(254,188,46,0.2); }
1874
-
1875
- /* v0.6 S2 — first-time onboarding card. Quiet authority: peach
1876
- accent border + subtle glass, doesn't compete with the textarea
1877
- below. Removed once dismissed OR once any session exists. */
1878
- .onboarding-card {
1879
- margin: 30px 0 18px; padding: 24px 28px;
1880
- border: 1px solid rgba(255,154,118,0.25);
1881
- background: linear-gradient(135deg, rgba(255,154,118,0.04) 0%, rgba(74,144,226,0.03) 100%);
1882
- border-radius: 12px;
1883
- position: relative; z-index: 1;
1884
- }
1885
- .onboarding-card .ob-head {
1886
- display: flex; align-items: flex-start; justify-content: space-between;
1887
- margin-bottom: 6px;
1888
- }
1889
- .onboarding-card .ob-title {
1890
- font-family: var(--sans); font-size: 18px; font-weight: 700;
1891
- color: var(--fg);
1892
- background: linear-gradient(135deg, #ff9a76 0%, #4a90e2 100%);
1893
- -webkit-background-clip: text; background-clip: text;
1894
- -webkit-text-fill-color: transparent;
1895
- }
1896
- .onboarding-card .ob-close {
1897
- background: transparent; border: none; cursor: pointer;
1898
- color: var(--fg-40); font-size: 22px; line-height: 1;
1899
- padding: 0 4px;
1900
- transition: color 0.15s ease;
1901
- }
1902
- .onboarding-card .ob-close:hover { color: var(--fg); }
1903
- .onboarding-card .ob-blurb {
1904
- font-family: var(--sans); font-size: 13px; line-height: 1.55;
1905
- color: var(--fg-70); margin-bottom: 16px; max-width: 640px;
1906
- }
1907
- .onboarding-card .ob-steps {
1908
- list-style: none; padding: 0; margin: 0;
1909
- display: flex; flex-direction: column; gap: 10px;
1910
- }
1911
- .onboarding-card .ob-steps li {
1912
- display: flex; gap: 12px; align-items: flex-start;
1913
- }
1914
- .onboarding-card .ob-num {
1915
- flex: 0 0 22px; height: 22px; line-height: 22px;
1916
- border-radius: 50%; text-align: center;
1917
- background: rgba(255,154,118,0.15);
1918
- color: var(--accent);
1919
- font-family: var(--mono); font-size: 11px; font-weight: 600;
1920
- }
1921
- .onboarding-card .ob-step-title {
1922
- font-family: var(--sans); font-size: 13px; font-weight: 600;
1923
- color: var(--fg); margin-bottom: 2px;
1924
- }
1925
- .onboarding-card .ob-step-body {
1926
- font-family: var(--sans); font-size: 12px; line-height: 1.5;
1927
- color: var(--fg-50);
1928
- }
1929
-
1930
- .intake { padding: 36px 0 24px; position: relative; z-index: 1; }
1931
- .intake h1 {
1932
- margin: 0 0 6px;
1933
- font-size: clamp(28px, 4vw, 44px);
1934
- font-weight: 900;
1935
- letter-spacing: -0.02em;
1936
- line-height: 1.08;
1937
- }
1938
- .intake p.sub { margin: 0 0 22px; color: var(--fg-50); font-size: 14px; max-width: 560px; }
1939
- .intake textarea {
1940
- width: 100%; min-height: 84px;
1941
- background: var(--bg-1); color: var(--fg);
1942
- border: 1px solid var(--border); border-radius: 10px;
1943
- padding: 14px 16px;
1944
- font-family: var(--mono); font-size: 13px; line-height: 1.55;
1945
- resize: vertical; outline: none;
1946
- }
1947
- .intake textarea:focus { border-color: rgba(255,154,118,0.4); }
1948
-
1949
- /* Pattern 5 · Composer + cost row. Sits between the textarea and the
1950
- intake-row so the user sees: who's going to run this · where ·
1951
- how much of the context window the prompt consumes · est. cost.
1952
- Live-updates on every keystroke + agent/cwd change.
1953
- Per docs/design/AGENTIC_UX.md Pattern 5. */
1954
- .composer-meta {
1955
- display: flex; gap: 10px; align-items: center;
1956
- margin-top: 6px; padding: 0 4px;
1957
- font-family: var(--mono); font-size: 11px; color: var(--fg-50);
1958
- min-height: 18px;
1959
- }
1960
- .composer-meta .cm-pill {
1961
- display: inline-flex; gap: 4px; align-items: center;
1962
- padding: 1px 8px; border-radius: 999px;
1963
- border: 1px solid var(--border-soft);
1964
- background: rgba(255,255,255,0.02);
1965
- }
1966
- .composer-meta .cm-pill b { color: var(--fg-70); font-weight: 500; }
1967
- .composer-meta .cm-sep { color: var(--fg-20); user-select: none; }
1968
- .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); }
1969
- .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); }
1970
- .composer-meta .cm-cost.free { color: var(--allow); border-color: rgba(40,200,64,0.22); background: rgba(40,200,64,0.06); }
1971
- .composer-meta .cm-cost.paid { color: var(--fg-70); }
1972
- .composer-meta .cm-spend { color: var(--fg-70); cursor: help; }
1973
- .composer-meta .cm-spend b { color: var(--fg-90); }
1974
- .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); }
1975
- .composer-meta .cm-spend[data-state="warn"] b{ color: var(--warn); }
1976
- .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); }
1977
- .composer-meta .cm-spend[data-state="block"] b{ color: var(--block); }
1978
- .composer-meta .cm-spacer { flex: 1; }
1979
- .composer-meta .cm-hint {
1980
- color: var(--fg-30); font-size: 10.5px;
1981
- margin-left: 4px;
1982
- }
1983
- /* On narrow viewports the hint hides — agent + budget pills stay,
1984
- keyboard hint becomes discoverable via tooltip on the textarea. */
1985
- @media (max-width: 720px) {
1986
- .composer-meta .cm-hint { display: none; }
1987
- }
1988
-
1989
- .intake-row { display: flex; align-items: center; gap: 10px; margin-top: 10px; }
1990
- .intake select, .intake button, .intake input.cwd {
1991
- font-family: var(--mono); font-size: 12px;
1992
- background: var(--bg-1); color: var(--fg);
1993
- border: 1px solid var(--border); border-radius: 8px;
1994
- padding: 8px 14px;
1995
- cursor: pointer;
1996
- }
1997
- .intake input.cwd {
1998
- cursor: text;
1999
- flex: 1 1 260px;
2000
- min-width: 200px;
2001
- }
2002
- .intake input.cwd:focus { outline: none; border-color: rgba(255,154,118,0.4); }
2003
- .intake input.cwd::placeholder { color: var(--fg-30); }
2004
- .intake button.spawn {
2005
- background: var(--accent); color: #000; font-weight: 600; border-color: var(--accent);
2006
- }
2007
- .intake button.spawn:hover { filter: brightness(1.05); }
2008
- .intake button.spawn[disabled] { opacity: 0.45; cursor: default; }
2009
- /* v0.6.x Slack-shape spike — alternative submit that routes the prompt as a
2010
- reply to the most-recent task in the current cwd, exercising the existing
2011
- appendMessage path. Lets a single intake feel like an ongoing conversation
2012
- when the user wants continuity, while preserving "Assign" as the explicit
2013
- new-task path. Disabled when no thread exists for the current cwd. */
2014
- .intake button.continue-thread {
2015
- background: rgba(255,154,118,0.10); color: var(--accent); font-weight: 600;
2016
- border-color: rgba(255,154,118,0.3);
2017
- }
2018
- .intake button.continue-thread:hover:not([disabled]) { background: rgba(255,154,118,0.18); }
2019
- .intake button.continue-thread[disabled] { opacity: 0.4; cursor: default; }
2020
- .intake .hint { margin-left: auto; color: var(--fg-40); font-family: var(--mono); font-size: 11px; }
2021
-
2022
- /* ── Setup panel (onboarding — appears when a non-ready agent is picked) ───
2023
- Principle: codehere is the control plane from install → orchestrate → audit.
2024
- When the user picks an agent that isn't ready, we surface what to do
2025
- inline rather than fail at spawn time. */
2026
- .setup-panel {
2027
- margin: 10px 0 0;
2028
- padding: 12px 14px;
2029
- border: 1px solid var(--border);
2030
- border-radius: 8px;
2031
- background: rgba(254,188,46,0.04);
2032
- font-family: var(--mono); font-size: 11px;
2033
- color: var(--fg-70);
2034
- display: none;
2035
- }
2036
- .setup-panel[data-state="needs_install"] { border-color: rgba(74,144,226,0.3); background: rgba(74,144,226,0.05); }
2037
- .setup-panel[data-state="needs_auth"] { border-color: rgba(254,188,46,0.4); background: rgba(254,188,46,0.06); }
2038
- .setup-panel[data-state="billed_key_required"] { border-color: rgba(255,95,87,0.35); background: rgba(255,95,87,0.05); }
2039
- .setup-panel .sp-title {
2040
- font-weight: 600; color: var(--fg); margin-bottom: 6px;
2041
- }
2042
- .setup-panel[data-state="needs_install"] .sp-title { color: var(--accent-blue); }
2043
- .setup-panel[data-state="needs_auth"] .sp-title { color: var(--warn); }
2044
- .setup-panel[data-state="billed_key_required"] .sp-title { color: var(--block); }
2045
- .setup-panel code {
2046
- background: rgba(255,255,255,0.08); padding: 3px 8px; border-radius: 4px;
2047
- color: var(--fg); font-size: 11px;
2048
- }
2049
- .setup-panel a { color: var(--accent); text-decoration: none; }
2050
- .setup-panel a:hover { text-decoration: underline; }
2051
- .setup-panel .swap { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
2052
- .setup-panel .swap .swap-label { color: var(--fg-40); text-transform: uppercase; letter-spacing: 0.1em; font-size: 10px; }
2053
- .setup-panel .swap button {
2054
- font-family: var(--mono); font-size: 11px;
2055
- color: var(--accent); background: transparent;
2056
- border: 1px solid rgba(255,154,118,0.35); border-radius: 999px;
2057
- padding: 3px 10px; cursor: pointer;
2058
- }
2059
- .setup-panel .swap button:hover { color: var(--fg); background: rgba(255,154,118,0.1); border-color: var(--accent); }
2060
-
2061
- /* F2 · Install button + streamed output (principle #10: codehere DOES the install) */
2062
- .setup-panel .install-row { margin-top: 10px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
2063
- .setup-panel .install-row button.install {
2064
- background: var(--accent); color: #000; font-weight: 600;
2065
- border: 1px solid var(--accent); border-radius: 8px;
2066
- padding: 8px 16px; cursor: pointer;
2067
- font-family: var(--sans); font-size: 12px;
2068
- }
2069
- .setup-panel .install-row button.install:hover { filter: brightness(1.05); }
2070
- .setup-panel .install-row button.install[disabled] { opacity: 0.5; cursor: default; }
2071
- .setup-panel .install-row button.install.running { background: transparent; color: var(--accent); }
2072
- .setup-panel .install-row button.install.done { background: rgba(40,200,64,0.1); color: var(--allow); border-color: rgba(40,200,64,0.35); }
2073
- .setup-panel .install-row button.install.failed { background: rgba(255,95,87,0.08); color: var(--block); border-color: rgba(255,95,87,0.35); }
2074
- .setup-panel .install-row .install-subtitle { color: var(--fg-50); font-size: 11px; font-family: var(--mono); }
2075
- .setup-panel .install-output {
2076
- margin-top: 10px; padding: 10px 12px;
2077
- background: #000; border: 1px solid var(--border-soft); border-radius: 6px;
2078
- font-family: var(--mono); font-size: 11px; color: var(--fg-70);
2079
- max-height: 220px; overflow-y: auto;
2080
- white-space: pre-wrap; word-break: break-word;
2081
- display: none;
2082
- }
2083
- .setup-panel .install-output.show { display: block; }
2084
- .setup-panel .install-output.has-err { border-color: rgba(255,95,87,0.3); }
2085
- .setup-panel .install-details-toggle {
2086
- font-family: var(--mono); font-size: 10px; color: var(--fg-40);
2087
- background: transparent; border: none; cursor: pointer;
2088
- padding: 2px 6px;
2089
- }
2090
- .setup-panel .install-details-toggle:hover { color: var(--fg-70); }
2091
-
2092
- /* Per-option glyph in the select dropdown — rendered via the option text itself
2093
- since <option> can't be styled with pseudo-elements cross-browser. */
2094
-
2095
- /* ── Starter prompts (Gate A1 — first-run path to success) ──────────
2096
- Principle #1 (web is not a terminal — a welcoming surface) + scope-discipline #9.
2097
- Three one-click prompts that let a stranger see codehere work in the project
2098
- they opened the daemon in, without needing to know what to ask. */
2099
- .starter-prompts {
2100
- display: flex; flex-wrap: wrap; gap: 6px;
2101
- margin: 10px 0 0;
2102
- }
2103
- .starter-prompts button {
2104
- font-family: var(--mono); font-size: 11px;
2105
- color: var(--fg-70);
2106
- border: 1px solid var(--border); border-radius: 999px;
2107
- background: rgba(255,255,255,0.02);
2108
- padding: 5px 12px; cursor: pointer;
2109
- transition: color .15s ease, border-color .15s ease, background .15s ease;
2110
- }
2111
- .starter-prompts button:hover {
2112
- color: var(--fg);
2113
- border-color: rgba(255,154,118,0.35);
2114
- background: rgba(255,154,118,0.06);
2115
- }
2116
- .starter-prompts .sp-label {
2117
- font-family: var(--mono); font-size: 10px; letter-spacing: 0.12em;
2118
- color: var(--fg-40); text-transform: uppercase;
2119
- padding: 5px 2px 5px 0;
2120
- }
2121
-
2122
- .section-label {
2123
- margin-top: 24px; margin-bottom: 8px;
2124
- font-family: var(--mono); font-size: 10px; letter-spacing: 0.14em;
2125
- color: var(--fg-40); text-transform: uppercase;
2126
- }
2127
-
2128
- .session {
2129
- border-radius: 12px;
2130
- margin-bottom: 12px;
2131
- overflow: hidden;
2132
- /* glass-card styling from shared tokens */
2133
- background: rgba(255,255,255,0.02);
2134
- backdrop-filter: blur(16px);
2135
- -webkit-backdrop-filter: blur(16px);
2136
- border: 1px solid var(--border);
2137
- transition: border-color 0.3s ease, transform 0.3s cubic-bezier(0.16,1,0.3,1), box-shadow 0.3s ease;
2138
- animation: fade-up 0.5s cubic-bezier(0.16,1,0.3,1) both;
2139
- }
2140
- .session:hover {
2141
- border-color: rgba(255,255,255,0.15);
2142
- transform: translateY(-2px);
2143
- box-shadow: 0 12px 40px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.04);
2144
- }
2145
- .session-head {
2146
- display: flex; align-items: center; justify-content: space-between;
2147
- padding: 14px 20px;
2148
- cursor: pointer;
2149
- user-select: none;
2150
- }
2151
- .session.expanded .session-head {
2152
- border-bottom: 1px solid var(--border-soft);
2153
- }
2154
- .session .chevron {
2155
- display: inline-block; margin-left: 8px; color: var(--fg-40);
2156
- font-family: var(--mono); font-size: 10px;
2157
- transition: transform 0.2s ease;
2158
- }
2159
- .session.expanded .chevron { transform: rotate(90deg); }
2160
- .session-body { display: none; }
2161
- .session.expanded .session-body { display: block; }
2162
- .session-head .meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
2163
- .session-head .prompt { font-family: var(--mono); font-size: 12px; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 620px; }
2164
- .session-head .sub { font-family: var(--mono); font-size: 11px; color: var(--fg-40); }
2165
- /* May 6 2026 design polish (Tier 1 #2): drop uppercase + heavy letter-spacing
2166
- per Open-Agents production-grade reference. Status pills now read as
2167
- human labels (Sentence case) not all-caps shouts. Killed demoted from
2168
- yellow-warn to muted neutral — it's just "stopped", not an alert. */
2169
- .session-head .status-pill {
2170
- font-family: var(--mono); font-size: 10.5px; font-weight: 500;
2171
- padding: 3px 10px; border-radius: 999px;
2172
- border: 1px solid transparent;
2173
- }
2174
- /* Pattern 8 · Status badge taxonomy — locked palette per Track A #2.
2175
- 7 owned states; semantic colors reflect actionable meaning.
2176
- Per docs/design/AGENTIC_UX.md Pattern 8. */
2177
- .status-pill.running { color: var(--accent); background: var(--accent-wash); border-color: rgba(255,154,118,0.25); }
2178
- .status-pill.completed { color: var(--allow); background: rgba(40,200,64,0.10); border-color: rgba(40,200,64,0.20); }
2179
- .status-pill.awaiting { color: var(--accent); background: transparent; border-color: var(--accent); border-style: dashed; }
2180
- .status-pill.blocked { color: var(--block); background: rgba(255,95,87,0.08); border-color: rgba(255,95,87,0.25); }
2181
- .status-pill.failed,
2182
- .status-pill.error { color: var(--block); background: rgba(255,95,87,0.10); border-color: rgba(255,95,87,0.30); }
2183
- .status-pill.killed { color: var(--fg-50); background: rgba(255,255,255,0.04); border-color: var(--border); }
2184
- .status-pill.observed-pill { color: var(--fg-50); background: rgba(255,255,255,0.03); border-color: var(--border); font-weight: 400; }
2185
-
2186
- /* Pattern 6 · macro states — wired to actual adapter error codes
2187
- emitted by failover-classifier.ts + per-adapter ERROR_PATTERNS.
2188
- yellow (--warn) = recoverable (failover-eligible per failover-classifier);
2189
- red (--block) = needs human action (auth, billing, model-pick).
2190
- Per docs/design/AGENTIC_UX.md Pattern 6. */
2191
- .status-pill.rate-limit,
2192
- .status-pill.quota-exceeded,
2193
- .status-pill.credit-low { color: var(--warn); background: rgba(254,188,46,0.08); border-color: rgba(254,188,46,0.25); }
2194
- .status-pill.auth-invalid,
2195
- .status-pill.auth-missing,
2196
- .status-pill.model-unavailable,
2197
- .status-pill.crashed { color: var(--block); background: rgba(255,95,87,0.10); border-color: rgba(255,95,87,0.30); }
2198
- .status-pill.thinking,
2199
- .status-pill.running-buffered { color: var(--fg-50); background: rgba(255,255,255,0.03); border-color: var(--border); }
2200
-
2201
- /* Persisted sessions = your own past tasks replayed from disk.
2202
- Faded but VISIBLE. The detected-process variant (.observed) is
2203
- diagnostic — hidden by default per claim_user_persona. */
2204
- .session.persisted { opacity: 0.88; }
2205
- .session.persisted .session-head { border-bottom: none; }
2206
- .session.observed { opacity: 0.88; }
2207
- .session.observed .session-head { border-bottom: none; }
2208
- /* claim_user_persona HIGH — detected-process rows ("Claude Code ·
2209
- pid 30531") are diagnostic, not user-facing. Hidden by default;
2210
- advanced toggle reveals. The user's OWN spawned + persisted
2211
- sessions still show — only externally-detected runners are gated. */
2212
- .session.observed { display: none; }
2213
- body.advanced-on .session.observed { display: block; }
2214
-
2215
- /* claim_user_persona — session list limit. Default shows the 5
2216
- most-recent cards; "Show all" reveals the rest. .show-all-on body
2217
- class lifts the cap. CSS-only via :nth-child so attachSession
2218
- prepend semantics stay simple.
2219
- CRITICAL: running sessions are NEVER capped — a user must always
2220
- see their actively-working session, even if it's the 10th card. */
2221
- #sessions > .session:nth-child(n+6):not(.status-running) { display: none; }
2222
- body.show-all-sessions #sessions > .session:nth-child(n+6) { display: block; }
2223
- /* When advanced is also off, observed rows beyond cap stay hidden. */
2224
- body.show-all-sessions #sessions > .session.observed { display: none; }
2225
- body.show-all-sessions.advanced-on #sessions > .session.observed { display: block; }
2226
- .sessions-actions {
2227
- display: flex; gap: 10px; justify-content: center; align-items: center;
2228
- margin: 14px 0 0;
2229
- }
2230
- .show-more-sessions {
2231
- display: none; /* shown via JS when count > 5 */
2232
- padding: 8px 16px;
2233
- background: transparent;
2234
- color: var(--fg-50);
2235
- border: 1px solid var(--border);
2236
- border-radius: 999px;
2237
- font-family: var(--mono); font-size: 11px;
2238
- cursor: pointer;
2239
- transition: color 0.15s ease, border-color 0.15s ease;
2240
- }
2241
- .show-more-sessions:hover { color: var(--fg); border-color: var(--fg-30); }
2242
- body.show-all-sessions .show-more-sessions { display: none; }
2243
- .stop-all-sessions {
2244
- padding: 8px 16px;
2245
- background: transparent;
2246
- color: var(--block);
2247
- border: 1px solid rgba(255,95,87,0.3);
2248
- border-radius: 999px;
2249
- font-family: var(--mono); font-size: 11px;
2250
- cursor: pointer;
2251
- transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
2252
- }
2253
- .stop-all-sessions:hover { color: var(--fg); border-color: var(--block); background: rgba(255,95,87,0.08); }
2254
-
2255
- .session-controls { display: flex; gap: 8px; }
2256
- .session-controls button {
2257
- font-family: var(--mono); font-size: 11px;
2258
- background: transparent; color: var(--fg-70);
2259
- border: 1px solid var(--border); border-radius: 6px;
2260
- padding: 4px 10px; cursor: pointer;
2261
- }
2262
- .session-controls button:hover { color: var(--fg); border-color: var(--fg-30); }
2263
- .session-controls button.kill:hover { color: var(--block); border-color: rgba(255,95,87,0.4); }
2264
- /* Pattern 19 — interrupt affordance state machine (ready / confirming / killing).
2265
- Confirming state turns the button red so the second press registers
2266
- as intentional. 3s window then reverts to ready. */
2267
- .session-controls button.kill[data-state="confirming"] {
2268
- color: var(--block);
2269
- border-color: var(--block);
2270
- background: rgba(255,95,87,0.08);
2271
- font-weight: 600;
2272
- animation: kill-confirm-pulse 0.6s ease-in-out infinite alternate;
2273
- }
2274
- .session-controls button.kill[data-state="killing"] {
2275
- color: var(--fg-50);
2276
- cursor: progress;
2277
- opacity: 0.7;
2278
- }
2279
- @keyframes kill-confirm-pulse {
2280
- from { box-shadow: 0 0 0 rgba(255,95,87,0); }
2281
- to { box-shadow: 0 0 8px rgba(255,95,87,0.4); }
2282
- }
2283
-
2284
- .session-body { padding: 0 20px 16px; }
2285
-
2286
- /* Session threading — visual link between original and failover sessions.
2287
- The original card shows "↻ continued as [agent]" with a link to the new card.
2288
- The new card shows "Continued from [agent]" at the top. */
2289
- .session-continued-as {
2290
- margin: 0 20px 12px;
2291
- padding: 8px 12px;
2292
- border: 1px dashed rgba(255,154,118,0.3);
2293
- border-radius: 8px;
2294
- background: rgba(255,154,118,0.04);
2295
- font-family: var(--mono);
2296
- font-size: 11px;
2297
- color: var(--fg-70);
2298
- }
2299
- .session-continued-as a {
2300
- color: var(--accent);
2301
- text-decoration: none;
2302
- font-weight: 500;
2303
- }
2304
- .session-continued-as a:hover { text-decoration: underline; }
2305
- .session-continued-from {
2306
- margin: 0 0 8px;
2307
- padding: 6px 12px;
2308
- border-left: 2px solid rgba(255,154,118,0.3);
2309
- font-family: var(--mono);
2310
- font-size: 10.5px;
2311
- color: var(--fg-50);
2312
- }
2313
- .session-continued-from a {
2314
- color: var(--accent);
2315
- text-decoration: none;
2316
- }
2317
- .session-continued-from a:hover { text-decoration: underline; }
2318
-
2319
- /* P18 slice 4 — permission gate panel. Renders inside the session
2320
- card when the per-tool hook blocked something. Per the dogfood-
2321
- learning + overconfidence-guard: framed as "Codehere noticed",
2322
- not "security violation". Approve button is the most prominent
2323
- because false-positives are the common case (e.g. /etc/shadow
2324
- in a docstring). Keep-blocking is the rare-but-real branch. */
2325
- .permission-gate {
2326
- margin: 10px 0 8px;
2327
- padding: 12px 14px;
2328
- border: 1px solid rgba(254,188,46,0.35);
2329
- background: rgba(254,188,46,0.05);
2330
- border-radius: 8px;
2331
- }
2332
- .permission-gate .pg-label {
2333
- font-family: var(--mono); font-size: 9.5px;
2334
- color: var(--warn);
2335
- text-transform: uppercase; letter-spacing: 0.14em;
2336
- margin-bottom: 4px;
2337
- }
2338
- .permission-gate .pg-title {
2339
- font-family: var(--sans); font-size: 13px;
2340
- color: var(--fg);
2341
- margin-bottom: 6px;
2342
- }
2343
- .permission-gate .pg-detail {
2344
- font-family: var(--mono); font-size: 11px;
2345
- color: var(--fg-70);
2346
- background: rgba(0,0,0,0.2);
2347
- padding: 6px 10px; border-radius: 4px;
2348
- margin-bottom: 10px;
2349
- word-break: break-all;
2350
- }
2351
- .permission-gate .pg-actions {
2352
- display: flex; gap: 8px; align-items: center;
2353
- }
2354
- .permission-gate button {
2355
- font-family: var(--mono); font-size: 11px;
2356
- padding: 6px 12px; border-radius: 6px;
2357
- cursor: pointer;
2358
- border: 1px solid var(--border);
2359
- background: transparent;
2360
- color: var(--fg-70);
2361
- }
2362
- .permission-gate button.pg-approve {
2363
- color: var(--accent);
2364
- border-color: var(--accent);
2365
- background: var(--accent-wash);
2366
- font-weight: 600;
2367
- }
2368
- .permission-gate button.pg-approve:hover { filter: brightness(1.1); }
2369
- .permission-gate button.pg-keep:hover { color: var(--fg); border-color: var(--fg-30); }
2370
- .permission-gate.approved {
2371
- border-color: rgba(40,200,64,0.3);
2372
- background: rgba(40,200,64,0.05);
2373
- }
2374
- .permission-gate.approved .pg-label { color: var(--allow); }
2375
- .permission-gate.approved .pg-actions { display: none; }
2376
- .permission-gate .pg-status {
2377
- display: none;
2378
- font-family: var(--mono); font-size: 10.5px;
2379
- color: var(--allow);
2380
- margin-top: 2px;
2381
- }
2382
- .permission-gate.approved .pg-status { display: block; }
2383
-
2384
- /* v0.6 M3 — PM narrator block. Quiet style — visible but doesn't
2385
- compete with primary content. Italic body to signal "this is
2386
- narration, not authoritative" per overconfidence-guard. */
2387
- .narrator-block {
2388
- margin: 10px 0 8px;
2389
- padding: 10px 14px;
2390
- border-left: 2px solid var(--accent);
2391
- background: rgba(255,154,118,0.04);
2392
- border-radius: 0 8px 8px 0;
2393
- }
2394
- .narrator-block .narrator-label {
2395
- font-family: var(--mono); font-size: 9.5px;
2396
- color: var(--fg-30);
2397
- text-transform: uppercase; letter-spacing: 0.14em;
2398
- margin-bottom: 4px;
2399
- }
2400
- .narrator-block .narrator-text {
2401
- font-family: var(--sans);
2402
- font-size: 13px; line-height: 1.45;
2403
- color: var(--fg-70);
2404
- font-style: italic;
2405
- }
2406
- .narrator-block .narrator-meta {
2407
- font-family: var(--mono); font-size: 10px;
2408
- color: var(--fg-30);
2409
- margin-top: 4px;
2410
- }
2411
-
2412
- /* Pattern 3 · Session card footer — elapsed · tools · files. Renders
2413
- beneath the body when the session has at least one step. Closes the
2414
- card visually + surfaces the same counts as the live tool-progress
2415
- chip but in a settled, summary form. Cost intentionally absent here
2416
- — that's P5's composer surface, not the per-card footer (per
2417
- AGENTIC_UX.md Pattern 3: "elapsed · cost · tool count · file count"
2418
- with cost-router not yet wired). */
2419
- .session-foot {
2420
- display: flex; gap: 14px; align-items: center;
2421
- padding: 10px 20px 12px;
2422
- border-top: 1px solid var(--border-soft);
2423
- font-family: var(--mono); font-size: 11px; color: var(--fg-50);
2424
- }
2425
- .session-foot[data-empty="true"] { display: none; }
2426
- .session-foot .sf-item { display: inline-flex; gap: 4px; align-items: center; }
2427
- .session-foot .sf-item b { color: var(--fg-70); font-weight: 600; }
2428
- .session-foot .sf-sep { color: var(--fg-20); user-select: none; }
2429
-
2430
- .event-log {
2431
- margin: 10px 0 0;
2432
- max-height: 360px; overflow-y: auto;
2433
- background: #000;
2434
- border: 1px solid var(--border-soft); border-radius: 8px;
2435
- padding: 10px 12px;
2436
- font-family: var(--mono); font-size: 11px; line-height: 1.6;
2437
- }
2438
- .event-log .event { color: var(--fg-70); white-space: pre-wrap; word-break: break-word; padding: 2px 0; }
2439
- .event-log .event .t { color: var(--fg-30); margin-right: 8px; }
2440
- .event-log .event .label { color: var(--fg-50); font-weight: 600; margin-right: 6px; }
2441
- .event-log .event.claude { color: var(--fg); }
2442
- .event-log .event.claude .label { color: var(--accent); }
2443
- .event-log .event.tool .label { color: var(--accent); }
2444
- .event-log .event.tool .tool-name { color: var(--accent); font-weight: 600; }
2445
- .event-log .event.result .label { color: var(--fg-50); }
2446
- .event-log .event.hook .label { color: var(--allow); }
2447
- .event-log .event.hook.blocked .label { color: var(--block); }
2448
- .event-log .event.done .label { color: var(--allow); }
2449
- .event-log .event.stderr { color: var(--warn); }
2450
- .event-log .event.stderr .label { color: var(--warn); }
2451
- .event-log .event.exit { color: var(--fg-50); font-style: italic; }
2452
- .event-log .event.error { color: var(--block); }
2453
- .event-log .event.error .label { color: var(--block); }
2454
-
2455
- /* codehere:error events — structured recovery hints (F1b) */
2456
- .event-log .event.codehere-error {
2457
- margin: 6px -4px; padding: 10px 12px;
2458
- background: rgba(255,95,87,0.08); border: 1px solid rgba(255,95,87,0.3); border-radius: 6px;
2459
- color: var(--fg);
2460
- }
2461
- .event-log .event.codehere-error .label { color: var(--block); display: block; margin-bottom: 4px; }
2462
- .event-log .event.codehere-error .recovery { color: var(--fg-70); margin-top: 4px; display: block; }
2463
-
2464
- /* Apr 29 — Slack-shape thread view.
2465
- Expanded session card defaults to thread view; raw event-log opt-in via
2466
- the Thread/Log toggle in the card head. Each event becomes a message:
2467
- user prompt seeds it; agent text/tool/hook events stream in as replies.
2468
- The reply input is a visual scaffold — wires up as a real follow-up
2469
- queue in a later release. */
2470
- .thread-messages {
2471
- margin: 10px 0 0;
2472
- display: flex; flex-direction: column; gap: 10px;
2473
- max-height: 420px; overflow-y: auto;
2474
- padding-right: 4px;
2475
- }
2476
- .thread-message { display: flex; gap: 10px; padding: 6px 0; }
2477
- .thread-message .avatar {
2478
- flex: 0 0 28px; height: 28px;
2479
- border-radius: 50%;
2480
- display: flex; align-items: center; justify-content: center;
2481
- font-size: 13px;
2482
- background: rgba(255,255,255,0.04); border: 1px solid var(--border-soft);
2483
- }
2484
- .thread-message.user .avatar { background: rgba(74,144,226,0.18); border-color: rgba(74,144,226,0.45); }
2485
- .thread-message.agent .avatar { background: rgba(255,154,118,0.18); border-color: rgba(255,154,118,0.45); }
2486
- .thread-message.tool .avatar { background: rgba(255,154,118,0.08); }
2487
- .thread-message.hook .avatar { background: rgba(127,193,154,0.18); border-color: rgba(127,193,154,0.4); }
2488
- .thread-message.hook.blocked .avatar { background: rgba(255,95,87,0.18); border-color: rgba(255,95,87,0.45); }
2489
- .thread-message.error .avatar { background: rgba(255,95,87,0.18); border-color: rgba(255,95,87,0.45); }
2490
- .thread-message .body { flex: 1; min-width: 0; }
2491
- .thread-message .head { display: flex; gap: 8px; align-items: baseline; font-family: var(--mono); font-size: 10.5px; }
2492
- .thread-message .author { color: var(--fg); font-weight: 600; }
2493
- .thread-message .time { color: var(--fg-40); }
2494
- .thread-message .content {
2495
- color: var(--fg-80); font-family: var(--mono); font-size: 11.5px;
2496
- line-height: 1.55; margin-top: 3px; white-space: pre-wrap; word-break: break-word;
2497
- }
2498
- .thread-message.tool .content { color: var(--fg-60); }
2499
- .thread-message.hook .content { color: var(--allow); }
2500
- .thread-message.hook.blocked .content { color: var(--block); }
2501
- .thread-message.error .content { color: var(--block); }
2502
-
2503
- .thread-input {
2504
- margin-top: 14px;
2505
- padding: 10px 12px;
2506
- background: rgba(255,255,255,0.02);
2507
- border: 1px solid var(--border-soft); border-radius: 8px;
2508
- color: var(--fg-50); font-size: 11px; font-family: var(--mono);
2509
- display: flex; align-items: center; gap: 8px;
2510
- }
2511
- /* Iter 12 — reply surface now functional. Wraps textarea + send button. */
2512
- .thread-input { padding: 8px; flex-direction: column; align-items: stretch; gap: 6px; }
2513
- .thread-input::before { display: none; }
2514
- .thread-input .reply-text {
2515
- width: 100%; min-height: 38px; resize: vertical;
2516
- background: transparent; border: 0; outline: none;
2517
- color: var(--fg); font-family: var(--sans); font-size: 12px; line-height: 1.5;
2518
- padding: 4px 0;
2519
- }
2520
- .thread-input .reply-text::placeholder { color: var(--fg-30); }
2521
- .thread-input .reply-send {
2522
- align-self: flex-end;
2523
- padding: 5px 14px; border-radius: 6px;
2524
- background: var(--accent-wash); color: var(--accent);
2525
- border: 1px solid rgba(255,154,118,0.25);
2526
- font-family: var(--sans); font-size: 11px; font-weight: 600;
2527
- cursor: pointer;
2528
- }
2529
- .thread-input .reply-send:hover { background: rgba(255,154,118,0.18); }
2530
- .thread-input .reply-send:disabled { opacity: 0.4; cursor: default; }
2531
- .thread-input .reply-status { color: var(--fg-50); font-size: 10px; font-family: var(--mono); }
2532
- .thread-input .reply-status.error { color: var(--block); }
2533
- .thread-input.disabled .reply-text,
2534
- .thread-input.disabled .reply-send { opacity: 0.5; cursor: not-allowed; }
2535
-
2536
- .view-toggle {
2537
- display: inline-flex; gap: 0;
2538
- border: 1px solid var(--border-soft); border-radius: 6px;
2539
- overflow: hidden;
2540
- margin-right: 8px;
2541
- }
2542
- .view-toggle button {
2543
- background: transparent; border: 0;
2544
- color: var(--fg-60); padding: 3px 9px;
2545
- font-family: var(--mono); font-size: 10px;
2546
- cursor: pointer;
2547
- }
2548
- .view-toggle button.active { color: var(--fg); background: rgba(255,255,255,0.06); }
2549
- .view-toggle button:hover { color: var(--fg); }
2550
-
2551
- /* Toggle visibility based on data-view attribute on session-body */
2552
- .session-body[data-view="thread"] .event-log { display: none; }
2553
- .session-body[data-view="raw"] .thread-messages { display: none; }
2554
- .session-body[data-view="raw"] .thread-input { display: none; }
2555
-
2556
- /* Apr 29 — UX overhaul: collapsed single-line stats row (replaces stacked
2557
- audit-summary + status-banner). Sits BELOW the intake, not above. Muted
2558
- so it doesn't compete with the H1 / textarea for eye attention. */
2559
- .stats-line {
2560
- margin: 14px 0 0;
2561
- font-family: var(--mono); font-size: 10.5px; color: var(--fg-40);
2562
- display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
2563
- }
2564
- .stats-line b { color: var(--fg-70); font-weight: 500; }
2565
- .stats-line .sep { color: var(--fg-20); }
2566
- .stats-line .blocked b { color: var(--block); }
2567
- .stats-line.empty { display: none; }
2568
-
2569
- /* Apr 29 — UX overhaul: readiness banner is now compact + below intake.
2570
- Was: full-width yellow box with multi-line prose above the H1.
2571
- Is: tighter padding, single-line preferred, sits as a subtle hint below
2572
- the assign action. The detail text wraps when long but contrast is muted. */
2573
- .readiness { margin-top: 14px; padding: 10px 12px; }
2574
- .readiness .r-title { font-size: 11px; }
2575
- .readiness .r-detail { font-size: 10.5px; }
2576
- .readiness .r-action { margin-top: 6px; }
2577
-
2578
- /* Apr 29 — UX overhaul: TRY chips fade out as soon as the user starts
2579
- typing. They're a discovery aid for first-visit users, not a permanent
2580
- fixture above an active textarea. */
2581
- .starter-prompts.dim { opacity: 0.35; pointer-events: none; transition: opacity 200ms ease; }
2582
-
2583
- #empty { padding: 48px 0; text-align: center; color: var(--fg-50); }
2584
- #empty .pill { display: inline-block; padding: 6px 14px; border: 1px solid var(--border); border-radius: 999px; background: rgba(255,255,255,0.03); font-family: var(--mono); font-size: 11px; color: var(--fg-70); }
2585
- </style>
2586
- </head>
2587
- <body>
2588
- <nav class="floating-nav">
2589
- <a href="/" class="logo">codehere</a>
2590
- <a href="/" class="nav-link active">Tasks</a>
2591
- <a href="/threads" class="nav-link">Threads</a>
2592
- <a href="/history" class="nav-link">History</a>
2593
- <a href="https://codehere.uk" class="nav-link nav-external" target="_blank" rel="noopener" title="Open codehere.uk">Docs ↗</a>
2594
- <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>
2595
- </nav>
2596
-
2597
- <div class="shell slack-shape">
2598
-
2599
- <aside class="channels-pane">
2600
- <div class="pane-head"><span>Projects</span><span class="pane-count" id="channels-count">0</span></div>
2601
- <div id="channels-list"><div class="pane-empty">Loading…</div></div>
2602
- </aside>
2603
-
2604
- <section class="thread-pane">
2605
-
2606
- <!-- v0.6 S2 — first-time onboarding card. Hidden once the user
2607
- dismisses (localStorage flag codehere.onboarded=1) OR once any
2608
- session exists in the dashboard. Per claim_user_persona HIGH:
2609
- non-technical users primary; the first 5 minutes is load-bearing
2610
- (feedback_first_5_minutes_is_load_bearing.md). This card guides
2611
- those 5 minutes: pick an agent → type a task → watch the report. -->
2612
- <div class="onboarding-card" id="onboarding-card" style="display:none">
2613
- <div class="ob-head">
2614
- <div class="ob-title">Welcome to codehere.</div>
2615
- <button type="button" class="ob-close" id="ob-close" aria-label="Dismiss">×</button>
2616
- </div>
2617
- <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>
2618
- <ol class="ob-steps">
2619
- <li>
2620
- <span class="ob-num">1</span>
2621
- <div>
2622
- <div class="ob-step-title">Pick an agent — or let codehere pick</div>
2623
- <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>
2624
- </div>
2625
- </li>
2626
- <li>
2627
- <span class="ob-num">2</span>
2628
- <div>
2629
- <div class="ob-step-title">Type what needs doing</div>
2630
- <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>
2631
- </div>
2632
- </li>
2633
- <li>
2634
- <span class="ob-num">3</span>
2635
- <div>
2636
- <div class="ob-step-title">Watch the Final Report</div>
2637
- <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>
2638
- </div>
2639
- </li>
2640
- </ol>
2641
- </div>
2642
-
2643
- <div class="intake">
2644
- <h1>Delegate coding tasks to any AI agent.</h1>
2645
- <p class="sub">Type what needs doing — codehere picks the right agent, watches every action, and reports back.</p>
2646
- <textarea id="prompt" placeholder="What needs doing? Describe a task — codehere picks the right agent."></textarea>
2647
- <!-- Pattern 5 · Composer meta line. UX-simplification iter (May 7):
2648
- dropped the cm-cwd pill — the cwd input below is the authoritative
2649
- editable surface; duplicating it as a non-editable pill caused
2650
- desync (pill stayed "—" while input showed the daemon-restored
2651
- cwd). Folded the keyboard-shortcut hint inline-left so the prior
2652
- second .intake-row could be deleted. -->
2653
- <div class="composer-meta" id="composer-meta">
2654
- <span class="cm-pill cm-agent" id="cm-agent"><b>Auto</b></span>
2655
- <span class="cm-sep">·</span>
2656
- <span class="cm-pill cm-budget" id="cm-budget" data-state="ok"><b>0%</b><span>used</span></span>
2657
- <span class="cm-sep">·</span>
2658
- <!-- v0.6 S3 slice 1 — today's estimated spend across every completed
2659
- session in ~/.codehere/sessions.jsonl. Hidden when zero so a
2660
- fresh-install dashboard isn't cluttered with "$0.00 today". Tooltip
2661
- points to vendor dashboards as the authoritative number. -->
2662
- <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>
2663
- <span class="cm-hint">⌘+Enter to start · ⌘+Shift+Enter to reply to last task</span>
2664
- <span class="cm-spacer"></span>
2665
- <span class="cm-pill cm-cost" id="cm-cost" style="display:none"></span>
2666
- </div>
2667
- <div class="starter-prompts" id="starter-prompts">
2668
- <span class="sp-label">TRY</span>
2669
- <button type="button" data-prompt="Read the README and summarize what this project is in 3 short bullets.">Summarize this repo</button>
2670
- <button type="button" data-prompt="List the top-level files and give me a one-line note on what each is for.">Map the architecture</button>
2671
- <button type="button" data-prompt="Run the tests and tell me what (if anything) is failing.">Run the tests</button>
2672
- </div>
2673
- <div class="intake-row">
2674
- <select id="agent" title="codehere picks the right tool unless you override"><option value="">Auto-pick</option></select>
2675
- <input type="text" id="cwd" class="cwd" placeholder="folder" spellcheck="false" />
2676
- <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>
2677
- <button class="spawn" id="spawn">Assign new</button>
2678
- </div>
2679
- <div class="setup-panel" id="setup-panel" data-state=""></div>
2680
- </div>
2681
-
2682
- <!-- Apr 29 — UX overhaul: readiness banner + stats demoted below intake.
2683
- Single muted line of stats replaces the previous two stacked rows.
2684
- Goal: textarea is the focal point above the fold; chrome subordinates. -->
2685
- <div class="readiness" id="readiness" style="position:relative;z-index:1"></div>
2686
-
2687
- <!-- v0.6.x P0 — failover offer banner. Surfaces when an adapter hits
2688
- rate-limit / quota so the user can manually switch agents instead
2689
- of waiting for auto-failover. Hidden by default; shown via JS when
2690
- offers arrive from /api/failover-offers. -->
2691
- <div class="failover-banner" id="failover-banner" style="display:none"></div>
2692
-
2693
- <!-- May 6 2026 design polish (Tier 1 #3) + dogfood Bug A3 fix:
2694
- The prior 4-stat row mixed two definitions of "agents" (the AGENTS
2695
- right-pane shows installed-and-ready agents; "agents on machine" stat
2696
- showed F1d-detected running processes — empty in practice) producing
2697
- a contradiction visible to users (e.g. "0 agents on machine" while the
2698
- right pane shows 6). Plus "blocked" was almost always 0 and "avg trust
2699
- 100" had no signal. Per Open-Agents production-grade reference and
2700
- claim_user_persona (HIGH non-technical-first): subtract dashboard noise.
2701
- Keep only the one stat that varies meaningfully — actions today —
2702
- rendered as a quiet muted line. The other IDs stay in the DOM (hidden)
2703
- so existing setText calls in updateBanner don't NPE. -->
2704
- <div class="stats-line empty" id="audit-summary" style="position:relative;z-index:1">
2705
- <span><b id="as-actions">0</b> actions today</span>
2706
- </div>
2707
-
2708
- <!-- UX-simplification iter 3 (May 7): dropped the .glow-line peach
2709
- divider here. The .section-label "Sessions" below already provides
2710
- visual separation; an extra 1px gradient line was decoration not
2711
- information. -->
2712
- <div class="section-label" style="position:relative;z-index:1">Sessions</div>
2713
- <div id="sessions" style="position:relative;z-index:1"></div>
2714
- <div class="sessions-actions" id="sessions-actions">
2715
- <button class="show-more-sessions" id="show-more-sessions" type="button">Show all sessions</button>
2716
- <button class="stop-all-sessions" id="stop-all-sessions" type="button" style="display:none">Stop all running</button>
2717
- </div>
2718
- <div id="empty">
2719
- <div class="pill">No sessions yet. Assign one above.</div>
2720
- </div>
2721
-
2722
- </section>
2723
-
2724
- <aside class="members-pane">
2725
- <div class="pane-head"><span>Agents</span><span class="pane-count" id="members-count">0</span></div>
2726
- <div id="members-list">
2727
- <div class="pane-empty">Loading agents…</div>
2728
- </div>
2729
- </aside>
2730
-
2731
- </div>
2732
-
2733
- <script>
2734
- (function () {
2735
- const promptEl = document.getElementById('prompt');
2736
- const spawnBtn = document.getElementById('spawn');
2737
- const agentSelect = document.getElementById('agent');
2738
- const cwdInput = document.getElementById('cwd');
2739
- const sessionsEl = document.getElementById('sessions');
2740
- const emptyEl = document.getElementById('empty');
2741
- const sessions = new Map(); // sessionId → { card, logEl, unsubscribeSse }
2742
-
2743
- // v0.6 S2 — first-time onboarding card. Show ONLY when:
2744
- // (a) localStorage flag codehere.onboarded is unset, AND
2745
- // (b) no sessions yet (empty-state).
2746
- // Hide when user dismisses (sets flag) OR when first session attaches.
2747
- var obCard = document.getElementById('onboarding-card');
2748
- var obClose = document.getElementById('ob-close');
2749
- var ONBOARDED_KEY = 'codehere.onboarded';
2750
- function isOnboarded() {
2751
- try { return window.localStorage.getItem(ONBOARDED_KEY) === '1'; }
2752
- catch (_) { return false; }
2753
- }
2754
- function markOnboarded() {
2755
- try { window.localStorage.setItem(ONBOARDED_KEY, '1'); }
2756
- catch (_) { /* private mode: persist for the session via the var below */ }
2757
- }
2758
- function maybeShowOnboarding() {
2759
- if (!obCard) return;
2760
- if (isOnboarded()) { obCard.style.display = 'none'; return; }
2761
- obCard.style.display = '';
2762
- }
2763
- function dismissOnboarding() {
2764
- if (!obCard) return;
2765
- obCard.style.display = 'none';
2766
- markOnboarded();
2767
- }
2768
- if (obClose) obClose.addEventListener('click', dismissOnboarding);
2769
- // Render decision runs immediately + after first sessions fetch
2770
- // (which sets sessions Map). Hide if any sessions already exist
2771
- // (returning user — onboarding doesn't help).
2772
- maybeShowOnboarding();
2773
-
2774
- // Pattern 5 — composer meta line wiring. Live-updates: agent label,
2775
- // context-budget %, cost preview. Bound below to the input surfaces
2776
- // (prompt textarea, agent select). UX-simplification iter (May 7):
2777
- // dropped cm-cwd pill — cwd input below is the authoritative surface.
2778
- const cmAgentEl = document.getElementById('cm-agent');
2779
- const cmBudgetEl = document.getElementById('cm-budget');
2780
- const cmCostEl = document.getElementById('cm-cost');
2781
- // v0.6 S3 slice 1 — today's spend chip. Lives next to the context-budget
2782
- // chip; hidden until the first non-zero spend lands so a fresh-install
2783
- // dashboard isn't cluttered. Polled, not pushed (events would need
2784
- // server-side fanout).
2785
- const cmSpendEl = document.getElementById('cm-spend');
2786
- function refreshSpendToday() {
2787
- if (!cmSpendEl) return;
2788
- fetch('/api/spend/today')
2789
- .then(function(r) { return r.ok ? r.json() : null; })
2790
- .then(function(d) {
2791
- if (!d || typeof d.totalUsd !== 'number') return;
2792
- // Show the chip when EITHER spend > 0 OR a budget is set (so the
2793
- // user sees "$0.00 / $5.00" right after \`codehere budget set 5\`).
2794
- var budget = d.budget || {};
2795
- var hasBudget = budget.dailyUsd !== null && typeof budget.dailyUsd === 'number';
2796
- if (d.totalUsd <= 0 && d.sessionCount === 0 && !hasBudget) {
2797
- cmSpendEl.style.display = 'none';
2798
- return;
2799
- }
2800
- cmSpendEl.style.display = 'inline-flex';
2801
- var b = cmSpendEl.querySelector('b');
2802
- if (b) {
2803
- var dollars = d.totalUsd;
2804
- var spendStr = dollars >= 0.01 ? '$' + dollars.toFixed(2) : '$' + dollars.toFixed(3);
2805
- // When a budget is set, render "$X.XX / $Y" so the ceiling is visible.
2806
- b.textContent = hasBudget
2807
- ? spendStr + ' / $' + budget.dailyUsd.toFixed(2)
2808
- : spendStr;
2809
- }
2810
- // Color state — warn at warnAtPct, block when over.
2811
- var state = 'ok';
2812
- if (hasBudget) {
2813
- if (budget.over) state = 'block';
2814
- else if (typeof budget.pctUsed === 'number' && budget.pctUsed >= (budget.warnAtPct || 70)) state = 'warn';
2815
- }
2816
- cmSpendEl.dataset.state = state;
2817
- // Tooltip: per-agent breakdown + budget context if set.
2818
- var lines = ['Estimated spend today across ' + d.sessionCount + ' completed session' + (d.sessionCount === 1 ? '' : 's') + ':'];
2819
- var keys = Object.keys(d.byAgent || {});
2820
- keys.sort(function(a, b) { return (d.byAgent[b].usd || 0) - (d.byAgent[a].usd || 0); });
2821
- for (var i = 0; i < keys.length; i++) {
2822
- var k = keys[i];
2823
- var v = d.byAgent[k];
2824
- lines.push(' ' + k + ': $' + (v.usd >= 0.01 ? v.usd.toFixed(2) : v.usd.toFixed(3)) + ' (' + v.sessions + 'x)');
2825
- }
2826
- if (hasBudget) {
2827
- lines.push('');
2828
- lines.push('Daily budget: $' + budget.dailyUsd.toFixed(2) + ' (' + (budget.pctUsed || 0) + '% used)');
2829
- if (budget.over) {
2830
- lines.push('OVER BUDGET — paid spawns will be refused. Use a free agent (gemini, opencode) or run: codehere budget set <higher-amount>');
2831
- }
2832
- }
2833
- lines.push('Vendor dashboards are authoritative — these are tier-ceiling estimates.');
2834
- cmSpendEl.title = lines.join('\\n');
2835
- })
2836
- .catch(function() { /* skip */ });
2837
- }
2838
- // Refresh on dashboard load + every 60s (sessions complete async, no point
2839
- // polling faster than per-minute).
2840
- refreshSpendToday();
2841
- setInterval(refreshSpendToday, 60_000);
2842
-
2843
- // Free-tier agents per feedback_adapters_must_have_free_tier.md. Cost
2844
- // preview hides for these (the user pays nothing per call). Conductor
2845
- // routes to one of the specialists, so its cost mirrors the picked one.
2846
- const FREE_TIER_AGENTS = new Set(['gemini', 'opencode']);
2847
-
2848
- // Per-agent rough cost estimate ($ per 1K input + 1K output tokens combined).
2849
- // These are CEILINGS for ballpark ("~$0.0X likely") not precise — adapter
2850
- // doesn't surface model selection, so the heuristic uses a balanced-tier
2851
- // assumption. Better to under-promise; users care about scale-of-magnitude.
2852
- const COST_PER_KTOK = {
2853
- 'claude-code': 0.018, // sonnet-tier balanced
2854
- 'codex': 0.020, // gpt-5-codex balanced
2855
- 'aider': 0.018, // typically sonnet
2856
- 'conductor': 0.018, // routes to claude-code by default
2857
- };
2858
-
2859
- function syncComposerMeta() {
2860
- if (!cmAgentEl || !cmBudgetEl) return;
2861
- // Resolve agent label. Empty value = Conductor-Auto routing.
2862
- var pickedId = (agentSelect && agentSelect.value) || '';
2863
- var label = pickedId
2864
- ? agentLabel(pickedId)
2865
- : 'Auto';
2866
- cmAgentEl.querySelector('b').textContent = label;
2867
- // Context budget — char-count / 800K (≈ 200K-token Sonnet budget).
2868
- // Cap at 100. --warn at 70, --block at 90 per spec.
2869
- var chars = (promptEl && promptEl.value && promptEl.value.length) || 0;
2870
- var pct = Math.min(100, Math.round((chars / 8000)));
2871
- var budgetState = pct >= 90 ? 'block' : pct >= 70 ? 'warn' : 'ok';
2872
- cmBudgetEl.dataset.state = budgetState;
2873
- cmBudgetEl.querySelector('b').textContent = pct + '%';
2874
- // Cost preview (paid agents only). Auto resolves to the conductor, so
2875
- // pick the cheapest balanced-tier estimate when no agent is set.
2876
- if (!cmCostEl) return;
2877
- var effective = pickedId || 'conductor';
2878
- if (FREE_TIER_AGENTS.has(effective)) {
2879
- cmCostEl.style.display = 'inline-flex';
2880
- cmCostEl.className = 'cm-pill cm-cost free';
2881
- cmCostEl.innerHTML = '<b>free tier</b>';
2882
- cmCostEl.title = effective + ' has a free tier — no per-call billing';
2883
- } else if (COST_PER_KTOK[effective] && chars > 20) {
2884
- // Rough heuristic: input tokens ≈ chars/4, output ~2× input for code
2885
- // tasks; clamp to a sensible floor. Show 2 decimals when > 0.01,
2886
- // 3 decimals otherwise so 4¢ doesn't render as "$0.04".
2887
- var inputTok = Math.ceil(chars / 4);
2888
- var outputTok = Math.max(500, Math.min(3000, inputTok * 2));
2889
- var totalK = (inputTok + outputTok) / 1000;
2890
- var dollars = totalK * COST_PER_KTOK[effective];
2891
- var dollarsStr = dollars >= 0.01
2892
- ? '~$' + (Math.round(dollars * 100) / 100).toFixed(2)
2893
- : '~$' + (Math.round(dollars * 1000) / 1000).toFixed(3);
2894
- cmCostEl.style.display = 'inline-flex';
2895
- cmCostEl.className = 'cm-pill cm-cost paid';
2896
- cmCostEl.innerHTML = '<b>' + dollarsStr + '</b><span> est.</span>';
2897
- cmCostEl.title = 'Estimate: ' + inputTok + ' input + ~' + outputTok + ' output tokens at balanced-tier rates';
2898
- } else {
2899
- cmCostEl.style.display = 'none';
2900
- }
2901
- }
2902
- if (promptEl) promptEl.addEventListener('input', syncComposerMeta);
2903
- if (agentSelect) agentSelect.addEventListener('change', syncComposerMeta);
2904
- // Initial paint.
2905
- syncComposerMeta();
2906
-
2907
- // E3 · Populate agent dropdown from per-agent readiness.
2908
- // Setup-aware: shows every registered agent with a state glyph (not just
2909
- // installed ones). Selecting a non-ready agent unfolds the setup panel with
2910
- // install/auth hints + swap suggestions.
2911
- let agentReadiness = []; // cached copy of the readiness response
2912
- const setupPanel = document.getElementById('setup-panel');
2913
-
2914
- const STATE_GLYPH = {
2915
- ready: '✓',
2916
- billed_key_required: '⚠',
2917
- needs_auth: '⚠',
2918
- needs_install: '⤓',
2919
- };
2920
-
2921
- // Apr 29: Conductor readiness state lives separately from the agents
2922
- // array (the dropdown shows specialists only). When auto.state is
2923
- // 'needs_install' (no specialists installed yet), we surface that on the
2924
- // Auto option AND in updateSetupPanel so a fresh-install user gets the
2925
- // setup prompt instead of a runtime spawn failure (codex Round 1 catch).
2926
- let autoReadiness = null;
2927
-
2928
- function renderAgentOptions(agents, auto) {
2929
- agentReadiness = agents || [];
2930
- autoReadiness = auto || null;
2931
- if (agentReadiness.length === 0 && !autoReadiness) return;
2932
- // Sort: ready first, then needs_auth/billed, then needs_install
2933
- const weight = { ready: 0, billed_key_required: 1, needs_auth: 2, needs_install: 3 };
2934
- const sorted = [...agentReadiness].sort((a, b) => (weight[a.state] ?? 99) - (weight[b.state] ?? 99));
2935
- const autoState = autoReadiness ? autoReadiness.state : 'ready';
2936
- const autoGlyph = autoState === 'ready' ? '⚡' : (STATE_GLYPH[autoState] || '⚠');
2937
- const autoLabel = autoState === 'ready' ? 'Auto-pick (codehere picks for you)' : 'Auto-pick (install an agent first)';
2938
- const autoOption = '<option value="" selected>' + autoGlyph + ' ' + autoLabel + '</option>';
2939
- const specialistOptions = sorted.map(a => {
2940
- // v0.6 S5 — adapter description surfaces as the option's hover title so
2941
- // non-technical users get a plain-English "when to use me" hint without
2942
- // having to read docs. Falls back to label-only when adapter has none.
2943
- const titleAttr = a.description
2944
- ? ' title="' + escape(a.description) + '"'
2945
- : '';
2946
- return '<option value="' + escape(a.id) + '"' + titleAttr + '>' +
2947
- (STATE_GLYPH[a.state] || '·') + ' ' + escape(a.label) +
2948
- '</option>';
2949
- }).join('');
2950
- // M5 slice 2 — fan-out option. Sends the same prompt to every
2951
- // installed specialist in parallel via /api/task/batch. Per
2952
- // claim_codehere_shape v5 + Decision 22 (every coding agent a
2953
- // first-class member), cross-check spawning is the v1.0 value
2954
- // prop. Hidden when fewer than 2 specialists are ready (no
2955
- // parallel value with one). Capped at 5 by the batch endpoint.
2956
- const readyCount = sorted.filter(a => a.state === 'ready').length;
2957
- const fanOutOption = readyCount >= 2
2958
- ? '<option value="*">⚡⚡ Send to every installed agent (' + readyCount + ' in parallel)</option>'
2959
- : '';
2960
- agentSelect.innerHTML = autoOption + fanOutOption + specialistOptions;
2961
- // Trigger the panel render for whatever is selected first
2962
- updateSetupPanel();
2963
- }
2964
-
2965
- function updateSetupPanel() {
2966
- const id = agentSelect.value;
2967
- // Apr 29: empty value = Auto option = use conductor's readiness (which
2968
- // says "install at least one specialist" when none are installed).
2969
- const agent = id === '' ? autoReadiness : agentReadiness.find(a => a.id === id);
2970
- if (!agent || agent.state === 'ready') {
2971
- setupPanel.style.display = 'none';
2972
- setupPanel.removeAttribute('data-state');
2973
- return;
2974
- }
2975
- setupPanel.dataset.state = agent.state;
2976
- setupPanel.style.display = 'block';
2977
-
2978
- let title = '', body = '';
2979
- let installBtnHtml = '';
2980
- if (agent.state === 'needs_install') {
2981
- // Principle #10: codehere does the install, it doesn't print shell commands.
2982
- // Show a button. Hide the raw command behind a "Details" toggle for users
2983
- // who want to know what's being run.
2984
- title = escape(agent.label) + ' isn\\'t installed yet';
2985
- body = '<div>Click install and codehere will set it up for you — nothing to type.</div>';
2986
- const hint = agent.installHint || {};
2987
- if (hint.command) {
2988
- installBtnHtml =
2989
- '<div class="install-row">' +
2990
- '<button type="button" class="install" data-agent="' + escape(agent.id) + '">Install ' + escape(agent.label) + '</button>' +
2991
- '<button type="button" class="install-details-toggle" data-toggle="details">Details</button>' +
2992
- '</div>' +
2993
- '<div class="install-output" data-role="details" style="display:none">' +
2994
- '<div style="color:var(--fg-50);margin-bottom:6px">We\\'ll run: <span style="color:var(--fg)">' + escape(hint.command) + '</span></div>' +
2995
- '</div>';
2996
- } else {
2997
- body += '<div style="margin-top:6px;color:var(--fg-50)">This agent doesn\\'t ship with an automatic installer yet. You can still swap to a ready agent below.</div>';
2998
- }
2999
- } else if (agent.state === 'needs_auth') {
3000
- title = escape(agent.label) + ' needs to be connected to an account';
3001
- const hint = agent.authHint || {};
3002
- body = (hint.detail ? '<div>' + escape(hint.detail) + '</div>' : '');
3003
- if (hint.url) {
3004
- body += '<div style="margin-top:6px"><a href="' + escape(hint.url) + '" target="_blank" rel="noreferrer">Open the sign-in page →</a></div>';
3005
- }
3006
- } else if (agent.state === 'billed_key_required') {
3007
- title = escape(agent.label) + ' needs a paid API key';
3008
- body = '<div>This agent doesn\\'t work with a Claude Code subscription — it needs a separate paid key from Anthropic or OpenAI. Using one of the ready agents below is usually the faster path.</div>';
3009
- }
3010
-
3011
- const readyAgents = agentReadiness.filter(a => a.state === 'ready');
3012
- const swapHtml = readyAgents.length > 0
3013
- ? '<div class="swap">' +
3014
- '<span class="swap-label">Use instead:</span>' +
3015
- readyAgents.map(a => '<button type="button" data-swap="' + escape(a.id) + '">' + escape(a.label) + '</button>').join('') +
3016
- '</div>'
3017
- : '';
3018
-
3019
- setupPanel.innerHTML =
3020
- '<div class="sp-title">' + title + '</div>' +
3021
- body + installBtnHtml + swapHtml;
3022
- }
3023
-
3024
- // F2 · Install button handler — streams output in place, flips readiness on done.
3025
- function runInstall(agentId, btnEl, outputEl) {
3026
- btnEl.classList.add('running');
3027
- btnEl.disabled = true;
3028
- btnEl.textContent = 'Installing…';
3029
- outputEl.classList.add('show');
3030
- outputEl.textContent = '';
3031
- let hadErr = false;
3032
-
3033
- const res = fetch('/api/agents/' + encodeURIComponent(agentId) + '/install', { method: 'POST' });
3034
- // The server responds with SSE. We read the body as a stream.
3035
- res.then(r => {
3036
- if (!r.ok) throw new Error('install request failed: ' + r.status);
3037
- const reader = r.body.getReader();
3038
- const decoder = new TextDecoder();
3039
- let buf = '';
3040
- const pump = () => reader.read().then(({ value, done }) => {
3041
- if (done) return;
3042
- buf += decoder.decode(value, { stream: true });
3043
- let idx;
3044
- while ((idx = buf.indexOf('\\n\\n')) >= 0) {
3045
- const chunk = buf.slice(0, idx).trim();
3046
- buf = buf.slice(idx + 2);
3047
- if (!chunk.startsWith('data:')) continue;
3048
- const json = chunk.slice(5).trim();
3049
- if (!json) continue;
3050
- try {
3051
- const evt = JSON.parse(json);
3052
- if (evt.type === 'out' || evt.type === 'err') {
3053
- if (evt.type === 'err') hadErr = true;
3054
- outputEl.textContent += evt.data || '';
3055
- outputEl.scrollTop = outputEl.scrollHeight;
3056
- } else if (evt.type === 'done') {
3057
- btnEl.classList.remove('running');
3058
- if (evt.code === 0) {
3059
- btnEl.classList.add('done');
3060
- btnEl.textContent = '✓ Installed';
3061
- // Re-fetch readiness so the dropdown glyph updates.
3062
- // Apr 29 codex Round 2: pass d.auto too — otherwise the Auto
3063
- // option's readiness gets stale after install-success refresh
3064
- // (the new shape needs both fields preserved on every render).
3065
- fetch('/api/readiness/agents').then(r => r.json()).then(d => {
3066
- renderAgentOptions(d.agents || [], d.auto);
3067
- // Select the now-installed agent so user can run the task immediately
3068
- if ([...agentSelect.options].some(o => o.value === agentId)) {
3069
- agentSelect.value = agentId;
3070
- updateSetupPanel();
3071
- }
3072
- });
3073
- } else {
3074
- btnEl.classList.add('failed');
3075
- btnEl.textContent = '✗ Install failed';
3076
- if (hadErr) outputEl.classList.add('has-err');
3077
- }
3078
- } else if (evt.type === 'error') {
3079
- btnEl.classList.remove('running');
3080
- btnEl.classList.add('failed');
3081
- btnEl.textContent = '✗ Couldn\\'t run';
3082
- outputEl.textContent += '\\n' + (evt.data || '');
3083
- outputEl.classList.add('has-err');
3084
- }
3085
- } catch { /* skip malformed */ }
3086
- }
3087
- return pump();
3088
- });
3089
- return pump();
3090
- }).catch(err => {
3091
- btnEl.classList.remove('running');
3092
- btnEl.classList.add('failed');
3093
- btnEl.textContent = '✗ Couldn\\'t start';
3094
- outputEl.textContent = String(err && err.message || err);
3095
- outputEl.classList.add('has-err');
3096
- });
3097
- }
3098
-
3099
- setupPanel.addEventListener('click', (ev) => {
3100
- const t = ev.target;
3101
- if (!t) return;
3102
- const swapBtn = t.closest('button[data-swap]');
3103
- if (swapBtn) {
3104
- const id = swapBtn.getAttribute('data-swap');
3105
- if (!id) return;
3106
- agentSelect.value = id;
3107
- updateSetupPanel();
3108
- return;
3109
- }
3110
- const installBtn = t.closest('button.install[data-agent]');
3111
- if (installBtn && !installBtn.disabled) {
3112
- const id = installBtn.getAttribute('data-agent');
3113
- const output = setupPanel.querySelector('.install-output[data-role="details"]');
3114
- if (id && output) runInstall(id, installBtn, output);
3115
- return;
3116
- }
3117
- const toggle = t.closest('button[data-toggle="details"]');
3118
- if (toggle) {
3119
- const output = setupPanel.querySelector('.install-output[data-role="details"]');
3120
- if (output) output.classList.toggle('show');
3121
- return;
3122
- }
3123
- });
3124
- agentSelect.addEventListener('change', updateSetupPanel);
3125
-
3126
- // Apr 29 — Slack-shape: render the members pane (right) from the same
3127
- // /api/readiness/agents response that populates the override dropdown.
3128
- // Single fetch, two surfaces. A later release replaces this with live presence
3129
- // (F1d-detected agents + spawned-by-codehere sessions joined by id).
3130
- function renderMembersPane(agents, auto) {
3131
- const list = document.getElementById('members-list');
3132
- const count = document.getElementById('members-count');
3133
- if (!list || !count) return;
3134
- const all = [];
3135
- if (auto) all.push({ ...auto, id: 'conductor', label: 'codehere · watches the others' });
3136
- for (const a of (agents || [])) all.push(a);
3137
- count.textContent = String(all.length);
3138
- if (all.length === 0) { list.innerHTML = '<div class="pane-empty">No agents detected</div>'; return; }
3139
- const presenceCls = (s) => s === 'ready' ? 'ready' : (s === 'needs_install' ? 'offline' : 'warn');
3140
- const presenceGlyph = (s) => s === 'ready' ? '●' : (s === 'needs_install' ? '○' : '◐');
3141
- list.innerHTML = all.map((a) => {
3142
- const cls = presenceCls(a.state);
3143
- const glyph = presenceGlyph(a.state);
3144
- // Iter 12 — members are now clickable. Non-technical user expects
3145
- // clicking an agent in the right pane to pick that agent for the
3146
- // next task. Click handler below sets the agent <select> + scrolls
3147
- // intake into view + focuses the prompt textarea.
3148
- const stateTitle = a.state === 'ready' ? 'ready · click to select' :
3149
- a.state === 'needs_install' ? 'needs install · click for setup' :
3150
- a.state === 'needs_auth' ? 'needs auth · click for setup' :
3151
- (a.state || 'unknown');
3152
- return '<div class="member" data-agent-id="' + escape(a.id) + '" title="' + escape(stateTitle) + '">' +
3153
- '<span class="member-presence ' + cls + '">' + glyph + '</span>' +
3154
- agentBadgeHTML(a.id) + ' ' +
3155
- '<span class="member-label">' + escape(a.label || a.id) + '</span>' +
3156
- '</div>';
3157
- }).join('');
3158
- // Wire click → select-this-agent. One delegated handler is cheaper than
3159
- // attaching one per item, and survives re-renders without leak.
3160
- list.onclick = (ev) => {
3161
- const item = ev.target && ev.target.closest && ev.target.closest('.member');
3162
- if (!item) return;
3163
- const id = item.getAttribute('data-agent-id');
3164
- if (!id) return;
3165
- // Set the agent dropdown to this agent. Trigger 'change' so the
3166
- // updateSetupPanel code (existing) fires and shows install/auth help
3167
- // for needs_install / needs_auth states.
3168
- if (agentSelect) {
3169
- agentSelect.value = id;
3170
- try { agentSelect.dispatchEvent(new Event('change')); } catch { /* old browsers */ }
3171
- }
3172
- // Scroll intake into view + focus the prompt textarea.
3173
- try { promptEl && promptEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch { /* noop */ }
3174
- if (promptEl) promptEl.focus();
3175
- };
3176
- }
3177
-
3178
- fetch('/api/readiness/agents').then(r => r.json()).then(d => {
3179
- renderAgentOptions(d.agents || [], d.auto);
3180
- renderMembersPane(d.agents || [], d.auto);
3181
- }).catch(() => { /* keep defaults */ });
3182
-
3183
- // Working directory: persist user's pick, auto-fill with daemon cwd on first run.
3184
- // Gate A1: strangers shouldn't have to type a path just to hit "Assign"; the
3185
- // daemon already knows where it was launched and that's usually the right cwd.
3186
- const savedCwd = localStorage.getItem('codehere:cwd');
3187
- if (savedCwd) cwdInput.value = savedCwd;
3188
-
3189
- // Track daemon cwd for active-channel highlight in the projects sidebar.
3190
- // Refreshed on every /api/readiness call; multi-project sidebar reads it.
3191
- let daemonCwdForActive = null;
3192
-
3193
- fetch('/api/readiness').then(r => r.json()).then(r => {
3194
- const daemonCwd = r && r.detected && r.detected.daemonCwd;
3195
- if (daemonCwd) {
3196
- cwdInput.placeholder = daemonCwd;
3197
- if (!cwdInput.value.trim()) cwdInput.value = daemonCwd; // auto-fill when nothing saved
3198
- daemonCwdForActive = daemonCwd;
3199
- loadDashboardProjects(); // re-render sidebar now that we know which project is "active"
3200
- }
3201
- }).catch(() => { /* placeholder unchanged */ });
3202
-
3203
- // Multi-project sidebar — bug A1 fix (May 5 2026). Pre-fix: sidebar showed
3204
- // a single hardcoded "current" channel that was un-clickable; users hit it
3205
- // expecting to switch projects. Post-fix: real /api/projects load, click
3206
- // navigates to /threads?project=<slug> (where the actual thread surface
3207
- // lives), pencil rename works inline, active state matches daemon cwd.
3208
- function shortPathDash(p) {
3209
- if (!p) return '';
3210
- const home = (window.location && window.location.hostname) ? '~' : '~';
3211
- if (p.length <= 32) return p;
3212
- const parts = p.split('/').filter(Boolean);
3213
- if (parts.length <= 2) return p;
3214
- return '…/' + parts.slice(-2).join('/');
3215
- }
3216
- function escapeHtmlDash(s) {
3217
- if (s == null) return '';
3218
- return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
3219
- }
3220
- function renderDashboardProjects(list) {
3221
- const wrap = document.getElementById('channels-list');
3222
- const countEl = document.getElementById('channels-count');
3223
- if (!wrap) return;
3224
- if (countEl) countEl.textContent = list.length;
3225
- if (list.length === 0) {
3226
- wrap.innerHTML = '<div class="pane-empty">No projects yet</div>';
3227
- return;
3228
- }
3229
- wrap.innerHTML = list.map(p => {
3230
- const isActive = (daemonCwdForActive && p.cwd === daemonCwdForActive) ? ' active' : '';
3231
- const name = escapeHtmlDash(p.title || '(untitled)');
3232
- const cwdHint = escapeHtmlDash(shortPathDash(p.cwd));
3233
- // Pattern 4 — multi-agent presence stack. Up to 3 dots (most-recent
3234
- // first, deduped). Empty span when no agents in the thread (older
3235
- // pre-mirror projects). agentPresenceDotHTML lives in AGENT_BADGE_JS;
3236
- // each dot is 6px, vendor-slot color, no letter.
3237
- const agents = Array.isArray(p.recentAgents) ? p.recentAgents : (p.lastAgent ? [p.lastAgent] : []);
3238
- const presenceStack = agents.length > 0
3239
- ? '<span class="presence-stack" title="Recent agents: ' + escapeHtmlDash(agents.join(', ')) + '">' +
3240
- agents.map(a => agentPresenceDotHTML(a)).join('') +
3241
- '</span>'
3242
- : '<span class="presence-stack presence-stack-empty"></span>';
3243
- return '<div class="channel' + isActive + '" data-slug="' + escapeHtmlDash(p.slug) + '" title="Open project thread">' +
3244
- '<span class="channel-glyph">#</span>' +
3245
- '<div style="flex:1;min-width:0">' +
3246
- '<div class="channel-name">' + name + '</div>' +
3247
- '<div class="channel-cwd-hint">' + cwdHint + '</div>' +
3248
- '</div>' +
3249
- presenceStack +
3250
- '<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>' +
3251
- '</div>';
3252
- }).join('');
3253
- wrap.querySelectorAll('.channel').forEach(el => {
3254
- el.addEventListener('click', (ev) => {
3255
- if (ev.target && ev.target.classList && ev.target.classList.contains('channel-rename')) return;
3256
- const slug = el.getAttribute('data-slug');
3257
- if (slug) window.location.href = '/threads?project=' + encodeURIComponent(slug);
3258
- });
3259
- });
3260
- wrap.querySelectorAll('.channel-rename').forEach(btn => {
3261
- btn.addEventListener('click', (ev) => {
3262
- ev.stopPropagation();
3263
- const slug = btn.getAttribute('data-slug');
3264
- if (!slug) return;
3265
- const channel = btn.closest('.channel');
3266
- const project = list.find(p => p.slug === slug);
3267
- const current = (project && project.title) || '';
3268
- startInlineRename(channel, slug, current, () => loadDashboardProjects());
3269
- });
3270
- });
3271
- }
3272
- // Inline edit-in-place — replaces prior window.prompt() dialog. Click pencil →
3273
- // .channel-name turns into an input pre-filled with the title; Enter saves,
3274
- // Esc cancels, blur saves. Validation errors flash the border red instead
3275
- // of a window.alert. Per Open-Agents production-grade UX reference.
3276
- function startInlineRename(channelEl, slug, currentTitle, onSaveSuccess) {
3277
- if (!channelEl) return;
3278
- const nameEl = channelEl.querySelector('.channel-name');
3279
- if (!nameEl || nameEl.dataset.editing === '1') return;
3280
- nameEl.dataset.editing = '1';
3281
- const originalText = nameEl.textContent;
3282
- const input = document.createElement('input');
3283
- input.type = 'text';
3284
- input.value = currentTitle;
3285
- input.maxLength = 100;
3286
- input.className = 'channel-rename-input';
3287
- nameEl.textContent = '';
3288
- nameEl.appendChild(input);
3289
- setTimeout(() => { input.focus(); input.select(); }, 0);
3290
- let cleanedUp = false;
3291
- function cleanup(restoreText) {
3292
- if (cleanedUp) return;
3293
- cleanedUp = true;
3294
- try { nameEl.removeChild(input); } catch (_) { /* already removed */ }
3295
- nameEl.textContent = restoreText;
3296
- delete nameEl.dataset.editing;
3297
- }
3298
- function commit() {
3299
- if (cleanedUp) return;
3300
- const trimmed = input.value.trim();
3301
- if (!trimmed || trimmed === currentTitle) { cleanup(originalText); return; }
3302
- fetch('/api/projects/' + encodeURIComponent(slug) + '/rename', {
3303
- method: 'POST',
3304
- headers: { 'Content-Type': 'application/json' },
3305
- body: JSON.stringify({ title: trimmed }),
3306
- })
3307
- .then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e.error || 'rename_failed')))
3308
- .then(() => { cleanup(trimmed); onSaveSuccess(); })
3309
- .catch(() => {
3310
- input.classList.remove('error');
3311
- // Force reflow so animation re-fires
3312
- void input.offsetWidth;
3313
- input.classList.add('error');
3314
- });
3315
- }
3316
- input.addEventListener('keydown', (ev) => {
3317
- if (ev.key === 'Enter') { ev.preventDefault(); commit(); }
3318
- else if (ev.key === 'Escape') { ev.preventDefault(); cleanup(originalText); }
3319
- });
3320
- input.addEventListener('blur', () => { if (!cleanedUp) commit(); });
3321
- }
3322
- function loadDashboardProjects() {
3323
- fetch('/api/projects')
3324
- .then(r => r.ok ? r.json() : Promise.reject(r.status))
3325
- .then(data => renderDashboardProjects(data.projects || []))
3326
- .catch(() => { /* leave loading state */ });
3327
- }
3328
- loadDashboardProjects();
3329
- cwdInput.addEventListener('change', () => {
3330
- localStorage.setItem('codehere:cwd', cwdInput.value.trim());
3331
- });
3332
-
3333
- // Iter 13 — sidebar 'add project' button + handler removed (was iter 12 work).
3334
- // The handler cleared cwd input + scrolled + focused, but the label promised
3335
- // a new sidebar entry that we do not ship until v0.6.x multi-project work.
3336
- // Mismatch caused real founder confusion in iter-13 tasting. Removed cleanly.
3337
-
3338
- // Gate A1: starter-prompt chips prefill the textarea + focus it so the user
3339
- // can edit or hit ⌘-Enter immediately. No scroll-jumping, no redirect.
3340
- const starterPromptsEl = document.getElementById('starter-prompts');
3341
- starterPromptsEl.addEventListener('click', (ev) => {
3342
- const btn = ev.target && ev.target.closest('button[data-prompt]');
3343
- if (!btn) return;
3344
- promptEl.value = btn.getAttribute('data-prompt') || '';
3345
- promptEl.focus();
3346
- starterPromptsEl.classList.add('dim'); // user picked one; chips fade
3347
- });
3348
-
3349
- // Apr 29 — UX overhaul: TRY chips fade out once the user starts typing.
3350
- // They serve as a discovery aid for first-visit users; a typing user has
3351
- // already committed to a task and doesn't need the chips above the page.
3352
- promptEl.addEventListener('input', () => {
3353
- if (promptEl.value.length > 0) starterPromptsEl.classList.add('dim');
3354
- else starterPromptsEl.classList.remove('dim');
3355
- });
3356
-
3357
- function escape(s) {
3358
- return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
3359
- }
3360
-
3361
- function now() {
3362
- return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
3363
- }
3364
-
3365
- // Render a stream-json event from claude -p --output-format=stream-json.
3366
- // We've seen the real shapes now (v0.3.0-alpha.1 dogfood). Key rules:
3367
- // - stream_event = token-level deltas. Skip entirely — they aggregate into
3368
- // 'assistant' / 'user' messages which we render cleanly.
3369
- // - system subtypes: init/status are noise (skip); hook_started is noise
3370
- // (skip — we render the paired hook_response for outcome). hook_response
3371
- // shows name + allow/block so the user sees the safety layer.
3372
- // - assistant message.content: mix of text + tool_use blocks. Render each.
3373
- // - user message.content: tool_result blocks. Render condensed.
3374
- // - result: final summary with duration + outcome.
3375
- // - exit/error/stderr: meta from our own wrapper.
3376
- function renderEvent(logEl, event) {
3377
- const type = String(event.type || '');
3378
- const data = event.data ?? {};
3379
-
3380
- // ── Silent types (reduce noise) ──
3381
- if (type === 'stream_event') return;
3382
- if (type === 'system' && (data.subtype === 'init' || data.subtype === 'status' || data.subtype === 'hook_started')) return;
3383
- if (type === 'stdout' && !String(data.line || '').trim()) return;
3384
- // Codex internal lifecycle — not useful to show per-event
3385
- if (type === 'thread.started' || type === 'turn.started') return;
3386
-
3387
- // Rendered lines
3388
- const lines = []; // { cls, label, body }
3389
-
3390
- if (type === 'assistant' && data.message && Array.isArray(data.message.content)) {
3391
- for (const block of data.message.content) {
3392
- if (block.type === 'text') {
3393
- const t = String(block.text || '').trim();
3394
- if (t) lines.push({ cls: 'claude', label: 'claude', body: t });
3395
- } else if (block.type === 'tool_use') {
3396
- const name = String(block.name || 'tool');
3397
- const input = block.input && typeof block.input === 'object' ? JSON.stringify(block.input) : '';
3398
- const preview = input.length > 180 ? input.slice(0, 180) + '…' : input;
3399
- lines.push({ cls: 'tool', label: name, body: preview });
3400
- } else if (block.type === 'thinking') {
3401
- // Skip thinking — too verbose for the log view
3402
- }
3403
- }
3404
- } else if (type === 'user' && data.message && Array.isArray(data.message.content)) {
3405
- for (const block of data.message.content) {
3406
- if (block.type === 'tool_result') {
3407
- const raw = Array.isArray(block.content)
3408
- ? block.content.map(c => c.text || '').join('')
3409
- : String(block.content || '');
3410
- const t = raw.replace(/\\s+/g, ' ').trim();
3411
- if (t) {
3412
- const preview = t.length > 220 ? t.slice(0, 220) + '…' : t;
3413
- lines.push({ cls: 'result', label: block.is_error ? 'error' : 'result', body: preview });
3414
- }
3415
- }
3416
- }
3417
- } else if (type === 'system' && data.subtype === 'hook_response') {
3418
- const name = String(data.hook_name || 'hook');
3419
- const exitCode = Number(data.exit_code ?? 0);
3420
- const blocked = exitCode !== 0;
3421
- const stderr = String(data.stderr || '').trim();
3422
- const body = blocked ? (stderr || 'blocked') : 'ok';
3423
- lines.push({ cls: 'hook' + (blocked ? ' blocked' : ''), label: name, body });
3424
- } else if (type === 'result') {
3425
- // Claude Code --output-format=stream-json final result
3426
- const dur = Math.round(Number(data.duration_ms || 0) / 1000);
3427
- const turns = Number(data.num_turns || 0);
3428
- const summary = String(data.result || '').slice(0, 220);
3429
- lines.push({ cls: 'done', label: data.subtype || 'done', body: turns + ' turns · ' + dur + 's · ' + summary });
3430
- } else if (type === 'item.started') {
3431
- // Codex fires item.started immediately; item.completed follows with results.
3432
- // Silent to avoid doubling every tool call in the feed.
3433
- return;
3434
- } else if (type === 'item.completed') {
3435
- // Codex event: { item: { type, ... } }. Observed item.types from live dogfood:
3436
- // agent_message → text
3437
- // command_execution → command (string), aggregated_output, exit_code, status
3438
- // (+future: tool_call / local_shell_call / etc — defensive branches kept)
3439
- const item = data.item && typeof data.item === 'object' ? data.item : {};
3440
- const itemType = String(item.type || 'item');
3441
-
3442
- if (itemType === 'agent_message' && item.text) {
3443
- const t = String(item.text).trim();
3444
- if (t) lines.push({ cls: 'claude', label: 'codex', body: t.slice(0, 280) });
3445
- } else if (itemType === 'command_execution') {
3446
- const cmd = String(item.command || '').replace(/\\n/g, ' ').trim();
3447
- const cmdShort = cmd.length > 140 ? cmd.slice(0, 140) + '…' : cmd;
3448
- lines.push({ cls: 'tool', label: 'shell', body: cmdShort });
3449
- const output = String(item.aggregated_output || '').trim();
3450
- if (output) {
3451
- const outShort = output.length > 200 ? output.slice(0, 200) + '…' : output;
3452
- lines.push({
3453
- cls: item.exit_code === 0 || item.status === 'completed' ? 'result' : 'error',
3454
- label: item.exit_code === 0 || item.status === 'completed' ? 'result' : 'exit ' + (item.exit_code ?? '?'),
3455
- body: outShort,
3456
- });
3457
- }
3458
- } else if (itemType === 'tool_call' || itemType === 'local_shell_call') {
3459
- // Forward-compat: other codex tool types we haven't seen yet
3460
- const cmd = String(item.command || item.tool_name || item.name || 'tool');
3461
- const args = item.arguments ? JSON.stringify(item.arguments).slice(0, 200) : '';
3462
- lines.push({ cls: 'tool', label: cmd, body: args });
3463
- } else if (itemType === 'reasoning' || itemType === 'thinking') {
3464
- // skip — too verbose
3465
- } else {
3466
- // Unknown item type — render minimally for diagnosis
3467
- lines.push({ cls: 'result', label: itemType, body: JSON.stringify(item).slice(0, 200) });
3468
- }
3469
- } else if (type === 'turn.completed') {
3470
- // Codex turn-completed event — light summary
3471
- const u = (data.usage && typeof data.usage === 'object') ? data.usage : {};
3472
- const input = Number(u.input_tokens || 0);
3473
- const output = Number(u.output_tokens || 0);
3474
- if (input || output) {
3475
- lines.push({ cls: 'done', label: 'turn', body: input + ' in · ' + output + ' out tokens' });
3476
- }
3477
- } else if (type === 'exit') {
3478
- const status = String(data.status || '?');
3479
- const dur = Math.round(Number(data.durationMs || 0) / 1000);
3480
- lines.push({ cls: 'exit', label: 'exit', body: 'status=' + status + ' duration=' + dur + 's' });
3481
- } else if (type === 'codehere:error') {
3482
- // F1b structured error with recovery hint
3483
- const code = String(data.code || 'error');
3484
- const msg = String(data.message || '');
3485
- const recovery = String(data.recovery || '');
3486
- const div = document.createElement('div');
3487
- div.className = 'event codehere-error';
3488
- div.innerHTML =
3489
- '<span class="label">' + escape(code) + '</span>' +
3490
- escape(msg) +
3491
- (recovery ? '<span class="recovery">→ ' + escape(recovery) + '</span>' : '');
3492
- logEl.appendChild(div);
3493
- logEl.scrollTop = logEl.scrollHeight;
3494
- return;
3495
- } else if (type === 'error') {
3496
- lines.push({ cls: 'error', label: 'error', body: String(data.message || JSON.stringify(data)) });
3497
- } else if (type === 'stderr') {
3498
- const t = String(data.line || '').trim();
3499
- if (t) lines.push({ cls: 'stderr', label: 'stderr', body: t });
3500
- } else if (type === 'stdout') {
3501
- lines.push({ cls: 'stdout', label: 'out', body: String(data.line || '') });
3502
- }
3503
- // Any other event type → silent (we don't dump raw JSON anymore)
3504
-
3505
- if (lines.length === 0) return;
3506
-
3507
- const ts = now();
3508
- for (const ln of lines) {
3509
- const div = document.createElement('div');
3510
- div.className = 'event ' + ln.cls;
3511
- div.innerHTML =
3512
- '<span class="t">' + escape(ts) + '</span>' +
3513
- '<span class="label">' + escape(ln.label) + '</span>' +
3514
- escape(ln.body);
3515
- logEl.appendChild(div);
3516
- }
3517
- logEl.scrollTop = logEl.scrollHeight;
3518
- }
3519
-
3520
- // Status display labels: keep the underlying status string ('killed', 'error')
3521
- // as the data-layer truth, but map to a calmer human label. May 6 2026 polish
3522
- // per Open-Agents reference + claim_user_persona (non-technical readers
3523
- // don't think of their work as "KILLED" — they think "I stopped it").
3524
- // Pattern 6 · macro state → user-visible copy. Plain English; non-technical-
3525
- // first per claim_user_persona HIGH. Each state's CSS class is the same
3526
- // string with hyphens (e.g. status === 'rate_limit' → class="status-pill
3527
- // rate-limit"); CSS classes can't contain underscores cleanly, so the
3528
- // wire-format state has underscores and the class form has hyphens.
3529
- // Per docs/design/AGENTIC_UX.md Pattern 6.
3530
- var STATE_LABELS = {
3531
- 'thinking': 'Thinking',
3532
- 'running': 'Running',
3533
- 'running_buffered': 'Running (buffered)',
3534
- 'completed': 'Done',
3535
- 'awaiting': 'Waiting on you',
3536
- 'blocked': 'Blocked',
3537
- 'killed': 'Stopped',
3538
- 'crashed': 'Crashed',
3539
- 'error': 'Failed',
3540
- 'failed': 'Failed',
3541
- 'rate_limit': 'Rate-limited',
3542
- 'quota_exceeded': 'Quota exceeded',
3543
- 'credit_low': 'Credits low',
3544
- 'auth_invalid': 'Sign-in needed',
3545
- 'auth_missing': 'Sign-in needed',
3546
- 'model_unavailable':'Model unavailable'
3547
- };
3548
- function statusLabel(status) {
3549
- if (!status) return 'Unknown';
3550
- if (STATE_LABELS[status]) return STATE_LABELS[status];
3551
- return status.charAt(0).toUpperCase() + status.slice(1);
3552
- }
3553
- // Convert wire-format state ('rate_limit') to CSS class form ('rate-limit').
3554
- function statusClass(status) {
3555
- return String(status || '').replace(/_/g, '-');
3556
- }
3557
- function updateStatus(pillEl, status) {
3558
- pillEl.className = 'status-pill ' + statusClass(status);
3559
- pillEl.textContent = statusLabel(status);
3560
- }
3561
-
3562
- // Per-agent badge helpers (agentLabel, AGENT_SLOTS, AGENT_ICONS,
3563
- // agentBadgeHTML, agentPresenceDotHTML) interpolated from AGENT_BADGE_JS
3564
- // — single source of truth shared with THREAD_HTML and any other page
3565
- // that needs vendor-attribution rendering. Per Pattern 10.
3566
- ${AGENT_BADGE_JS}
3567
-
3568
- function attachSession(session) {
3569
- const card = document.createElement('div');
3570
- card.className = 'session';
3571
- card.dataset.sessionId = session.sessionId;
3572
- card.dataset.status = session.status;
3573
- // C3 · Persisted sessions are from a prior daemon run — no live stream, no
3574
- // kill. Still useful: user sees their previous Final reports across restarts.
3575
- const isPersisted = session.provenance === 'persisted';
3576
- const runningClass = session.status === 'running' ? ' badge-running-glow' : '';
3577
- const liveDot = session.status === 'running' ? '<span class="live-dot"></span>' : '';
3578
- card.innerHTML =
3579
- '<div class="session-head">' +
3580
- '<div class="meta">' +
3581
- '<div class="prompt" title="' + escape(session.prompt) + '">' +
3582
- escape(session.prompt) +
3583
- '<span class="chevron">▸</span>' +
3584
- '</div>' +
3585
- '<div class="sub">' + liveDot + agentBadgeHTML(session.agent) + ' ' + escape(agentLabel(session.agent)) +
3586
- (session.cwd ? ' · <span class="sub-cwd" title="' + escape(session.cwd) + '">' + escape(cwdTail(session.cwd)) + '</span>' : '') +
3587
- ' · ' + new Date(session.startedAt).toLocaleTimeString() +
3588
- '<span class="safety-pill" style="display:none">' +
3589
- '<span class="shield">🛡</span> ' +
3590
- '<span class="safe">0</span> safe' +
3591
- '<span class="blocked-portion" style="display:none"> · <span class="blocked">0</span> blocked</span>' +
3592
- '</span>' +
3593
- '</div>' +
3594
- '<div class="tool-progress-chip" data-state="hidden"></div>' +
3595
- '<div class="micro-activity"></div>' +
3596
- '<div class="steps-mini"></div>' +
3597
- '</div>' +
3598
- '<div class="session-controls">' +
3599
- '<div class="view-toggle" title="Switch between thread view and raw event log">' +
3600
- '<button class="view-thread active" data-view="thread">Thread</button>' +
3601
- '<button class="view-raw" data-view="raw">Log</button>' +
3602
- '</div>' +
3603
- '<span class="status-pill ' + session.status + runningClass + '">' + statusLabel(session.status) + '</span>' +
3604
- '<button class="kill">Kill</button>' +
3605
- '</div>' +
3606
- '</div>' +
3607
- '<div class="session-body" data-view="thread">' +
3608
- // Session threading — "Continued from" indicator for failover sessions
3609
- (session.continuedFrom ? '<div class="session-continued-from">Continued from <a href="#' + escape(session.continuedFrom) + '">' + escape(agentLabel(session.agent)) + '</a> session</div>' : '') +
3610
- // Order: thread messages (chronological) → final report (closing
3611
- // summary, rendered as a block when artifacts arrive) → reply input.
3612
- // Apr 29 bug fix: previously final-report was BEFORE thread-messages
3613
- // so on completed sessions the Final Report appeared above the user's
3614
- // seed prompt, breaking chronological order in thread view.
3615
- '<div class="thread-messages"></div>' +
3616
- // P18 slice 4 — permission-gate stack. Empty container; panels
3617
- // get inserted by the per-session blocks poll when the per-tool
3618
- // hook fires for this session. Each block becomes one panel with
3619
- // an "Approve in this folder" button.
3620
- '<div class="permission-gates"></div>' +
3621
- // v0.6 M3 — PM narrator block. Hidden until first narration
3622
- // arrives; renders one paragraph plain-English of what the
3623
- // agent is doing right now. Per claim_user_persona HIGH:
3624
- // non-technical users read this, not the tool calls.
3625
- // Per feedback_overconfidence_guard.md: framed as "narrator"
3626
- // not authoritative truth — narration is an LLM call, may
3627
- // hallucinate.
3628
- '<div class="narrator-block" style="display:none">' +
3629
- '<div class="narrator-label">narrator</div>' +
3630
- '<div class="narrator-text"></div>' +
3631
- '<div class="narrator-meta"></div>' +
3632
- '</div>' +
3633
- '<div class="final-report" style="display:none"></div>' +
3634
- // Iter 12 — reply surface now functional; replaced the prior dead
3635
- // placeholder. Active for adapters with canAppendMessage=true
3636
- // (claude-code today); disabled state for adapters that don't yet
3637
- // implement appendMessage.
3638
- '<div class="thread-input">' +
3639
- '<textarea class="reply-text" placeholder="Reply to thread… (Cmd+Enter to send)" rows="2"></textarea>' +
3640
- '<button class="reply-send" type="button">Send</button>' +
3641
- '<div class="reply-status" style="display:none"></div>' +
3642
- '</div>' +
3643
- '<div class="event-log"></div>' +
3644
- '</div>' +
3645
- // Pattern 3 — session footer. Hidden until populated (data-empty=true);
3646
- // renderToolProgress flips it to data-empty=false once steps appear.
3647
- '<div class="session-foot" data-empty="true">' +
3648
- '<span class="sf-item sf-elapsed"><b>0s</b><span>elapsed</span></span>' +
3649
- '<span class="sf-sep">·</span>' +
3650
- '<span class="sf-item sf-tools"><b>0</b><span>tools</span></span>' +
3651
- '<span class="sf-sep">·</span>' +
3652
- '<span class="sf-item sf-files"><b>0</b><span>files</span></span>' +
3653
- '</div>';
3654
-
3655
- sessionsEl.prepend(card);
3656
- emptyEl.style.display = 'none';
3657
- // v0.6 S2 — first session attached means user has done step 1+2;
3658
- // dismiss the onboarding card. They can re-open by clearing local
3659
- // storage, but for the typical flow this is "I'm using it now".
3660
- dismissOnboarding();
3661
-
3662
- // Session threading — "continued as" indicator for sessions that already
3663
- // have a failover successor (e.g., after page refresh).
3664
- if (session.continuedAs) {
3665
- var continuedAsEl = document.createElement('div');
3666
- continuedAsEl.className = 'session-continued-as';
3667
- continuedAsEl.innerHTML = '↻ Continued as <a href="#' + escape(session.continuedAs) + '">new session</a> · session ' + escape(session.continuedAs.slice(0, 8));
3668
- var headEl2 = card.querySelector('.session-head');
3669
- if (headEl2 && headEl2.parentNode) headEl2.parentNode.insertBefore(continuedAsEl, headEl2.nextSibling);
3670
- }
3671
-
3672
- const logEl = card.querySelector('.event-log');
3673
- const pillEl = card.querySelector('.status-pill');
3674
- const killBtn = card.querySelector('.kill');
3675
- const headEl = card.querySelector('.session-head');
3676
- const stepsEl = card.querySelector('.steps-mini');
3677
- const microActivityEl = card.querySelector('.micro-activity');
3678
- const toolProgressEl = card.querySelector('.tool-progress-chip');
3679
- // Pattern 1 — tool-progress live chip. Renders "12 tools | 3/4 ●●●○ | Ns".
3680
- // Sources from the same codehere:steps synthetic event renderSteps reads.
3681
- function fmtElapsed(ms) {
3682
- var s = Math.max(0, Math.floor(ms / 1000));
3683
- if (s < 60) return s + 's';
3684
- var m = Math.floor(s / 60);
3685
- var rem = s % 60;
3686
- if (m < 60) return rem ? m + 'm ' + rem + 's' : m + 'm';
3687
- var h = Math.floor(m / 60);
3688
- return h + 'h ' + (m % 60) + 'm';
3689
- }
3690
- function renderToolProgress(steps) {
3691
- if (!toolProgressEl) return;
3692
- if (!Array.isArray(steps) || steps.length === 0) {
3693
- toolProgressEl.dataset.state = 'hidden';
3694
- toolProgressEl.innerHTML = '';
3695
- return;
3696
- }
3697
- // Compute counts + state.
3698
- var done = 0, active = 0, failed = 0, pending = 0;
3699
- for (var i = 0; i < steps.length; i++) {
3700
- var st = String(steps[i].state || '');
3701
- if (st === 'done') done++;
3702
- else if (st === 'active') active++;
3703
- else if (st === 'failed') failed++;
3704
- else pending++;
3705
- }
3706
- var total = steps.length;
3707
- var chipState;
3708
- if (failed > 0) chipState = 'failed';
3709
- else if (session.status !== 'running') chipState = (done === total ? 'complete' : 'paused');
3710
- else if (active > 0) chipState = 'running';
3711
- else chipState = 'paused';
3712
-
3713
- // Dot pattern — last 6 steps, oldest first. ● = done, ◐ = active,
3714
- // ○ = pending, ✗ = failed. Cap at 6 so the chip doesn't grow.
3715
- var tail = steps.slice(-6);
3716
- var dots = tail.map(function(s) {
3717
- var st = String(s.state || '');
3718
- if (st === 'done') return '●';
3719
- if (st === 'active') return '◐';
3720
- if (st === 'failed') return '✗';
3721
- return '○';
3722
- }).join('');
3723
-
3724
- var elapsed = fmtElapsed(Date.now() - session.startedAt);
3725
- toolProgressEl.dataset.state = chipState;
3726
- toolProgressEl.innerHTML =
3727
- '<span>' + total + ' tool' + (total === 1 ? '' : 's') + '</span>' +
3728
- '<span class="tp-sep">|</span>' +
3729
- '<span>' + done + '/' + total + ' <span class="tp-dots">' + dots + '</span></span>' +
3730
- '<span class="tp-sep">|</span>' +
3731
- '<span class="tp-elapsed">' + elapsed + '</span>';
3732
-
3733
- // Pattern 3 — keep the footer in sync with the same step data the
3734
- // chip reads. Cheaper than a separate event subscription and stays
3735
- // structurally consistent with the live counts above.
3736
- renderSessionFoot(steps);
3737
- }
3738
- // Pattern 3 — session-foot: elapsed · tools · files. Reveals on first
3739
- // step; updates counts on every renderSteps call. Read-only — no
3740
- // click affordance (rec'y / file-list belong elsewhere).
3741
- var sessionFootEl = card.querySelector('.session-foot');
3742
- function renderSessionFoot(steps) {
3743
- if (!sessionFootEl) return;
3744
- if (!Array.isArray(steps) || steps.length === 0) {
3745
- sessionFootEl.dataset.empty = 'true';
3746
- return;
3747
- }
3748
- sessionFootEl.dataset.empty = 'false';
3749
- var elapsed = session.completedAt
3750
- ? fmtElapsed(session.completedAt - session.startedAt)
3751
- : fmtElapsed(Date.now() - session.startedAt);
3752
- var elapsedB = sessionFootEl.querySelector('.sf-elapsed b');
3753
- var toolsB = sessionFootEl.querySelector('.sf-tools b');
3754
- var filesB = sessionFootEl.querySelector('.sf-files b');
3755
- if (elapsedB) elapsedB.textContent = elapsed;
3756
- if (toolsB) toolsB.textContent = String(steps.length);
3757
- if (filesB) filesB.textContent = String(countTouchedFiles(steps));
3758
- }
3759
- // Tick elapsed display every second while running so the chip feels live.
3760
- var elapsedTicker = null;
3761
- function startElapsedTick() {
3762
- if (elapsedTicker) return;
3763
- elapsedTicker = setInterval(function() {
3764
- if (!toolProgressEl || toolProgressEl.dataset.state === 'hidden') return;
3765
- var elEl = toolProgressEl.querySelector('.tp-elapsed');
3766
- if (elEl) elEl.textContent = fmtElapsed(Date.now() - session.startedAt);
3767
- }, 1000);
3768
- }
3769
- function stopElapsedTick() {
3770
- if (elapsedTicker) { clearInterval(elapsedTicker); elapsedTicker = null; }
3771
- }
3772
- if (session.status === 'running') startElapsedTick();
3773
-
3774
- // v0.6 M3 — PM narrator polling. Backend generates a one-paragraph
3775
- // mid-task summary every 30s while the session runs; frontend polls
3776
- // /api/task/:id/narration and renders it in the narrator-block.
3777
- // Per claim_user_persona: non-technical users read narration, not
3778
- // tool calls. Per overconfidence-guard: framed as "narrator", not
3779
- // authoritative truth.
3780
- var narrTicker = null;
3781
- var lastNarrAt = 0;
3782
- function pollNarration() {
3783
- fetch('/api/task/' + session.sessionId + '/narration')
3784
- .then(function(r) { return r.ok ? r.json() : null; })
3785
- .then(function(d) {
3786
- if (!d || !d.narration) return;
3787
- var n = d.narration;
3788
- if (n.generatedAt <= lastNarrAt) return; // unchanged
3789
- lastNarrAt = n.generatedAt;
3790
- var block = card.querySelector('.narrator-block');
3791
- if (!block) return;
3792
- var textEl = block.querySelector('.narrator-text');
3793
- var metaEl = block.querySelector('.narrator-meta');
3794
- if (textEl) textEl.textContent = n.summary;
3795
- if (metaEl) {
3796
- var ago = Math.max(0, Math.floor((Date.now() - n.generatedAt) / 1000));
3797
- metaEl.textContent = 'after ' + n.stepCountAtSnapshot + ' steps · ' + ago + 's ago · may be inaccurate';
3798
- }
3799
- block.style.display = 'block';
3800
- })
3801
- .catch(function() { /* skip */ });
3802
- }
3803
- function startNarrationTick() {
3804
- if (narrTicker) return;
3805
- pollNarration(); // immediate
3806
- narrTicker = setInterval(pollNarration, 30000);
3807
- }
3808
- function stopNarrationTick() {
3809
- if (narrTicker) { clearInterval(narrTicker); narrTicker = null; }
3810
- }
3811
- if (session.status === 'running') startNarrationTick();
3812
-
3813
- // P18 slice 4 — poll /api/task/:id/blocks for hook-blocks against
3814
- // this session. Render one .permission-gate panel per unique block.
3815
- // De-dup by hash of category + matched + timestamp.
3816
- var blockTicker = null;
3817
- var seenBlockKeys = Object.create(null);
3818
- function escapeHtmlInline(s) {
3819
- return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
3820
- return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
3821
- });
3822
- }
3823
- function renderBlockGate(block) {
3824
- // The block entry shape comes from the audit log; toolInput may be
3825
- // a JSON string with {agent, command, allowedByPermissions, ...}
3826
- var key = (block.timestamp || '') + '|' + (block.toolName || '') + '|' + (block.findings || 0);
3827
- if (seenBlockKeys[key]) return;
3828
- seenBlockKeys[key] = true;
3829
- var gateContainer = card.querySelector('.permission-gates');
3830
- if (!gateContainer) return;
3831
- // Pull a useful matched-text + cwd out of the block. The audit
3832
- // entry surfaces them in toolInput JSON shape.
3833
- var pattern = '';
3834
- var cwd = '';
3835
- try {
3836
- var parsedInput = typeof block.toolInput === 'string' ? JSON.parse(block.toolInput) : block.toolInput;
3837
- if (parsedInput && typeof parsedInput === 'object') {
3838
- // command (Bash) or file_path (Edit/Write) — best-effort
3839
- pattern = String(parsedInput.command || parsedInput.file_path || '');
3840
- cwd = String(parsedInput.cwd || '');
3841
- }
3842
- } catch (_) { /* not JSON */ }
3843
- var truncatedPattern = pattern.length > 200 ? pattern.slice(0, 200) + '…' : pattern;
3844
- var gate = document.createElement('div');
3845
- gate.className = 'permission-gate';
3846
- gate.innerHTML =
3847
- '<div class="pg-label">codehere noticed a pattern</div>' +
3848
- '<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>' +
3849
- '<div class="pg-detail">' + escapeHtmlInline(truncatedPattern || '(no command captured)') + '</div>' +
3850
- '<div class="pg-actions">' +
3851
- '<button type="button" class="pg-approve">Approve in this folder</button>' +
3852
- '<button type="button" class="pg-keep">Keep blocking</button>' +
3853
- '</div>' +
3854
- '<div class="pg-status">Approved · added to .codehere/permissions.yaml</div>';
3855
- gateContainer.appendChild(gate);
3856
- gate.querySelector('.pg-keep').addEventListener('click', function() {
3857
- gate.remove();
3858
- });
3859
- gate.querySelector('.pg-approve').addEventListener('click', function() {
3860
- var btn = gate.querySelector('.pg-approve');
3861
- btn.disabled = true;
3862
- btn.textContent = '…';
3863
- // Approve the EXACT pattern that triggered the block; reason
3864
- // captures the block's matched-finding context for the audit.
3865
- // Fall back to a substring of the command if no finding text.
3866
- var approvePattern = pattern;
3867
- var reasonSuffix = block.findings ? (block.findings + ' finding' + (block.findings === 1 ? '' : 's') + ' caught at this site') : 'caught by hook';
3868
- var reason = 'Approved via dashboard panel · ' + reasonSuffix;
3869
- if (!cwd) {
3870
- alert('Cannot approve: no cwd captured in the audit entry. Use codehere permission allow on the CLI.');
3871
- btn.disabled = false; btn.textContent = 'Approve in this folder';
3872
- return;
3873
- }
3874
- fetch('/api/permissions', {
3875
- method: 'POST',
3876
- headers: { 'Content-Type': 'application/json' },
3877
- body: JSON.stringify({ cwd: cwd, pattern: approvePattern, reason: reason }),
3878
- })
3879
- .then(function(r) { return r.ok ? r.json() : Promise.reject(r); })
3880
- .then(function() {
3881
- gate.classList.add('approved');
3882
- })
3883
- .catch(function() {
3884
- btn.disabled = false; btn.textContent = 'Approve in this folder';
3885
- alert('Approve failed — try the CLI: codehere permission allow ' + JSON.stringify(approvePattern));
3886
- });
3887
- });
3888
- }
3889
- function pollBlocks() {
3890
- fetch('/api/task/' + session.sessionId + '/blocks')
3891
- .then(function(r) { return r.ok ? r.json() : null; })
3892
- .then(function(d) {
3893
- if (!d || !Array.isArray(d.blocks)) return;
3894
- for (var i = 0; i < d.blocks.length; i++) renderBlockGate(d.blocks[i]);
3895
- })
3896
- .catch(function() { /* skip */ });
3897
- }
3898
- function startBlockTick() {
3899
- if (blockTicker) return;
3900
- pollBlocks();
3901
- blockTicker = setInterval(pollBlocks, 30000);
3902
- }
3903
- function stopBlockTick() {
3904
- if (blockTicker) { clearInterval(blockTicker); blockTicker = null; }
3905
- }
3906
- if (session.status === 'running') startBlockTick();
3907
-
3908
- // Pattern 13 — start whimsy rotator only while status === 'running' and
3909
- // there's no active tool step. stopWhimsy() runs on exit (or on every
3910
- // active-step swap; we restart it when the active step ends).
3911
- var stopWhimsy = null;
3912
- var lastMode = ''; // 'whimsy' | 'tool' | ''
3913
- function setMicroState(mode, label) {
3914
- if (!microActivityEl) return;
3915
- if (mode === '') {
3916
- if (stopWhimsy) { stopWhimsy(); stopWhimsy = null; }
3917
- microActivityEl.classList.remove('live', 'tool');
3918
- microActivityEl.textContent = '';
3919
- lastMode = '';
3920
- return;
3921
- }
3922
- if (mode === 'tool') {
3923
- if (stopWhimsy) { stopWhimsy(); stopWhimsy = null; }
3924
- microActivityEl.classList.add('live', 'tool');
3925
- microActivityEl.textContent = label || 'Working…';
3926
- lastMode = 'tool';
3927
- return;
3928
- }
3929
- // mode === 'whimsy'
3930
- microActivityEl.classList.add('live');
3931
- microActivityEl.classList.remove('tool');
3932
- if (lastMode !== 'whimsy') {
3933
- if (stopWhimsy) stopWhimsy();
3934
- stopWhimsy = startWhimsyRotator(microActivityEl);
3935
- lastMode = 'whimsy';
3936
- }
3937
- }
3938
- if (session.status === 'running') setMicroState('whimsy');
3939
- const finalReportEl = card.querySelector('.final-report');
3940
- const threadEl = card.querySelector('.thread-messages');
3941
- const bodyEl = card.querySelector('.session-body');
3942
- // Iter 12 — reply-to-thread refs + handler.
3943
- const threadInputEl = card.querySelector('.thread-input');
3944
- const replyTextEl = card.querySelector('.reply-text');
3945
- const replySendBtn = card.querySelector('.reply-send');
3946
- const replyStatusEl = card.querySelector('.reply-status');
3947
- if (!session.canAppendMessage) {
3948
- threadInputEl.classList.add('disabled');
3949
- threadInputEl.title = 'Replies require a live session — re-spawn to continue this thread';
3950
- // All adapters wire appendMessage as of v0.6.0 Slices 1-3. This branch
3951
- // now only fires for sessions evicted from in-memory tracking (the
3952
- // canAppendMessage flag persists from spawn but the API endpoint
3953
- // requires the session to still be live in SessionManager).
3954
- replyTextEl.placeholder = 'Replies require a live session — re-spawn to continue this thread';
3955
- replyTextEl.disabled = true;
3956
- replySendBtn.disabled = true;
3957
- } else {
3958
- const sendReply = async () => {
3959
- const text = (replyTextEl.value || '').trim();
3960
- if (!text) return;
3961
- replySendBtn.disabled = true;
3962
- replySendBtn.textContent = '...';
3963
- replyStatusEl.style.display = 'none';
3964
- replyStatusEl.classList.remove('error');
3965
- // Optimistic: render the user's message immediately in the thread.
3966
- appendThreadMessage(threadMessage({
3967
- cls: 'user', author: 'You', time: new Date(), content: text,
3968
- }));
3969
- replyTextEl.value = '';
3970
- try {
3971
- const r = await fetch('/api/task/' + session.sessionId + '/message', {
3972
- method: 'POST',
3973
- headers: { 'Content-Type': 'application/json' },
3974
- body: JSON.stringify({ text }),
3975
- });
3976
- const data = await r.json();
3977
- if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
3978
- // Render assistant response in the thread.
3979
- appendThreadMessage(threadMessage({
3980
- cls: 'agent', author: agentLabel(session.agent), time: new Date(),
3981
- content: String(data.result || '(empty response)'),
3982
- }));
3983
- } catch (err) {
3984
- replyStatusEl.textContent = 'Reply failed: ' + (err && err.message ? err.message : String(err));
3985
- replyStatusEl.classList.add('error');
3986
- replyStatusEl.style.display = 'block';
3987
- } finally {
3988
- replySendBtn.disabled = false;
3989
- replySendBtn.textContent = 'Send';
3990
- replyTextEl.focus();
3991
- }
3992
- };
3993
- replySendBtn.addEventListener('click', sendReply);
3994
- replyTextEl.addEventListener('keydown', (ev) => {
3995
- if ((ev.metaKey || ev.ctrlKey) && ev.key === 'Enter') { ev.preventDefault(); sendReply(); }
3996
- });
3997
- }
3998
- const safetyPillEl = card.querySelector('.safety-pill');
3999
- const safetySafeEl = safetyPillEl.querySelector('.safe');
4000
- const safetyBlockedEl = safetyPillEl.querySelector('.blocked');
4001
- const safetyBlockedWrap = safetyPillEl.querySelector('.blocked-portion');
4002
- let safeCount = 0, blockedCount = 0;
4003
-
4004
- // Gate B1 · increment safety counters on every Claude hook_response.
4005
- // Principle #7 (agent-provenance): shows the hook firing continuously,
4006
- // so the safety moat is visible mid-task, not only when something blocks.
4007
- function bumpSafety(event) {
4008
- if (event.type !== 'system') return;
4009
- const d = event.data || {};
4010
- if (d.subtype !== 'hook_response') return;
4011
- if (Number(d.exit_code ?? 0) === 0) safeCount += 1; else blockedCount += 1;
4012
- safetyPillEl.style.display = 'inline-flex';
4013
- safetySafeEl.textContent = String(safeCount);
4014
- if (blockedCount > 0) {
4015
- safetyBlockedWrap.style.display = '';
4016
- safetyBlockedEl.textContent = String(blockedCount);
4017
- safetyPillEl.classList.add('has-blocked');
4018
- }
4019
- }
4020
-
4021
- // Collapse/expand on header click (but not when clicking Kill)
4022
- headEl.addEventListener('click', (ev) => {
4023
- if (ev.target && ev.target.closest('.session-controls')) return;
4024
- card.classList.toggle('expanded');
4025
- });
4026
-
4027
- // Apr 29 — Slack-shape thread view.
4028
- // Thread/Raw toggle in the card head. Default = thread; Raw stays available
4029
- // for power users who want the unfiltered event stream.
4030
- const viewBtns = card.querySelectorAll('.view-toggle button');
4031
- viewBtns.forEach((btn) => {
4032
- btn.addEventListener('click', (ev) => {
4033
- ev.stopPropagation(); // don't collapse the card
4034
- const view = btn.dataset.view;
4035
- bodyEl.dataset.view = view;
4036
- viewBtns.forEach((b) => b.classList.toggle('active', b === btn));
4037
- });
4038
- });
4039
-
4040
- // Build a single thread message DOM string.
4041
- function threadMessage(opts) {
4042
- const cls = opts.cls || 'agent';
4043
- const av = opts.avatar || (cls === 'user' ? '👤' : (cls === 'tool' ? '⚙' : (cls === 'hook' ? '🛡' : (cls === 'error' ? '✗' : '🤖'))));
4044
- const time = opts.time || new Date();
4045
- return '<div class="thread-message ' + escape(cls) + (opts.modifier ? ' ' + escape(opts.modifier) : '') + '">' +
4046
- '<div class="avatar">' + escape(av) + '</div>' +
4047
- '<div class="body">' +
4048
- '<div class="head">' +
4049
- '<span class="author">' + escape(opts.author || 'agent') + '</span>' +
4050
- '<span class="time">' + time.toLocaleTimeString() + '</span>' +
4051
- '</div>' +
4052
- '<div class="content">' + escape(String(opts.content || '')) + '</div>' +
4053
- '</div>' +
4054
- '</div>';
4055
- }
4056
-
4057
- function appendThreadMessage(html) {
4058
- if (!html) return;
4059
- threadEl.insertAdjacentHTML('beforeend', html);
4060
- // Auto-scroll to bottom of thread (within the card)
4061
- threadEl.scrollTop = threadEl.scrollHeight;
4062
- }
4063
-
4064
- // Seed: user prompt as the first thread message
4065
- threadEl.innerHTML = threadMessage({
4066
- cls: 'user',
4067
- author: 'You',
4068
- time: new Date(session.startedAt),
4069
- content: session.prompt,
4070
- });
4071
-
4072
- // Parallel event → thread renderer. Initial pass duplicates parts of
4073
- // renderEvent's branching; a follow-up refactor extracts a shared
4074
- // eventToLines() primitive feeding both renderers.
4075
- function renderEventToThread(event) {
4076
- const type = String(event.type || '');
4077
- const data = event.data ?? {};
4078
- const t = new Date(event.timestamp || Date.now());
4079
- const agent = agentLabel(session.agent);
4080
-
4081
- if (type === 'stream_event') return;
4082
- if (type === 'system' && (data.subtype === 'init' || data.subtype === 'status' || data.subtype === 'hook_started')) return;
4083
- if (type === 'stdout' && !String(data.line || '').trim()) return;
4084
- if (type === 'thread.started' || type === 'turn.started') return;
4085
- if (type === 'item.started') return;
4086
-
4087
- if (type === 'assistant' && data.message && Array.isArray(data.message.content)) {
4088
- for (const block of data.message.content) {
4089
- if (block.type === 'text') {
4090
- const txt = String(block.text || '').trim();
4091
- if (txt) appendThreadMessage(threadMessage({ cls: 'agent', author: agent, time: t, content: txt }));
4092
- } else if (block.type === 'tool_use') {
4093
- const name = String(block.name || 'tool');
4094
- const input = block.input && typeof block.input === 'object' ? JSON.stringify(block.input) : '';
4095
- const preview = input.length > 200 ? input.slice(0, 200) + '…' : input;
4096
- appendThreadMessage(threadMessage({ cls: 'tool', author: agent + ' · ' + name, time: t, content: preview, avatar: '⚙' }));
4097
- }
4098
- }
4099
- } else if (type === 'user' && data.message && Array.isArray(data.message.content)) {
4100
- for (const block of data.message.content) {
4101
- if (block.type === 'tool_result') {
4102
- const raw = Array.isArray(block.content) ? block.content.map((c) => c.text || '').join('') : String(block.content || '');
4103
- const txt = raw.replace(/\\s+/g, ' ').trim();
4104
- if (txt) {
4105
- const preview = txt.length > 240 ? txt.slice(0, 240) + '…' : txt;
4106
- const cls = block.is_error ? 'error' : 'tool';
4107
- const author = block.is_error ? agent + ' · error' : agent + ' · result';
4108
- appendThreadMessage(threadMessage({ cls, author, time: t, content: preview }));
4109
- }
4110
- }
4111
- }
4112
- } else if (type === 'system' && data.subtype === 'hook_response') {
4113
- const name = String(data.hook_name || 'hook');
4114
- const blocked = Number(data.exit_code ?? 0) !== 0;
4115
- const stderr = String(data.stderr || '').trim();
4116
- const content = blocked ? (stderr || 'blocked') : 'allowed';
4117
- appendThreadMessage(threadMessage({ cls: 'hook', modifier: blocked ? 'blocked' : '', author: 'codehere · ' + name, time: t, content }));
4118
- } else if (type === 'item.completed') {
4119
- const item = data.item && typeof data.item === 'object' ? data.item : {};
4120
- const itemType = String(item.type || 'item');
4121
- if (itemType === 'agent_message' && item.text) {
4122
- const txt = String(item.text).trim();
4123
- if (txt) appendThreadMessage(threadMessage({ cls: 'agent', author: agent, time: t, content: txt.slice(0, 320) }));
4124
- } else if (itemType === 'command_execution') {
4125
- const cmd = String(item.command || '').replace(/\\n/g, ' ').trim();
4126
- const cmdShort = cmd.length > 160 ? cmd.slice(0, 160) + '…' : cmd;
4127
- appendThreadMessage(threadMessage({ cls: 'tool', author: agent + ' · shell', time: t, content: cmdShort, avatar: '⚙' }));
4128
- const out = String(item.aggregated_output || '').trim();
4129
- if (out) {
4130
- const outShort = out.length > 200 ? out.slice(0, 200) + '…' : out;
4131
- const ok = item.exit_code === 0 || item.status === 'completed';
4132
- appendThreadMessage(threadMessage({
4133
- cls: ok ? 'tool' : 'error',
4134
- author: agent + ' · ' + (ok ? 'result' : 'exit ' + (item.exit_code ?? '?')),
4135
- time: t,
4136
- content: outShort,
4137
- }));
4138
- }
4139
- }
4140
- } else if (type === 'stdout') {
4141
- // Aider and generic CLI adapters stream via plain stdout. Codex
4142
- // Round 1 (Apr 29) caught: without this branch, those sessions
4143
- // showed only the seed prompt + exit marker in thread view.
4144
- const line = String(data.line || '').trim();
4145
- if (line) appendThreadMessage(threadMessage({ cls: 'agent', author: agent, time: t, content: line.length > 320 ? line.slice(0, 320) + '…' : line }));
4146
- } else if (type === 'stderr') {
4147
- const line = String(data.line || '').trim();
4148
- if (line) appendThreadMessage(threadMessage({ cls: 'tool', author: agent + ' · stderr', time: t, content: line.length > 240 ? line.slice(0, 240) + '…' : line, avatar: '⚠' }));
4149
- } else if (type === 'error') {
4150
- const msg = String(data.message || data.error || 'error');
4151
- appendThreadMessage(threadMessage({ cls: 'error', author: agent, time: t, content: msg }));
4152
- } else if (type === 'codehere:error') {
4153
- const msg = String(data.message || data.kind || 'error');
4154
- appendThreadMessage(threadMessage({ cls: 'error', author: 'codehere', time: t, content: msg }));
4155
- } else if (type === 'exit') {
4156
- const status = String(data.status || 'completed');
4157
- appendThreadMessage(threadMessage({
4158
- cls: status === 'error' ? 'error' : 'agent',
4159
- author: agent + ' · ' + status,
4160
- time: t,
4161
- content: 'session ended',
4162
- avatar: status === 'error' ? '✗' : '✓',
4163
- }));
4164
- }
4165
- }
4166
-
4167
- // Layer 2: render the semantic step list inside the collapsed card.
4168
- // Principles #1 (web is not a terminal) + #2 (info density) — show last 3
4169
- // steps with pass/active/fail glyphs; hide the rest behind "+ N more" so
4170
- // the card stays compact even on a 20-step task.
4171
- function renderSteps(steps) {
4172
- // Pattern 1 — update the tool-progress chip on every step snapshot.
4173
- renderToolProgress(steps);
4174
- if (!Array.isArray(steps) || steps.length === 0) {
4175
- stepsEl.innerHTML = '';
4176
- // Pattern 13: no steps + still running → keep whimsy rotation alive.
4177
- if (session.status === 'running' && lastMode !== 'whimsy') setMicroState('whimsy');
4178
- return;
4179
- }
4180
- const tail = steps.slice(-3);
4181
- const more = steps.length - tail.length;
4182
- const glyph = { done: '✓', active: '⟳', failed: '✗', pending: '·' };
4183
- stepsEl.innerHTML =
4184
- tail.map(s => {
4185
- const st = String(s.state || 'pending');
4186
- const g = glyph[st] || '·';
4187
- // Pattern 2 — derive tool kind + split label so the verb-color
4188
- // semantic CSS applies (read/grep muted, write/edit green, bash
4189
- // peach). Falls back to a single .verb span when label is one word.
4190
- // Pattern 14 — smart-truncate the target per tool kind so the
4191
- // meaningful chunk survives the trace's narrow column.
4192
- const kind = inferToolKind(s.label);
4193
- const parts = splitStepLabel(String(s.label || ''));
4194
- const truncatedTarget = truncateTarget(kind, parts.target);
4195
- const labelHtml = truncatedTarget
4196
- ? '<span class="verb">' + escape(parts.verb) + '</span><span class="target">' + escape(truncatedTarget) + '</span>'
4197
- : '<span class="verb">' + escape(parts.verb) + '</span>';
4198
- // P22 / Track B #8 — bash output preview. Render last 4
4199
- // lines of step.output under the step header. Only shows
4200
- // for done/failed steps (active steps don't have output yet).
4201
- // Note: split-on-newline uses fromCharCode(10) to dodge the
4202
- // outer TS template literal eating the backslash-n.
4203
- var outputHtml = '';
4204
- if (s.output && (st === 'done' || st === 'failed')) {
4205
- var lines = String(s.output).split(String.fromCharCode(10));
4206
- var tailLines = lines.slice(-4);
4207
- outputHtml = '<div class="step-output">' +
4208
- tailLines.map(function(ln) { return escape(ln); }).join('<br>') +
4209
- '</div>';
4210
- }
4211
- return '<div class="step ' + st + '" data-tool="' + escape(kind) + '"><span class="ico">' + g + '</span>' + labelHtml + outputHtml + '</div>';
4212
- }).join('') +
4213
- (more > 0 ? '<div class="more">+ ' + more + ' earlier</div>' : '');
4214
-
4215
- // Pattern 13: tool-prefixed verb takes over while a step is active.
4216
- // Most-recent active step's label becomes the micro-activity text;
4217
- // when no step is active we fall back to whimsy rotation.
4218
- // Pattern 17: 3-bucket long-op indicator — 5s timer → 15s bar → 30s
4219
- // still-running + ESC hint.
4220
- const active = steps.filter(s => String(s.state || '') === 'active').slice(-1)[0];
4221
- if (active && session.status === 'running') {
4222
- let label = String(active.label || 'Working on it…');
4223
- const startedAt = Number(active.startedAt || 0);
4224
- if (startedAt > 0) {
4225
- const elapsedMs = Date.now() - startedAt;
4226
- const elapsedSec = Math.floor(elapsedMs / 1000);
4227
- if (elapsedSec >= 5) {
4228
- const timer = fmtElapsed(elapsedMs);
4229
- // 15s threshold: add indeterminate progress bar
4230
- if (elapsedSec >= 15) {
4231
- const barFill = Math.floor((elapsedSec % 15) / 15 * 8);
4232
- const barEmpty = 8 - barFill;
4233
- const bar = '▮'.repeat(barFill) + '░'.repeat(barEmpty);
4234
- label += ' · ' + timer + ' ' + bar;
4235
- } else {
4236
- // 5–15s: elapsed timer only
4237
- label += ' · ' + timer;
4238
- }
4239
- if (elapsedSec >= 30) label += ' · still running · ESC to cancel';
4240
- }
4241
- }
4242
- setMicroState('tool', label);
4243
- } else if (session.status === 'running') {
4244
- setMicroState('whimsy');
4245
- } else {
4246
- setMicroState('');
4247
- }
4248
- }
4249
-
4250
- // Layer 3: render the final report block when the task completes.
4251
- // Principles #2 + #7 — density (files/tests/trust in one view) and
4252
- // agent-provenance (the report attributes to a specific agent).
4253
- // Iter 12 — prose summary (3 paragraphs, plain English) prepended above
4254
- // the artifact list. Non-technical users see prose first; raw artifact
4255
- // detail (files/tests/tokens) below as supporting context.
4256
- function renderArtifacts(artifacts, finalReport, status, completedAt) {
4257
- // Show the block if either prose summary OR artifacts exist OR if a
4258
- // completed task is still generating its report (placeholder state).
4259
- const isCompleted = status === 'completed' || status === 'killed' || status === 'error';
4260
- const hasContent = artifacts || finalReport;
4261
- const reportFailed = session.failedReport === true;
4262
- // v0.6.x — bound the "Generating report…" placeholder to the actual
4263
- // generation window. Final Report typically returns within 30s; after
4264
- // 90s post-completion with no report, the report either failed silently
4265
- // or the daemon was restarted (in-memory finalReportBySession Map gets
4266
- // wiped). Either way, "Generating report…" forever is dishonest.
4267
- const REPORT_WAIT_BUDGET_MS = 90_000;
4268
- const sinceCompleted = completedAt ? Date.now() - completedAt : 0;
4269
- const stillWaitingForReport = isCompleted && !finalReport && !reportFailed && sinceCompleted < REPORT_WAIT_BUDGET_MS;
4270
- const reportTimedOut = isCompleted && !finalReport && !reportFailed && sinceCompleted >= REPORT_WAIT_BUDGET_MS;
4271
- const isAwaitingReport = stillWaitingForReport;
4272
- if (!hasContent && !isAwaitingReport && !reportTimedOut && !reportFailed) { finalReportEl.style.display = 'none'; return; }
4273
- finalReportEl.style.display = 'block';
4274
- const files = Array.isArray(artifacts?.files) ? artifacts.files : [];
4275
- const tests = artifacts?.tests;
4276
- let html = '<div class="fr-label">Final report · ' + escape(agentLabel(session.agent)) + '</div>';
4277
- if (finalReport && finalReport.summary) {
4278
- // Plain-English prose. Split paragraphs on blank lines, render each as <p>.
4279
- // NOTE: this code is inside a TS template literal — backslashes in
4280
- // regex literals must be doubled so the served JS gets the right
4281
- // sequence (test/cli/ui-served-html-syntax.test.ts guards this).
4282
- const paragraphs = String(finalReport.summary).split(/\\n\\s*\\n/).filter(p => p.trim());
4283
- html += '<div class="fr-summary">' +
4284
- paragraphs.map(p => '<p>' + escape(p.trim()) + '</p>').join('') +
4285
- '</div>';
4286
- } else if (isAwaitingReport) {
4287
- html += '<div class="fr-summary fr-summary-pending"><p>Generating report…</p></div>';
4288
- } else if (reportFailed) {
4289
- html += '<div class="fr-summary fr-summary-pending"><p>Final Report generation failed. The agent finished but the summary couldn\\'t be created. Re-spawn to get a fresh report.</p></div>';
4290
- } else if (reportTimedOut) {
4291
- // Honest message instead of perpetual "Generating report…". Most
4292
- // common cause: daemon restarted (Final Report is in-memory only,
4293
- // doesn't persist to disk yet — v0.6.x cleanup item). Less common:
4294
- // the report provider (claude-free / claude) failed silently.
4295
- 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>';
4296
- }
4297
- if (files.length > 0) {
4298
- // Pattern 7 — file chip with mutation tint, trust grade letter,
4299
- // diff stats. Mutation kind derives from added/removed counts so
4300
- // the file-icon color signals "create / edit / delete" at glance.
4301
- 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>';
4302
- const trustGrade = (n) => n >= 90 ? 'A' : n >= 75 ? 'B' : n >= 60 ? 'C' : n >= 40 ? 'D' : 'F';
4303
- html += '<div class="fr-files">' + files.map(f => {
4304
- const added = Number(f.added || 0);
4305
- const removed = Number(f.removed || 0);
4306
- let mutation;
4307
- if (added > 0 && removed === 0) mutation = 'write';
4308
- else if (added === 0 && removed > 0) mutation = 'delete';
4309
- else mutation = 'edit';
4310
- const lines = '<span class="add">+' + added + '</span> <span class="rem">−' + removed + '</span>';
4311
- // Pattern 11 — file chip uses the standalone trust-badge helper
4312
- // instead of inline class-picking. Single source of truth for
4313
- // grade-cutoff + color-split. (Note: this CORRECTS the prior
4314
- // 60-cutoff which put D-grade trust in red; spec puts D in
4315
- // warn-yellow.)
4316
- return '<div class="fr-file" data-mutation="' + escape(mutation) + '">' +
4317
- FILE_ICON_SVG +
4318
- '<span class="path" title="' + escape(String(f.path)) + '">' + escape(String(f.path)) + '</span>' +
4319
- '<span class="lines">' + lines + '</span>' +
4320
- trustBadgeHTML(typeof f.trust === 'number' ? f.trust : null) +
4321
- '</div>';
4322
- }).join('') + '</div>';
4323
- } else {
4324
- html += '<div class="fr-none">No file changes detected (or not run inside a git repo).</div>';
4325
- }
4326
- if (tests && (tests.passed || tests.failed)) {
4327
- html += '<div class="fr-tests">Tests · <span class="pass">' + (tests.passed || 0) + ' passed</span>' +
4328
- (tests.failed ? ', <span class="fail">' + tests.failed + ' failed</span>' : '') +
4329
- (tests.durationMs ? ' · ' + Math.round(tests.durationMs / 100) / 10 + 's' : '') + '</div>';
4330
- }
4331
- const u = artifacts.usage;
4332
- if (u && (u.inputTokens || u.outputTokens)) {
4333
- const fmt = (n) => n >= 10000 ? (Math.round(n / 100) / 10).toFixed(1) + 'k' : String(n);
4334
- let tokensLine = 'Tokens · <b>' + fmt(u.inputTokens) + '</b> in · <b>' + fmt(u.outputTokens) + '</b> out';
4335
- if (u.cacheReadTokens) tokensLine += ' · ' + fmt(u.cacheReadTokens) + ' cached';
4336
- html += '<div class="fr-tests">' + tokensLine + '</div>';
4337
- }
4338
- finalReportEl.innerHTML = html;
4339
- }
4340
-
4341
- // Hydrate from initial snapshot (late-joiner support + persisted sessions)
4342
- if (Array.isArray(session.steps) && session.steps.length) renderSteps(session.steps);
4343
- // Render Final Report block if there's anything to show (prose summary,
4344
- // artifacts, or "generating…" placeholder for a freshly-completed session).
4345
- if (session.artifacts || session.finalReport || session.status === 'completed' || session.status === 'killed' || session.status === 'error') {
4346
- renderArtifacts(session.artifacts, session.finalReport, session.status, session.completedAt);
4347
- }
4348
-
4349
- // Persisted sessions have no live event stream and no subprocess to kill.
4350
- if (isPersisted) {
4351
- killBtn.style.display = 'none';
4352
- // Use .persisted (visible, faded). Pre-fix this added .observed
4353
- // which then collided with the detected-process hide rule from
4354
- // claim_user_persona work — your own past sessions disappeared.
4355
- card.classList.add('persisted');
4356
- return;
4357
- }
4358
-
4359
- const es = new EventSource('/api/task/' + session.sessionId + '/events');
4360
- es.onmessage = (ev) => {
4361
- try {
4362
- const event = JSON.parse(ev.data);
4363
-
4364
- // Layer 2/3 synthetic events — handle before renderEvent (so they don't leak into the raw log)
4365
- if (event.type === 'codehere:steps') { renderSteps(event.data?.steps); return; }
4366
- if (event.type === 'codehere:artifacts') { renderArtifacts(event.data, undefined, session.status, session.completedAt); return; }
4367
-
4368
- // Pattern 6 · codehere:error events carry a structured code that maps
4369
- // 1:1 to a macro state. rate_limit / quota_exceeded / credit_low /
4370
- // auth_invalid / model_unavailable each get their own pill copy +
4371
- // color so users can see WHY the agent stopped, not just THAT it did.
4372
- // Falls back to 'error' (red "Failed") for unknown codes.
4373
- if (event.type === 'codehere:error') {
4374
- var code = event.data && event.data.code;
4375
- var state = code && STATE_LABELS[code] ? code : 'error';
4376
- updateStatus(pillEl, state);
4377
- // Pattern 9 — surface a recovery affordance below the session
4378
- // header for known failure codes. Idempotent: if already shown,
4379
- // skip (sessions can emit codehere:error multiple times).
4380
- if (code && !card.querySelector('.recovery-affordance')) {
4381
- var nextAgent = pickFailoverTarget(session.agent);
4382
- var built = buildRecoveryAffordance(code, session.agent, nextAgent);
4383
- // Insert between session-head and session-body so the affordance
4384
- // sits at the top of the card body (most-prominent position).
4385
- var bodyEl = card.querySelector('.session-body');
4386
- if (bodyEl) bodyEl.parentNode.insertBefore(built.el, bodyEl);
4387
- else card.appendChild(built.el);
4388
- built.el.querySelector('.ra-secondary').addEventListener('click', function() {
4389
- built.el.remove();
4390
- });
4391
- built.el.querySelector('.ra-primary').addEventListener('click', async function() {
4392
- if (!built.copy.canFailover) {
4393
- built.el.remove();
4394
- return;
4395
- }
4396
- var primary = built.el.querySelector('.ra-primary');
4397
- primary.disabled = true;
4398
- primary.textContent = 'Switching…';
4399
- try {
4400
- var r = await fetch('/api/failover-accept', {
4401
- method: 'POST',
4402
- headers: { 'Content-Type': 'application/json' },
4403
- body: JSON.stringify({ sessionId: session.sessionId, targetAgent: nextAgent })
4404
- });
4405
- var d = await r.json();
4406
- if (d.error || (!d.ok || !d.newSessionId)) {
4407
- built.el.querySelector('.ra-msg').innerHTML = '<b>Failover failed:</b> ' + escape(d.detail || 'Target agent not ready');
4408
- primary.style.display = 'none';
4409
- built.el.querySelector('.ra-secondary').textContent = 'Dismiss';
4410
- return;
4411
- }
4412
- // Session threading: add "continued as" link on the original card
4413
- var continuedAsEl = document.createElement('div');
4414
- continuedAsEl.className = 'session-continued-as';
4415
- continuedAsEl.innerHTML = '↻ Continued as <a href="#' + escape(d.newSessionId) + '">' + escape(nextAgent) + '</a> · session ' + escape(d.newSessionId.slice(0, 8));
4416
- // Insert after session-head
4417
- var headEl = card.querySelector('.session-head');
4418
- if (headEl && headEl.parentNode) headEl.parentNode.insertBefore(continuedAsEl, headEl.nextSibling);
4419
- built.el.querySelector('.ra-msg').innerHTML = '↻ Continued as <b>' + escape(nextAgent) + '</b>';
4420
- primary.style.display = 'none';
4421
- built.el.querySelector('.ra-secondary').textContent = 'OK';
4422
- } catch {
4423
- built.el.querySelector('.ra-msg').innerHTML = '<b>Failover failed:</b> Network error';
4424
- primary.disabled = false;
4425
- primary.textContent = 'Retry';
4426
- }
4427
- });
4428
- }
4429
- // fall through — renderEvent renders the red banner in the log
4430
- }
4431
-
4432
- // Gate B1 · tally safety events continuously (non-exclusive with render)
4433
- bumpSafety(event);
4434
-
4435
- renderEvent(logEl, event);
4436
- renderEventToThread(event); // Apr 29 · Slack-shape thread v0 (Day 2)
4437
- if (event.type === 'exit') {
4438
- updateStatus(pillEl, event.data?.status || 'completed');
4439
- killBtn.disabled = true; killBtn.style.opacity = '0.4';
4440
- const dot = card.querySelector('.sub .live-dot');
4441
- if (dot) dot.remove();
4442
- setMicroState(''); // Pattern 13 — stop rotator on session exit
4443
- stopElapsedTick(); // Pattern 1 — stop the chip's elapsed ticker
4444
- stopNarrationTick(); // v0.6 M3 — stop the narrator poll on exit
4445
- // Final narration fetch — backend may emit one last summary
4446
- // after the exit event; give it a beat then poll once more.
4447
- setTimeout(pollNarration, 2000);
4448
- stopBlockTick(); // P18 slice 4 — stop the per-session block poll
4449
- // Final blocks fetch — agent may have run one last hook-blocked
4450
- // tool call right before exit; surface it.
4451
- setTimeout(pollBlocks, 2000);
4452
- // Final chip render with the terminal status so it shows the last
4453
- // dot pattern + final elapsed value in the chip's complete/failed state.
4454
- if (Array.isArray(session.steps)) renderToolProgress(session.steps);
4455
- es.close();
4456
- }
4457
- if (event.type === 'error') {
4458
- setMicroState(''); // Pattern 13
4459
- updateStatus(pillEl, 'error');
4460
- }
4461
- } catch { /* skip */ }
4462
- };
4463
- es.onerror = () => { /* server may have closed after exit; fine */ };
4464
-
4465
- // Pattern 19 — interrupt affordance with 2-press confirm + ESC shortcut.
4466
- // First press flips the button to a "press again to confirm" red state
4467
- // for 3s; second press during that window actually kills. ESC while the
4468
- // card is focused triggers the same flow. Prevents accidental kills.
4469
- var killConfirmTimer = null;
4470
- function armKillConfirm() {
4471
- killBtn.dataset.state = 'confirming';
4472
- killBtn.textContent = 'Confirm?';
4473
- if (killConfirmTimer) clearTimeout(killConfirmTimer);
4474
- killConfirmTimer = setTimeout(function() {
4475
- killBtn.dataset.state = 'ready';
4476
- killBtn.textContent = 'Kill';
4477
- killConfirmTimer = null;
4478
- }, 3000);
4479
- }
4480
- async function performKill() {
4481
- if (killConfirmTimer) { clearTimeout(killConfirmTimer); killConfirmTimer = null; }
4482
- killBtn.disabled = true;
4483
- killBtn.dataset.state = 'killing';
4484
- killBtn.textContent = '…';
4485
- try {
4486
- await fetch('/api/task/' + session.sessionId + '/kill', { method: 'POST' });
4487
- } catch { /* ignore */ }
4488
- killBtn.dataset.state = 'ready';
4489
- killBtn.textContent = 'Kill';
4490
- killBtn.disabled = false;
4491
- }
4492
- killBtn.dataset.state = 'ready';
4493
- killBtn.addEventListener('click', function(ev) {
4494
- ev.stopPropagation();
4495
- if (killBtn.dataset.state === 'confirming') {
4496
- performKill();
4497
- } else {
4498
- armKillConfirm();
4499
- }
4500
- });
4501
- // ESC keyboard shortcut — fires when the session card has focus within.
4502
- card.addEventListener('keydown', function(ev) {
4503
- if (ev.key !== 'Escape') return;
4504
- if (killBtn.disabled) return;
4505
- ev.preventDefault();
4506
- if (killBtn.dataset.state === 'confirming') {
4507
- performKill();
4508
- } else {
4509
- armKillConfirm();
4510
- }
4511
- });
4512
-
4513
- // Iter 12 — expose renderArtifacts so the polling loop can refresh the
4514
- // Final Report block when the prose summary arrives async (~2-5s after
4515
- // task exit). Without this, non-technical users would have to refresh.
4516
- sessions.set(session.sessionId, { card, logEl, es, renderArtifacts });
4517
- }
4518
-
4519
- async function assignTask() {
4520
- const prompt = promptEl.value.trim();
4521
- if (!prompt) return;
4522
- // Apr 29: empty value = "Auto" option = backend defaults to conductor
4523
- // (the supervisor routes). Specialists are explicit overrides.
4524
- // M5 slice 2: agent === '*' = fan-out (every installed specialist in
4525
- // parallel via /api/task/batch).
4526
- const agent = agentSelect.value;
4527
- const cwd = cwdInput.value.trim();
4528
- if (agent === '*') {
4529
- await assignParallel(prompt, cwd);
4530
- return;
4531
- }
4532
- spawnBtn.disabled = true; spawnBtn.textContent = '...';
4533
- try {
4534
- const res = await fetch('/api/task', {
4535
- method: 'POST',
4536
- headers: { 'Content-Type': 'application/json' },
4537
- body: JSON.stringify({ prompt, agent: agent || undefined, cwd: cwd || undefined }),
4538
- });
4539
- const data = await res.json();
4540
- if (!res.ok) {
4541
- // v0.6 S3 slice 2 — friendly message on daily-budget refusal so
4542
- // non-technical users see the WHY + the next move instead of a
4543
- // raw error code.
4544
- if (res.status === 429 && data.error === 'daily_budget_exhausted') {
4545
- alert(data.message || 'Daily budget reached.');
4546
- } else {
4547
- alert('Failed: ' + (data.error || res.status));
4548
- }
4549
- return;
4550
- }
4551
- attachSession({ sessionId: data.sessionId, prompt, startedAt: data.startedAt, status: data.status, agent: data.agent || agent || 'conductor', canAppendMessage: data.canAppendMessage === true });
4552
- promptEl.value = '';
4553
- // Spend chip refreshes on its own 60s tick; nudge it now since a
4554
- // spawn means new spend is imminent.
4555
- refreshSpendToday();
4556
- } catch (e) {
4557
- alert('Network error: ' + (e && e.message ? e.message : e));
4558
- } finally {
4559
- spawnBtn.disabled = false; spawnBtn.textContent = 'Assign task';
4560
- }
4561
- }
4562
-
4563
- // M5 slice 2 — fan-out spawn. Sends the same prompt to every installed
4564
- // ready specialist in parallel via /api/task/batch. Cap of 5 enforced
4565
- // by the backend; if the user has more than 5 ready specialists we
4566
- // take the first 5 in the readiness order (sort already favors ready).
4567
- async function assignParallel(prompt, cwd) {
4568
- const readySpecialists = (agentReadiness || [])
4569
- .filter(a => a.state === 'ready')
4570
- .map(a => a.id)
4571
- .slice(0, 5);
4572
- if (readySpecialists.length < 2) {
4573
- alert('Fan-out needs at least 2 ready agents. Currently ready: ' + readySpecialists.length);
4574
- return;
4575
- }
4576
- spawnBtn.disabled = true; spawnBtn.textContent = '...';
4577
- try {
4578
- const res = await fetch('/api/task/batch', {
4579
- method: 'POST',
4580
- headers: { 'Content-Type': 'application/json' },
4581
- body: JSON.stringify({
4582
- tasks: readySpecialists.map(agentId => ({
4583
- prompt,
4584
- agent: agentId,
4585
- cwd: cwd || undefined,
4586
- })),
4587
- }),
4588
- });
4589
- const data = await res.json();
4590
- if (!res.ok) { alert('Batch failed: ' + (data.error || res.status)); return; }
4591
- // Attach a card per spawned task. Per-task errors get surfaced
4592
- // via alert listing the failed agents — successful spawns still
4593
- // attach.
4594
- const failed = [];
4595
- for (const t of (data.tasks || [])) {
4596
- if (t.sessionId) {
4597
- attachSession({
4598
- sessionId: t.sessionId,
4599
- prompt,
4600
- startedAt: t.startedAt,
4601
- status: t.status,
4602
- agent: t.agent,
4603
- canAppendMessage: false, // batch doesn't surface this; default conservative
4604
- });
4605
- } else if (t.error) {
4606
- failed.push(t.agent + ': ' + (t.reason || t.error));
4607
- }
4608
- }
4609
- if (failed.length > 0) alert(failed.length + ' of ' + (data.tasks || []).length + ' fan-out spawns failed: ' + failed.join('; '));
4610
- promptEl.value = '';
4611
- } catch (e) {
4612
- alert('Network error: ' + (e && e.message ? e.message : e));
4613
- } finally {
4614
- spawnBtn.disabled = false; spawnBtn.textContent = 'Assign task';
4615
- }
4616
- }
4617
-
4618
- spawnBtn.addEventListener('click', assignTask);
4619
-
4620
- // v0.6.x Slack-shape spike — Continue thread button + Cmd+Shift+Enter.
4621
- // Routes the prompt as a reply to the most recent in-memory session for
4622
- // the current cwd via the existing /api/task/:id/message endpoint.
4623
- // Disabled when no eligible session exists; auto-refresh state every 5s
4624
- // alongside the existing tasks polling.
4625
- const continueBtn = document.getElementById('continue-thread');
4626
- let continueTargetSessionId = null;
4627
-
4628
- function findContinueTarget(spawnedList, cwd) {
4629
- if (!cwd) return null;
4630
- const eligible = (spawnedList || []).filter(s => s.cwd === cwd && s.canAppendMessage === true);
4631
- if (eligible.length === 0) return null;
4632
- eligible.sort((a, b) => b.startedAt - a.startedAt);
4633
- return eligible[0];
4634
- }
4635
-
4636
- function refreshContinueAvailability() {
4637
- const cwd = cwdInput.value.trim();
4638
- if (!cwd) {
4639
- continueTargetSessionId = null;
4640
- continueBtn.disabled = true;
4641
- continueBtn.title = 'Set a folder first to continue its thread';
4642
- return;
4643
- }
4644
- fetch('/api/tasks').then(r => r.json()).then(d => {
4645
- const target = findContinueTarget(d.spawned, cwd);
4646
- if (target) {
4647
- continueTargetSessionId = target.sessionId;
4648
- continueBtn.disabled = false;
4649
- const promptPreview = (target.prompt || '').slice(0, 40);
4650
- continueBtn.title = 'Reply to last task in this folder: "' + promptPreview + (target.prompt && target.prompt.length > 40 ? '…' : '') + '" (' + target.agent + ')';
4651
- } else {
4652
- continueTargetSessionId = null;
4653
- continueBtn.disabled = true;
4654
- continueBtn.title = 'No live session for this folder. Use Assign new instead.';
4655
- }
4656
- }).catch(() => {});
4657
- }
4658
-
4659
- async function continueThread() {
4660
- const prompt = promptEl.value.trim();
4661
- if (!prompt) return;
4662
- if (!continueTargetSessionId) { alert('No live session for this folder. Use Assign new instead.'); return; }
4663
- const targetId = continueTargetSessionId;
4664
- continueBtn.disabled = true;
4665
- const originalText = continueBtn.textContent;
4666
- continueBtn.textContent = '...';
4667
- try {
4668
- const res = await fetch('/api/task/' + encodeURIComponent(targetId) + '/message', {
4669
- method: 'POST',
4670
- headers: { 'Content-Type': 'application/json' },
4671
- body: JSON.stringify({ text: prompt }),
4672
- });
4673
- const data = await res.json();
4674
- if (!res.ok) { alert('Failed: ' + (data.error || res.status)); return; }
4675
- promptEl.value = '';
4676
- // Scroll the existing card into view; existing 5s polling refreshes content.
4677
- const card = document.querySelector('[data-session-id="' + targetId + '"]');
4678
- if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
4679
- } catch (e) {
4680
- alert('Network error: ' + (e && e.message ? e.message : e));
4681
- } finally {
4682
- continueBtn.disabled = false;
4683
- continueBtn.textContent = originalText;
4684
- // Re-check availability (target may have settled differently)
4685
- refreshContinueAvailability();
4686
- }
4687
- }
4688
-
4689
- continueBtn.addEventListener('click', continueThread);
4690
- cwdInput.addEventListener('input', refreshContinueAvailability);
4691
- cwdInput.addEventListener('change', refreshContinueAvailability);
4692
-
4693
- promptEl.addEventListener('keydown', (e) => {
4694
- if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
4695
- e.preventDefault();
4696
- if (e.shiftKey) {
4697
- continueThread();
4698
- } else {
4699
- assignTask();
4700
- }
4701
- }
4702
- });
4703
-
4704
- // Refresh continue-target availability alongside the existing tasks polling
4705
- setInterval(refreshContinueAvailability, 5000);
4706
- refreshContinueAvailability();
4707
-
4708
- // ── Observed sessions (F1d) ──────────────────────────────────────────
4709
- function shortId(id) { return String(id || '').slice(0, 8); }
4710
-
4711
- function timeAgo(ms) {
4712
- const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
4713
- if (s < 60) return s + 's ago';
4714
- if (s < 3600) return Math.floor(s / 60) + 'm ago';
4715
- return Math.floor(s / 3600) + 'h ago';
4716
- }
4717
-
4718
- function renderActiveAgent(a) {
4719
- const existingKey = 'agent:' + a.pid;
4720
- const existing = document.querySelector('[data-session-id="' + CSS.escape(existingKey) + '"]');
4721
- if (existing) return;
4722
- const card = document.createElement('div');
4723
- card.className = 'session observed';
4724
- card.dataset.sessionId = existingKey;
4725
- const uptime = a.uptimeSeconds < 60
4726
- ? a.uptimeSeconds + 's'
4727
- : a.uptimeSeconds < 3600
4728
- ? Math.floor(a.uptimeSeconds / 60) + 'm'
4729
- : Math.floor(a.uptimeSeconds / 3600) + 'h';
4730
- card.innerHTML =
4731
- '<div class="session-head">' +
4732
- '<div class="meta">' +
4733
- '<div class="prompt">' +
4734
- agentBadgeHTML(a.id || a.label) + ' ' + escape(a.label) + ' · ' + escape(a.tty) + ' · pid ' + a.pid +
4735
- '</div>' +
4736
- '<div class="sub">running ' + escape(uptime) + '</div>' +
4737
- '</div>' +
4738
- '<div class="session-controls">' +
4739
- '<span class="status-pill observed-pill">detected</span>' +
4740
- '</div>' +
4741
- '</div>';
4742
- sessionsEl.appendChild(card);
4743
- emptyEl.style.display = 'none';
4744
- }
4745
-
4746
- function pruneStaleAgents(seenKeys) {
4747
- // Remove cards for agents that have exited since the last fetch
4748
- const cards = sessionsEl.querySelectorAll('.session.observed');
4749
- cards.forEach(card => {
4750
- const key = card.dataset.sessionId;
4751
- if (key && key.startsWith('agent:') && !seenKeys.has(key)) card.remove();
4752
- });
4753
- }
4754
-
4755
- // Gate B3 · audit summary row — moat made visible on the main page.
4756
- // Principles #3 (status at a glance), #7 (agent-provenance), #6 (marketing voice).
4757
- const auditRow = document.getElementById('audit-summary');
4758
- function updateAudit(d) {
4759
- if (!d) { auditRow.classList.add('empty'); return; }
4760
- const hasAny = (d.actions || 0) > 0;
4761
- if (!hasAny) { auditRow.classList.add('empty'); return; }
4762
- auditRow.classList.remove('empty');
4763
- document.getElementById('as-actions').textContent = String(d.actions || 0);
4764
- // UX-simplification iter 2 (May 7): dropped writes to hidden as-blocked /
4765
- // as-trust / as-agents spans — they were updated but never displayed.
4766
- // The mode-indicator pill in the nav now carries the safety-posture
4767
- // signal those hidden values were once meant to surface.
4768
- }
4769
- function refreshAudit() {
4770
- fetch('/api/audit/summary').then(r => r.json()).then(updateAudit).catch(() => { /* fine */ });
4771
- }
4772
- refreshAudit();
4773
- setInterval(refreshAudit, 30000); // audit log isn't hot; 30s is plenty
4774
-
4775
- // Apr 29 status-banner is now the single stats-line above ("X actions
4776
- // today"). UX-simplification iter 2 (May 7) dropped the hidden setText
4777
- // counters + the per-second timer that updated sb-last for a value
4778
- // that was never displayed. updateBanner still toggles row visibility
4779
- // when there's any session activity.
4780
- function updateBanner(d) {
4781
- const spawned = d.spawned || d.sessions || [];
4782
- const detected = (d.activeAgents || []).length;
4783
- if (spawned.length + detected > 0) {
4784
- auditRow.classList.remove('empty');
4785
- }
4786
- }
4787
-
4788
- function loadSessions() {
4789
- fetch('/api/tasks').then(r => r.json()).then(d => {
4790
- for (const s of (d.spawned || d.sessions || [])) attachSession(s);
4791
- // C3 · render completed sessions from prior daemon runs (read-only cards)
4792
- for (const s of (d.persisted || [])) attachSession({ ...s, provenance: 'persisted' });
4793
- const seen = new Set();
4794
- for (const a of (d.activeAgents || [])) {
4795
- seen.add('agent:' + a.pid);
4796
- renderActiveAgent(a);
4797
- }
4798
- pruneStaleAgents(seen);
4799
- updateBanner(d);
4800
- updateShowMoreButton();
4801
- updateStopAllButton();
4802
- }).catch(() => { /* fine */ });
4803
- }
4804
- // claim_user_persona — show "Show all sessions (N more)" button only
4805
- // when there are more than 5 spawned sessions. Detected-process rows
4806
- // don't count (they're already hidden by default).
4807
- function updateShowMoreButton() {
4808
- const btn = document.getElementById('show-more-sessions');
4809
- if (!btn) return;
4810
- const allSessions = sessionsEl.querySelectorAll('.session:not(.observed)');
4811
- if (allSessions.length > 5 && !document.body.classList.contains('show-all-sessions')) {
4812
- btn.style.display = 'block';
4813
- btn.textContent = 'Show all sessions (' + (allSessions.length - 5) + ' more)';
4814
- } else {
4815
- btn.style.display = 'none';
4816
- }
4817
- }
4818
- const showMoreBtn = document.getElementById('show-more-sessions');
4819
- if (showMoreBtn) showMoreBtn.addEventListener('click', function() {
4820
- document.body.classList.add('show-all-sessions');
4821
- updateShowMoreButton();
4822
- });
4823
-
4824
- // Stop all running sessions — one click kills every active session.
4825
- const stopAllBtn = document.getElementById('stop-all-sessions');
4826
- function updateStopAllButton() {
4827
- if (!stopAllBtn) return;
4828
- const runningCards = sessionsEl.querySelectorAll('.session[data-status="running"]');
4829
- stopAllBtn.style.display = runningCards.length > 1 ? 'block' : 'none';
4830
- stopAllBtn.textContent = 'Stop all running (' + runningCards.length + ')';
4831
- }
4832
- if (stopAllBtn) stopAllBtn.addEventListener('click', function() {
4833
- if (!confirm('Stop all running sessions?')) return;
4834
- const runningCards = sessionsEl.querySelectorAll('.session[data-status="running"]');
4835
- runningCards.forEach(function(card) {
4836
- const killBtn = card.querySelector('.session-controls button.kill');
4837
- if (killBtn && !killBtn.disabled) killBtn.click();
4838
- });
4839
- updateStopAllButton();
4840
- });
4841
- loadSessions();
4842
- // Refresh detected-agents list + banner every 5s. Iter 12 — also patches
4843
- // existing session cards with new finalReport prose when it arrives async.
4844
- // Cheap re-render (innerHTML on a small div); non-technical user sees the
4845
- // report appear on its own, no manual refresh.
4846
- setInterval(() => {
4847
- fetch('/api/tasks').then(r => r.json()).then(d => {
4848
- const seen = new Set();
4849
- for (const a of (d.activeAgents || [])) {
4850
- seen.add('agent:' + a.pid);
4851
- renderActiveAgent(a);
4852
- }
4853
- pruneStaleAgents(seen);
4854
- updateBanner(d);
4855
- // Patch existing session cards with finalReport when it arrives.
4856
- for (const s of (d.spawned || d.sessions || [])) {
4857
- const entry = sessions.get(s.sessionId);
4858
- if (entry && entry.renderArtifacts) {
4859
- entry.renderArtifacts(s.artifacts, s.finalReport, s.status, s.completedAt);
4860
- }
4861
- }
4862
- }).catch(() => { /* fine */ });
4863
- }, 5000);
4864
-
4865
- // ── F1a readiness banner ─────────────────────────────────────────────
4866
- const readinessEl = document.getElementById('readiness');
4867
-
4868
- function renderReadiness(report) {
4869
- // Hide the banner unless the state is genuinely actionable. May 5 2026
4870
- // design pass: the prior api_key_shadow state surfaced a yellow warning
4871
- // banner with shell-grep jargon for an environment condition that ISNT
4872
- // a problem -- codehere already uses subscription auth and ignores the
4873
- // ANTHROPIC_API_KEY env var by design. Per claim_user_persona (HIGH
4874
- // non-technical-first) + the Open-Agents production-grade design
4875
- // reference, only show the banner when the user can / must act on it
4876
- // (needs_install, needs_auth, billed_key_required). Other states stay
4877
- // observable in /api/readiness for tooling.
4878
- const ACTIONABLE_STATES = new Set(['needs_install', 'needs_auth', 'billed_key_required']);
4879
- if (!report || !ACTIONABLE_STATES.has(report.state)) {
4880
- readinessEl.style.display = 'none';
4881
- return;
4882
- }
4883
- readinessEl.style.display = 'block';
4884
- readinessEl.setAttribute('data-severity', report.severity);
4885
- let actionHtml = '';
4886
- if (report.action) {
4887
- if (report.action.command) {
4888
- actionHtml += '<code>' + escape(report.action.command) + '</code>';
4889
- }
4890
- if (report.action.url) {
4891
- actionHtml += ' <a href="' + escape(report.action.url) + '" target="_blank" rel="noreferrer">' + escape(report.action.label || 'Open') + ' →</a>';
4892
- }
4893
- }
4894
- actionHtml += ' <button id="readiness-refresh">Recheck</button>';
4895
- readinessEl.innerHTML =
4896
- '<div class="r-title">' + escape(report.title) + '</div>' +
4897
- '<div class="r-detail">' + escape(report.detail) + '</div>' +
4898
- '<div class="r-action">' + actionHtml + '</div>';
4899
- const btn = document.getElementById('readiness-refresh');
4900
- if (btn) btn.addEventListener('click', checkReadiness);
4901
- }
4902
-
4903
- function checkReadiness() {
4904
- fetch('/api/readiness').then(r => r.json()).then(renderReadiness).catch(() => {
4905
- readinessEl.style.display = 'none';
4906
- });
4907
- }
4908
-
4909
- checkReadiness();
4910
-
4911
- // ── Failover offer banner (P0) ──────────────────────────────────
4912
- // Polls /api/failover-offers every 5s. When offers exist, shows a
4913
- // banner: "Claude rate-limited — switch to Codex?" with one-click
4914
- // accept (re-spawns on the target agent) and dismiss buttons.
4915
- const fbEl = document.getElementById('failover-banner');
4916
- let fbDismissed = false;
4917
-
4918
- function renderFailoverOffers(offers) {
4919
- if (!offers || offers.length === 0 || fbDismissed) {
4920
- fbEl.style.display = 'none';
4921
- return;
4922
- }
4923
- fbEl.style.display = 'flex';
4924
- const parts = offers.map(function(o) {
4925
- var msg = '<b>' + escape(o.signal.fromAgent) + '</b> hit ' + escape(o.signal.errorCode) +
4926
- ' — switch to <b>' + escape(o.target) + '</b>?';
4927
- var actions = '<div class="fb-actions">' +
4928
- '<button class="fb-accept" data-session="' + escape(o.sessionId) + '" data-target="' + escape(o.target) + '">Switch</button>' +
4929
- '<button class="fb-dismiss" data-session="' + escape(o.sessionId) + '">Dismiss</button>' +
4930
- '</div>';
4931
- return '<div class="fb-msg">' + msg + '</div>' + actions;
4932
- });
4933
- fbEl.innerHTML = parts.join('');
4934
-
4935
- // Wire accept buttons
4936
- fbEl.querySelectorAll('.fb-accept').forEach(function(btn) {
4937
- btn.addEventListener('click', function() {
4938
- var sessionId = btn.dataset.session;
4939
- var target = btn.dataset.target;
4940
- btn.disabled = true;
4941
- btn.textContent = 'Switching…';
4942
- fetch('/api/failover-accept', {
4943
- method: 'POST',
4944
- headers: { 'Content-Type': 'application/json' },
4945
- body: JSON.stringify({ sessionId: sessionId, targetAgent: target }),
4946
- }).then(function(r) { return r.json(); }).then(function(d) {
4947
- if (d.error) {
4948
- fbEl.innerHTML = '<div class="fb-msg"><b>Failover failed:</b> ' + escape(d.error) + (d.detail ? ' — ' + escape(d.detail) : '') + '</div>';
4949
- return;
4950
- }
4951
- if (!d.ok || !d.newSessionId) {
4952
- fbEl.innerHTML = '<div class="fb-msg"><b>Failover unavailable:</b> ' + escape(d.detail || 'Target agent not ready') + '</div>';
4953
- return;
4954
- }
4955
- // Session threading: add "continued as" link on the original card
4956
- var origCard = document.querySelector('[data-session-id="' + escape(sessionId) + '"]');
4957
- if (origCard && !origCard.querySelector('.session-continued-as')) {
4958
- var headEl = origCard.querySelector('.session-head');
4959
- if (headEl && headEl.parentNode) {
4960
- var continuedAsEl = document.createElement('div');
4961
- continuedAsEl.className = 'session-continued-as';
4962
- continuedAsEl.innerHTML = '↻ Continued as <a href="#' + escape(d.newSessionId) + '">' + escape(target) + '</a> · session ' + escape(d.newSessionId.slice(0, 8));
4963
- headEl.parentNode.insertBefore(continuedAsEl, headEl.nextSibling);
4964
- }
4965
- }
4966
- fbEl.innerHTML = '<div class="fb-msg">↻ Switched to <b>' + escape(target) + '</b>. New session: <b>' + escape(d.newSessionId.slice(0, 8)) + '</b></div>' +
4967
- '<div class="fb-actions"><button class="fb-dismiss" id="fb-success-dismiss">OK</button></div>';
4968
- document.getElementById('fb-success-dismiss').addEventListener('click', function() {
4969
- fbDismissed = true;
4970
- fbEl.style.display = 'none';
4971
- });
4972
- }).catch(function() {
4973
- fbEl.innerHTML = '<div class="fb-msg"><b>Failover failed:</b> Network error. Check if the daemon is running.</div>';
4974
- });
4975
- });
4976
- });
4977
-
4978
- // Wire dismiss buttons (initial render)
4979
- fbEl.querySelectorAll('.fb-dismiss').forEach(function(btn) {
4980
- btn.addEventListener('click', function() {
4981
- fbDismissed = true;
4982
- fbEl.style.display = 'none';
4983
- });
4984
- });
4985
- }
4986
-
4987
- setInterval(function() {
4988
- fetch('/api/failover-offers').then(function(r) { return r.json(); }).then(function(d) {
4989
- renderFailoverOffers(d.offers);
4990
- }).catch(function() { /* fine */ });
4991
- }, 5000);
4992
- })();
4993
- </script>
4994
- </body>
4995
- </html>`;
4996
1709
  /**
4997
1710
  * Project thread view — v0.6.0 M1 phase 1.
4998
1711
  *