switchroom 0.13.5 → 0.13.8

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.
@@ -4,19 +4,20 @@ Telegram is a chat — replies should feel like one, not a terminal dump or a tr
4
4
 
5
5
  **Every turn that responds to a user message MUST end with a `reply` (or `stream_reply` with `done=true`).** The user is on Telegram — they don't see your CLI output, tool-use trace, or inline thinking. The ONLY path for words to reach them is an MCP tool call. If you have a final answer, send it via `reply`. The text in your terminal is not the conversation.
6
6
 
7
- **Conversational pacing.**
8
- - **Open with an acknowledgement.** A person answers in a beat — *"got it"*, *"on it, checking now"* before the work is done. Default to that: unless the real answer lands within a second or two, your **first `reply` is a short human one-liner** in persona voice, sent fast (`disable_notification: true`). It is the line between a colleague and a black box. Skip it only when the answer itself arrives in the first couple of secondsthen the answer is the acknowledgement.
9
- - **Mid-turn updates at meaningful punctuation only.** Finished a hard step; hit a blocker; pivoting; dispatching a sub-agent; waiting on a slow tool; found something worth surfacing. **Not** on every tool call, **not** on a cadence, **not** to fill silence — the reaction on the user's inbound message already signals alive.
10
- - **Mid-turn updates pass `disable_notification: true`.** The user only gets pinged on the final answer (or a genuine heads-up). Update freely without notification fatigue.
11
- - **Narrate sub-agent dispatches***"spinning up @reviewer to look at this"* and summarise their reply when they report back. Sub-agent work belongs in the chat, not inferred from absence.
12
- - **Final answer is a fresh `reply`** (omit `disable_notification`, or pass false). Pings once.
13
- - **Silence-poke reminders.** A `<system-reminder>` containing `[silence-poke]` means the framework detected you've been quiet too long — send one short `reply` (*"still working through X"*, *"npm install is slow"*), brief, no task restatement. Skip if you're within ~5s of finishing.
7
+ **Conversational pacing — a human is on the other side.** Match the rhythm of a capable colleague messaging you back. Five beats:
8
+ - **1 · Acknowledge first.** Your first action on any turn that needs real work a file read, a search, a command is a short one-liner via `reply`, persona voice, sent fast (`disable_notification: true`): *"on it checking now"*. Skip it only when the whole answer is one sentence you can give immediately (*"what's 2+2"*). This is a beat, not filler it's the line between a colleague and a black box.
9
+ - **2 · Then go quiet and work.** Heads-down is right do **not** narrate every tool call. A typing indicator runs for you automatically; you don't keep it alive.
10
+ - **3 · Surface meaningful progress** at genuine inflection points a hard step finished, a blocker, a pivot, dispatching a sub-agent, a notably slow wait, a finding worth knowing now. One short `reply`, `disable_notification: true` (no mid-turn ping).
11
+ - **4 · Hand back delegations with synthesis.** When a sub-agent reports back, re-enter in your own voice what it found, what you're doing next (*"reviewer flagged the auth gap; fixing it now"*). Never let its raw report stand as your reply.
12
+ - **5 · Deliver the answer** as a fresh `reply` (omit `disable_notification` pings once).
13
+
14
+ The one thing to avoid is **spam**: a reply on every tool call, on a cadence, or repeating yourself. Responsive and human, never a flood. A `<system-reminder>` containing `[silence-poke]` means you've gone quiet too long — send one short `reply` and carry on; skip it only if you're within ~5s of finishing.
14
15
 
15
16
  **`stream_reply` vs `reply`.**
16
- - **`reply`** is the default. Use for soft-commits, mid-turn updates, sub-agent narration, final answers. Pass `disable_notification: true` mid-turn.
17
+ - **`reply`** is the default. Use for acks, mid-turn updates, sub-agent handbacks, final answers. Pass `disable_notification: true` mid-turn.
17
18
  - **`stream_reply`** is for content whose final answer benefits from streaming character-by-character (long prose, code blocks). First call sends fresh; subsequent calls edit (no ping until `done=true`). Don't use it just to "show progress" — that's what `reply` is for.
18
19
 
19
- The status-reaction lifecycle (👀 🤔 🔥 👍) on the user's inbound message signals "working" automatically; you don't need a typing message or periodic "still working" replies just to keep that signal alive.
20
+ The 👀→🤔→🔥→👍 status reaction and the typing indicator are *ambient* liveness they tell the user the agent is alive and working, automatically. They do **not** replace the five beats: ambient says "alive", your `reply` messages say "here's what's happening." Different layers; both run.
20
21
 
21
22
  **Reactions ON your replies.** Sometimes you'll receive a turn whose body is wrapped in `<channel source="reaction">`. That means the user reacted to one of your earlier messages and the gateway forwarded the reaction as a synthetic turn (the message preview is included so you know which reply they reacted to). 👎 / ❌ are stop signals — pause, reconsider the approach, ask what's off. 👍 / ✅ are acknowledgements — keep going if mid-task, no extra reply needed. A brief explicit acknowledgement is fine but not required; don't ceremonially reply to every reaction. The allowlist + per-hour cap are operator-tunable (default 10/hour); other emojis you might see don't trigger turns.
22
23
 
