@aragon-agent/cli 0.6.4 → 0.6.7

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 (72) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +108 -11
  3. package/dist/agent/controller.d.ts +39 -2
  4. package/dist/agent/controller.js +125 -0
  5. package/dist/agent/reducer.d.ts +33 -19
  6. package/dist/agent/reducer.js +62 -71
  7. package/dist/agent/usage.d.ts +34 -0
  8. package/dist/agent/usage.js +43 -0
  9. package/dist/cli.js +48 -1
  10. package/dist/commands/builtins.js +9 -0
  11. package/dist/compaction/compactor.d.ts +60 -24
  12. package/dist/compaction/compactor.js +89 -61
  13. package/dist/compaction/context-command.d.ts +33 -0
  14. package/dist/compaction/context-command.js +166 -0
  15. package/dist/compaction/meter.d.ts +251 -0
  16. package/dist/compaction/meter.js +428 -0
  17. package/dist/compaction/pressure.d.ts +6 -0
  18. package/dist/compaction/pressure.js +4 -0
  19. package/dist/compaction/types.d.ts +41 -0
  20. package/dist/compaction/wiring.d.ts +47 -0
  21. package/dist/compaction/wiring.js +88 -27
  22. package/dist/config/cli-commands.js +5 -0
  23. package/dist/config/env.js +15 -1
  24. package/dist/config/load.d.ts +17 -0
  25. package/dist/config/load.js +45 -1
  26. package/dist/config/schema.d.ts +44 -0
  27. package/dist/config/schema.js +36 -0
  28. package/dist/config/store.js +27 -1
  29. package/dist/exec/events.d.ts +138 -1
  30. package/dist/exec/events.js +42 -0
  31. package/dist/exec/index.js +43 -6
  32. package/dist/exec/progress-reporter.d.ts +23 -0
  33. package/dist/exec/progress-reporter.js +88 -0
  34. package/dist/exec/runner.d.ts +34 -0
  35. package/dist/exec/runner.js +137 -25
  36. package/dist/fast/reviewer.d.ts +19 -1
  37. package/dist/fast/reviewer.js +22 -0
  38. package/dist/fast/types.d.ts +15 -0
  39. package/dist/fast/wiring.d.ts +10 -1
  40. package/dist/fast/wiring.js +18 -1
  41. package/dist/todo/limits.d.ts +4 -17
  42. package/dist/todo/limits.js +4 -17
  43. package/dist/todo/panel-rows.d.ts +8 -6
  44. package/dist/todo/panel-rows.js +19 -6
  45. package/dist/ui/App.js +75 -63
  46. package/dist/ui/AutocompletePopup.d.ts +3 -1
  47. package/dist/ui/AutocompletePopup.js +9 -11
  48. package/dist/ui/BottomStatusRow.d.ts +1 -1
  49. package/dist/ui/Composer.d.ts +3 -1
  50. package/dist/ui/Composer.js +2 -2
  51. package/dist/ui/PromptInput.d.ts +4 -1
  52. package/dist/ui/PromptInput.js +19 -5
  53. package/dist/ui/StatusBar.d.ts +21 -15
  54. package/dist/ui/StatusBar.js +48 -9
  55. package/dist/ui/TeamPanel.d.ts +3 -1
  56. package/dist/ui/TeamPanel.js +13 -9
  57. package/dist/ui/TodoPanel.d.ts +5 -5
  58. package/dist/ui/TodoPanel.js +56 -24
  59. package/dist/ui/UpdateLine.d.ts +1 -1
  60. package/dist/ui/capabilities.js +5 -5
  61. package/dist/ui/exit-snapshot.d.ts +9 -0
  62. package/dist/ui/layout/ScrollViewport.d.ts +1 -1
  63. package/dist/ui/layout/autocomplete.d.ts +18 -0
  64. package/dist/ui/layout/autocomplete.js +26 -0
  65. package/dist/ui/layout/rail.d.ts +4 -53
  66. package/dist/ui/layout/rail.js +10 -62
  67. package/dist/ui/layout/team-panel.d.ts +18 -0
  68. package/dist/ui/layout/team-panel.js +22 -0
  69. package/dist/ui/layout/todo-layout.d.ts +24 -0
  70. package/dist/ui/layout/todo-layout.js +27 -0
  71. package/dist/ui/transcript-text.js +6 -2
  72. package/package.json +2 -2
