toolroll 0.9.73 → 0.9.75

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 (145) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/THIRD_PARTY_NOTICES.md +40 -0
  3. package/dist/agent-turns.js +5 -0
  4. package/dist/agents-live.d.ts +31 -0
  5. package/dist/agents-live.js +43 -0
  6. package/dist/assignment.d.ts +3 -0
  7. package/dist/assignment.js +27 -8
  8. package/dist/browser/THIRD_PARTY_NOTICES.txt +40 -0
  9. package/dist/browser/workspace.css +2 -2
  10. package/dist/browser/workspace.js +31 -30
  11. package/dist/browser-workspace.d.ts +7 -1
  12. package/dist/builder.js +1 -1
  13. package/dist/chat-acceptance.d.ts +4 -1
  14. package/dist/chat-acceptance.js +13 -3
  15. package/dist/chat-continuity.js +10 -0
  16. package/dist/chat-quiet.js +3 -3
  17. package/dist/cli.js +1 -1
  18. package/dist/coding-handoff.js +4 -3
  19. package/dist/coding-ui.js +2 -2
  20. package/dist/coding-workspace.js +16 -4
  21. package/dist/contracts/console-api.d.ts +5 -1
  22. package/dist/contracts/console-api.js +10 -0
  23. package/dist/control-ui.d.ts +3 -2
  24. package/dist/control-ui.js +7 -2
  25. package/dist/demo.js +5 -3
  26. package/dist/deploy-gate.js +5 -2
  27. package/dist/desktop-update.d.ts +4 -1
  28. package/dist/desktop-update.js +43 -9
  29. package/dist/dispose.js +35 -2
  30. package/dist/exec.js +13 -16
  31. package/dist/first-run.d.ts +20 -1
  32. package/dist/first-run.js +71 -0
  33. package/dist/flow-card-task.js +3 -2
  34. package/dist/flow-unattended.d.ts +2 -0
  35. package/dist/flow-unattended.js +12 -1
  36. package/dist/integrations.d.ts +4 -3
  37. package/dist/integrations.js +5 -6
  38. package/dist/invoke.d.ts +13 -0
  39. package/dist/invoke.js +21 -12
  40. package/dist/knowledge-ui.d.ts +4 -1
  41. package/dist/knowledge-ui.js +10 -5
  42. package/dist/lead-contract.d.ts +1 -1
  43. package/dist/lead-contract.js +2 -2
  44. package/dist/lead-follow.js +3 -1
  45. package/dist/lead-progress.d.ts +4 -1
  46. package/dist/lead-progress.js +3 -1
  47. package/dist/lead.js +4 -0
  48. package/dist/live-bus.d.ts +2 -0
  49. package/dist/live-bus.js +1 -0
  50. package/dist/live-children-read.d.ts +52 -0
  51. package/dist/live-children-read.js +333 -0
  52. package/dist/live-children.d.ts +73 -0
  53. package/dist/live-children.js +318 -0
  54. package/dist/live-detail.d.ts +4 -3
  55. package/dist/live-detail.js +9 -5
  56. package/dist/live-input.js +7 -8
  57. package/dist/live-main-read.d.ts +19 -0
  58. package/dist/live-main-read.js +91 -0
  59. package/dist/live-open.d.ts +28 -0
  60. package/dist/live-open.js +125 -0
  61. package/dist/live-records.d.ts +10 -1
  62. package/dist/live-records.js +16 -5
  63. package/dist/live.d.ts +54 -22
  64. package/dist/live.js +218 -69
  65. package/dist/openrouter-models.d.ts +5 -2
  66. package/dist/openrouter-models.js +7 -4
  67. package/dist/operate.js +5 -2
  68. package/dist/peek-cli.d.ts +10 -3
  69. package/dist/peek-cli.js +17 -8
  70. package/dist/planner.js +1 -1
  71. package/dist/project-learning.js +44 -6
  72. package/dist/project-tools.js +19 -1
  73. package/dist/provider-limits.d.ts +3 -1
  74. package/dist/provider-limits.js +5 -2
  75. package/dist/provider.js +6 -12
  76. package/dist/pull-request-ui.d.ts +1 -1
  77. package/dist/pull-request-ui.js +5 -1
  78. package/dist/push.d.ts +2 -0
  79. package/dist/push.js +24 -19
  80. package/dist/read-only-request.d.ts +4 -0
  81. package/dist/read-only-request.js +15 -0
  82. package/dist/release-adapters.js +4 -0
  83. package/dist/release.d.ts +3 -0
  84. package/dist/release.js +3 -0
  85. package/dist/repository-context-ui.js +2 -1
  86. package/dist/repository-context.js +6 -1
  87. package/dist/result-follow-ups.js +20 -1
  88. package/dist/result-review.js +5 -1
  89. package/dist/run-efficiency.d.ts +3 -3
  90. package/dist/run-efficiency.js +20 -8
  91. package/dist/scout.d.ts +3 -0
  92. package/dist/scout.js +5 -3
  93. package/dist/serve.js +196 -29
  94. package/dist/server/agents-live-filed.d.ts +17 -0
  95. package/dist/server/agents-live-filed.js +97 -0
  96. package/dist/server/agents-live-lead.d.ts +47 -0
  97. package/dist/server/agents-live-lead.js +116 -0
  98. package/dist/server/agents-live-room.d.ts +23 -0
  99. package/dist/server/agents-live-room.js +91 -0
  100. package/dist/server/chat.js +115 -53
  101. package/dist/server/chrome.js +2 -2
  102. package/dist/server/console.css +68 -40
  103. package/dist/server/http.d.ts +5 -0
  104. package/dist/server/last-checks.d.ts +17 -0
  105. package/dist/server/last-checks.js +22 -0
  106. package/dist/server/live.d.ts +4 -0
  107. package/dist/server/live.js +42 -2
  108. package/dist/server/pages.js +98 -4
  109. package/dist/server/people-tokens.js +7 -4
  110. package/dist/server/render-chat.d.ts +15 -1
  111. package/dist/server/render-chat.js +14 -7
  112. package/dist/server/render-pages.d.ts +10 -5
  113. package/dist/server/render-pages.js +83 -74
  114. package/dist/server/render-results.d.ts +11 -2
  115. package/dist/server/render-results.js +54 -12
  116. package/dist/server/render-settings.js +3 -3
  117. package/dist/server/render-tasks.js +13 -3
  118. package/dist/server/route-table.js +19 -2
  119. package/dist/server/runtime.d.ts +17 -2
  120. package/dist/server/settings.js +85 -32
  121. package/dist/server/task-views.js +1 -2
  122. package/dist/server/tasks.js +70 -43
  123. package/dist/storage-ui.d.ts +2 -0
  124. package/dist/storage-ui.js +1 -1
  125. package/dist/storage.js +2 -1
  126. package/dist/store.d.ts +34 -1
  127. package/dist/store.js +108 -11
  128. package/dist/stream-scope.d.ts +18 -0
  129. package/dist/stream-scope.js +28 -0
  130. package/dist/subagent-work.js +3 -0
  131. package/dist/subscription-chat.js +5 -3
  132. package/dist/task-status.d.ts +28 -1
  133. package/dist/task-status.js +93 -11
  134. package/dist/teardown-git.d.ts +27 -1
  135. package/dist/teardown-git.js +185 -39
  136. package/dist/telegram-progress.js +4 -4
  137. package/dist/telegram-status.js +8 -3
  138. package/dist/tool-secret-store.js +8 -0
  139. package/dist/toolroll-update-ui.d.ts +2 -0
  140. package/dist/toolroll-update-ui.js +4 -3
  141. package/dist/toolroll-update.js +10 -9
  142. package/dist/work-index.js +29 -12
  143. package/dist/workspace-ui.d.ts +5 -0
  144. package/dist/workspace-ui.js +38 -10
  145. package/package.json +1 -1
