switchroom 0.19.34 β†’ 0.19.36

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 (35) hide show
  1. package/dist/auth-broker/index.js +7 -1
  2. package/dist/cli/skill-validate-pretool.mjs +15 -2
  3. package/dist/cli/switchroom.js +1005 -482
  4. package/dist/host-control/main.js +156 -4
  5. package/dist/vault/approvals/kernel-server.js +7 -1
  6. package/dist/vault/broker/server.js +7 -1
  7. package/package.json +4 -2
  8. package/profiles/_base/start.sh.hbs +37 -12
  9. package/telegram-plugin/dist/gateway/gateway.js +476 -116
  10. package/telegram-plugin/format.ts +70 -15
  11. package/telegram-plugin/gateway/gateway.ts +27 -31
  12. package/telegram-plugin/gateway/ipc-server.ts +18 -15
  13. package/telegram-plugin/gateway/model-command.ts +279 -23
  14. package/telegram-plugin/gateway/outbound-send-path.ts +12 -1
  15. package/telegram-plugin/operator-events.ts +40 -16
  16. package/telegram-plugin/secret-detect/db-uri.ts +90 -0
  17. package/telegram-plugin/secret-detect/index.ts +24 -1
  18. package/telegram-plugin/secret-detect/inert-values.ts +147 -0
  19. package/telegram-plugin/secret-detect/kv-scanner.ts +108 -0
  20. package/telegram-plugin/secret-detect/patterns.ts +24 -4
  21. package/telegram-plugin/tests/format-consistency.test.ts +93 -0
  22. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +40 -2
  23. package/telegram-plugin/tests/ipc-server-validate-operator.test.ts +20 -11
  24. package/telegram-plugin/tests/model-command.test.ts +415 -4
  25. package/telegram-plugin/tests/outbound-send-path.test.ts +1 -1
  26. package/telegram-plugin/tests/secret-detect-cross-engine.test.ts +263 -0
  27. package/telegram-plugin/tests/secret-detect-write-path.test.ts +403 -0
  28. package/telegram-plugin/tests/turn-flush-safety.test.ts +2 -2
  29. package/vendor/hindsight-memory/scripts/lib/client.py +58 -0
  30. package/vendor/hindsight-memory/scripts/lib/secret_patterns.json +431 -0
  31. package/vendor/hindsight-memory/scripts/lib/secret_redact.py +563 -0
  32. package/vendor/hindsight-memory/scripts/lib/secret_redaction_vectors.json +397 -0
  33. package/vendor/hindsight-memory/scripts/subagent_retain.py +103 -7
  34. package/vendor/hindsight-memory/scripts/tests/test_secret_redact.py +522 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +377 -0
@@ -33,6 +33,7 @@ import {
33
33
  type DiscoverResult,
34
34
  type ModelPickerOption,
35
35
  } from '../../src/agents/model-picker.js'
36
+ import { assessThinkingEffortRisk } from '../../src/config/thinking-effort-risk.js'
36
37
 
37
38
  /**
38
39
  * Aliases the claude CLI resolves natively (`claude --help`: "an alias for
@@ -50,6 +51,34 @@ import {
50
51
  */
51
52
  export const MODEL_ALIASES = ['opus', 'sonnet', 'haiku', 'fable', 'default'] as const
52
53
 
54
+ /**
55
+ * Short SWITCHROOM-side spellings for pinned **Anthropic** model ids. These are
56
+ * expanded to the full `claude-*` id on the `/model` path BEFORE any
57
+ * Claude-vs-external classification runs, so what reaches the `.session-model`
58
+ * carrier (and therefore `claude --model`) is always the canonical id β€” the CLI
59
+ * never sees the short spelling.
60
+ *
61
+ * Deliberately NOT `SR_MODEL_ALIASES`: that map means "external / OpenRouter /
62
+ * the Anthropic OAuth header is NOT forwarded". These targets are Anthropic
63
+ * OAuth-passthrough models, so putting them there would flip the
64
+ * header-forwarding + external-billing classification and surface them under the
65
+ * "🌐 External models" keyboard page with an `srFriendlyLabel`.
66
+ *
67
+ * Also NOT `MODEL_ALIASES`: that list is the aliases the claude CLI resolves
68
+ * *itself*, and its members double as FAMILY tokens in `modelFamilyToken` /
69
+ * `canonicalClaudeToken` / `servedModelMatchesRequested`. A pinned-id shortcut
70
+ * is neither.
71
+ *
72
+ * NB the repo prefers family aliases over pinned ids (an alias tracks the
73
+ * current flagship, a pinned id goes stale) β€” these exist because an operator
74
+ * sometimes wants a specific pinned Opus, and typing `claude-opus-4-8` on a
75
+ * phone is hostile. Keys are matched case-insensitively.
76
+ */
77
+ export const CLAUDE_MODEL_ALIASES: Record<string, string> = {
78
+ opus48: 'claude-opus-4-8',
79
+ 'opus-4-8': 'claude-opus-4-8',
80
+ }
81
+
53
82
  /**
54
83
  * Shape gate for the model argument. This string is typed literally
55
84
  * into the agent's tmux pane, so the gate is strict by construction:
@@ -67,6 +96,40 @@ export function isValidModelArg(arg: string): boolean {
67
96
  return MODEL_ARG_RE.test(arg)
68
97
  }
69
98
 
99
+ export const DEFAULT_SESSION_MODEL_RESOLUTION_TIMEOUT_MS = 180_000
100
+ export const SESSION_MODEL_RESOLUTION_POLL_MS = 250
101
+
102
+ /** Parse the bounded boot-resolution wait without allowing NaN/negative hangs. */
103
+ export function resolveSessionModelResolutionTimeoutMs(raw: string | undefined): number {
104
+ if (raw == null || raw.trim() === '') return DEFAULT_SESSION_MODEL_RESOLUTION_TIMEOUT_MS
105
+ const value = Number(raw)
106
+ return Number.isFinite(value) && value >= 0
107
+ ? value
108
+ : DEFAULT_SESSION_MODEL_RESOLUTION_TIMEOUT_MS
109
+ }
110
+
111
+ /**
112
+ * Asynchronously wait until start.sh atomically publishes this boot's resolved
113
+ * session-model outputs. The deadline is checked on every iteration, including
114
+ * timeout=0, so a missing barrier can never hang gateway startup indefinitely.
115
+ */
116
+ export async function waitForSessionModelResolution(input: {
117
+ barrierExists: () => boolean
118
+ timeoutMs: number
119
+ pollMs?: number
120
+ sleep?: (ms: number) => Promise<void>
121
+ }): Promise<boolean> {
122
+ const pollMs = input.pollMs ?? SESSION_MODEL_RESOLUTION_POLL_MS
123
+ const sleep = input.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
124
+ const startedAt = Date.now()
125
+ for (;;) {
126
+ if (input.barrierExists()) return true
127
+ const remaining = input.timeoutMs - (Date.now() - startedAt)
128
+ if (remaining <= 0) return false
129
+ await sleep(Math.min(pollMs, remaining))
130
+ }
131
+ }
132
+
70
133
  /** True when `name` is an sr-* (LiteLLM/OpenRouter) model identifier. */
