omniharness-cli 0.1.84 → 0.1.86

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/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ import { ownVersion, runUpdate } from './update.js';
7
7
  import { readActiveCombo } from './config/settings.js';
8
8
  import { OmniRouteClient } from './config/omniRoute.js';
9
9
  import { doctor, helpText, models } from './doctor.js';
10
+ import { debounceResizeEvents } from './ui/resizeDebounce.js';
10
11
  // A crash anywhere below would otherwise surface as a raw Node stack trace, or
11
12
  // as an unhandled rejection that terminates the process without saying why.
12
13
  // A CLI should fail with a sentence.
@@ -81,8 +82,12 @@ else {
81
82
  // The cost is that quitting no longer restores the pre-launch screen. For
82
83
  // a tool whose output you are meant to read back, that is the right trade.
83
84
  //
85
+ // Coalesce resize delivery before Ink's own internal listener ever sees
86
+ // it — see resizeDebounce.ts. Only meaningful for a real terminal; a
87
+ // non-TTY stdout never emits 'resize' and patching it would be inert.
88
+ const stdout = process.stdout.isTTY ? debounceResizeEvents(process.stdout, 80) : process.stdout;
84
89
  // The app owns Ctrl+C so idle quits but an in-flight run is cancelled first.
85
- const { waitUntilExit } = render(_jsx(TerminalInterface, { engine: engine }), { exitOnCtrlC: false });
90
+ const { waitUntilExit } = render(_jsx(TerminalInterface, { engine: engine }), { stdout, exitOnCtrlC: false });
86
91
  await waitUntilExit();
87
92
  })();
88
93
  }
@@ -145,17 +145,27 @@ export class OmniRouteClient {
145
145
  }
146
146
  /** List every model id the gateway exposes, including `auto/*` virtual combos and individual providers. */
147
147
  async listModels(signal) {
148
+ return (await this.listCatalog(signal)).map((entry) => entry.id);
149
+ }
150
+ /**
151
+ * The catalog with the context window each entry advertises. OmniRoute
152
+ * states `context_length` on providers' models, on `auto/*` engines and on
153
+ * combos alike; `max_input_tokens` stands in when an entry carries only that.
154
+ */
155
+ async listCatalog(signal) {
148
156
  const response = await this.requestWithRetry('/v1/models', { method: 'GET', signal });
149
157
  const payload = await response.json();
150
158
  const data = this.isRecord(payload) && Array.isArray(payload.data) ? payload.data : [];
151
- const ids = [];
159
+ const entries = [];
152
160
  for (const entry of data) {
153
- if (this.isRecord(entry) && typeof entry.id === 'string' && entry.id.trim() !== '')
154
- ids.push(entry.id);
161
+ if (!this.isRecord(entry) || typeof entry.id !== 'string' || entry.id.trim() === '')
162
+ continue;
163
+ const window = this.positive(entry.context_length) ?? this.positive(entry.max_input_tokens);
164
+ entries.push(window !== undefined ? { id: entry.id, contextLength: window } : { id: entry.id });
155
165
  }
156
- if (ids.length === 0)
166
+ if (entries.length === 0)
157
167
  throw new OmniRouteError(response.status, 'invalid models response');
158
- return ids;
168
+ return entries;
159
169
  }
160
170
  /** Retry transient responses for idempotent metadata reads without touching chat/tool requests. */