@@ -17,6 +17,7 @@
17
17
  import { formatStreamError } from '../agent/reducer.js';
18
18
  import { setSignalTerminator } from '../logging/install.js';
19
19
  import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow-through.js';
20
+ import { ExecProgressReporter } from './progress-reporter.js';
20
21
  export function singlePrompt(text) {
21
22
  let served = false;
22
23
  return {
@@ -40,6 +41,7 @@ export class ExecRunner {
40
41
  opts;
41
42
  signals;
42
43
  now;
44
+ progress;
43
45
  controller = null;
44
46
  unsubscribers = [];
45
47
  turns = 0;
@@ -62,6 +64,7 @@ export class ExecRunner {
62
64
  this.opts = options;
63
65
  this.signals = options.signals ?? DEFAULT_SIGNAL_PORT;
64
66
  this.now = options.now ?? Date.now;
67
+ this.progress = new ExecProgressReporter(this.sessionId, (event) => this.emit(event));
65
68
  }
66
69
  // -----------------------------------------------------------------------
67
70
  // Attach / detach
@@ -103,20 +106,65 @@ export class ExecRunner {
103
106
  });
104
107
  if (todos)
105
108
  this.unsubscribers.push(todos);
109
+ // FOUR BRANCHES, and the split is the whole of W2
110
+ // (web-use-tier-cooperation-and-control-closure §4.2.1):
111
+ //
112
+ // · `usage` — folded into the run's totals (unchanged).
113
+ // · `review_end` — the EXISTING `fast_review` still fires only when the
114
+ // critique has text, byte-for-byte as before; a
115
+ // `fast_tier` now fires UNCONDITIONALLY beside it, so a
116
+ // failed / empty / dropped review is finally visible.
117
+ // · `tier_changed` — a `fast_tier` with no `review` sub-object. This is the
118
+ // one that carries `selfDisabled: true`, because the
119
+ // reviewer emits it right after switching itself off.
120
+ // · `review_start` — DROPPED. `inFlight` on the snapshot already says it,
121
+ // and once every N turns it is pure noise.
106
122
  const fast = controller.subscribeFast?.((event) => {
107
123
  if (event.type === 'usage') {
108
124
  this.usage.inputTokens += event.usage.inputTokens;
109
125
  this.usage.outputTokens += event.usage.outputTokens;
110
126
  return;
111
127
  }
112
- if (event.type !== 'review_end' || !event.review.text)
128
+ if (event.type === 'review_start')
129
+ return;
130
+ if (event.type === 'review_end' && event.review.text) {
131
+ this.emit({
132
+ type: 'fast_review',
133
+ sessionId: this.sessionId,
134
+ turn: event.review.turn,
135
+ model: event.review.model,
136
+ text: event.review.text,
137
+ });
138
+ }
139
+ // `null` means this session never registered a tier, and a session with no
140
+ // tier has no business appearing on this channel at all.
141
+ const snapshot = controller.fastSnapshot?.();
142
+ if (!snapshot)
113
143
  return;
114
144
  this.emit({
115
- type: 'fast_review',
145
+ type: 'fast_tier',
116
146
  sessionId: this.sessionId,
117
- turn: event.review.turn,
118
- model: event.review.model,
119
- text: event.review.text,
147
+ turn: this.turns,
148
+ live: snapshot.live,
149
+ selfDisabled: snapshot.selfDisabled,
150
+ model: snapshot.model,
151
+ sameAsMain: snapshot.sameAsMain,
152
+ reviews: snapshot.reviews,
153
+ reviewBudget: snapshot.reviewBudget,
154
+ budgetReached: snapshot.budgetReached,
155
+ delegated: snapshot.delegated,
156
+ inFlight: snapshot.inFlight,
157
+ ...(event.type === 'review_end'
158
+ ? {
159
+ review: {
160
+ index: event.review.index,
161
+ kind: event.review.kind,
162
+ ...(event.review.detail !== undefined ? { detail: event.review.detail } : {}),
163
+ durationMs: event.review.durationMs,
164
+ injected: event.review.injected,
165
+ },
166
+ }
167
+ : {}),
120
168
  });
121
169
  });
122
170
  if (fast)
@@ -132,6 +180,7 @@ export class ExecRunner {
132
180
  return;
133
181
  }
134
182
  if (event.type === 'compaction_start') {
183
+ this.progress.record('compaction');
135
184
  this.emit({
136
185
  type: 'compaction',
137
186
  sessionId: this.sessionId,
@@ -143,6 +192,7 @@ export class ExecRunner {
143
192
  }
144
193
  if (event.type !== 'compaction_end')
145
194
  return;
195
+ this.progress.record('model');
146
196
  const r = event.record;
147
197
  this.emit({
148
198
  type: 'compaction',
@@ -177,6 +227,7 @@ export class ExecRunner {
177
227
  if (this.detached)
178
228
  return;
179
229
  this.detached = true;
230
+ this.progress.dispose();
180
231
  for (const off of this.unsubscribers.splice(0)) {
181
232
  try {
182
233
  off();
@@ -241,15 +292,42 @@ export class ExecRunner {
241
292
  // nothing more to say exits 0 with `end_turn`, not 3 with `max_turns`.
242
293
  if (next === null)
243
294
  break;
244
- if (this.turnBudgetExhausted()) {
245
- this.stopReason = 'max_turns';
246
- break;
295
+ const requestSeq = ++this.requestSeq;
296
+ this.requestHadError = false;
297
+ this.progress.begin(requestSeq);
298
+ this.emit({ type: 'turn_state', sessionId: this.sessionId, requestSeq, phase: 'started' });
299
+ this.progress.started(requestSeq);
300
+ try {
301
+ if (this.isRequestCancelled())
302
+ break;
303
+ if (this.turnBudgetExhausted()) {
304
+ this.stopReason = 'max_turns';
305
+ break;
306
+ }
307
+ const finished = await this.runFollowThroughChain(controller, next, followThrough);
308
+ if (finished)
309
+ break;
310
+ }
311
+ catch (err) {
312
+ this.errored = true;
313
+ this.recordError('agent_error', err instanceof Error ? err.message : String(err));
314
+ throw err;
315
+ }
316
+ finally {
317
+ this.progress.finish(requestSeq);
318
+ const phase = this.isRequestCancelled()
319
+ ? 'cancelled'
320
+ : this.requestHadError ? 'failed' : 'completed';
321
+ this.emit({ type: 'turn_state', sessionId: this.sessionId, requestSeq, phase });
247
322
  }
248
- const finished = await this.runFollowThroughChain(controller, next, followThrough);
249
- if (finished)
250
- break;
251
323
  }
252
324
  }
325
+ requestSeq = 0;
326
+ requestHadError = false;
327
+ isRequestCancelled() {
328
+ return this.stopReason === 'interrupted' || this.stopReason === 'timeout' ||
329
+ this.stopReason === 'max_turns';
330
+ }
253
331
  /** One caller message plus every auto-continuation it produces. */
254
332
  async runFollowThroughChain(controller, initial, followThrough) {
255
333
  let budget = emptyBudget();
@@ -281,7 +359,7 @@ export class ExecRunner {
281
359
  // `aborted` is false here for the same structural reason `-p` gives: an
282
360
  // interrupt sets `stopReason` and returns above, so this branch is only
283
361
  // reached by a run that ended on its own terms.
284
- runEnd: { aborted: false, errored: this.errored },
362
+ runEnd: { aborted: false, errored: this.requestHadError },
285
363
  budget,
286
364
  interactive: false,
287
365
  });
@@ -394,18 +472,39 @@ export class ExecRunner {
394
472
  * this and add the getter.
395
473
  */
396
474
  installTerminator() {
397
- this.signals.setTerminator((signo) => {
398
- const at = this.now();
399
- const secondSignal = at - this.lastSignalAt <= SECOND_SIGNAL_WINDOW_MS;
400
- if (this.detached || (this.lastSignalAt > 0 && secondSignal)) {
401
- this.signals.exit(128 + signo);
402
- return;
403
- }
404
- this.lastSignalAt = at;
405
- this.signalNumber = signo;
406
- this.stopReason = 'interrupted';
407
- this.controller?.abort();
408
- });
475
+ this.signals.setTerminator((signo) => this.onSignal(signo));
476
+ }
477
+ /**
478
+ * Exactly synonymous with a SIGINT, only it does not travel through a signal.
479
+ *
480
+ * The stdin `{"type":"interrupt"}` frame routes here. It used to route through
481
+ * `process.kill(process.pid, 'SIGINT')`, which is fine on POSIX and WRONG on
482
+ * Windows: Node does not deliver a self-directed SIGINT to its own handlers
483
+ * there, so the process terminates with exit code 1 having run neither the
484
+ * terminator below, nor the settle path, nor `persist()`, nor the `result`
485
+ * event. A wrapper that interrupts a turn and respawns on the same
486
+ * `--session-id` would silently get a conversation missing everything since the
487
+ * last save - and `exec/index.ts` was already designed for exactly that caller
488
+ * ("PERSIST FIRST, ANNOUNCE SECOND").
489
+ *
490
+ * POSIX behaviour is byte-for-byte unchanged: the same closure runs, with
491
+ * `signalNumber = 2`, `exitCode() = 130`, and the two-second second-signal
492
+ * escape hatch intact.
493
+ */
494
+ requestInterrupt() {
495
+ this.onSignal(2);
496
+ }
497
+ onSignal(signo) {
498
+ const at = this.now();
499
+ const secondSignal = at - this.lastSignalAt <= SECOND_SIGNAL_WINDOW_MS;
500
+ if (this.detached || (this.lastSignalAt > 0 && secondSignal)) {
501
+ this.signals.exit(128 + signo);
502
+ return;
503
+ }
504
+ this.lastSignalAt = at;
505
+ this.signalNumber = signo;
506
+ this.stopReason = 'interrupted';
507
+ this.controller?.abort();
409
508
  }
410
509
  // -----------------------------------------------------------------------
411
510
  // Event translation
@@ -431,6 +530,7 @@ export class ExecRunner {
431
530
  this.checkTurnBudget();
432
531
  break;
433
532
  case 'tool_execution_start':
533
+ this.progress.record('tool');
434
534
  this.emit({
435
535
  type: 'tool_call',
436
536
  sessionId: this.sessionId,
@@ -441,6 +541,7 @@ export class ExecRunner {
441
541
  });
442
542
  break;
443
543
  case 'tool_execution_end':
544
+ this.progress.record('model');
444
545
  this.emit({
445
546
  type: 'tool_result',
446
547
  sessionId: this.sessionId,
@@ -457,7 +558,7 @@ export class ExecRunner {
457
558
  break;
458
559
  case 'agent_end':
459
560
  // A swallowed throw (missing key / empty stream) produces no `turn_end`.
460
- if (!this.sawTurnEnd && !this.errored && this.stopReason === 'end_turn') {
561
+ if (!this.sawTurnEnd && !this.requestHadError && !this.isRequestCancelled()) {
461
562
  this.errored = true;
462
563
  this.recordError('agent_error', 'The run produced no assistant turn.');
463
564
  }
@@ -467,6 +568,16 @@ export class ExecRunner {
467
568
  }
468
569
  }
469
570
  onStreamEvent(se) {
571
+ if (se.type === 'text_delta' && se.delta)
572
+ this.progress.record('model');
573
+ if (se.type === 'thinking_delta' && se.delta)
574
+ this.progress.record('thinking');
575
+ if (se.type === 'tool_call_delta' && se.argsDelta)
576
+ this.progress.record('tool_arguments');
577
+ if (se.type === 'retry_scheduled')
578
+ this.progress.record('retry', se.delayMs);
579
+ if (se.type === 'retry_attempt')
580
+ this.progress.record('model');
470
581
  if (se.type === 'text_delta') {
471
582
  this.flushThinking();
472
583
  if (this.opts.partialMessages) {
@@ -527,6 +638,7 @@ export class ExecRunner {
527
638
  this.emit({ type: 'thinking', sessionId: this.sessionId, turn: this.turns + 1, text });
528
639
  }
529
640
  recordError(code, message) {
641
+ this.requestHadError = true;
530
642
  if (this.errorCode)
531
643
  return;
532
644
  this.errorCode = code;
@@ -59,7 +59,7 @@
59
59
  import type { AgentEvent, AssistantMessage, LLMRequest, TokenUsage } from '@aragon-agent/core';
60
60
  import type { FastConfig } from '../config/schema.js';
61
61
  import type { NoticeLevel } from '../agent/reducer.js';
62
- import type { FastEvent, FastTier } from './types.js';
62
+ import type { FastEvent, FastSnapshot, FastTier } from './types.js';
63
63
  /**
64
64
  * Everything the reviewer needs from the rest of the world.
65
65
  *
@@ -93,6 +93,14 @@ export interface FastReviewerDeps {
93
93
  available(): boolean;
94
94
  getApiKey(providerId: string): string | undefined;
95
95
  emit(event: FastEvent): void;
96
+ /**
97
+ * `FastWiring.snapshot()`, so the self-disable can announce itself.
98
+ *
99
+ * The reviewer cannot build a `FastSnapshot` on its own — `live`,
100
+ * `sameAsMain`, `delegated` and `pricingUnknown` all belong to the wiring —
101
+ * and the two existing `tier_changed` emitters read it the same way.
102
+ */
103
+ snapshot(): FastSnapshot;
96
104
  notify(level: NoticeLevel, text: string): void;
97
105
  /** Injected clock. A test that reads the wall clock fails on a slow machine
98
106
  * and nowhere else - the reason `TodoStore` and `TeamPanel` take one. */
@@ -155,6 +163,16 @@ export declare class FastReviewer {
155
163
  isBudgetReached(): boolean;
156
164
  sessionUsage(): TokenUsage;
157
165
  isReviewInFlight(): boolean;
166
+ /**
167
+ * Whether this reviewer switched itself off after
168
+ * `FAST_LIMITS.maxConsecutiveFailures` non-transient failures in a row.
169
+ *
170
+ * Carried onto `FastSnapshot` so a wrapper can say "the fast reviews stopped,
171
+ * and here is why" instead of showing a tier that reads as healthy while not
172
+ * one review ever arrives. Same shape as `reviewCount()` / `isBudgetReached()`
173
+ * — a read-only accessor, no side effects.
174
+ */
175
+ isSelfDisabled(): boolean;
158
176
  /**
159
177
  * `AgentController.abort()`.
160
178
  *
@@ -141,6 +141,18 @@ export class FastReviewer {
141
141
  isReviewInFlight() {
142
142
  return this.inFlight;
143
143
  }
144
+ /**
145
+ * Whether this reviewer switched itself off after
146
+ * `FAST_LIMITS.maxConsecutiveFailures` non-transient failures in a row.
147
+ *
148
+ * Carried onto `FastSnapshot` so a wrapper can say "the fast reviews stopped,
149
+ * and here is why" instead of showing a tier that reads as healthy while not
150
+ * one review ever arrives. Same shape as `reviewCount()` / `isBudgetReached()`
151
+ * — a read-only accessor, no side effects.
152
+ */
153
+ isSelfDisabled() {
154
+ return this.selfDisabled;
155
+ }
144
156
  /**
145
157
  * `AgentController.abort()`.
146
158
  *
@@ -561,6 +573,16 @@ export class FastReviewer {
561
573
  this.selfDisabled = true;
562
574
  this.deps.notify('warn', `Fast review disabled for this session after ${FAST_LIMITS.maxConsecutiveFailures} ` +
563
575
  `failures. Last error: ${errorText(err)}`);
576
+ // THE ONE LINE THAT MAKES THE FLAG OBSERVABLE OUTSIDE THIS PROCESS.
577
+ //
578
+ // `emitReview({kind:'failed'})` above already fired, carrying a snapshot
579
+ // in which `selfDisabled` was still `false`, and after this point the
580
+ // reviewer emits NOTHING EVER AGAIN (`shouldReview()` opens with
581
+ // `if (this.selfDisabled) return false;`). Without this extra
582
+ // `tier_changed`, the field exists, the accessor exists, the snapshot line
583
+ // exists — and no consumer can ever see it as `true`. The `App` chip also
584
+ // consumes `tier_changed`; one more refresh is free.
585
+ this.deps.emit({ type: 'tier_changed', snapshot: this.deps.snapshot() });
564
586
  }
565
587
  }
566
588
  // -----------------------------------------------------------------------
@@ -78,6 +78,21 @@ export interface FastReview {
78
78
  export interface FastSnapshot {
79
79
  /** The tier resolved AND the live switch is on. */
80
80
  live: boolean;
81
+ /**
82
+ * The reviewer switched ITSELF off after `maxConsecutiveFailures` in a row
83
+ * (`FastReviewer.onReviewFailure`).
84
+ *
85
+ * ORTHOGONAL TO `live`, AND NEITHER IMPLIES THE OTHER. `live` answers "did
86
+ * this tier resolve at all" and is `registered && enabled && tier.ok`; the
87
+ * self-disable touches none of those three, so `live` stays `true` after it.
88
+ * A consumer that wants to report "the fast reviews stopped happening" MUST
89
+ * read this field: deriving it as `live === false` is a predicate that can
90
+ * never be true, and a hand-written fixture makes its test green for ever.
91
+ *
92
+ * `available()` is deliberately NOT widened to include it — see the note on
93
+ * `FastWiring.available`.
94
+ */
95
+ selfDisabled: boolean;
81
96
  model: string;
82
97
  sameAsMain: boolean;
83
98
  reviews: number;
@@ -115,7 +115,16 @@ export declare class FastWiring {
115
115
  private getFastRegistry;
116
116
  isRegistered(): boolean;
117
117
  isEnabled(): boolean;
118
- /** §3.3's live predicate. THE THIRD TERM IS THE POINT - see the header. */
118
+ /**
119
+ * §3.3's live predicate. THE THIRD TERM IS THE POINT - see the header.
120
+ *
121
+ * `FastReviewer.isSelfDisabled()` is DELIBERATELY NOT a fourth term. Folding
122
+ * it in would look tidier and would break something else: `delegationAvailable()`
123
+ * is `available() && config.fast.delegate === true`, so three consecutive
124
+ * REVIEW failures would silently switch DELEGATION off as well - turning a
125
+ * diagnostic signal into a behaviour change for every user. The two facts are
126
+ * reported separately on `FastSnapshot` instead (`live` / `selfDisabled`).
127
+ */
119
128
  available(): boolean;
120
129
  /**
121
130
  * Whether `model: "fast"` on `task` is honoured right now.
@@ -53,6 +53,9 @@ export function offFastStatus() {
53
53
  tier: { ok: false, reason: 'disabled' },
54
54
  snapshot: {
55
55
  live: false,
56
+ // A tier that never existed was never switched off by its reviewer, so
57
+ // `false` here is the truth and not a placeholder.
58
+ selfDisabled: false,
56
59
  model: '',
57
60
  sameAsMain: false,
58
61
  reviews: 0,
@@ -113,6 +116,9 @@ export class FastWiring {
113
116
  available: () => this.available(),
114
117
  getApiKey: deps.getApiKey,
115
118
  emit: (event) => this.emit(event),
119
+ // The reviewer announces its own self-disable with a `tier_changed`, and
120
+ // only the wiring can build the snapshot that carries it.
121
+ snapshot: () => this.snapshot(),
116
122
  notify: deps.notify,
117
123
  });
118
124
  }
@@ -158,7 +164,16 @@ export class FastWiring {
158
164
  isEnabled() {
159
165
  return this.enabled;
160
166
  }
161
- /** §3.3's live predicate. THE THIRD TERM IS THE POINT - see the header. */
167
+ /**
168
+ * §3.3's live predicate. THE THIRD TERM IS THE POINT - see the header.
169
+ *
170
+ * `FastReviewer.isSelfDisabled()` is DELIBERATELY NOT a fourth term. Folding
171
+ * it in would look tidier and would break something else: `delegationAvailable()`
172
+ * is `available() && config.fast.delegate === true`, so three consecutive
173
+ * REVIEW failures would silently switch DELEGATION off as well - turning a
174
+ * diagnostic signal into a behaviour change for every user. The two facts are
175
+ * reported separately on `FastSnapshot` instead (`live` / `selfDisabled`).
176
+ */
162
177
  available() {
163
178
  return this.registered && this.enabled && this.tier.ok;
164
179
  }
@@ -272,6 +287,8 @@ export class FastWiring {
272
287
  const ref = this.tier.ok ? this.tier.ref : null;
273
288
  return {
274
289
  live: this.available(),
290
+ // ORTHOGONAL TO `live`, never derivable from it (see `FastSnapshot`).
291
+ selfDisabled: this.reviewer.isSelfDisabled(),
275
292
  model: ref ? ref.modelId : '',
276
293
  sameAsMain: this.tier.ok ? this.tier.sameAsMain : false,
277
294
  // THE NUMERATOR, THE DENOMINATOR AND THE FLAG ARE READ IN ONE EXPRESSION,
@@ -32,13 +32,10 @@ export declare const TODO_LIMITS: {
32
32
  * there (§3.3 step 2).
33
33
  */
34
34
  readonly minFreshItems: 2;
35
- /** Rows the in-progress row may wrap to. Every other row truncates. */
36
- readonly activeWrapRows: 2;
37
- /**
38
- * Below this many AVAILABLE rail rows (`todoRailRows`, NOT `viewportBudget`)
39
- * the rail is not mounted at all.
40
- */
41
- readonly panelMinRows: 6;
35
+ /** Minimum shared middle rows for the compact rail. */
36
+ readonly panelMinRows: 3;
37
+ /** Full rail includes a blank separator and separate overflow markers. */
38
+ readonly panelFullRows: 6;
42
39
  /** Below this many terminal columns the status-bar counter degrades to `[3/7]`. */
43
40
  readonly statusCompactCols: 100;
44
41
  /**
@@ -51,16 +48,6 @@ export declare const TODO_LIMITS: {
51
48
  * component was a third policy or a typo for one of them.
52
49
  */
53
50
  readonly stripDoneCols: 80;
54
- /**
55
- * Rows `todoRailRows()` subtracts while a team dispatch is live (§3.9 / P1-3).
56
- *
57
- * `TEAM_LIMITS.panelMaxRows` (5) + header + `+N more` + the mail line = 8, and
58
- * this is deliberately the FULL worst case rather than a measurement: an
59
- * over-subtraction costs one item row, an under-subtraction eats the `+N
60
- * below` marker - the one row whose absence is indistinguishable from "the
61
- * list is short" - and those two mistakes are not equally bad.
62
- */
63
- readonly railReservedRows: 8;
64
51
  /**
65
52
  * Consecutive user turns an UNFINISHED list survives without a `todo_write`
66
53
  * before `beginUserTurn()` drops it (§3.2 / P1-7). Three keeps the common
@@ -32,13 +32,10 @@ export const TODO_LIMITS = {
32
32
  * there (§3.3 step 2).
33
33
  */
34
34
  minFreshItems: 2,
35
- /** Rows the in-progress row may wrap to. Every other row truncates. */
36
- activeWrapRows: 2,
37
- /**
38
- * Below this many AVAILABLE rail rows (`todoRailRows`, NOT `viewportBudget`)
39
- * the rail is not mounted at all.
40
- */
41
- panelMinRows: 6,
35
+ /** Minimum shared middle rows for the compact rail. */
36
+ panelMinRows: 3,
37
+ /** Full rail includes a blank separator and separate overflow markers. */
38
+ panelFullRows: 6,
42
39
  /** Below this many terminal columns the status-bar counter degrades to `[3/7]`. */
43
40
  statusCompactCols: 100,
44
41
  /**
@@ -51,16 +48,6 @@ export const TODO_LIMITS = {
51
48
  * component was a third policy or a typo for one of them.
52
49
  */
53
50
  stripDoneCols: 80,
54
- /**
55
- * Rows `todoRailRows()` subtracts while a team dispatch is live (§3.9 / P1-3).
56
- *
57
- * `TEAM_LIMITS.panelMaxRows` (5) + header + `+N more` + the mail line = 8, and
58
- * this is deliberately the FULL worst case rather than a measurement: an
59
- * over-subtraction costs one item row, an under-subtraction eats the `+N
60
- * below` marker - the one row whose absence is indistinguishable from "the
61
- * list is short" - and those two mistakes are not equally bad.
62
- */
63
- railReservedRows: 8,
64
51
  /**
65
52
  * Consecutive user turns an UNFINISHED list survives without a `todo_write`
66
53
  * before `beginUserTurn()` drops it (§3.2 / P1-7). Three keeps the common
@@ -37,17 +37,19 @@ export interface TodoRowSelection {
37
37
  export declare function todoAnchorIndex(items: TodoItem[]): number;
38
38
  /**
39
39
  * @param maxRows Rows available for the item rows AND the two overflow markers
40
- * together. `todoRailRows()` upstream is what makes this number
41
- * honest while a team dispatch is live (I-10 / P1-3).
40
+ * together. The shared layout subtracts actual team/menu rows.
42
41
  *
43
42
  * THE ANCHOR IS ALWAYS VISIBLE, and that beats the row bound when the two
44
43
  * conflict. They can only conflict below `maxRows` 3 with overflow on BOTH
45
44
  * sides — one item plus two markers is three rows — where this returns the
46
45
  * anchor and lets the caller's `overflow="hidden"` clip a marker. Hiding the
47
46
  * one row the user is watching in order to keep a `+N above` would be the wrong
48
- * trade. `TODO_LIMITS.panelMinRows` (6) gates the mount and leaves `TodoPanel`
49
- * at least 4 item rows, so the case is unreachable from the rail; a future
50
- * caller with a smaller budget should read this paragraph before assuming
51
- * otherwise.
47
+ * trade. The full rail has at least four item rows. The compact rail uses
48
+ * `selectCompactTodoRows` with a single merged footer instead.
52
49
  */
53
50
  export declare function selectTodoRows(items: TodoItem[], maxRows: number): TodoRowSelection;
51
+ /** Select an anchored contiguous window without reserving overflow-marker rows.
52
+ * maxItemRows is a row count; invalid/nonpositive values return an empty window
53
+ * with every item counted below it. Does not mutate the list or throw.
54
+ */
55
+ export declare function selectCompactTodoRows(items: TodoItem[], maxItemRows: number): TodoRowSelection;
@@ -58,18 +58,15 @@ function windowFor(count, anchor, slots) {
58
58
  }
59
59
  /**
60
60
  * @param maxRows Rows available for the item rows AND the two overflow markers
61
- * together. `todoRailRows()` upstream is what makes this number
62
- * honest while a team dispatch is live (I-10 / P1-3).
61
+ * together. The shared layout subtracts actual team/menu rows.
63
62
  *
64
63
  * THE ANCHOR IS ALWAYS VISIBLE, and that beats the row bound when the two
65
64
  * conflict. They can only conflict below `maxRows` 3 with overflow on BOTH
66
65
  * sides — one item plus two markers is three rows — where this returns the
67
66
  * anchor and lets the caller's `overflow="hidden"` clip a marker. Hiding the
68
67
  * one row the user is watching in order to keep a `+N above` would be the wrong
69
- * trade. `TODO_LIMITS.panelMinRows` (6) gates the mount and leaves `TodoPanel`
70
- * at least 4 item rows, so the case is unreachable from the rail; a future
71
- * caller with a smaller budget should read this paragraph before assuming
72
- * otherwise.
68
+ * trade. The full rail has at least four item rows. The compact rail uses
69
+ * `selectCompactTodoRows` with a single merged footer instead.
73
70
  */
74
71
  export function selectTodoRows(items, maxRows) {
75
72
  const count = items.length;
@@ -101,3 +98,19 @@ export function selectTodoRows(items, maxRows) {
101
98
  visible.push({ item: items[i], index: i });
102
99
  return { visible, hiddenAbove: start, hiddenBelow: count - end };
103
100
  }
101
+ /** Select an anchored contiguous window without reserving overflow-marker rows.
102
+ * maxItemRows is a row count; invalid/nonpositive values return an empty window
103
+ * with every item counted below it. Does not mutate the list or throw.
104
+ */
105
+ export function selectCompactTodoRows(items, maxItemRows) {
106
+ const slots = Number.isFinite(maxItemRows) ? Math.max(0, Math.floor(maxItemRows)) : 0;
107
+ if (slots === 0 || items.length === 0) {
108
+ return { visible: [], hiddenAbove: 0, hiddenBelow: items.length };
109
+ }
110
+ const { start, end } = windowFor(items.length, todoAnchorIndex(items), slots);
111
+ return {
112
+ visible: items.slice(start, end).map((item, offset) => ({ item, index: start + offset })),
113
+ hiddenAbove: start,
114
+ hiddenBelow: items.length - end,
115
+ };
116
+ }