71
134
  export function isSrModel(name: string): boolean {
72
135
  return name.startsWith('sr-')
@@ -76,11 +139,15 @@ export function isSrModel(name: string): boolean {
76
139
  * True when `name` is a Claude model β€” either a well-known alias or a
77
140
  * full `claude-*` id (including `[1m]` variants). This is intentionally
78
141
  * broader than MODEL_ALIASES: any `claude-…` string from claude's own
79
- * picker (e.g. `claude-opus-4-8`) qualifies.
142
+ * picker (e.g. `claude-opus-4-8`) qualifies, as does a switchroom-side short
143
+ * spelling of a pinned Claude id (CLAUDE_MODEL_ALIASES, e.g. `opus48`) β€” those
144
+ * name an Anthropic OAuth-passthrough model, so a classification consumer must
145
+ * never mistake one for an external/OpenRouter route.
80
146
  */
81
147
  export function isClaudeModel(name: string): boolean {
82
148
  const lower = name.toLowerCase()
83
149
  if ((MODEL_ALIASES as readonly string[]).includes(lower)) return true
150
+ if (lower in CLAUDE_MODEL_ALIASES) return true
84
151
  return lower.startsWith('claude-')
85
152
  }
86
153
 
@@ -621,7 +688,7 @@ export function planModelCommand(
621
688
  ): ModelCommandDisposition {
622
689
  if (parsed.kind === 'show' && ctx.menuEnabled) return { kind: 'menu' }
623
690
  if (parsed.kind === 'set' && isModelCommandBusy(ctx)) {
624
- return { kind: 'queue', target: expandSrAlias(parsed.model) }
691
+ return { kind: 'queue', target: expandModelAlias(parsed.model) }
625
692
  }
626
693
  return { kind: 'apply', parsed }
627
694
  }
@@ -662,6 +729,24 @@ export interface ModelCommandDeps {
662
729
  * rendered as "default".
663
730
  */
664
731
  getConfiguredModel: () => string | null
732
+ /**
733
+ * The agent's cascade-resolved `thinking_effort` from `switchroom agent list`
734
+ * β€” the value start.sh bakes into `--effort` on the relaunch this command is
735
+ * about to schedule. Null when unset / unreadable (treated as the scaffold
736
+ * floor `low`, i.e. safe).
737
+ *
738
+ * Needed because a `/model` switch is SESSION-scoped and therefore invisible
739
+ * to `switchroom doctor`, whose `thinking_effort Γ— adaptive model` check
740
+ * (`src/cli/doctor.ts:572`) reads the CONFIGURED `model:` out of
741
+ * switchroom.yaml. Switching the live session onto a pinned Opus 4.x id β€” by
742
+ * typing it, by a `CLAUDE_MODEL_ALIASES` shortcut, or by tapping the
743
+ * "Opus 4.8" button β€” lands the exact (pinned Opus 4.x, effort > low)
744
+ * combination doctor exists to warn about, with doctor none the wiser. So the
745
+ * assessment is re-run HERE, at switch time, against the effort the relaunch
746
+ * will actually use. A relaunch writes no `.session-effort` carrier, so any
747
+ * live `/effort` override is shed and the configured value is the right input.
748
+ */
749
+ getConfiguredEffort: () => string | null
665
750
  escapeHtml: (s: string) => string
666
751
  preBlock: (s: string) => string
667
752
  /**
@@ -721,14 +806,34 @@ export interface ModelCommandReply {
721
806
  const PERSIST_NOTE =
722
807
  '_A `/model` switch relaunches the session (~30s) on the chosen model. Session-only β€” reverts to the configured \`model:\` on the next restart. \`/model default\` reverts now. Live scrollback is replaced by a fresh session; memory and the handoff briefing carry the context. To change the default permanently, set \`model:\` in switchroom.yaml._'
723
808
 
809
+ /**
810
+ * Discoverability line for CLAUDE_MODEL_ALIASES, grouped by target so several
811
+ * spellings of one id read as one entry (`opus48` Β· `opus-4-8` β†’ `claude-opus-4-8`).
812
+ * Derived from the map, never hand-listed, so a new shortcut shows up in the
813
+ * help and dashboard text for free.
814
+ */
815
+ function claudeAliasHints(prefix: string): string {
816
+ const byTarget = new Map<string, string[]>()
817
+ for (const [alias, target] of Object.entries(CLAUDE_MODEL_ALIASES)) {
818
+ const spellings = byTarget.get(target) ?? []
819
+ spellings.push(alias)
820
+ byTarget.set(target, spellings)
821
+ }
822
+ return [...byTarget]
823
+ .map(([target, spellings]) => `${spellings.map(a => `\`${prefix}${a}\``).join(' Β· ')} β†’ \`${target}\``)
824
+ .join('; ')
825
+ }
826
+
724
827
  function helpText(deps: ModelCommandDeps, reason?: string): ModelCommandReply {
725
828
  const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map(a => `\`${a}\``).join(' Β· ')
829
+ const claudeAliasExamples = claudeAliasHints('')
726
830
  const lines: string[] = []
727
831
  if (reason) lines.push(`⚠️ ${deps.escapeHtml(reason)}`)
728
832
  lines.push(
729
833
  '**/model** β€” show or switch the Claude model',
730
834
  '\`/model\` β€” show the configured model',
731
835
  `\`/model <name>\` β€” switch the live session (${MODEL_ALIASES.map(a => `\`${a}\``).join(' Β· ')} or a full model id)`,
836
+ `_Pinned Claude shortcuts:_ ${claudeAliasExamples}`,
732
837
  `_OpenRouter shortcuts:_ ${srAliasExamples}`,
733
838
  '_Every switch relaunches the session (~30s) on the chosen model β€” Claude and OpenRouter (sr-\\*) alike._',
734
839
  PERSIST_NOTE,
@@ -751,6 +856,7 @@ export async function handleModelCommand(
751
856
  `**Model β€” ${deps.escapeHtml(deps.getAgentName())}**`,
752
857
  `Configured: \`${deps.escapeHtml(shown)}\``,
