switchroom 0.14.84 → 0.14.85

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.
@@ -49815,8 +49815,8 @@ var {
49815
49815
  } = import__.default;
49816
49816
 
49817
49817
  // src/build-info.ts
49818
- var VERSION = "0.14.84";
49819
- var COMMIT_SHA = "af97bc41";
49818
+ var VERSION = "0.14.85";
49819
+ var COMMIT_SHA = "292c683f";
49820
49820
 
49821
49821
  // src/cli/agent.ts
49822
49822
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.84",
3
+ "version": "0.14.85",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,20 +27,53 @@ import { join } from "path";
27
27
  const STATE_FILE = "credits-watch.json";
28
28
 
29
29
  /**
30
- * Possible values of `cachedExtraUsageDisabledReason` in `.claude.json`
31
- * that warrant a user-facing notification. Other values (null,
32
- * undefined, transient unknowns) are treated as "no notification
33
- * needed".
30
+ * Which `cachedExtraUsageDisabledReason` values warrant a user-facing alarm.
34
31
  *
35
- * Conservative list: only fatal-billing reasons. We don't want to fire
36
- * on every transient API blip the cache happens to write.
32
+ * IMPORTANT empty by default (subscription-only model). The flag name says it
33
+ * all: `cachedExtraUsageDisabledReason` is *always* "why **extra usage** (the
34
+ * optional pay-as-you-go layer beyond the Pro/Max plan) is disabled." switchroom
35
+ * is subscription-only by design (compliance pillar 3 — "the plan is the
36
+ * ceiling, no API/pay-as-you-go"), so extra usage being OFF is the **expected,
37
+ * desired** state. Every value (`out_of_credits`, `extra_usage_disabled`,
38
+ * `credits_exhausted`, even `org_level_disabled` = "org admin disabled extra
39
+ * usage") describes that benign state — none indicates a real switchroom
40
+ * failure. Firing a "cron + replies will fail, buy credits" card on it is a
41
+ * false alarm AND advises something that contradicts the subscription-honest
42
+ * model.
43
+ *
44
+ * The genuine failure modes are covered elsewhere:
45
+ * - an account's subscription window exhausts → stderr → fleet auto-fallback
46
+ * (model-unavailable.ts → fireFleetAutoFallback) rolls to a healthy account;
47
+ * - the WHOLE fleet exhausts → the quota-watch all-exhausted operator alert.
48
+ *
49
+ * So credits-watch defaults to silent. An operator who actually runs on
50
+ * pay-as-you-go and wants the alarm can opt specific reasons back in via
51
+ * `SWITCHROOM_CREDITS_WATCH_FATAL_REASONS` (comma-separated). See
52
+ * `resolveCreditWatchFatalReasons`.
37
53
  */
38
- const FATAL_REASONS = new Set([
54
+ export const DEFAULT_CREDIT_FATAL_REASONS = new Set<string>();
55
+
56
+ /** All reason strings recognized for opt-in via the env var. */
57
+ const KNOWN_CREDIT_REASONS = [
39
58
  "out_of_credits",
40
59
  "org_level_disabled",
41
60
  "credits_exhausted",
42
61
  "extra_usage_disabled",
43
- ]);
62
+ ] as const;
63
+
64
+ /**
65
+ * Resolve the active fatal-reason set. Empty by default (subscription-only);
66
+ * if `SWITCHROOM_CREDITS_WATCH_FATAL_REASONS` is set, parse it as a
67
+ * comma-separated list (tokens kept verbatim — an unknown token is harmless,
68
+ * it simply never matches a real reason value). `*` opts in all known reasons.
69
+ */
70
+ export function resolveCreditWatchFatalReasons(env: NodeJS.ProcessEnv): Set<string> {
71
+ const raw = env.SWITCHROOM_CREDITS_WATCH_FATAL_REASONS;
72
+ if (!raw || raw.trim().length === 0) return new Set(DEFAULT_CREDIT_FATAL_REASONS);
73
+ if (raw.trim() === "*") return new Set(KNOWN_CREDIT_REASONS);
74
+ const wanted = new Set(raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0));
75
+ return wanted;
76
+ }
44
77
 