@@ -172,6 +172,30 @@
172
172
  color: color-mix(in srgb, var(--running) 72%, var(--foreground)); border-color: var(--border);
173
173
  }
174
174
  .badge-cut { background: var(--muted); }
175
+ /* The status chip (components/ui/badge.tsx `size="chip"`, after ReUI's light badge with a dot): 20px, 6px
176
+ corners, a soft wash, 11.5px words and a 6px dot. Needs you is the amber wash with its words leaned toward ink
177
+ (4.5:1); the dot is decoration and the word carries the meaning. */
178
+ .chip {
179
+ display: inline-flex; align-items: center; gap: .375rem; height: 1.25rem; padding: 0 .375rem; border-radius: var(--so-radius-sm);
180
+ font-family: var(--font-sans); font-size: .71875rem; font-weight: 500; line-height: 1; white-space: nowrap; vertical-align: middle;
181
+ background: var(--so-neutral-soft); color: var(--so-ink);
182
+ }
183
+ /* Ink words, the tone in the dot (the Agents → Live chip); Needs you keeps amber words leaned toward ink. */
184
+ .chip-dot { width: .375rem; height: .375rem; flex-shrink: 0; border-radius: var(--so-radius-full); background: var(--so-neutral-ink); }
185
+ .chip-needs { background: var(--so-warning-soft); color: color-mix(in oklab, var(--so-warning) 85%, var(--so-ink)); }
186
+ .chip-needs .chip-dot { background: var(--so-warning); }
187
+ .chip-live { background: var(--so-info-soft); }
188
+ .chip-live .chip-dot { background: var(--so-info); }
189
+ .chip-done { background: var(--so-success-soft); }
190
+ .chip-done .chip-dot { background: var(--so-success); }
191
+ .chip-failed { background: var(--so-danger-soft); }
192
+ .chip-failed .chip-dot { background: var(--so-danger); }
193
+ /* The frame (components/ui/frame.tsx, after ReUI's Frame): a raised well with a hairline, its heading on the
194
+ well's fill, and paper panels whose corners sit concentric inside it. No shadow on a panel. */
195
+ .frame { display: flex; flex-direction: column; gap: .25rem; padding: .25rem; border: 1px solid var(--so-line); border-radius: var(--so-radius-card); background: var(--so-raised); }
196
+ .frame-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem; min-height: 2rem; padding: .25rem .625rem; }
197
+ .frame-panel { position: relative; border: 1px solid var(--so-line); border-radius: calc(var(--so-radius-card) - .25rem - 1px); background: var(--so-paper); padding: .75rem .875rem; }
198
+ .frame-panel.waits { border-color: var(--so-warning); }
175
199
 
176
200
  .card {
177
201
  border: 1px solid var(--glass-border); border-radius: var(--radius); background: var(--glass);
@@ -1749,13 +1773,13 @@
1749
1773
  says which way it folds. */
1750
1774
  @media (max-width: 760px) {
1751
1775
  .board { display: flex; flex-direction: column; align-items: stretch; gap: .5rem; padding-bottom: 0; }
1752
- .board .lane { min-height: 0; padding: 0 .625rem .125rem; }
1776
+ .board .lane { min-height: 0; padding: 0 .25rem .25rem; }
1753
1777
  .board .lane > summary {
1754
1778
  cursor: pointer; display: flex; align-items: center; min-height: 2.75rem; margin: 0;
1755
1779
  -webkit-tap-highlight-color: transparent;
1756
1780
  }
1757
- .board .lane > summary h2 { flex: 1; font-size: .75rem; }
1758
- .board .lane > summary .lane-count { border: 1px solid var(--border); border-radius: 999px; padding: .04rem .5rem; margin-left: .25rem; }
1781
+ .board .lane > summary { padding: 0 .375rem; }
1782
+ .board .lane > summary h2 { flex: 1; }
1759
1783
  .board .lane > summary::after {
1760
1784
  content: ""; width: .5rem; height: .5rem; flex: none; margin-right: .25rem;
1761
1785
  border-right: 1.5px solid var(--muted-foreground); border-bottom: 1.5px solid var(--muted-foreground);
@@ -1763,63 +1787,66 @@
1763
1787
  }
1764
1788
  .board .lane[open] > summary::after { transform: rotate(225deg) translateY(-.125rem); }
1765
1789
  .board .lane .hint { margin-bottom: .25rem; }
1766
- .board .lane .lane-card:last-of-type { margin-bottom: .5rem; }
1790
+ .board .lane .lane-card:last-of-type { margin-bottom: 0; }
1767
1791
  .board .lane-attention { order: 0; }
1768
1792
  .board .lane-building { order: 1; }
1769
1793
  .board .lane-queued { order: 2; }
1770
1794
  .board .lane-waiting { order: 3; }
1771
1795
  .board .lane-done { order: 4; }
1772
1796
  }
1797
+ /* A lane is a frame (components/ui/frame.tsx, after ReUI's Frame): a raised well with a hairline, its head a
1798
+ status chip and a mono count on the well's fill, its cards paper panels at concentric corners. Only the lane
1799
+ that waits on a person is amber: its chip, and each card's thin full outline. No fill, no stripe, no shadow. */
1773
1800
  .lane {
1774
- background: color-mix(in srgb, var(--card) 45%, var(--background)); border: 1px solid var(--border);
1775
- border-radius: var(--radius); padding: .625rem; min-height: 12rem;
1801
+ background: var(--so-raised); border: 1px solid var(--so-line);
1802
+ border-radius: var(--so-radius-card); padding: .25rem; min-height: 12rem;
1776
1803
  }
1777
- .lane > summary { list-style: none; cursor: default; margin: 0 0 .125rem; }
1804
+ .lane > summary { list-style: none; cursor: default; margin: 0; padding: .375rem .375rem .125rem; }
1778
1805
  .lane > summary::-webkit-details-marker { display: none; }
1779
- .lane h2 { display: flex; align-items: center; gap: .4rem; margin: 0; font-size: .6875rem; }
1806
+ .lane h2 { display: flex; align-items: center; gap: .5rem; margin: 0; font-size: .8125rem; font-weight: 600; }
1780
1807
  .lane h2 a { color: inherit; text-decoration: none; }
1781
1808
  @media (hover: hover) and (pointer: fine) { .lane h2 a:hover { text-decoration: underline; } }
1782
- .lane .hint { margin-top: 0; }
1783
- .lane-count { color: var(--muted-foreground); font-weight: 400; font-variant-numeric: tabular-nums; }
1784
- .lane h2::before {
1785
- content: ""; width: .375rem; height: .375rem; border-radius: var(--so-radius-full); flex: none;
1786
- background: var(--muted-foreground); opacity: .55;
1787
- }
1788
- .lane-attention h2::before { background: var(--brand); opacity: 1; }
1789
- .lane-building h2::before { background: var(--running); opacity: 1; }
1790
- .lane-done h2::before { background: var(--muted-foreground); opacity: 1; }
1809
+ .lane .hint { margin: 0; padding: .125rem .375rem .375rem; }
1810
+ .lane-count { color: var(--muted-foreground); font-family: var(--font-mono); font-size: .6875rem; font-weight: 500; font-variant-numeric: tabular-nums; }
1791
1811
  .lane-card {
1792
- display: block; text-decoration: none; color: inherit;
1793
- background: var(--card); border: 1px solid var(--border);
1794
- border-radius: calc(var(--radius) - 2px); padding: .5rem .625rem; margin-top: .5rem;
1795
- }
1796
- @media (hover: hover) and (pointer: fine) { .lane-card:hover { border-color: color-mix(in srgb, var(--border) 55%, var(--muted-foreground)); } }
1797
- .lane-card .id { display: block; font-family: var(--font-mono); font-size: .6875rem; color: var(--muted-foreground); margin-bottom: .125rem; overflow-wrap: anywhere; }
1798
- .lane-card .t { display: block; font-size: .8125rem; font-weight: 500; line-height: 1.35; }
1812
+ display: flex; flex-direction: column; text-decoration: none; color: inherit;
1813
+ background: var(--so-paper); border: 1px solid var(--so-line);
1814
+ border-radius: calc(var(--so-radius-card) - .25rem - 1px); padding: .625rem .75rem; margin-top: .25rem;
1815
+ }
1816
+ .lane-attention .lane-card { border-color: var(--so-warning); }
1817
+ /* The order view's columns are frames too, with roomier insides (a form, a note) and a quiet dot by the name. */
1818
+ .lane.queue-column { padding: .5rem; }
1819
+ .queue-column > h2::before { content: ""; width: .375rem; height: .375rem; border-radius: var(--so-radius-full); flex: none; background: var(--muted-foreground); opacity: .55; }
1820
+ @media (hover: hover) and (pointer: fine) { .lane-card:hover { background: var(--so-raised); } }
1821
+ /* The title leads; the task id is a machine fact under it, in mono. */
1822
+ .lane-card .t { display: block; font-size: .84375rem; font-weight: 500; line-height: 1.35; }
1823
+ .lane-card .why { display: block; margin-top: .125rem; font-size: .75rem; color: var(--muted-foreground); }
1824
+ .lane-card .id { display: block; margin-top: .375rem; font-family: var(--font-mono); font-size: .75rem; color: var(--muted-foreground); overflow-wrap: anywhere; }
1825
+ .lane-card .who { display: block; margin-top: .125rem; font-size: .75rem; color: var(--muted-foreground); overflow-wrap: anywhere; }
1826
+ .lane-card .who .mono { display: inline; margin: 0; font-family: var(--font-mono); font-size: .6875rem; }
1799
1827
  .lane-card .dot { margin-right: .4rem; }
