@commonlyai/cli 0.1.9 → 0.1.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -710,6 +710,7 @@ export const performRun = ({
710
710
  retryJitterRatio,
711
711
  claimLeaseSeconds = 90,
712
712
  cascadeCap = 3,
713
+ cascadeAddressedGrace = 2,
713
714
  cascadeResetMs = 10 * 60 * 1000,
714
715
  chatCharLimit = 400,
715
716
  maxChatChunks = 3,
@@ -729,7 +730,11 @@ export const performRun = ({
729
730
  const spawnJitterRatio = retryJitterRatio ?? spawnRetryJitter(agentName);
730
731
  // Per-seat cascade state — lives with the process, like the session store.
731
732
  // A wrapper restart forgets the streak; the decay window covers that gap.
732
- const cascadeGovernor = createCascadeGovernor({ cap: cascadeCap, resetMs: cascadeResetMs });
733
+ const cascadeGovernor = createCascadeGovernor({
734
+ cap: cascadeCap,
735
+ addressedGrace: cascadeAddressedGrace,
736
+ resetMs: cascadeResetMs,
737
+ });
733
738
  // Fairness: recent broadcast-race winners start the next race from the back.
734
739
  const claimHandicap = createClaimHandicap({ delayMs: claimYieldDelayMs });
735
740
 
@@ -798,11 +803,47 @@ export const performRun = ({
798
803
  // capped agent-triggered event is exactly the traffic we want dropped.
799
804
  // Human-triggered turns are never capped.
800
805
  const trigger = classifyTrigger(event, preSpawn);
801
- const admission = cascadeGovernor.admit(eventPodId, trigger);
806
+ // A human turn is a property of the POD, not of this seat's reaction to
807
+ // it. Record it as soon as it is classified — before the cascade check and
808
+ // before the claim race — so a seat that stands down still observes the
809
+ // reset.
810
+ //
811
+ // Without this, losing a claim on a human message is SELF-REINFORCING.
812
+ // The claim stand-down returns before `runTurn`, and `record()` lives at
813
+ // the end of `runTurn`, so the loser never records the human turn that
814
+ // would have cleared its streak. It meets the next broadcast still capped,
815
+ // declines, and again skips `record()`. The only other escape is the
816
+ // seat's own resetMs clock: stateFor reads only this process's
817
+ // lastAgentTurnAt, so a capped seat self-clears 10 minutes after its last
818
+ // completed agent turn regardless of room traffic — cyclical throttling,
819
+ // not permanent starvation.
820
+ //
821
+ // Measured in one pod, 2026-08-18, same window: ux-lead 14 lost claims /
822
+ // 73 cap refusals / 2 posts, against sprint-review 0 / 2 / 95. Losing one
823
+ // race is what kept it losing — the mechanism meant to SHARE work
824
+ // concentrated it instead.
825
+ //
826
+ // `=== 'human'` rather than `!== 'agent'`. These are behaviourally the
827
+ // same today: `record()` dispatches on exact equality and leaves every
828
+ // other value untouched, including the 'unknown' that `classifyTrigger`
829
+ // returns for an unresolvable trigger — `enforcement.js:123-131`, and the
830
+ // "'unknown' is neutral: no count, no reset" note at :130. The narrow form
831
+ // is defensive, not load-bearing: it keeps an unidentifiable event from
832
+ // becoming a cap-reset primitive if `record()` ever grows a fallthrough
833
+ // branch. It closes no hole that was open.
834
+ //
835
+ // Agent-triggered turns still record only on completion (see `runTurn`),
836
+ // so the no-double-count property for redelivered events is unchanged.
837
+ // Recording a human trigger sets the streak to 0, so this call and the
838
+ // completion-time one are idempotent with each other.
839
+ if (trigger === 'human') cascadeGovernor.record(eventPodId, trigger);
840
+ const admission = cascadeGovernor.admit(eventPodId, trigger, event.type);
802
841
  if (!admission.allowed) {
803
842
  log(
804
843
  `[${event.type}] cascade cap: ${admission.streak} consecutive agent-triggered `
805
- + `turns in pod ${eventPodId} — standing down until a human speaks or the streak decays`,
844
+ + `turns in pod ${eventPodId}`
845
+ + (admission.addressed ? ' (addressed grace also spent)' : '')
846
+ + ' — standing down until a human speaks or the pod goes quiet for the reset window',
806
847
  );
807
848
  return { outcome: 'no_action', reason: 'cascade-cap' };
808
849
  }
@@ -980,7 +1021,34 @@ export const performRun = ({
980
1021
  }
981
1022
  } else if (silentReply) {
982
1023
  const reason = heartbeatControlReply ? replyText : (replyText || 'empty output');
983
- log(`[${event.type}] no wrapper-post (${reason})`);
1024
+ // A turn that posted via tool and THEN ended with the sentinel is not the
1025
+ // same event as a turn that produced nothing — but this branch reported
1026
+ // both as `no wrapper-post (NO_REPLY)`, because it is evaluated before
1027
+ // the `agentPostedItself` branch below and swallowed that fact.
1028
+ //
1029
+ // The skill told agents to do exactly this (post via commonly_post_message,
1030
+ // then end with NO_REPLY so the wrapper does not double-post), so CORRECT
1031
+ // behaviour and total silence were indistinguishable on stdout.
1032
+ //
1033
+ // Cost, 2026-08-18: a seat was diagnosed as mute for "19 hours" on the
1034
+ // strength of `grep -c "posted via tool" == 0` against its log. It had in
1035
+ // fact posted seven times in that window. Five remedies were applied to a
1036
+ // seat that was never broken — cleared session, fresh process, MCP
1037
+ // repointed, model repinned — before the seat itself pointed at the
1038
+ // ledger. The instrument was broken, not the agent.
1039
+ //
1040
+ // Report the two cases distinctly. `posted` is the load-bearing word: a
1041
+ // reader scanning for whether a seat is contributing must not have to
1042
+ // infer it from the absence of a different line.
1043
+ if (agentPostedItself) {
1044
+ log(
1045
+ `[${event.type}] posted via tool, then ${reason} — no echo needed `
1046
+ + `(matched message ${suppressedBy.id} by ${suppressedBy.author} `
1047
+ + `via ${suppressedBy.basis})`,
1048
+ );
1049
+ } else {
1050
+ log(`[${event.type}] no wrapper-post (${reason}) — nothing posted this turn`);
1051
+ }
984
1052
  } else if (agentPostedItself) {
985
1053
  // Name the message that caused the suppression. A silently dropped reply
986
1054
  // is invisible to everyone; #757 went unnoticed precisely because this
@@ -1082,8 +1150,26 @@ export const performRun = ({
1082
1150
  circuitOpen: retry.circuitOpen,
1083
1151
  eventId: event._id,
1084
1152
  });
1085
- log(`[${event.type}] ${wrapped.message}`);
1086
- onError?.(wrapped);
1153
+ // ONE emission, not two. `wrapped.message` already opens with the
1154
+ // event type, so the log copy added a second prefix and a second
1155
+ // line of identical text into the same merged stream — enough that
1156
+ // `grep -c 'session limit'` returned 8 for 4 failures, and a reader
1157
+ // counting hits per event id saw 2 and inferred two deliveries.
1158
+ // On 2026-08-18 that count pointed at the wrong cause for #993:
1159
+ // two deliveries per event puts `attempts` at 2 and the requeue cap
1160
+ // one step away. It did not decide anything — the cap was ruled out
1161
+ // from the DB, where a capped event would have left a `failed` row
1162
+ // and none existed — but it corroborated the wrong theory, and the
1163
+ // doubling had to be spotted by hand before the count could be
1164
+ // discounted. A log that inflates is worse than one that is silent,
1165
+ // because it argues.
1166
+ //
1167
+ // Routed to the error channel when a caller provides one, and to
1168
+ // the log when it doesn't, so neither contract loses the failure:
1169
+ // an embedder that passes no `onError` still sees it, and `agent
1170
+ // run` — which always passes one — stops printing it twice.
1171
+ if (onError) onError(wrapped);
1172
+ else log(`[${event.type}] ${wrapped.message}`);
1087
1173
  break;
1088
1174
  }
1089
1175
  // Only a completed model turn proves the local runtime and delivery
@@ -1300,6 +1386,24 @@ export const performDetach = async ({
1300
1386
  return { backend: backendResult, localCleaned: true };
1301
1387
  };
1302
1388
 
1389
+ /**
1390
+ * Every line a seat emits lands in one file, because the fleet is launched as
1391
+ * `nohup commonly agent run <name> > <log> 2>&1`. Until now none of those lines
1392
+ * carried a time.
1393
+ *
1394
+ * That is not a cosmetic gap. On 2026-08-18 a fleet-wide quota stall destroyed
1395
+ * 38 queued events (#993), and the seat log was the ONLY surviving trace —
1396
+ * the kernel rows were deleted, so nothing else recorded that those turns had
1397
+ * been attempted. The log records `(4 consecutive)` and `next probe in 2.0m`
1398
+ * and gives no way to place either on a clock: the investigation had to date
1399
+ * events by decoding ObjectId prefixes instead, because the file that named
1400
+ * them could not say when.
1401
+ *
1402
+ * ISO-8601 so stamps sort lexically, diff cleanly, and line up with the
1403
+ * kernel's own timestamps without conversion.
1404
+ */
1405
+ const stamp = () => new Date().toISOString();
1406
+
1303
1407
  export const registerAgent = (program) => {
1304
1408
  const agent = program.command('agent').description('Manage agents');
1305
1409
 
@@ -1649,15 +1753,15 @@ Docs:
1649
1753
  record = await bootstrapAgentRecordFromEnv({
1650
1754
  name,
1651
1755
  adapterOverride: opts.adapter || null,
1652
- log: (line) => console.log(`[${name}] ${line}`),
1756
+ log: (line) => console.log(`${stamp()} [${name}] ${line}`),
1653
1757
  });
1654
1758
  } catch (err) {
1655
- console.error(err.message);
1759
+ console.error(`${stamp()} [${name}] ${err.message}`);
1656
1760
  process.exit(1);
1657
1761
  }
1658
1762
  if (record) {
1659
1763
  saveAgentToken(record.agentName, record);
1660
- console.log(`[${name}] bootstrapped ${tokenFile(record.agentName)} from COMMONLY_AGENT_TOKEN (adapter: ${record.adapter})`);
1764
+ console.log(`${stamp()} [${name}] bootstrapped ${tokenFile(record.agentName)} from COMMONLY_AGENT_TOKEN (adapter: ${record.adapter})`);
1661
1765
  } else {
1662
1766
  console.error(
1663
1767
  `No token for '${name}'. Either export COMMONLY_API_URL + COMMONLY_AGENT_TOKEN`
@@ -1674,7 +1778,14 @@ Docs:
1674
1778
  process.exit(1);
1675
1779
  }
1676
1780
 
1677
- console.log(`[${name}] polling ${record.instanceUrl} for events (ctrl+c to stop)`);
1781
+ // THE line to stamp, not just one of them. It is the first line of every
1782
+ // seat log and the truncation boundary — the fleet is launched as
1783
+ // `nohup … > log 2>&1`, so this banner is written at boot and everything
1784
+ // before it is gone. Stamped, it dates the restart from the log itself.
1785
+ // Unstamped, it took `ps -o lstart` to establish when nine seats came
1786
+ // back on 2026-08-18, and that only worked because the processes were
1787
+ // still alive — after the next restart that route is gone too.
1788
+ console.log(`${stamp()} [${name}] polling ${record.instanceUrl} for events (ctrl+c to stop)`);
1678
1789
 
1679
1790
  const { stop } = performRun({
1680
1791
  instanceUrl: record.instanceUrl,
@@ -1686,12 +1797,32 @@ Docs:
1686
1797
  environment: record.environment || null,
1687
1798
  workspacePath: record.workspacePath || null,
1688
1799
  intervalMs: parseInt(opts.interval, 10),
1689
- log: (line) => console.log(`[${name}] ${line}`),
1690
- onError: (err) => console.error(`[${name}] ${err.message}`),
1800
+ log: (line) => console.log(`${stamp()} [${name}] ${line}`),
1801
+ // Both sinks are stamped, and both must be — but not for the reason
1802
+ // this comment gave until now, which its own PR falsified.
1803
+ //
1804
+ // A spawn failure is routed to `onError` when a caller provides one and
1805
+ // to `log` only as the fallback, so in `agent run` — which always passes
1806
+ // one — EVERY failure line comes out of the error channel and none out
1807
+ // of the log. Stamping only `log:` would leave the entire failure class
1808
+ // undated, which is the class these stamps exist for.
1809
+ //
1810
+ // The two sinks also carry genuinely different text elsewhere: the
1811
+ // no-prompt skip logs a terse line and sends a detailed diagnostic, so
1812
+ // one stamped and one bare would date half of that pair.
1813
+ //
1814
+ // (History, because the reasoning moved twice. This originally argued
1815
+ // from a DOUBLE emission — the retry path called both sinks with
1816
+ // identical text, so `grep -c 'session limit'` returned 8 for 4 failures
1817
+ // and a per-event count read as two deliveries. That was true when the
1818
+ // stamps landed and false eleven minutes later, when the same PR
1819
+ // collapsed the duplication. The conclusion survived the premise; the
1820
+ // sentence did not, and nothing in the diff pointed at it.)
1821
+ onError: (err) => console.error(`${stamp()} [${name}] ${err.message}`),
1691
1822
  });
1692
1823
 
1693
1824
  process.on('SIGINT', () => {
1694
- console.log(`\n[${name}] stopping...`);
1825
+ console.log(`\n${stamp()} [${name}] stopping...`);
1695
1826
  stop();
1696
1827
  process.exit(0);
1697
1828
  });
@@ -474,7 +474,18 @@ export default {
474
474
  const sessionId = ctx.sessionId || randomUUID();
475
475
  const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm || '');
476
476
  const sessionFlag = isResume ? '--resume' : '--session-id';
477
- const baseArgs = ['-p', fullPrompt, '--output-format', 'text', sessionFlag, sessionId];
477
+ // Model pin from the ADR-008 environment spec. Absent it, claude picks its
478
+ // own default — which is how a fleet of ten agents ended up running three
479
+ // different Opus versions that nobody chose and nothing recorded, with one
480
+ // seat named after a model it does not run.
481
+ //
482
+ // Computed ONCE and spread into BOTH arg builders. The session-recovery
483
+ // path at the bottom of this function constructs its own array, and a model
484
+ // present in one but not the other means a retry silently runs a different
485
+ // model than the turn it is replacing — the same drifting-copy shape that
486
+ // has bitten this codebase repeatedly.
487
+ const modelArgs = ctx.environment?.model ? ['--model', String(ctx.environment.model)] : [];
488
+ const baseArgs = ['-p', fullPrompt, '--output-format', 'text', sessionFlag, sessionId, ...modelArgs];
478
489
 
479
490
  if (ctx.environment && ctx.cwd) {
480
491
  const skills = await mountSkills(ctx.environment, ctx.cwd);
@@ -529,7 +540,7 @@ export default {
529
540
  // session id poisons every subsequent event re-delivery.
530
541
  if (isResume && /already in use|no conversation|no session/i.test(String(err.message))) {
531
542
  const freshId = randomUUID();
532
- const retryBase = ['-p', fullPrompt, '--output-format', 'text', '--session-id', freshId];
543
+ const retryBase = ['-p', fullPrompt, '--output-format', 'text', '--session-id', freshId, ...modelArgs];
533
544
  const retry = await prepareArgv(retryBase, {
534
545
  ...ctx,
535
546
  mcpConfigPath: mcpConfig?.file || null,
@@ -55,6 +55,13 @@ export const classifyTrigger = (event, recentMessages) => {
55
55
  return trigger.isBot ? 'agent' : 'human';
56
56
  };
57
57
 
58
+ // Direct-address event types: the seat was NAMED (explicit @, implicit human
59
+ // reply, or DM routing). A lost claim on these does not silence the seat —
60
+ // being chosen by a human outranks being beaten to a CAS. Broadcast wakes
61
+ // (message.posted) are the opposite: nobody asked for THIS seat, so a lost
62
+ // race is a free stand-down.
63
+ export const ADDRESSED_EVENT_TYPES = new Set(['chat.mention', 'thread.mention', 'dm.message']);
64
+
58
65
  // ── cascade governor ────────────────────────────────────────────────────────
59
66
 
60
67
  /**
@@ -68,12 +75,18 @@ export const classifyTrigger = (event, recentMessages) => {
68
75
  * pod recovers on its own — a legitimate a2a handoff an hour later must not
69
76
  * inherit a stale cap).
70
77
  *
78
+ * The only other escape is the seat's own resetMs clock: stateFor reads only
79
+ * this process's lastAgentTurnAt, so a capped seat self-clears 10
80
+ * minutes after its last completed agent turn regardless of room traffic —
81
+ * cyclical throttling, not permanent starvation.
82
+ *
71
83
  * Split into admit/record so a spawn that fails (and will be redelivered)
72
84
  * never double-counts: admit() only reads, record() runs after a turn
73
85
  * actually completed. Human-triggered turns are always admitted.
74
86
  */
75
87
  export const createCascadeGovernor = ({
76
88
  cap = 3,
89
+ addressedGrace = 2,
77
90
  resetMs = 10 * 60 * 1000,
78
91
  now = Date.now,
79
92
  } = {}) => {
@@ -88,10 +101,23 @@ export const createCascadeGovernor = ({
88
101
  };
89
102
 
90
103
  return {
91
- admit(podId, trigger) {
92
- if (trigger !== 'agent') return { allowed: true, streak: 0 };
104
+ admit(podId, trigger, eventType) {
105
+ if (trigger !== 'agent') return { allowed: true, streak: 0, addressed: false };
93
106
  const s = stateFor(podId);
94
- return { allowed: s.streak < cap, streak: s.streak };
107
+ // Being NAMED outranks a mechanical brake the same judgement the claim
108
+ // path already makes forty lines down in agent.js. Without this, a peer
109
+ // can @mention a capped seat and get silence, with no signal to either
110
+ // side that anything was suppressed. Observed 2026-08-18: one seat took
111
+ // 51 wakes and 28 consecutive cap refusals, five of them chat.mention,
112
+ // and answered none of them.
113
+ //
114
+ // A GRACE, not an exemption: an unbounded pass would restore the exact
115
+ // A-mentions-B-mentions-A echo this governor exists to kill. Addressed
116
+ // turns still count toward the streak, so a mention loop terminates at
117
+ // cap + addressedGrace instead of never.
118
+ const addressed = ADDRESSED_EVENT_TYPES.has(eventType);
119
+ const limit = addressed ? cap + addressedGrace : cap;
120
+ return { allowed: s.streak < limit, streak: s.streak, addressed };
95
121
  },
96
122
  record(podId, trigger) {
97
123
  if (trigger === 'human') {
@@ -107,12 +133,6 @@ export const createCascadeGovernor = ({
107
133
 
108
134
  // ── claim fairness ──────────────────────────────────────────────────────────
109
135
 
110
- // Direct-address event types: the seat was NAMED (explicit @, implicit human
111
- // reply, or DM routing). A lost claim on these does not silence the seat —
112
- // being chosen by a human outranks being beaten to a CAS. Broadcast wakes
113
- // (message.posted) are the opposite: nobody asked for THIS seat, so a lost
114
- // race is a free stand-down.
115
- export const ADDRESSED_EVENT_TYPES = new Set(['chat.mention', 'thread.mention', 'dm.message']);
116
136
 
117
137
  // Frame prepended when an ADDRESSED seat lost the claim race: it still gets
118
138
  // its turn, but knows a peer is (probably) already answering — the bar for
@@ -29,8 +29,19 @@ import { homedir } from 'os';
29
29
  // exposes `$HOME` layout for zero server-side benefit. Callers pass
30
30
  // `envFileDir` as a separate argument to resolveWorkspace / mountSkills.
31
31
 
32
+ // `model` closes issue #774. Before it, the only way to pin a wrapper's model
33
+ // was `ANTHROPIC_MODEL` exported at `commonly agent run` time — per-process,
34
+ // never persisted, and silently dropped by any restart. Measured consequence on
35
+ // 2026-08-16: six of nine live seats had lost their assigned model, including
36
+ // ux-lead, which was supposed to be on Fable and had been running the CLI
37
+ // default for days with nothing recording the drift.
38
+ //
39
+ // Putting it in the env spec makes the model a persisted, server-side fact that
40
+ // survives restarts and is readable by the platform — which is what ADR-022's
41
+ // "persona and runtime are chosen separately" requires to mean anything for a
42
+ // BYO seat, and what lets an identity card answer "what is this running".
32
43
  const ALLOWED_TOP_KEYS = new Set([
33
- 'version', 'workspace', 'sandbox', 'skills', 'mcp',
44
+ 'version', 'workspace', 'sandbox', 'skills', 'mcp', 'model',
34
45
  ]);
35
46
  const ALLOWED_SANDBOX_MODES = new Set([
36
47
  'none', 'workspace', 'read-only', 'bwrap', 'firejail', 'container', 'managed',
@@ -112,6 +123,18 @@ export const validateEnvironmentSpec = (spec) => {
112
123
  errors.push(`version must be 1, got ${JSON.stringify(spec.version)}`);
113
124
  }
114
125
 
126
+ // Validated as an opaque non-empty string, deliberately not against a list of
127
+ // known model ids. A whitelist here would need editing every time a provider
128
+ // ships a model, and would reject a valid id the local CLI understands and
129
+ // this file does not — failing the user's attach for a fact it is not the
130
+ // authority on. The adapter passes it through to `--model`; the CLI is the
131
+ // thing that knows what is valid, and its error is the honest one.
132
+ if (spec.model !== undefined) {
133
+ if (typeof spec.model !== 'string' || spec.model.trim() === '') {
134
+ errors.push('model must be a non-empty string');
135
+ }
136
+ }
137
+
115
138
  if (spec.workspace !== undefined) {
116
139
  if (typeof spec.workspace !== 'object' || spec.workspace === null) {
117
140
  errors.push('workspace must be an object');
@@ -19,11 +19,37 @@ export const SPAWN_CIRCUIT_THRESHOLD = 3;
19
19
  export const SPAWN_RETRY_MAX_MS = 15 * 60 * 1000;
20
20
  export const SPAWN_RETRY_JITTER_MAX_RATIO = 0.2;
21
21
 
22
- // `out of credits` is codex's exact wording for an exhausted workspace balance
23
- // ("Your workspace is out of credits. Ask your workspace owner to refill…").
24
- // Without it that outage classified as RUNTIME and drew the shortest backoff —
25
- // observed live on 2026-08-03 before this pattern was added.
26
- const QUOTA_RE = /(?:quota|usage limit|credit balance|out of credits|billing|insufficient[_ -]?quota|resource exhausted|spending limit)/i;
22
+ // This list is a per-provider allowlist, and it only ever grows after an
23
+ // outage has already been misclassified. Twice now:
24
+ // 2026-08-03 codex "Your workspace is out of credits." → `out of credits`
25
+ // 2026-08-18 claude "You've hit your session limit" → `session limit`
26
+ // The second one is the instructive failure: `usage limit` was already here
27
+ // it is Claude's OTHER exhaustion wording — so the fleet stalled for an hour on
28
+ // a string one word away from a pattern we had. Both times the miss meant
29
+ // RUNTIME, the weakest class with the shortest backoff, against a provider that
30
+ // was not going to answer for hours.
31
+ //
32
+ // What a miss costs, measured against these constants (intervalMs 5000,
33
+ // jitter 0; `*` = circuit open):
34
+ //
35
+ // quota n=1,2,3 -> 900s* 900s* 900s*
36
+ // configuration n=1,2,3 -> 900s* 900s* 900s*
37
+ // rate_limit n=1,2,3 -> 60s* 120s* 240s*
38
+ // runtime n=1,2,3 -> 5s 10s 60s*
39
+ //
40
+ // So an unmatched wording is not one class off — it is 180x faster on the
41
+ // first retry than the class it belonged in, and the only class that does not
42
+ // open the circuit at n=1. That is the price of a missing string, and it is
43
+ // why the entry below is a list of exact wordings rather than a loose pattern.
44
+ //
45
+ // Note RUNTIME is also `classifySpawnFailure`'s fallthrough, so "unrecognised"
46
+ // and "transient local fault" resolve to the same, most aggressive schedule.
47
+ // That is a structural issue rather than a vocabulary one — see #996.
48
+ //
49
+ // Deliberately NOT loosened to a bare `limit`: QUOTA is tested before
50
+ // RATE_LIMIT, so that would swallow every "rate limit" error into the 15-minute
51
+ // cooldown. Add exact wordings, not looser ones.
52
+ const QUOTA_RE = /(?:quota|usage limit|session limit|credit balance|out of credits|billing|insufficient[_ -]?quota|resource exhausted|spending limit)/i;
27
53
  const RATE_LIMIT_RE = /(?:rate[ -]?limit|too many requests|\b429\b|overloaded|capacity)/i;
28
54
  const CONFIGURATION_RE = /(?:ENOENT|command not found|not on PATH|login required|not logged in|invalid api key|authentication failed|unauthori[sz]ed|forbidden|\b40[13]\b)/i;
29
55