45
78
  export interface CreditState {
46
79
  /** Last reason we notified the user about. null when healthy / never notified. */
@@ -103,15 +136,22 @@ export function evaluateCreditState(args: {
103
136
  currentReason: string | null;
104
137
  prev: CreditState;
105
138
  now: number;
139
+ /**
140
+ * Which reasons are treated as fatal (worth alarming). Defaults to
141
+ * `DEFAULT_CREDIT_FATAL_REASONS` (empty — subscription-only: extra-usage-off
142
+ * is never a real failure). The gateway passes the env-resolved set.
143
+ */
144
+ fatalReasons?: Set<string>;
106
145
  }): CreditDecision {
107
146
  const { agentName, currentReason, prev, now } = args;
147
+ const fatalReasons = args.fatalReasons ?? DEFAULT_CREDIT_FATAL_REASONS;
108
148
 
109
149
  // Non-fatal current state (null, or some unknown reason) — no
110
150
  // notification regardless of prev (we already notified on entry to
111
151
  // fatal; recovery from a known-fatal state below is the only path
112
152
  // that fires when current is null).
113
- const currentIsFatal = currentReason != null && FATAL_REASONS.has(currentReason);
114
- const prevIsFatal = prev.lastNotifiedReason != null && FATAL_REASONS.has(prev.lastNotifiedReason);
153
+ const currentIsFatal = currentReason != null && fatalReasons.has(currentReason);
154
+ const prevIsFatal = prev.lastNotifiedReason != null && fatalReasons.has(prev.lastNotifiedReason);
115
155
 
116
156
  // Recovery path: last-notified was fatal, current is null/non-fatal.
117
157
  if (!currentIsFatal && prevIsFatal) {
@@ -52507,12 +52507,22 @@ function extractFlowItems(line) {
52507
52507
  import { readFileSync as readFileSync32, writeFileSync as writeFileSync21, existsSync as existsSync33, mkdirSync as mkdirSync21 } from "fs";
52508
52508
  import { join as join30 } from "path";
52509
52509
  var STATE_FILE = "credits-watch.json";
52510
- var FATAL_REASONS = new Set([
52510
+ var DEFAULT_CREDIT_FATAL_REASONS = new Set;
52511
+ var KNOWN_CREDIT_REASONS = [
52511
52512
  "out_of_credits",
52512
52513
  "org_level_disabled",
52513
52514
  "credits_exhausted",
52514
52515
  "extra_usage_disabled"
52515
- ]);
52516
+ ];
52517
+ function resolveCreditWatchFatalReasons(env) {
52518
+ const raw = env.SWITCHROOM_CREDITS_WATCH_FATAL_REASONS;
52519
+ if (!raw || raw.trim().length === 0)
52520
+ return new Set(DEFAULT_CREDIT_FATAL_REASONS);
52521
+ if (raw.trim() === "*")
52522
+ return new Set(KNOWN_CREDIT_REASONS);
52523
+ const wanted = new Set(raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0));
52524
+ return wanted;
52525
+ }
52516
52526
  function emptyCreditState() {
52517
52527
  return { lastNotifiedReason: null, lastNotifiedAt: 0 };
52518
52528
  }
@@ -52541,8 +52551,9 @@ function readClaudeJsonOverage(claudeConfigDir) {
52541
52551
  }
52542
52552
  function evaluateCreditState(args) {
52543
52553
  const { agentName: agentName3, currentReason, prev, now } = args;
52544
- const currentIsFatal = currentReason != null && FATAL_REASONS.has(currentReason);
52545
- const prevIsFatal = prev.lastNotifiedReason != null && FATAL_REASONS.has(prev.lastNotifiedReason);
52554
+ const fatalReasons = args.fatalReasons ?? DEFAULT_CREDIT_FATAL_REASONS;
52555
+ const currentIsFatal = currentReason != null && fatalReasons.has(currentReason);
52556
+ const prevIsFatal = prev.lastNotifiedReason != null && fatalReasons.has(prev.lastNotifiedReason);
52546
52557
  if (!currentIsFatal && prevIsFatal) {
52547
52558
  return {
52548
52559
  kind: "notify",
@@ -52869,9 +52880,9 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
52869
52880
  }
52870
52881
 
52871
52882
  // ../src/build-info.ts
52872
- var VERSION = "0.14.84";
52873
- var COMMIT_SHA = "af97bc41";
52874
- var COMMIT_DATE = "2026-06-07T13:38:19+10:00";
52883
+ var VERSION = "0.14.85";
52884
+ var COMMIT_SHA = "292c683f";
52885
+ var COMMIT_DATE = "2026-06-07T14:50:17+10:00";
52875
52886
  var LATEST_PR = null;
52876
52887
  var COMMITS_AHEAD_OF_TAG = 2;
52877
52888
 
@@ -61040,7 +61051,8 @@ async function runCreditWatch() {
61040
61051
  agentName: agentName3,
61041
61052
  currentReason: reason,
61042
61053
  prev,
61043
- now: Date.now()
61054
+ now: Date.now(),
61055
+ fatalReasons: resolveCreditWatchFatalReasons(process.env)
61044
61056
  });
61045
61057
  if (decision.kind === "skip") {
61046
61058
  return;
@@ -408,6 +408,7 @@ import { synthesizeAllowRuleDiff, extractAddedAllowRule } from '../permission-di
408
408
  import {
409
409
  readClaudeJsonOverage,
410
410
  evaluateCreditState,
411
+ resolveCreditWatchFatalReasons,
411
412
  loadCreditState,
412
413
  saveCreditState,
413
414
  } from '../credits-watch.js'
@@ -14742,6 +14743,9 @@ async function runCreditWatch(): Promise<void> {
14742
14743
  currentReason: reason,
14743
14744
  prev,
14744
14745
  now: Date.now(),
14746
+ // Subscription-only: empty by default → extra-usage-off never alarms.
14747
+ // Opt back in via SWITCHROOM_CREDITS_WATCH_FATAL_REASONS. See credits-watch.ts.
14748
+ fatalReasons: resolveCreditWatchFatalReasons(process.env),
14745
14749
  })
14746
14750
  if (decision.kind === 'skip') {
14747
14751
  return
@@ -13,6 +13,7 @@ import { join } from "path";
13
13
  import {
14
14
  readClaudeJsonOverage,
15
15
  evaluateCreditState,
16
+ resolveCreditWatchFatalReasons,
16
17
  loadCreditState,
17
18
  saveCreditState,
18
19
  emptyCreditState,
@@ -81,10 +82,14 @@ describe("readClaudeJsonOverage", () => {
81
82
  });
82
83
  });
83
84
 
84
- describe("evaluateCreditState — transition decisions", () => {
85
+ describe("evaluateCreditState — transition decisions (machinery, explicit fatal set)", () => {
85
86
  const NOW = 1_780_000_000_000;
86
87
  const HEALTHY = emptyCreditState();
87
88
  const FATAL_OUT = { lastNotifiedReason: "out_of_credits", lastNotifiedAt: NOW - 1000 };
89
+ // The transition machinery is policy-agnostic — pass an explicit fatal set so
90
+ // these tests pin entered/changed/exited behaviour independent of the (now
91
+ // empty) subscription-only default.
92
+ const FATAL = new Set(["out_of_credits", "org_level_disabled", "credits_exhausted", "extra_usage_disabled"]);
88
93
 
89
94
  it("entry: healthy → fatal triggers a notify", () => {
90
95
  const d = evaluateCreditState({
@@ -92,6 +97,7 @@ describe("evaluateCreditState — transition decisions", () => {
92
97
  currentReason: "out_of_credits",
93
98
  prev: HEALTHY,
94
99
  now: NOW,
100
+ fatalReasons: FATAL,
95
101
  });
96
102
  expect(d.kind).toBe("notify");
97
103
  if (d.kind !== "notify") return;
@@ -108,6 +114,7 @@ describe("evaluateCreditState — transition decisions", () => {
108
114
  currentReason: "out_of_credits",
109
115
  prev: FATAL_OUT,
110
116
  now: NOW,
117
+ fatalReasons: FATAL,
111
118
  });
112
119
  expect(d.kind).toBe("skip");
113
120
  if (d.kind !== "skip") return;
@@ -120,6 +127,7 @@ describe("evaluateCreditState — transition decisions", () => {
120
127
  currentReason: "org_level_disabled",
121
128
  prev: FATAL_OUT,
122
129
  now: NOW,
130
+ fatalReasons: FATAL,
123
131
  });
124
132
  expect(d.kind).toBe("notify");
125
133
  if (d.kind !== "notify") return;
@@ -134,6 +142,7 @@ describe("evaluateCreditState — transition decisions", () => {
134
142
  currentReason: null,
135
143
  prev: FATAL_OUT,
136
144
  now: NOW,
145
+ fatalReasons: FATAL,
137
146
  });
138
147
  expect(d.kind).toBe("notify");
139
148
  if (d.kind !== "notify") return;
@@ -148,6 +157,7 @@ describe("evaluateCreditState — transition decisions", () => {
148
157
  currentReason: "some_unknown_transient_reason",
149
158
  prev: HEALTHY,
150
159
  now: NOW,
160
+ fatalReasons: FATAL,
151
161
  });
152
162
  expect(d.kind).toBe("skip");
153
163
  if (d.kind !== "skip") return;
@@ -160,6 +170,7 @@ describe("evaluateCreditState — transition decisions", () => {
160
170
  currentReason: null,
161
171
  prev: HEALTHY,
162
172
  now: NOW,
173
+ fatalReasons: FATAL,
163
174
  });
164
175
  expect(d.kind).toBe("skip");
165
176
  });
@@ -170,6 +181,7 @@ describe("evaluateCreditState — transition decisions", () => {
170
181
  currentReason: "out_of_credits",
171
182
  prev: HEALTHY,
172
183
  now: NOW,
184
+ fatalReasons: FATAL,
173
185
  });
174
186
  expect(d.kind).toBe("notify");
175
187
  if (d.kind !== "notify") return;
@@ -178,6 +190,58 @@ describe("evaluateCreditState — transition decisions", () => {
178
190
  });
179
191
  });
180
192
 
193
+ describe("evaluateCreditState — subscription-only default (the fix)", () => {
194
+ const NOW = 1_780_000_000_000;
195
+ const HEALTHY = emptyCreditState();
196
+
197
+ // With the default (empty) fatal set, NONE of the extra-usage reasons alarm —
198
+ // because for subscription-only switchroom, extra-usage-off is the expected
199
+ // state and real exhaustion is handled by failover. This is the bug fix.
200
+ for (const reason of ["out_of_credits", "extra_usage_disabled", "credits_exhausted", "org_level_disabled"]) {
201
+ it(`default: '${reason}' does NOT alarm (no false 'out of credits' card)`, () => {
202
+ const d = evaluateCreditState({
203
+ agentName: "clerk",
204
+ currentReason: reason,
205
+ prev: HEALTHY,
206
+ now: NOW,
207
+ // fatalReasons omitted → DEFAULT_CREDIT_FATAL_REASONS (empty)
208
+ });
209
+ expect(d.kind).toBe("skip");
210
+ });
211
+ }
212
+
213
+ it("opt-in via explicit set restores the alarm (operator on pay-as-you-go)", () => {
214
+ const d = evaluateCreditState({
215
+ agentName: "clerk",
216
+ currentReason: "out_of_credits",
217
+ prev: HEALTHY,
218
+ now: NOW,
219
+ fatalReasons: new Set(["out_of_credits"]),
220
+ });
221
+ expect(d.kind).toBe("notify");
222
+ });
223
+ });
224
+
225
+ describe("resolveCreditWatchFatalReasons", () => {
226
+ it("defaults to EMPTY (subscription-only)", () => {
227
+ expect(resolveCreditWatchFatalReasons({}).size).toBe(0);
228
+ });
229
+ it("parses a comma-separated opt-in list", () => {
230
+ const s = resolveCreditWatchFatalReasons({ SWITCHROOM_CREDITS_WATCH_FATAL_REASONS: "out_of_credits, org_level_disabled" });
231
+ expect(s.has("out_of_credits")).toBe(true);
232
+ expect(s.has("org_level_disabled")).toBe(true);
233
+ expect(s.size).toBe(2);
234
+ });
235
+ it("'*' opts in all known reasons", () => {
236
+ const s = resolveCreditWatchFatalReasons({ SWITCHROOM_CREDITS_WATCH_FATAL_REASONS: "*" });
237
+ expect(s.has("out_of_credits")).toBe(true);
238
+ expect(s.size).toBeGreaterThanOrEqual(4);
239
+ });
240
+ it("blank/whitespace → empty", () => {
241
+ expect(resolveCreditWatchFatalReasons({ SWITCHROOM_CREDITS_WATCH_FATAL_REASONS: " " }).size).toBe(0);
242
+ });
243
+ });
244
+
181
245
  describe("loadCreditState / saveCreditState — round-trip", () => {
182
246
  let tmp: string;
183
247