switchroom 0.19.1 → 0.19.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/agent-scheduler/index.js +31 -1
  2. package/dist/auth-broker/index.js +565 -48
  3. package/dist/cli/autoaccept-poll.js +31 -1
  4. package/dist/cli/drive-write-pretool.mjs +32 -2
  5. package/dist/cli/ms-365-write-pretool.mjs +32 -2
  6. package/dist/cli/switchroom.js +1148 -274
  7. package/dist/host-control/main.js +3 -3
  8. package/dist/vault/approvals/kernel-server.js +2 -2
  9. package/dist/vault/broker/server.js +2 -2
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +1 -0
  12. package/profiles/default/CLAUDE.md.hbs +8 -0
  13. package/skills/mental-model-curator/SKILL.md +68 -2
  14. package/skills/switchroom-cli/SKILL.md +25 -0
  15. package/telegram-plugin/auth-snapshot-format.ts +143 -12
  16. package/telegram-plugin/dist/bridge/bridge.js +8 -2
  17. package/telegram-plugin/dist/gateway/gateway.js +1427 -689
  18. package/telegram-plugin/dist/server.js +8 -2
  19. package/telegram-plugin/external-spend.ts +135 -0
  20. package/telegram-plugin/flushed-turn-supersede.ts +117 -13
  21. package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
  22. package/telegram-plugin/gateway/auth-command.ts +138 -5
  23. package/telegram-plugin/gateway/gateway.ts +141 -158
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
  25. package/telegram-plugin/gateway/model-command.ts +309 -1
  26. package/telegram-plugin/gateway/narrative-lane.ts +23 -9
  27. package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
  28. package/telegram-plugin/gateway/session-model-source.ts +90 -10
  29. package/telegram-plugin/gateway/status-pin-store.ts +64 -4
  30. package/telegram-plugin/gateway/stream-render.ts +22 -5
  31. package/telegram-plugin/gateway/usage-mask.ts +29 -0
  32. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
  33. package/telegram-plugin/quota-bar-format.ts +78 -12
  34. package/telegram-plugin/quota-check.ts +17 -2
  35. package/telegram-plugin/reply-owner-resolve.ts +76 -11
  36. package/telegram-plugin/session-tail.ts +27 -3
  37. package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
  38. package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
  39. package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
  40. package/telegram-plugin/tests/external-spend.test.ts +168 -0
  41. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
  42. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +219 -29
  43. package/telegram-plugin/tests/model-command.test.ts +220 -0
  44. package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
  45. package/telegram-plugin/tests/quota-check.test.ts +57 -0
  46. package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
  47. package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
  48. package/telegram-plugin/tests/session-model-source.test.ts +142 -0
  49. package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
  50. package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
  51. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
  52. package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
  53. package/telegram-plugin/tests/usage-mask.test.ts +35 -0
  54. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
  55. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +131 -1
  56. package/vendor/hindsight-memory/CHANGELOG.md +102 -0
  57. package/vendor/hindsight-memory/README.md +2 -1
  58. package/vendor/hindsight-memory/hooks/hooks.json +12 -0
  59. package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
  60. package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
  61. package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
  62. package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
  63. package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
  64. package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
  65. package/vendor/hindsight-memory/scripts/recall.py +789 -143
  66. package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
  67. package/vendor/hindsight-memory/scripts/retain.py +71 -2
  68. package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
  73. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
  75. package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
  77. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
  78. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
  79. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
  80. package/vendor/hindsight-memory/settings.json +3 -1
@@ -42,6 +42,7 @@ import {
42
42
  resolveSafeBoundaryEnabled,
43
43
  } from './interrupt-defer.js'
44
44
  import { parseStopKeyword, buildStopReply } from './stop-command.js'
45
+ import { shouldMaskUsageLabels } from './usage-mask.js'
45
46
  import { shouldPostBusyAck, formatBusyAckText, BUSY_ACK_STEP_AGE_THRESHOLD_MS } from './busy-ack.js'
