switchroom 0.18.13 → 0.18.15
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/agent-scheduler/index.js +49 -9
- package/dist/auth-broker/index.js +152 -46
- package/dist/cli/autoaccept-poll.js +23 -0
- package/dist/cli/drive-write-pretool.mjs +24 -1
- package/dist/cli/foreground-hog-pretool.mjs +264 -0
- package/dist/cli/notion-write-pretool.mjs +0 -1
- package/dist/cli/switchroom.js +1185 -1072
- package/dist/host-control/main.js +53 -52
- package/dist/vault/approvals/kernel-server.js +16 -13
- package/dist/vault/broker/server.js +672 -669
- package/package.json +1 -1
- package/profiles/coding/CLAUDE.md.hbs +2 -0
- package/profiles/default/CLAUDE.md.hbs +2 -0
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/telegram-plugin/auth-snapshot-format.ts +37 -5
- package/telegram-plugin/auto-fallback-fleet.ts +29 -1
- package/telegram-plugin/bridge/bridge.ts +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +23 -0
- package/telegram-plugin/dist/gateway/gateway.js +765 -67
- package/telegram-plugin/dist/server.js +24 -1
- package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +14 -0
- package/telegram-plugin/gateway/forward-origin.ts +235 -0
- package/telegram-plugin/gateway/gateway.ts +270 -10
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
- package/telegram-plugin/history.ts +55 -6
- package/telegram-plugin/model-unavailable.ts +234 -2
- package/telegram-plugin/render/rich-render.ts +40 -32
- package/telegram-plugin/runtime-metrics.ts +31 -0
- package/telegram-plugin/session-tail.ts +14 -2
- package/telegram-plugin/stream-controller.ts +3 -2
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
- package/telegram-plugin/tests/forward-origin.test.ts +309 -0
- package/telegram-plugin/tests/history.test.ts +157 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +187 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +55 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
- package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
- package/telegram-plugin/tests/runtime-metrics.test.ts +24 -0
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
- package/telegram-plugin/tests/status-accent.test.ts +5 -3
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
- package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
- package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +454 -0
- package/telegram-plugin/throttle-tier.ts +323 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
3
|
"//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
|
|
4
|
-
"version": "0.18.
|
|
4
|
+
"version": "0.18.15",
|
|
5
5
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -51,6 +51,8 @@ Save proactively: architecture decisions, codebase patterns, conventions, known
|
|
|
51
51
|
|
|
52
52
|
If sub-agents are configured, delegate implementation to `@worker` (background, own worktree) and research to `@researcher` (background). Keep your turns short — dispatch and acknowledge quickly so you stay available for the user.
|
|
53
53
|
|
|
54
|
+
If the user amends in-flight delegated work mid-turn, steer the running worker now (`SendMessage` to the worker by name, or by the agent id from its spawn result) instead of holding the update for handback — and say in your reply whether you folded the update into the running worker or queued it as a separate task; never classify silently. If unsure whether a message amends in-flight work, queue it and say so — queue is the default. If the steer lands too late (worker effectively done), say so and apply the update yourself.
|
|
55
|
+
|
|
54
56
|
{{#if schedule}}
|
|
55
57
|
## Schedule
|
|
56
58
|
You are running on a schedule: `{{schedule}}`. Use scheduled runs to check CI status, review open PRs, or surface stale issues.
|
|
@@ -92,6 +92,8 @@ The main session is for conversation. Execution belongs in sub-agents. Before ma
|
|
|
92
92
|
|
|
93
93
|
**Anti-patterns:** starting a task inline then realizing it's complex mid-way; doing 5+ tool calls "because it's almost done"; polling sub-agent status in a loop.
|
|
94
94
|
|
|
95
|
+
**Steer running workers — don't sit on amendments.** When a mid-turn user message amends work you've already delegated to a running sub-agent, forward the amendment to that worker now — `SendMessage` to the worker by name (or by the agent id from the Agent tool's spawn result, if it has no name) — rather than holding it for handback. If the message is a new independent task, queue it instead; if you're unsure whether it amends the in-flight work, queue it and say so — queue is the default. Either way, state in your reply which you did ("folded your update into the running worker" / "queued as a separate task after the current one") — the steer-or-queue rule is that the classification is always visible in chat, never inferred. If the steer arrives too late (the worker is effectively done), say that and apply the update yourself in the parent.
|
|
96
|
+
|
|
95
97
|
If no sub-agents are configured, do the work yourself.
|
|
96
98
|
|
|
97
99
|
## Session Continuity
|
|
@@ -81,7 +81,6 @@ agents:
|
|
|
81
81
|
| Var | Source | Purpose |
|
|
82
82
|
|-----|--------|---------|
|
|
83
83
|
| `SWITCHROOM_TG_FORMAT` | `channels.telegram.format` | Default reply format |
|
|
84
|
-
| `SWITCHROOM_TG_RATE_LIMIT_MS` | `channels.telegram.rate_limit_ms` | Min delay between outgoing messages |
|
|
85
84
|
| `TELEGRAM_STATE_DIR` | Auto-set by scaffold | Path to `telegram/` dir |
|
|
86
85
|
| `SWITCHROOM_AGENT_NAME` | Auto-set by scaffold | Agent name (used for self-restart detection) |
|
|
87
86
|
| `SWITCHROOM_CONFIG` | Auto-set by scaffold | Path to switchroom.yaml |
|
|
@@ -704,6 +704,22 @@ export interface FallbackAnnouncementInput {
|
|
|
704
704
|
* shows the target's headroom and is unchanged.
|
|
705
705
|
*/
|
|
706
706
|
fleetSnapshots?: AccountSnapshot[];
|
|
707
|
+
/**
|
|
708
|
+
* 429 throttle tier enrichment — the reset time PARSED from the error
|
|
709
|
+
* prose ("resets 8:50am (TZ)" / "retry after 60s") that triggered the
|
|
710
|
+
* fallback. Fallback for the recovery line when the old account's probe
|
|
711
|
+
* carried no reset (probe failed / thin headers), so the announcement
|
|
712
|
+
* still names when the account frees. A probe-derived reset wins when
|
|
713
|
+
* present — it is the fresher, server-authoritative signal.
|
|
714
|
+
*/
|
|
715
|
+
parsedResetAt?: Date | null;
|
|
716
|
+
/**
|
|
717
|
+
* 429 throttle tier escalation — the trigger was a long-reset transient
|
|
718
|
+
* RATE LIMIT, not a quota wall. Names the headline honestly ("rate limit
|
|
719
|
+
* on X" instead of a utilization-derived "5-hour limit on X", which would
|
|
720
|
+
* be wrong: a rate-limited account's utilization is typically LOW).
|
|
721
|
+
*/
|
|
722
|
+
cause?: 'rate-limit';
|
|
707
723
|
tz?: string;
|
|
708
724
|
now?: Date;
|
|
709
725
|
}
|
|
@@ -728,7 +744,12 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
|
|
|
728
744
|
const lines: string[] = [];
|
|
729
745
|
|
|
730
746
|
const limitWord = input.oldQuota ? limitWordFor(input.oldQuota) : 'quota';
|
|
731
|
-
const headerLimit =
|
|
747
|
+
const headerLimit =
|
|
748
|
+
input.cause === 'rate-limit'
|
|
749
|
+
? 'rate limit'
|
|
750
|
+
: limitWord === 'quota'
|
|
751
|
+
? 'quota cap'
|
|
752
|
+
: `${limitWord} limit`;
|
|
732
753
|
|
|
733
754
|
if (!input.newLabel) {
|
|
734
755
|
// All-blocked path — no swap occurred. Tell user what's broken and, so they
|
|
@@ -765,9 +786,14 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
|
|
|
765
786
|
`${formatAbsolute(earliest.at, tz)} (in ${formatRelative(earliest.at, now)})`,
|
|
766
787
|
);
|
|
767
788
|
}
|
|
768
|
-
} else
|
|
789
|
+
} else {
|
|
769
790
|
// Back-compat: no fleet snapshot supplied → old single-account shape.
|
|
770
|
-
|
|
791
|
+
// Probe-derived reset first; the prose-parsed reset (429 throttle tier
|
|
792
|
+
// enrichment) covers the probe-failed case.
|
|
793
|
+
const recovery =
|
|
794
|
+
(input.oldQuota ? recoveryAtFor(input.oldQuota) : null) ??
|
|
795
|
+
input.parsedResetAt ??
|
|
796
|
+
null;
|
|
771
797
|
if (recovery) {
|
|
772
798
|
lines.push(
|
|
773
799
|
`${escapeMarkdown(input.oldLabel)} recovers ${formatAbsolute(recovery, tz)} ` +
|
|
@@ -794,8 +820,14 @@ export function renderFallbackAnnouncement(input: FallbackAnnouncementInput): st
|
|
|
794
820
|
lines.push(`Triggered by: agent **${escapeMarkdown(input.triggerAgent)}**`);
|
|
795
821
|
lines.push('');
|
|
796
822
|
|
|
797
|
-
|
|
798
|
-
|
|
823
|
+
{
|
|
824
|
+
// Probe-derived reset first; the prose-parsed reset (429 throttle tier
|
|
825
|
+
// enrichment) keeps the recovery line honest when the old account's
|
|
826
|
+
// probe failed at the moment of the wall.
|
|
827
|
+
const recovery =
|
|
828
|
+
(input.oldQuota ? recoveryAtFor(input.oldQuota) : null) ??
|
|
829
|
+
input.parsedResetAt ??
|
|
830
|
+
null;
|
|
799
831
|
if (recovery) {
|
|
800
832
|
lines.push(
|
|
801
833
|
`\`${codeSpanSafe(input.oldLabel)}\` recovers ` +
|
|
@@ -183,6 +183,26 @@ export interface FleetFallbackDeps {
|
|
|
183
183
|
/** Operator timezone for absolute reset times in the announcement. */
|
|
184
184
|
tz?: string;
|
|
185
185
|
now?: Date;
|
|
186
|
+
/**
|
|
187
|
+
* The reset time PARSED from the triggering error prose (429 throttle tier
|
|
188
|
+
* enrichment) — threaded into the announcement as the recovery-line
|
|
189
|
+
* fallback when the old account's live probe carried no reset.
|
|
190
|
+
*/
|
|
191
|
+
parsedResetAt?: Date;
|
|
192
|
+
/**
|
|
193
|
+
* 429 throttle tier escalation: the trigger is a TERMINAL transient 429
|
|
194
|
+
* whose parsed reset lies beyond the retry-in-place threshold. Its wording
|
|
195
|
+
* explicitly NEGATES the usage-limit reading, so the old account's
|
|
196
|
+
* UTILIZATION probe typically classifies healthy — the healthy-idempotency
|
|
197
|
+
* guard would self-cancel the swap ("probed healthy / Stale event?"),
|
|
198
|
+
* silently dropping the spec's ">threshold → mark + fail over" leg. When
|
|
199
|
+
* set, the terminal parsed-reset signal is trusted over the utilization
|
|
200
|
+
* probe: the guard is bypassed and the swap proceeds (the broker mark
|
|
201
|
+
* honors the caller-passed `until` — the parsed reset). Staleness safety
|
|
202
|
+
* holds upstream: the flag is only set for a terminal error line the
|
|
203
|
+
* session-tail just read, never for replayed/late events.
|
|
204
|
+
*/
|
|
205
|
+
rateLimitTrigger?: boolean;
|
|
186
206
|
}
|
|
187
207
|
|
|
188
208
|
/**
|
|
@@ -217,8 +237,12 @@ export async function runFleetAutoFallback(
|
|
|
217
237
|
// normalization uses the same clock as the rest of the decision (a default
|
|
218
238
|
// `new Date()` would diverge from `deps.now` and could mis-zero a window
|
|
219
239
|
// whose reset is still future relative to the event's clock).
|
|
240
|
+
// 429 throttle tier: a rate-limit trigger's wording NEGATES the usage-limit
|
|
241
|
+
// reading, so healthy utilization is the EXPECTED state, not evidence of a
|
|
242
|
+
// stale event — the guard must not self-cancel that swap (see
|
|
243
|
+
// FleetFallbackDeps.rateLimitTrigger).
|
|
220
244
|
const oldHealth = classifyHealth(oldSnap, now);
|
|
221
|
-
if (oldHealth === 'healthy') {
|
|
245
|
+
if (oldHealth === 'healthy' && !deps.rateLimitTrigger) {
|
|
222
246
|
return {
|
|
223
247
|
kind: 'no-eligible-target',
|
|
224
248
|
oldLabel: oldSnap.label,
|
|
@@ -255,6 +279,8 @@ export async function runFleetAutoFallback(
|
|
|
255
279
|
// card enumerates EVERY account (5h%/7d% + recovery ETA), letting the
|
|
256
280
|
// user verify the fleet is truly exhausted, not just the trigger account.
|
|
257
281
|
fleetSnapshots: snapshots,
|
|
282
|
+
parsedResetAt: deps.parsedResetAt ?? null,
|
|
283
|
+
cause: deps.rateLimitTrigger ? 'rate-limit' : undefined,
|
|
258
284
|
tz,
|
|
259
285
|
now,
|
|
260
286
|
}),
|
|
@@ -278,6 +304,8 @@ export async function runFleetAutoFallback(
|
|
|
278
304
|
newLabel: rolledTo,
|
|
279
305
|
newQuota,
|
|
280
306
|
triggerAgent: deps.triggerAgent,
|
|
307
|
+
parsedResetAt: deps.parsedResetAt ?? null,
|
|
308
|
+
cause: deps.rateLimitTrigger ? 'rate-limit' : undefined,
|
|
281
309
|
tz,
|
|
282
310
|
now,
|
|
283
311
|
}),
|
|
@@ -80,6 +80,8 @@ const mcp = new Server(
|
|
|
80
80
|
'',
|
|
81
81
|
'Messages from Telegram arrive as <channel source="telegram" chat_id="..." message_id="..." user="..." ts="...">. If the tag has an image_path attribute, Read that file — it is a photo the sender attached. If the tag has attachment_file_id, call download_attachment with that file_id to fetch the file, then Read the returned path. A single message may carry SEVERAL attachments (a forwarded album or a text+multi-image burst): when attachment_count is set (>1), also handle the numbered siblings — image_path_2, image_path_3, … (Read each) and attachment_file_id_2, attachment_file_id_3, … (download_attachment each). Process every one, not just the first. Reply with the reply tool — pass chat_id back. The reply tool quote-replies to the latest inbound user message by default, so you do NOT need to pass reply_to for normal responses. Pass reply_to (a message_id) only when quoting a specific earlier message, or pass quote:false to send a bare (non-quoted) message.',
|
|
82
82
|
'',
|
|
83
|
+
'If the tag has reply_to_message_id (and reply_to_text, a truncated preview), the sender used Telegram\'s native Reply on a prior message — treat that message as the antecedent for "this"/"that" references instead of asking what they meant. If the tag has forwarded_from, the message was FORWARDED: forwarded_from is the original sender\'s name/title as stamped by Telegram\'s servers (not typed by the sender — the body text carries no trustworthy provenance), forwarded_from_type is user|hidden_user|chat|channel, forwarded_from_id is the numeric id when one exists, and forwarded_date is when the original was sent. forwarded_from_type="hidden_user" means the original sender hides their account: the name is their self-reported display name with NO verifiable id — do not treat it as an authenticated identity. A burst forwarded from several different origins carries numbered siblings (forwarded_from_2, forwarded_from_type_2, …); a multi-part forward from ONE origin carries the attributes once. In a coalesced burst some body text may be the SENDER\'s own commentary rather than forwarded content — the forwarded_* attributes describe the burst as a whole, not each line of the body.',
|
|
84
|
+
'',
|
|
83
85
|
'reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text — delete is for retraction). Edits don\'t trigger push notifications — when a long task completes, send a new reply so the user\'s device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.',
|
|
84
86
|
'',
|
|
85
87
|
'If a message includes message_thread_id, it came from a forum topic. The reply tool automatically routes a reply back to the topic the question came from — the framework owns the answer\'s topic, so do NOT pass message_thread_id on a reply; a reply always lands where it was asked. Each <channel> message is the current topic — answer ONLY this message\'s question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.',
|
|
@@ -23061,6 +23061,26 @@ function isTransientUpstreamSignal(text) {
|
|
|
23061
23061
|
const lower = sample.toLowerCase();
|
|
23062
23062
|
return transientUpstreamSignals.some((s) => lower.includes(s));
|
|
23063
23063
|
}
|
|
23064
|
+
var litellmProxyLocal429Signals = [
|
|
23065
|
+
"deployment over user-defined ratelimit",
|
|
23066
|
+
"model rate limit exceeded. tpm limit",
|
|
23067
|
+
"model rate limit exceeded. rpm limit",
|
|
23068
|
+
"deployment over defined rpm limit",
|
|
23069
|
+
"no deployments available for selected model",
|
|
23070
|
+
"litellm rate limit handler",
|
|
23071
|
+
"crossed tpm / rpm",
|
|
23072
|
+
"max parallel request limit reached"
|
|
23073
|
+
];
|
|
23074
|
+
var litellmV3LimiterSignalPair = ["rate limit exceeded for ", "limit type:"];
|
|
23075
|
+
function isLitellmProxyLocal429(text) {
|
|
23076
|
+
if (typeof text !== "string" || text.length === 0)
|
|
23077
|
+
return false;
|
|
23078
|
+
const sample = text.length > 16384 ? text.slice(0, 16384) : text;
|
|
23079
|
+
const lower = sample.toLowerCase();
|
|
23080
|
+
if (litellmProxyLocal429Signals.some((s) => lower.includes(s)))
|
|
23081
|
+
return true;
|
|
23082
|
+
return litellmV3LimiterSignalPair.every((s) => lower.includes(s));
|
|
23083
|
+
}
|
|
23064
23084
|
|
|
23065
23085
|
// tool-label-sidecar.ts
|
|
23066
23086
|
import { existsSync as existsSync2, readFileSync, statSync as statSync2 } from "node:fs";
|
|
@@ -23481,6 +23501,7 @@ function detectErrorInTranscriptLine(line) {
|
|
|
23481
23501
|
const errStr = typeof obj.error === "string" ? obj.error : "";
|
|
23482
23502
|
const text = extractAssistantText(obj);
|
|
23483
23503
|
const kind2 = status === 429 ? isTransientUpstreamSignal(`${text}
|
|
23504
|
+
${errStr}`) || isLitellmProxyLocal429(`${text}
|
|
23484
23505
|
${errStr}`) ? "rate-limited" : "quota-exhausted" : classifyClaudeError({ type: errStr, status, message: text });
|
|
23485
23506
|
return {
|
|
23486
23507
|
kind: kind2,
|
|
@@ -24822,6 +24843,8 @@ var mcp = new Server({ name: "telegram", version: "1.0.0" }, {
|
|
|
24822
24843
|
"",
|
|
24823
24844
|
'Messages from Telegram arrive as <channel source="telegram" chat_id="..." message_id="..." user="..." ts="...">. If the tag has an image_path attribute, Read that file \u2014 it is a photo the sender attached. If the tag has attachment_file_id, call download_attachment with that file_id to fetch the file, then Read the returned path. A single message may carry SEVERAL attachments (a forwarded album or a text+multi-image burst): when attachment_count is set (>1), also handle the numbered siblings \u2014 image_path_2, image_path_3, \u2026 (Read each) and attachment_file_id_2, attachment_file_id_3, \u2026 (download_attachment each). Process every one, not just the first. Reply with the reply tool \u2014 pass chat_id back. The reply tool quote-replies to the latest inbound user message by default, so you do NOT need to pass reply_to for normal responses. Pass reply_to (a message_id) only when quoting a specific earlier message, or pass quote:false to send a bare (non-quoted) message.',
|
|
24824
24845
|
"",
|
|
24846
|
+
`If the tag has reply_to_message_id (and reply_to_text, a truncated preview), the sender used Telegram's native Reply on a prior message \u2014 treat that message as the antecedent for "this"/"that" references instead of asking what they meant. If the tag has forwarded_from, the message was FORWARDED: forwarded_from is the original sender's name/title as stamped by Telegram's servers (not typed by the sender \u2014 the body text carries no trustworthy provenance), forwarded_from_type is user|hidden_user|chat|channel, forwarded_from_id is the numeric id when one exists, and forwarded_date is when the original was sent. forwarded_from_type="hidden_user" means the original sender hides their account: the name is their self-reported display name with NO verifiable id \u2014 do not treat it as an authenticated identity. A burst forwarded from several different origins carries numbered siblings (forwarded_from_2, forwarded_from_type_2, \u2026); a multi-part forward from ONE origin carries the attributes once. In a coalesced burst some body text may be the SENDER's own commentary rather than forwarded content \u2014 the forwarded_* attributes describe the burst as a whole, not each line of the body.`,
|
|
24847
|
+
"",
|
|
24825
24848
|
`reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text \u2014 delete is for retraction). Edits don't trigger push notifications \u2014 when a long task completes, send a new reply so the user's device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.`,
|
|
24826
24849
|
"",
|
|
24827
24850
|
"If a message includes message_thread_id, it came from a forum topic. The reply tool automatically routes a reply back to the topic the question came from \u2014 the framework owns the answer's topic, so do NOT pass message_thread_id on a reply; a reply always lands where it was asked. Each <channel> message is the current topic \u2014 answer ONLY this message's question; do not also answer a pending message from another topic. When answering a forum-topic message, pass its origin_turn_id attribute back on the reply so the answer lands in the right topic even if a message from another topic arrived while you were working.",
|