161
171
  async requestWithRetry(path, init) {
@@ -431,6 +441,9 @@ export class OmniRouteClient {
431
441
  const provider = headers.get('x-omniroute-provider') ?? decision.provider;
432
442
  if (provider)
433
443
  fallback.activeProvider = provider;
444
+ const model = headers.get('x-omniroute-model');
445
+ if (model && model.trim() !== '')
446
+ fallback.model = model.trim();
434
447
  if (decision.strategy)
435
448
  fallback.strategy = decision.strategy;
436
449
  if (decision.latencyMs !== undefined)
@@ -485,6 +498,9 @@ export class OmniRouteClient {
485
498
  number(value) {
486
499
  return typeof value === 'number' && Number.isFinite(value) ? value : 0;
487
500
  }
501
+ positive(value) {
502
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
503
+ }
488
504
  safeParse(text) {
489
505
  try {
490
506
  return JSON.parse(text);
@@ -0,0 +1,18 @@
1
+ export function debounce(fn, delayMs) {
2
+ let timer;
3
+ const debounced = ((...args) => {
4
+ if (timer)
5
+ clearTimeout(timer);
6
+ timer = setTimeout(() => {
7
+ timer = undefined;
8
+ fn(...args);
9
+ }, delayMs);
10
+ });
11
+ debounced.cancel = () => {
12
+ if (timer)
13
+ clearTimeout(timer);
14
+ timer = undefined;
15
+ };
16
+ return debounced;
17
+ }
18
+ //# sourceMappingURL=debounce.js.map
@@ -6,6 +6,12 @@
6
6
  * a model id (or the provider name OmniRoute reports) to a token budget;
7
7
  * `contextMeter` turns "tokens in" into a fill fraction and a zone the UI
8
8
  * colours (ok / warn / danger) using the playbook's 70 / 90 thresholds.
9
+ *
10
+ * The gateway's catalog is the first source: `/v1/models` states a
11
+ * `context_length` per entry, and `windowIndex` turns that into a lookup the
12
+ * meter consults before the substring table below. The table remains the
13
+ * answer for a model the catalog does not size, and when the catalog could
14
+ * not be read at all.
9
15
  */
10
16
  /** Known windows keyed by a substring of the model / provider id (longest match wins). */
11
17
  const WINDOWS = [
@@ -35,12 +41,52 @@ const WINDOWS = [
35
41
  ];
36
42
  /** Fallback window when nothing matches — conservative so the meter warns early rather than late. */
37
43
  export const DEFAULT_WINDOW = 128_000;
44
+ /**
45
+ * Build the lookup from a catalog. Each entry is keyed by its full id and,
46
+ * when the id carries a provider prefix, by the bare model name after it —
47
+ * `X-OmniRoute-Model` reports the upstream name (`claude-sonnet-4-6`) while
48
+ * the catalog lists it under a prefix (`cc/claude-sonnet-4-6`). The first
49
+ * entry to claim a bare name keeps it, so a `dual`-mode mirror never
50
+ * contradicts its primary.
51
+ */
52
+ export function windowIndex(entries) {
53
+ const index = new Map();
54
+ for (const entry of entries) {
55
+ const tokens = entry.contextLength;
56
+ if (tokens === undefined || !Number.isFinite(tokens) || tokens <= 0)
57
+ continue;
58
+ const id = entry.id.trim().toLowerCase();
59
+ if (id === '')
60
+ continue;
61
+ if (!index.has(id))
62
+ index.set(id, tokens);
63
+ const slash = id.indexOf('/');
64
+ if (slash > 0 && slash < id.length - 1) {
65
+ const bare = id.slice(slash + 1);
66
+ if (!index.has(bare))
67
+ index.set(bare, tokens);
68
+ }
69
+ }
70
+ return index;
71
+ }
38
72
  /**
39
73
  * Resolve a context window for a model id and/or the provider OmniRoute
40
- * reported for the turn. Matching is case-insensitive substring; the longest
41
- * matching pattern wins so `gpt-4o-mini` beats `gpt-4o`.
74
+ * reported for the turn. A catalog `known` answers first, by the exact id and
75
+ * then by the bare name after a provider prefix. Otherwise matching is
76
+ * case-insensitive substring; the longest matching pattern wins so
77
+ * `gpt-4o-mini` beats `gpt-4o`.
42
78
  */
43
- export function windowFor(modelId, provider) {
79
+ export function windowFor(modelId, provider, known) {
80
+ if (known && modelId) {
81
+ const id = modelId.trim().toLowerCase();
82
+ const exact = known.get(id);
83
+ if (exact !== undefined)
84
+ return exact;
85
+ const slash = id.indexOf('/');
86
+ const bare = slash > 0 ? known.get(id.slice(slash + 1)) : undefined;
87
+ if (bare !== undefined)
88
+ return bare;
89
+ }
44
90
  const haystack = `${modelId ?? ''} ${provider ?? ''}`.toLowerCase();
45
91
  let best;
46
92
  let bestLen = 0;
@@ -53,8 +99,8 @@ export function windowFor(modelId, provider) {
53
99
  return best ?? DEFAULT_WINDOW;
54
100
  }
55
101
  /** Build the meter. `used` below 0 clamps to 0; the fraction is capped at 1. */
56
- export function contextMeter(used, modelId, provider) {
57
- const window = windowFor(modelId, provider);
102
+ export function contextMeter(used, modelId, provider, known) {
103
+ const window = windowFor(modelId, provider, known);
58
104
  const safeUsed = Math.max(0, used);
59
105
  const fraction = Math.min(1, safeUsed / window);
60
106
  const zone = fraction >= 0.9 ? 'danger' : fraction >= 0.7 ? 'warn' : 'ok';
@@ -0,0 +1,52 @@
1
+ import { debounce } from './debounce.js';
2
+ /**
3
+ * Coalesce `resize` event delivery on a TTY stream so every listener — Ink's
4
+ * own internal one included — sees one event per burst instead of one per raw
5
+ * OS-level tick.
6
+ *
7
+ * Ink attaches its own `resize` listener directly to whatever stream is
8
+ * handed to `render()`, and on every single tick it recalculates its Yoga
9
+ * layout and writes a fresh frame — independent of anything application state
10
+ * does. That is not reachable by debouncing a `useState` call in a component:
11
+ * the redraw happens inside Ink regardless.
12
+ *
13
+ * A maximise or restore on Windows Terminal does not deliver one resize
14
+ * event. It animates through the transition and fires a burst of
15
+ * intermediate sizes a few milliseconds apart. Reacting to each one means
16
+ * Ink redraws against a size that is already stale by the time the escape
17
+ * sequences reach the terminal, and the frames land on top of one another
18
+ * instead of replacing one another — duplicated prompt boxes, fragments of an
19
+ * earlier frame still on screen once the window settles.
20
+ *
21
+ * The only place to fix that is before Ink's own listener ever sees the
22
+ * event — so this patches `.on` / `.off` for `resize` specifically, on the
23
+ * same stream object Ink and application code both subscribe to. `write`,
24
+ * `columns`, `rows`, `isTTY` and every other event pass through untouched.
25
+ */
26
+ export function debounceResizeEvents(stream, delayMs) {
27
+ const realOn = stream.on.bind(stream);
28
+ const realOff = stream.off.bind(stream);
29
+ // Keyed on the caller's own listener, so .off(listener) still finds and
30
+ // cancels the right debounced wrapper — the caller never sees the wrapper.
31
+ const wrapped = new WeakMap();
32
+ stream.on = ((event, listener) => {
33
+ if (event !== 'resize')
34
+ return realOn(event, listener);
35
+ const debounced = debounce(listener, delayMs);
36
+ wrapped.set(listener, debounced);
37
+ return realOn(event, debounced);
38
+ });
39
+ stream.off = ((event, listener) => {
40
+ if (event !== 'resize')
41
+ return realOff(event, listener);
42
+ const debounced = wrapped.get(listener);
43
+ if (debounced) {
44
+ debounced.cancel();
45
+ wrapped.delete(listener);
46
+ return realOff(event, debounced);
47
+ }
48
+ return realOff(event, listener);
49
+ });
50
+ return stream;
51
+ }
52
+ //# sourceMappingURL=resizeDebounce.js.map
@@ -12,7 +12,7 @@ import { capabilityLine, recentRows, shortenPath, twoColumn } from './home.js';
12
12
  import { conversationWidth, overflowCount, sidebarMode, SIDEBAR_WIDTH, todoRows, usageRows, clip as clipRow } from './sidebar.js';
13
13
  import { planViewport } from './viewport.js';
14
14
  import { statusMarker, toolHead } from './toolrow.js';
15
- import { contextMeter, meterBar } from './modelWindows.js';
15
+ import { contextMeter, meterBar, windowIndex } from './modelWindows.js';
16
16
  import { BEL, SYNC_QUERY, isSyncOutputReply, osc9Notify, osc52Copy, shouldNudgeOnFinish, wrapSynchronizedOutput } from './termcaps.js';
17
17
  import { KITTY_POP, KITTY_PUSH, KITTY_QUERY, isEncodedKey, isKittyQueryResponse, parseRawKey } from './keys.js';
18
18
  import { ownVersion } from '../update.js';
@@ -271,6 +271,9 @@ export function TerminalInterface({ engine }) {
271
271
  const [pickerItems, setPickerItems] = useState([]);
272
272
  const [pickerIndex, setPickerIndex] = useState(0);
273
273
  const [pickerError, setPickerError] = useState();
274
+ // Context windows the catalog states, keyed by model id. Empty until the
275
+ // catalog has been read; the meter falls back to its own table meanwhile.
276
+ const [windows, setWindows] = useState(() => new Map());
274
277
  const [mode, setMode] = useState(engine.state.mode);
275
278
  const [permMode, setPermMode] = useState(engine.state.permissionMode ?? 'ask');
276
279
  const [approval, setApproval] = useState(null);
@@ -310,9 +313,20 @@ export function TerminalInterface({ engine }) {
310
313
  if (alive)
311
314
  setRecentSessions(found);
312
315
  }).catch(() => { });
316
+ void Promise.resolve().then(() => engine.client.listCatalog()).then((catalog) => {
317
+ if (alive)
318
+ setWindows(windowIndex(catalog));
319
+ }).catch(() => { });
313
320
  return () => { alive = false; };
314
321
  }, []);
315
322
  useEffect(() => {
323
+ // The real fix for a resize storm (maximise/restore firing a burst of
324
+ // intermediate sizes) lives one layer down, in resizeDebounce.ts: it
325
+ // coalesces delivery of the raw 'resize' event on the stream itself,
326
+ // before Ink's own internal listener — which redraws unconditionally on
327
+ // every tick, independent of any component state — ever sees it. This
328
+ // handler can stay a plain listener because of that; by the time it
329
+ // fires, the event has already been coalesced upstream.
316
330
  const onResize = () => {
317
331
  setWidth(widthOf(stdout));
318
332
  setRows(rowsOf(stdout));
@@ -419,7 +433,7 @@ export function TerminalInterface({ engine }) {
419
433
  if (kittyTimer)
420
434
  clearTimeout(kittyTimer);
421
435
  stdin?.off('data', onProbe);
422
- stdout.off('resize', onResize);
436
+ stdout.off('resize', onResize); // resizeDebounce cancels the coalesced timer on .off itself
423
437
  const pendingApproval = approvalResolve.current;
424
438
  approvalResolve.current = null;
425
439
  pendingApproval?.({ approved: false });
@@ -434,7 +448,9 @@ export function TerminalInterface({ engine }) {
434
448
  const loadPicker = async () => {
435
449
  setPickerError(undefined);
436
450
  try {
437
- const [accountCombos, modelIds] = await Promise.all([engine.client.listCombos(), engine.client.listModels()]);
451
+ const [accountCombos, catalog] = await Promise.all([engine.client.listCombos(), engine.client.listCatalog()]);
452
+ setWindows(windowIndex(catalog));
453
+ const modelIds = catalog.map((entry) => entry.id);
438
454
  const items = [];
439
455
  for (const combo of accountCombos) {
440
456
  if (combo.name.trim() !== '' && !items.some((item) => item.id === combo.name)) {
@@ -984,7 +1000,9 @@ export function TerminalInterface({ engine }) {
984
1000
  // The prompt tokens of the last completion, as the gateway counted them,
985
1001
  // are the size of the context the next turn will carry.
986
1002
  const contextTokens = metrics.usage?.contextTokens ?? 0;
987
- const meter = contextMeter(contextTokens, engine.state.activeModel, metrics.fallback.activeProvider);
1003
+ // Sized to the model that answered, when the gateway named one: an `auto/*`
1004
+ // engine or a combo can land anywhere, and the window is that model's.
1005
+ const meter = contextMeter(contextTokens, metrics.fallback.model ?? engine.state.activeModel, metrics.fallback.activeProvider, windows);
988
1006
  const meterColor = meter.zone === 'danger' ? PALETTE.error : meter.zone === 'warn' ? PALETTE.warn : PALETTE.muted;
989
1007
  const contextLabel = contextTokens > 0 ? `ctx ${meterBar(meter.fraction, 8)} ${Math.round(meter.fraction * 100)}%` : '';
990
1008
  const compression = metrics.compression.inputTokens > 0 ? `${Math.round((1 - metrics.compression.ratio) * 100)}% ${metrics.compression.strategy.toUpperCase()}` : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omniharness-cli",
3
- "version": "0.1.84",
3
+ "version": "0.1.86",
4
4
  "description": "OmniHarness — local-first agent orchestration harness for OmniRoute.",
5
5
  "license": "MIT",
6
6
  "type": "module",