@@ -59,7 +60,7 @@ Don't use `accent` on routine conversational replies — it's for status communi
59
60
 
60
61
  **When stickers / GIFs land well**: confirming a real milestone the user celebrated (✅ workout logged, 🎉 deal closed); softening genuinely awkward news; mirroring back a sticker or GIF the user just sent — once, not as a habit. Use the user's emoji-sticker (echo back the file_id from inbound `(sticker — 😊 from "PackName")`) to acknowledge their tone. The agent persona's own curated aliases — declared by the operator under `telegram.stickers` in switchroom.yaml — are the standard alphabet (`happy`, `thinking`, `done`, etc.); call `send_sticker(chat_id, sticker='happy')`. Errors list available aliases when an unknown one is asked for.
61
62
 
62
- **When stickers / GIFs land badly**: in lieu of an actual answer, decorating routine acknowledgements ("got it 👍 [+sticker]"), peppering a long thread, or any time the user is task-focused. If you find yourself wanting to send one to lighten an otherwise empty reply, send no reply instead silence is a valid answer when you have nothing to add. Two stickers in a row is always wrong.
63
+ **When stickers / GIFs land badly**: in lieu of an actual answer, decorating routine acknowledgements ("got it 👍 [+sticker]"), peppering a long thread, or any time the user is task-focused. If you find yourself wanting to send one to lighten an otherwise empty reply, don't a sticker is never a substitute for an actual answer. Two stickers in a row is always wrong.
63
64
 
64
65
  **Interrupt marker.** If a user asks how to stop you mid-turn, tell them: *"Start your message with `!` — it interrupts whatever I'm doing and treats the rest as a fresh request."* For implementation detail (cgroup escape, `tmux send-keys`, doubled-bang, empty-bang gateway behavior), invoke the `/switchroom-runtime` skill. The `!` interrupt wakes a fresh `SWITCHROOM_PENDING_TURN` cycle, so the resume protocol fires on the next turn.
65
66
 
@@ -67,4 +68,4 @@ Don't use `accent` on routine conversational replies — it's for status communi
67
68
 
68
69
  **"Why did you restart?"** If the user asks about a restart, crash, or absence, invoke `/switchroom-runtime`. The `SWITCHROOM_PENDING_*` env vars are one-shot and gone by the time the user asks; the skill knows which on-disk sources to read (`clean-shutdown.json`, container/journal logs, watchdog audit log) and how to quote the reason verbatim. Never answer from memory.
69
70
 
70
- **"status?" / "still there?" / "any update?" is a UX-failure signal**, not a feature request. The progress card and stream-reply pattern exist precisely so the user never has to ask. When you see one of those messages, answer the literal question in one sentence and invoke `/switchroom-runtime` for the offer-RCA flow (the skill walks the `/file-bug` integration).
71
+ **"status?" / "still there?" / "any update?" is a UX-failure signal**, not a feature request. The five-beat conversational pacing exists precisely so the user never has to ask. When you see one of those messages, answer the literal question in one sentence and invoke `/switchroom-runtime` for the offer-RCA flow (the skill walks the `/file-bug` integration).
@@ -41,19 +41,20 @@ Telegram is a chat — replies should feel like one, not a terminal dump or a tr
41
41
 
42
42
  **Every turn that responds to a user message MUST end with a `reply` (or `stream_reply` with `done=true`).** The user is on Telegram — they don't see your CLI output, tool-use trace, or inline thinking. The ONLY path for words to reach them is an MCP tool call. If you have a final answer, send it via `reply`. The text in your terminal is not the conversation.
43
43
 
44
- **Conversational pacing.**
45
- - **Open with an acknowledgement.** A person answers in a beat — *"got it"*, *"on it, checking now"* before the work is done. Default to that: unless the real answer lands within a second or two, your **first `reply` is a short human one-liner** in persona voice, sent fast (`disable_notification: true`). It is the line between a colleague and a black box. Skip it only when the answer itself arrives in the first couple of secondsthen the answer is the acknowledgement.
46
- - **Mid-turn updates at meaningful punctuation only.** Finished a hard step; hit a blocker; pivoting; dispatching a sub-agent; waiting on a slow tool; found something worth surfacing. **Not** on every tool call, **not** on a cadence, **not** to fill silence — the reaction on the user's inbound message already signals alive.
47
- - **Mid-turn updates pass `disable_notification: true`.** The user only gets pinged on the final answer (or a genuine heads-up). Update freely without notification fatigue.
48
- - **Narrate sub-agent dispatches***"spinning up @reviewer to look at this"* and summarise their reply when they report back. Sub-agent work belongs in the chat, not inferred from absence.
49
- - **Final answer is a fresh `reply`** (omit `disable_notification`, or pass false). Pings once.
50
- - **Silence-poke reminders.** A `<system-reminder>` containing `[silence-poke]` means the framework detected you've been quiet too long — send one short `reply` (*"still working through X"*, *"npm install is slow"*), brief, no task restatement. Skip if you're within ~5s of finishing.
44
+ **Conversational pacing — a human is on the other side.** Match the rhythm of a capable colleague messaging you back. Five beats:
45
+ - **1 · Acknowledge first.** Your first action on any turn that needs real work a file read, a search, a command is a short one-liner via `reply`, persona voice, sent fast (`disable_notification: true`): *"on it checking now"*. Skip it only when the whole answer is one sentence you can give immediately (*"what's 2+2"*). This is a beat, not filler it's the line between a colleague and a black box.
46
+ - **2 · Then go quiet and work.** Heads-down is right do **not** narrate every tool call. A typing indicator runs for you automatically; you don't keep it alive.
47
+ - **3 · Surface meaningful progress** at genuine inflection points a hard step finished, a blocker, a pivot, dispatching a sub-agent, a notably slow wait, a finding worth knowing now. One short `reply`, `disable_notification: true` (no mid-turn ping).
48
+ - **4 · Hand back delegations with synthesis.** When a sub-agent reports back, re-enter in your own voice what it found, what you're doing next (*"reviewer flagged the auth gap; fixing it now"*). Never let its raw report stand as your reply.
49
+ - **5 · Deliver the answer** as a fresh `reply` (omit `disable_notification` pings once).
50
+
51
+ The one thing to avoid is **spam**: a reply on every tool call, on a cadence, or repeating yourself. Responsive and human, never a flood. A `<system-reminder>` containing `[silence-poke]` means you've gone quiet too long — send one short `reply` and carry on; skip it only if you're within ~5s of finishing.
51
52
 
