switchroom 0.8.1 → 0.10.0

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 (105) hide show
  1. package/README.md +49 -57
  2. package/bin/timezone-hook.sh +9 -7
  3. package/dist/agent-scheduler/index.js +285 -45
  4. package/dist/auth-broker/index.js +13932 -0
  5. package/dist/cli/switchroom.js +15931 -12778
  6. package/dist/host-control/main.js +582 -43
  7. package/dist/vault/approvals/kernel-server.js +276 -47
  8. package/dist/vault/broker/server.js +333 -69
  9. package/examples/minimal.yaml +63 -0
  10. package/examples/personal-google-workspace-mcp/.env.example +34 -0
  11. package/examples/personal-google-workspace-mcp/README.md +194 -0
  12. package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
  13. package/examples/switchroom.yaml +220 -0
  14. package/package.json +6 -4
  15. package/profiles/_base/start.sh.hbs +3 -3
  16. package/profiles/_shared/agent-self-service.md.hbs +126 -0
  17. package/profiles/default/CLAUDE.md +10 -0
  18. package/profiles/default/CLAUDE.md.hbs +16 -0
  19. package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
  20. package/skills/buildkite-agent-runtime/SKILL.md +44 -11
  21. package/skills/buildkite-api/SKILL.md +31 -8
  22. package/skills/buildkite-cli/SKILL.md +27 -9
  23. package/skills/buildkite-migration/SKILL.md +22 -9
  24. package/skills/buildkite-pipelines/SKILL.md +26 -9
  25. package/skills/buildkite-secure-delivery/SKILL.md +23 -9
  26. package/skills/buildkite-test-engine/SKILL.md +25 -8
  27. package/skills/docx/SKILL.md +1 -1
  28. package/skills/file-bug/SKILL.md +34 -6
  29. package/skills/humanizer/SKILL.md +15 -0
  30. package/skills/humanizer-calibrate/SKILL.md +7 -1
  31. package/skills/mcp-builder/SKILL.md +1 -1
  32. package/skills/pdf/SKILL.md +1 -1
  33. package/skills/pptx/SKILL.md +1 -1
  34. package/skills/skill-creator/SKILL.md +21 -1
  35. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  36. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  37. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  38. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  39. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  40. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  41. package/skills/switchroom-cli/SKILL.md +63 -64
  42. package/skills/switchroom-health/SKILL.md +23 -10
  43. package/skills/switchroom-install/SKILL.md +3 -3
  44. package/skills/switchroom-manage/SKILL.md +26 -19
  45. package/skills/switchroom-runtime/SKILL.md +67 -15
  46. package/skills/switchroom-status/SKILL.md +26 -1
  47. package/skills/telegram-test-harness/SKILL.md +3 -0
  48. package/skills/webapp-testing/SKILL.md +31 -1
  49. package/skills/xlsx/SKILL.md +1 -1
  50. package/telegram-plugin/admin-commands/index.ts +7 -5
  51. package/telegram-plugin/dist/gateway/gateway.js +13042 -12844
  52. package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
  53. package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
  54. package/telegram-plugin/gateway/auth-command.ts +794 -0
  55. package/telegram-plugin/gateway/auth-line.ts +123 -0
  56. package/telegram-plugin/gateway/boot-card.ts +22 -36
  57. package/telegram-plugin/gateway/boot-probes.ts +3 -3
  58. package/telegram-plugin/gateway/gateway.ts +313 -798
  59. package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
  60. package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
  61. package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
  62. package/telegram-plugin/permission-title.ts +56 -0
  63. package/telegram-plugin/quota-check.ts +19 -41
  64. package/telegram-plugin/scripts/build.mjs +0 -1
  65. package/telegram-plugin/shared/bot-runtime.ts +5 -4
  66. package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
  67. package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
  68. package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
  69. package/telegram-plugin/tests/boot-probes.test.ts +11 -4
  70. package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
  71. package/telegram-plugin/tests/permission-title.test.ts +31 -0
  72. package/telegram-plugin/tests/quota-check.test.ts +5 -35
  73. package/telegram-plugin/uat/SETUP.md +31 -1
  74. package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
  75. package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
  76. package/telegram-plugin/uat/runners/report.ts +150 -0
  77. package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
  78. package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
  79. package/telegram-plugin/uat/runners/scorer.ts +106 -0
  80. package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
  81. package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
  82. package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +7 -1
  83. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
  84. package/telegram-plugin/auth-dashboard.ts +0 -1104
  85. package/telegram-plugin/auth-slot-parser.ts +0 -497
  86. package/telegram-plugin/dist/foreman/foreman.js +0 -31358
  87. package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
  88. package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
  89. package/telegram-plugin/foreman/foreman.ts +0 -1165
  90. package/telegram-plugin/foreman/setup-flow.ts +0 -345
  91. package/telegram-plugin/foreman/setup-state.ts +0 -239
  92. package/telegram-plugin/foreman/state.ts +0 -203
  93. package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
  94. package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
  95. package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
  96. package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
  97. package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
  98. package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
  99. package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
  100. package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
  101. package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
  102. package/telegram-plugin/tests/foreman-state.test.ts +0 -164
  103. package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
  104. package/telegram-plugin/tests/setup-flow.test.ts +0 -510
  105. package/telegram-plugin/tests/setup-state.test.ts +0 -146
