switchroom 0.13.41 → 0.13.43

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,27 @@ function checkAclByAgent(config, agentName, key) {
27303
27303
  return { allow: true };
27304
27304
  }
27305
27305
  }
27306
+ const cfgWithProfiles = config;
27307
+ const profileName = agentConfig.extends;
27308
+ const profileMcp = profileName != null && profileName.length > 0 ? cfgWithProfiles.profiles?.[profileName]?.mcp_servers ?? {} : {};
27309
+ const effectiveMcp = {
27310
+ ...cfgWithProfiles.defaults?.mcp_servers ?? {},
27311
+ ...profileMcp,
27312
+ ...agentConfig.mcp_servers ?? {}
27313
+ };
27314
+ for (const mcpEntry of Object.values(effectiveMcp)) {
27315
+ if (!mcpEntry || typeof mcpEntry !== "object")
27316
+ continue;
27317
+ const declared = mcpEntry.secrets;
27318
+ if (Array.isArray(declared) && declared.includes(key)) {
27319
+ return { allow: true };
27320
+ }
27321
+ }
27306
27322
  const schedule = agentConfig.schedule ?? [];
27307
27323
  if (schedule.length === 0) {
27308
27324
  return {
27309
27325
  allow: false,
27310
- reason: `agent '${agentName}' has no schedule entries declaring 'secrets'; nothing is broker-accessible`
27326
+ reason: `agent '${agentName}' has no schedule entries declaring 'secrets' and no mcp_servers.*.secrets[] declaring '${key}'; nothing is broker-accessible`
27311
27327
  };
27312
27328
  }
27313
27329
  for (const entry of schedule) {
@@ -47747,8 +47763,8 @@ var {
47747
47763
  } = import__.default;
47748
47764
 
47749
47765
  // src/build-info.ts
47750
- var VERSION = "0.13.41";
47751
- var COMMIT_SHA = "c5897e47";
47766
+ var VERSION = "0.13.43";
47767
+ var COMMIT_SHA = "58ee3f04";
47752
47768
 
47753
47769
  // src/cli/agent.ts
47754
47770
  init_source();
@@ -48778,7 +48794,12 @@ function filterMcpServers(servers) {
48778
48794
  for (const [key, value] of Object.entries(servers)) {
48779
48795
  if (value === false)
48780
48796
  continue;
48781
- out[key] = value;
48797
+ if (value != null && typeof value === "object" && !Array.isArray(value)) {
48798
+ const { secrets: _secrets, ...rest } = value;
48799
+ out[key] = rest;
48800
+ } else {
48801
+ out[key] = value;
48802
+ }
48782
48803
  }
48783
48804
  return Object.keys(out).length > 0 ? out : undefined;
48784
48805
  }
@@ -13055,11 +13055,27 @@ function checkAclByAgent(config, agentName, key) {
13055
13055
  return { allow: true };
13056
13056
  }
13057
13057
  }
13058
+ const cfgWithProfiles = config;
13059
+ const profileName = agentConfig.extends;
13060
+ const profileMcp = profileName != null && profileName.length > 0 ? cfgWithProfiles.profiles?.[profileName]?.mcp_servers ?? {} : {};
13061
+ const effectiveMcp = {
13062
+ ...cfgWithProfiles.defaults?.mcp_servers ?? {},
13063
+ ...profileMcp,
13064
+ ...agentConfig.mcp_servers ?? {}
13065
+ };
13066
+ for (const mcpEntry of Object.values(effectiveMcp)) {
13067
+ if (!mcpEntry || typeof mcpEntry !== "object")
13068
+ continue;
13069
+ const declared = mcpEntry.secrets;
13070
+ if (Array.isArray(declared) && declared.includes(key)) {
13071
+ return { allow: true };
13072
+ }
13073
+ }
13058
13074
  const schedule = agentConfig.schedule ?? [];
13059
13075
  if (schedule.length === 0) {
13060
13076
  return {
13061
13077
  allow: false,
13062
- reason: `agent '${agentName}' has no schedule entries declaring 'secrets'; nothing is broker-accessible`
13078
+ reason: `agent '${agentName}' has no schedule entries declaring 'secrets' and no mcp_servers.*.secrets[] declaring '${key}'; nothing is broker-accessible`
13063
13079
  };
13064
13080
  }
13065
13081
  for (const entry of schedule) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.41",
3
+ "version": "0.13.43",
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.41";
48734
- var COMMIT_SHA = "c5897e47";
48735
- var COMMIT_DATE = "2026-05-25T07:56:53Z";
48736
- var LATEST_PR = 1803;
48733
+ var VERSION = "0.13.43";
48734
+ var COMMIT_SHA = "58ee3f04";
48735
+ var COMMIT_DATE = "2026-05-25T09:03:16Z";
48736
+ var LATEST_PR = 1810;
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 {{{')