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.
- package/dist/cli/switchroom.js +32 -4
- package/dist/cli/ui/index.html +13 -6
- package/dist/vault/broker/server.js +10 -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,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.
|
|
47751
|
-
var COMMIT_SHA = "
|
|
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
|
-
|
|
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) {
|
package/dist/cli/ui/index.html
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
@@ -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.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 {{{')
|