switchroom 0.19.2 → 0.19.4
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/dist/agent-scheduler/index.js +2 -0
- package/dist/auth-broker/index.js +109 -7
- package/dist/cli/autoaccept-poll.js +2 -0
- package/dist/cli/drive-write-pretool.mjs +2 -0
- package/dist/cli/ms-365-write-pretool.mjs +2 -0
- package/dist/cli/switchroom.js +404 -245
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/telegram-plugin/auth-snapshot-format.ts +104 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1194 -794
- package/telegram-plugin/dist/server.js +8 -2
- package/telegram-plugin/flushed-turn-supersede.ts +117 -13
- package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
- package/telegram-plugin/gateway/auth-command.ts +138 -5
- package/telegram-plugin/gateway/gateway.ts +68 -101
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +203 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/quota-bar-format.ts +60 -12
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
- package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
- package/telegram-plugin/tests/session-model-source.test.ts +142 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
- package/vendor/hindsight-memory/CHANGELOG.md +102 -0
- package/vendor/hindsight-memory/README.md +2 -1
- package/vendor/hindsight-memory/hooks/hooks.json +12 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
- package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
- package/vendor/hindsight-memory/scripts/recall.py +789 -143
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
- package/vendor/hindsight-memory/scripts/retain.py +71 -2
- package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
- package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
- package/vendor/hindsight-memory/settings.json +3 -1
|
@@ -44,7 +44,19 @@ export type ParsedAuthCommand =
|
|
|
44
44
|
| { kind: 'list' }
|
|
45
45
|
| { kind: 'use'; label: string }
|
|
46
46
|
| { kind: 'rotate' }
|
|
47
|
-
| {
|
|
47
|
+
| {
|
|
48
|
+
kind: 'add'
|
|
49
|
+
label: string
|
|
50
|
+
/**
|
|
51
|
+
* Re-auth an EXISTING account label in place (broker `addAccount(...,
|
|
52
|
+
* replace=true)`). Set by the `/auth readd <label>` verb (and
|
|
53
|
+
* `/auth add <label> --replace`). Plain `/auth add` leaves it false —
|
|
54
|
+
* the broker rejects a duplicate label, which is the desired guard for
|
|
55
|
+
* a fresh add. `/auth readd` is the path to widen scope on the 3 pooled
|
|
56
|
+
* accounts without renaming them.
|
|
57
|
+
*/
|
|
58
|
+
replace: boolean
|
|
59
|
+
}
|
|
48
60
|
| { kind: 'cancel' }
|
|
49
61
|
| {
|
|
50
62
|
kind: 'provider-add'
|
|
@@ -149,12 +161,25 @@ export function parseAuthCommand(text: string): ParsedAuthCommand | null {
|
|
|
149
161
|
if (!label) return { kind: 'help', reason: 'Usage: /auth use <label>' }
|
|
150
162
|
return { kind: 'use', label }
|
|
151
163
|
}
|
|
152
|
-
case 'add':
|
|
153
|
-
|
|
154
|
-
|
|
164
|
+
case 'add':
|
|
165
|
+
case 'readd': {
|
|
166
|
+
// `readd` is `add` with replace=true; `add --replace` is the same.
|
|
167
|
+
const positional = parts.slice(1).filter((t) => !t.startsWith('--'))
|
|
168
|
+
const flags = new Set(
|
|
169
|
+
parts.slice(1).filter((t) => t.startsWith('--')).map((t) => t.toLowerCase()),
|
|
170
|
+
)
|
|
171
|
+
const label = positional[0]
|
|
172
|
+
const usage = verb === 'readd' ? 'Usage: /auth readd <label>' : 'Usage: /auth add <label>'
|
|
173
|
+
if (!label) return { kind: 'help', reason: usage }
|
|
155
174
|
const err = validateAuthAddLabel(label)
|
|
156
175
|
if (err) return { kind: 'help', reason: err }
|
|
157
|
-
|
|
176
|
+
for (const f of flags) {
|
|
177
|
+
if (f !== '--replace') {
|
|
178
|
+
return { kind: 'help', reason: `Unknown flag \`${codeSpanSafe(f)}\` for \`/auth ${verb}\`.` }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const replace = verb === 'readd' || flags.has('--replace')
|
|
182
|
+
return { kind: 'add', label, replace }
|
|
158
183
|
}
|
|
159
184
|
case 'cancel':
|
|
160
185
|
return { kind: 'cancel' }
|
|
@@ -451,6 +476,7 @@ export async function handleAuthCommand(
|
|
|
451
476
|
` \`/auth use <label>\` — admin: swap the fleet to <label>\n` +
|
|
452
477
|
` \`/auth rotate\` — admin: cycle to next non-exhausted fallback\n` +
|
|
453
478
|
` \`/auth add <label>\` — admin: OAuth-add a new Anthropic account from chat\n` +
|
|
479
|
+
` \`/auth readd <label>\` — admin: re-auth an EXISTING account in place (widen scope)\n` +
|
|
454
480
|
` \`/auth google add <email>\` — admin: Telegram-native Google account add/re-auth\n` +
|
|
455
481
|
` \`/auth microsoft add <email>\` — admin: Telegram-native Microsoft account add/re-auth\n` +
|
|
456
482
|
` \`/auth cancel\` — abort an \`/auth add\` or provider add in progress\n` +
|
|
@@ -819,6 +845,113 @@ function isAdmin(ctx: AuthCommandContext): boolean {
|
|
|
819
845
|
return ctx.isAdmin === true
|
|
820
846
|
}
|
|
821
847
|
|
|
848
|
+
/**
|
|
849
|
+
* The scope every `server:` mode agent needs from a pooled account for
|
|
850
|
+
* fable/Claude 7-day usage reporting. Absence is the whole reason
|
|
851
|
+
* `/auth readd` exists (setup-token tokens can't carry it).
|
|
852
|
+
*/
|
|
853
|
+
export const REQUIRED_USAGE_SCOPE = 'user:profile'
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Precheck for the gateway `/auth add`/`readd` dispatch. Returns a
|
|
857
|
+
* user-facing error string when the request is incoherent against current
|
|
858
|
+
* broker state, else null.
|
|
859
|
+
*
|
|
860
|
+
* - `readd` (replace=true) of a label that does NOT exist → error (there's
|
|
861
|
+
* nothing to re-auth; the operator wants `/auth add`).
|
|
862
|
+
* - plain `add` (replace=false) of a label that ALREADY exists → error
|
|
863
|
+
* (the broker would reject the duplicate anyway; fail fast with a clear
|
|
864
|
+
* message pointing at `/auth readd`).
|
|
865
|
+
*
|
|
866
|
+
* Kept pure so it's unit-testable without a live broker.
|
|
867
|
+
*/
|
|
868
|
+
export function readdPrecheckError(
|
|
869
|
+
label: string,
|
|
870
|
+
replace: boolean,
|
|
871
|
+
labelExists: boolean,
|
|
872
|
+
): string | null {
|
|
873
|
+
if (replace && !labelExists) {
|
|
874
|
+
return (
|
|
875
|
+
`**/auth readd:** no account named \`${codeSpanSafe(label)}\` to re-auth. ` +
|
|
876
|
+
`Run \`/auth show\` for the current list, or \`/auth add ${codeSpanSafe(label)}\` to add it fresh.`
|
|
877
|
+
)
|
|
878
|
+
}
|
|
879
|
+
if (!replace && labelExists) {
|
|
880
|
+
return (
|
|
881
|
+
`**/auth add:** \`${codeSpanSafe(label)}\` already exists. ` +
|
|
882
|
+
`Use \`/auth readd ${codeSpanSafe(label)}\` to re-authenticate it in place (e.g. to widen scope).`
|
|
883
|
+
)
|
|
884
|
+
}
|
|
885
|
+
return null
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Broker-backed precheck for the `/auth add` / `/auth readd` chat verb.
|
|
890
|
+
* Resolves a broker client (via the caller-supplied getter), queries live
|
|
891
|
+
* account state, and returns the {@link readdPrecheckError} message (or null
|
|
892
|
+
* when the request is valid) so the gateway can fail fast before spinning up
|
|
893
|
+
* a tmux/OAuth flow.
|
|
894
|
+
*
|
|
895
|
+
* Best-effort by design: if the broker is unreachable (getter returns null or
|
|
896
|
+
* `listState` throws) this returns null and lets `addAccountViaBroker` enforce
|
|
897
|
+
* existence at add time. Kept here — beside `readdPrecheckError` — so the
|
|
898
|
+
* gateway carries only a thin call site (switchroom#2996 line-ratchet: no new
|
|
899
|
+
* inline bodies in gateway.ts).
|
|
900
|
+
*/
|
|
901
|
+
export async function runReaddPrecheck(
|
|
902
|
+
getClient: () => Promise<AuthBrokerClient | null>,
|
|
903
|
+
label: string,
|
|
904
|
+
replace: boolean,
|
|
905
|
+
): Promise<string | null> {
|
|
906
|
+
try {
|
|
907
|
+
const client = await getClient()
|
|
908
|
+
if (!client) return null
|
|
909
|
+
const state = await client.listState()
|
|
910
|
+
const exists = state.accounts.some((a) => a.label === label)
|
|
911
|
+
return readdPrecheckError(label, replace, exists)
|
|
912
|
+
} catch {
|
|
913
|
+
// broker unreachable — skip precheck; addAccountViaBroker enforces.
|
|
914
|
+
return null
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Render the "granted scopes" tail for a successful `/auth add`/`readd`.
|
|
920
|
+
* Reads the scopes STRUCTURALLY from `credentials.claudeAiOauth.scopes`
|
|
921
|
+
* (never scraped from the pane). Returns the text plus a `warn` flag set
|
|
922
|
+
* when {@link REQUIRED_USAGE_SCOPE} is absent, so the caller can surface a
|
|
923
|
+
* loud warning at add time instead of a silent `/usage` failure later.
|
|
924
|
+
*/
|
|
925
|
+
export function formatGrantedScopesReply(scopes: string[] | undefined): {
|
|
926
|
+
text: string
|
|
927
|
+
hasUsageScope: boolean
|
|
928
|
+
} {
|
|
929
|
+
const list = Array.isArray(scopes) ? scopes.filter((s) => typeof s === 'string') : []
|
|
930
|
+
const hasUsageScope = list.includes(REQUIRED_USAGE_SCOPE)
|
|
931
|
+
if (list.length === 0) {
|
|
932
|
+
return {
|
|
933
|
+
text:
|
|
934
|
+
`\n⚠️ **No scopes reported** on the new token — could not confirm \`${REQUIRED_USAGE_SCOPE}\`. ` +
|
|
935
|
+
`7-day usage reporting may not work. Re-run \`/auth readd\` if usage is missing.`,
|
|
936
|
+
hasUsageScope: false,
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
const rendered = list.map((s) => `\`${codeSpanSafe(s)}\``).join(', ')
|
|
940
|
+
if (hasUsageScope) {
|
|
941
|
+
return {
|
|
942
|
+
text: `\nGranted scopes: ${rendered}\n✓ \`${REQUIRED_USAGE_SCOPE}\` present — 7-day usage reporting is unlocked.`,
|
|
943
|
+
hasUsageScope: true,
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
return {
|
|
947
|
+
text:
|
|
948
|
+
`\nGranted scopes: ${rendered}\n` +
|
|
949
|
+
`⚠️ **\`${REQUIRED_USAGE_SCOPE}\` is MISSING** — 7-day usage reporting will not work for this account. ` +
|
|
950
|
+
`This usually means the narrow \`setup-token\` minter was used. Re-run \`/auth readd <label>\` with the broad login to fix.`,
|
|
951
|
+
hasUsageScope: false,
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
822
955
|
/**
|
|
823
956
|
* Choose the next account `auth rotate` should set active. Walks
|
|
824
957
|
* `fallback_order` starting *after* the currently-active label,
|
|
@@ -366,10 +366,10 @@ import { createFleetFallbackResumeGate } from '../fleet-fallback-resume.js'
|
|
|
366
366
|
import { resolveExhaustUntil } from './exhaust-until.js'
|
|
367
367
|
import {
|
|
368
368
|
pendingAuthAddFlows,
|
|
369
|
-
startAccountAuthSession,
|
|
370
369
|
submitAccountAuthCode,
|
|
371
370
|
cancelAccountAuthSession,
|
|
372
371
|
cleanScratchDir as cleanAuthAddScratchDir,
|
|
372
|
+
handleAuthAddOrCancel,
|
|
373
373
|
} from './auth-add-flow.js'
|
|
374
374
|
import {
|
|
375
375
|
pendingLoopbackFlows,
|
|
@@ -483,6 +483,7 @@ import {
|
|
|
483
483
|
} from '../turn-flush-safety.js'
|
|
484
484
|
import {
|
|
485
485
|
resolveReplyOwnerTurnId,
|
|
486
|
+
type AnswerDeliveredLatch,
|
|
486
487
|
} from '../reply-owner-resolve.js'
|
|
487
488
|
// PR A — deterministic answer-ready quiescence flush (late-delivery fix).
|
|
488
489
|
import {
|
|
@@ -539,8 +540,10 @@ import {
|
|
|
539
540
|
handleModelCommand,
|
|
540
541
|
classifyModelSwitchConfirmation,
|
|
541
542
|
formatModelRelaunchDiagLog,
|
|
542
|
-
|
|
543
|
-
|
|
543
|
+
servedModelMatchesRequested,
|
|
544
|
+
buildServedModelDivergenceHandler,
|
|
545
|
+
deliverModelSwitchBootNotice,
|
|
546
|
+
type ModelBootCardDeps,
|
|
544
547
|
buildModelMenu,
|
|
545
548
|
handleModelMenuCallback,
|
|
546
549
|
isValidModelArg,
|
|
@@ -3412,18 +3415,33 @@ export type CurrentTurn = {
|
|
|
3412
3415
|
// false ONLY at turn start, mirroring `activityEverOpened`'s sticky-true
|
|
3413
3416
|
// contract.
|
|
3414
3417
|
finalAnswerEverDelivered: boolean
|
|
3415
|
-
// 2026-07 double-reply-on-DM fix (Part 2 — race backstop)
|
|
3416
|
-
// SYNCHRONOUSLY at turn-flush FIRE time (before
|
|
3417
|
-
// before `flushedTurnSupersede.record`)
|
|
3418
|
-
//
|
|
3419
|
-
// substantive `reply` sends. It persists on the ended turn in
|
|
3418
|
+
// 2026-07 double-reply-on-DM fix (Part 2 — race backstop), SOURCE-TAGGED
|
|
3419
|
+
// since #3426. Set to 'flush' SYNCHRONOUSLY at turn-flush FIRE time (before
|
|
3420
|
+
// the ~500 ms async send and before `flushedTurnSupersede.record`) and at
|
|
3421
|
+
// supersede-record consumption (the resurrection window); set to 'reply'
|
|
3422
|
+
// when a substantive `reply` sends. It persists on the ended turn in
|
|
3420
3423
|
// `recentTurnsById`, so a LATE reply landing in the flush's post-fire
|
|
3421
3424
|
// pre-record race window (where `flushedTurnSupersede` finds no record to
|
|
3422
3425
|
// delete yet) resolves this turn via the unified owner resolver, sees the
|
|
3423
|
-
// latch already set, and suppresses itself — closing the residual
|
|
3424
|
-
// 1's supersede cannot reach.
|
|
3425
|
-
//
|
|
3426
|
-
|
|
3426
|
+
// 'flush' latch already set, and suppresses itself — closing the residual
|
|
3427
|
+
// window Part 1's supersede cannot reach. The 'reply' tag deliberately does
|
|
3428
|
+
// NOT suppress a late reply (#3426): a substantive interim ack followed by
|
|
3429
|
+
// an async sub-agent handback (which lands with NO live gateway turn and
|
|
3430
|
+
// resolves this ended turn as owner via the latest-ended tier) must deliver,
|
|
3431
|
+
// not silently drop. Scoped to the substantive floor so an interim sub-floor
|
|
3432
|
+
// ack NEITHER sets nor trips it. Reset false at turn start.
|
|
3433
|
+
answerDelivered: AnswerDeliveredLatch
|
|
3434
|
+
// #3429 — the text the turn-flush backstop delivered (or is mid-delivering)
|
|
3435
|
+
// as this turn's answer. Stamped SYNCHRONOUSLY alongside the 'flush' latch
|
|
3436
|
+
// arm (stream-render fire site; outbound-send-path supersede-consumption
|
|
3437
|
+
// resurrection site) and cleared wherever that latch is reset. Lets the
|
|
3438
|
+
// late-reply suppression discriminate BY CONTENT between the flushed answer
|
|
3439
|
+
// landing again (suppress — the flush race) and a genuinely new async
|
|
3440
|
+
// handback attributed to this flush-delivered ended turn (deliver fresh —
|
|
3441
|
+
// suppressing/editing it is the #3429 silent client-side drop), including in
|
|
3442
|
+
// the post-fire pre-record window where no supersede record exists yet.
|
|
3443
|
+
// Null when no flush armed this turn. Reset null at turn start.
|
|
3444
|
+
flushedAnswerText: string | null
|
|
3427
3445
|
// 2026-07 double-reply-on-DM fix (F2 — recency bound). Wall-clock ms the turn
|
|
3428
3446
|
// ENDED (stamped once by `endCurrentTurnAtomic`), or null while still live.
|
|
3429
3447
|
// The `findLatestEndedTurnForChat` supersede tier carries DESTRUCTIVE
|
|
@@ -3673,8 +3691,9 @@ const currentTurnMap = new CurrentTurnMap<CurrentTurn>()
|
|
|
3673
3691
|
// is seq-stamped and `resolve()` prefers the NEWER observation, so neither
|
|
3674
3692
|
// source can go stale behind the other (session-model-source.ts, pinned by
|
|
3675
3693
|
// tests/session-model-source.test.ts). buildAgentMetadata reads resolve();
|
|
3676
|
-
// the /model command paths write via setOverride.
|
|
3677
|
-
|
|
3694
|
+
// the /model command paths write via setOverride. The comparator arms the
|
|
3695
|
+
// #3427 requested-vs-served tripwire (handler registered at boot rehydration).
|
|
3696
|
+
const sessionModelSource = createSessionModelSource({ servedMatchesRequested: servedModelMatchesRequested })
|
|
3678
3697
|
// Captures the most-recently-started turn's sessionChatId. Unlike currentTurn,
|
|
3679
3698
|
// this is NOT cleared by the silence poke (firePoke/clearTurnStarted). It lets
|
|
3680
3699
|
// the Bug B fallback in executeReply route to the correct chat even when the
|
|
@@ -20859,68 +20878,17 @@ bot.command("auth", async ctx => {
|
|
|
20859
20878
|
// handleAuthCommand which only needs the narrow broker surface.
|
|
20860
20879
|
const chatId = String(ctx.chat?.id ?? '')
|
|
20861
20880
|
if (parsed.kind === 'add' || parsed.kind === 'cancel') {
|
|
20862
|
-
|
|
20863
|
-
|
|
20864
|
-
|
|
20865
|
-
|
|
20866
|
-
|
|
20867
|
-
|
|
20868
|
-
|
|
20869
|
-
|
|
20870
|
-
)
|
|
20871
|
-
|
|
20872
|
-
}
|
|
20873
|
-
// PR3 supergroup-mode: key auth-add flows by (chat, thread) so
|
|
20874
|
-
// separate flows in two topics of one supergroup can't collide.
|
|
20875
|
-
// In DM chats message_thread_id is undefined → key collapses to
|
|
20876
|
-
// `chatId:_`, identical to today's behavior.
|
|
20877
|
-
const authAddKey = chatKey(chatId, ctx.message?.message_thread_id ?? null) as string
|
|
20878
|
-
if (parsed.kind === 'cancel') {
|
|
20879
|
-
const existing = pendingAuthAddFlows.get(authAddKey)
|
|
20880
|
-
if (!existing) {
|
|
20881
|
-
await switchroomReply(ctx, "_No pending \`/auth add\` flow in this chat._", { html: true })
|
|
20882
|
-
return
|
|
20883
|
-
}
|
|
20884
|
-
cancelAccountAuthSession(existing)
|
|
20885
|
-
pendingAuthAddFlows.delete(authAddKey)
|
|
20886
|
-
await switchroomReply(ctx, "Cancelled.", { html: true })
|
|
20887
|
-
return
|
|
20888
|
-
}
|
|
20889
|
-
// parsed.kind === 'add'
|
|
20890
|
-
if (pendingAuthAddFlows.has(authAddKey)) {
|
|
20891
|
-
await switchroomReply(
|
|
20892
|
-
ctx,
|
|
20893
|
-
"_An \`/auth add\` flow is already in progress for this chat. " +
|
|
20894
|
-
"Finish the paste, or send \`/auth cancel\` to abort._",
|
|
20895
|
-
{ html: true },
|
|
20896
|
-
)
|
|
20897
|
-
return
|
|
20898
|
-
}
|
|
20899
|
-
try {
|
|
20900
|
-
const { loginUrl, scratchDir, tmuxSocket, tmuxSession } = await startAccountAuthSession(parsed.label)
|
|
20901
|
-
pendingAuthAddFlows.set(authAddKey, {
|
|
20902
|
-
label: parsed.label,
|
|
20903
|
-
scratchDir,
|
|
20904
|
-
tmuxSocket,
|
|
20905
|
-
tmuxSession,
|
|
20906
|
-
startedAt: Date.now(),
|
|
20907
|
-
})
|
|
20908
|
-
await switchroomReply(
|
|
20909
|
-
ctx,
|
|
20910
|
-
`**Adding account** \`${parsed.label}\`\n\n` +
|
|
20911
|
-
`1. Open this URL on your phone:\n${loginUrl}\n\n` +
|
|
20912
|
-
`2. Log into Anthropic, copy the code Claude shows.\n` +
|
|
20913
|
-
`3. Paste it back here.\n\n` +
|
|
20914
|
-
`Send \`/auth cancel\` to abort.`,
|
|
20915
|
-
{ html: true },
|
|
20916
|
-
)
|
|
20917
|
-
} catch (err) {
|
|
20918
|
-
await switchroomReply(
|
|
20919
|
-
ctx,
|
|
20920
|
-
`**/auth add failed:** ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`,
|
|
20921
|
-
{ html: true },
|
|
20922
|
-
)
|
|
20923
|
-
}
|
|
20881
|
+
// Gateway-routed `/auth add|readd|cancel` — extracted to
|
|
20882
|
+
// handleAuthAddOrCancel in auth-add-flow.ts (switchroom#2996 ratchet).
|
|
20883
|
+
await handleAuthAddOrCancel({
|
|
20884
|
+
parsed,
|
|
20885
|
+
isAdmin,
|
|
20886
|
+
currentAgent,
|
|
20887
|
+
chatId,
|
|
20888
|
+
threadId: ctx.message?.message_thread_id ?? null,
|
|
20889
|
+
reply: (text: string) => switchroomReply(ctx, text, { html: true }),
|
|
20890
|
+
escapeHtml: escapeHtmlForTg,
|
|
20891
|
+
})
|
|
20924
20892
|
return
|
|
20925
20893
|
}
|
|
20926
20894
|
|
|
@@ -23909,7 +23877,23 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
23909
23877
|
// real post-boot signal (`.active-session-model`), never from a
|
|
23910
23878
|
// scraped pane or an optimistic record.
|
|
23911
23879
|
const isApplyBoot = launched.length > 0 && launched !== configured
|
|
23912
|
-
|
|
23880
|
+
// { verify: true } (#3427 item 4 / H1): ONLY this site arms the
|
|
23881
|
+
// requested-vs-served tripwire — `launched` IS the token of the
|
|
23882
|
+
// session now serving. Command-time setOverride never arms.
|
|
23883
|
+
sessionModelSource.setOverride(isApplyBoot ? launched : null, { verify: true })
|
|
23884
|
+
// Boot /model cards (#3427): the divergence tripwire warn and
|
|
23885
|
+
// the switch confirmation share one deps surface; the card
|
|
23886
|
+
// logic lives in model-command.ts (#2996 ratchet).
|
|
23887
|
+
const modelBootCardDeps: ModelBootCardDeps = {
|
|
23888
|
+
agent: getMyAgentName(),
|
|
23889
|
+
chat: modelSwitchMarkerChat,
|
|
23890
|
+
log: (line) => process.stderr.write(line),
|
|
23891
|
+
// allow-raw-bot-api: one-shot boot /model cards (divergence warn + switch confirmation), same shape as the session-model alert relay below
|
|
23892
|
+
sendCard: (chatId, body, opts) => lockedBot.api.sendMessage(chatId, body, opts),
|
|
23893
|
+
}
|
|
23894
|
+
if (isApplyBoot) {
|
|
23895
|
+
sessionModelSource.setDivergenceHandler(buildServedModelDivergenceHandler(modelBootCardDeps))
|
|
23896
|
+
}
|
|
23913
23897
|
// F1/N4: classify + log + one confirmation card. Formatters live
|
|
23914
23898
|
// in model-command.ts so this file does not inflate (#2996 ratchet).
|
|
23915
23899
|
const confirmation = modelSwitchReason != null
|
|
@@ -23928,30 +23912,13 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
|
|
|
23928
23912
|
isApplyBoot,
|
|
23929
23913
|
}),
|
|
23930
23914
|
)
|
|
23931
|
-
if (confirmation != null
|
|
23932
|
-
|
|
23933
|
-
|
|
23934
|
-
|
|
23935
|
-
|
|
23936
|
-
|
|
23937
|
-
|
|
23938
|
-
target: confirmation.target,
|
|
23939
|
-
}),
|
|
23940
|
-
)
|
|
23941
|
-
} else {
|
|
23942
|
-
const body = formatModelSwitchConfirmationBody(confirmation)
|
|
23943
|
-
// allow-raw-bot-api: one-shot boot confirmation, same shape as the session-model alert relay below
|
|
23944
|
-
void lockedBot.api
|
|
23945
|
-
.sendMessage(chat.chatId, body, {
|
|
23946
|
-
parse_mode: 'Markdown',
|
|
23947
|
-
...(chat.threadId != null ? { message_thread_id: chat.threadId } : {}),
|
|
23948
|
-
})
|
|
23949
|
-
.catch((err: unknown) =>
|
|
23950
|
-
process.stderr.write(
|
|
23951
|
-
`telegram gateway: model-switch confirmation send failed: ${(err as Error)?.message ?? String(err)}\n`,
|
|
23952
|
-
),
|
|
23953
|
-
)
|
|
23954
|
-
}
|
|
23915
|
+
if (confirmation != null) {
|
|
23916
|
+
// #3427 item 2: card-vs-suppress decision is pure + behaviorally tested.
|
|
23917
|
+
deliverModelSwitchBootNotice({
|
|
23918
|
+
...modelBootCardDeps,
|
|
23919
|
+
confirmation,
|
|
23920
|
+
hasSessionModelAlert: existsSync(join(smAgentDir, '.session-model-alert')),
|
|
23921
|
+
})
|
|
23955
23922
|
}
|
|
23956
23923
|
} catch { /* leave override as-is on a bad read */ }
|
|
23957
23924
|
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import type { Context } from 'grammy'
|
|
19
19
|
import type { ReactionTypeEmoji } from 'grammy/types'
|
|
20
20
|
import { parseStopKeyword, buildStopReply } from './stop-command.js'
|
|
21
|
+
import { formatGrantedScopesReply } from './auth-command.js'
|
|
21
22
|
import { decideInterruptTiming, resolveSafeBoundaryEnabled } from './interrupt-defer.js'
|
|
22
23
|
import { naturalAction } from '../permission-title.js'
|
|
23
24
|
import { richMessage } from '../rich-send.js'
|
|
@@ -463,15 +464,24 @@ export async function interceptAuthAdd(
|
|
|
463
464
|
deps.pendingAuthAddFlows.delete(p.interceptKey)
|
|
464
465
|
try {
|
|
465
466
|
const credentials = await deps.submitAccountAuthCode(pendingAdd, p.text.trim())
|
|
467
|
+
const replace = pendingAdd.replace === true
|
|
466
468
|
try {
|
|
467
|
-
await deps.addAccountViaBroker(pendingAdd.label, credentials, { replace
|
|
469
|
+
await deps.addAccountViaBroker(pendingAdd.label, credentials, { replace })
|
|
468
470
|
// success — wipe scratch dir now that the broker owns the creds
|
|
469
471
|
deps.cleanAuthAddScratchDir(pendingAdd.scratchDir)
|
|
472
|
+
// Read the minted scopes STRUCTURALLY (never scraped) and surface
|
|
473
|
+
// them so a scope regression (missing user:profile) is caught at add
|
|
474
|
+
// time, not at /usage time.
|
|
475
|
+
const scopeReply = formatGrantedScopesReply(
|
|
476
|
+
(credentials as { claudeAiOauth?: { scopes?: string[] } }).claudeAiOauth?.scopes,
|
|
477
|
+
)
|
|
478
|
+
const verb = replace ? 're-authenticated' : 'added'
|
|
470
479
|
await deps.switchroomReply(
|
|
471
480
|
p.ctx,
|
|
472
|
-
`✓ Account \`${pendingAdd.label}\`
|
|
481
|
+
`✓ Account \`${pendingAdd.label}\` ${verb}.\n` +
|
|
473
482
|
`The fleet's active account hasn't changed. Send ` +
|
|
474
|
-
`\`/auth use ${deps.escapeHtmlForTg(pendingAdd.label)}\` to switch to it
|
|
483
|
+
`\`/auth use ${deps.escapeHtmlForTg(pendingAdd.label)}\` to switch to it.` +
|
|
484
|
+
scopeReply.text,
|
|
475
485
|
{ html: true },
|
|
476
486
|
)
|
|
477
487
|
} catch (brokerErr) {
|
|
@@ -293,6 +293,187 @@ export function formatModelRelaunchSuppressNotAppliedLog(input: {
|
|
|
293
293
|
)
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
/**
|
|
297
|
+
* The single boot-time notification decision for a classified /model switch
|
|
298
|
+
* (#3427 item 2): either the ONE confirmation card (green applied/default or
|
|
299
|
+
* the ⚠️ not-applied warn), or — LOW-2 dedup — a suppress log when start.sh
|
|
300
|
+
* wrote a TAILORED `.session-model-alert` for this boot that already explains
|
|
301
|
+
* why the switch didn't apply (the alert relay is the more specific message,
|
|
302
|
+
* so the generic not-applied card would double-warn). Pure so the decision is
|
|
303
|
+
* unit-testable end-to-end (classify → notice) without booting the gateway;
|
|
304
|
+
* gateway.ts only sends `card` bodies / writes `suppress` logs.
|
|
305
|
+
*/
|
|
306
|
+
export type ModelSwitchBootNotice =
|
|
307
|
+
| { kind: 'card'; body: string }
|
|
308
|
+
| { kind: 'suppress'; log: string }
|
|
309
|
+
|
|
310
|
+
export function resolveModelSwitchBootNotice(input: {
|
|
311
|
+
agent: string
|
|
312
|
+
confirmation: ModelSwitchConfirmation
|
|
313
|
+
hasSessionModelAlert: boolean
|
|
314
|
+
}): ModelSwitchBootNotice {
|
|
315
|
+
const { agent, confirmation, hasSessionModelAlert } = input
|
|
316
|
+
if (confirmation.kind === 'not-applied' && hasSessionModelAlert) {
|
|
317
|
+
return {
|
|
318
|
+
kind: 'suppress',
|
|
319
|
+
log: formatModelRelaunchSuppressNotAppliedLog({ agent, target: confirmation.target }),
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return { kind: 'card', body: formatModelSwitchConfirmationBody(confirmation) }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Requested-vs-served divergence gate (#3427 item 4). `--fallback-model` masks
|
|
327
|
+
* a shape-valid but UNKNOWN requested Claude id: claude silently serves the
|
|
328
|
+
* fallback while `.active-session-model` (and the boot confirmation) carry the
|
|
329
|
+
* requested token, until the first assistant transcript line reclaims /status.
|
|
330
|
+
* That window used to self-heal SILENTLY — the operator was never told their
|
|
331
|
+
* requested id was bogus. This comparator lets the session-model source flag
|
|
332
|
+
* the divergence deterministically at the FIRST possible post-launch signal
|
|
333
|
+
* (the first assistant line's `message.model`).
|
|
334
|
+
*
|
|
335
|
+
* Deliberately conservative — return true ("matches") whenever the pair is not
|
|
336
|
+
* DETERMINISTICALLY comparable, so a false accusation is impossible:
|
|
337
|
+
* - served non-`claude-*` ids (sr-* / LiteLLM-mapped names) are skipped: the
|
|
338
|
+
* proxy may echo an alias, a mapped id, or the raw route name;
|
|
339
|
+
* - a requested alias (opus/sonnet/haiku/fable) family-matches its resolved
|
|
340
|
+
* full id (`sonnet` ≡ `claude-sonnet-5-…`) via modelFamilyToken;
|
|
341
|
+
* - a requested full `claude-*` id must be served exactly, or as a
|
|
342
|
+
* date-stamped descendant (`claude-sonnet-5` ≡ `claude-sonnet-5-20260203`);
|
|
343
|
+
* - anything else (sr-* requests, legacy friendly labels like "Opus 4.8")
|
|
344
|
+
* is not comparable → true.
|
|
345
|
+
*/
|
|
346
|
+
export function servedModelMatchesRequested(requested: string, served: string): boolean {
|
|
347
|
+
const req = requested.trim().toLowerCase()
|
|
348
|
+
const srv = served.trim().toLowerCase()
|
|
349
|
+
if (!srv.startsWith('claude-')) return true
|
|
350
|
+
if ((MODEL_ALIASES as readonly string[]).includes(req)) {
|
|
351
|
+
if (req === 'default') return true
|
|
352
|
+
if (modelFamilyToken(srv) === req) return true
|
|
353
|
+
// L3 (#3437 review): legacy id shapes put the family AFTER the version
|
|
354
|
+
// (`claude-3-opus-20240229` → first segment "3", not "opus"). Accept any
|
|
355
|
+
// dash-segment equal to the alias — widens toward "match" only, so it can
|
|
356
|
+
// suppress a real accusation in weird shapes but never create a false one.
|
|
357
|
+
return srv.slice('claude-'.length).split('-').includes(req)
|
|
358
|
+
}
|
|
359
|
+
if (req.startsWith('claude-')) {
|
|
360
|
+
return srv === req || srv.startsWith(req + '-')
|
|
361
|
+
}
|
|
362
|
+
return true
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Greppable stderr line for a requested-vs-served divergence (#3427 item 4).
|
|
367
|
+
* Names BOTH candidate causes (M2): `--fallback-model` substitutes for an
|
|
368
|
+
* invalid/unknown id AND for a transiently-unavailable one — the signal alone
|
|
369
|
+
* cannot distinguish them, so the log must not assert "invalid".
|
|
370
|
+
*/
|
|
371
|
+
export function formatServedModelDivergenceLog(input: {
|
|
372
|
+
agent: string
|
|
373
|
+
requested: string
|
|
374
|
+
served: string
|
|
375
|
+
}): string {
|
|
376
|
+
return (
|
|
377
|
+
'telegram gateway: gw /model served-model DIVERGENCE agent=' +
|
|
378
|
+
input.agent +
|
|
379
|
+
' requested=' +
|
|
380
|
+
input.requested +
|
|
381
|
+
' served=' +
|
|
382
|
+
input.served +
|
|
383
|
+
' (--fallback-model substituted: requested id invalid/unknown OR model transiently unavailable)\n'
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Operator card for a requested-vs-served divergence (#3427 item 4, M2-softened). */
|
|
388
|
+
export function formatServedModelDivergenceCard(input: {
|
|
389
|
+
requested: string
|
|
390
|
+
served: string
|
|
391
|
+
}): string {
|
|
392
|
+
return (
|
|
393
|
+
'⚠️ The first reply was served by `' +
|
|
394
|
+
input.served +
|
|
395
|
+
'`, not the requested `' +
|
|
396
|
+
input.requested +
|
|
397
|
+
'` — claude substituted the fallback model. Either the requested id is invalid/unknown, or the model was temporarily unavailable for that call (a transient substitution self-corrects on later replies). If it persists, re-issue `/model <valid id>` or `/model default`. `/status` always shows the model actually serving calls.'
|
|
398
|
+
)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** The boot marker chat that initiated a /model switch (thread-aware). */
|
|
402
|
+
export interface ModelBootCardTarget {
|
|
403
|
+
chatId: string
|
|
404
|
+
threadId: number | null
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Injected side-effect surface for the boot-time /model cards (#3427): the
|
|
409
|
+
* served-model divergence warn and the switch confirmation share ONE raw
|
|
410
|
+
* Markdown send closure plus a stderr log sink. Lives here (not inline in
|
|
411
|
+
* gateway.ts's boot IIFE) so gateway.ts does not inflate (#2996 ratchet).
|
|
412
|
+
*/
|
|
413
|
+
export interface ModelBootCardDeps {
|
|
414
|
+
agent: string
|
|
415
|
+
/** null → no initiating chat known; cards are skipped, logs still write. */
|
|
416
|
+
chat: ModelBootCardTarget | null
|
|
417
|
+
log: (line: string) => void
|
|
418
|
+
sendCard: (
|
|
419
|
+
chatId: string,
|
|
420
|
+
body: string,
|
|
421
|
+
opts: { parse_mode: 'Markdown'; message_thread_id?: number },
|
|
422
|
+
) => Promise<unknown>
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** One-shot fire-and-forget card to the marker chat; send failures log, never throw. */
|
|
426
|
+
function sendModelBootCard(deps: ModelBootCardDeps, chat: ModelBootCardTarget, body: string, failLabel: string): void {
|
|
427
|
+
void deps
|
|
428
|
+
.sendCard(chat.chatId, body, {
|
|
429
|
+
parse_mode: 'Markdown',
|
|
430
|
+
...(chat.threadId != null ? { message_thread_id: chat.threadId } : {}),
|
|
431
|
+
})
|
|
432
|
+
.catch((err: unknown) =>
|
|
433
|
+
deps.log(`telegram gateway: ${failLabel} send failed: ${(err as Error)?.message ?? String(err)}\n`),
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Build the divergence-tripwire handler gateway.ts registers at the boot
|
|
439
|
+
* rehydration site (#3427 item 4): the first LIVE assistant line serving a
|
|
440
|
+
* different model (invalid id OR transient unavailability — --fallback-model
|
|
441
|
+
* substituted) logs + warns the operator. The override is KEPT (M2): freshness
|
|
442
|
+
* rules already make /status show the served model, and a transient
|
|
443
|
+
* substitution self-corrects without destroying the switch record.
|
|
444
|
+
*/
|
|
445
|
+
export function buildServedModelDivergenceHandler(
|
|
446
|
+
deps: ModelBootCardDeps,
|
|
447
|
+
): (d: { requested: string; served: string }) => void {
|
|
448
|
+
return (d) => {
|
|
449
|
+
deps.log(formatServedModelDivergenceLog({ agent: deps.agent, requested: d.requested, served: d.served }))
|
|
450
|
+
if (deps.chat == null) return
|
|
451
|
+
sendModelBootCard(deps, deps.chat, formatServedModelDivergenceCard(d), 'served-model divergence')
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Deliver the boot-time model-switch confirmation (#3427 item 2): resolve the
|
|
457
|
+
* card-vs-suppress decision (pure — resolveModelSwitchBootNotice) and either
|
|
458
|
+
* log the suppress line or send the card. No-op when no initiating chat is
|
|
459
|
+
* known — matching the pre-extraction inline gateway.ts behavior (the
|
|
460
|
+
* suppress log only ever wrote when a marker chat existed).
|
|
461
|
+
*/
|
|
462
|
+
export function deliverModelSwitchBootNotice(
|
|
463
|
+
deps: ModelBootCardDeps & { confirmation: ModelSwitchConfirmation; hasSessionModelAlert: boolean },
|
|
464
|
+
): void {
|
|
465
|
+
if (deps.chat == null) return
|
|
466
|
+
const notice = resolveModelSwitchBootNotice({
|
|
467
|
+
agent: deps.agent,
|
|
468
|
+
confirmation: deps.confirmation,
|
|
469
|
+
hasSessionModelAlert: deps.hasSessionModelAlert,
|
|
470
|
+
})
|
|
471
|
+
if (notice.kind === 'suppress') {
|
|
472
|
+
deps.log(notice.log)
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
sendModelBootCard(deps, deps.chat, notice.body, 'model-switch confirmation')
|
|
476
|
+
}
|
|
296
477
|
|
|
297
478
|
export type ParsedModelCommand =
|
|
298
479
|
| { kind: 'show' }
|
|
@@ -634,6 +815,23 @@ function relaunchErrorReply(
|
|
|
634
815
|
return { text: `❌ Could not schedule model switch: ${deps.escapeHtml(msg)}`, html: true }
|
|
635
816
|
}
|
|
636
817
|
|
|
818
|
+
/**
|
|
819
|
+
* Fail-fast caveat (#3427 item 4) for a free-text full `claude-*` id: the
|
|
820
|
+
* gateway cannot pre-validate an arbitrary id against the API (Claude-native
|
|
821
|
+
* constraint — no raw API probes), so if the id is bogus `--fallback-model`
|
|
822
|
+
* silently serves the fallback. Say so IMMEDIATELY in the switch ack, and
|
|
823
|
+
* point at the first-reply tripwire that will catch it. Aliases and sr-* ids
|
|
824
|
+
* are vouched by their own gates (MODEL_ALIASES / the LiteLLM route probe),
|
|
825
|
+
* so only typed `claude-*` ids carry the caveat. Exported for tests.
|
|
826
|
+
*/
|
|
827
|
+
export function unvalidatedIdCaveat(
|
|
828
|
+
deps: Pick<ModelCommandDeps, 'escapeHtml'>,
|
|
829
|
+
model: string,
|
|
830
|
+
): string | null {
|
|
831
|
+
if (!model.trim().toLowerCase().startsWith('claude-')) return null
|
|
832
|
+
return `_\`${deps.escapeHtml(model)}\` can't be validated before launch — if it isn't a real Claude model id, claude will silently serve the configured fallback model instead. I check the first reply and will warn if that happens._`
|
|
833
|
+
}
|
|
834
|
+
|
|
637
835
|
/** Schedule a carrier relaunch onto `model`, returning the deterministic ack. */
|
|
638
836
|
async function scheduleRelaunchReply(
|
|
639
837
|
deps: ModelCommandDeps,
|
|
@@ -645,7 +843,11 @@ async function scheduleRelaunchReply(
|
|
|
645
843
|
} catch (err) {
|
|
646
844
|
return relaunchErrorReply(deps, model, err)
|
|
647
845
|
}
|
|
648
|
-
|
|
846
|
+
const caveat = unvalidatedIdCaveat(deps, model)
|
|
847
|
+
return {
|
|
848
|
+
text: [switchingLine(deps, model), ...(caveat ? [caveat] : []), PERSIST_NOTE].join('\n'),
|
|
849
|
+
html: true,
|
|
850
|
+
}
|
|
649
851
|
}
|
|
650
852
|
|
|
651
853
|
/** Schedule the `/model default` clear + revert relaunch, returning its ack. */
|