@@ -82,32 +82,25 @@ import {
82
82
  import { clearStaleTelegramPollingState } from '../startup-reset.js'
83
83
  import { gatewayStartupRetry } from './startup-network-retry.js'
84
84
  import { writeQuarantineMarker } from './quarantine.js'
85
+ // RFC H §7.3: auth-dashboard + auth-slot-parser deleted. Three chat
86
+ // verbs (/auth show | use | rotate) talk to switchroom-auth-broker
87
+ // via the thin client in src/auth/broker/client.ts.
85
88
  import {
86
- parseAuthSubCommand,
87
- checkRemoveSafety,
88
- formatSlotList,
89
- type SlotListingFromCli,
90
- } from '../auth-slot-parser.js'
89
+ parseAuthCommand,
90
+ handleAuthCommand,
91
+ isAuthAdmin,
92
+ pendingAuthRmFlows,
93
+ } from './auth-command.js'
94
+ import type { AuthBrokerClient } from './auth-command.js'
95
+ import type { ListStateData } from './auth-line.js'
96
+ import { getAuthBrokerClient, addAccountViaBroker } from './auth-broker-client.js'
91
97
  import {
92
- buildDashboard,
93
- buildRemoveConfirmKeyboard,
94
- buildAccountConfirmKeyboard,
95
- buildAccountPromoteConfirmKeyboard,
96
- buildSwitchPrimaryKeyboard,
97
- buildAccountSubViewText,
98
- buildAccountSubViewKeyboard,
99
- buildAccountRemoveConfirmKeyboard,
100
- parseCallbackData,
101
- encodeCallbackData,
102
- isQuotaHot,
103
- isAccountQuotaHot,
104
- ACCOUNTS_DISPLAY_CAP,
105
- type DashboardState,
106
- type DashboardSlot,
107
- type SlotHealth,
108
- type AccountSummary,
109
- type AccountHealth,
110
- } from '../auth-dashboard.js'
98
+ pendingAuthAddFlows,
99
+ startAccountAuthSession,
100
+ submitAccountAuthCode,
101
+ cancelAccountAuthSession,
102
+ cleanScratchDir as cleanAuthAddScratchDir,
103
+ } from './auth-add-flow.js'
111
104
  import {
112
105
  initHistory, recordInbound, recordOutbound, recordEdit,
113
106
  deleteFromHistory, query as queryHistory, getLatestInboundMessageId,
@@ -196,10 +189,6 @@ import { PreambleSuppressor } from './preamble-suppressor.js'
196
189
  import {
197
190
  fetchQuota,
198
191
  formatQuotaBlock,
199
- getCachedAccountQuota,
200
- prefetchAccountQuotaIfStale,
201
- hydrateAccountQuotaCacheFromDisk,
202
- clearAccountQuotaCache,
203
192
  } from '../quota-check.js'
204
193
  import {
205
194
  evaluateFallbackTrigger,
@@ -220,6 +209,12 @@ import { type BannerState } from '../slot-banner.js'
220
209
  import { refreshBanner } from '../slot-banner-driver.js'
221
210
  import { dispatchFallbackNotification } from '../auto-fallback-dispatcher.js'
222
211
  import { loadConfig as loadSwitchroomConfig } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
212
+ import {
213
+ tryHostdDispatch,
214
+ hostdRequestId,
215
+ hostdWillBeUsed,
216
+ _resetHostdEnabledCache,
217
+ } from './hostd-dispatch.js'
223
218
  import type { AgentAudit } from '../welcome-text.js'
224
219
  import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
225
220
 
@@ -373,9 +368,14 @@ const INBOX_DIR = join(STATE_DIR, 'inbox')
373
368
  * gateway plugin (we're a child of claude inside the same container).
374
369
  * `targetAgent` is informational only here — we can't restart a
375
370
  * different agent's container from inside our own (no docker.sock).
376
- * - else (legacy systemd): detached `systemctl --user restart` of the
377
- * two units. The detach is required so the systemctl job survives
378
- * us being SIGTERM'd by systemd itself.
371
+ * - else (v0.6 legacy non-docker path, scheduled for removal in
372
+ * Phase 3 of the host-control daemon rollout see
373
+ * `docs/rfcs/host-control-daemon.md`): detached `systemctl --user
374
+ * restart` of the two units. This branch is never reached on
375
+ * v0.7+ docker installs (the `isDocker` guard above takes the
376
+ * docker branch); only callable on legacy systemd hosts that
377
+ * ran the gateway as a user unit. Don't add new dependencies
378
+ * on this path.
379
379
  *
380
380
  * `targetAgent` defaults to `SWITCHROOM_AGENT_NAME`; pass a different
381
381
  * value only for the inline restart-button callback handler. Under
@@ -2024,9 +2024,23 @@ function isAutoFallbackCooldownActive(_agentName: string, now: number): boolean
2024
2024
  // 60-second sweep drops anything past its documented TTL.
2025
2025
  const pendingStateReaper = setInterval(() => {
2026
2026
  const now = Date.now()
2027
+ // OAuth-code state grouped first (pinned by secret-detect-oauth-code.test.ts).
2027
2028
  for (const [k, v] of pendingReauthFlows) {
2028
2029
  if (now - v.startedAt > REAUTH_INTERCEPT_TTL_MS) pendingReauthFlows.delete(k)
2029
2030
  }
2031
+ for (const [k, v] of pendingAuthAddFlows) {
2032
+ if (now - v.startedAt > REAUTH_INTERCEPT_TTL_MS) {
2033
+ cancelAccountAuthSession(v)
2034
+ pendingAuthAddFlows.delete(k)
2035
+ }
2036
+ }
2037
+ for (const [k, v] of awaitingAuthCodeAt) {
2038
+ if (now - v > AUTH_CODE_CONTEXT_TTL_MS) awaitingAuthCodeAt.delete(k)
2039
+ }
2040
+ // /auth rm two-step confirm window — self-expires at `expiresAt`.
2041
+ for (const [k, v] of pendingAuthRmFlows) {
2042
+ if (now >= v.expiresAt) pendingAuthRmFlows.delete(k)
2043
+ }
2030
2044
  for (const [k, v] of pendingVaultOps) {
2031
2045
  if (now - v.startedAt > VAULT_INPUT_TTL_MS) pendingVaultOps.delete(k)
2032
2046
  }
@@ -2036,9 +2050,6 @@ const pendingStateReaper = setInterval(() => {
2036
2050
  for (const [k, v] of vaultPassphraseCache) {
2037
2051
  if (now > v.expiresAt) vaultPassphraseCache.delete(k)
2038
2052
  }
2039
- for (const [k, v] of awaitingAuthCodeAt) {
2040
- if (now - v > AUTH_CODE_CONTEXT_TTL_MS) awaitingAuthCodeAt.delete(k)
2041
- }
2042
2053
  for (const [k, v] of deferredSecrets) {
2043
2054
  if (now - v.staged_at > DEFERRED_SECRET_TTL_MS) deferredSecrets.delete(k)
2044
2055
  }
@@ -5942,6 +5953,60 @@ async function handleInbound(
5942
5953
  return
5943
5954
  }
5944
5955
 
5956
+ // `/auth add` paste-back intercept — sibling to pendingReauthFlows.
5957
+ // Both intercepts are deliberate so the LLM never sees the OAuth
5958
+ // code (it doesn't need to + plaintext OAuth in chat history is bad
5959
+ // hygiene). The add-flow intercept comes first because /auth add
5960
+ // creates fresh credentials at the broker layer, vs /reauth which
5961
+ // mutates an existing agent's slot — different success paths.
5962
+ const pendingAdd = pendingAuthAddFlows.get(chat_id)
5963
+ if (pendingAdd && looksLikeAuthCode(text)) {
5964
+ const elapsed = Date.now() - pendingAdd.startedAt
5965
+ if (elapsed < REAUTH_INTERCEPT_TTL_MS) {
5966
+ pendingAuthAddFlows.delete(chat_id)
5967
+ try {
5968
+ const credentials = await submitAccountAuthCode(pendingAdd, text.trim())
5969
+ try {
5970
+ await addAccountViaBroker(pendingAdd.label, credentials, { replace: false })
5971
+ // success — wipe scratch dir now that the broker owns the creds
5972
+ cleanAuthAddScratchDir(pendingAdd.scratchDir)
5973
+ await switchroomReply(
5974
+ ctx,
5975
+ `✓ Account <code>${escapeHtmlForTg(pendingAdd.label)}</code> added.\n` +
5976
+ `The fleet's active account hasn't changed. Send ` +
5977
+ `<code>/auth use ${escapeHtmlForTg(pendingAdd.label)}</code> to switch to it.`,
5978
+ { html: true },
5979
+ )
5980
+ } catch (brokerErr) {
5981
+ // Broker rejected (e.g. label already exists). Wipe scratch
5982
+ // either way — the credentials are useless without broker
5983
+ // bookkeeping.
5984
+ cleanAuthAddScratchDir(pendingAdd.scratchDir)
5985
+ await switchroomReply(
5986
+ ctx,
5987
+ `<b>/auth add failed at broker:</b> ${escapeHtmlForTg((brokerErr as Error)?.message ?? String(brokerErr))}`,
5988
+ { html: true },
5989
+ )
5990
+ }
5991
+ } catch (err) {
5992
+ // submitAccountAuthCode wiped the scratch dir on its own
5993
+ // failure paths (timeout, child exit, stdin broken).
5994
+ await switchroomReply(
5995
+ ctx,
5996
+ `<b>/auth add code failed:</b> ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`,
5997
+ { html: true },
5998
+ )
5999
+ }
6000
+ // Redact the OAuth code paste from chat history (#488).
6001
+ redactAuthCodeMessage(bot.api as never, chat_id, msgId ?? null, line => process.stderr.write(line))
6002
+ return
6003
+ }
6004
+ // Stale — drop the pending entry but let the message fall through
6005
+ // to other intercepts (defensively wipe scratch).
6006
+ cancelAccountAuthSession(pendingAdd)
6007
+ pendingAuthAddFlows.delete(chat_id)
6008
+ }
6009
+
5945
6010
  // Auth-code intercept
5946
6011
  const pendingReauth = pendingReauthFlows.get(chat_id)
5947
6012
  if (pendingReauth && looksLikeAuthCode(text)) {
@@ -6982,6 +7047,11 @@ export function _resetDockerReachableCache(): void {
6982
7047
  _dockerReachable = undefined
6983
7048
  }
6984
7049
 
7050
+ // hostd dispatch lives in `hostd-dispatch.ts` (extracted for testability).
7051
+ // Re-export the cache-reset so existing test patterns that reach into
7052
+ // gateway.ts for `_resetDockerReachableCache` find a parallel hook.
7053
+ export { _resetHostdEnabledCache }
7054
+
6985
7055
  function spawnSwitchroomDetached(
6986
7056
  args: string[],
6987
7057
  onFailure?: (info: { code: number; tail: string }) => void,
@@ -7364,7 +7434,7 @@ function renderAuthCodeOutcome(outcome: AuthCodeOutcome | null | undefined): str
7364
7434
  case 'pane-not-ready':
7365
7435
  return `Auth pane not ready — tap <b>Retry</b>.`
7366
7436
  case 'timeout':
7367
- return `Still waiting after 2 min — tap <b>Retry</b> or check <code>switchroom auth status</code>.${tail}`
7437
+ return `Still waiting after 2 min — tap <b>Retry</b> or check <code>switchroom auth list</code>.${tail}`
7368
7438
  }
7369
7439
  }
7370
7440
 
@@ -7551,6 +7621,10 @@ function buildAgentAudit(agentName: string): AgentAudit | undefined {
7551
7621
 
7552
7622
  // Build an AgentMetadata snapshot for the current agent by shelling out
7553
7623
  // to `switchroom agent list --json` and `switchroom auth status --json`.
7624
+ // TODO(rfc-h): the `auth status` verb was retired by RFC H. The shell
7625
+ // fails silently and `authSummary` lands as null — /status renders
7626
+ // without auth detail. Replace with an `auth show --json` adapter that
7627
+ // maps the new fleet-broker shape to the per-agent AuthSummary fields.
7554
7628
  // Best-effort — any missing piece renders as a placeholder in the text
7555
7629
  // templates rather than blocking the reply.
7556
7630
  async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
@@ -7771,9 +7845,32 @@ bot.command('restart', async ctx => {
7771
7845
  // of whatever reason the downstream CLI would default to.
7772
7846
  stampUserRestartReason('user: /restart from chat')
7773
7847
  await sweepBeforeSelfRestart()
7774
- spawnSwitchroomDetached(
7775
- ['agent', 'restart', name, '--force'],
7776
- notifyDetachedFailure(chatId, threadId ?? null, `restart ${name}`),
7848
+ const hostdResp = await tryHostdDispatch(getMyAgentName(), {
7849
+ v: 1,
7850
+ op: 'agent_restart',
7851
+ request_id: hostdRequestId('gw-restart'),
7852
+ args: { name, force: true, reason: 'user: /restart from chat' },
7853
+ })
7854
+ if (hostdResp === 'not-configured') {
7855
+ spawnSwitchroomDetached(
7856
+ ['agent', 'restart', name, '--force'],
7857
+ notifyDetachedFailure(chatId, threadId ?? null, `restart ${name}`),
7858
+ )
7859
+ return
7860
+ }
7861
+ if (hostdResp.result === 'started' || hostdResp.result === 'completed') {
7862
+ // Dispatched via hostd. The recreate will kill this gateway
7863
+ // shortly; the new gateway reads the marker and edits the ack.
7864
+ return
7865
+ }
7866
+ // hostd was attempted but errored/denied — clear marker and surface.
7867
+ clearRestartMarker()
7868
+ await switchroomReply(
7869
+ ctx,
7870
+ `❌ <b>restart ${escapeHtmlForTg(name)} failed via hostd</b> ` +
7871
+ `(result=${escapeHtmlForTg(hostdResp.result)}):\n` +
7872
+ preBlock(hostdResp.error ?? '(no error message)'),
7873
+ { html: true },
7777
7874
  )
7778
7875
  return
7779
7876
  }
@@ -7889,9 +7986,29 @@ async function handleNewOrResetCommand(ctx: Context, kind: 'new' | 'reset'): Pro
7889
7986
  // /new" / "user: /reset" rather than the downstream CLI default.
7890
7987
  stampUserRestartReason(`user: /${kind} from chat`)
7891
7988
  await sweepBeforeSelfRestart()
7892
- spawnSwitchroomDetached(
7893
- ['agent', 'restart', name, '--force'],
7894
- notifyDetachedFailure(chatId, threadId ?? null, `${kind} ${name}`),
7989
+ const hostdResp = await tryHostdDispatch(getMyAgentName(), {
7990
+ v: 1,
7991
+ op: 'agent_restart',
7992
+ request_id: hostdRequestId(`gw-${kind}`),
7993
+ args: { name, force: true, reason: `user: /${kind} from chat` },
7994
+ })
7995
+ if (hostdResp === 'not-configured') {
7996
+ spawnSwitchroomDetached(
7997
+ ['agent', 'restart', name, '--force'],
7998
+ notifyDetachedFailure(chatId, threadId ?? null, `${kind} ${name}`),
7999
+ )
8000
+ return
8001
+ }
8002
+ if (hostdResp.result === 'started' || hostdResp.result === 'completed') {
8003
+ return
8004
+ }
8005
+ clearRestartMarker()
8006
+ await switchroomReply(
8007
+ ctx,
8008
+ `❌ <b>${escapeHtmlForTg(kind)} ${escapeHtmlForTg(name)} failed via hostd</b> ` +
8009
+ `(result=${escapeHtmlForTg(hostdResp.result)}):\n` +
8010
+ preBlock(hostdResp.error ?? '(no error message)'),
8011
+ { html: true },
7895
8012
  )
7896
8013
  }
7897
8014
 
@@ -7947,22 +8064,23 @@ bot.command('update', async ctx => {
7947
8064
  // container, which has the switchroom CLI baked in but no docker
7948
8065
  // binary and no /var/run/docker.sock mount. So `switchroom update`'s
7949
8066
  // pull-images and recreate-containers steps would fail with
7950
- // "docker: command not found". Without this guard, the operator
7951
- // sees an opaque "❌ update failed (exit 127)" via
7952
- // notifyDetachedFailure ~5s after the ack.
8067
+ // "docker: command not found".
7953
8068
  //
7954
- // Surface a clean explanation instead, pointing them at the host
7955
- // CLI as the working path. /update (dry-run) does NOT need docker
7956
- // and is unaffected only /update apply.
7957
- if (!isDockerReachable()) {
8069
+ // BYPASSED when hostd is on (#1175 Phase 2 RFC C): hostd runs on the
8070
+ // host with the docker socket mounted, so the in-container docker
8071
+ // dependency goes away. Skip the guard so /update apply can dispatch
8072
+ // through hostd. When hostd is NOT in play, keep the guard so the
8073
+ // operator gets a clean explanation instead of an opaque exit-127.
8074
+ if (!hostdWillBeUsed(getMyAgentName()) && !isDockerReachable()) {
7958
8075
  await switchroomReply(
7959
8076
  ctx,
7960
8077
  `❌ <b>/update apply</b> needs docker access from inside the agent ` +
7961
8078
  `container, but it's not available (no <code>docker</code> binary on ` +
7962
8079
  `PATH, no <code>/var/run/docker.sock</code> mount).\n\n` +
7963
- `On docker installs, run <code>switchroom update</code> from the ` +
7964
- `host shell instead.\n\n` +
7965
- `<i>Tracked as #926 host-side update daemon would close this gap.</i>`,
8080
+ `On docker installs, either run <code>switchroom update</code> from ` +
8081
+ `the host shell, or enable <code>host_control.enabled</code> in ` +
8082
+ `<code>switchroom.yaml</code> and <code>switchroom hostd install</code> ` +
8083
+ `so this verb dispatches through the host-side daemon.`,
7966
8084
  { html: true },
7967
8085
  )
7968
8086
  return
@@ -8036,9 +8154,34 @@ bot.command('update', async ctx => {
8036
8154
  // pinned-progress-card surface is the headline feature per CLAUDE.md;
8037
8155
  // leaving one pinned across the recreate would surprise the operator.
8038
8156
  await sweepBeforeSelfRestart()
8039
- spawnSwitchroomDetached(
8040
- ['update', ...passthrough],
8041
- notifyDetachedFailure(chatId, threadId ?? null, 'update'),
8157
+ const skipImages = passthrough.includes('--skip-images')
8158
+ const rebuild = passthrough.includes('--rebuild')
8159
+ const hostdResp = await tryHostdDispatch(getMyAgentName(), {
8160
+ v: 1,
8161
+ op: 'update_apply',
8162
+ request_id: hostdRequestId('gw-update'),
8163
+ args: {
8164
+ ...(skipImages ? { skip_images: true } : {}),
8165
+ ...(rebuild ? { rebuild: true } : {}),
8166
+ },
8167
+ })
8168
+ if (hostdResp === 'not-configured') {
8169
+ spawnSwitchroomDetached(
8170
+ ['update', ...passthrough],
8171
+ notifyDetachedFailure(chatId, threadId ?? null, 'update'),
8172
+ )
8173
+ return
8174
+ }
8175
+ if (hostdResp.result === 'started' || hostdResp.result === 'completed') {
8176
+ return
8177
+ }
8178
+ clearRestartMarker()
8179
+ await switchroomReply(
8180
+ ctx,
8181
+ `❌ <b>/update apply failed via hostd</b> ` +
8182
+ `(result=${escapeHtmlForTg(hostdResp.result)}):\n` +
8183
+ preBlock(hostdResp.error ?? '(no error message)'),
8184
+ { html: true },
8042
8185
  )
8043
8186
  })
8044
8187
 
@@ -8346,14 +8489,19 @@ async function runCreditWatch(): Promise<void> {
8346
8489
  // assumption mirrors auto-fallback's notification routing.
8347
8490
  const access = loadAccess()
8348
8491
  for (const chat_id of access.allowFrom) {
8349
- try {
8350
- await bot.api.sendMessage(chat_id, decision.message, {
8351
- parse_mode: 'HTML',
8352
- link_preview_options: { is_disabled: true },
8353
- })
8354
- } catch (err) {
8355
- process.stderr.write(`telegram gateway: credit-watch notify chat=${chat_id} failed: ${err}\n`)
8356
- }
8492
+ // Credit-watch notify — best-effort. Wrap via swallowingApiCall so
8493
+ // flood-wait / deleted-chat / not-found surface as a stderr log
8494
+ // rather than a thrown exception that aborts the loop and leaves
8495
+ // half the allowFrom chats unnotified. Matches the wrapping
8496
+ // contract enforced by scripts/check-bot-api-wrapping.sh (#1075).
8497
+ await swallowingApiCall(
8498
+ () =>
8499
+ bot.api.sendMessage(chat_id, decision.message, {
8500
+ parse_mode: 'HTML',
8501
+ link_preview_options: { is_disabled: true },
8502
+ }),
8503
+ { chat_id, verb: 'credit-watch.notify' },
8504
+ )
8357
8505
  }
8358
8506
  // Persist state regardless of whether send succeeded — losing a
8359
8507
  // notify is bad, but re-spamming on every poll tick is worse.
@@ -8373,429 +8521,114 @@ async function runCreditWatch(): Promise<void> {
8373
8521
  // pinned messages from earlier versions still work, and the
8374
8522
  // auto-fallback poller still calls runAutoFallbackCheck directly.
8375
8523
 
8376
- bot.command('auth', async ctx => {
8524
+ bot.command("auth", async ctx => {
8377
8525
  if (!isAuthorizedSender(ctx)) return
8378
- const parts = getCommandArgs(ctx).split(/\s+/).filter(Boolean)
8526
+ const text = ctx.message?.text ?? ""
8527
+ const parsed = parseAuthCommand(text)
8528
+ if (!parsed) return
8379
8529
  const currentAgent = getMyAgentName()
8380
- const intent = parseAuthSubCommand(parts, currentAgent)
8381
-
8382
- if (intent.kind === 'error' || intent.kind === 'usage') {
8383
- await switchroomReply(ctx, intent.message)
8384
- return
8385
- }
8386
-
8387
- if (intent.kind === 'login' || intent.kind === 'reauth' || intent.kind === 'link') {
8388
- await runSwitchroomAuthCommand(ctx, intent.cliArgs, intent.label)
8389
- if (intent.registerReauth) pendingReauthFlows.set(String(ctx.chat!.id), { agent: intent.agent, startedAt: Date.now() })
8390
- return
8391
- }
8392
- if (intent.kind === 'code') {
8393
- // Use structured JSON path so we can render typed outcome messages.
8394
- const { result, errorText } = execAuthCode(intent.agent, intent.code)
8395
- if (errorText) {
8396
- await switchroomReply(ctx, `<b>${escapeHtmlForTg(intent.label)} failed:</b>\n${preBlock(formatSwitchroomOutput(errorText))}`, { html: true })
8397
- } else if (result) {
8398
- const outcomeMsg = renderAuthCodeOutcome(result.outcome)
8399
- if (outcomeMsg) {
8400
- await switchroomReply(ctx, outcomeMsg, { html: true })
8401
- } else {
8402
- const output = result.instructions.join('\n')
8403
- const formatted = formatAuthOutputForTelegram(output)
8404
- await switchroomReply(ctx, formatted.text, { html: true })
8405
- }
8406
- }
8407
- pendingReauthFlows.delete(String(ctx.chat!.id))
8408
- // Redact the OAuth code from chat history (#488).
8409
- redactAuthCodeMessage(bot.api as never, String(ctx.chat!.id), ctx.message?.message_id ?? null, line => process.stderr.write(line))
8410
- return
8411
- }
8412
- if (intent.kind === 'cancel') {
8413
- await runSwitchroomCommand(ctx, intent.cliArgs, intent.label)
8414
- pendingReauthFlows.delete(String(ctx.chat!.id))
8415
- return
8416
- }
8417
-
8418
- // --- Slot management verbs ---
8419
-
8420
- if (intent.kind === 'add') {
8421
- await runSwitchroomAuthCommand(ctx, intent.cliArgs, intent.label)
8422
- pendingReauthFlows.set(String(ctx.chat!.id), { agent: intent.agent, startedAt: Date.now() })
8423
- void refreshPinnedBanner('auth-add')
8424
- return
8425
- }
8426
-
8427
- if (intent.kind === 'use') {
8428
- // Soft-confirm: a slot swap restarts the agent process, killing any
8429
- // in-flight turn. If a turn is currently running, refuse without
8430
- // --force so a fat-finger tap doesn't quietly destroy the user's
8431
- // work-in-progress. Idle-state swaps proceed with no friction. (#421B)
8432
- if (!intent.force && activeTurnStartedAt.size > 0) {
8530
+ // Post-unification admin gating: admin authority is sourced from each
8531
+ // agent's own `admin: true` flag (the same flag that gates /agents,
8532
+ // /restart, /update etc. per PR #1258). The gateway looks itself up
8533
+ // and passes a boolean through — handler-side code does not consult
8534
+ // any list. The broker enforces server-side from the same source.
8535
+ let isAdmin = false
8536
+ try {
8537
+ const cfg = loadSwitchroomConfig()
8538
+ const me = (cfg as unknown as { agents?: Record<string, { admin?: boolean }> })?.agents?.[currentAgent]
8539
+ isAdmin = me?.admin === true
8540
+ } catch { /* best-effort — non-admin is the safe default */ }
8541
+
8542
+ // `/auth add` and `/auth cancel` are gateway-routed (drive a
8543
+ // scratch-dir-backed `claude setup-token` lifecycle the broker
8544
+ // client can't model). Everything else delegates to
8545
+ // handleAuthCommand which only needs the narrow broker surface.
8546
+ const chatId = String(ctx.chat?.id ?? '')
8547
+ if (parsed.kind === 'add' || parsed.kind === 'cancel') {
8548
+ if (!isAuthAdmin({ isAdmin })) {
8433
8549
  await switchroomReply(
8434
8550
  ctx,
8435
- `⚠️ A turn is in flight. Swapping to <code>${escapeHtmlForTg(intent.slot)}</code> will abort it.\n` +
8436
- `Resend as <code>/auth use ${escapeHtmlForTg(intent.agent)} ${escapeHtmlForTg(intent.slot)} --force</code> to proceed.`,
8551
+ `<b>Not authorized.</b> <code>/auth ${parsed.kind}</code> is admin-only.\n` +
8552
+ `Set <code>admin: true</code> on this agent in switchroom.yaml to unlock ` +
8553
+ `(the same flag that gates <code>/agents</code>, <code>/restart</code>, ` +
8554
+ `<code>/update</code> etc.).`,
8437
8555
  { html: true },
8438
8556
  )
8439
8557
  return
8440
8558
  }
8441
- await runSwitchroomCommand(ctx, intent.cliArgs, intent.label)
8442
- // Restart the agent so the new OAuth token is picked up.
8443
- try { assertSafeAgentName(intent.agent) } catch { return }
8444
- await runSwitchroomCommand(ctx, ['agent', 'restart', intent.agent], `restart ${intent.agent}`)
8445
- void refreshPinnedBanner('auth-use')
8446
- return
8447
- }
8448
-
8449
- if (intent.kind === 'list') {
8450
- await runSwitchroomCommandFormatted(ctx, intent.cliArgs, intent.label, () => {
8451
- const data = switchroomExecJson<SlotListingFromCli>(intent.cliArgs)
8452
- if (!data) return null
8453
- return formatSlotList({ ...data, agent: data.agent ?? intent.agent })
8454
- })
8455
- return
8456
- }
8457
-
8458
- if (intent.kind === 'rm') {
8459
- // Safety check against current slot listing unless --force.
8460
- if (!intent.force) {
8461
- const listing = switchroomExecJson<SlotListingFromCli>(['auth', 'list', intent.agent, '--json'])
8462
- if (listing) {
8463
- const err = checkRemoveSafety({ ...listing, agent: listing.agent ?? intent.agent }, intent.slot, intent.force)
8464
- if (err) { await switchroomReply(ctx, err); return }
8465
- }
8466
- }
8467
- await runSwitchroomCommand(ctx, intent.cliArgs, intent.label)
8468
- return
8469
- }
8470
-
8471
- // --- New account-shaped verbs (see reference/share-auth-across-the-fleet.md) ---
8472
-
8473
- if (intent.kind === 'account-add') {
8474
- // /auth account add <label> [--from-agent <name>]
8475
- // Lifts an already-authenticated agent's credentials into a global
8476
- // account so other agents can share the same Anthropic subscription
8477
- // without each running its own OAuth flow.
8478
- await runSwitchroomCommand(ctx, intent.cliArgs, intent.label)
8479
- return
8480
- }
8481
-
8482
- if (intent.kind === 'account-list') {
8483
- // /auth account list — table of accounts + which agents use each.
8484
- await runSwitchroomCommand(ctx, intent.cliArgs, intent.label)
8485
- return
8486
- }
8487
-
8488
- if (intent.kind === 'account-rm') {
8489
- // /auth account rm <label> — refused if any agent is still enabled.
8490
- await runSwitchroomCommand(ctx, intent.cliArgs, intent.label)
8491
- return
8492
- }
8493
-
8494
- if (intent.kind === 'account-rename') {
8495
- // /auth account rename <old> <new> — atomic dir rename + YAML
8496
- // rewrite of every agents.<name>.auth.accounts list. No agent
8497
- // restart required: per-agent credentials.json content is
8498
- // unchanged (only the source-of-truth label moved).
8499
- await runSwitchroomCommand(ctx, intent.cliArgs, intent.label)
8500
- return
8501
- }
8502
-
8503
- if (intent.kind === 'enable') {
8504
- // /auth enable <label> [agents...|all] — wires the account to those agents
8505
- // (defaults to the current agent), then restarts each so claude picks
8506
- // up the freshly fanned-out credentials. The CLI accepts the `all`
8507
- // keyword verbatim and expands it itself; we expand here too so the
8508
- // restart loop knows the real agent names.
8509
- await runSwitchroomCommand(ctx, intent.cliArgs, intent.label)
8510
- if (intent.restartAgentsAfter) {
8511
- const restartTargets = await resolveAgentsForRestart(intent.agents)
8512
- for (const a of restartTargets) {
8513
- try { assertSafeAgentName(a) } catch { continue }
8514
- await runSwitchroomCommand(ctx, ['agent', 'restart', a], `restart ${a}`)
8559
+ if (parsed.kind === 'cancel') {
8560
+ const existing = pendingAuthAddFlows.get(chatId)
8561
+ if (!existing) {
8562
+ await switchroomReply(ctx, "<i>No pending <code>/auth add</code> flow in this chat.</i>", { html: true })
8563
+ return
8515
8564
  }
8516
- }
8517
- void refreshPinnedBanner('auth-enable')
8518
- return
8519
- }
8520
-
8521
- if (intent.kind === 'disable') {
8522
- // /auth disable <label> [agents...|all] — unwires the account from those
8523
- // agents. Doesn't auto-restart: the operator may want to drain the
8524
- // current credential first. The CLI hint already says "restart now".
8525
- await runSwitchroomCommand(ctx, intent.cliArgs, intent.label)
8526
- return
8527
- }
8528
-
8529
- if (intent.kind === 'share') {
8530
- // /auth share <label> [--from-agent <name>] — one-shot account-add +
8531
- // enable on every claude-enabled agent. The CLI does the merged YAML
8532
- // write; we restart every agent it touched so credentials load.
8533
- await runSwitchroomCommand(ctx, intent.cliArgs, intent.label)
8534
- const restartTargets = await resolveAgentsForRestart(['all'])
8535
- for (const a of restartTargets) {
8536
- try { assertSafeAgentName(a) } catch { continue }
8537
- await runSwitchroomCommand(ctx, ['agent', 'restart', a], `restart ${a}`)
8538
- }
8539
- void refreshPinnedBanner('auth-share')
8540
- return
8541
- }
8542
-
8543
- // intent.kind === 'status' — render the inline-keyboard dashboard.
8544
- // For the dashboard we're the bot-bound agent: we don't list every
8545
- // agent in the switchroom config; we show THIS bot's agent with its
8546
- // slots and actions. The 'status' branch of AuthIntent has no
8547
- // `agent` field; use currentAgent as the dashboard target.
8548
- await sendAuthDashboard(ctx, currentAgent)
8549
- })
8550
-
8551
- /**
8552
- * Gather DashboardState for an agent and send the dashboard as a fresh
8553
- * message (on `/auth` command) or editMessageText (on callback refresh).
8554
- *
8555
- * Implementation note: we could poll fetchQuota here to populate the
8556
- * fiveHour/sevenDay utilization per slot. Skipping for the initial
8557
- * landing — quota-check is expensive (one Anthropic API call per poll)
8558
- * and the background auto-fallback already surfaces quota-exhausted
8559
- * state. Dashboard renders the CLI-side health badges and omits
8560
- * utilization numbers when they're absent; a future PR can wire
8561
- * quota-check in.
8562
- */
8563
- async function sendAuthDashboard(
8564
- ctx: Context,
8565
- agent: string,
8566
- opts: { edit?: boolean } = {},
8567
- ): Promise<void> {
8568
- const state = fetchDashboardState(agent)
8569
- if (!state) {
8570
- await switchroomReply(
8571
- ctx,
8572
- `<b>/auth</b> — no data (agent "${escapeHtmlForTg(agent)}" missing from switchroom.yaml or CLI unreachable)`,
8573
- { html: true },
8574
- )
8575
- return
8576
- }
8577
- const { text, keyboard } = buildDashboard(state)
8578
- if (opts.edit && ctx.callbackQuery) {
8579
- try {
8580
- await ctx.editMessageText(text, { parse_mode: 'HTML', reply_markup: keyboard, link_preview_options: { is_disabled: true } })
8565
+ cancelAccountAuthSession(existing)
8566
+ pendingAuthAddFlows.delete(chatId)
8567
+ await switchroomReply(ctx, "Cancelled.", { html: true })
8581
8568
  return
8582
- } catch {
8583
- // Message may have been deleted or identical content
8584
- // (editMessageText throws MESSAGE_NOT_MODIFIED) — fall through
8585
- // to sending a new one.
8586
- }
8587
- }
8588
- await switchroomReply(ctx, text, { html: true, reply_markup: keyboard })
8589
- }
8590
-
8591
- /**
8592
- * Drop the cached per-account quota and immediately schedule a
8593
- * background re-probe for every known account. Used after auth-mutating
8594
- * dashboard taps (enable/disable/promote/share/account-rm-confirm) so
8595
- * the next `/auth` render shows fresh quota rather than a 30s-stale
8596
- * snapshot from before the change.
8597
- *
8598
- * Fire-and-forget: probes complete in ~hundreds of ms; the user's
8599
- * follow-up dashboard render reads whatever's cached at that moment,
8600
- * usually the freshly-warmed value. Errors (network, rate limit) are
8601
- * absorbed by `prefetchAccountQuotaIfStale`.
8602
- */
8603
- function clearAndRewarmAccountQuotas(): void {
8604
- clearAccountQuotaCache()
8605
- try {
8606
- const accounts = switchroomExecJson<Array<{ label: string }>>([
8607
- 'auth', 'account', 'list', '--json',
8608
- ])
8609
- if (Array.isArray(accounts)) {
8610
- for (const a of accounts) {
8611
- if (typeof a?.label === 'string') prefetchAccountQuotaIfStale(a.label)
8612
- }
8613
8569
  }
8614
- } catch {
8615
- /* clear-only fallback — next dashboard render's lazy prefetch will warm */
8616
- }
8617
- }
8618
-
8619
- function fetchDashboardState(agent: string): DashboardState | null {
8620
- // Slots come from switchroom auth list --json.
8621
- let slots: DashboardSlot[] = []
8622
- try {
8623
- const listing = switchroomExecJson<SlotListingFromCli>(['auth', 'list', agent, '--json'])
8624
- if (listing && Array.isArray(listing.slots)) {
8625
- slots = listing.slots.map((s) => ({
8626
- slot: s.slot,
8627
- active: s.active,
8628
- health: (s.health as SlotHealth) ?? 'missing',
8629
- quotaExhaustedUntil: s.quota_exhausted_until ?? null,
8630
- fiveHourPct: null,
8631
- sevenDayPct: null,
8632
- }))
8570
+ // parsed.kind === 'add'
8571
+ if (pendingAuthAddFlows.has(chatId)) {
8572
+ await switchroomReply(
8573
+ ctx,
8574
+ "<i>An <code>/auth add</code> flow is already in progress for this chat. " +
8575
+ "Finish the paste, or send <code>/auth cancel</code> to abort.</i>",
8576
+ { html: true },
8577
+ )
8578
+ return
8633
8579
  }
8634
- } catch {
8635
- return null
8636
- }
8637
-
8638
- // Plan + bank + rateLimitTier come from switchroom auth status for
8639
- // THIS agent. rateLimitTier is the signal users need to verify the
8640
- // correct Anthropic account got authorized during reauth (e.g.
8641
- // max_5x vs max_20x). See 2026-04-22 account-mismatch discussion.
8642
- let plan: string | null = null
8643
- let rateLimitTier: string | null = null
8644
- const bankId = agent
8645
- try {
8646
- type AuthStatusResp = { agents: Array<{ name: string; subscription_type: string | null; rate_limit_tier?: string | null }> }
8647
- const statusData = switchroomExecJson<AuthStatusResp>(['auth', 'status'])
8648
- const thisAgent = statusData?.agents?.find((a) => a.name === agent)
8649
- if (thisAgent?.subscription_type) plan = thisAgent.subscription_type
8650
- if (thisAgent?.rate_limit_tier) rateLimitTier = thisAgent.rate_limit_tier
8651
- } catch {
8652
- /* best-effort */
8653
- }
8654
-
8655
- // Check for a pending auth session on disk. When present, surface it
8656
- // on the dashboard so the user can tap [♻️ Restart flow] without
8657
- // waiting for the automatic stale-session detection to fire (which
8658
- // only fires on actual PKCE challenge drift).
8659
- const pendingSessionSlot = readPendingSessionSlot(agent)
8660
-
8661
- // Account-level state for the dashboard's accounts section. The CLI
8662
- // emits a sorted, JSON array via `auth account list --json` (added
8663
- // in v0.6.x). We map it to the dashboard's `AccountSummary` shape,
8664
- // computing `enabledHere` from the per-account `agents` list.
8665
- //
8666
- // Wrapped in try/catch so an older CLI without --json (or any other
8667
- // failure) leaves `accounts` undefined — the renderer hides the
8668
- // section gracefully.
8669
- type AccountListItem = {
8670
- label: string
8671
- health: AccountHealth
8672
- subscriptionType?: string
8673
- expiresAt?: number
8674
- quotaExhaustedUntil?: number
8675
- email?: string
8676
- agents: string[]
8677
- /** v0.6.9+: agents for which this label is at index 0 of
8678
- * auth.accounts: (i.e. the post-fanout active for that agent).
8679
- * Optional — older CLIs without the field cause the dashboard to
8680
- * fall back to the v3a unmarked render. */
8681
- primaryForAgents?: string[]
8682
- }
8683
- let accounts: AccountSummary[] | undefined
8684
- let accountsTruncated = false
8685
- try {
8686
- const raw = switchroomExecJson<AccountListItem[]>([
8687
- 'auth', 'account', 'list', '--json',
8688
- ])
8689
- if (Array.isArray(raw)) {
8690
- accounts = raw.map((a) => {
8691
- // Layer per-account quota onto the summary from the in-process
8692
- // cache (warmed by `prefetchAccountQuotaIfStale` below). Sync
8693
- // read keeps `fetchDashboardState` itself sync; the cache TTL
8694
- // (30s) keeps the API-call rate bounded.
8695
- const cached = getCachedAccountQuota(a.label)
8696
- const summary: AccountSummary = {
8697
- label: a.label,
8698
- health: a.health,
8699
- enabledHere: Array.isArray(a.agents) && a.agents.includes(agent),
8700
- ...(Array.isArray(a.primaryForAgents)
8701
- ? { activeForThisAgent: a.primaryForAgents.includes(agent) }
8702
- : {}),
8703
- ...(a.subscriptionType ? { subscriptionType: a.subscriptionType } : {}),
8704
- ...(a.expiresAt != null ? { expiresAt: a.expiresAt } : {}),
8705
- ...(a.quotaExhaustedUntil != null
8706
- ? { quotaExhaustedUntil: a.quotaExhaustedUntil }
8707
- : {}),
8708
- ...(cached?.ok
8709
- ? {
8710
- fiveHourPct: cached.data.fiveHourUtilizationPct,
8711
- sevenDayPct: cached.data.sevenDayUtilizationPct,
8712
- ...(cached.data.fiveHourResetAt
8713
- ? { fiveHourResetAt: cached.data.fiveHourResetAt.getTime() }
8714
- : {}),
8715
- ...(cached.data.sevenDayResetAt
8716
- ? { sevenDayResetAt: cached.data.sevenDayResetAt.getTime() }
8717
- : {}),
8718
- }
8719
- : {}),
8720
- }
8721
- // Fire a background probe if the cache is cold/stale. The
8722
- // current render uses whatever's already cached; the *next*
8723
- // tap of /auth (after the probe completes) sees the fresh
8724
- // numbers. Swallowed errors keep the dashboard responsive even
8725
- // when Anthropic is slow or returns a transient 5xx.
8726
- prefetchAccountQuotaIfStale(a.label)
8727
- return summary
8580
+ try {
8581
+ const { loginUrl, scratchDir, child } = await startAccountAuthSession(parsed.label)
8582
+ pendingAuthAddFlows.set(chatId, {
8583
+ label: parsed.label,
8584
+ scratchDir,
8585
+ child,
8586
+ startedAt: Date.now(),
8728
8587
  })
8729
- accountsTruncated = accounts.length > ACCOUNTS_DISPLAY_CAP
8588
+ await switchroomReply(
8589
+ ctx,
8590
+ `<b>Adding account</b> <code>${escapeHtmlForTg(parsed.label)}</code>\n\n` +
8591
+ `1. Open this URL on your phone:\n${loginUrl}\n\n` +
8592
+ `2. Log into Anthropic, copy the code Claude shows.\n` +
8593
+ `3. Paste it back here.\n\n` +
8594
+ `Send <code>/auth cancel</code> to abort.`,
8595
+ { html: true },
8596
+ )
8597
+ } catch (err) {
8598
+ await switchroomReply(
8599
+ ctx,
8600
+ `<b>/auth add failed:</b> ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`,
8601
+ { html: true },
8602
+ )
8730
8603
  }
8731
- } catch {
8732
- /* leave accounts undefined */
8604
+ return
8733
8605
  }
8734
8606
 
8735
- // `canBootstrapShare` decides whether to surface the "🌐 Share to
8736
- // fleet" button when zero accounts exist. We only show it when this
8737
- // agent has slot creds we could promote otherwise the share verb
8738
- // would fail at the credentials lookup.
8739
- const canBootstrapShare = slots.some(
8740
- (s) => s.health === 'healthy' || s.health === 'active',
8741
- )
8742
-
8743
- // `quotaHot` now considers BOTH per-slot percentages (legacy slot
8744
- // model) and per-account percentages (new account model). Either
8745
- // path lighting up flips the [Fall back now] affordance, so the
8746
- // operator sees the warning whether they're on the legacy or new
8747
- // framework.
8748
- const slotQuotaHot = isQuotaHot(slots)
8749
- const accountQuotaHot = isAccountQuotaHot(accounts)
8750
-
8751
- return {
8752
- agent,
8753
- bankId,
8754
- plan,
8755
- rateLimitTier,
8756
- slots,
8757
- quotaHot: slotQuotaHot || accountQuotaHot,
8758
- generatedAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
8759
- pendingSessionSlot,
8760
- accounts,
8761
- accountsTruncated,
8762
- canBootstrapShare,
8607
+ const client = await getAuthBrokerClient(currentAgent)
8608
+ if (!client) {
8609
+ await switchroomReply(ctx, "<b>/auth unavailable:</b> auth-broker client is not loaded (post-RFC-H rewire in progress?).", { html: true })
8610
+ return
8763
8611
  }
8764
- }
8612
+ const reply = await handleAuthCommand(parsed, {
8613
+ agentName: currentAgent,
8614
+ isAdmin,
8615
+ client,
8616
+ chatId,
8617
+ })
8618
+ await switchroomReply(ctx, reply.text, { html: reply.html })
8619
+ })
8765
8620
 
8766
- /**
8767
- * Build the per-account list rendered on the boot/health card (issue
8768
- * #708). Reuses `fetchDashboardState` so the data source matches
8769
- * `/auth` exactly same cache, same shape. Returns null on any
8770
- * failure so the boot card silently omits the section.
8771
- */
8772
- function loadAccountsForBootCard(agent: string): ReadonlyArray<AccountSummary> | null {
8621
+ // Boot-card auth-row loader (issue #708, RFC H rewire). Queries the
8622
+ // broker for `list-state` and hands the raw shape to the boot card,
8623
+ // which delegates rendering to `renderAuthLine`. Returns null on any
8624
+ // failure so the boot card silently omits the section.
8625
+ async function loadAccountsForBootCard(agent: string): Promise<ListStateData | null> {
8773
8626
  try {
8774
- // Re-hydrate the in-process cache from on-disk snapshots
8775
- // captured by previous gateway lifetimes. Without this, a fresh
8776
- // boot would render the accounts section with empty quota rows
8777
- // until the background prefetch ticks. Best-effort.
8778
- try {
8779
- const labels = switchroomExecJson<Array<{ label?: string }>>([
8780
- 'auth', 'account', 'list', '--json',
8781
- ])
8782
- if (Array.isArray(labels)) {
8783
- hydrateAccountQuotaCacheFromDisk(
8784
- labels.map((l) => l?.label).filter((s): s is string => typeof s === 'string'),
8785
- )
8786
- }
8787
- } catch {
8788
- /* hydrate is best-effort; fall through to live state */
8789
- }
8790
-
8791
- const state = fetchDashboardState(agent)
8792
- if (!state || !state.accounts) return null
8793
- // Show only accounts enabled on this agent — fallback rows on the
8794
- // dashboard are useful, but on the boot card "accounts I'm using"
8795
- // is the right scope.
8796
- const enabled = state.accounts.filter((a) => a.enabledHere)
8797
- return enabled.length > 0 ? enabled : null
8798
- } catch {
8627
+ const client = await getAuthBrokerClient(agent)
8628
+ if (!client) return null
8629
+ return await client.listState()
8630
+ } catch (err) {
8631
+ process.stderr.write(`telegram gateway: boot-card auth probe failed: ${(err as Error)?.message ?? String(err)}\n`)
8799
8632
  return null
8800
8633
  }
8801
8634
  }
@@ -10405,310 +10238,17 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
10405
10238
  }
10406
10239
  }
10407
10240
 
10241
+ // RFC H §7.3: the dashboard callback dispatcher is gone — there are
10242
+ // no auth: callback buttons in the new chat surface. We keep a no-op
10243
+ // stub so any stale pinned message that fires an `auth:*` tap is
10244
+ // silently dismissed instead of crashing the gateway.
10408
10245
  async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
10409
- const data = ctx.callbackQuery?.data ?? ''
10410
- const senderId = String(ctx.from?.id ?? '')
10411
- const access = loadAccess()
10412
- if (!access.allowFrom.includes(senderId)) {
10413
- await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
10414
- return
10415
- }
10416
- const action = parseCallbackData(data)
10417
-
10418
- switch (action.kind) {
10419
- case 'refresh': {
10420
- await ctx.answerCallbackQuery({ text: 'Refreshed' }).catch(() => {})
10421
- await sendAuthDashboard(ctx, action.agent, { edit: true })
10422
- return
10423
- }
10424
- case 'reauth': {
10425
- await ctx.answerCallbackQuery({ text: 'Starting reauth…' }).catch(() => {})
10426
- await runSwitchroomAuthCommand(
10427
- ctx,
10428
- action.slot ? ['auth', 'reauth', action.agent, '--slot', action.slot] : ['auth', 'reauth', action.agent],
10429
- `auth reauth ${action.agent}`,
10430
- )
10431
- pendingReauthFlows.set(String(ctx.chat!.id), { agent: action.agent, startedAt: Date.now() })
10432
- return
10433
- }
10434
- case 'add': {
10435
- await ctx.answerCallbackQuery({ text: 'Adding slot…' }).catch(() => {})
10436
- await runSwitchroomAuthCommand(ctx, ['auth', 'add', action.agent], `auth add ${action.agent}`)
10437
- pendingReauthFlows.set(String(ctx.chat!.id), { agent: action.agent, startedAt: Date.now() })
10438
- return
10439
- }
10440
- case 'use': {
10441
- await ctx.answerCallbackQuery({ text: `Switching to ${action.slot}…` }).catch(() => {})
10442
- await runSwitchroomCommand(ctx, ['auth', 'use', action.agent, action.slot], `auth use ${action.agent} ${action.slot}`)
10443
- try { assertSafeAgentName(action.agent) } catch { return }
10444
- await runSwitchroomCommand(ctx, ['agent', 'restart', action.agent], `restart ${action.agent}`)
10445
- await sendAuthDashboard(ctx, action.agent, { edit: true })
10446
- return
10447
- }
10448
- case 'rm': {
10449
- // Two-step confirm — swap the dashboard keyboard for a
10450
- // confirmation keyboard before doing anything destructive.
10451
- await ctx.answerCallbackQuery({ text: `Confirm remove ${action.slot}?` }).catch(() => {})
10452
- try {
10453
- await ctx.editMessageReplyMarkup({ reply_markup: buildRemoveConfirmKeyboard(action.agent, action.slot) })
10454
- } catch { /* ignore */ }
10455
- return
10456
- }
10457
- case 'confirm-rm': {
10458
- await ctx.answerCallbackQuery({ text: `Removing ${action.slot}…` }).catch(() => {})
10459
- const listing = switchroomExecJson<SlotListingFromCli>(['auth', 'list', action.agent, '--json'])
10460
- if (listing) {
10461
- const err = checkRemoveSafety({ ...listing, agent: listing.agent ?? action.agent }, action.slot, false)
10462
- if (err) {
10463
- await switchroomReply(ctx, err)
10464
- await sendAuthDashboard(ctx, action.agent, { edit: true })
10465
- return
10466
- }
10467
- }
10468
- await runSwitchroomCommand(ctx, ['auth', 'rm', action.agent, action.slot], `auth rm ${action.agent} ${action.slot}`)
10469
- await sendAuthDashboard(ctx, action.agent, { edit: true })
10470
- return
10471
- }
10472
- case 'fallback': {
10473
- await ctx.answerCallbackQuery({ text: 'Triggering fallback…' }).catch(() => {})
10474
- const result = await runAutoFallbackCheck({ trigger: 'manual' })
10475
- if (result.kind === 'executed') {
10476
- await switchroomReply(ctx, `✅ Switched <code>${escapeHtmlForTg(result.previousSlot)}</code> → <code>${escapeHtmlForTg(result.newSlot)}</code>.`, { html: true })
10477
- } else if (result.kind === 'exhausted-all') {
10478
- await switchroomReply(ctx, `🚨 All slots quota-exhausted. Tap ➕ Add slot.`, { html: true })
10479
- } else if (result.kind === 'error') {
10480
- await switchroomReply(ctx, `❌ Fallback error: ${escapeHtmlForTg(result.message)}`, { html: true })
10481
- } else {
10482
- await switchroomReply(ctx, `No action: ${escapeHtmlForTg(result.reason)}`, { html: true })
10483
- }
10484
- await sendAuthDashboard(ctx, action.agent, { edit: true })
10485
- return
10486
- }
10487
- case 'restart-flow': {
10488
- // Kill any pending session + restart the same flow (reauth or
10489
- // add-slot) fresh. Exists for the case where the user wants to
10490
- // start over BEFORE the automatic stale-session detection fires
10491
- // (e.g. closed the browser tab, 2FA failed, waited too long).
10492
- await ctx.answerCallbackQuery({ text: `Restarting ${action.slot} flow…` }).catch(() => {})
10493
- // Step 1: cancel any pending session for this agent.
10494
- try {
10495
- await runSwitchroomCommand(ctx, ['auth', 'cancel', action.agent], `auth cancel ${action.agent}`)
10496
- } catch { /* cancel is best-effort */ }
10497
- // Step 2: re-initiate. Slot == 'default' → reauth; else → add-slot.
10498
- // Both paths print the fresh URL + button + ForceReply prompt via
10499
- // runSwitchroomAuthCommand.
10500
- if (action.slot === 'default') {
10501
- await runSwitchroomAuthCommand(ctx, ['auth', 'reauth', action.agent], `auth reauth ${action.agent}`)
10502
- } else {
10503
- await runSwitchroomAuthCommand(ctx, ['auth', 'add', action.agent, '--slot', action.slot], `auth add ${action.agent} --slot ${action.slot}`)
10504
- }
10505
- pendingReauthFlows.set(String(ctx.chat!.id), { agent: action.agent, startedAt: Date.now() })
10506
- return
10507
- }
10508
- case 'usage': {
10509
- await ctx.answerCallbackQuery({ text: 'Fetching quota…' }).catch(() => {})
10510
- const agentDir = resolveAgentDirFromEnv()
10511
- if (!agentDir) {
10512
- await switchroomReply(ctx, 'Quota lookup unavailable: no agent directory.')
10513
- return
10514
- }
10515
- try {
10516
- const quota = await fetchQuota({ claudeConfigDir: join(agentDir, '.claude') })
10517
- if (!quota.ok) {
10518
- await switchroomReply(ctx, `<b>Quota:</b> ${escapeHtmlForTg(quota.reason)}`, { html: true })
10519
- } else {
10520
- await switchroomReply(ctx, formatQuotaBlock(quota.data), { html: true })
10521
- }
10522
- } catch (err) {
10523
- await switchroomReply(ctx, `Quota fetch failed: ${escapeHtmlForTg(String(err))}`, { html: true })
10524
- }
10525
- return
10526
- }
10527
- // Account-level toggles (#share-auth-across-the-fleet). Two-stage
10528
- // confirm pattern mirrors `rm`/`confirm-rm` so a stray tap doesn't
10529
- // re-shuffle credentials. The CLI verb is the one source of truth
10530
- // for the YAML mutation + fanout; we only translate the tap into
10531
- // a `runSwitchroomCommand` call and refresh the dashboard.
10532
- case 'account-enable': {
10533
- await ctx.answerCallbackQuery({ text: `Confirm enable ${action.label}?` }).catch(() => {})
10534
- try {
10535
- await ctx.editMessageReplyMarkup({
10536
- reply_markup: buildAccountConfirmKeyboard(action.agent, action.label, 'enable'),
10537
- })
10538
- } catch { /* ignore */ }
10539
- return
10540
- }
10541
- case 'account-disable': {
10542
- await ctx.answerCallbackQuery({ text: `Confirm disable ${action.label}?` }).catch(() => {})
10543
- try {
10544
- await ctx.editMessageReplyMarkup({
10545
- reply_markup: buildAccountConfirmKeyboard(action.agent, action.label, 'disable'),
10546
- })
10547
- } catch { /* ignore */ }
10548
- return
10549
- }
10550
- case 'confirm-account-enable': {
10551
- await ctx.answerCallbackQuery({ text: `Enabling ${action.label}…` }).catch(() => {})
10552
- try { assertSafeAgentName(action.agent) } catch { return }
10553
- // CLI does the YAML mutation + per-agent credential fanout. The
10554
- // restart afterwards is what actually loads the new credentials
10555
- // into the running claude process.
10556
- await runSwitchroomCommand(
10557
- ctx,
10558
- ['auth', 'enable', action.label, action.agent],
10559
- `auth enable ${action.label} ${action.agent}`,
10560
- )
10561
- await runSwitchroomCommand(ctx, ['agent', 'restart', action.agent], `restart ${action.agent}`)
10562
- // Account roster changed — drop cached quota so the next
10563
- // dashboard render kicks a fresh probe instead of showing
10564
- // stale numbers (or a zero row for a label that just got added).
10565
- clearAndRewarmAccountQuotas()
10566
- await sendAuthDashboard(ctx, action.agent, { edit: true })
10567
- return
10568
- }
10569
- case 'confirm-account-disable': {
10570
- await ctx.answerCallbackQuery({ text: `Disabling ${action.label}…` }).catch(() => {})
10571
- try { assertSafeAgentName(action.agent) } catch { return }
10572
- await runSwitchroomCommand(
10573
- ctx,
10574
- ['auth', 'disable', action.label, action.agent],
10575
- `auth disable ${action.label} ${action.agent}`,
10576
- )
10577
- // Force restart so claude drops the stale credentials immediately.
10578
- // The CLI's `disable` doesn't auto-restart (it expects the operator
10579
- // to drain manually); the dashboard tap is implicit "I'm done with
10580
- // this account on this agent now," so we restart on their behalf.
10581
- await runSwitchroomCommand(ctx, ['agent', 'restart', action.agent], `restart ${action.agent}`)
10582
- clearAndRewarmAccountQuotas()
10583
- await sendAuthDashboard(ctx, action.agent, { edit: true })
10584
- return
10585
- }
10586
- case 'account-promote': {
10587
- // Two-stage confirm — same UX as enable/disable, just a different
10588
- // verb on the confirm row's callback. The CLI verb does the
10589
- // YAML reorder + fanout. Reachable via the legacy v3b per-row
10590
- // `⤴ Promote` button (callback verb `apr`) — kept for any
10591
- // already-pinned messages that still have it.
10592
- await ctx.answerCallbackQuery({ text: `Confirm promote ${action.label}?` }).catch(() => {})
10593
- try {
10594
- await ctx.editMessageReplyMarkup({
10595
- reply_markup: buildAccountPromoteConfirmKeyboard(action.agent, action.label),
10596
- })
10597
- } catch { /* ignore */ }
10598
- return
10599
- }
10600
- case 'switch-primary-view': {
10601
- // v3c picker: open a sub-keyboard that lists every non-active
10602
- // account as a one-tap promote target. Direct
10603
- // `confirm-account-promote` callbacks (no second confirm) — the
10604
- // picker IS the confirmation surface.
10605
- await ctx.answerCallbackQuery().catch(() => {})
10606
- const state = fetchDashboardState(action.agent)
10607
- const candidates = (state?.accounts ?? [])
10608
- .filter((a) => a.activeForThisAgent !== true)
10609
- .map((a) => ({ label: a.label, health: a.health }))
10610
- if (candidates.length === 0) {
10611
- // No fallbacks to switch to — return to the main board with a
10612
- // toast explaining why.
10613
- await ctx.answerCallbackQuery({ text: 'No fallback accounts to switch to.' }).catch(() => {})
10614
- await sendAuthDashboard(ctx, action.agent, { edit: true })
10615
- return
10616
- }
10617
- try {
10618
- await ctx.editMessageReplyMarkup({
10619
- reply_markup: buildSwitchPrimaryKeyboard(action.agent, candidates),
10620
- })
10621
- } catch { /* ignore MESSAGE_NOT_MODIFIED */ }
10622
- return
10623
- }
10624
- case 'confirm-account-promote': {
10625
- await ctx.answerCallbackQuery({ text: `Promoting ${action.label}…` }).catch(() => {})
10626
- try { assertSafeAgentName(action.agent) } catch { return }
10627
- await runSwitchroomCommand(
10628
- ctx,
10629
- ['auth', 'promote', action.label, action.agent],
10630
- `auth promote ${action.label} ${action.agent}`,
10631
- )
10632
- // Promotion changes the active credential — must restart so
10633
- // claude reloads the new primary's tokens.
10634
- await runSwitchroomCommand(ctx, ['agent', 'restart', action.agent], `restart ${action.agent}`)
10635
- clearAndRewarmAccountQuotas()
10636
- await sendAuthDashboard(ctx, action.agent, { edit: true })
10637
- return
10638
- }
10639
- case 'share-fleet': {
10640
- // Bootstrap one-tap: zero accounts exist, this agent has healthy
10641
- // slot creds. Synthesise label="default" so the user gets a
10642
- // sensible starting state in one tap; rename via CLI later.
10643
- await ctx.answerCallbackQuery({ text: 'Sharing to fleet…' }).catch(() => {})
10644
- try { assertSafeAgentName(action.agent) } catch { return }
10645
- await runSwitchroomCommand(
10646
- ctx,
10647
- ['auth', 'share', 'default', '--from-agent', action.agent],
10648
- `auth share default --from-agent ${action.agent}`,
10649
- )
10650
- await runSwitchroomCommand(ctx, ['agent', 'restart', action.agent], `restart ${action.agent}`)
10651
- clearAndRewarmAccountQuotas()
10652
- await sendAuthDashboard(ctx, action.agent, { edit: true })
10653
- return
10654
- }
10655
- // v3a: per-account drill-down sub-view handlers.
10656
- case 'account-view': {
10657
- // Drill into the per-account sub-view. Fetch current account state
10658
- // so the sub-view reflects live health, then edit-in-place.
10659
- await ctx.answerCallbackQuery().catch(() => {})
10660
- const state = fetchDashboardState(action.agent)
10661
- const acc = state?.accounts?.find((a) => a.label === action.label)
10662
- if (!acc || !state) {
10663
- await ctx.answerCallbackQuery({ text: `Account "${action.label}" not found.` }).catch(() => {})
10664
- await sendAuthDashboard(ctx, action.agent, { edit: true })
10665
- return
10666
- }
10667
- const text = buildAccountSubViewText(action.agent, acc)
10668
- const keyboard = buildAccountSubViewKeyboard(action.agent, action.label)
10669
- try {
10670
- await ctx.editMessageText(text, { parse_mode: 'HTML', reply_markup: keyboard, link_preview_options: { is_disabled: true } })
10671
- } catch { /* ignore MESSAGE_NOT_MODIFIED */ }
10672
- return
10673
- }
10674
- case 'account-reauth': {
10675
- // Reauth by account is not wired to a CLI verb in v3a.
10676
- // Surface a toast so the button is visible-but-inert; the full
10677
- // flow lands in v3b when `auth account reauth <label>` exists.
10678
- await ctx.answerCallbackQuery({ text: 'Reauth not yet wired — coming in v3b' }).catch(() => {})
10679
- return
10680
- }
10681
- case 'account-rm': {
10682
- // Two-step confirm — swap sub-view keyboard for remove confirm.
10683
- await ctx.answerCallbackQuery({ text: `Remove ${action.label}?` }).catch(() => {})
10684
- try {
10685
- await ctx.editMessageReplyMarkup({
10686
- reply_markup: buildAccountRemoveConfirmKeyboard(action.agent, action.label),
10687
- })
10688
- } catch { /* ignore */ }
10689
- return
10690
- }
10691
- case 'account-rm-confirm': {
10692
- await ctx.answerCallbackQuery({ text: `Removing ${action.label}…` }).catch(() => {})
10693
- try { assertSafeAgentName(action.agent) } catch { return }
10694
- await runSwitchroomCommand(
10695
- ctx,
10696
- ['auth', 'account', 'rm', action.label],
10697
- `auth account rm ${action.label}`,
10698
- )
10699
- // Removed account label is gone — drop its cache entry (and any
10700
- // siblings, since `enabledHere` shifts when an agent's account
10701
- // list changes).
10702
- clearAndRewarmAccountQuotas()
10703
- await sendAuthDashboard(ctx, action.agent, { edit: true })
10704
- return
10705
- }
10706
- case 'noop':
10707
- default: {
10708
- await ctx.answerCallbackQuery().catch(() => {})
10709
- return
10710
- }
10711
- }
10246
+ try {
10247
+ await ctx.answerCallbackQuery({
10248
+ text: "This button is from the old /auth dashboard (removed in RFC H). Send /auth show instead.",
10249
+ show_alert: false,
10250
+ })
10251
+ } catch { /* tap from a too-old message — drop */ }
10712
10252
  }
10713
10253
 
10714
10254
  // /reauth was removed in v0.6.13 — the `/auth` dashboard's
@@ -12993,35 +12533,10 @@ void (async () => {
12993
12533
  )
12994
12534
  }
12995
12535
 
12996
- // v0.6.10 boot-warm: kick off a background per-account quota
12997
- // probe for every account in the new auth framework. Without
12998
- // this, the FIRST `/auth` after a restart shows no mini-bars
12999
- // because the in-process cache is cold — the dashboard's lazy
13000
- // prefetch fires the probe but the operator's already-rendered
13001
- // message has empty quota rows. Pre-warming fills the cache
13002
- // before the user can tap.
13003
- //
13004
- // Fire-and-forget per label. Failures (rate limit, network,
13005
- // expired token) leave the cache unset so the dashboard's lazy
13006
- // path retries on the next render — same safety-net contract
13007
- // as available_reactions above.
13008
- try {
13009
- const accountsAtBoot = switchroomExecJson<Array<{ label: string }>>([
13010
- 'auth', 'account', 'list', '--json',
13011
- ])
13012
- if (Array.isArray(accountsAtBoot) && accountsAtBoot.length > 0) {
13013
- for (const a of accountsAtBoot) {
13014
- if (typeof a?.label === 'string') prefetchAccountQuotaIfStale(a.label)
13015
- }
13016
- process.stderr.write(
13017
- `telegram gateway: boot-warmed quota cache for ${accountsAtBoot.length} account(s)\n`,
13018
- )
13019
- }
13020
- } catch (err) {
13021
- process.stderr.write(
13022
- `telegram gateway: boot-warm of account quota cache failed (continuing): ${(err as Error).message}\n`,
13023
- )
13024
- }
12536
+ // RFC H removes the per-account-quota-cache boot-warm: the
12537
+ // auth-broker owns quota state now; the gateway reads it via
12538
+ // `list-state` on demand and renders directly. No in-process
12539
+ // cache to warm.
13025
12540
 
13026
12541
  // #412 boot-cleanup: clear any pre-existing turn-active marker.
13027
12542
  // By definition no turn can be in flight when the gateway just