switchroom 0.19.2 → 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 (60) hide show
  1. package/dist/agent-scheduler/index.js +2 -0
  2. package/dist/auth-broker/index.js +13 -0
  3. package/dist/cli/autoaccept-poll.js +2 -0
  4. package/dist/cli/drive-write-pretool.mjs +2 -0
  5. package/dist/cli/ms-365-write-pretool.mjs +2 -0
  6. package/dist/cli/switchroom.js +404 -245
  7. package/dist/host-control/main.js +1 -1
  8. package/package.json +1 -1
  9. package/profiles/default/CLAUDE.md.hbs +8 -0
  10. package/skills/mental-model-curator/SKILL.md +68 -2
  11. package/telegram-plugin/auth-snapshot-format.ts +104 -12
  12. package/telegram-plugin/dist/bridge/bridge.js +8 -2
  13. package/telegram-plugin/dist/gateway/gateway.js +1194 -794
  14. package/telegram-plugin/dist/server.js +8 -2
  15. package/telegram-plugin/flushed-turn-supersede.ts +117 -13
  16. package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
  17. package/telegram-plugin/gateway/auth-command.ts +138 -5
  18. package/telegram-plugin/gateway/gateway.ts +68 -101
  19. package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
  20. package/telegram-plugin/gateway/model-command.ts +203 -1
  21. package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
  22. package/telegram-plugin/gateway/session-model-source.ts +90 -10
  23. package/telegram-plugin/gateway/stream-render.ts +22 -5
  24. package/telegram-plugin/quota-bar-format.ts +60 -12
  25. package/telegram-plugin/reply-owner-resolve.ts +76 -11
  26. package/telegram-plugin/session-tail.ts +27 -3
  27. package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
  28. package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
  29. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
  30. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
  31. package/telegram-plugin/tests/model-command.test.ts +220 -0
  32. package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
  33. package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
  34. package/telegram-plugin/tests/session-model-source.test.ts +142 -0
  35. package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
  36. package/vendor/hindsight-memory/CHANGELOG.md +102 -0
  37. package/vendor/hindsight-memory/README.md +2 -1
  38. package/vendor/hindsight-memory/hooks/hooks.json +12 -0
  39. package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
  40. package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
  41. package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
  42. package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
  43. package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
  44. package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
  45. package/vendor/hindsight-memory/scripts/recall.py +789 -143
  46. package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
  47. package/vendor/hindsight-memory/scripts/retain.py +71 -2
  48. package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
  49. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
  51. package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
  52. package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
  54. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
  55. package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
  56. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
  57. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
  58. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
  59. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
  60. 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
+ });
@@ -21,8 +21,10 @@ import { describe, it, expect } from 'vitest'
21
21
  import {
22
22
  decideSupersede,
23
23
  decideSupersedeCorrection,
24
+ flushedAnswerMatchesReply,
24
25
  FlushedTurnSupersedeRegistry,
25
26
  DEFAULT_SUPERSEDE_TTL_MS,
27
+ SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS,
26
28
  type FlushedTurnRecord,
27
29
  } from '../flushed-turn-supersede.js'
28
30
 
@@ -264,3 +266,118 @@ describe('FlushedTurnSupersedeRegistry — record / peek / take lifecycle', () =
264
266
  expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-A', now: 5000 }).reason).toBe('no-record')
265
267
  })
266
268
  })
