bullswarm 0.28.4 → 0.28.6

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # bullswarm changelog
2
2
 
3
+ ## 0.28.6 — the 5-hour near-limit line reads the clock
4
+
5
+ - routing: the 5-hour near-limit line is now clock-relative. A pool is
6
+ deprioritized only when its 5h forecast is at/above 75% AND ahead of the
7
+ share of the 5h window that has already elapsed. On 2026-09-10 at 22:19Z a
8
+ high-tier integrator skipped `claude-code:wati` (81% used, 23 minutes to the
9
+ reset — 92.3% of the window elapsed, 88.1% projected) and
10
+ `claude-code:petsona` (75.3% projected, 85.7% elapsed) and went to the one
11
+ account already ahead of its weekly pace, while wati still held 34% of its
12
+ weekly quota unspent with 13% of the week left to spend it. Both pools now keep the lane:
13
+ 88.1% with 23 minutes left is a pool spending at the clock's pace, not a pool
14
+ about to hit a wall. Pools with no `resets_at`, an unparsable one, or a reset
15
+ already past keep the fixed 75% line, and the 90% burst gate is unchanged —
16
+ it ignores the clock. The routing reason and the `candidates[]` rows say
17
+ which case applied: `5h used 81% -> 88.1% projected, under the clock (92.3%
18
+ elapsed)`, `skipped near 5h limit (projected): claude-code:wati 88.1% (20.0%
19
+ elapsed)`, and a new `fiveHourElapsedPct` field. `bullswarm pools` shows the
20
+ same clock: `5h=81% (92% elapsed)`.
21
+ - routing: 5-hour spend is clipped at the reset. Quota spent after the window
22
+ rolls over lands in the next window, so the candidate's minutes and each
23
+ in-flight record's remaining minutes are charged to the 5h forecast only up
24
+ to `fiveHourResetsAt` — 10 minutes from a reset, a 40-minute task at 0.5
25
+ points per minute adds 5 points to the forecast, not 20. The weekly/monthly
26
+ pacing charge is deliberately not clipped: that spend counts against its
27
+ window whichever side of the 5h reset it lands on.
28
+
29
+ ## 0.28.5 — narrow-terminal detail panes use the whole screen
30
+
31
+ - tui: on a narrow terminal (under 100 columns, e.g. a phone over SSH) the
32
+ agent detail, workflow technical details and planner overview panes wrapped
33
+ their text to the width they would have beside the 34-column sidebar, so a
34
+ 60-column screen showed 22-character lines inside a 58-character panel. The
35
+ text now wraps to the pane it occupies: full width when narrow, the right
36
+ column otherwise. Regression test at 60 columns.
37
+
3
38
  ## 0.28.4 — the skill says how, the schema file says what
4
39
 
5
40
  - skill: the method and the schema are now separate files. `skill/SKILL.md`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bullswarm",
3
- "version": "0.28.4",
3
+ "version": "0.28.6",
4
4
  "description": "Route work across coding-agent CLI subscriptions — paced by live quota meters, verified by content, never trusting exit codes.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
4
4
  import { join, resolve } from 'node:path';
5
5
  import { homedir, tmpdir } from 'node:os';
6
- import { pickPool } from './lib/route.js';
6
+ import { fiveHourElapsedPct, pickPool } from './lib/route.js';
7
7
  import { argvWithModel, watchOnce } from './lib/watch.js';
