switchroom 0.13.40 → 0.13.42

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.
@@ -27303,11 +27303,20 @@ function checkAclByAgent(config, agentName, key) {
27303
27303
  return { allow: true };
27304
27304
  }
27305
27305
  }
27306
+ const mcpServers = agentConfig.mcp_servers ?? {};
27307
+ for (const mcpEntry of Object.values(mcpServers)) {
27308
+ if (!mcpEntry || typeof mcpEntry !== "object")
27309
+ continue;
27310
+ const declared = mcpEntry.secrets;
27311
+ if (Array.isArray(declared) && declared.includes(key)) {
27312
+ return { allow: true };
27313
+ }
27314
+ }
27306
27315
  const schedule = agentConfig.schedule ?? [];
27307
27316
  if (schedule.length === 0) {
27308
27317
  return {
27309
27318
  allow: false,
27310
- reason: `agent '${agentName}' has no schedule entries declaring 'secrets'; nothing is broker-accessible`
27319
+ reason: `agent '${agentName}' has no schedule entries declaring 'secrets' and no mcp_servers.*.secrets[] declaring '${key}'; nothing is broker-accessible`
27311
27320
  };
27312
27321
  }
27313
27322
  for (const entry of schedule) {
@@ -47747,8 +47756,8 @@ var {
47747
47756
  } = import__.default;
47748
47757
 
47749
47758
  // src/build-info.ts
47750
- var VERSION = "0.13.40";
47751
- var COMMIT_SHA = "1f8f075b";
47759
+ var VERSION = "0.13.42";
47760
+ var COMMIT_SHA = "915bf972";
47752
47761
 
47753
47762
  // src/cli/agent.ts
47754
47763
  init_source();
@@ -48778,7 +48787,12 @@ function filterMcpServers(servers) {
48778
48787
  for (const [key, value] of Object.entries(servers)) {
48779
48788
  if (value === false)
48780
48789
  continue;
48781
- out[key] = value;
48790
+ if (value != null && typeof value === "object" && !Array.isArray(value)) {
48791
+ const { secrets: _secrets, ...rest } = value;
48792
+ out[key] = rest;
48793
+ } else {
48794
+ out[key] = value;
48795
+ }
48782
48796
  }
48783
48797
  return Object.keys(out).length > 0 ? out : undefined;
48784
48798
  }
@@ -49552,6 +49566,20 @@ ${body}
49552
49566
  writeFileSync5(mdPath, content, "utf-8");
49553
49567
  }
49554
49568
  }
49569
+ {
49570
+ const telegramDir = join8(agentDir, "telegram");
49571
+ if (existsSync11(telegramDir)) {
49572
+ for (const file of readdirSync5(telegramDir)) {
49573
+ const isCronScript = CRON_SCRIPT_BASENAME_RE.test(file) || LEGACY_CRON_SCRIPT_BASENAME_RE.test(file);
49574
+ if (isCronScript) {
49575
+ unlinkSync4(join8(telegramDir, file));
49576
+ const sidecar = join8(telegramDir, file.replace(/\.sh$/, ".source"));
49577
+ if (existsSync11(sidecar))
49578
+ unlinkSync4(sidecar);
49579
+ }
49580
+ }
49581
+ }
49582
+ }
49555
49583
  copyProfileSkills(profilePath, join8(agentDir, ".claude", "skills"));
49556
49584
  renderProfileClaudeTemplate(agentConfig.extends ?? DEFAULT_PROFILE);