269
+
270
+ /**
271
+ * #3429 — async handback edit-in-place supersede of a flush-delivered message
272
+ * can fail to surface client-side.
273
+ *
274
+ * The flush ends its turn synchronously BEFORE recording, so EVERY superseding
275
+ * reply is a late reply — and an async sub-agent handback landing within the
276
+ * 60 s TTL with no live gateway turn resolves the flush-delivered ENDED turn
277
+ * as its owner via the latest-ended tier, the SAME identity as the turn's own
278
+ * canonical late replay. Identity-only supersede then consumed the record and
279
+ * EDITED the flushed message in place with the handback's unrelated content
280
+ * (msgs 10482/10486, 2026-07-20) — Telegram edits never push-notify, so the
281
+ * handback silently failed to surface AND the flushed answer was destroyed.
282
+ *
283
+ * The content gate: `replyText` is compared against the record's flushed text
284
+ * (`flushedAnswerMatchesReply` — whitespace-normalized equality, or containment
285
+ * with a minimum-length guard on the contained side). Same answer → supersede
286
+ * (the wanted correction); different content → 'new-content', send fresh,
287
+ * record NOT consumed.
288
+ */
289
+ describe('#3429 — flushedAnswerMatchesReply (content discriminator)', () => {
290
+ const ANSWER = 'The deploy is green: all 12 services rolled out and health checks pass.'
291
+ const FLUSH_BLOB = `Let me check the rollout status.\n\n${ANSWER}`
292
+
293
+ it('matches whitespace-normalized equality', () => {
294
+ expect(flushedAnswerMatchesReply(ANSWER, ANSWER)).toBe(true)
295
+ expect(flushedAnswerMatchesReply(`${ANSWER}\n`, ANSWER.replace(': ', ': '))).toBe(true)
296
+ })
297
+
298
+ it('matches the classic containment class: flush = narration+answer ⊇ clean reply', () => {
299
+ expect(flushedAnswerMatchesReply(FLUSH_BLOB, ANSWER)).toBe(true)
300
+ })
301
+
302
+ it('matches reverse containment: reply ⊇ partially-delivered flush text', () => {
303
+ expect(flushedAnswerMatchesReply(ANSWER, FLUSH_BLOB)).toBe(true)
304
+ })
305
+
306
+ it('does NOT match genuinely different content (the handback)', () => {
307
+ const handback =
308
+ 'Worker finished: PR #3430 is up with the fix for the vault broker timeout, ' +
309
+ 'tests are green, ready for your review.'
310
+ expect(flushedAnswerMatchesReply(FLUSH_BLOB, handback)).toBe(false)
311
+ })
312
+
313
+ it('short containment below the minimum-length guard does NOT match ' +
314
+ '(a coincidental substring must never claim a handback)', () => {
315
+ const shortReply = 'rolled out'
316
+ expect(shortReply.length).toBeLessThan(SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS)
317
+ expect(FLUSH_BLOB.includes(shortReply)).toBe(true)
318
+ expect(flushedAnswerMatchesReply(FLUSH_BLOB, shortReply)).toBe(false)
319
+ })
320
+
321
+ it('short EQUAL texts still match (equality has no length floor)', () => {
322
+ expect(flushedAnswerMatchesReply('yes, done', 'yes, done')).toBe(true)
323
+ })
324
+ })
325
+
326
+ describe('#3429 — decideSupersede new-content gate', () => {
327
+ const FLUSHED = 'Narration first.\n\nHere is the finished summary of the incident you asked about.'
328
+ const HANDBACK =
329
+ 'Sub-agent handback: the researcher finished and found three root causes, ' +
330
+ 'written up in the report at /tmp/report.md — want the highlights?'
331
+
332
+ it('same-turn same-answer late reply still supersedes (the wanted correction)', () => {
333
+ const d = decideSupersede(rec({ text: FLUSHED }), {
334
+ liveTurnId: 'turn-A',
335
+ replyText: 'Here is the finished summary of the incident you asked about.',
336
+ now: 1_000_010,
337
+ })
338
+ expect(d.supersede).toBe(true)
339
+ expect(d.reason).toBe('supersede')
340
+ expect(d.recordText).toBe(FLUSHED)
341
+ })
342
+
343
+ it('CORE #3429: same turn identity + DIFFERENT content → new-content, NO supersede', () => {
344
+ const d = decideSupersede(rec({ text: FLUSHED }), {
345
+ liveTurnId: 'turn-A',
346
+ replyText: HANDBACK,
347
+ now: 1_000_010,
348
+ })
349
+ expect(d.supersede).toBe(false)
350
+ expect(d.reason).toBe('new-content')
351
+ expect(d.deleteMessageIds).toEqual([])
352
+ expect(d.recordText).toBe(FLUSHED)
353
+ })
354
+
355
+ it('legacy identity-only callers (no replyText) keep the pre-#3429 behaviour', () => {
356
+ const d = decideSupersede(rec({ text: FLUSHED }), { liveTurnId: 'turn-A', now: 1_000_010 })
357
+ expect(d.supersede).toBe(true)
358
+ })
359
+
360
+ it('take() does NOT consume the record on new-content — the genuine replay ' +
361
+ 'can still correct the flushed message afterwards', () => {
362
+ const reg = new FlushedTurnSupersedeRegistry()
363
+ const now = 1_000_000
364
+ reg.record('chat9', undefined, { turnId: 'turn-F', messageIds: [7001], text: FLUSHED }, now)
365
+
366
+ // The handback lands first: new-content, nothing consumed, nothing deleted.
367
+ const d1 = reg.take('chat9', undefined, { liveTurnId: 'turn-F', replyText: HANDBACK, now: now + 10_000 })
368
+ expect(d1.supersede).toBe(false)
369
+ expect(d1.reason).toBe('new-content')
370
+
371
+ // The turn's own canonical replay lands later: record still there, supersede
372
+ // fires and consumes it.
373
+ const d2 = reg.take('chat9', undefined, {
374
+ liveTurnId: 'turn-F',
375
+ replyText: 'Here is the finished summary of the incident you asked about.',
376
+ now: now + 20_000,
377
+ })
378
+ expect(d2.supersede).toBe(true)
379
+ expect(d2.deleteMessageIds).toEqual([7001])
380
+ // Now consumed.
381
+ expect(reg.peek('chat9', undefined, { liveTurnId: 'turn-F', now: now + 21_000 }).reason).toBe('no-record')
382
+ })
383
+ })