46
47
  import {
47
48
  resolveStickerSendArgs,
@@ -365,10 +366,10 @@ import { createFleetFallbackResumeGate } from '../fleet-fallback-resume.js'
365
366
  import { resolveExhaustUntil } from './exhaust-until.js'
366
367
  import {
367
368
  pendingAuthAddFlows,
368
- startAccountAuthSession,
369
369
  submitAccountAuthCode,
370
370
  cancelAccountAuthSession,
371
371
  cleanScratchDir as cleanAuthAddScratchDir,
372
+ handleAuthAddOrCancel,
372
373
  } from './auth-add-flow.js'
373
374
  import {
374
375
  pendingLoopbackFlows,
@@ -482,6 +483,7 @@ import {
482
483
  } from '../turn-flush-safety.js'
483
484
  import {
484
485
  resolveReplyOwnerTurnId,
486
+ type AnswerDeliveredLatch,
485
487
  } from '../reply-owner-resolve.js'
486
488
  // PR A — deterministic answer-ready quiescence flush (late-delivery fix).
487
489
  import {
@@ -537,6 +539,11 @@ import {
537
539
  modelCommandReceiptLine,
538
540
  handleModelCommand,
539
541
  classifyModelSwitchConfirmation,
542
+ formatModelRelaunchDiagLog,
543
+ servedModelMatchesRequested,
544
+ buildServedModelDivergenceHandler,
545
+ deliverModelSwitchBootNotice,
546
+ type ModelBootCardDeps,
540
547
  buildModelMenu,
541
548
  handleModelMenuCallback,
542
549
  isValidModelArg,
@@ -651,6 +658,7 @@ import {
651
658
  mutateStatusPinRow,
652
659
  reconcileAndPersistStatusPin,
653
660
  runStatusPinBootCleanup,
661
+ withPinReconcileLock,
654
662
  type PersistedStatusPin,
655
663
  type StatusPinPersistOp,
656
664
  } from './status-pin-store.js'
@@ -877,7 +885,7 @@ import {
877
885
  decideAnnouncementDelivery,
878
886
  foldAnnouncementIntoCard,
879
887
  } from '../fallback-card-collapse.js'
880
- import { buildSnapshotsFromState, buildSnapshotsFromCachedState, zipProbeResults } from '../auth-snapshot-format.js'
888
+ import { buildSnapshotsFromState, buildSnapshotsFromCachedState, zipProbeResults, deriveUsageFooterFreshness } from '../auth-snapshot-format.js'
881
889
  import { maskUsername } from '../demo-mask.js'
882
890
  import {
883
891
  writeTurnActiveMarker,
@@ -3407,18 +3415,33 @@ export type CurrentTurn = {
3407
3415
  // false ONLY at turn start, mirroring `activityEverOpened`'s sticky-true
3408
3416
  // contract.
3409
3417
  finalAnswerEverDelivered: boolean
3410
- // 2026-07 double-reply-on-DM fix (Part 2 — race backstop). Set true
3411
- // SYNCHRONOUSLY at turn-flush FIRE time (before the ~500 ms async send and
3412
- // before `flushedTurnSupersede.record`) when the flush delivers a SUBSTANTIVE
3413
- // (≥`FLUSH_SUBSTANTIVE_MIN_CHARS`) terminal answer, and also set when a
3414
- // 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
3415
3423
  // `recentTurnsById`, so a LATE reply landing in the flush's post-fire
3416
3424
  // pre-record race window (where `flushedTurnSupersede` finds no record to
3417
3425
  // delete yet) resolves this turn via the unified owner resolver, sees the
3418
- // latch already set, and suppresses itself — closing the residual window Part
3419
- // 1's supersede cannot reach. Scoped to the substantive floor so an interim
3420
- // sub-floor ack NEITHER sets nor trips it. Reset false at turn start.
3421
- answerDelivered: boolean
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
3422
3445
  // 2026-07 double-reply-on-DM fix (F2 — recency bound). Wall-clock ms the turn
3423
3446
  // ENDED (stamped once by `endCurrentTurnAtomic`), or null while still live.
3424
3447
  // The `findLatestEndedTurnForChat` supersede tier carries DESTRUCTIVE
@@ -3668,8 +3691,9 @@ const currentTurnMap = new CurrentTurnMap<CurrentTurn>()
3668
3691
  // is seq-stamped and `resolve()` prefers the NEWER observation, so neither
3669
3692
  // source can go stale behind the other (session-model-source.ts, pinned by
3670
3693
  // tests/session-model-source.test.ts). buildAgentMetadata reads resolve();
3671
- // the /model command paths write via setOverride.
3672
- const sessionModelSource = createSessionModelSource()
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 })
3673
3697
  // Captures the most-recently-started turn's sessionChatId. Unlike currentTurn,
3674
3698
  // this is NOT cleared by the silence poke (firePoke/clearTurnStarted). It lets
3675
3699
  // the Bug B fallback in executeReply route to the correct chat even when the
@@ -8440,6 +8464,10 @@ const PIN_STATUS_WHILE_WORKING = (() => {
8440
8464
  // handlers unconditionally unpinning on every send) and runs NO polling
8441
8465
  // watchdog / getChat().pinned_message reconciler.
8442
8466
  const statusPinState = new Map<string, PinState>()
8467
+ // F2 serialization: same-pinKey reconciles run one-at-a-time via
8468
+ // `withPinReconcileLock` (status-pin-store.ts — kept there so it's
8469
+ // unit-testable) so each reads a fresh `prev`; see its doc for the stale-`prev`
8470
+ // race it closes. Adds zero Telegram API calls (a serialized noop still no-ops).
8443
8471
  // Companion registry: pinKey → chatId, so the pre-restart sweep can unpin
8444
8472
  // owned pins without threading the chat id through every call site. Written on
8445
8473
  // every desired-pinned reconcile, cleared alongside the state on unpin.
@@ -9023,7 +9051,10 @@ async function reconcileStatusPin(
9023
9051
  // absorbed. (The `pin_message` MCP tool still surfaces failures to the agent
9024
9052
  // as a normal tool-error — that path is `executePinMessage`, not this one.)
9025
9053
  try {
9026
- await reconcileStatusPinInner(pinKey, chatId, desired)
9054
+ // Serialize per pinKey (F2): reconcileStatusPinInner reads `prev` from the
9055
+ // in-memory claim map at its top, so overlapping same-key reconciles must
9056
+ // run one-at-a-time or a stale `prev` clears the disk row under a live pin.
9057
+ await withPinReconcileLock(pinKey, () => reconcileStatusPinInner(pinKey, chatId, desired))
9027
9058
  } catch (err) {
9028
9059
  const msg = err instanceof Error ? err.message : String(err)
9029
9060
  process.stderr.write(
@@ -9040,14 +9071,11 @@ async function reconcileStatusPinInner(
9040
9071
  ): Promise<void> {
9041
9072
  if (!PIN_STATUS_WHILE_WORKING) return
9042
9073
  if (chatId.length === 0) return
9043
- // NOTE (invisible-worker-cards review, intentionally left): this reconcile is
9044
- // NOT serialized per pinKey it snapshots `prev` then awaits. Two edits that
9045
- // fire `syncPin` in the same microtask window after a dropped claim can both
9046
- // read `prev=null` and both issue a `pinChatMessage` for the SAME id. That is
9047
- // benign and self-healing: re-pinning an already-pinned id is idempotent on
9048
- // Telegram, and the first reconcile to set the claim makes every subsequent
9049
- // edit a no-op — it converges in one round, never a storm. A per-key mutex
9050
- // would remove the duplicate pin but adds lock complexity for zero UX gain.
9074
+ // Serialized per pinKey by `withPinReconcileLock` at the caller (F2), so this
9075
+ // `prev` snapshot is taken only after any prior same-key reconcile fully
9076
+ // settled always the true current claim. Closes the stale-`prev` race (a
9077
+ // turn-end clear dropping the disk row under a flood-delayed open-pin) and the
9078
+ // older duplicate-pin concern (two edits both reading prev=null).
9051
9079
  const prev = statusPinState.get(pinKey) ?? null
9052
9080
 
9053
9081
  const runReconcile = () =>
@@ -14333,6 +14361,26 @@ function isAuthorizedSender(ctx: Context): boolean {
14333
14361
  return false
14334
14362
  }
14335
14363
 
14364
+ // Adversarial-review F4 — a group configured with an EMPTY `allowFrom`
14365
+ // authorizes every member (isAuthorizedSender returns true for any sender in
14366
+ // that group). That's an intentional "whole-group" access mode, but it means
14367
+ // `/usage` would expose per-account email labels + quota headroom to every
14368
+ // member of a broadly-shared group. Harden minimally: for the quota-bearing
14369
+ // card in a non-private chat, mask account labels (reusing the demo-mask
14370
+ // machinery) UNLESS the group pinned a non-empty `allowFrom` — i.e. an
14371
+ // explicit operator-curated member list is treated as trusted enough to see
14372
+ // the real labels. Private (operator DM) chats are never masked. This changes
14373
+ // only what /usage REVEALS, not who may run it. The pure decision lives in
14374
+ // ./usage-mask.ts (shouldMaskUsageLabels) so it is unit-testable without
14375
+ // importing the whole gateway module.
14376
+ function shouldMaskAccountLabels(ctx: Context): boolean {
14377
+ const groupAllowFrom =
14378
+ ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup'
14379
+ ? loadAccess().groups[String(ctx.chat.id)]?.allowFrom
14380
+ : undefined
14381
+ return shouldMaskUsageLabels(ctx.chat?.type, groupAllowFrom)
14382
+ }
14383
+
14336
14384
  // safeName moved to ./media-message-handlers.ts (switchroom#2996 P6 cluster A);
14337
14385
  // imported above and shared with the attachment handlers still inline here.
14338
14386
 
@@ -20830,68 +20878,17 @@ bot.command("auth", async ctx => {
20830
20878
  // handleAuthCommand which only needs the narrow broker surface.
20831
20879
  const chatId = String(ctx.chat?.id ?? '')
20832
20880
  if (parsed.kind === 'add' || parsed.kind === 'cancel') {
20833
- if (!isAuthAdmin({ isAdmin })) {
20834
- await switchroomReply(
20835
- ctx,
20836
- `**Not authorized.** \`/auth ${parsed.kind}\` is admin-only.\n` +
20837
- `Set \`admin: true\` on this agent in switchroom.yaml to unlock ` +
20838
- `(the same flag that gates \`/agents\`, \`/restart\`, ` +
20839
- `\`/update\` etc.).`,
20840
- { html: true },
20841
- )
20842
- return
20843
- }
20844
- // PR3 supergroup-mode: key auth-add flows by (chat, thread) so
20845
- // separate flows in two topics of one supergroup can't collide.
20846
- // In DM chats message_thread_id is undefined → key collapses to
20847
- // `chatId:_`, identical to today's behavior.
20848
- const authAddKey = chatKey(chatId, ctx.message?.message_thread_id ?? null) as string
20849
- if (parsed.kind === 'cancel') {
20850
- const existing = pendingAuthAddFlows.get(authAddKey)
20851
- if (!existing) {
20852
- await switchroomReply(ctx, "_No pending \`/auth add\` flow in this chat._", { html: true })
20853
- return
20854
- }
20855
- cancelAccountAuthSession(existing)
20856
- pendingAuthAddFlows.delete(authAddKey)
20857
- await switchroomReply(ctx, "Cancelled.", { html: true })
20858
- return
20859
- }
20860
- // parsed.kind === 'add'
20861
- if (pendingAuthAddFlows.has(authAddKey)) {
20862
- await switchroomReply(
20863
- ctx,
20864
- "_An \`/auth add\` flow is already in progress for this chat. " +
20865
- "Finish the paste, or send \`/auth cancel\` to abort._",
20866
- { html: true },
20867
- )
20868
- return
20869
- }
20870
- try {
20871
- const { loginUrl, scratchDir, tmuxSocket, tmuxSession } = await startAccountAuthSession(parsed.label)
20872
- pendingAuthAddFlows.set(authAddKey, {
20873
- label: parsed.label,
20874
- scratchDir,
20875
- tmuxSocket,
20876
- tmuxSession,
20877
- startedAt: Date.now(),
20878
- })
20879
- await switchroomReply(
20880
- ctx,
20881
- `**Adding account** \`${parsed.label}\`\n\n` +
20882
- `1. Open this URL on your phone:\n${loginUrl}\n\n` +
20883
- `2. Log into Anthropic, copy the code Claude shows.\n` +
20884
- `3. Paste it back here.\n\n` +
20885
- `Send \`/auth cancel\` to abort.`,
20886
- { html: true },
20887
- )
20888
- } catch (err) {
20889
- await switchroomReply(
20890
- ctx,
20891
- `**/auth add failed:** ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`,
20892
- { html: true },
20893
- )
20894
- }
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
+ })
20895
20892
  return
20896
20893
  }
20897
20894
 
@@ -21444,7 +21441,12 @@ bot.command('issues', async ctx => {
21444
21441
  })
21445
21442
  bot.command('usage', async ctx => {
21446
21443
  if (!isAuthorizedSender(ctx)) return
21447
- const demo = hasDemoFlag(getCommandArgs(ctx))
21444
+ // `demo` is the explicit `/usage demo` opt-in mask. F4 additionally masks
21445
+ // account labels for a non-private chat whose group has no pinned
21446
+ // `allowFrom` (open-membership group) — see shouldMaskAccountLabels. The
21447
+ // effective mask feeds the label-rendering paths (renderUsageCard,
21448
+ // buildSnapshotKeyboard) exactly as `demo` did.
21449
+ const demo = hasDemoFlag(getCommandArgs(ctx)) || shouldMaskAccountLabels(ctx)
21448
21450
  // Format 2 path: enumerate every account in the broker's known set,
21449
21451
  // probe live quota in parallel, render the health-grouped snapshot.
21450
21452
  // Falls back to the legacy single-agent shape when the broker is
@@ -21484,26 +21486,34 @@ bot.command('usage', async ctx => {
21484
21486
  // segment, just relative instead of absolute — no info lost; the
21485
21487
  // recommendation + cached/live footer the table used to carry are
21486
21488
  // preserved by renderUsageCard.
21489
+ // External OpenRouter/$ block (layout B) — best-effort; omitted when
21490
+ // LiteLLM admin key is unavailable or the spend endpoint fails.
21491
+ const { fetchExternalSpendSummary } = await import('../external-spend.js')
21492
+ const externalSpend = await fetchExternalSpendSummary(renderNow).catch(() => null)
21487
21493
  const exhaustedByLabel = new Map<string, boolean>(
21488
21494
  state.accounts.map((a) => [a.label, a.exhausted]),
21489
21495
  )
21490
21496
  const text = renderUsageCard(snapshots, exhaustedByLabel, {
21491
21497
  now: renderNow,
21492
21498
  demo,
21499
+ externalSpend,
21493
21500
  // #2495 Change 2 — a TTL-hit / failed-probe fallback is tagged
21494
21501
  // served:"cache"; surface it as `⚠ cached Nm ago` instead of a
21495
21502
  // false live stamp. Otherwise stamp the live refresh time.
21496
21503
  // Honesty backstop: a TOTAL probe failure (the .catch above
21497
21504
  // returned `{results: []}` and nothing was served from cache)
21498
21505
  // must render an explicit "probe failed" marker, NOT a false
21499
- // "Live" stamp next to "⚠️ no data" rows. Without this the
21500
- // footer claimed "Live · refreshed 0s ago" while every account
21501
- // row said "no data probe failed" (#2959 review finding).
21502
- ...(staleCachedAtMs != null
21503
- ? { staleCachedAtMs }
21504
- : probeResp.results.length > 0
21505
- ? { liveProbedAtMs: renderNow.getTime() }
21506
- : { probeFailed: true }),
21506
+ // "Live" stamp next to "⚠️ no data" rows (#2959 review finding).
21507
+ // Honesty invariant (adversarial-review F1): the live stamp is
21508
+ // derived from whether ANY row carries usable data, NOT from the
21509
+ // array length — a failed live probe against an empty cache returns
21510
+ // a non-empty results array of all-`ok:false` rows. Full rationale
21511
+ // in deriveUsageFooterFreshness (auth-snapshot-format.ts).
21512
+ ...deriveUsageFooterFreshness(
21513
+ probeResp.results,
21514
+ staleCachedAtMs,
21515
+ renderNow.getTime(),
21516
+ ),
21507
21517
  })
21508
21518
  // Preserve the Switch/Refresh/usage/Add inline keyboard on the
21509
21519
  // rich-message render — the table card carries the same actions the
@@ -23867,75 +23877,48 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23867
23877
  // real post-boot signal (`.active-session-model`), never from a
23868
23878
  // scraped pane or an optimistic record.
23869
23879
  const isApplyBoot = launched.length > 0 && launched !== configured
23870
- sessionModelSource.setOverride(isApplyBoot ? launched : null)
23871
- // Diagnosability (rev 5): the applied model is now always
23872
- // greppable `grep 'gw /model relaunch applied'`. F4 note:
23873
- // `launched` is the REQUESTED token start.sh wrote before `exec
23874
- // claude` (it is NOT a post-launch confirmation). If a shape-valid
23875
- // but unknown Claude id was requested, `--fallback-model` may mask
23876
- // it: claude serves a fallback while this records the requested
23877
- // token. That divergence is NOT a persistent lie — the transcript's
23878
- // `message.model` (noteTranscriptModel) reclaims the source from
23879
- // this override on the first assistant line, correcting /status to
23880
- // the model actually serving calls. The pre-first-assistant window
23881
- // is the only optimistic window (G2), and it is bounded and
23882
- // self-healing; it is documented, not silently asserted as success.
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
+ }
23897
+ // F1/N4: classify + log + one confirmation card. Formatters live
23898
+ // in model-command.ts so this file does not inflate (#2996 ratchet).
23899
+ const confirmation = modelSwitchReason != null
23900
+ ? classifyModelSwitchConfirmation({
23901
+ reason: modelSwitchReason,
23902
+ launched,
23903
+ configured,
23904
+ })
23905
+ : null
23883
23906
  process.stderr.write(
23884
- `telegram gateway: gw /model relaunch applied agent=${getMyAgentName()} launched=${launched || '(none)'} configured=${configured} override=${isApplyBoot ? 'set' : 'cleared'}\n`,
23885
- )
23886
- // Switch-confirmation (F1 / PLAN §4 step 2): on a /model apply-boot
23887
- // with a known initiating chat, send ONE confirmation built from the
23888
- // ACTUAL launched model — never optimistic. Keyed on the DETERMINISTIC
23889
- // /model switch reason (from the clean-shutdown marker), not on
23890
- // `launched !== configured`, so it ALSO fires when a switch landed on
23891
- // the configured default (`/model default`, or `/model <configured>`)
23892
- // — N4. The generic boot card is suppressed for this boot (N3), so
23893
- // this is the single card the operator sees for the switch.
23894
- if (modelSwitchReason != null && modelSwitchMarkerChat) {
23895
- const chat = modelSwitchMarkerChat
23896
- // Derive the confirmation from the DETERMINISTIC post-boot
23897
- // signals. A non-default switch that reverted to the configured
23898
- // default (a wedged/consumed apply-boot — the silent-revert bug)
23899
- // must WARN, not print a misleading green "✅ Now running
23900
- // <default>" card. `applied` / `default` keep the honest green
23901
- // card (N4: the default/revert case still confirms).
23902
- const confirmation = classifyModelSwitchConfirmation({
23903
- reason: modelSwitchReason,
23907
+ formatModelRelaunchDiagLog({
23908
+ agent: getMyAgentName(),
23904
23909
  launched,
23905
23910
  configured,
23911
+ confirmation,
23912
+ isApplyBoot,
23913
+ }),
23914
+ )
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')),
23906
23921
  })
23907
- // LOW-2 dedup: the config-default-changed / proxy-down revert
23908
- // paths in start.sh write a TAILORED `.session-model-alert`
23909
- // (relayed to operators below) that already explains why the
23910
- // switch didn't apply and how to re-issue it. Suppress the
23911
- // generic not-applied card when such an alert is present for
23912
- // this boot so the operator isn't double-warned — the alert is
23913
- // the more specific message. The not-applied card still fires
23914
- // for the plain wedge/revert case (no alert on disk).
23915
- const hasSessionModelAlert = existsSync(join(smAgentDir, '.session-model-alert'))
23916
- if (confirmation.kind === 'not-applied' && hasSessionModelAlert) {
23917
- process.stderr.write(
23918
- `telegram gateway: gw /model relaunch applied — suppressing not-applied confirmation (a .session-model-alert is present and will be relayed) agent=${getMyAgentName()} target=${confirmation.target}\n`,
23919
- )
23920
- } else {
23921
- const body =
23922
- confirmation.kind === 'applied'
23923
- ? `✅ Now running \`${confirmation.launched}\` — session-only, reverts to the configured model on the next restart. Fresh session; memory and the handoff briefing carry the context.`
23924
- : confirmation.kind === 'not-applied'
23925
- ? `⚠️ Your switch to \`${confirmation.target}\` didn't apply — the agent reverted to \`${confirmation.revertedTo}\` (the apply-boot didn't complete). Re-issue \`/model ${confirmation.target}\` to try again.`
23926
- : `✅ Now running \`${confirmation.launched}\` (the configured default) — fresh session; memory and the handoff briefing carry the context.`
23927
- // allow-raw-bot-api: one-shot boot confirmation, same shape as the session-model alert relay below
23928
- void lockedBot.api
23929
- .sendMessage(chat.chatId, body, {
23930
- parse_mode: 'Markdown',
23931
- ...(chat.threadId != null ? { message_thread_id: chat.threadId } : {}),
23932
- })
23933
- .catch((err: unknown) =>
23934
- process.stderr.write(
23935
- `telegram gateway: model-switch confirmation send failed: ${(err as Error)?.message ?? String(err)}\n`,
23936
- ),
23937
- )
23938
- }
23939
23922
  }
23940
23923
  } catch { /* leave override as-is on a bad read */ }
23941
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: false })
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}\` added.\n` +
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) {