switchroom 0.13.5 → 0.13.7

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.
@@ -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.7";
47745
+ var COMMIT_SHA = "84d28022";
47746
+ var COMMIT_DATE = "2026-05-22T08:53:51+10:00";
47726
47747
  var LATEST_PR = null;
47727
- var COMMITS_AHEAD_OF_TAG = 6;
47748
+ var COMMITS_AHEAD_OF_TAG = 2;
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()