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
@@ -60,6 +60,10 @@ import {
60
60
  handleAuthCommand,
61
61
  isAuthAdmin,
62
62
  validateAuthAddLabel,
63
+ readdPrecheckError,
64
+ runReaddPrecheck,
65
+ formatGrantedScopesReply,
66
+ REQUIRED_USAGE_SCOPE,
63
67
  } from '../gateway/auth-command.js'
64
68
  import {
65
69
  pendingAuthAddFlows,
@@ -111,6 +115,7 @@ function makeMockTmuxOps(opts: {
111
115
  } = {}): AuthAddTmuxOps & {
112
116
  newSessionCalls: Array<{ socket: string; session: string; env: Record<string, string>; cmd: string }>
113
117
  sendCalls: Array<{ socket: string; session: string; text: string }>
118
+ sendKeyCalls: Array<{ socket: string; session: string; key: string }>
114
119
  killCalls: Array<{ socket: string; session: string }>
115
120
  captureCallCount: number
116
121
  sessionAlive: boolean
@@ -123,12 +128,14 @@ function makeMockTmuxOps(opts: {
123
128
  let sessionAlive = opts.initialSessionAlive ?? true
124
129
  const newSessionCalls: Array<{ socket: string; session: string; env: Record<string, string>; cmd: string }> = []
125
130
  const sendCalls: Array<{ socket: string; session: string; text: string }> = []
131
+ const sendKeyCalls: Array<{ socket: string; session: string; key: string }> = []
126
132
  const killCalls: Array<{ socket: string; session: string }> = []
127
133
  let captureCallCount = 0
128
134
 
129
135
  const mock = {
130
136
  get newSessionCalls() { return newSessionCalls },
131
137
  get sendCalls() { return sendCalls },
138
+ get sendKeyCalls() { return sendKeyCalls },
132
139
  get killCalls() { return killCalls },
133
140
  get captureCallCount() { return captureCallCount },
134
141
  get sessionAlive() { return sessionAlive },
@@ -149,6 +156,9 @@ function makeMockTmuxOps(opts: {
149
156
  sendCalls.push({ socket, session, text })
150
157
  mock.onSend?.(socket, session, text)
151
158
  },
159
+ sendKey(socket: string, session: string, key: string) {
160
+ sendKeyCalls.push({ socket, session, key })
161
+ },
152
162
  hasSession(socket: string, session: string): boolean {
153
163
  void socket; void session
154
164
  return sessionAlive
@@ -166,12 +176,12 @@ function makeMockTmuxOps(opts: {
166
176
  describe('parseAuthCommand — /auth add and /auth cancel', () => {
167
177
  it('recognises "/auth add <label>" with a valid label', () => {
168
178
  const p = parseAuthCommand('/auth add alice@example.com')
169
- expect(p).toEqual({ kind: 'add', label: 'alice@example.com' })
179
+ expect(p).toEqual({ kind: 'add', label: 'alice@example.com', replace: false })
170
180
  })
171
181
 
172
182
  it('recognises gmail-tag labels (the + character)', () => {
173
183
  const p = parseAuthCommand('/auth add alice+work@example.com')
174
- expect(p).toEqual({ kind: 'add', label: 'alice+work@example.com' })
184
+ expect(p).toEqual({ kind: 'add', label: 'alice+work@example.com', replace: false })
175
185
  })
176
186
 
177
187
  it('treats "/auth add" with no label as a help reply', () => {
@@ -191,7 +201,7 @@ describe('parseAuthCommand — /auth add and /auth cancel', () => {
191
201
  // whitespace is the parser's contract — the validator catches
192
202
  // shape violations on the first token.
193
203
  const p = parseAuthCommand('/auth add foo bar')
194
- expect(p).toEqual({ kind: 'add', label: 'foo' })
204
+ expect(p).toEqual({ kind: 'add', label: 'foo', replace: false })
195
205
  })
196
206
 
197
207
  it('rejects an over-length label (>64 chars)', () => {
@@ -260,7 +270,7 @@ describe('isAuthAdmin', () => {
260
270
  describe('handleAuthCommand — add/cancel are gateway-routed (defensive contract)', () => {
261
271
  it('returns a "not routed" error for parsed.kind === "add" so the contract is loud if a future refactor forgets the gateway dispatch', async () => {
262
272
  const reply = await handleAuthCommand(
263
- { kind: 'add', label: 'foo' },
273
+ { kind: 'add', label: 'foo', replace: false },
264
274
  {
265
275
  agentName: 'clerk',
266
276
  isAdmin: true,
@@ -272,7 +282,7 @@ describe('handleAuthCommand — add/cancel are gateway-routed (defensive contrac
272
282
 
273
283
  it('refuses /auth add for non-admin before the not-routed branch', async () => {
274
284
  const reply = await handleAuthCommand(
275
- { kind: 'add', label: 'foo' },
285
+ { kind: 'add', label: 'foo', replace: false },
276
286
  {
277
287
  agentName: 'other',
278
288
  isAdmin: false,
@@ -657,6 +667,161 @@ describe('mocked-broker addAccount integration sketch', () => {
657
667
  expect(addAccountSpy).toHaveBeenCalledTimes(1)
658
668
  })
659
669
  })
670
+
671
+ it('readd threads replace=true through to the broker addAccount verb', async () => {
672
+ const fakeCredentials = {
673
+ claudeAiOauth: {
674
+ accessToken: 'sk-ant-oat01-test-' + 'x'.repeat(40),
675
+ expiresAt: Date.now() + 3600_000,
676
+ scopes: ['org:create_api_key', 'user:profile', 'user:inference'],
677
+ },
678
+ }
679
+ const addAccountSpy = vi.fn(async (label: string, _c: typeof fakeCredentials, replace?: boolean) => ({
680
+ label,
681
+ replace,
682
+ }))
683
+ // Plain add → replace false; readd → replace true. Both round-trip.
684
+ const added = await addAccountSpy('pooled@example.com', fakeCredentials, false)
685
+ const readded = await addAccountSpy('pooled@example.com', fakeCredentials, true)
686
+ expect(added.replace).toBe(false)
687
+ expect(readded.replace).toBe(true)
688
+ expect(addAccountSpy).toHaveBeenCalledTimes(2)
689
+ })
690
+ })
691
+
692
+ /* ── 9b. /auth readd parser + precheck + scope reply (PR C) ────────────── */
693
+
694
+ describe('parseAuthCommand — /auth readd and add --replace', () => {
695
+ it('parses "/auth readd <label>" as an add with replace=true', () => {
696
+ expect(parseAuthCommand('/auth readd pooled@example.com')).toEqual({
697
+ kind: 'add',
698
+ label: 'pooled@example.com',
699
+ replace: true,
700
+ })
701
+ })
702
+
703
+ it('parses "/auth add <label> --replace" as replace=true too', () => {
704
+ expect(parseAuthCommand('/auth add pooled@example.com --replace')).toEqual({
705
+ kind: 'add',
706
+ label: 'pooled@example.com',
707
+ replace: true,
708
+ })
709
+ })
710
+
711
+ it('plain "/auth add <label>" stays replace=false', () => {
712
+ expect(parseAuthCommand('/auth add fresh@example.com')).toEqual({
713
+ kind: 'add',
714
+ label: 'fresh@example.com',
715
+ replace: false,
716
+ })
717
+ })
718
+
719
+ it('"/auth readd" with no label is a help reply', () => {
720
+ const p = parseAuthCommand('/auth readd')
721
+ expect(p?.kind).toBe('help')
722
+ if (p?.kind === 'help') expect(p.reason).toMatch(/Usage: \/auth readd/)
723
+ })
724
+
725
+ it('rejects an unknown flag on add/readd', () => {
726
+ const p = parseAuthCommand('/auth add foo --wipe')
727
+ expect(p?.kind).toBe('help')
728
+ if (p?.kind === 'help') expect(p.reason).toMatch(/Unknown flag/i)
729
+ })
730
+
731
+ it('is case-insensitive on the readd verb', () => {
732
+ expect(parseAuthCommand('/auth READD foo')).toEqual({ kind: 'add', label: 'foo', replace: true })
733
+ })
734
+ })
735
+
736
+ describe('readdPrecheckError — exists gate', () => {
737
+ it('readd of a NONEXISTENT label errors clearly', () => {
738
+ const err = readdPrecheckError('ghost@example.com', true, false)
739
+ expect(err).toMatch(/no account named/i)
740
+ expect(err).toMatch(/ghost@example\.com/)
741
+ })
742
+
743
+ it('readd of an EXISTING label passes (null)', () => {
744
+ expect(readdPrecheckError('pooled@example.com', true, true)).toBeNull()
745
+ })
746
+
747
+ it('plain add of an EXISTING label errors and points at readd', () => {
748
+ const err = readdPrecheckError('pooled@example.com', false, true)
749
+ expect(err).toMatch(/already exists/i)
750
+ expect(err).toMatch(/\/auth readd/)
751
+ })
752
+
753
+ it('plain add of a NEW label passes (null)', () => {
754
+ expect(readdPrecheckError('fresh@example.com', false, false)).toBeNull()
755
+ })
756
+ })
757
+
758
+ describe('runReaddPrecheck — broker-backed wrapper (extracted from gateway.ts)', () => {
759
+ const clientWith = (labels: string[]) => ({
760
+ listState: async () => ({ accounts: labels.map((label) => ({ label })) }) as any,
761
+ })
762
+
763
+ it('readd of an EXISTING label queries the broker and passes (null)', async () => {
764
+ const err = await runReaddPrecheck(
765
+ async () => clientWith(['pooled@example.com']) as any,
766
+ 'pooled@example.com',
767
+ true,
768
+ )
769
+ expect(err).toBeNull()
770
+ })
771
+
772
+ it('readd of a NONEXISTENT label surfaces the precheck error', async () => {
773
+ const err = await runReaddPrecheck(
774
+ async () => clientWith([]) as any,
775
+ 'ghost@example.com',
776
+ true,
777
+ )
778
+ expect(err).toMatch(/no account named/i)
779
+ })
780
+
781
+ it('plain add of an EXISTING label surfaces the precheck error', async () => {
782
+ const err = await runReaddPrecheck(
783
+ async () => clientWith(['pooled@example.com']) as any,
784
+ 'pooled@example.com',
785
+ false,
786
+ )
787
+ expect(err).toMatch(/already exists/i)
788
+ })
789
+
790
+ it('best-effort: null client (broker unreachable) skips the precheck (null)', async () => {
791
+ const err = await runReaddPrecheck(async () => null, 'x@example.com', true)
792
+ expect(err).toBeNull()
793
+ })
794
+
795
+ it('best-effort: a throwing broker is swallowed and skips the precheck (null)', async () => {
796
+ const err = await runReaddPrecheck(
797
+ async () => ({ listState: async () => { throw new Error('broker down') } }) as any,
798
+ 'x@example.com',
799
+ false,
800
+ )
801
+ expect(err).toBeNull()
802
+ })
803
+ })
804
+
805
+ describe('formatGrantedScopesReply — scope-in-reply (structured, no scraping)', () => {
806
+ it('lists granted scopes and confirms when user:profile is present', () => {
807
+ const r = formatGrantedScopesReply(['org:create_api_key', 'user:profile', 'user:inference'])
808
+ expect(r.hasUsageScope).toBe(true)
809
+ expect(r.text).toContain('user:profile')
810
+ expect(r.text).toMatch(/unlocked/i)
811
+ })
812
+
813
+ it('warns LOUDLY when user:profile is absent (the setup-token footgun)', () => {
814
+ const r = formatGrantedScopesReply(['user:inference'])
815
+ expect(r.hasUsageScope).toBe(false)
816
+ expect(r.text).toMatch(/MISSING/)
817
+ expect(r.text).toContain(REQUIRED_USAGE_SCOPE)
818
+ })
819
+
820
+ it('warns when the token reports no scopes at all', () => {
821
+ const r = formatGrantedScopesReply(undefined)
822
+ expect(r.hasUsageScope).toBe(false)
823
+ expect(r.text).toMatch(/No scopes reported/i)
824
+ })
660
825
  })
661
826
 
662
827
  /* ── 10. Help text mentions add + cancel ─────────────────────────────── */
@@ -773,6 +938,9 @@ printf '{\\n "claudeAiOauth": {\\n "accessToken": "${fakeAccessToken}",\\n
773
938
  send(_socket, session, text) {
774
939
  return realOps.send(tmuxSocket, session, text)
775
940
  },
941
+ sendKey(_socket, session, key) {
942
+ return realOps.sendKey(tmuxSocket, session, key)
943
+ },
776
944
  hasSession(_socket, session) {
777
945
  return realOps.hasSession(tmuxSocket, session)
778
946
  },
@@ -816,6 +984,200 @@ printf '{\\n "claudeAiOauth": {\\n "accessToken": "${fakeAccessToken}",\\n
816
984
  }, 30_000)
817
985
  })
818
986
 
987
+ /* ── 12. via-claude broad-scope minter (PR B) ─────────────────────────── */
988
+
989
+ describe('startAccountAuthSession — via-claude mode (broad scope)', () => {
990
+ // The picker-rendered broad-scope authorize URL (org:create_api_key +
991
+ // user:profile + user:inference …). This is what `claude` (not setup-token)
992
+ // emits after "Claude account with subscription".
993
+ const BROAD_URL =
994
+ 'https://claude.com/cai/oauth/authorize?code=true&client_id=x&response_type=code' +
995
+ '&scope=org%3Acreate_api_key+user%3Aprofile+user%3Ainference+user%3Asessions%3Aclaude_code' +
996
+ '&code_challenge=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-z&state=s'
997
+
998
+ it('defaults to via-claude mode: spawns bare `claude` (NOT `claude setup-token`) and reports mode', async () => {
999
+ const mock = makeMockTmuxOps({ captureResponses: [BROAD_URL] })
1000
+ const result = await startAccountAuthSession('broad@example.com', {
1001
+ home: workspace,
1002
+ tmuxOps: mock,
1003
+ urlTimeoutMs: 3_000,
1004
+ })
1005
+ expect(result.mode).toBe('via-claude')
1006
+ // cmd must be the bare login picker, not setup-token (setup-token mints
1007
+ // only user:inference, which server: agents refuse).
1008
+ expect(mock.newSessionCalls[0].cmd).toBe('claude')
1009
+ expect(mock.newSessionCalls[0].cmd).not.toMatch(/setup-token/)
1010
+ cleanScratchDir(result.scratchDir)
1011
+ })
1012
+
1013
+ it('dispatches the pre-paste picker choreography (theme + login-method Enter) via sendKey before the URL', async () => {
1014
+ // Pane walks: theme picker → login-method picker → URL. Each fires one
1015
+ // Enter via sendKey (bare key, never the literal-code `send`).
1016
+ const mock = makeMockTmuxOps({
1017
+ captureResponses: [
1018
+ 'Choose the text style that looks best with your terminal',
1019
+ 'Select login method:\n 1. Claude account with subscription',
1020
+ BROAD_URL,
1021
+ ],
1022
+ })
1023
+ const result = await startAccountAuthSession('picker@example.com', {
1024
+ home: workspace,
1025
+ tmuxOps: mock,
1026
+ urlTimeoutMs: 5_000,
1027
+ })
1028
+ // Two pre-paste Enters dispatched via sendKey, both bare "Enter".
1029
+ expect(mock.sendKeyCalls.map((c) => c.key)).toEqual(['Enter', 'Enter'])
1030
+ // The literal-code `send` (which would echo a secret) is NOT used pre-paste.
1031
+ expect(mock.sendCalls).toHaveLength(0)
1032
+ expect(result.loginUrl).toMatch(/^https:\/\/claude\.com\/cai\/oauth\/authorize\?/)
1033
+ cleanScratchDir(result.scratchDir)
1034
+ })
1035
+
1036
+ it('surfaces both URL shapes the picker can render (claude.ai/ and claude.com/cai/)', async () => {
1037
+ for (const url of [
1038
+ 'https://claude.ai/oauth/authorize?client_id=x&code=true&scope=user%3Aprofile',
1039
+ BROAD_URL,
1040
+ ]) {
1041
+ const mock = makeMockTmuxOps({ captureResponses: [`\x1b[0m${url}\nPaste code here:\n`] })
1042
+ const result = await startAccountAuthSession('urlshape', {
1043
+ home: workspace,
1044
+ tmuxOps: mock,
1045
+ urlTimeoutMs: 3_000,
1046
+ })
1047
+ expect(result.loginUrl).toContain('/oauth/authorize?')
1048
+ expect(result.loginUrl).not.toMatch(/\s/) // line-wrap collapsed
1049
+ cleanScratchDir(result.scratchDir)
1050
+ }
1051
+ })
1052
+
1053
+ it('setup-token mode is still available and uses `claude setup-token` with no picker dispatch', async () => {
1054
+ const url =
1055
+ 'https://claude.com/cai/oauth/authorize?code=true&client_id=y&response_type=code&code_challenge=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-z'
1056
+ const mock = makeMockTmuxOps({ captureResponses: [url] })
1057
+ const result = await startAccountAuthSession('narrow@example.com', {
1058
+ home: workspace,
1059
+ tmuxOps: mock,
1060
+ urlTimeoutMs: 3_000,
1061
+ mode: 'setup-token',
1062
+ })
1063
+ expect(result.mode).toBe('setup-token')
1064
+ expect(mock.newSessionCalls[0].cmd).toBe('claude setup-token')
1065
+ expect(mock.sendKeyCalls).toHaveLength(0) // no picker choreography
1066
+ cleanScratchDir(result.scratchDir)
1067
+ })
1068
+ })
1069
+
1070
+ describe('submitAccountAuthCode — via-claude broad-scope + blind post-paste Enter (PR B)', () => {
1071
+ function makeFlow(scratchDir: string, mode: 'via-claude' | 'setup-token'): PendingAuthAddFlow {
1072
+ return {
1073
+ label: 'broad@example.com',
1074
+ scratchDir,
1075
+ tmuxSocket: 'switchroom-test',
1076
+ tmuxSession: 'auth-add-broad-abc123',
1077
+ startedAt: Date.now(),
1078
+ mode,
1079
+ }
1080
+ }
1081
+
1082
+ it('returns the minted broad scopes (user:profile present) and NEVER captures after the code', async () => {
1083
+ const scratchDir = mkdtempSync(join(workspace, 'broad-'))
1084
+ const credPath = join(scratchDir, '.credentials.json')
1085
+ const broadCreds = JSON.stringify({
1086
+ claudeAiOauth: {
1087
+ accessToken: 'sk-ant-oat01-test-' + 'e'.repeat(40),
1088
+ refreshToken: 'sk-ant-ort01-test',
1089
+ expiresAt: Date.now() + 8 * 3600_000,
1090
+ scopes: [
1091
+ 'org:create_api_key',
1092
+ 'user:profile',
1093
+ 'user:inference',
1094
+ 'user:sessions:claude_code',
1095
+ 'user:mcp_servers',
1096
+ 'user:file_upload',
1097
+ ],
1098
+ subscriptionType: 'max',
1099
+ },
1100
+ })
1101
+ const mock = makeMockTmuxOps({ initialSessionAlive: true })
1102
+ let sendCalled = false
1103
+ let captureAfterSend = false
1104
+ mock.onSend = () => {
1105
+ sendCalled = true
1106
+ writeFileSync(credPath, broadCreds, 'utf8')
1107
+ }
1108
+ mock.onCapture = () => { if (sendCalled) captureAfterSend = true }
1109
+
1110
+ const creds = await submitAccountAuthCode(makeFlow(scratchDir, 'via-claude'), 'browser-code-xyz', {
1111
+ pollIntervalMs: 20,
1112
+ pollTimeoutMs: 3_000,
1113
+ tmuxOps: mock,
1114
+ })
1115
+
1116
+ // The actual mission outcome: user:profile made it through.
1117
+ expect(creds.claudeAiOauth.scopes).toContain('user:profile')
1118
+ expect(creds.claudeAiOauth.scopes).toEqual(
1119
+ expect.arrayContaining(['org:create_api_key', 'user:profile', 'user:inference']),
1120
+ )
1121
+ // Invariant preserved: no capture-pane after the code paste.
1122
+ expect(captureAfterSend).toBe(false)
1123
+ expect(mock.captureCallCount).toBe(0)
1124
+ })
1125
+
1126
+ it('dispatches BLIND Enter key-presses after the code (no capture) to clear picker screens', async () => {
1127
+ const scratchDir = mkdtempSync(join(workspace, 'blind-'))
1128
+ const credPath = join(scratchDir, '.credentials.json')
1129
+ const mock = makeMockTmuxOps({ initialSessionAlive: true })
1130
+ // Materialise creds only AFTER the blind Enters have had a chance to fire
1131
+ // (mimics a picker screen Enter-gating the credentials flush).
1132
+ let ticks = 0
1133
+ mock.onSend = () => {
1134
+ // schedule cred write a few polls later via the poll loop below
1135
+ ticks = 0
1136
+ }
1137
+ // Use a tight blind-enter schedule so the test is fast.
1138
+ const submitP = submitAccountAuthCode(makeFlow(scratchDir, 'via-claude'), 'code-abc', {
1139
+ pollIntervalMs: 10,
1140
+ pollTimeoutMs: 3_000,
1141
+ tmuxOps: mock,
1142
+ blindEnterDelaysMs: [15, 30],
1143
+ })
1144
+ // Write creds after the blind Enters would have fired.
1145
+ const timer = setInterval(() => {
1146
+ ticks++
1147
+ if (ticks >= 6) {
1148
+ writeFileSync(credPath, JSON.stringify({
1149
+ claudeAiOauth: { accessToken: 'sk-ant-oat01-test-' + 'f'.repeat(40), scopes: ['user:profile'] },
1150
+ }), 'utf8')
1151
+ clearInterval(timer)
1152
+ }
1153
+ }, 10)
1154
+ const creds = await submitP
1155
+ clearInterval(timer)
1156
+ // Blind Enters were dispatched via sendKey, and capture-pane never was.
1157
+ expect(mock.sendKeyCalls.every((c) => c.key === 'Enter')).toBe(true)
1158
+ expect(mock.sendKeyCalls.length).toBeGreaterThanOrEqual(1)
1159
+ expect(mock.captureCallCount).toBe(0)
1160
+ expect(creds.claudeAiOauth.scopes).toContain('user:profile')
1161
+ })
1162
+
1163
+ it('setup-token mode dispatches NO blind Enters (session exits on its own)', async () => {
1164
+ const scratchDir = mkdtempSync(join(workspace, 'st-'))
1165
+ const credPath = join(scratchDir, '.credentials.json')
1166
+ const mock = makeMockTmuxOps({ initialSessionAlive: true })
1167
+ mock.onSend = () => {
1168
+ writeFileSync(credPath, JSON.stringify({
1169
+ claudeAiOauth: { accessToken: 'sk-ant-oat01-test-' + 'g'.repeat(40), scopes: ['user:inference'] },
1170
+ }), 'utf8')
1171
+ }
1172
+ await submitAccountAuthCode(makeFlow(scratchDir, 'setup-token'), 'code', {
1173
+ pollIntervalMs: 20,
1174
+ pollTimeoutMs: 2_000,
1175
+ tmuxOps: mock,
1176
+ })
1177
+ expect(mock.sendKeyCalls).toHaveLength(0)
1178
+ })
1179
+ })
1180
+
819
1181
  /* ── helpers ─────────────────────────────────────────────────────────── */
820
1182
 
821
1183
  function randomHex(): string {
@@ -1199,3 +1199,44 @@ describe('#2494 — renderAuthSnapshotFormat2 row rendering (out_of_credits demo
1199
1199
  expect(out).not.toContain('All accounts blocked');
1200
1200
  });
1201
1201
  });
1202
+
1203
+ // ── retired / org-blocked (in-service classification, PR1) ────────────
1204
+ describe('classifyHealth — retired / org-blocked precedence', () => {
1205
+ // A retired/org-disabled account must NEVER read as available: config and
1206
+ // entitlement outrank the quota windows for any non-active account.
1207
+ const healthyQ = quota({ fiveHourUtilizationPct: 5, sevenDayUtilizationPct: 10 });
1208
+
1209
+ it('non-active account with inService:false → retired, even on healthy quota', () => {
1210
+ expect(classifyHealth(snap({ inService: false, quota: healthyQ }))).toBe('retired');
1211
+ });
1212
+ it('non-active account with entitlementBlocked → org-blocked', () => {
1213
+ expect(classifyHealth(snap({ entitlementBlocked: true, quota: healthyQ }))).toBe('org-blocked');
1214
+ });
1215
+ it('entitlement block outranks retirement', () => {
1216
+ expect(
1217
+ classifyHealth(snap({ inService: false, entitlementBlocked: true, quota: healthyQ })),
1218
+ ).toBe('org-blocked');
1219
+ });
1220
+ it('absent inService defaults to in-service (a pre-field broker is never falsely retired)', () => {
1221
+ expect(classifyHealth(snap({ quota: healthyQ }))).toBe('healthy');
1222
+ });
1223
+ it('the active account keeps its quota-derived health even if inService is false', () => {
1224
+ // active is in-service by definition; the guard skips retirement for it.
1225
+ expect(classifyHealth(snap({ isActive: true, inService: false, quota: healthyQ }))).toBe('healthy');
1226
+ });
1227
+ });
1228
+
1229
+ describe('recommendation — excludes retired/org-blocked accounts', () => {
1230
+ it('a retired account (even with healthy quota) is never offered as a switch target', () => {
1231
+ // active is throttling; the only OTHER account is retired but at 0% util.
1232
+ // Without the exclusion it would be recommended as a healthy switch target;
1233
+ // with it, the retired account is invisible → "no healthy alternative".
1234
+ const snaps: AccountSnapshot[] = [
1235
+ snap({ label: 'a@x', isActive: true, quota: quota({ fiveHourUtilizationPct: 90 }) }),
1236
+ snap({ label: 'retired@x', inService: false, quota: quota({ fiveHourUtilizationPct: 0 }) }),
1237
+ ];
1238
+ const out = recommendation(snaps, NOW);
1239
+ expect(out).not.toContain('retired@x');
1240
+ expect(out).toContain('no healthy alternative');
1241
+ });
1242
+ });
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Unit tests for External OpenRouter/$ spend (layout B on /usage).
3
+ * Pure filter/format coverage lives primarily in
4
+ * `src/litellm/external-spend.test.ts`. This file covers the card
5
+ * block formatter + broker-backed fetch ExternalSpendSummary path.
6
+ */
7
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
8
+ import {
9
+ formatExternalSpendBlock,
10
+ fetchExternalSpendSummary,
11
+ clearExternalSpendCache,
12
+ formatUsd,
13
+ isExternalModel,
14
+ shortModelLabel,
15
+ summarizeExternalSpend,
16
+ } from '../external-spend.js';
17
+ import { renderUsageCard } from '../quota-bar-format.js';
18
+ import type { AccountSnapshot } from '../auth-snapshot-format.js';
19
+ import type { QuotaUtilization } from '../quota-check.js';
20
+
21
+ const NOW = new Date('2026-07-19T15:30:00.000Z');
22
+
23
+ beforeEach(() => {
24
+ clearExternalSpendCache();
25
+ });
26
+
27
+ describe('re-exported pure helpers', () => {
28
+ it('filter / labels / money still work from the telegram module', () => {
29
+ expect(isExternalModel('openrouter/openai/gpt-oss-20b')).toBe(true);
30
+ expect(isExternalModel('claude-sonnet-5')).toBe(false);
31
+ expect(shortModelLabel('openrouter/x-ai/grok-4.5')).toBe('grok-4.5');
32
+ expect(formatUsd(8.07)).toBe('$8.07');
33
+ const s = summarizeExternalSpend(
34
+ [
35
+ {
36
+ startTime: '2026-07-19',
37
+ models: { 'openrouter/openai/gpt-oss-20b': 1.5, 'claude-sonnet-5': 9 },
38
+ },
39
+ ],
40
+ NOW,
41
+ );
42
+ expect(s.day24hUsd).toBeCloseTo(1.5, 5);
43
+ });
44
+ });
45
+
46
+ describe('formatExternalSpendBlock', () => {
47
+ it('renders locked layout B', () => {
48
+ expect(
49
+ formatExternalSpendBlock({
50
+ day24hUsd: 8.07,
51
+ day7dUsd: 119.89,
52
+ top: [
53
+ { label: 'gpt-oss-20b', usd: 6.1 },
54
+ { label: 'grok-4.5', usd: 1.18 },
55
+ { label: 'gemini-3.1-flash-lite', usd: 0.79 },
56
+ ],
57
+ }),
58
+ ).toEqual([
59
+ '- 💸 External',
60
+ '- 24h `$8.07` · 7d `$119.89`',
61
+ '- top `gpt-oss-20b $6.10` · `grok-4.5 $1.18` · `gemini-3.1-flash-lite $0.79`',
62
+ ]);
63
+ });
64
+
65
+ it('omits top and returns [] for null', () => {
66
+ expect(formatExternalSpendBlock({ day24hUsd: 0, day7dUsd: 0, top: [] })).toEqual([
67
+ '- 💸 External',
68
+ '- 24h `$0.00` · 7d `$0.00`',
69
+ ]);
70
+ expect(formatExternalSpendBlock(null)).toEqual([]);
71
+ });
72
+ });
73
+
74
+ describe('fetchExternalSpendSummary (broker path)', () => {
75
+ it('returns null when broker says unavailable', async () => {
76
+ const s = await fetchExternalSpendSummary({
77
+ getExternalSpend: async () => ({ available: false, reason: 'master_key_unavailable' }),
78
+ bypassCache: true,
79
+ });
80
+ expect(s).toBeNull();
81
+ });
82
+
83
+ it('maps available broker payload + caches', async () => {
84
+ let calls = 0;
85
+ const getExternalSpend = vi.fn(async () => {
86
+ calls += 1;
87
+ return {
88
+ available: true,
89
+ day24hUsd: 1.5,
90
+ day7dUsd: 10,
91
+ top: [{ label: 'gpt-oss-20b', usd: 1.5 }],
92
+ served: 'live' as const,
93
+ capturedAtMs: Date.now(),
94
+ };
95
+ });
96
+ const first = await fetchExternalSpendSummary({
97
+ getExternalSpend,
98
+ bypassCache: true,
99
+ });
100
+ expect(first).toEqual({
101
+ day24hUsd: 1.5,
102
+ day7dUsd: 10,
103
+ top: [{ label: 'gpt-oss-20b', usd: 1.5 }],
104
+ });
105
+ const second = await fetchExternalSpendSummary({ getExternalSpend });
106
+ expect(second?.day24hUsd).toBeCloseTo(1.5, 5);
107
+ expect(calls).toBe(1);
108
+ });
109
+
110
+ it('returns null on thrown broker error', async () => {
111
+ const s = await fetchExternalSpendSummary({
112
+ getExternalSpend: async () => {
113
+ throw new Error('unreachable');
114
+ },
115
+ bypassCache: true,
116
+ });
117
+ expect(s).toBeNull();
118
+ });
119
+ });
120
+
121
+ describe('renderUsageCard + externalSpend', () => {
122
+ function quota(part: Partial<QuotaUtilization>): QuotaUtilization {
123
+ return {
124
+ fiveHourUtilizationPct: 0,
125
+ sevenDayUtilizationPct: 0,
126
+ fiveHourResetAt: null,
127
+ sevenDayResetAt: null,
128
+ representativeClaim: null,
129
+ overageStatus: null,
130
+ overageDisabledReason: null,
131
+ ...part,
132
+ };
133
+ }
134
+ const snapshots: AccountSnapshot[] = [
135
+ {
136
+ label: 'ken@example.com',
137
+ isActive: true,
138
+ quota: quota({
139
+ fiveHourUtilizationPct: 0,
140
+ sevenDayUtilizationPct: 47,
141
+ fiveHourResetAt: new Date(NOW.getTime() + 60 * 60_000),
142
+ sevenDayResetAt: new Date(NOW.getTime() + (3 * 24 + 1) * 60 * 60_000),
143
+ }),
144
+ },
145
+ ];
146
+ const exhausted = new Map<string, boolean>([['ken@example.com', false]]);
147
+
148
+ it('inserts External between recommendation and freshness', () => {
149
+ const out = renderUsageCard(snapshots, exhausted, {
150
+ now: NOW,
151
+ externalSpend: {
152
+ day24hUsd: 8.07,
153
+ day7dUsd: 119.89,
154
+ top: [{ label: 'gpt-oss-20b', usd: 6.1 }],
155
+ },
156
+ });
157
+ const lines = out.split('\n');
158
+ const recIdx = lines.findIndex((l) => l.includes('Recommendation'));
159
+ const extIdx = lines.findIndex((l) => l.includes('💸 External'));
160
+ expect(extIdx).toBe(recIdx + 1);
161
+ expect(lines[lines.length - 1]).toBe('_Live_');
162
+ });
163
+
164
+ it('omits External when null', () => {
165
+ const out = renderUsageCard(snapshots, exhausted, { now: NOW, externalSpend: null });
166
+ expect(out).not.toContain('💸 External');
167
+ });
168
+ });