52
53
  **`stream_reply` vs `reply`.**
53
- - **`reply`** is the default. Use for soft-commits, mid-turn updates, sub-agent narration, final answers. Pass `disable_notification: true` mid-turn.
54
+ - **`reply`** is the default. Use for acks, mid-turn updates, sub-agent handbacks, final answers. Pass `disable_notification: true` mid-turn.
54
55
  - **`stream_reply`** is for content whose final answer benefits from streaming character-by-character (long prose, code blocks). First call sends fresh; subsequent calls edit (no ping until `done=true`). Don't use it just to "show progress" — that's what `reply` is for.
55
56
 
56
- The status-reaction lifecycle (👀 🤔 🔥 👍) on the user's inbound message signals "working" automatically; you don't need a typing message or periodic "still working" replies just to keep that signal alive.
57
+ The 👀→🤔→🔥→👍 status reaction and the typing indicator are *ambient* liveness they tell the user the agent is alive and working, automatically. They do **not** replace the five beats: ambient says "alive", your `reply` messages say "here's what's happening." Different layers; both run.
57
58
 
58
59
  **Reactions ON your replies.** Sometimes you'll receive a turn whose body is wrapped in `<channel source="reaction">`. That means the user reacted to one of your earlier messages and the gateway forwarded the reaction as a synthetic turn (the message preview is included so you know which reply they reacted to). 👎 / ❌ are stop signals — pause, reconsider the approach, ask what's off. 👍 / ✅ are acknowledgements — keep going if mid-task, no extra reply needed. A brief explicit acknowledgement is fine but not required; don't ceremonially reply to every reaction. The allowlist + per-hour cap are operator-tunable (default 10/hour); other emojis you might see don't trigger turns.
59
60
 
@@ -96,7 +97,7 @@ Don't use `accent` on routine conversational replies — it's for status communi
96
97
 
97
98
  **When stickers / GIFs land well**: confirming a real milestone the user celebrated (✅ workout logged, 🎉 deal closed); softening genuinely awkward news; mirroring back a sticker or GIF the user just sent — once, not as a habit. Use the user's emoji-sticker (echo back the file_id from inbound `(sticker — 😊 from "PackName")`) to acknowledge their tone. The agent persona's own curated aliases — declared by the operator under `telegram.stickers` in switchroom.yaml — are the standard alphabet (`happy`, `thinking`, `done`, etc.); call `send_sticker(chat_id, sticker='happy')`. Errors list available aliases when an unknown one is asked for.
98
99
 
99
- **When stickers / GIFs land badly**: in lieu of an actual answer, decorating routine acknowledgements ("got it 👍 [+sticker]"), peppering a long thread, or any time the user is task-focused. If you find yourself wanting to send one to lighten an otherwise empty reply, send no reply instead silence is a valid answer when you have nothing to add. Two stickers in a row is always wrong.
100
+ **When stickers / GIFs land badly**: in lieu of an actual answer, decorating routine acknowledgements ("got it 👍 [+sticker]"), peppering a long thread, or any time the user is task-focused. If you find yourself wanting to send one to lighten an otherwise empty reply, don't a sticker is never a substitute for an actual answer. Two stickers in a row is always wrong.
100
101
 
101
102
  **Interrupt marker.** If a user asks how to stop you mid-turn, tell them: *"Start your message with `!` — it interrupts whatever I'm doing and treats the rest as a fresh request."* For implementation detail (cgroup escape, `tmux send-keys`, doubled-bang, empty-bang gateway behavior), invoke the `/switchroom-runtime` skill. The `!` interrupt wakes a fresh `SWITCHROOM_PENDING_TURN` cycle, so the resume protocol fires on the next turn.
102
103
 
@@ -104,7 +105,7 @@ Don't use `accent` on routine conversational replies — it's for status communi
104
105
 
105
106
  **"Why did you restart?"** If the user asks about a restart, crash, or absence, invoke `/switchroom-runtime`. The `SWITCHROOM_PENDING_*` env vars are one-shot and gone by the time the user asks; the skill knows which on-disk sources to read (`clean-shutdown.json`, container/journal logs, watchdog audit log) and how to quote the reason verbatim. Never answer from memory.
