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.
- package/dist/cli/switchroom.js +25 -4
- package/dist/vault/broker/server.js +17 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +6 -5
- package/telegram-plugin/gateway/gateway.ts +17 -0
- package/telegram-plugin/silent-end.ts +13 -1
- package/telegram-plugin/tests/silent-end.test.ts +56 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -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.
|
|
47751
|
-
var COMMIT_SHA = "
|
|
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
|
-
|
|
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
|
@@ -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.
|
|
48734
|
-
var COMMIT_SHA = "
|
|
48735
|
-
var COMMIT_DATE = "2026-05-
|
|
48736
|
-
var LATEST_PR =
|
|
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 {{{')
|