8
8
  import {
9
9
  isReasoningLevel, REASONING_DEFAULT, REASONING_LEVELS, resolveReasoningLevel,
@@ -113,9 +113,13 @@ async function cmdPools(opts) {
113
113
  const projectedPct = p.projectedFiveHourPct == null
114
114
  ? null
115
115
  : Math.round(p.projectedFiveHourPct * 10) / 10;
116
+ // R10: the same reading means different things at different points in the
117
+ // window, so show where the window stands when the provider reported it.
118
+ const elapsed = fiveHourElapsedPct(p);
119
+ const clock = elapsed == null ? '' : ` (${Math.round(elapsed)}% elapsed)`;
116
120
  const fiveHour = readingPct == null
117
- ? (projectedPct == null ? '' : ` 5h=?->${projectedPct}%`)
118
- : ` 5h=${readingPct}%${projectedPct != null && projectedPct !== readingPct ? `->${projectedPct}%` : ''}`;
121
+ ? (projectedPct == null ? '' : ` 5h=?->${projectedPct}%${clock}`)
122
+ : ` 5h=${readingPct}%${projectedPct != null && projectedPct !== readingPct ? `->${projectedPct}%` : ''}${clock}`;
119
123
  const nearLimit = p.nearFiveHourLimit === true ? ' NEAR-5H-LIMIT' : '';
120
124
  const status = !p.enabled
121
125
  ? 'disabled'
package/src/help.js CHANGED
@@ -324,7 +324,9 @@ const poolsText = rich({
324
324
  + 'pace surplus, in-flight assignment count, projected 5-hour utilization, and '
325
325
  + 'quarantine/burst-gate status. The 5-hour column reads `5h=<reading>%` alone when '
326
326
  + 'nothing is in flight and `5h=<reading>%-><projected>%` when in-flight work is '
327
- + 'expected to push the window further; routing decides on the right-hand number.',
327
+ + 'expected to push the window further; routing decides on the right-hand number. '
328
+ + 'A trailing `(<n>% elapsed)` is how much of that 5-hour window has already run: '
329
+ + 'routing only deprioritizes a near-limit pool whose usage is ahead of its clock.',
328
330
  args: [],
329
331
  options: [
330
332
  { flag: '--force', desc: 'bypass the meter cache and re-read live usage for every pool', default: 'off (cached meter readings reused within their TTL)' },
package/src/lib/route.js CHANGED
@@ -15,10 +15,12 @@
15
15
  // R6. A pool at 100% used is exhausted; quarantined pools are ineligible
16
16
  // until their quarantine expires (the re-probe path).
17
17
  // R7. 5h headroom outranks pace: a pool at/above FIVE_HOUR_NEAR_LIMIT_PCT of
18
- // its 5h window is chosen only when no eligible pool below the threshold
19
- // exists for the lane. Like quarantine and burst gates, this outranks an
20
- // explicit assignment and incumbency a near-limit pool that is picked
21
- // anyway spends the run's next attempt on a quota failure.
18
+ // its 5h window AND above that window's elapsed share (R10 the
19
+ // threshold is clock-relative, not a fixed line) is chosen only when no
20
+ // eligible pool below the threshold exists for the lane. Like quarantine
21
+ // and burst gates, this outranks an explicit assignment and incumbency —
22
+ // a near-limit pool that is picked anyway spends the run's next attempt
23
+ // on a quota failure.
22
24
  // R8. Route on the FORECAST, not on the reading. A reading is already old at
23
25
  // the moment it is read: work dispatched seconds ago has spent quota the
24
26
  // meter has not seen, and the assignment being routed will spend more.
@@ -34,8 +36,27 @@
34
36
  // R9. Load beats incumbency: an incumbent carrying more in-flight agents
35
37
  // than a challenger keeps neither its margin nor its cost guard; the
36
38
  // quieter pool wins as soon as its effective surplus is higher.
37
-
38
- import { FIVE_HOUR_NEAR_LIMIT_PCT, BURST_BLOCK_PCT } from '../meters/framework.js';
39
+ // R10. The near-limit line is clock-relative. R7's tier is for a pool that
40
+ // will hit its 5h wall mid-run, and that danger is time-shaped: 88%
41
+ // projected with 23 minutes left in the window is a pool spending at its
42
+ // own pace that is about to be handed a fresh window; 77% with four
43
+ // hours left is a pool heading for the wall. So a pool is deprioritized
44
+ // only when its forecast is at/above FIVE_HOUR_NEAR_LIMIT_PCT *and*
45
+ // above the percentage of the 5h window already elapsed. (Observed
46
+ // 2026-09-10T22:19Z: claude-code:wati, 81% used with 23 minutes left —
47
+ // 92.3% of its window elapsed — was tiered down for a forecast of 88.1%,
48
+ // so a high-tier integrator went to the one account already ahead of its
49
+ // weekly pace while wati's quota, 34% of the week unspent with 13% of
50
+ // the week left, expired unused.) Spend that lands after the reset
51
+ // belongs to the NEXT window: the candidate's minutes and each in-flight
52
+ // record's remaining minutes are clipped at resets_at before they are
53
+ // charged to the 5h forecast — the weekly/monthly pacing penalty is
54
+ // never clipped, that spend does count against its window. No
55
+ // resets_at, an unparsable one, or a reset already in the past means no
56
+ // clock: R7 keeps its fixed line and nothing is clipped, because a pool
57
+ // is never treated differently for a number nobody produced (R8).
58
+
59
+ import { FIVE_HOUR_NEAR_LIMIT_PCT, BURST_BLOCK_PCT, WINDOW_MS } from '../meters/framework.js';
39
60
  // One strict numeric coercion for the whole codebase (src/lib/num.js): a
40
61
  // missing measurement stays null instead of becoming a confident zero.
41
62
  import { finiteOrNull as num } from './num.js';
@@ -100,36 +121,115 @@ function tenth(value) {
100
121
  return Math.round(Number(value) * 10) / 10;
101
122
  }
102
123
 
124
+ /** The 5h window, in minutes — 300. */
125
+ export const FIVE_HOUR_WINDOW_MINUTES = WINDOW_MS['5h'] / 60_000;
126
+
127
+ /**
128
+ * Minutes left before this pool's 5h window resets, from the provider's
129
+ * `fiveHourResetsAt` (src/lib/config.js, straight off the meter reading).
130
+ *
131
+ * null when there is no reading, when it cannot be parsed, or when the reset
132
+ * is already at/behind `now` — an outrun deadline is unknown, not a
133
+ * zero-length window (R8/R10).
134
+ */
135
+ export function minutesUntilFiveHourReset(pool, now = Date.now()) {
136
+ const resetsAtMs = Date.parse(pool?.fiveHourResetsAt ?? '');
137
+ if (!Number.isFinite(resetsAtMs)) return null;
138
+ const minutes = (resetsAtMs - now) / 60_000;
139
+ return minutes > 0 ? minutes : null;
140
+ }
141
+
142
+ /**
143
+ * How much of the 5h window has already elapsed, 0–100, or null when the
144
+ * reset time is unknown (R10). elapsed = 100 × (300 − minutes left) / 300.
145
+ */
146
+ export function fiveHourElapsedPct(pool, now = Date.now()) {
147
+ const left = minutesUntilFiveHourReset(pool, now);
148
+ if (left == null) return null;
149
+ const elapsed = (100 * (FIVE_HOUR_WINDOW_MINUTES - left)) / FIVE_HOUR_WINDOW_MINUTES;
150
+ return Math.max(0, Math.min(100, elapsed));
151
+ }
152
+
153
+ /**
154
+ * In-flight minutes that fall PAST the 5h reset, summed over the records the
155
+ * producer already charged to this window (R10). Only these minutes are
156
+ * credited back from the projection — the rest of the record still spends
157
+ * inside the window being forecast.
158
+ */
159
+ function inflightOverflowMinutes(pool, minutesToReset) {
160
+ const records = Array.isArray(pool?.inflight?.records) ? pool.inflight.records : [];
161
+ let overflow = 0;
162
+ for (const record of records) {
163
+ const m = num(record?.remainingMinutes);
164
+ if (m == null) continue;
165
+ overflow += Math.max(0, Math.max(0, m) - minutesToReset);
166
+ }
167
+ return overflow;
168
+ }
169
+
103
170
  /**
104
171
  * 5h forecast for one pool and the assignment being routed (R8 rule a):
105
172
  *
106
173
  * forecast = (projectedFiveHourPct ?? fiveHourUsedPct)
107
- * + fiveHour.ratePerMinute × candidateMinutes
174
+ * ratePerMinute × in-flight minutes past the reset
175
+ * + ratePerMinute × min(candidateMinutes, minutes to the reset)
108
176
  *
109
177
  * with the candidate term added only when both of its numbers exist; anything
110
178
  * else falls back to the best available reading, and a pool with no reading
111
179
  * at all forecasts null (unknown — never gated, never deprioritized).
112
180
  *
181
+ * R10 clipping: quota spent after `fiveHourResetsAt` lands in the NEXT 5h
182
+ * window and cannot overflow this one, so the candidate's minutes are clipped
183
+ * at the reset, and the in-flight minutes the producer charged past the reset
184
+ * are credited back out of its projection (never below the pool's own
185
+ * reading). This clip is the 5h forecast's alone — the pacing-window charge in
186
+ * inflightLoad() is deliberately left unclipped, because that spend does count
187
+ * against the weekly/monthly window whichever side of the 5h reset it lands
188
+ * on. With no parsable reset time nothing is clipped at all.
189
+ *
113
190
  * `forecasted` records whether the number is more than the raw reading. Only a
114
191
  * real projection input (a producer-supplied projectedFiveHourPct, or a rate ×
115
192
  * candidateMinutes term) turns a reading into a forecast; a bare reading keeps
116
193
  * exactly its old meaning so nothing changes for callers that attach no model.
117
194
  *
195
+ * `nearLimit` is the raw R7 test (forecast >= FIVE_HOUR_NEAR_LIMIT_PCT);
196
+ * `underClock` is R10's exemption from it — near the limit, but no further
197
+ * into the window's quota than into the window's time.
198
+ *
118
199
  * @param {object} pool
119
200
  * @param {number|null} [candidateMinutes] expected minutes of this assignment
201
+ * @param {number} [now]
120
202
  * @returns {{raw: number|null, projected: number|null,
121
203
  * ratePerMinute: number|null, candidateAdd: number|null,
122
- * forecast: number|null, forecasted: boolean}}
204
+ * forecast: number|null, forecasted: boolean,
205
+ * minutesToReset: number|null, elapsedPct: number|null,
206
+ * inflightCreditPct: number, nearLimit: boolean,
207
+ * underClock: boolean}}
123
208
  */
124
- export function fiveHourForecast(pool, candidateMinutes = null) {
209
+ export function fiveHourForecast(pool, candidateMinutes = null, now = Date.now()) {
125
210
  const raw = num(pool?.fiveHourUsedPct);
126
211
  const projected = num(pool?.projectedFiveHourPct);
127
212
  const ratePerMinute = num(pool?.spend?.fiveHour?.ratePerMinute);
128
213
  const minutes = num(candidateMinutes);
129
- const base = projected ?? raw;
214
+ const minutesToReset = minutesUntilFiveHourReset(pool, now);
215
+ const clip = (m) => (minutesToReset == null ? m : Math.min(m, minutesToReset));
130
216
  const candidateAdd =
131
- ratePerMinute != null && minutes != null ? ratePerMinute * minutes : null;
217
+ ratePerMinute != null && minutes != null ? ratePerMinute * clip(minutes) : null;
218
+ // Credit back only what the producer charged past the reset; a zero credit
219
+ // leaves the projection byte-for-byte what it was before R10.
220
+ const overflowMinutes =
221
+ minutesToReset == null || ratePerMinute == null
222
+ ? 0
223
+ : inflightOverflowMinutes(pool, minutesToReset);
224
+ const inflightCreditPct = overflowMinutes > 0 ? ratePerMinute * overflowMinutes : 0;
225
+ const base =
226
+ projected == null ? raw
227
+ : inflightCreditPct > 0
228
+ ? Math.max(raw ?? projected - inflightCreditPct, projected - inflightCreditPct)
229
+ : projected;
132
230
  const forecast = base == null ? null : base + (candidateAdd ?? 0);
231
+ const elapsedPct = fiveHourElapsedPct(pool, now);
232
+ const nearLimit = forecast != null && forecast >= FIVE_HOUR_NEAR_LIMIT_PCT;
133
233
  return {
134
234
  raw,
135
235
  projected,
@@ -137,6 +237,13 @@ export function fiveHourForecast(pool, candidateMinutes = null) {
137
237
  candidateAdd,
138
238
  forecast,
139
239
  forecasted: projected != null || candidateAdd != null,
240
+ minutesToReset,
241
+ elapsedPct,
242
+ inflightCreditPct,
243
+ nearLimit,
244
+ // R10: at/above the line but no further through its quota than through its
245
+ // window — the reset arrives before the wall does.
246
+ underClock: nearLimit && elapsedPct != null && forecast <= elapsedPct,
140
247
  };
141
248
  }
142
249
 
@@ -167,6 +274,12 @@ export function fiveHourForecast(pool, candidateMinutes = null) {
167
274
  * elapsed worker-minutes (src/lib/assignments.js attachInflight), which says
168
275
  * nothing about the quota still to be spent.
169
276
  *
277
+ * These minutes are NOT clipped at the 5h reset (R10). This charge is the
278
+ * pacing window's — weekly or monthly — and an agent still running an hour
279
+ * after the 5h window rolls over goes on spending the same weekly quota. Only
280
+ * the 5h forecast in fiveHourForecast() clips at `fiveHourResetsAt`, and it
281
+ * computes that separately from this number.
282
+ *
170
283
  * estimateSource: `none` (nothing to charge), `penalty` (the flat floor set the
171
284
  * charge), or the source label of the rate that was used (`history` /
172
285
  * `bootstrap`, from `spend.pacing` or `spend.weekly`) when the measured
@@ -307,7 +420,7 @@ export function pickPool(lane, pools, opts = {}) {
307
420
  const eligible = laneCapable.filter((p) => p.modelPolicy?.eligible !== false);
308
421
 
309
422
  const scored = eligible.map((p) => {
310
- const forecast = fiveHourForecast(p, candidateMins);
423
+ const forecast = fiveHourForecast(p, candidateMins, now);
311
424
  const load = inflightLoad(p, { candidateMinutes: candidateMins, inflightPenaltyPct });
312
425
  const pace = paceScore(p, now);
313
426
  return {
@@ -319,8 +432,10 @@ export function pickPool(lane, pools, opts = {}) {
319
432
  effective: pace - load.penalty,
320
433
  load,
321
434
  forecast,
322
- // R8b: R7's tier, applied to the forecast instead of the reading.
323
- tier: forecast.forecast != null && forecast.forecast >= FIVE_HOUR_NEAR_LIMIT_PCT ? 1 : 0,
435
+ // R8b: R7's tier, applied to the forecast instead of the reading — and
436
+ // R10: only for a pool further through its 5h quota than through its 5h
437
+ // window. A pool at 88% with 23 minutes left keeps its tier 0.
438
+ tier: forecast.nearLimit && !forecast.underClock ? 1 : 0,
324
439
  // A pool is gated only by a FORECAST at/above the burst line — a bare
325
440
  // reading keeps its current meaning (dispatch owns that gate), so pools
326
441
  // without a spend model behave exactly as before.
@@ -345,6 +460,9 @@ export function pickPool(lane, pools, opts = {}) {
345
460
  fiveHourUsedPct: e.forecast.raw,
346
461
  projectedFiveHourPct: e.forecast.projected,
347
462
  forecastFiveHourPct: e.forecast.forecast == null ? null : tenth(e.forecast.forecast),
463
+ // R10: how far into the 5h window this reading sits; null when the
464
+ // provider reported no reset time, in which case the fixed line applies.
465
+ fiveHourElapsedPct: e.forecast.elapsedPct == null ? null : tenth(e.forecast.elapsedPct),
348
466
  projectedWeeklyPct: num(e.pool.projectedWeeklyPct),
349
467
  // The window this pool is paced by, and the projection in it. Equal to
350
468
  // the weekly pair for every pool that declares no monthly quota window.
@@ -555,9 +673,15 @@ function routingReason(
555
673
  [winnerEntry.pool.name, note, inflight].filter(Boolean).join(', ')
556
674
  })`;
557
675
  } else {
558
- base = `most-behind capable pool${
559
- note ? (winnerEntry.tier === 0 ? ' with 5h headroom' : ' near its 5h limit') : ''
560
- } (${detail})`;
676
+ // Three states, not two (R10): headroom, near the limit and tiered down,
677
+ // or near the limit but under the window's clock where the note itself
678
+ // carries the explanation, so the label stays out of its way.
679
+ const standing =
680
+ !note ? ''
681
+ : winnerEntry.tier === 1 ? ' near its 5h limit'
682
+ : winnerEntry.forecast.underClock ? ''
683
+ : ' with 5h headroom';
684
+ base = `most-behind capable pool${standing} (${detail})`;
561
685
  }
562
686
  const clauses = [base];
563
687
  if (skippedNearLimit.length) {
@@ -583,21 +707,42 @@ function routingReason(
583
707
  return clauses.join(' · ');
584
708
  }
585
709
 
586
- /** `<pool> <pct>%` using the forecast when one exists, else the raw reading. */
710
+ /**
711
+ * `<pool> <pct>%` using the forecast when one exists, else the raw reading —
712
+ * and, for a pool at/above the near-limit line, where its 5h window stands
713
+ * (R10): `claude-code:wati 88.1% (92.3% elapsed)`. The clock is what decided
714
+ * the tier, so the number that decided it is named.
715
+ */
587
716
  function poolPctLabel(entry) {
588
717
  const pct = entry.forecast.forecasted ? entry.forecast.forecast : entry.forecast.raw;
589
- return `${entry.pool.name}${pct == null ? '' : ` ${tenth(pct)}%`}`;
718
+ const clock = entry.forecast.nearLimit ? elapsedText(entry.forecast.elapsedPct) : null;
719
+ return `${entry.pool.name}${pct == null ? '' : ` ${tenth(pct)}%`}${clock ? ` (${clock})` : ''}`;
720
+ }
721
+
722
+ /** `92.3% elapsed`, or null when the provider reported no 5h reset time. */
723
+ function elapsedText(elapsedPct) {
724
+ return elapsedPct == null ? null : `${Number(elapsedPct).toFixed(1)}% elapsed`;
590
725
  }
591
726
 
592
- /** `5h used 30%` or, when a forecast adds to it, `5h used 30% -> 41% projected`. */
727
+ /**
728
+ * `5h used 30%` or, when a forecast adds to it, `5h used 30% -> 41% projected`.
729
+ *
730
+ * A forecast at/above the near-limit line also carries its window's clock —
731
+ * `, under the clock (92.3% elapsed)` when R10 exempts it from the tier,
732
+ * `, 20.0% elapsed` when the clock is what put it there.
733
+ */
593
734
  function fiveHourNote(entry) {
594
- const { raw, forecast, forecasted } = entry.forecast;
735
+ const { raw, forecast, forecasted, nearLimit, underClock, elapsedPct } = entry.forecast;
736
+ const clock = nearLimit ? elapsedText(elapsedPct) : null;
737
+ const suffix = clock ? `, ${underClock ? `under the clock (${clock})` : clock}` : '';
595
738
  if (raw == null) {
596
- return forecasted && forecast != null ? `5h projected ${tenth(forecast)}%` : null;
739
+ return forecasted && forecast != null ? `5h projected ${tenth(forecast)}%${suffix}` : null;
597
740
  }
598
741
  const reading = `5h used ${tenth(raw)}%`;
599
- if (!forecasted || forecast == null || tenth(forecast) === tenth(raw)) return reading;
600
- return `${reading} -> ${tenth(forecast)}% projected`;
742
+ if (!forecasted || forecast == null || tenth(forecast) === tenth(raw)) {
743
+ return `${reading}${suffix}`;
744
+ }
745
+ return `${reading} -> ${tenth(forecast)}% projected${suffix}`;
601
746
  }
602
747
 
603
748
  /** `2 in flight`, or null when the caller tracks no in-flight work here. */
@@ -572,16 +572,15 @@ export function renderWorkflowTui(row, {
572
572
  // readable and explicit back navigation preserves the same hierarchy.
573
573
  const leftWidth = Math.min(SIDEBAR_WIDTH, Math.max(1, width - 3));
574
574
  const rightWidth = Math.max(1, width - leftWidth);
575
- const orchestrationLines = orchestratorDetailLines(
576
- model,
577
- Math.max(20, (orchestratorDetail ? width : rightWidth) - 4),
578
- spinnerFrame,
579
- { verbose: orchestratorVerbose },
580
- );
581
- const detail = orchestratorDetail
582
- ? orchestrationLines
583
- : agentDetailLines(model, Math.max(20, rightWidth - 4), spinnerFrame);
584
- const technical = workflowTechnicalLines(model, Math.max(20, rightWidth - 4));
575
+ // The detail text is wrapped once, before the layout below picks a body, so
576
+ // it must wrap to the pane it will actually occupy: the full width on a
577
+ // narrow terminal (one pane at a time), the right column beside the sidebar
578
+ // otherwise. Wrapping to the right column on a 60-column phone left every
579
+ // line at 22 characters inside a 58-character panel.
580
+ const paneWidth = Math.max(20, (narrow ? width : rightWidth) - 4);
581
+ const orchestrationLines = orchestratorDetailLines(model, paneWidth, spinnerFrame, { verbose: orchestratorVerbose });
582
+ const detail = orchestratorDetail ? orchestrationLines : agentDetailLines(model, paneWidth, spinnerFrame);
583
+ const technical = workflowTechnicalLines(model, paneWidth);
585
584
  const contentHeight = bodyHeight - 2;
586
585
  const scrollSource = workflowVerbose ? technical : detail;
587
586
  const maxScroll = Math.max(0, scrollSource.length - contentHeight);