106
107
 
107
- **"status?" / "still there?" / "any update?" is a UX-failure signal**, not a feature request. The progress card and stream-reply pattern exist precisely so the user never has to ask. When you see one of those messages, answer the literal question in one sentence and invoke `/switchroom-runtime` for the offer-RCA flow (the skill walks the `/file-bug` integration).
108
+ **"status?" / "still there?" / "any update?" is a UX-failure signal**, not a feature request. The five-beat conversational pacing exists precisely so the user never has to ask. When you see one of those messages, answer the literal question in one sentence and invoke `/switchroom-runtime` for the offer-RCA flow (the skill walks the `/file-bug` integration).
108
109
 
109
110
  ## Memory — Hindsight is your single backend
110
111
 
@@ -23361,6 +23361,13 @@ function detectErrorInTranscriptLine(line) {
23361
23361
  if (typeof obj !== "object" || obj == null)
23362
23362
  return null;
23363
23363
  const type = obj.type;
23364
+ if (obj.isApiErrorMessage === true) {
23365
+ const status = typeof obj.apiErrorStatus === "number" ? obj.apiErrorStatus : null;
23366
+ const errStr = typeof obj.error === "string" ? obj.error : "";
23367
+ const text = extractAssistantText(obj);
23368
+ const kind2 = status === 429 ? "quota-exhausted" : classifyClaudeError({ type: errStr, status, message: text });
23369
+ return { kind: kind2, raw: obj, detail: text || errStr || "api error" };
23370
+ }
23364
23371
  const isErrorLine = type === "api_error" || type === "error";
23365
23372
  const embeddedError = typeof obj.error === "object" && obj.error != null ? obj.error : null;
23366
23373
  if (!isErrorLine && !embeddedError)
@@ -23376,6 +23383,23 @@ function extractDetailMessage(obj) {
23376
23383
  const msg = obj.message;
23377
23384
  return typeof msg === "string" && msg.length > 0 ? msg : null;
23378
23385
  }
23386
+ function extractAssistantText(obj) {
23387
+ const message = obj.message;
23388
+ if (typeof message !== "object" || message == null)
23389
+ return "";
23390
+ const content = message.content;
23391
+ if (!Array.isArray(content))
23392
+ return "";
23393
+ const parts = [];
23394
+ for (const block of content) {
23395
+ if (typeof block === "object" && block != null && block.type === "text") {
23396
+ const t = block.text;
23397
+ if (typeof t === "string")
23398
+ parts.push(t);
23399
+ }
23400
+ }
23401
+ return parts.join(" ").trim();
23402
+ }
23379
23403
  function startSessionTail(config2) {
23380
23404
  const cwd = config2.cwd ?? process.cwd();
23381
23405
  const claudeHome = config2.claudeHome ?? process.env.CLAUDE_CONFIG_DIR ?? join3(homedir2(), ".claude");
@@ -23592,7 +23592,7 @@ var init_dist = __esm(() => {
23592
23592
  });
23593
23593
 
23594
23594
  // ../src/config/schema.ts
23595
- var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, HostControlConfigSchema, SwitchroomConfigSchema;
23595
+ var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, HostControlConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
23596
23596
  var init_schema = __esm(() => {
23597
23597
  init_zod();
23598
23598
  CodeRepoEntrySchema = exports_external.object({
@@ -23943,6 +23943,10 @@ var init_schema = __esm(() => {
23943
23943
  HostControlConfigSchema = exports_external.object({
23944
23944
  enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip \u2014 the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` \u2014 " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C \u00a75.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3).")
23945
23945
  });
23946
+ HostdConfigSchema = exports_external.object({
23947
+ config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit \u00a73). Default false \u2014 the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
23948
+ config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit \u00a75). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
23949
+ });
23946
23950
  SwitchroomConfigSchema = exports_external.object({
23947
23951
  switchroom: exports_external.object({
23948
23952
  version: exports_external.literal(1).describe("Config schema version"),
@@ -23969,6 +23973,7 @@ var init_schema = __esm(() => {
23969
23973
  google_workspace: GoogleWorkspaceConfigSchema.describe("RFC G canonical key. Top-level Google Workspace configuration \u2014 " + "OAuth client credentials, approver allowlist, and tier knob (`core` " + "| `extended` | `complete`, default `core`). Mutually exclusive with " + "`drive:` at the top level (loader fails fast if both are set)."),
23970
23974
  quota: QuotaConfigSchema.optional().describe("Optional weekly/monthly USD spend budgets rendered in the session " + "greeting. Usage is read from ccusage at runtime; no network calls."),
23971
23975
  host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/rfcs/host-control-daemon.md). Omit the block " + "to accept defaults; set `enabled: false` only on legacy systemd-" + "mode installs (removal tracked as RFC C Phase 3)."),
23976
+ hostd: HostdConfigSchema.default({}).describe("hostd verb-level knobs (RFC admin-agent-config-edit). Distinct " + "from `host_control:` which governs whether the daemon runs at " + "all. Currently scopes the opt-in flag and rate cap for the new " + "`config_propose_edit` verb (PR 1a \u2014 disabled by default)."),
23972
23977
  google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
23973
23978
  message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
23974
23979
  }).transform((v) => v.trim().toLowerCase()), exports_external.object({
@@ -39342,7 +39347,9 @@ function detectModelUnavailable(stderr) {
39342
39347
  "quota exhausted",
39343
39348
  "quota_exhausted",
39344
39349
  "plan limit",
39345
- "subscription limit"
39350
+ "subscription limit",
39351
+ "hit your limit",
39352
+ "hit the limit"
39346
39353
  ];
39347
39354
  if (quotaSignals.some((s) => lower.includes(s))) {
39348
39355
  const resetAt = parseResetTime(sample);
@@ -42801,6 +42808,15 @@ var AgentSmokeRequestSchema = exports_external.object({
42801
42808
  deep: exports_external.boolean().optional()
42802
42809
  })
42803
42810
  });
42811
+ var ConfigProposeEditRequestSchema = exports_external.object({
42812
+ ...RequestEnvelope,
42813
+ op: exports_external.literal("config_propose_edit"),
42814
+ args: exports_external.object({
42815
+ unified_diff: exports_external.string().min(1).max(MAX_FRAME_BYTES3 - 1024),
42816
+ reason: exports_external.string().min(1).max(500),
42817
+ target_path: exports_external.literal("/state/config/switchroom.yaml")
42818
+ })
42819
+ });
42804
42820
  var RequestSchema3 = exports_external.discriminatedUnion("op", [
42805
42821
  AgentRestartRequestSchema,
42806
42822
  UpgradeStatusRequestSchema,
@@ -42813,7 +42829,8 @@ var RequestSchema3 = exports_external.discriminatedUnion("op", [
42813
42829
  AgentLogsRequestSchema,
42814
42830
  AgentExecRequestSchema,
42815
42831
  DoctorRequestSchema,
42816
- AgentSmokeRequestSchema
42832
+ AgentSmokeRequestSchema,
42833
+ ConfigProposeEditRequestSchema
42817
42834
  ]);
42818
42835
  var ResultSchema = exports_external.enum(["started", "completed", "denied", "error"]);
42819
42836
  var ResponseEnvelope = {
@@ -44023,6 +44040,10 @@ function chatKey(chatId, threadId) {
44023
44040
  function chatKeyWithSuffix(chatId, threadId, suffix) {
44024
44041
  return `${chatKey(chatId, threadId)}:${suffix}`;
44025
44042
  }
44043
+ function chatIdOfChatKey(key) {
44044
+ const idx = key.indexOf(":");
44045
+ return idx === -1 ? key : key.slice(0, idx);
44046
+ }
44026
44047
 
44027
44048
  // gateway/inbound-delivery-machine.ts
44028
44049
  function initialState() {
@@ -47720,11 +47741,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
47720
47741
  }
47721
47742
 
47722
47743
  // ../src/build-info.ts
47723
- var VERSION = "0.13.5";
47724
- var COMMIT_SHA = "cb688641";
47725
- var COMMIT_DATE = "2026-05-22T05:10:31+10:00";
47744
+ var VERSION = "0.13.8";
47745
+ var COMMIT_SHA = "bb713414";
47746
+ var COMMIT_DATE = "2026-05-22T10:15:33+10:00";
47726
47747
  var LATEST_PR = null;
47727
- var COMMITS_AHEAD_OF_TAG = 6;
47748
+ var COMMITS_AHEAD_OF_TAG = 4;
47728
47749
 
47729
47750
  // gateway/boot-version.ts
47730
47751
  function formatRelativeAgo(iso) {
@@ -48681,6 +48702,7 @@ function purgeReactionTracking(key, endingTurn) {
48681
48702
  activeStatusReactions.delete(key);
48682
48703
  activeReactionMsgIds.delete(key);
48683
48704
  activeTurnStartedAt.delete(key);
48705
+ stopTurnTypingLoop(chatIdOfChatKey(key));
48684
48706
  if (msgInfo) {
48685
48707
  const agentDir = resolveAgentDirFromEnv();
48686
48708
  if (agentDir != null)
@@ -48982,6 +49004,22 @@ function stopTypingLoop(chat_id) {
48982
49004
  typingRetryTimers.delete(chat_id);
48983
49005
  }
48984
49006
  }
49007
+ var turnTypingIntervals = new Map;
49008
+ function startTurnTypingLoop(chat_id) {
49009
+ stopTurnTypingLoop(chat_id);
49010
+ const send = () => {
49011
+ bot.api.sendChatAction(chat_id, "typing").catch(() => {});
49012
+ };
49013
+ send();
49014
+ turnTypingIntervals.set(chat_id, setInterval(send, 4000));
49015
+ }
49016
+ function stopTurnTypingLoop(chat_id) {
49017
+ const iv = turnTypingIntervals.get(chat_id);
49018
+ if (iv) {
49019
+ clearInterval(iv);
49020
+ turnTypingIntervals.delete(chat_id);
49021
+ }
49022
+ }
48985
49023
  var typingWrapper = createTypingWrapper({
48986
49024
  startTypingLoop,
48987
49025
  stopTypingLoop,
@@ -52367,6 +52405,7 @@ ${preBlock(write.output)}`;
52367
52405
  logStreamingEvent({ kind: "inbound_ack", chatId: chat_id, messageId: msgId, ackDelayMs: Date.now() - inboundReceivedAt });
52368
52406
  reset(statusKey(chat_id, messageThreadId), Date.now());
52369
52407
  startTurn(statusKey(chat_id, messageThreadId), Date.now());
52408
+ startTurnTypingLoop(chat_id);
52370
52409
  emitRuntimeMetric({
52371
52410
  kind: "turn_started",
52372
52411
  chat_id,
@@ -56527,6 +56566,9 @@ async function shutdown(signal) {
56527
56566
  for (const iv of [...typingIntervals.values()])
56528
56567
  clearInterval(iv);
56529
56568
  typingIntervals.clear();
56569
+ for (const iv of [...turnTypingIntervals.values()])
56570
+ clearInterval(iv);
56571
+ turnTypingIntervals.clear();
56530
56572
  for (const t of [...typingRetryTimers.values()])
56531
56573
  clearTimeout(t);
56532
56574
  typingRetryTimers.clear();
@@ -17399,6 +17399,13 @@ function detectErrorInTranscriptLine(line) {
17399
17399
  if (typeof obj !== "object" || obj == null)
17400
17400
  return null;
17401
17401
  const type = obj.type;
17402
+ if (obj.isApiErrorMessage === true) {
17403
+ const status = typeof obj.apiErrorStatus === "number" ? obj.apiErrorStatus : null;
17404
+ const errStr = typeof obj.error === "string" ? obj.error : "";
17405
+ const text = extractAssistantText(obj);
17406
+ const kind2 = status === 429 ? "quota-exhausted" : classifyClaudeError({ type: errStr, status, message: text });
17407
+ return { kind: kind2, raw: obj, detail: text || errStr || "api error" };
17408
+ }
17402
17409
  const isErrorLine = type === "api_error" || type === "error";
17403
17410
  const embeddedError = typeof obj.error === "object" && obj.error != null ? obj.error : null;
17404
17411
  if (!isErrorLine && !embeddedError)
@@ -17414,6 +17421,23 @@ function extractDetailMessage(obj) {
17414
17421
  const msg = obj.message;
17415
17422
  return typeof msg === "string" && msg.length > 0 ? msg : null;
17416
17423
  }
17424
+ function extractAssistantText(obj) {
17425
+ const message = obj.message;
17426
+ if (typeof message !== "object" || message == null)
17427
+ return "";
17428
+ const content = message.content;
17429
+ if (!Array.isArray(content))
17430
+ return "";
17431
+ const parts = [];
17432
+ for (const block of content) {
17433
+ if (typeof block === "object" && block != null && block.type === "text") {
17434
+ const t = block.text;
17435
+ if (typeof t === "string")
17436
+ parts.push(t);
17437
+ }
17438
+ }
17439
+ return parts.join(" ").trim();
17440
+ }
17417
17441
  function startSessionTail(config2) {
17418
17442
  const cwd = config2.cwd ?? process.cwd();
17419
17443
  const claudeHome = config2.claudeHome ?? process.env.CLAUDE_CONFIG_DIR ?? join4(homedir3(), ".claude");
@@ -264,7 +264,7 @@ import { createInboundSpool } from './inbound-spool.js'
264
264
  import { purgeStaleTurnsForChat } from './turn-state-purge.js'
265
265
  import { decideInboundDelivery } from './inbound-delivery-gate.js'
266
266
  import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
267
- import { chatKey, chatKeyWithSuffix } from './chat-key.js'
267
+ import { chatKey, chatKeyWithSuffix, chatIdOfChatKey } from './chat-key.js'
268
268
  // Phase 2b PR 2 — shadow mode. Each event-site below calls shadowEmit()
269
269
  // to record what the InboundDeliveryStateMachine PREDICTS the gateway
270
270
  // should do. Behavior unchanged in this PR — the imperative code below
@@ -1310,6 +1310,13 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
1310
1310
  activeStatusReactions.delete(key)
1311
1311
  activeReactionMsgIds.delete(key)
1312
1312
  activeTurnStartedAt.delete(key)
1313
+ // Human-feel UX: stop the turn-long `typing…` indicator started in
1314
+ // the turn-start block. `purgeReactionTracking` is the canonical
1315
+ // turn-end, so this is the single owner of the stop. (If an abnormal
1316
+ // abort skips purge, the stray loop self-heals: the next turn on this
1317
+ // chat calls `startTurnTypingLoop`, which stops the old interval
1318
+ // first.)
1319
+ stopTurnTypingLoop(chatIdOfChatKey(key as _ChatKey))
1313
1320
  if (msgInfo) {
1314
1321
  const agentDir = resolveAgentDirFromEnv()
1315
1322
  if (agentDir != null) removeActiveReaction(agentDir, msgInfo.chatId, msgInfo.messageId)
@@ -1781,6 +1788,32 @@ function stopTypingLoop(chat_id: string): void {
1781
1788
  if (retry) { clearTimeout(retry); typingRetryTimers.delete(chat_id) }
1782
1789
  }
1783
1790
 
1791
+ // Turn-level `typing…` indicator. Deliberately a SEPARATE interval map
1792
+ // from `typingIntervals` (which the reply handler and the tool-use
1793
+ // typing wrapper share and freely stop). If the turn loop lived in the
1794
+ // shared map, a mid-turn reply's `finally { stopTypingLoop }` would
1795
+ // kill it and the chat would go dark for the rest of the turn — the
1796
+ // exact black-box gap this is here to close. A dedicated map makes the
1797
+ // turn loop structurally immune to those stops: only `stopTurnTypingLoop`
1798
+ // (called at the canonical turn-end) clears it. The redundant `typing`
1799
+ // pings while a reply is mid-flight are harmless — same action, and
1800
+ // sendChatAction is cheap.
1801
+ const turnTypingIntervals = new Map<string, ReturnType<typeof setInterval>>()
1802
+
1803
+ function startTurnTypingLoop(chat_id: string): void {
1804
+ stopTurnTypingLoop(chat_id)
1805
+ const send = () => {
1806
+ void bot.api.sendChatAction(chat_id, 'typing').catch(() => {})
1807
+ }
1808
+ send()
1809
+ turnTypingIntervals.set(chat_id, setInterval(send, 4000))
1810
+ }
1811
+
1812
+ function stopTurnTypingLoop(chat_id: string): void {
1813
+ const iv = turnTypingIntervals.get(chat_id)
1814
+ if (iv) { clearInterval(iv); turnTypingIntervals.delete(chat_id) }
1815
+ }
1816
+
1784
1817
  const typingWrapper = createTypingWrapper({
1785
1818
  startTypingLoop,
1786
1819
  stopTypingLoop,
@@ -7563,6 +7596,16 @@ async function handleInbound(
7563
7596
  // the framework can nudge the model if it goes quiet past the
7564
7597
  // soft / firm thresholds.
7565
7598
  silencePoke.startTurn(statusKey(chat_id, messageThreadId), Date.now())
7599
+ // Human-feel UX: hold a continuous `typing…` indicator for the
7600
+ // WHOLE turn, not just the split-second a reply is transmitted.
7601
+ // A person you message shows as typing the entire time they
7602
+ // compose; switchroom used to fire only one-shot ~5s pings, so
7603
+ // any turn that read a file or thought for a moment went dark
7604
+ // after 5s. Self-renews every 4s; stopped at the canonical
7605
+ // turn-end (`purgeReactionTracking → stopTurnTypingLoop`).
7606
+ // Deterministic, framework-owned, no prose — the mechanical
7607
+ // ambient layer of the pacing contract.
7608
+ startTurnTypingLoop(chat_id)
7566
7609
  // #1122 KPI: emit turn_started so dashboards can compute funnel
7567
7610
  // start counts + correlate to turn_ended for duration / TTFO.
7568
7611
  emitRuntimeMetric({
@@ -14111,6 +14154,8 @@ async function shutdown(signal: string): Promise<void> {
14111
14154
 
14112
14155
  for (const iv of [...typingIntervals.values()]) clearInterval(iv)
14113
14156
  typingIntervals.clear()
14157
+ for (const iv of [...turnTypingIntervals.values()]) clearInterval(iv)
14158
+ turnTypingIntervals.clear()
14114
14159
  for (const t of [...typingRetryTimers.values()]) clearTimeout(t)
14115
14160
  typingRetryTimers.clear()
14116
14161
 
@@ -80,6 +80,10 @@ export function detectModelUnavailable(
80
80
  'quota_exhausted',
81
81
  'plan limit',
82
82
  'subscription limit',
83
+ // Claude Code v2.1.x usage-limit wording: "You've hit your limit ·
84
+ // resets 8:50am (Australia/Melbourne)".
85
+ 'hit your limit',
86
+ 'hit the limit',
83
87
  ]
84
88
  if (quotaSignals.some(s => lower.includes(s))) {
85
89
  const resetAt = parseResetTime(sample)
@@ -423,6 +423,33 @@ export function detectErrorInTranscriptLine(
423
423
 
424
424
  const type = obj.type as string | undefined
425
425
 
426
+ // Claude Code (v2.1.x) records a usage-limit / API error as a
427
+ // SYNTHETIC ASSISTANT MESSAGE, not an api_error / error line:
428
+ // { type: "assistant",
429
+ // message: { role: "assistant",
430
+ // content: [{ type: "text", text: "You've hit your limit · resets …" }] },
431
+ // error: "rate_limit", isApiErrorMessage: true, apiErrorStatus: 429 }
432
+ // It has no `api_error`/`error` top-type and no nested error OBJECT
433
+ // (`error` is a bare string), so the structured checks below miss it
434
+ // entirely. That silent miss is what kept fleet auto-fallback from
435
+ // ever firing on a quota hit — the exhaustion signal never reached
436
+ // the operator-event path. Detect this shape explicitly.
437
+ if (obj.isApiErrorMessage === true) {
438
+ const status =
439
+ typeof obj.apiErrorStatus === 'number' ? obj.apiErrorStatus : null
440
+ const errStr = typeof obj.error === 'string' ? obj.error : ''
441
+ const text = extractAssistantText(obj)
442
+ // A 429 in this shape is a subscription usage-limit hit (it carries
443
+ // a reset time) — classify it quota-exhausted so the operator event
444
+ // resolves to an auto-fallback-eligible kind. Other statuses fall
445
+ // through to the shared classifier.
446
+ const kind: OperatorEventKind =
447
+ status === 429
448
+ ? 'quota-exhausted'
449
+ : classifyClaudeError({ type: errStr, status, message: text })
450
+ return { kind, raw: obj, detail: text || errStr || 'api error' }
451
+ }
452
+
426
453
  // Explicit error line types from Claude Code JSONL
427
454
  const isErrorLine = type === 'api_error' || type === 'error'
428
455
 
@@ -454,6 +481,32 @@ function extractDetailMessage(obj: Record<string, unknown> | null): string | nul
454
481
  return typeof msg === 'string' && msg.length > 0 ? msg : null
455
482
  }
456
483
 
484
+ /**
485
+ * Pull the human-readable text out of a synthetic assistant message
486
+ * (`message.content[].text`, joined). Used for the v2.1.x
487
+ * `isApiErrorMessage` shape, where the user-facing error string lives
488
+ * inside the assistant message rather than in an `error` object.
489
+ * Returns '' for any non-conforming shape — never throws.
490
+ */
491
+ function extractAssistantText(obj: Record<string, unknown>): string {
492
+ const message = obj.message
493
+ if (typeof message !== 'object' || message == null) return ''
494
+ const content = (message as Record<string, unknown>).content
495
+ if (!Array.isArray(content)) return ''
496
+ const parts: string[] = []
497
+ for (const block of content) {
498
+ if (
499
+ typeof block === 'object'
500
+ && block != null
501
+ && (block as Record<string, unknown>).type === 'text'
502
+ ) {
503
+ const t = (block as Record<string, unknown>).text
504
+ if (typeof t === 'string') parts.push(t)
505
+ }
506
+ }
507
+ return parts.join(' ').trim()
508
+ }
509
+
457
510
  // ─── The tail watcher ─────────────────────────────────────────────────────
458
511
 
459
512
  /** Emitted to onOperatorEvent when the tail detects a Claude API error. */
@@ -42,6 +42,15 @@ describe('detectModelUnavailable — quota / billing strings', () => {
42
42
  it('classifies "quota exhausted" verbatim', () => {
43
43
  expect(detectModelUnavailable('quota exhausted on slot main')?.kind).toBe('quota_exhausted')
44
44
  })
45
+
46
+ it("classifies Claude Code v2.1.x 'You've hit your limit' wording", () => {
47
+ // The exact text claude writes inside the synthetic
48
+ // isApiErrorMessage assistant message on a subscription quota hit.
49
+ const d = detectModelUnavailable(
50
+ "You've hit your limit · resets 8:50am (Australia/Melbourne)",
51
+ )
52
+ expect(d?.kind).toBe('quota_exhausted')
53
+ })
45
54
  })
46
55
 
47
56
  describe('detectModelUnavailable — overload / 429 / 5xx strings', () => {
@@ -70,6 +70,49 @@ describe('detectErrorInTranscriptLine — error detection', () => {
70
70
  expect(detectErrorInTranscriptLine(line)).toBeNull()
71
71
  })
72
72
 
73
+ // Regression — the fleet-auto-failover dead-zone. Claude Code v2.1.x
74
+ // records a usage-limit hit as a synthetic assistant message with
75
+ // isApiErrorMessage:true (no api_error type, no nested error OBJECT).
76
+ // Pre-fix, detectErrorInTranscriptLine missed it entirely → the
77
+ // operator-event path never ran → fleet auto-fallback never fired.
78
+ it('detects the v2.1.x synthetic-assistant-message usage-limit shape', () => {
79
+ // The exact on-disk line shape, verbatim from a real quota hit.
80
+ const line = JSON.stringify({
81
+ type: 'assistant',
82
+ message: {
83
+ role: 'assistant',
84
+ model: '<synthetic>',
85
+ content: [
86
+ {
87
+ type: 'text',
88
+ text: "You've hit your limit · resets 8:50am (Australia/Melbourne)",
89
+ },
90
+ ],
91
+ },
92
+ error: 'rate_limit',
93
+ isApiErrorMessage: true,
94
+ apiErrorStatus: 429,
95
+ })
96
+ const result = detectErrorInTranscriptLine(line)
97
+ expect(result).not.toBeNull()
98
+ // A 429 in this shape is a subscription usage-limit hit → must
99
+ // classify quota-exhausted so the operator event resolves to an
100
+ // auto-fallback-eligible kind.
101
+ expect(result!.kind).toBe('quota-exhausted')
102
+ // The user-facing text must survive into `detail` (the model-
103
+ // unavailable card + the text-pattern path both rely on it).
104
+ expect(result!.detail).toContain('hit your limit')
105
+ })
106
+
107
+ it('still returns null for a normal (non-error) assistant message', () => {
108
+ // No isApiErrorMessage flag → must NOT be treated as an error.
109
+ const line = JSON.stringify({
110
+ type: 'assistant',
111
+ message: { role: 'assistant', content: [{ type: 'text', text: 'Done.' }] },
112
+ })
113
+ expect(detectErrorInTranscriptLine(line)).toBeNull()
114
+ })
115
+
73
116
  it('returns null for lines with null error field', () => {
74
117
  const line = JSON.stringify({ type: 'assistant', error: null })
75
118
  expect(detectErrorInTranscriptLine(line)).toBeNull()