753
858
  `Switch the live session: ${MODEL_ALIASES.map(a => `\`/model ${a}\``).join(' Β· ')}`,
859
+ `Pinned Claude shortcuts: ${claudeAliasHints('/model ')}`,
754
860
  `OpenRouter shortcuts: ${srAliasExamples}`,
755
861
  'or \`/model <full-model-id>\`',
756
862
  PERSIST_NOTE,
@@ -765,8 +871,9 @@ export async function handleModelCommand(
765
871
  return helpText(deps, `not a valid model name: ${parsed.model}`)
766
872
  }
767
873
 
768
- // Expand short aliases: `flash` β†’ `sr-gemini-2.5-flash`, `codex` β†’ `sr-codex-5.5`, etc.
769
- const model = expandSrAlias(parsed.model)
874
+ // Expand short aliases BEFORE any Claude-vs-external decision below:
875
+ // `opus48`/`opus-4-8` β†’ `claude-opus-4-8`, `flash` β†’ `sr-gemini-2.5-flash`, etc.
876
+ const model = expandModelAlias(parsed.model)
770
877
 
771
878
  // Busy gate: a switch RELAUNCHES the session, which is unsafe mid-turn (it
772
879
  // would tear down the live turn). The gateway's mid-turn path ACKs + queues +
@@ -822,16 +929,70 @@ function relaunchErrorReply(
822
929
  * silently serves the fallback. Say so IMMEDIATELY in the switch ack, and
823
930
  * point at the first-reply tripwire that will catch it. Aliases and sr-* ids
824
931
  * are vouched by their own gates (MODEL_ALIASES / the LiteLLM route probe),
825
- * so only typed `claude-*` ids carry the caveat. Exported for tests.
932
+ * so only typed `claude-*` ids carry the caveat β€” and a CLAUDE_MODEL_ALIASES
933
+ * TARGET is vouched by the same curation gate that makes it offline-trustable
934
+ * (isOfflineTrustedModelToken), so it is exempt too; warning "can't be
935
+ * validated" on a shortcut switchroom itself curates would contradict that.
936
+ *
937
+ * The exemption compares `canonicalModelToken(model)` β€” the SAME form
938
+ * `expandModelAlias` emits β€” so the token that reaches `claude --model` and the
939
+ * token this gate judges can never disagree. Comparing a lowercased form against
940
+ * a verbatim-emitted one is exactly how `/model CLAUDE-OPUS-4-8` used to launch
941
+ * an uncanonical id under a clean green ack.
942
+ *
943
+ * What the exemption costs, stated honestly: the ONLY remaining signal that an
944
+ * exempted target silently fell back is the first-reply divergence tripwire
945
+ * (`servedModelMatchesRequested`). The post-boot confirmation card is NOT a
946
+ * second signal for a pinned id whose FAMILY equals the configured default's:
947
+ * `modelFamilyToken('claude-opus-4-8')` is `'opus'`, so on an `opus`-default
948
+ * agent `classifyModelSwitchConfirmation` returns `default`, not `not-applied`
949
+ * (see #4005 β€” a pre-existing property of the family reduction, reachable
950
+ * before this map existed by typing the full id).
951
+ * Exported for tests.
826
952
  */
827
953
  export function unvalidatedIdCaveat(
828
954
  deps: Pick<ModelCommandDeps, 'escapeHtml'>,
829
955
  model: string,
830
956
  ): string | null {
831
- if (!model.trim().toLowerCase().startsWith('claude-')) return null
957
+ const lower = canonicalModelToken(model).toLowerCase()
958
+ if (!lower.startsWith('claude-')) return null
959
+ if (Object.values(CLAUDE_MODEL_ALIASES).includes(lower)) return null
832
960
  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
961
  }
834
962
 
963
+ /**
964
+ * Switch-time surfacing of the #1978 adaptive-thinking risk (`thinking_effort`
965
+ * above `low` on a PINNED Opus 4.x id).
966
+ *
967
+ * `assessThinkingEffortRisk` had exactly ONE consumer β€” `switchroom doctor`
968
+ * (`src/cli/doctor.ts:572`) β€” which reads the CONFIGURED `model:` from
969
+ * switchroom.yaml. A `/model` switch is session-scoped and never touches that
970
+ * file, so every route onto a pinned Opus 4.x (typed full id, a
971
+ * `CLAUDE_MODEL_ALIASES` shortcut, or the one-tap "Opus 4.8" button) reached the
972
+ * risky combination with no warning anywhere. This is the second consumer, on
973
+ * the path that actually creates the combination.
974
+ *
975
+ * Advisory only β€” it never rewrites anyone's effort. Returns null when the combo
976
+ * is safe (effort unset/`low`, or any non-Opus-4.x model, including Opus 5 and
977
+ * the bare `opus` alias).
978
+ */
979
+ export function thinkingEffortCaveat(
980
+ deps: Pick<ModelCommandDeps, 'escapeHtml' | 'getConfiguredEffort'>,
981
+ model: string,
982
+ ): string | null {
983
+ let effort: string | null
984
+ try {
985
+ effort = deps.getConfiguredEffort()
986
+ } catch {
987
+ // The effort probe shells out to `switchroom agent list`; a transient
988
+ // failure must never block or fail the switch it is only annotating.
989
+ return null
990
+ }
991
+ const risk = assessThinkingEffortRisk(model, effort ?? undefined)
992
+ if (!risk.risky || !risk.reason) return null
993
+ return `⚠️ _${deps.escapeHtml(risk.reason)}_`
994
+ }
995
+
835
996
  /** Schedule a carrier relaunch onto `model`, returning the deterministic ack. */
836
997
  async function scheduleRelaunchReply(
837
998
  deps: ModelCommandDeps,
@@ -844,8 +1005,14 @@ async function scheduleRelaunchReply(
844
1005
  return relaunchErrorReply(deps, model, err)
845
1006
  }
846
1007
  const caveat = unvalidatedIdCaveat(deps, model)
1008
+ const effortRisk = thinkingEffortCaveat(deps, model)
847
1009
  return {
848
- text: [switchingLine(deps, model), ...(caveat ? [caveat] : []), PERSIST_NOTE].join('\n'),
1010
+ text: [
1011
+ switchingLine(deps, model),
1012
+ ...(caveat ? [caveat] : []),
1013
+ ...(effortRisk ? [effortRisk] : []),
1014
+ PERSIST_NOTE,
1015
+ ].join('\n'),
849
1016
  html: true,
850
1017
  }
851
1018
  }
@@ -934,15 +1101,28 @@ export const MODEL_CALLBACK_PAGE_MAIN = 'mdl:page:main'
934
1101
  export type ModelMenuPage = 'main' | 'external'
935
1102
 
936
1103
  /**
937
- * Static Claude aliases appended to the scraped Claude group. The claude CLI's
938
- * own `/model` picker (deps.discover) does NOT list `fable`, but the CLI
939
- * resolves the alias natively, so we render it as an extra button that switches
940
- * via the carrier relaunch (MODEL_CALLBACK_ALIAS β†’ scheduleModelRelaunch, rev
941
- * 5). Extend this list to surface further CLI-resolvable aliases the picker
942
- * omits.
1104
+ * Static Claude `--model` TOKENS appended to the scraped Claude group β€” extra
1105
+ * keyboard rows for targets the claude CLI's own `/model` picker (deps.discover)
1106
+ * does not list. Each renders as a button that switches via the carrier relaunch
1107
+ * (MODEL_CALLBACK_ALIAS β†’ scheduleModelRelaunch, rev 5).
1108
+ *
1109
+ * NB "token", not "alias": members are not required to be CLI-resolvable aliases
1110
+ * and are treated opaquely by both call sites (they are only concatenated onto
1111
+ * the `mdl:alias:` callback prefix, whose wire format predates this list). The
1112
+ * two kinds present today:
1113
+ * - `fable` β€” a genuine alias the CLI resolves itself; the picker just omits
1114
+ * it. Boots via the LiteLLM router repoint in start.sh (see the fable case).
1115
+ * - `claude-opus-4-8` β€” a full PINNED id, not an alias. Rendered because the
1116
+ * picker doesn't offer it and typing it on a phone is hostile.
1117
+ * Extend with either kind; a member only has to be a legal `claude --model` arg.
943
1118
  */
944
- export const EXTRA_CLAUDE_ALIASES: ReadonlyArray<{ alias: string; label: string }> = [
945
- { alias: 'fable', label: 'Fable' },
1119
+ export const EXTRA_CLAUDE_MENU_TOKENS: ReadonlyArray<{ token: string; label: string }> = [
1120
+ { token: 'fable', label: 'Fable' },
1121
+ // Pinned Opus 4.8. The button carries the CANONICAL id (not the `opus48`
1122
+ // shortcut) so the tap needs no expansion to be recognized by an older
1123
+ // gateway, and so `canonicalClaudeToken` resolves it directly. Typed
1124
+ // `/model opus48` / `/model opus-4-8` land on the same target.
1125
+ { token: 'claude-opus-4-8', label: 'Opus 4.8' },
946
1126
  ]
947
1127
 
948
1128
  /**
@@ -1039,13 +1219,69 @@ export function isOfflineTrustedModelToken(token: string): boolean {
1039
1219
  const lower = token.toLowerCase()
1040
1220
  if ((MODEL_ALIASES as readonly string[]).includes(lower)) return true
1041
1221
  if (lower in SR_MODEL_ALIASES) return true
1042
- return Object.values(SR_MODEL_ALIASES).includes(lower)
1222
+ if (Object.values(SR_MODEL_ALIASES).includes(lower)) return true
1223
+ // Curated pinned-Claude shortcuts and their targets. Both spellings are
1224
+ // trustable for the same reason the sr-* alias TARGETS are: the id is
1225
+ // switchroom-curated, not a hand-typed guess. A queue that already expanded
1226
+ // (planModelCommand) persists the VALUE, an alias tap persists the KEY β€” so
1227
+ // both sides must be accepted or `/model opus48` would survive a boot only
1228
+ // on one of the two paths.
1229
+ if (lower in CLAUDE_MODEL_ALIASES) return true
1230
+ return Object.values(CLAUDE_MODEL_ALIASES).includes(lower)
1043
1231
  }
1044
1232
 
1045
1233
  export function expandSrAlias(arg: string): string {
1046
1234
  return SR_MODEL_ALIASES[arg.toLowerCase()] ?? arg
1047
1235
  }
1048
1236
 
1237
+ /**
1238
+ * The ONE canonical form of a model token β€” what `claude --model` is handed and
1239
+ * what every downstream gate compares against.
1240
+ *
1241
+ * Two normalizations, both narrow:
1242
+ * - **trim** β€” unconditional. `unvalidatedIdCaveat` already trimmed while
1243
+ * expansion did not, so the two disagreed on a padded token.
1244
+ * - **lowercase, `claude-*` ONLY.** Every real Anthropic model id is lowercase
1245
+ * and a phone autocapitalizes, so `/model Claude-Opus-4-8` used to be launched
1246
+ * VERBATIM as an unvalidated id while the caveat exemption (which lowercases)
1247
+ * suppressed the warning β€” a clean green ack for a token the caveat was
1248
+ * written for. Non-`claude-` tokens are left alone: `sr-*` ids are matched
1249
+ * case-insensitively by their own maps, and rewriting an arbitrary external
1250
+ * id's case is not ours to do.
1251
+ *
1252
+ * Applied by `expandModelAlias`, so canonicalization happens on every `/model`
1253
+ * path (typed apply, mid-turn queue, menu tap, queued-across-restart persist)
1254
+ * whether or not the token hit a shortcut map β€” one canonical form flows to
1255
+ * `claude --model`, and `unvalidatedIdCaveat` sees exactly that form.
1256
+ */
1257
+ export function canonicalModelToken(arg: string): string {
1258
+ const trimmed = arg.trim()
1259
+ const lower = trimmed.toLowerCase()
1260
+ return lower.startsWith('claude-') ? lower : trimmed
1261
+ }
1262
+
1263
+ /** Expand a short pinned-Claude spelling (case-insensitive) to its full `claude-*` id. */
1264
+ export function expandClaudeAlias(arg: string): string {
1265
+ return CLAUDE_MODEL_ALIASES[arg.trim().toLowerCase()] ?? arg
1266
+ }
1267
+
1268
+ /**
1269
+ * The ONE expansion every `/model` argument goes through, on every path that
1270
+ * consumes a user-supplied token (typed apply, mid-turn queue, menu tap,
1271
+ * queued-across-restart persist). Claude shortcuts resolve first, then sr-*
1272
+ * shortcuts; the two key sets are disjoint by construction (a Claude shortcut
1273
+ * never expands to an `sr-*` id and vice versa), so order only documents intent.
1274
+ * The result is `canonicalModelToken`-normalized, so a miss can no longer emit a
1275
+ * verbatim mixed-case `claude-*` id that the caveat exemption then mis-matches.
1276
+ *
1277
+ * Expanding HERE β€” before `isClaudeModel` / `isSrModel` / `externalModelNames`
1278
+ * ever see the token β€” is what keeps a pinned-Claude shortcut on the Anthropic
1279
+ * OAuth passthrough instead of being misread as an external route.
1280
+ */
1281
+ export function expandModelAlias(arg: string): string {
1282
+ return canonicalModelToken(expandSrAlias(expandClaudeAlias(arg)))
1283
+ }
1284
+
1049
1285
  export function srFriendlyLabel(srName: string): string {
1050
1286
  return SR_MODEL_LABELS[srName] ?? srName.replace(/^sr-/, '').replace(/-/g, ' ')
1051
1287
  }
@@ -1161,6 +1397,13 @@ function busyStaticMenu(
1161
1397
  callback_data: `${MODEL_CALLBACK_ALIAS}${alias}`,
1162
1398
  }])
1163
1399
  }
1400
+ // Same extra Claude rows the live keyboard renders (Fable, pinned Opus 4.8),
1401
+ // minus any already covered by a MODEL_ALIASES row above β€” otherwise a model
1402
+ // is selectable when idle but vanishes from the mid-turn quick list.
1403
+ for (const { token, label } of EXTRA_CLAUDE_MENU_TOKENS) {
1404
+ if ((MODEL_ALIASES as readonly string[]).includes(token.toLowerCase())) continue
1405
+ rows.push([{ text: label, callback_data: `${MODEL_CALLBACK_ALIAS}${token}` }])
1406
+ }
1164
1407
  rows.push([{ text: 'Default (configured)', callback_data: `${MODEL_CALLBACK_ALIAS}default` }])
1165
1408
  if (externalNames.length > 0) {
1166
1409
  rows.push([{ text: '🌐 External models β–Έ', callback_data: MODEL_CALLBACK_PAGE_EXTERNAL }])
@@ -1230,16 +1473,16 @@ function mainPageKeyboard(
1230
1473
  }])
1231
1474
  }
1232
1475
 
1233
- // Static Claude aliases the CLI picker omits (e.g. Fable). Deduped: if the
1234
- // scraped Claude options already include a matching row, don't render the
1235
- // static one too.
1236
- for (const { alias, label } of EXTRA_CLAUDE_ALIASES) {
1476
+ // Static Claude tokens the CLI picker omits (Fable, pinned Opus 4.8).
1477
+ // Deduped: if the scraped Claude options already include a matching row,
1478
+ // don't render the static one too.
1479
+ for (const { token, label } of EXTRA_CLAUDE_MENU_TOKENS) {
1237
1480
  const already = claudeOptions.some(
1238
1481
  (o) => o.label.toLowerCase() === label.toLowerCase() ||
1239
- o.label.toLowerCase() === alias.toLowerCase(),
1482
+ o.label.toLowerCase() === token.toLowerCase(),
1240
1483
  )
1241
1484
  if (already) continue
1242
- rows.push([{ text: label, callback_data: `${MODEL_CALLBACK_ALIAS}${alias}` }])
1485
+ rows.push([{ text: label, callback_data: `${MODEL_CALLBACK_ALIAS}${token}` }])
1243
1486
  }
1244
1487
 
1245
1488
  if (hasExternal) {
@@ -1434,9 +1677,17 @@ export async function handleModelMenuCallback(
1434
1677
  token = data.slice(MODEL_CALLBACK_SELECT.length)
1435
1678
  label = token
1436
1679
  }
1680
+ // Shape gate FIRST, on the RAW callback payload. `expandModelAlias` trims,
1681
+ // so gating after it would let a whitespace-padded payload through a regex
1682
+ // that exists precisely to guarantee one whitespace-free token reaches the
1683
+ // tmux pane. Order: shape β†’ expand β†’ recognition.
1437
1684
  if (!isValidModelArg(token)) {
1438
1685
  return { answer: 'Invalid model name', reply: await buildModelMenu(deps) }
1439
1686
  }
1687
+ // Same expansion the typed path applies, before the recognition gate: a
1688
+ // callback minted by an older gateway (or a hand-crafted `mdl:alias:opus48`)
1689
+ // must resolve to the canonical id rather than be rejected as unrecognized.
1690
+ token = expandModelAlias(token)
1440
1691
  // N1: reject a token we don't recognize (a stale OLD-gateway `mdl:s:<hex>`
1441
1692
  // callback, an unmapped SELECT row, or garbage). Relaunching onto it would
1442
1693
  // write a carrier claude silently falls back from (--fallback-model). Re-render
@@ -1498,11 +1749,16 @@ async function menuRelaunchOutcome(
1498
1749
  }
1499
1750
  }
1500
1751
  const friendly = isDefault ? 'the configured default' : label
1752
+ // Same #1978 switch-time guard the typed path carries β€” the one-tap
1753
+ // "Opus 4.8" button is the EASIEST route into (pinned Opus 4.x, effort > low)
1754
+ // and doctor cannot see a session-scoped switch. `/model default` reverts to
1755
+ // the configured model, which doctor already covers, so it is not assessed.
1756
+ const effortRisk = isDefault ? null : thinkingEffortCaveat(deps, token)
1501
1757
  return {
1502
1758
  answer: `Switching to ${isDefault ? 'default' : label} β€” relaunching (~30s)`,
1503
1759
  reply: await menuWithBannerStatic(
1504
1760
  deps,
1505
- `πŸ”„ Switching session to **${deps.escapeHtml(friendly)}** β€” relaunching (~30s).\n${PERSIST_NOTE}`,
1761
+ `πŸ”„ Switching session to **${deps.escapeHtml(friendly)}** β€” relaunching (~30s).${effortRisk ? `\n${effortRisk}` : ''}\n${PERSIST_NOTE}`,
1506
1762
  ),
1507
1763
  }
1508
1764
  }
@@ -191,7 +191,18 @@ export function normalizeOutboundBody(
191
191
  if (!literalText) text = normalizeParagraphBreaks(text)
192
192
  text = redact(text, site)
193
193
  if (!literalText) {
194
- let formatted = stripExcessBold(normalizePunctuation(text))
194
+ let formatted = stripExcessBold(normalizePunctuation(text), (d) => {
195
+ // Observability: the over-bold tripwire silently flattened formatting.
196
+ // Emit one diagnostic line naming the rule + measured ratio so a lost
197
+ // reply is traceable (it previously vanished with no signal).
198
+ try {
199
+ process.stderr.write(
200
+ `telegram gateway: strip-excess-bold: fired site=${site} rule=${d.rule} ratio=${d.ratio.toFixed(3)}\n`,
201
+ )
202
+ } catch {
203
+ // stderr write must never break the send path. Swallow.
204
+ }
205
+ })
195
206
  if (addSpacers) formatted = addParagraphSpacers(formatted)
196
207
  text = formatted
197
208
  // Temporal normalization (#3501): UTC/Zulu β†’ local wall clock, then
@@ -23,11 +23,32 @@ import {
23
23
 
24
24
  // ─── Taxonomy ────────────────────────────────────────────────────────────────
25
25
 
26
- export type OperatorEventKind =
27
- | 'credentials-expired'
28
- | 'credentials-invalid'
29
- | 'proxy-misconfig'
30
- | 'credit-exhausted'
26
+ /**
27
+ * The CANONICAL runtime list of every operator-event kind β€” the SINGLE source
28
+ * of truth for the taxonomy. The `OperatorEventKind` type is derived from it
29
+ * (below), and every other place that needs the set at RUNTIME (notably the
30
+ * gateway IPC validator's `VALID_OPERATOR_KINDS` in `gateway/ipc-server.ts`)
31
+ * imports THIS array rather than re-declaring a hand-maintained literal.
32
+ *
33
+ * WHY THIS IS AN ARRAY, NOT JUST A TYPE (the drift this closes)
34
+ * ------------------------------------------------------------
35
+ * A TypeScript union erases at compile time, so a runtime allowlist could only
36
+ * mirror it by hand β€” and it drifted: `ipc-server.ts` shipped a 9-entry Set
37
+ * that never gained `provider-credit-exhausted` / `mcp-dependency-blocked` /
38
+ * `proxy-misconfig` when those kinds were added here. The bridge forwarded a
39
+ * `provider-credit-exhausted` operator_event; the gateway validator rejected it
40
+ * as an "invalid IPC message shape" and DROPPED it β€” so a real OpenRouter/
41
+ * LiteLLM 402 credit wall produced NO loud Telegram card at all (the 2026-07-30
42
+ * incident). Deriving both the type and the runtime allowlist from this one
43
+ * array makes that class of silent drop structurally impossible.
44
+ *
45
+ * Order is not significant. Adding a kind is a one-line edit HERE.
46
+ */
47
+ export const OPERATOR_EVENT_KINDS = [
48
+ 'credentials-expired',
49
+ 'credentials-invalid',
50
+ 'proxy-misconfig',
51
+ 'credit-exhausted',
31
52
  /**
32
53
  * A THIRD-PARTY model provider (OpenRouter / OpenAI / Perplexity) reports no
33
54
  * credit remaining β€” HTTP 402 `payment_required`, "insufficient credits",
@@ -37,7 +58,7 @@ export type OperatorEventKind =
37
58
  * must not share a card. Operator-actionable (top up in the vendor console),
38
59
  * never user-actionable β€” see {@link OPERATOR_ACTIONABLE_KINDS}.
39
60
  */
40
- | 'provider-credit-exhausted'
61
+ 'provider-credit-exhausted',
41
62
  /**
42
63
  * A paid MCP dependency (Perplexity, Eraser, Brevo, Postiz, Meta/Google Ads,
43
64
  * Cloudflare …) is refusing work because its KEY is the problem β€” out of
@@ -49,16 +70,19 @@ export type OperatorEventKind =
49
70
  * `mcp-credential-failure.ts`. Operator-actionable: only the operator can top
50
71
  * up or re-issue a key.
51
72
  */
52
- | 'mcp-dependency-blocked'
53
- | 'quota-exhausted'
54
- | 'rate-limited'
55
- | 'agent-crashed'
56
- | 'agent-restarted-unexpectedly'
57
- | 'unknown-4xx'
58
- | 'unknown-5xx'
59
- | 'config-warning'
60
- | 'always-allow-persist-failed'
61
- | 'mental-model-persist-failed'
73
+ 'mcp-dependency-blocked',
74
+ 'quota-exhausted',
75
+ 'rate-limited',
76
+ 'agent-crashed',
77
+ 'agent-restarted-unexpectedly',
78
+ 'unknown-4xx',
79
+ 'unknown-5xx',
80
+ 'config-warning',
81
+ 'always-allow-persist-failed',
82
+ 'mental-model-persist-failed',
83
+ ] as const
84
+
85
+ export type OperatorEventKind = (typeof OPERATOR_EVENT_KINDS)[number]
62
86
 
63
87
  export interface OperatorEvent {
64
88
  kind: OperatorEventKind
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Database / service connection-URI credential scanner.
3
+ *
4
+ * Gap closed (2026-07 hindsight write-path audit): a connection string
5
+ * like `postgres://appuser:<password>@db.internal:5432/prod` passed the
6
+ * whole detection stack untouched and was stored verbatim in agent
7
+ * memory. `url-redact.ts` only rewrites `http(s)`/`ws(s)`/`ftp` (its
8
+ * `URL_RE` is scheme-limited), and no pattern in `patterns.ts` describes
9
+ * the `scheme://user:pass@host` shape, so a production database password
10
+ * was invisible to every caller of `detectSecrets`.
11
+ *
12
+ * This scanner is deliberately SCHEME-AGNOSTIC β€” it matches any
13
+ * `<scheme>://<user>:<password>@<host>` β€” so postgres, mysql, mongodb,
14
+ * redis, amqp, clickhouse, mssql and whatever ships next are all covered
15
+ * without a scheme allowlist to keep current. Precision comes from the
16
+ * shape, not from the scheme list: userinfo-with-password in a URI is a
17
+ * credential by construction.
18
+ *
19
+ * Only the PASSWORD bytes are reported. The username is left intact: it
20
+ * is rarely the secret, and keeping it preserves the diagnostic value of
21
+ * the stored line ("which account", not just "some database").
22
+ *
23
+ * Confidence is `ambiguous`, NOT `high`. `pipeline.ts` auto-writes every
24
+ * high-confidence inbound hit to the vault and the caller DELETES the
25
+ * user's Telegram message; promoting this rule to `high` would change
26
+ * inbound gating as a side effect of a redaction fix. `redact()` masks
27
+ * ambiguous hits, which is what this change is for.
28
+ */
29
+ import type { RawHit } from './kv-scanner.js'
30
+
31
+ /**
32
+ * `<scheme>://<user>:<password>@<host>`.
33
+ *
34
+ * - scheme: RFC 3986 shape, at least two chars so a Windows drive letter
35
+ * (`c://`) can't anchor a match.
36
+ * - user: no `/`, `@`, `:` or whitespace.
37
+ * - password: anything up to an authority TERMINATOR (`/`, `?`, `#` or
38
+ * whitespace) β€” `@` deliberately INCLUDED. Greedy matching then backs
39
+ * off to the LAST `@` in the authority, which is where the host
40
+ * actually starts.
41
+ *
42
+ * #3982 review, MAJOR 3: excluding `@` (so the match stopped at the
43
+ * FIRST one) looked conservative and was the opposite. An unencoded
44
+ * `@` in a pasted DSN is common, and
45
+ * `postgres://appuser:p@ssW0rd123@db.internal:5432/prod` masked one
46
+ * byte and stored the other ten in clear β€”
47
+ * `postgres://appuser:[REDACTED:db_uri_password]@ssW0rd123@db.internal…`
48
+ * β€” while also telling a reader exactly how long the masked prefix
49
+ * was. `url-redact.ts` already did this right for http(s) by taking
50
+ * `lastIndexOf('@')` over the authority; this is the same rule for the
51
+ * non-HTTP schemes.
52
+ * - host: at least one non-delimiter byte, so `user:pass@` with nothing
53
+ * after it is not a connection string.
54
+ */
55
+ const DB_URI_RE =
56
+ /\b([a-zA-Z][a-zA-Z0-9+.-]+):\/\/([^\s:/@]+):([^\s/?#]+)@([^\s/@?#]+)/g
57
+
58
+ export const DB_URI_RULE_ID = 'db_uri_password'
59
+
60
+ /** Values that are already masked or are obviously not a live credential. */
61
+ function isInertPassword(value: string): boolean {
62
+ if (value.startsWith('[REDACTED')) return true
63
+ // `redactUrls()` rewrites `user:pass@` to `***@` (empty password), but a
64
+ // hand-written `***`/`xxx` mask should not round-trip into a detection
65
+ // either.
66
+ return /^[*x]+$/i.test(value)
67
+ }
68
+
69
+ export function scanDbUris(text: string): RawHit[] {
70
+ const hits: RawHit[] = []
71
+ DB_URI_RE.lastIndex = 0
72
+ let m: RegExpExecArray | null
73
+ while ((m = DB_URI_RE.exec(text)) !== null) {
74
+ const scheme = m[1]!
75
+ const user = m[2]!
76
+ const password = m[3]!
77
+ if (isInertPassword(password)) continue
78
+ // Offset of the password = match start + scheme + "://" + user + ":".
79
+ const start = m.index + scheme.length + 3 + user.length + 1
80
+ hits.push({
81
+ rule_id: DB_URI_RULE_ID,
82
+ start,
83
+ end: start + password.length,
84
+ matched_text: password,
85
+ key_name: `${scheme}_password`,
86
+ confidence: 'ambiguous',
87
+ })
88
+ }
89
+ return hits
90
+ }