1800
1828
  .lane-card .meta, .lane-card .mono { display: block; margin-top: .125rem; font-size: .75rem; }
1801
- .lane-card .why { display: block; margin-top: .125rem; font-size: .75rem; color: var(--muted-foreground); }
1802
- /* The facts: mono key–value pairs, keys dim, values ink — one grammar on every lane. */
1803
- .lane-card .facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .0625rem .625rem; margin-top: .4rem; font-size: .6875rem; line-height: 1.5; }
1829
+ /* The facts: a muted word, then the value; only a machine's own words (a name, a model, a branch, a clock) are mono. */
1830
+ .lane-card .facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .0625rem .625rem; margin-top: .25rem; font-size: .75rem; line-height: 1.5; }
1804
1831
  .lane-card .fact { display: contents; }
1805
- .lane-card .fact .k { color: var(--muted-foreground); font-family: var(--font-mono); }
1806
- .lane-card .fact .v { font-family: var(--font-mono); color: var(--foreground); overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
1807
- .lane-card .chips { display: flex; flex-wrap: wrap; gap: .25rem; margin-top: .4rem; }
1832
+ .lane-card .fact .k { color: var(--muted-foreground); }
1833
+ .lane-card .fact .v { color: var(--foreground); overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
1834
+ .lane-card .fact .v.mono { display: inline; margin: 0; font-family: var(--font-mono); font-size: .6875rem; }
1835
+ .lane-card .chips { display: flex; flex-wrap: wrap; gap: .25rem; margin-top: .5rem; }
1808
1836
  .lane-card .chips .badge { margin: 0; }
1809
- /* The live strip on a building card: stage and clock in an inset well —
1810
- the run's own facts, never a percent. */
1837
+ /* The live strip on a building card: the stage in words, the clock in mono, on a sunken well. */
1811
1838
  .lane-card .live-line {
1812
1839
  display: flex; align-items: center; justify-content: space-between; gap: .5rem; margin-top: .375rem;
1813
- padding: .3rem .5rem; border-radius: calc(var(--radius) - 4px); background: var(--muted);
1814
- font-family: var(--font-mono); font-size: .6875rem; color: var(--running); font-variant-numeric: tabular-nums;
1840
+ padding: .3rem .5rem; border-radius: var(--so-radius-sm); background: var(--so-raised);
1841
+ font-size: .75rem; color: var(--so-info);
1815
1842
  }
1816
- .lane-card .live-line .clock { color: var(--muted-foreground); }
1843
+ .lane-card .live-line .clock { color: var(--muted-foreground); font-family: var(--font-mono); font-size: .6875rem; font-variant-numeric: tabular-nums; }
1817
1844
  /* The build's steps — set up, writing, checking and committing: three quiet
1818
1845
  ticks, the current one in the running hue. A stage, never a percent. */
1819
1846
  .lane-card .steps { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .1875rem; margin-top: .5rem; }
1820
- .lane-card .steps i { height: .1875rem; border-radius: 999px; background: var(--border); }
1821
- .lane-card .steps i.on { background: color-mix(in srgb, var(--running) 45%, var(--border)); }
1822
- .lane-card .steps i.now { background: var(--running); }
1847
+ .lane-card .steps i { height: .1875rem; border-radius: var(--so-radius-full); background: var(--so-line); }
1848
+ .lane-card .steps i.on { background: color-mix(in srgb, var(--so-info) 45%, var(--so-line)); }
1849
+ .lane-card .steps i.now { background: var(--so-info); }
1823
1850
 
1824
1851
  /* The worker strip: one quiet tile per worker above the lanes — what it is
1825
1852
  building here, the stage, the clock. Ink and lines only; the running dot
@@ -2613,8 +2640,9 @@ button { min-height: 44px; }
2613
2640
  .chat-workspace.projects-open::before { animation: fade 180ms ease-out; }
2614
2641
  .switcher[open] .switcher-menu { animation: rise 160ms ease-out; }
2615
2642
  @media (hover: hover) and (pointer: fine) {
2616
- .lane-card, .decide-card, .menu-row, .chat-overview-item, .proposal { transition: border-color .15s, transform .15s, box-shadow .15s; }
2617
- .lane-card:hover, .decide-card:hover, .menu-row:hover {
2643
+ .lane-card, .decide-card, .menu-row, .chat-overview-item, .proposal { transition: border-color .15s, background-color .15s, transform .15s, box-shadow .15s; }
2644
+ /* Board cards are panels in a frame: a hover fills them raised, never a shadow. */
2645
+ .decide-card:hover, .menu-row:hover {
2618
2646
 
2619
2647
  box-shadow: 0 2px 8px -2px rgb(0 0 0 / .35);
2620
2648
  }
@@ -165,6 +165,11 @@ export type ServeOptions = {
165
165
  * the exact part of the ceiling, so an addition or removal shows without a restart. */
166
166
  admittedRepos?: () => readonly string[];
167
167
  connectionProbe?: typeof execRun;
168
+ /** The console's background checks (docs/plans/get-side-effects.md): sign-in checks, integration checks, model
169
+ * lists and the tidy-ups pages used to do. On for `up` and `serve`; a test turns them on, and `backgroundChecksMs` sets
170
+ * how often they run and ask this computer's agents again. */
171
+ backgroundChecks?: boolean;
172
+ backgroundChecksMs?: number;
168
173
  connectionHome?: string;
169
174
  /** Test seam for Chat's first tasks: the `gh` and `git grep` reads. */
170
175
  firstTaskRunner?: typeof execRun;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The last answer of each check a page shows (docs/plans/get-side-effects.md). A page reads `known`; the background
3
+ * checks and a person's Check now (a POST) call `run`. One per console, held in memory: a restart asks again.
4
+ */
5
+ export type LastCheck<T> = {
6
+ at: number;
7
+ value: T;
8
+ };
9
+ export declare class LastChecks {
10
+ private readonly answers;
11
+ private readonly running;
12
+ known<T>(key: string): LastCheck<T> | null;
13
+ /** Whether the last answer is older than `everyMs` (or missing). */
14
+ due(key: string, everyMs: number, now?: number): boolean;
15
+ /** Run the check (one at a time per key) and keep its answer. */
16
+ run<T>(key: string, check: () => Promise<T>): Promise<T>;
17
+ }
@@ -0,0 +1,22 @@
1
+ export class LastChecks {
2
+ answers = new Map();
3
+ running = new Map();
4
+ known(key) {
5
+ return this.answers.get(key) ?? null;
6
+ }
7
+ /** Whether the last answer is older than `everyMs` (or missing). */
8
+ due(key, everyMs, now = Date.now()) {
9
+ const last = this.answers.get(key);
10
+ return last === undefined || now - last.at >= everyMs;
11
+ }
12
+ /** Run the check (one at a time per key) and keep its answer. */
13
+ run(key, check) {
14
+ const pending = this.running.get(key);
15
+ if (pending !== undefined)
16
+ return pending;
17
+ const started = check().then(value => { this.answers.set(key, { at: Date.now(), value }); return value; })
18
+ .finally(() => { this.running.delete(key); });
19
+ this.running.set(key, started);
20
+ return started;
21
+ }
22
+ }
@@ -45,6 +45,10 @@ export type LiveRoom = {
45
45
  } | {
46
46
  kind: "agent";
47
47
  run: number;
48
+ }
49
+ /** The Agents view's Live grid (agents-live-room.ts): per viewer, watching only. */
50
+ | {
51
+ kind: "agents-live";
48
52
  };
49
53
  /** A room's name, read strictly: anything else is not a room. */
50
54
  export declare function parseLiveRoom(name: string): LiveRoom | null;
@@ -9,9 +9,11 @@ import { handlersOf } from "./handler-registry.js";
9
9
  import { respond } from "./http.js";
10
10
  import { adapterPolicy } from "./route-policy.js";
11
11
  import { requestContext } from "./request-context.js";
12
+ import { createAgentsLiveRooms } from "./agents-live-room.js";
13
+ import { leadTilesFor } from "./agents-live-lead.js";
12
14
  /** A page asks for a few rooms at once: its workspace, what it shows, its conversation. */
13
15
  export const LIVE_ROOMS_PER_PAGE = 6;
14
- const ROOM_NAME = /^(workspace|task|flow|chat|team|agent|summary)(?::([^?]{1,400}))?(?:\?([^#]{0,600}))?$/;
16
+ const ROOM_NAME = /^(workspace|task|flow|chat|team|agent|summary|agents-live)(?::([^?]{1,400}))?(?:\?([^#]{0,600}))?$/;
15
17
  /** At most one workspace nudge a second: a burst of writes is one re-read, the first heard at once. */
16
18
  const WORKSPACE_SETTLE_MS = 1_000;
17
19
  /** A running reply's preview: at most one snapshot every 80 ms; the last one always goes out. */
@@ -34,6 +36,7 @@ export function parseLiveRoom(name) {
34
36
  switch (kind) {
35
37
  case "workspace": return key === null && query === undefined ? { kind } : null;
36
38
  case "summary": return key === null && query === undefined ? { kind } : null;
39
+ case "agents-live": return key === null && query === undefined ? { kind } : null;
37
40
  case "task": return key !== null && key !== "" && query === undefined ? { kind, task: key } : null;
38
41
  case "flow": {
39
42
  const flow = Number(key), card = params.get("card");
@@ -92,6 +95,11 @@ export function createLiveHandlers(runtime, live) {
92
95
  const chatViewers = new Set();
93
96
  // One agent's open view (Agents board): what it shows, keyed by the exact run.
94
97
  const agentRooms = createLiveRooms(run => agentRunFingerprint(store, run), null, { bus: live.bus });
98
+ // The Live grid: one room per viewer and scope, watching only (no lease, no seen mark).
99
+ // Each Live page's own lead tiles, read as its viewer's saved session now (a page's room key carries the page).
100
+ const liveLeads = new Map();
101
+ const agentsLive = createAgentsLiveRooms({ store, bus: live.bus, evidenceRoot: runtime.evidenceRoot, localRunner: runtime.options.localRunner, clock, visible,
102
+ leads: key => liveLeads.get(key)?.() ?? null, forget: key => liveLeads.delete(key) });
95
103
  const readBrowser = (request, who) => rereadBrowser(runtime, request, who);
96
104
  const sameBrowser = (request, who) => () => readBrowser(request, who) !== null;
97
105
  function admitted(viewer, thread) {
@@ -196,10 +204,11 @@ export function createLiveHandlers(runtime, live) {
196
204
  if ([...url.searchParams.keys()].some(key => key !== "room") || names.length === 0 || names.length > LIVE_ROOMS_PER_PAGE || new Set(names).size !== names.length)
197
205
  return json(response, 400, { error: "room" });
198
206
  const rooms = names.map(name => ({ name, room: parseLiveRoom(name) }));
199
- if (rooms.some(one => one.room === null) || rooms.filter(one => one.room?.kind === "agent").length > 1 || rooms.filter(one => one.room?.kind === "workspace").length > 1 || rooms.filter(one => one.room?.kind === "chat").length > 1 || rooms.filter(one => one.room?.kind === "team").length > 1 || rooms.filter(one => one.room?.kind === "summary").length > 1)
207
+ if (rooms.some(one => one.room === null) || rooms.filter(one => one.room?.kind === "agent").length > 1 || rooms.filter(one => one.room?.kind === "workspace").length > 1 || rooms.filter(one => one.room?.kind === "chat").length > 1 || rooms.filter(one => one.room?.kind === "team").length > 1 || rooms.filter(one => one.room?.kind === "summary").length > 1 || rooms.filter(one => one.room?.kind === "agents-live").length > 1)
200
208
  return json(response, 400, { error: "room" });
201
209
  // Admit every room before the stream opens, with the answers each page's own stream gave.
202
210
  const joins = [];
211
+ let refusedJoin = false;
203
212
  for (const { name, room } of rooms) {
204
213
  if (room.kind !== "team" && who.via !== "cookie")
205
214
  return json(response, 403, { error: "session" });
@@ -216,6 +225,33 @@ export function createLiveHandlers(runtime, live) {
216
225
  joins.push(connection => summaryRooms.join(summaryKey(who.name, scope), { name: who.name, room: name, connection,
217
226
  valid: () => signedIn() && live.projectScopeFor(who.name) === scope }));
218
227
  }
228
+ else if (room.kind === "agents-live") {
229
+ const browser = who;
230
+ const scope = live.projectScopeFor(who.name);
231
+ if (scope === null)
232
+ return json(response, 404, { error: "agents-live" });
233
+ const signedIn = sameBrowser(request, browser);
234
+ const valid = () => signedIn() && live.projectScopeFor(who.name) === scope;
235
+ joins.push(connection => {
236
+ // Proved before the room is made or read: an invalid viewer never causes a look at a file.
237
+ let ok = false;
238
+ try {
239
+ ok = valid();
240
+ }
241
+ catch {
242
+ ok = false;
243
+ }
244
+ if (!ok) {
245
+ connection.send(name, "gone");
246
+ refusedJoin = true;
247
+ return;
248
+ }
249
+ // Each page's own room (its key carries this connection): a newcomer is never handed a digest computed earlier.
250
+ const key = agentsLive.keyOf(who.name, scope, randomBytes(9).toString("hex"));
251
+ liveLeads.set(key, () => { const again = readBrowser(request, browser); return again === null ? null : leadTilesFor(runtime, again); });
252
+ agentsLive.rooms.join(key, { name: who.name, room: name, connection, valid });
253
+ });
254
+ }
219
255
  else if (room.kind === "task") {
220
256
  const browser = who;
221
257
  const family = familyOf(room.task);
@@ -342,6 +378,9 @@ export function createLiveHandlers(runtime, live) {
342
378
  for (const join of joins)
343
379
  if (connection.open)
344
380
  join(connection);
381
+ // A page whose only room was refused at its join ends now (`ended`), its keep-alive stopped, not left open.
382
+ if (refusedJoin && connection.rooms === 0)
383
+ connection.refuse(() => connection.close());
345
384
  request.once("close", () => connection.close());
346
385
  }
347
386
  const detachChat = live.bus.subscribe(() => {
@@ -357,6 +396,7 @@ export function createLiveHandlers(runtime, live) {
357
396
  summaryRooms.close();
358
397
  teamRooms.close();
359
398
  agentRooms.close();
399
+ agentsLive.rooms.close();
360
400
  for (const viewer of [...chatViewers])
361
401
  viewer.connection.close();
362
402
  },
@@ -26,6 +26,11 @@ import { LEDGER_CSV_HEADER, ledgerCsvRows } from "../ledger-csv.js";
26
26
  import { ledgerBody } from "../ledger-view.js";
27
27
  import { agentLanesOf, agentRowOf, agentsBoardOf, agentFactsFor, laneWindowOf, sealedDiffOf } from "../agents-board.js";
28
28
  import { markAgentSeen } from "../agent-board.js";
29
+ import { agentsLiveOf } from "../agents-live.js";
30
+ import { readChildWindow } from "../live-children-read.js";
31
+ import { leadFiledLinks } from "./agents-live-filed.js";
32
+ import { readMainWindow } from "../live-main-read.js";
33
+ import { runLiveCommands } from "../store.js";
29
34
  import { prometheusMetrics } from "../metrics.js";
30
35
  import { readMonitoring } from "../monitoring-settings.js";
31
36
  import { targetOf } from "../monitoring.js";
@@ -38,6 +43,7 @@ import { healthWords } from "../server-telemetry.js";
38
43
  import { tally } from "../summary.js";
39
44
  import { serveInstallAsset } from "./pages-assets.js";
40
45
  import { healthz } from "./remote-hooks.js";
46
+ import { leadsStillAdmitted, leadTilesFor } from './agents-live-lead.js';
41
47
  import { refuse, regionScript, screen, settingsRows, WORKSPACE_STYLE, workToolRows } from "./chrome.js";
42
48
  import { redirect, respond, safeReturn, SAFETY, taskHref } from "./http.js";
43
49
  import { assignmentOf } from "../assignment.js";
@@ -46,8 +52,9 @@ import { browsePage, fleetBody, fleetScript, githubIdentityOf, githubReposPage,
46
52
  import { capsPage } from "./render-settings.js";
47
53
  import { decisionAnswerScript, taskBody } from "./render-tasks.js";
48
54
  import {} from "./session.js";
55
+ import { rereadBrowser } from "./live.js";
49
56
  export function createPagesHandlers(runtime) {
50
- const { clock, store, visible, consoleProjects, restricted, ceiling, options, projectFamilyPeek, sendScreen, chromeFor, unscopedMode, admissionList, taskRepoOf, taskViewData, runVisible, evidenceRoot, workAccess, identify, liveCeiling, sessions, deletedRepos, authenticateApprover, authenticateCredential, projectOf, lookupSession } = runtime;
57
+ const { clock, lastChecks, store, visible, consoleProjects, restricted, ceiling, options, projectFamilyPeek, sendScreen, chromeFor, unscopedMode, admissionList, taskRepoOf, taskViewData, runVisible, evidenceRoot, workAccess, identify, liveCeiling, sessions, deletedRepos, authenticateApprover, authenticateCredential, projectOf, lookupSession } = runtime;
51
58
  // The filesystem browser (operator request): pick a project folder by
52
59
  // looking, not typing. CONFINED to exactly what opening allows —
53
60
  // explicit --repo mode has an enumerable list and gets no browser at
@@ -117,7 +124,8 @@ export function createPagesHandlers(runtime) {
117
124
  const { who, response } = ctx;
118
125
  if (store.isDemo())
119
126
  return refuse(response, who, 403, "the sandbox never talks to GitHub");
120
- const listed = await (options.ghList ?? listGithubRepos)();
127
+ // The last listing (docs/plans/get-side-effects.md): List repositories posts; opening the page asks GitHub nothing.
128
+ const listed = lastChecks.known("github-repos")?.value ?? null;
121
129
  // What this machine already has, matched by the clone's own recorded
122
130
  // origin — registered projects first, then the immediate children of
123
131
  // the configured roots. Identities are compared case-insensitively
@@ -136,7 +144,7 @@ export function createPagesHandlers(runtime) {
136
144
  // Roots are walked ONLY when there are listed repos to match against
137
145
  // (finding 1), through an iterator that stops at 400 entries — a
138
146
  // directory with a million children costs 400 stats, not a scan.
139
- if (listed.ok && listed.repos.length > 0) {
147
+ if (listed !== null && listed.ok && listed.repos.length > 0) {
140
148
  for (const root of ceiling.roots) {
141
149
  const children = [];
142
150
  try {
@@ -176,6 +184,14 @@ export function createPagesHandlers(runtime) {
176
184
  openProject: who.via === "cookie" ? who.session.project ?? null : null,
177
185
  }));
178
186
  }
187
+ // List repositories / Refresh list: ask the GitHub CLI signed in on this machine, then show the page.
188
+ async function projectsGithubList(ctx) {
189
+ const { who, response } = ctx;
190
+ if (store.isDemo())
191
+ return refuse(response, who, 403, "the sandbox never talks to GitHub");
192
+ await lastChecks.run("github-repos", () => (options.ghList ?? listGithubRepos)());
193
+ return redirect(response, "/projects/github");
194
+ }
179
195
  async function projectsIndex(ctx) {
180
196
  const { url, who, response } = ctx;
181
197
  return void projectsScreen(response, who, url.searchParams.get("said"), 200, safeReturn(url.searchParams.get("return")));
@@ -444,8 +460,14 @@ export function createPagesHandlers(runtime) {
444
460
  const now = clock();
445
461
  const access = workAccess();
446
462
  const asked = url.searchParams.get("run");
463
+ // The Live grid only watches (docs/plans/agents-view.md §3): it branches off before an opened agent is built, so
464
+ // viewing it never marks a run seen, takes a watch or offers a composer — a reload of ?view=live&run=… included.
465
+ const watching = url.searchParams.get("view") === "live";
466
+ // Live is a browser session's only, like its reads (no token sees the grid).
467
+ if (watching && who.via !== "cookie")
468
+ return respond(response, 403, "text/plain; charset=utf-8", "The Live view is for a signed-in browser.");
447
469
  let selected = null;
448
- if (asked !== null && /^[1-9][0-9]{0,9}$/.test(asked)) {
470
+ if (!watching && asked !== null && /^[1-9][0-9]{0,9}$/.test(asked)) {
449
471
  const run = store.getRun(Number(asked));
450
472
  const facts = run === null || !runVisible(run) ? null : agentFactsFor(store, run.id, access, who.name);
451
473
  if (facts !== null) {
@@ -485,6 +507,11 @@ export function createPagesHandlers(runtime) {
485
507
  };
486
508
  }
487
509
  }
510
+ // The Live grid is drawn in the browser from its own read-only reads (agents-live-view.tsx): no board facts here.
511
+ if (watching) {
512
+ const view = { kind: "agents", groups: [], selected: null, lanes: null, empty: null, live: true };
513
+ return sendScreen(response, 200, screen("Agents", html `<h1>Agents</h1><p class="meta">Live needs JavaScript.</p>`, { chrome: chromeFor(project, "runs"), workspace: { view } }));
514
+ }
488
515
  const groups = agentsBoardOf(store, now, access, who.name, evidenceRoot);
489
516
  const lanes = selected === null ? agentLanesOf(store, now, access, who.name, laneWindowOf(url.searchParams.get("window"))) : null;
490
517
  const view = { kind: "agents", groups, selected, lanes,
@@ -989,9 +1016,74 @@ export function createPagesHandlers(runtime) {
989
1016
  response.setHeader("cache-control", "no-store");
990
1017
  return respond(response, 200, "application/json", JSON.stringify({ proof: createHmac("sha256", Buffer.from(identity, "hex")).update(challenge).digest("hex") }));
991
1018
  }
1019
+ // The Live grid (docs/plans/agents-view.md §3), kept after every other handler.
1020
+ /** The Live grid's tiles (JSON, read-only): every role's running agents and those that just ended, that this viewer
1021
+ * may see — the console's ceiling and the account's own access, proved per run, on every request. */
1022
+ async function agentsLive(ctx) {
1023
+ const { who, request, response } = ctx;
1024
+ response.setHeader("cache-control", "no-store");
1025
+ // A browser session only (the route says so too): live output is never a token's to read.
1026
+ if (who.via !== "cookie")
1027
+ return respond(response, 403, "application/json", JSON.stringify({ error: "session" }));
1028
+ const browser = who;
1029
+ const canSee = (run) => { const repo = taskRepoOf(run.taskRef); return repo !== null && visible(repo) && store.accountCanAccess(who.name, repo); };
1030
+ const tiles = agentsLiveOf(store, clock(), { canSee, localRunner: options.localRunner });
1031
+ // The viewer's own lead turns, admitted as their chat is, from the session as it is saved now.
1032
+ const current = rereadBrowser(runtime, request, browser);
1033
+ const leads = current === null ? [] : leadTilesFor(runtime, current);
1034
+ // Re-proved right before delivery: the session still held, each tile's run still in view from its current row, and
1035
+ // each lead turn still admitted, from the session as it is saved now.
1036
+ const again = current === null ? null : rereadBrowser(runtime, request, browser);
1037
+ if (again === null)
1038
+ return respond(response, 401, "application/json", JSON.stringify({ error: "session" }));
1039
+ const shown = tiles.filter(tile => { const run = store.getRun(tile.runId); return run !== null && canSee(run); });
1040
+ // Work the viewer's own lead filed nests under it (docs/plans/lead-dispatched-tiles.md): proved from the store now,
1041
+ // for the tiles already shown, and only ever for their owner.
1042
+ const filed = again.role === "approver" ? leadFiledLinks(store, again.name, shown.map(tile => tile.runId)) : new Map();
1043
+ return respond(response, 200, "application/json", JSON.stringify({ tiles: shown.map(tile => ({ ...tile, filedBy: filed.get(tile.runId) ?? null })), leads: leadsStillAdmitted(runtime, again, leads) }));
1044
+ }
1045
+ /** This console's key for the Live grid's read cursors: random, in memory only (never where a build can reach), so a
1046
+ * restart makes old cursors start over visibly. A cursor is state, never permission. */
1047
+ const liveCursorKey = randomBytes(32);
1048
+ /** One window of a run's live output for the Live grid (JSON, read-only): its own records or its subagents'. Access
1049
+ * is proved before the read (ceiling, account, this computer's run) and again after it, before a byte is sent. */
1050
+ async function agentsLiveRead(ctx) {
1051
+ const { url, who, response } = ctx;
1052
+ response.setHeader("cache-control", "no-store");
1053
+ // A browser session only (the route says so too): live output is never a token's to read.
1054
+ if (who.via !== "cookie")
1055
+ return respond(response, 403, "application/json", JSON.stringify({ error: "session" }));
1056
+ const runId = Number(url.pathname.slice("/agents/live/".length));
1057
+ const file = url.searchParams.get("file");
1058
+ const cursor = url.searchParams.get("cursor");
1059
+ if ((file !== "main" && file !== "sub") || [...url.searchParams.keys()].some(key => key !== "file" && key !== "cursor")) {
1060
+ return respond(response, 400, "application/json", JSON.stringify({ error: "request" }));
1061
+ }
1062
+ const browser = who;
1063
+ // The saved session still held (not just the cookie), and the run still in view from its current row.
1064
+ const allowed = () => {
1065
+ if (rereadBrowser(runtime, ctx.request, browser) === null)
1066
+ return false;
1067
+ const run = store.getRun(runId);
1068
+ const repo = run === null ? null : taskRepoOf(run.taskRef);
1069
+ return run !== null && repo !== null && runVisible(run) && store.accountCanAccess(who.name, repo);
1070
+ };
1071
+ if (!allowed())
1072
+ return respond(response, 404, "application/json", JSON.stringify({ error: "run" }));
1073
+ if (options.localRunner === undefined || store.getRun(runId)?.runner !== options.localRunner) {
1074
+ return allowed() ? respond(response, 200, "application/json", JSON.stringify({ error: "off" })) : respond(response, 404, "application/json", JSON.stringify({ error: "run" }));
1075
+ }
1076
+ const read = { key: liveCursorKey, commands: runLiveCommands(store, runId) };
1077
+ const window = file === "sub" ? readChildWindow(evidenceRoot, runId, cursor, read) : readMainWindow(evidenceRoot, runId, cursor, read);
1078
+ // Re-proved after the read: a narrowed account or a run gone from view gets nothing it read.
1079
+ if (!allowed() || store.getRun(runId)?.runner !== options.localRunner)
1080
+ return respond(response, 404, "application/json", JSON.stringify({ error: "run" }));
1081
+ return respond(response, 200, "application/json", JSON.stringify(window.ok ? window : { error: window.reason }));
1082
+ }
992
1083
  const registrations = handlersOf("pages", {
993
1084
  "projects.browse": projectsBrowse,
994
1085
  "projects.github": projectsGithub,
1086
+ "projects.github-list": projectsGithubList,
995
1087
  "projects.page": projectsIndex,
996
1088
  "home": homeRedirect,
997
1089
  "morning": morningAlias,
@@ -1001,6 +1093,8 @@ export function createPagesHandlers(runtime) {
1001
1093
  "system": systemScreen,
1002
1094
  "peek": peekPage,
1003
1095
  "agents": agentsPage,
1096
+ "agents.live": agentsLive,
1097
+ "agents.live.read": agentsLiveRead,
1004
1098
  "fleet": fleetPage,
1005
1099
  "ledger.export": ledgerExport,
1006
1100
  "menu": menuPage,
@@ -39,7 +39,7 @@ export function sameVisitCheck(authTime, startedAt, now) {
39
39
  return Math.min(Math.max(at, startedAt), now);
40
40
  }
41
41
  export function createPeopleHandlers(runtime) {
42
- const { joinBySource, ssoOffer, ssoVisits, ssoSettings, options, clock, ssoHandoffs, managedRepos, store, visible, admissionList, sendScreen, chromeFor, restricted, sessions, consoleProjects, authenticateApprover, authenticateCredential, bustBadge, projectOf, signInLinks, linkKey, fromThisComputer, recordSignIn, passwordAllowed, arrival, defaultProject, cookieSecure, consoleOrigin, providerFor, joinSourceOf, signInBudget, signInActor, minutesWords } = runtime;
42
+ const { joinBySource, ssoOffer, ssoVisits, ssoSettings, options, clock, ssoHandoffs, managedRepos, store, visible, admissionList, sendScreen, chromeFor, restricted, sessions, consoleProjects, authenticateApprover, authenticateCredential, bustBadge, projectOf, signInLinks, linkKey, fromThisComputer, recordSignIn, passwordAllowed, arrival, defaultProject, cookieSecure, consoleOrigin, providerFor, providerKnown, joinSourceOf, signInBudget, signInActor, minutesWords } = runtime;
43
43
  // v101: Settings → Sessions & tokens: where you're signed in, and your API tokens. An instance operator can see everyone's.
44
44
  async function sessionsPage(ctx) {
45
45
  const { url, who, response, project } = ctx;
@@ -353,9 +353,12 @@ ${reauth(html `<label>Password <input type="password" name="token" autocomplete=
353
353
  const origin = consoleOrigin(request.headers.host);
354
354
  if (origin === null)
355
355
  return page(response, 400, loginPage("Sign in from this computer (localhost) or from your https address.", returnTo, ssoOffer()));
356
- const found = await providerFor(settings.issuer);
357
- if (!found.ok)
358
- return page(response, 502, loginPage(`${settings.label} isn't answering: ${found.said}`, returnTo, ssoOffer()));
356
+ // Discovered as the console starts and in the background (docs/plans/get-side-effects.md): following this link
357
+ // fetches nothing.
358
+ const known = providerKnown(settings.issuer);
359
+ if (known === null)
360
+ return page(response, 503, loginPage(`${settings.label} isn't answering yet. Try again in a moment.`, returnTo, ssoOffer()));
361
+ const found = { provider: known };
359
362
  const visit = newOidcVisit(), redirectUri = `${origin}${SSO_CALLBACK}`;
360
363
  for (const [key, one] of ssoVisits)
361
364
  if (one.expires < Date.now())
@@ -359,8 +359,14 @@ export declare function leadAfterComposerHtml(data: {
359
359
  pending: LeadTurn | null;
360
360
  focusTask: TaskChatFocus | null;
361
361
  }): Html;
362
+ /** A conversation shown before it exists (quiet page visits): its page binds sends to this session id, and the first
363
+ * send starts the conversation (chatSend). A visit never starts one. */
364
+ export declare const NOT_STARTED_SESSION = 0;
362
365
  export declare function leadPage(chrome: Chrome, data: LeadThreadRows & {
363
- session: LeadSession;
366
+ /** Null: no conversation yet; the first message starts one. */
367
+ session: LeadSession | null;
368
+ /** Whose conversation this is (the session's approver once it exists). */
369
+ approver: string;
364
370
  /** A project's own thread (v77); null for the lead conversation or a task. */
365
371
  chatProject?: string | null;
366
372
  follow?: {
@@ -390,6 +396,14 @@ export type LiveTurn = {
390
396
  ok: boolean;
391
397
  listeners: Set<() => void>;
392
398
  expiry?: NodeJS.Timeout;
399
+ /** For the Live grid's lead tile (agents-live-lead.ts): the turn's id once it says it, when it started and ended, and
400
+ * when each step began (epoch ms). Never sent to the chat room. */
401
+ turn?: number;
402
+ startedAt?: number;
403
+ endedAt?: number;
404
+ stepAt?: number[];
405
+ /** Per step: its text is the provider's finished answer, secret-checked whole (lead.ts `final`). */
406
+ confirmed?: boolean[];
393
407
  };
394
408
  export type ChatEnablement = {
395
409
  ok: true;
@@ -952,18 +952,25 @@ export function leadThreadHtml(data) {
952
952
  export function leadAfterComposerHtml(data) {
953
953
  return html `<div id="chat-after-composer" data-chat-region="after">${data.messages.length === 0 && data.pending === null ? leadPromptStarters(data.focusTask) : ""}</div>`;
954
954
  }
955
+ /** A conversation shown before it exists (quiet page visits): its page binds sends to this session id, and the first
956
+ * send starts the conversation (chatSend). A visit never starts one. */
957
+ export const NOT_STARTED_SESSION = 0;
955
958
  export function leadPage(chrome, data) {
956
959
  const subscription = isSubscriptionChatProvider(data.config.provider);
957
960
  const chatProject = data.focusTask === null ? data.chatProject ?? null : null;
958
961
  const returnTo = data.focusTask !== null ? taskChatHref(data.focusTask.id) : chatProject !== null ? projectChatHref(chatProject) : "/chat";
962
+ const sessionId = data.session?.id ?? NOT_STARTED_SESSION;
963
+ const approver = data.session?.approver ?? data.approver;
959
964
  const controlsHtml = joinHtml([
960
- html `<details class="lead-follow"><summary>Automatic crew updates${data.follow?.enabled ? ' · On' : ''}</summary><p class="meta">${data.follow?.detail ?? 'Automatic crew updates are off.'}</p>${postForm("/chat/mate/follow", html `${data.follow?.enabled ? '' : html `<p>The lead responds when results or decisions arrive. Uses this conversation’s ${subscription ? 'membership usage, with no dollar maximum' : 'remaining spend allowance'} and daily turn limit. Existing task approvals still apply.</p>`}<button type="submit" class="quiet">${data.follow?.enabled ? 'Pause updates' : 'Enable updates'}</button>`, { returnTo, hidden: { enabled: data.follow?.enabled ? 'no' : 'yes' } })}</details>`,
965
+ data.session === null ? "" : html `<details class="lead-follow"><summary>Automatic crew updates${data.follow?.enabled ? ' · On' : ''}</summary><p class="meta">${data.follow?.detail ?? 'Automatic crew updates are off.'}</p>${postForm("/chat/mate/follow", html `${data.follow?.enabled ? '' : html `<p>The lead responds when results or decisions arrive. Uses this conversation’s ${subscription ? 'membership usage, with no dollar maximum' : 'remaining spend allowance'} and daily turn limit. Existing task approvals still apply.</p>`}<button type="submit" class="quiet">${data.follow?.enabled ? 'Pause updates' : 'Enable updates'}</button>`, { returnTo, hidden: { enabled: data.follow?.enabled ? 'no' : 'yes' } })}</details>`,
961
966
  html `<details class="chat-limits chat-session-details"><summary>Conversation details<span class="meta">${subscription ? "membership" : data.config.provider}</span></summary>\n${joinHtml([
962
967
  html `<div class="chat-budget"><span class="mono">${data.config.provider} · ${data.config.model}</span><span>${data.turnsToday} / ${data.config.dailyTurns} turns today</span>${subscription
963
968
  ? html `<span>membership login · no dollar ceiling</span>`
964
- : html `<span>this conversation: ${chatMoney(data.session.spentMicrousd)} of ${chatMoney(data.session.ceilingMicrousd)}</span><span>this week ${chatMoney(data.weeklySpent)} of ${chatMoney(data.config.weeklyCeilingMicrousd)}</span>`}</div>`,
965
- html `<p class="meta">Started ${data.session.mintedAt.slice(0, 16).replace("T", " ")}Z. It stays open until you end it; only bounded recent context is sent to the model.</p>`,
966
- postForm("/chat/mate/end", html `<button type="submit" class="quiet">End the conversation and forget the thread</button>`, { attrs: { class: "inline" }, returnTo }),
969
+ : html `<span>this conversation: ${chatMoney(data.session?.spentMicrousd ?? 0)} of ${chatMoney(data.session?.ceilingMicrousd ?? 0)}</span><span>this week ${chatMoney(data.weeklySpent)} of ${chatMoney(data.config.weeklyCeilingMicrousd)}</span>`}</div>`,
970
+ data.session === null
971
+ ? html `<p class="meta">No conversation yet. Send a message to start one.</p>`
972
+ : html `<p class="meta">Started ${data.session.mintedAt.slice(0, 16).replace("T", " ")}Z. It stays open until you end it; only bounded recent context is sent to the model.</p>`,
973
+ data.session === null ? "" : postForm("/chat/mate/end", html `<button type="submit" class="quiet">End the conversation and forget the thread</button>`, { attrs: { class: "inline" }, returnTo }),
967
974
  data.recent.length === 0
968
975
  ? ""
969
976
  : html `<p class="meta">Recent turns: ${joinHtml(data.recent
@@ -996,8 +1003,8 @@ export function leadPage(chrome, data) {
996
1003
  html `<label>Message<textarea id="chat-message" name="message" rows="1" maxlength="${LEAD_MESSAGE_MAX_CHARS}" placeholder="${data.focusTask !== null ? "Ask about this task…" : chatProject !== null ? `Ask about ${projectName(chatProject)}…` : "Describe what you want done…"}"></textarea></label>`,
997
1004
  html `<button type="submit" aria-label="${data.pending === null ? "send message" : "send message — it waits for the current reply"}">Send</button>`,
998
1005
  ], "\n"), {
999
- attrs: { class: "card composer", id: latestReply === null && data.pending === null ? "latest" : "chat-composer", "aria-label": "message the lead", "data-chat-session": data.session.id, "data-chat-task": data.focusTask?.id ?? "", "data-chat-user": data.session.approver, "data-chat-busy": data.pending === null ? "0" : "1", "data-chat-version": leadChatVersion(data), "data-chat-approval": data.focusTask?.approval?.digest ?? "" },
1000
- hidden: { request: randomBytes(16).toString("hex"), "request-session": data.session.id, task: data.focusTask?.id, project: chatProject, result: data.resultRunId },
1006
+ attrs: { class: "card composer", id: latestReply === null && data.pending === null ? "latest" : "chat-composer", "aria-label": "message the lead", "data-chat-session": sessionId, "data-chat-task": data.focusTask?.id ?? "", "data-chat-user": approver, "data-chat-busy": data.pending === null ? "0" : "1", "data-chat-version": leadChatVersion(data), "data-chat-approval": data.focusTask?.approval?.digest ?? "" },
1007
+ hidden: { request: randomBytes(16).toString("hex"), "request-session": sessionId, task: data.focusTask?.id, project: chatProject, result: data.resultRunId },
1001
1008
  }),
1002
1009
  // Concise pass (2026-09-13): the status line speaks only when there is
1003
1010
  // a state to report — a reply in progress here, the connection from
@@ -1009,7 +1016,7 @@ export function leadPage(chrome, data) {
1009
1016
  return screen("chat", chatWorkspace(joinHtml(conversation, "\n"), data.projects, false, data.focusTask, data.resultPanel ?? null), { chrome, functional: { script: CHAT_CONTINUITY_SCRIPT + CHAT_UI_SCRIPT + (data.focusTask === null ? "" : RESULT_REVIEW_SCRIPT), fetches: true },
1010
1017
  workspace: {
1011
1018
  conversation: {
1012
- sessionId: data.session.id, user: data.session.approver, version: leadChatVersion(data),
1019
+ sessionId, user: approver, version: leadChatVersion(data),
1013
1020
  messages: leadBrowserMessages(data, data.focusTask === null && chatProject === null ? null : returnTo, { task: data.focusTask?.id ?? null, project: chatProject }),
1014
1021
  pendingTurnId: data.pending?.id ?? null, requestId: randomBytes(16).toString('hex'), maxChars: LEAD_MESSAGE_MAX_CHARS,
1015
1022
  taskId: data.focusTask?.id ?? null, resultRunId: data.resultRunId ?? null, project: chatProject,