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.
- package/README.md +49 -57
- package/bin/timezone-hook.sh +9 -7
- package/dist/agent-scheduler/index.js +285 -45
- package/dist/auth-broker/index.js +13932 -0
- package/dist/cli/switchroom.js +15931 -12778
- package/dist/host-control/main.js +582 -43
- package/dist/vault/approvals/kernel-server.js +276 -47
- package/dist/vault/broker/server.js +333 -69
- package/examples/minimal.yaml +63 -0
- package/examples/personal-google-workspace-mcp/.env.example +34 -0
- package/examples/personal-google-workspace-mcp/README.md +194 -0
- package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
- package/examples/switchroom.yaml +220 -0
- package/package.json +6 -4
- package/profiles/_base/start.sh.hbs +3 -3
- package/profiles/_shared/agent-self-service.md.hbs +126 -0
- package/profiles/default/CLAUDE.md +10 -0
- package/profiles/default/CLAUDE.md.hbs +16 -0
- package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
- package/skills/buildkite-agent-runtime/SKILL.md +44 -11
- package/skills/buildkite-api/SKILL.md +31 -8
- package/skills/buildkite-cli/SKILL.md +27 -9
- package/skills/buildkite-migration/SKILL.md +22 -9
- package/skills/buildkite-pipelines/SKILL.md +26 -9
- package/skills/buildkite-secure-delivery/SKILL.md +23 -9
- package/skills/buildkite-test-engine/SKILL.md +25 -8
- package/skills/docx/SKILL.md +1 -1
- package/skills/file-bug/SKILL.md +34 -6
- package/skills/humanizer/SKILL.md +15 -0
- package/skills/humanizer-calibrate/SKILL.md +7 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/pdf/SKILL.md +1 -1
- package/skills/pptx/SKILL.md +1 -1
- package/skills/skill-creator/SKILL.md +21 -1
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/skills/switchroom-cli/SKILL.md +63 -64
- package/skills/switchroom-health/SKILL.md +23 -10
- package/skills/switchroom-install/SKILL.md +3 -3
- package/skills/switchroom-manage/SKILL.md +26 -19
- package/skills/switchroom-runtime/SKILL.md +67 -15
- package/skills/switchroom-status/SKILL.md +26 -1
- package/skills/telegram-test-harness/SKILL.md +3 -0
- package/skills/webapp-testing/SKILL.md +31 -1
- package/skills/xlsx/SKILL.md +1 -1
- package/telegram-plugin/admin-commands/index.ts +7 -5
- package/telegram-plugin/dist/gateway/gateway.js +13042 -12844
- package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
- package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
- package/telegram-plugin/gateway/auth-command.ts +794 -0
- package/telegram-plugin/gateway/auth-line.ts +123 -0
- package/telegram-plugin/gateway/boot-card.ts +22 -36
- package/telegram-plugin/gateway/boot-probes.ts +3 -3
- package/telegram-plugin/gateway/gateway.ts +313 -798
- package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
- package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
- package/telegram-plugin/permission-title.ts +56 -0
- package/telegram-plugin/quota-check.ts +19 -41
- package/telegram-plugin/scripts/build.mjs +0 -1
- package/telegram-plugin/shared/bot-runtime.ts +5 -4
- package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
- package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
- package/telegram-plugin/tests/boot-probes.test.ts +11 -4
- package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
- package/telegram-plugin/tests/permission-title.test.ts +31 -0
- package/telegram-plugin/tests/quota-check.test.ts +5 -35
- package/telegram-plugin/uat/SETUP.md +31 -1
- package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
- package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
- package/telegram-plugin/uat/runners/report.ts +150 -0
- package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
- package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
- package/telegram-plugin/uat/runners/scorer.ts +106 -0
- package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
- package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
- package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +7 -1
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
- package/telegram-plugin/auth-dashboard.ts +0 -1104
- package/telegram-plugin/auth-slot-parser.ts +0 -497
- package/telegram-plugin/dist/foreman/foreman.js +0 -31358
- package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
- package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
- package/telegram-plugin/foreman/foreman.ts +0 -1165
- package/telegram-plugin/foreman/setup-flow.ts +0 -345
- package/telegram-plugin/foreman/setup-state.ts +0 -239
- package/telegram-plugin/foreman/state.ts +0 -203
- package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
- package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
- package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
- package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
- package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
- package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
- package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
- package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
- package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
- package/telegram-plugin/tests/foreman-state.test.ts +0 -164
- package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
- package/telegram-plugin/tests/setup-flow.test.ts +0 -510
- 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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
} from '
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
377
|
-
*
|
|
378
|
-
*
|
|
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
|
|
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
|
-
|
|
7775
|
-
|
|
7776
|
-
|
|
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
|
-
|
|
7893
|
-
|
|
7894
|
-
|
|
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".
|
|
7951
|
-
// sees an opaque "❌ update failed (exit 127)" via
|
|
7952
|
-
// notifyDetachedFailure ~5s after the ack.
|
|
8067
|
+
// "docker: command not found".
|
|
7953
8068
|
//
|
|
7954
|
-
//
|
|
7955
|
-
//
|
|
7956
|
-
//
|
|
7957
|
-
|
|
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
|
|
7964
|
-
`host shell
|
|
7965
|
-
`<
|
|
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
|
-
|
|
8040
|
-
|
|
8041
|
-
|
|
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
|
-
|
|
8350
|
-
|
|
8351
|
-
|
|
8352
|
-
|
|
8353
|
-
|
|
8354
|
-
|
|
8355
|
-
|
|
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(
|
|
8524
|
+
bot.command("auth", async ctx => {
|
|
8377
8525
|
if (!isAuthorizedSender(ctx)) return
|
|
8378
|
-
const
|
|
8526
|
+
const text = ctx.message?.text ?? ""
|
|
8527
|
+
const parsed = parseAuthCommand(text)
|
|
8528
|
+
if (!parsed) return
|
|
8379
8529
|
const currentAgent = getMyAgentName()
|
|
8380
|
-
|
|
8381
|
-
|
|
8382
|
-
|
|
8383
|
-
|
|
8384
|
-
|
|
8385
|
-
|
|
8386
|
-
|
|
8387
|
-
|
|
8388
|
-
|
|
8389
|
-
|
|
8390
|
-
|
|
8391
|
-
|
|
8392
|
-
|
|
8393
|
-
|
|
8394
|
-
|
|
8395
|
-
|
|
8396
|
-
|
|
8397
|
-
|
|
8398
|
-
|
|
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
|
-
|
|
8436
|
-
`
|
|
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
|
-
|
|
8442
|
-
|
|
8443
|
-
|
|
8444
|
-
|
|
8445
|
-
|
|
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
|
-
|
|
8518
|
-
|
|
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
|
-
|
|
8615
|
-
|
|
8616
|
-
|
|
8617
|
-
|
|
8618
|
-
|
|
8619
|
-
|
|
8620
|
-
|
|
8621
|
-
|
|
8622
|
-
|
|
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
|
-
|
|
8635
|
-
|
|
8636
|
-
|
|
8637
|
-
|
|
8638
|
-
|
|
8639
|
-
|
|
8640
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8732
|
-
/* leave accounts undefined */
|
|
8604
|
+
return
|
|
8733
8605
|
}
|
|
8734
8606
|
|
|
8735
|
-
|
|
8736
|
-
|
|
8737
|
-
|
|
8738
|
-
|
|
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
|
-
|
|
8768
|
-
|
|
8769
|
-
|
|
8770
|
-
|
|
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
|
-
|
|
8775
|
-
|
|
8776
|
-
|
|
8777
|
-
|
|
8778
|
-
|
|
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
|
-
|
|
10410
|
-
|
|
10411
|
-
|
|
10412
|
-
|
|
10413
|
-
|
|
10414
|
-
|
|
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
|
-
//
|
|
12997
|
-
//
|
|
12998
|
-
//
|
|
12999
|
-
//
|
|
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
|