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 +35 -0
- package/package.json +1 -1
- package/src/cli.js +7 -3
- package/src/help.js +3 -1
- package/src/lib/route.js +169 -24
- package/src/workflow/dashboard.js +9 -10
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
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
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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)}
|
|
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))
|
|
600
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
);
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
|
|
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);
|