49557
49585
  if (agentConfig.skills && agentConfig.skills.length > 0) {
@@ -774,16 +774,24 @@
774
774
  // usage % cell: live 5h/7d utilization from the last cached
775
775
  // probe (cost-gated — see refreshQuota). null → "—" + a per-
776
776
  // account ↻ that force-probes. quotaStale → value shown muted
777
- // with ↻ to refresh.
778
- const pctCell = (pct, label, stale) => {
777
+ // with ↻ to refresh. When `resetAt` is provided we append a
778
+ // muted "resets in Xh" line so the operator can see WHEN the
779
+ // window rolls over without hovering for a tooltip — matches
780
+ // anthropic-ratelimit-unified-{5h,7d}-reset headers exposed by
781
+ // the broker probe (src/auth/quota.ts:97-98).
782
+ const pctCell = (pct, label, stale, resetAt) => {
779
783
  if (pct == null) return '<span style="color:var(--text-dim)">—</span>';
780
784
  let cls = 'quota-pct';
781
785
  if (pct >= 90) cls += ' high';
782
786
  else if (pct >= 70) cls += ' mid';
783
787
  const v = `${Math.round(pct)}%`;
784
- return stale
788
+ const reset = resetAt
789
+ ? `<div style="font-size:.72rem;color:var(--text-dim);font-weight:normal;margin-top:.1rem">resets ${formatTimestamp(resetAt)}</div>`
790
+ : '';
791
+ const pctSpan = stale
785
792
  ? `<span class="${cls}" title="stale — click ↻" style="opacity:.55">${v}</span>`
786
793
  : `<span class="${cls}">${v}</span>`;
794
+ return reset ? `<div>${pctSpan}${reset}</div>` : pctSpan;
787
795
  };
788
796
  const enriched = accounts.map(a => {
789
797
  const q = a.quota || null;
@@ -792,9 +800,8 @@
792
800
  return {
793
801
  a,
794
802
  usedBy: a.usedBy || [],
795
- fiveH: pctCell(u ? u.fiveHourPct : null, '5h', a.quotaStale),
796
- sevenD: pctCell(u ? u.sevenDayPct : null, '7d', a.quotaStale),
797
- fiveReset: u && u.fiveHourResetAt ? formatTimestamp(u.fiveHourResetAt) : '<span style="color:var(--text-dim)">—</span>',
803
+ fiveH: pctCell(u ? u.fiveHourPct : null, '5h', a.quotaStale, u ? u.fiveHourResetAt : null),
804
+ sevenD: pctCell(u ? u.sevenDayPct : null, '7d', a.quotaStale, u ? u.sevenDayResetAt : null),
798
805
  captured: u && u.capturedAt
799
806
  ? formatTimestamp(u.capturedAt)
800
807
  : '<span style="color:var(--text-dim)">never</span>',
@@ -13055,11 +13055,20 @@ function checkAclByAgent(config, agentName, key) {
13055
13055
  return { allow: true };
13056
13056
  }
13057
13057
  }
13058
+ const mcpServers = agentConfig.mcp_servers ?? {};
13059
+ for (const mcpEntry of Object.values(mcpServers)) {
13060
+ if (!mcpEntry || typeof mcpEntry !== "object")
13061
+ continue;
13062
+ const declared = mcpEntry.secrets;
13063
+ if (Array.isArray(declared) && declared.includes(key)) {
13064
+ return { allow: true };
13065
+ }
13066
+ }
13058
13067
  const schedule = agentConfig.schedule ?? [];
13059
13068
  if (schedule.length === 0) {
13060
13069
  return {
13061
13070
  allow: false,
13062
- reason: `agent '${agentName}' has no schedule entries declaring 'secrets'; nothing is broker-accessible`
13071
+ reason: `agent '${agentName}' has no schedule entries declaring 'secrets' and no mcp_servers.*.secrets[] declaring '${key}'; nothing is broker-accessible`
13063
13072
  };
13064
13073
  }
13065
13074
  for (const entry of schedule) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.40",
3
+ "version": "0.13.42",
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": {
@@ -37679,7 +37679,7 @@ function clearSilentEndState(turnKey, deps) {
37679
37679
  return;
37680
37680
  try {
37681
37681
  const prev = JSON.parse(readFileSync3(statePath, "utf8"));
37682
- if (prev.turnKey !== turnKey)
37682
+ if (prev.turnKey != null && prev.turnKey !== turnKey)
37683
37683
  return;
37684
37684
  unlinkSync(statePath);
37685
37685
  emitLog(deps, `silent-end: cleared state file turnKey=${turnKey}
@@ -48730,10 +48730,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
48730
48730
  }
48731
48731
 
48732
48732
  // ../src/build-info.ts
48733
- var VERSION = "0.13.40";
48734
- var COMMIT_SHA = "1f8f075b";
48735
- var COMMIT_DATE = "2026-05-25T07:28:32Z";
48736
- var LATEST_PR = 1800;
48733
+ var VERSION = "0.13.42";
48734
+ var COMMIT_SHA = "915bf972";
48735
+ var COMMIT_DATE = "2026-05-25T08:37:49Z";
48736
+ var LATEST_PR = 1808;
48737
48737
  var COMMITS_AHEAD_OF_TAG = 0;
48738
48738
 
48739
48739
  // gateway/boot-version.ts
@@ -52580,6 +52580,7 @@ function handleSessionEvent(ev) {
52580
52580
  };
52581
52581
  currentTurn = next;
52582
52582
  preambleSuppressor.reset();
52583
+ clearSilentEndState(statusKey(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null));
52583
52584
  if (turnsDb != null) {
52584
52585
  const evThreadIdNum = ev.threadId != null ? Number(ev.threadId) : null;
52585
52586
  const turnKey = chatKeyWithSuffix(ev.chatId, evThreadIdNum, String(startedAt));
@@ -6503,6 +6503,23 @@ function handleSessionEvent(ev: SessionEvent): void {
6503
6503
  currentTurn = next
6504
6504
  // #549 fix — fresh turn, reset preamble-suppression state.
6505
6505
  preambleSuppressor.reset()
6506
+ // Reset the silent-end retry budget for this chat. The stored
6507
+ // turnKey is `chat:thread` shape (no per-instance suffix), so
6508
+ // without an explicit per-turn clear, `writeSilentEndState`
6509
+ // (silent-end.ts:114) inherits `retryCount` across turns
6510
+ // whenever a prior turn for the same chat hit retryCount=1.
6511
+ // The Stop hook then sees `retryCount >= MAX_RETRIES=1` on the
6512
+ // very first silent-end of every subsequent turn and bails
6513
+ // without re-prompting. finn hit this on 2026-05-25 with a
6514
+ // stuck retryCount=1 file. A new turn invalidates any prior
6515
+ // turn's retry budget by definition; clear it eagerly here.
6516
+ // ev.threadId is `string | null` (Telegram's wire shape);
6517
+ // statusKey wants `number | null` — same conversion as the
6518
+ // registry-key branch a few lines down.
6519
+ clearSilentEndState(statusKey(
6520
+ ev.chatId,
6521
+ ev.threadId != null ? Number(ev.threadId) : null,
6522
+ ))
6506
6523
  // Stage 3b: stamp turn-start in the registry. turn_key is
6507
6524
  // chat:thread:startTs — unique per turn, distinct from the
6508
6525
  // progress-card-driver's per-chat sequence number (these are two
@@ -150,6 +150,18 @@ export function writeSilentEndState(
150
150
  * Called the moment a reply / stream_reply first-emit lands so the
151
151
  * Stop hook doesn't fire a stale block on the next stop.
152
152
  *
153
+ * Pre-#1664 (`commit f664cde8`) state files lacked a `turnKey` field
154
+ * entirely. The strict `prev.turnKey !== turnKey` check meant
155
+ * `undefined !== <anything>` was always true, so legacy files survived
156
+ * every clear path and remained readable by the Stop hook indefinitely.
157
+ * In production this stranded clerk with `retryCount=1` for ~hours
158
+ * across two container restarts, breaking the Stop hook's retry budget
159
+ * for every subsequent silent-end (the 2026-05-25 incident).
160
+ *
161
+ * Tolerance: treat a missing `prev.turnKey` as "stale unknown, unlink
162
+ * it" rather than "preserve". Strict comparison still applies when
163
+ * both sides are present, so the same-turn invariant is preserved.
164
+ *
153
165
  * Fail-silent: missing file, mismatched turnKey, or read/unlink errors
154
166
  * are all benign. The Stop hook itself defends against stale files via
155
167
  * the retryCount mechanism.
@@ -159,7 +171,7 @@ export function clearSilentEndState(turnKey: string, deps?: SilentEndDeps): void
159
171
  if (!existsSync(statePath)) return
160
172
  try {
161
173
  const prev = JSON.parse(readFileSync(statePath, 'utf8')) as Partial<SilentEndState>
162
- if (prev.turnKey !== turnKey) return
174
+ if (prev.turnKey != null && prev.turnKey !== turnKey) return
163
175
  unlinkSync(statePath)
164
176
  emitLog(deps, `silent-end: cleared state file turnKey=${turnKey}\n`)
165
177
  } catch {
@@ -62,6 +62,38 @@ describe('silent-end.ts — gateway state writer', () => {
62
62
  expect(state!.retryCount).toBe(0)
63
63
  })
64
64
 
65
+ it('turn-start clear pattern resets retryCount for a new turn on the same chat', () => {
66
+ // 2026-05-25 finn incident: the stored turnKey is `chat:thread`
67
+ // shape (no per-instance startedAt suffix). When a prior turn for
68
+ // a chat left the silent-end-pending.json at retryCount=1 (Stop
69
+ // hook had incremented but no successful reply cleared it),
70
+ // writeSilentEndState on the next silent-end for the SAME chat
71
+ // matches `prev.turnKey === args.turnKey` and inherits
72
+ // retryCount=1 — so the Stop hook bails on the very first
73
+ // silent-end of the new turn without re-prompting.
74
+ //
75
+ // The gateway's fix (gateway.ts:6503) calls
76
+ // clearSilentEndState(statusKey(chatId, threadId)) at turn-start.
77
+ // This test pins the contract: clear-then-write yields
78
+ // retryCount=0, not the inherited 1.
79
+ const path = join(stateDir, 'silent-end-pending.json')
80
+ // Prior turn left retryCount=1 stuck on this chat.
81
+ writeFileSync(path, JSON.stringify({
82
+ chatId: '12345', threadId: null, turnKey: '12345:_',
83
+ retryCount: 1, timestamp: 0,
84
+ }))
85
+
86
+ // Gateway turn-start hook fires for a NEW turn on the same chat.
87
+ clearSilentEndState('12345:_')
88
+ expect(existsSync(path)).toBe(false)
89
+
90
+ // The new turn later ends silent → writeSilentEndState writes
91
+ // fresh state. Without the turn-start clear above, retryCount
92
+ // would have inherited as 1 (per the test at line 42 above).
93
+ writeSilentEndState({ chatId: '12345', threadId: null, turnKey: '12345:_' })
94
+ expect(readSilentEndState()!.retryCount).toBe(0)
95
+ })
96
+
65
97
  it('writeSilentEndState falls back to ~/.claude/channels/telegram when TELEGRAM_STATE_DIR is unset', () => {
66
98
  // Updated 2026-05-13 UAT overnight: discovered the writer used to
67
99
  // silently no-op when the env var was unset, while the Stop hook
@@ -106,6 +138,30 @@ describe('silent-end.ts — gateway state writer', () => {
106
138
  expect(() => clearSilentEndState('123:_')).not.toThrow()
107
139
  })
108
140
 
141
+ it('clearSilentEndState unlinks a pre-#1664 legacy-format file (no turnKey field)', () => {
142
+ // 2026-05-25 incident: clerk had a stale `{retryCount:1,timestamp:...}`
143
+ // file with NO `turnKey` field — written by a pre-#1664 build before
144
+ // the chatId/threadId/turnKey schema landed. The strict
145
+ // `prev.turnKey !== turnKey` check meant `undefined !== <anything>`
146
+ // returned true and every clear callsite no-op'd against it. The file
147
+ // survived two container restarts. When a new turn ended silently
148
+ // ~hours later, the Stop hook read the stale retryCount=1 and bailed
149
+ // without re-prompting, leaving the user with no reply for 5 minutes
150
+ // (until the framework's silence-poke fallback fired).
151
+ //
152
+ // Contract: any clearSilentEndState call must be able to evict a
153
+ // legacy file whose turnKey is missing. Strict matching still applies
154
+ // when both sides have a turnKey (the `clearSilentEndState leaves the
155
+ // file alone when turnKey does NOT match` test above pins that).
156
+ const path = join(stateDir, 'silent-end-pending.json')
157
+ writeFileSync(path, JSON.stringify({ retryCount: 1, timestamp: 1779692417907 }))
158
+ expect(existsSync(path)).toBe(true)
159
+
160
+ clearSilentEndState('any-turn-key-here')
161
+
162
+ expect(existsSync(path)).toBe(false)
163
+ })
164
+
109
165
  it('writeSilentEndState handles corrupt prior file by resetting retryCount', () => {
110
166
  const path = join(stateDir, 'silent-end-pending.json')
111
167
  writeFileSync(path, 'not valid json {{{')