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.
Files changed (47) hide show
  1. package/dist/agent-scheduler/index.js +49 -9
  2. package/dist/auth-broker/index.js +152 -46
  3. package/dist/cli/autoaccept-poll.js +23 -0
  4. package/dist/cli/drive-write-pretool.mjs +24 -1
  5. package/dist/cli/foreground-hog-pretool.mjs +264 -0
  6. package/dist/cli/notion-write-pretool.mjs +0 -1
  7. package/dist/cli/switchroom.js +1185 -1072
  8. package/dist/host-control/main.js +53 -52
  9. package/dist/vault/approvals/kernel-server.js +16 -13
  10. package/dist/vault/broker/server.js +672 -669
  11. package/package.json +1 -1
  12. package/profiles/coding/CLAUDE.md.hbs +2 -0
  13. package/profiles/default/CLAUDE.md.hbs +2 -0
  14. package/skills/switchroom-architecture/telegram.md +0 -1
  15. package/telegram-plugin/auth-snapshot-format.ts +37 -5
  16. package/telegram-plugin/auto-fallback-fleet.ts +29 -1
  17. package/telegram-plugin/bridge/bridge.ts +2 -0
  18. package/telegram-plugin/dist/bridge/bridge.js +23 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +765 -67
  20. package/telegram-plugin/dist/server.js +24 -1
  21. package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
  22. package/telegram-plugin/gateway/auth-command.ts +14 -0
  23. package/telegram-plugin/gateway/forward-origin.ts +235 -0
  24. package/telegram-plugin/gateway/gateway.ts +270 -10
  25. package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
  26. package/telegram-plugin/history.ts +55 -6
  27. package/telegram-plugin/model-unavailable.ts +234 -2
  28. package/telegram-plugin/render/rich-render.ts +40 -32
  29. package/telegram-plugin/runtime-metrics.ts +31 -0
  30. package/telegram-plugin/session-tail.ts +14 -2
  31. package/telegram-plugin/stream-controller.ts +3 -2
  32. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +309 -0
  34. package/telegram-plugin/tests/history.test.ts +157 -0
  35. package/telegram-plugin/tests/model-unavailable.test.ts +187 -0
  36. package/telegram-plugin/tests/operator-events-session-tail.test.ts +55 -0
  37. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
  38. package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
  39. package/telegram-plugin/tests/runtime-metrics.test.ts +24 -0
  40. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
  41. package/telegram-plugin/tests/status-accent.test.ts +5 -3
  42. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
  43. package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
  44. package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
  45. package/telegram-plugin/tests/throttle-tier.test.ts +454 -0
  46. package/telegram-plugin/throttle-tier.ts +323 -0
  47. 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.13",
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 = limitWord === 'quota' ? 'quota cap' : `${limitWord} limit`;
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 if (input.oldQuota) {
789
+ } else {
769
790
  // Back-compat: no fleet snapshot supplied → old single-account shape.
770
- const recovery = recoveryAtFor(input.oldQuota);
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
- if (input.oldQuota) {
798
- const recovery = recoveryAtFor(input.oldQuota);
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.",