@north-light/crouter 0.3.273 → 0.3.275

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.
@@ -1,4 +1,5 @@
1
1
  import { hasManagedProviderCoolingDiagnostic } from './runtime/managed-provider-cooling.js';
2
+ import { recognizeProviderError } from './provider-error-recognition.js';
2
3
  import { isRecord } from '../shared/predicates.js';
3
4
  const errorClassByFaultKind = {
4
5
  'rate-limit': 'rate_limit',
@@ -17,85 +18,21 @@ export function errorClassFromFault(fault) {
17
18
  retry_disposition: fault.retry.disposition,
18
19
  };
19
20
  }
20
- function text(value) {
21
- return typeof value === 'string' ? value : '';
22
- }
23
- function classifyProviderText(message) {
24
- const m = message ?? '';
25
- // ORDER MATTERS: rate-limit → auth/protocol → overloaded → connection → other.
26
- // Auth must win over a generic API-error wrapper, and a policy-violation close
27
- // must not become a retryable transport interruption.
28
- if (/rate.?limit|\b429\b|too many requests|quota/i.test(m))
29
- return 'rate-limit';
30
- // A body the other side refuses for SIZE is the request itself being
31
- // unacceptable, never a transient server condition: the retry re-posts the
32
- // same conversation and fails identically, forever. This must precede the
33
- // overloaded rule, which otherwise claims a size rejection wrapped in 5xx
34
- // prose ("500 ... request entity too large") and arms an unwinnable retry.
35
- if (/\b413\b|payload too large|entity too large|request too large|entity\.too\.large|body exceeds/i.test(m))
36
- return 'protocol';
37
- if (/invalid_grant|refresh token|unauthori[sz]ed|invalid.?api.?key|authentication failed|\b401\b|\b403\b/i.test(m))
38
- return 'auth';
39
- // pi compacts and retries a raw provider overflow. Its failed-recovery result
40
- // is the point at which the session cannot become viable without a new launch.
41
- if (/context overflow recovery failed/i.test(m))
42
- return 'context-overflow';
43
- if (/websocket.*(?:closed.*\b(?:1002|1003|1007|1008|1009|1010)\b|protocol error|policy violation|unsupported data|invalid (?:frame|payload)|message too big|mandatory extension)/i.test(m))
44
- return 'protocol';
45
- // proper-lockfile contention on a credential file: another process holds the
46
- // mutex right now, so the same request wins once it releases.
47
- if (/lock file is already being held/i.test(m))
48
- return 'overloaded';
49
- if (/overloaded|\b5\d\d\b|internal server error|api.?error|capacity|server.{0,3}busy|temporarily unavailable/i.test(m))
50
- return 'overloaded';
51
- if (/connection|econnreset|etimedout|enotfound|econnrefused|network|fetch failed|socket hang|timed? out|timeout|\bterminated\b|other side closed|premature close|websocket\s+(?:was\s+)?closed|websocket stream closed before response\.completed|websocket\s+error|websocket.*interrupt/i.test(m))
52
- return 'connection';
53
- return 'other';
54
- }
55
- function classifyErrno(raw) {
56
- const code = text(raw.code) || text(raw.errno);
57
- if (code === '')
58
- return null;
59
- if (/^(ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOENT|ENOTFOUND|EAI_AGAIN|EPIPE|EHOSTUNREACH|ENETUNREACH)$/i.test(code)) {
60
- return 'connection';
61
- }
62
- // A held filesystem mutex, not a broken link: back off on time rather than
63
- // behind the online probe a connection fault waits on.
64
- if (/^ELOCKED$/i.test(code))
65
- return 'overloaded';
66
- return null;
67
- }
68
- function classifyHttp(raw) {
69
- const status = typeof raw.status === 'number' ? raw.status : null;
70
- if (status === null)
71
- return null;
72
- if (status === 401 || status === 403)
73
- return 'auth';
74
- if (status === 429)
75
- return 'rate-limit';
76
- if (status === 503 || status === 529)
77
- return 'overloaded';
78
- if (status >= 500 && status <= 599)
79
- return 'overloaded';
80
- if (status >= 400 && status <= 499)
81
- return 'protocol';
82
- return null;
83
- }
84
- function classifyWsClose(raw) {
85
- const code = typeof raw.code === 'number' ? raw.code : null;
86
- if (code === null)
87
- return null;
88
- if (code === 1008)
89
- return 'protocol';
90
- if (code === 1006)
91
- return 'connection';
92
- if (code === 1011) {
93
- const reason = text(raw.reason);
94
- if (/no running broker/i.test(reason))
95
- return 'connection';
96
- return classifyProviderText(reason) === 'connection' ? 'connection' : 'other';
21
+ function faultKindFromProviderSignal(signal) {
22
+ switch (signal.kind) {
23
+ case 'rate-limit': return 'rate-limit';
24
+ case 'auth': return 'auth';
25
+ case 'protocol': return 'protocol';
26
+ case 'context-overflow': return 'context-overflow';
27
+ case 'overloaded':
28
+ case 'overloaded-only':
29
+ case 'transient-http': return 'overloaded';
30
+ case 'connection':
31
+ case 'connection-only': return 'connection';
32
+ case 'socket':
33
+ case 'service-unavailable':
34
+ case 'other': return 'other';
97
35
  }
98
- return null;
99
36
  }
100
37
  function classifyDisposition(link, kind) {
101
38
  if (kind === 'auth' || kind === 'protocol' || kind === 'context-overflow')
@@ -140,17 +77,15 @@ export function classify(link, raw) {
140
77
  if (link === 'pi→provider' && hasManagedProviderCoolingDiagnostic(raw)) {
141
78
  return { kind: 'rate-limit', disposition: 'auto' };
142
79
  }
143
- if (isRecord(raw)) {
144
- const errno = classifyErrno(raw);
145
- if (errno !== null)
146
- return { kind: errno, disposition: classifyDisposition(link, errno) };
147
- const http = classifyHttp(raw);
148
- if (http !== null)
149
- return { kind: http, disposition: classifyDisposition(link, http) };
150
- const ws = classifyWsClose(raw);
151
- if (ws !== null)
152
- return { kind: ws, disposition: classifyDisposition(link, ws) };
153
- }
154
- const kind = classifyProviderText(rawText(raw));
80
+ const value = isRecord(raw) ? raw : {};
81
+ const signal = recognizeProviderError({
82
+ code: value['code'],
83
+ errno: value['errno'],
84
+ status: value['status'],
85
+ wsCloseCode: value['code'],
86
+ wsCloseReason: value['reason'],
87
+ message: rawText(raw),
88
+ });
89
+ const kind = faultKindFromProviderSignal(signal);
155
90
  return { kind, disposition: classifyDisposition(link, kind) };
156
91
  }
@@ -0,0 +1,27 @@
1
+ export type ProviderErrorSignalKind = 'rate-limit' | 'auth' | 'protocol' | 'context-overflow' | 'overloaded' | 'overloaded-only' | 'transient-http' | 'connection' | 'connection-only' | 'socket' | 'service-unavailable' | 'other';
2
+ export type ProviderErrorMatchedOn = 'errno' | 'status' | 'websocket-close' | 'text' | 'none';
3
+ export interface ProviderErrorTextMatch {
4
+ kind: ProviderErrorSignalKind;
5
+ match: string;
6
+ }
7
+ export interface ProviderErrorSignal {
8
+ kind: ProviderErrorSignalKind;
9
+ matchedOn: ProviderErrorMatchedOn;
10
+ status?: number;
11
+ retryAfterMs?: number;
12
+ match?: string;
13
+ textKind?: ProviderErrorSignalKind;
14
+ textMatch?: string;
15
+ textMatches?: readonly ProviderErrorTextMatch[];
16
+ }
17
+ export interface ProviderErrorRecognitionInput {
18
+ status?: unknown;
19
+ retryAfterMs?: number;
20
+ code?: unknown;
21
+ errno?: unknown;
22
+ wsCloseCode?: unknown;
23
+ wsCloseReason?: unknown;
24
+ message?: string;
25
+ }
26
+ /** Recognizes provider failure facts. Consumers apply their own retry policy. */
27
+ export declare function recognizeProviderError(input: ProviderErrorRecognitionInput): ProviderErrorSignal;
@@ -0,0 +1,74 @@
1
+ function text(value) {
2
+ return typeof value === 'string' ? value : '';
3
+ }
4
+ function signal(kind, matchedOn, options = {}) {
5
+ return { kind, matchedOn, ...options };
6
+ }
7
+ function withTextMatch(signal, textSignal) {
8
+ return textSignal.matchedOn === 'text'
9
+ ? { ...signal, textKind: textSignal.kind, textMatch: textSignal.match, textMatches: textSignal.textMatches }
10
+ : signal;
11
+ }
12
+ const TEXT_PATTERNS = [
13
+ ['rate-limit', /rate.?limit|\b429\b|too many requests|usage[ _]limit|usage_not_included|usagelimiterror|insufficient_quota|quota exceeded/i],
14
+ ['protocol', /\b413\b|payload too large|entity too large|request too large|entity\.too\.large|body exceeds/i],
15
+ ['auth', /invalid_grant|refresh token|unauthori[sz]ed|invalid.?api.?key|authentication failed|\b401\b|\b403\b/i],
16
+ ['context-overflow', /context overflow recovery failed/i],
17
+ ['protocol', /websocket.*(?:closed.*\b(?:1002|1003|1007|1008|1009|1010)\b|protocol error|policy violation|unsupported data|invalid (?:frame|payload)|message too big|mandatory extension)/i],
18
+ ['overloaded-only', /lock file is already being held/i],
19
+ ['overloaded', /overloaded|temporarily unavailable/i],
20
+ ['service-unavailable', /service.?unavailable/i],
21
+ ['overloaded-only', /internal server error|api.?error|\b5\d\d\b|capacity|server.{0,3}busy/i],
22
+ ['connection', /connection|econnreset|etimedout|enotfound|econnrefused|network|fetch failed|socket hang|timed? out|timeout|websocket\s+(?:was\s+)?closed|websocket stream closed before response\.completed|websocket\s+error|websocket.*interrupt/i],
23
+ ['socket', /socket/i],
24
+ ['connection-only', /eai_again|epipe|ehostunreach|enetunreach|\bterminated\b|other side closed|premature close/i],
25
+ ];
26
+ function recognizeText(message) {
27
+ // The order preserves the canonical taxonomy: size wins over wrapped 5xx prose,
28
+ // and auth wins over a generic API-error wrapper. Every match remains available
29
+ // because rotation deliberately gives transient text priority over rate-limit text.
30
+ const matches = TEXT_PATTERNS.flatMap(([kind, pattern]) => {
31
+ const match = message.match(pattern);
32
+ return match ? [{ kind, match: match[0] }] : [];
33
+ });
34
+ const first = matches[0];
35
+ return first === undefined ? signal('other', 'none') : { ...signal(first.kind, 'text', { match: first.match }), textMatches: matches };
36
+ }
37
+ /** Recognizes provider failure facts. Consumers apply their own retry policy. */
38
+ export function recognizeProviderError(input) {
39
+ const retryAfterMs = input.retryAfterMs;
40
+ const textSignal = recognizeText(input.message ?? '');
41
+ const errno = text(input.code) || text(input.errno);
42
+ if (/^(ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOENT|ENOTFOUND|EAI_AGAIN|EPIPE|EHOSTUNREACH|ENETUNREACH)$/i.test(errno)) {
43
+ return withTextMatch(signal('connection', 'errno', { retryAfterMs }), textSignal);
44
+ }
45
+ if (/^ELOCKED$/i.test(errno))
46
+ return withTextMatch(signal('overloaded', 'errno', { retryAfterMs }), textSignal);
47
+ const status = typeof input.status === 'number' ? input.status : undefined;
48
+ if (status !== undefined) {
49
+ if (status === 401 || status === 403)
50
+ return withTextMatch(signal('auth', 'status', { status, retryAfterMs }), textSignal);
51
+ if (status === 429)
52
+ return withTextMatch(signal('rate-limit', 'status', { status, retryAfterMs }), textSignal);
53
+ if (status === 500 || status === 502 || status === 503 || status === 504)
54
+ return withTextMatch(signal('transient-http', 'status', { status, retryAfterMs }), textSignal);
55
+ if (status >= 500 && status <= 599)
56
+ return withTextMatch(signal('overloaded', 'status', { status, retryAfterMs }), textSignal);
57
+ if (status >= 400 && status <= 499)
58
+ return withTextMatch(signal('protocol', 'status', { status, retryAfterMs }), textSignal);
59
+ }
60
+ const wsCloseCode = typeof input.wsCloseCode === 'number' ? input.wsCloseCode : undefined;
61
+ if (wsCloseCode === 1008)
62
+ return withTextMatch(signal('protocol', 'websocket-close', { retryAfterMs }), textSignal);
63
+ if (wsCloseCode === 1006)
64
+ return withTextMatch(signal('connection', 'websocket-close', { retryAfterMs }), textSignal);
65
+ if (wsCloseCode === 1011) {
66
+ const reason = text(input.wsCloseReason);
67
+ const reasonSignal = recognizeText(reason);
68
+ if (/no running broker/i.test(reason) || reasonSignal.kind === 'connection' || reasonSignal.kind === 'connection-only') {
69
+ return withTextMatch(signal('connection', 'websocket-close', { retryAfterMs }), textSignal);
70
+ }
71
+ return withTextMatch(signal('other', 'websocket-close', { retryAfterMs }), textSignal);
72
+ }
73
+ return retryAfterMs === undefined ? textSignal : { ...textSignal, retryAfterMs };
74
+ }
@@ -1,10 +1,12 @@
1
- /** The SGR escape that turns ON the distinct-surface background (theme
2
- * `selectedBg`), e.g. `\x1b[48;2;58;58;74m`. Pair with `\x1b[49m` to reset just
1
+ /** The SGR escape that turns ON the distinct-surface background (the theme's
2
+ * card plane), e.g. `\x1b[48;2;52;53;65m`. Pair with `\x1b[49m` to reset just
3
3
  * the background. Throws if no process has initialized the theme — callers on a
4
4
  * render path (the attach overlay) always have, by construction. */
5
5
  export declare function surfaceBgAnsi(): string;
6
- /** tmux flags that frame a `display-menu` / `display-popup` on the distinct
7
- * surface background — a rounded border + the surface bg on the body and border.
6
+ /** tmux flags that frame a `display-menu` / `display-popup` as a distinct
7
+ * surface — a rounded border in the theme accent around a body painted with the
8
+ * theme's card plane. The accent border is what carries the "you are somewhere
9
+ * else" signal, because a card sits close to the page by design.
8
10
  * Empty when the theme is unavailable in this process (chrome stays default; the
9
11
  * attach viewer re-installs the menu with these once it has themed). One knob:
10
12
  * every crtr tmux float shares this single definition. */
@@ -3,10 +3,24 @@
3
3
  // crtr's overlays and popups must read as a SEPARATE surface from the normal one
4
4
  // (CTO ruling, insights/terminal-ui/inline-ui-placement #11): a popup whose background matches
5
5
  // the surface has invisible edges, so you can't tell where the float begins. We
6
- // paint them with the active pi theme's `selectedBg` role — theme-derived, so it
7
- // adapts to light/dark and is ONE centralized knob, not a scattered hardcode.
6
+ // paint them from the active pi theme — theme-derived, so it adapts to light/dark
7
+ // and is ONE centralized knob, not a scattered hardcode.
8
8
  //
9
- // pi 0.79 does not re-export `selectedBg` through its public API (the `.` entry
9
+ // WHICH ROLE: `userMessageBg`, the theme's CARD plane — not `selectedBg`. Every
10
+ // theme places its card one step off the page background (gruvbox-material
11
+ // #32302f vs #282828; pi's own dark #343541, light #e8e8e8) while `selectedBg` is
12
+ // a SELECTION BAND, deliberately pushed far off the page so a highlighted row
13
+ // lifts. Painting a 90%×85% takeover with the selection band made the whole
14
+ // screen that band: every dim/muted role lost the contrast it was designed
15
+ // against (gruvbox dim text fell to 2.1:1), and the row highlight drawn ON TOP
16
+ // had nowhere left to go. The card plane keeps content legibility and leaves the
17
+ // selection band free to do its actual job inside the popup.
18
+ //
19
+ // A card sits close to the page by design, so the EDGE signal rule 11 demands
20
+ // comes from the border, not the fill: we paint the rounded border in the theme
21
+ // accent so the float's boundary is unmistakable.
22
+ //
23
+ // pi 0.79 does not re-export these roles through its public API (the `.` entry
10
24
  // omits `getResolvedThemeColors` and the live `theme` singleton). It DOES publish
11
25
  // the live `Theme` instance on a process-global symbol once `initTheme()` has run
12
26
  // — the same hook pi's own SDK reads (see broker.ts `ctx.ui.theme`). We read it
@@ -26,43 +40,46 @@ function liveTheme() {
26
40
  const t = globalThis[THEME_SYMBOL];
27
41
  return (t ?? undefined);
28
42
  }
29
- /** The SGR escape that turns ON the distinct-surface background (theme
30
- * `selectedBg`), e.g. `\x1b[48;2;58;58;74m`. Pair with `\x1b[49m` to reset just
43
+ /** The SGR escape that turns ON the distinct-surface background (the theme's
44
+ * card plane), e.g. `\x1b[48;2;52;53;65m`. Pair with `\x1b[49m` to reset just
31
45
  * the background. Throws if no process has initialized the theme — callers on a
32
46
  * render path (the attach overlay) always have, by construction. */
33
47
  export function surfaceBgAnsi() {
34
48
  const t = liveTheme();
35
49
  if (t === undefined)
36
50
  throw new Error('surfaceBgAnsi: pi theme not initialized in this process');
37
- return t.getBgAnsi('selectedBg');
51
+ return t.getBgAnsi('userMessageBg');
38
52
  }
39
- /** The distinct-surface background as a tmux colour token (`#rrggbb` on a
40
- * truecolor theme, `colourN` on a 256-colour theme), or undefined when the theme
41
- * is not yet loaded in this process OR defines no selection background. tmux
42
- * cannot read pi's theme, so we translate the SGR pi resolved for us. */
43
- function surfaceBgTmux() {
44
- const t = liveTheme();
45
- if (t === undefined)
46
- return undefined;
47
- const ansi = t.getBgAnsi('selectedBg');
48
- const tc = /48;2;(\d+);(\d+);(\d+)/.exec(ansi);
53
+ /** An SGR colour parameter (`48;2;r;g;b` or `38;5;n`) as a tmux colour token —
54
+ * `#rrggbb` on a truecolor theme, `colourN` on a 256-colour one. tmux cannot
55
+ * read pi's theme, so we translate the SGR pi already resolved for us.
56
+ * undefined when the role is empty (the SGR is a bare default-colour reset). */
57
+ function tmuxColor(ansi) {
58
+ const tc = /[34]8;2;(\d+);(\d+);(\d+)/.exec(ansi);
49
59
  if (tc !== null) {
50
60
  const hex = (n) => Number(n).toString(16).padStart(2, '0');
51
61
  return `#${hex(tc[1])}${hex(tc[2])}${hex(tc[3])}`;
52
62
  }
53
- const c256 = /48;5;(\d+)/.exec(ansi);
63
+ const c256 = /[34]8;5;(\d+)/.exec(ansi);
54
64
  if (c256 !== null)
55
65
  return `colour${c256[1]}`;
56
- return undefined; // empty/default selectedBg → nothing distinct to paint
66
+ return undefined;
57
67
  }
58
- /** tmux flags that frame a `display-menu` / `display-popup` on the distinct
59
- * surface background — a rounded border + the surface bg on the body and border.
68
+ /** tmux flags that frame a `display-menu` / `display-popup` as a distinct
69
+ * surface — a rounded border in the theme accent around a body painted with the
70
+ * theme's card plane. The accent border is what carries the "you are somewhere
71
+ * else" signal, because a card sits close to the page by design.
60
72
  * Empty when the theme is unavailable in this process (chrome stays default; the
61
73
  * attach viewer re-installs the menu with these once it has themed). One knob:
62
74
  * every crtr tmux float shares this single definition. */
63
75
  export function surfaceTmuxStyleArgs() {
64
- const bg = surfaceBgTmux();
76
+ const t = liveTheme();
77
+ if (t === undefined)
78
+ return [];
79
+ const bg = tmuxColor(t.getBgAnsi('userMessageBg'));
65
80
  if (bg === undefined)
66
81
  return [];
67
- return ['-b', 'rounded', '-s', `bg=${bg}`, '-S', `bg=${bg}`];
82
+ const accent = tmuxColor(t.getFgAnsi('accent'));
83
+ const border = accent === undefined ? `bg=${bg}` : `fg=${accent},bg=${bg}`;
84
+ return ['-b', 'rounded', '-s', `bg=${bg}`, '-S', border];
68
85
  }
@@ -1,3 +1,5 @@
1
+ export declare const META_FG = "38;5;246";
2
+ export declare const RECESSIVE_FG = "38;5;243";
1
3
  export interface Size {
2
4
  cols: number;
3
5
  rows: number;
@@ -22,6 +22,21 @@ const REVERSE = `${ESC}7m`;
22
22
  const DIM = `${ESC}2m`;
23
23
  const BOLD = `${ESC}1m`;
24
24
  const ITALIC = `${ESC}3m`;
25
+ // ── The recessive text ramp ────────────────────────────────────────────
26
+ //
27
+ // Two explicit greys for text that must sit BEHIND the primary content without
28
+ // disappearing into it. Use these instead of the two things that look equivalent
29
+ // and are not:
30
+ //
31
+ // - basic-16 bright-black (`90`) resolves against the TERMINAL's palette, which
32
+ // a dark theme routinely sets within a hair of its own background;
33
+ // - SGR DIM (`2`) is a multiplier, so stacking it on an already-dark grey lands
34
+ // at mud — and stacking it on THESE would undo the contrast they encode.
35
+ //
36
+ // Never combine either with these. Ratios below are against the two planes a
37
+ // raw-ANSI view can render on: a bare dark terminal and a themed popup card.
38
+ export const META_FG = '38;5;246'; // 4.3–4.9:1 — metadata you actively scan
39
+ export const RECESSIVE_FG = '38;5;243'; // 2.9–3.3:1 — deliberately behind, still read
25
40
  /** An SGR fg/bg parameter is digits and semicolons only (e.g. '32', '1;36', '236').
26
41
  * Guards styleSpan against a non-numeric value (a color name) producing a broken
27
42
  * escape sequence. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.273",
3
+ "version": "0.3.275",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.273",
3
+ "version": "0.3.275",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.273",
9
+ "version": "0.3.275",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {