switchroom 0.19.2 → 0.19.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/agent-scheduler/index.js +2 -0
  2. package/dist/auth-broker/index.js +109 -7
  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
@@ -17792,6 +17792,7 @@ function startSessionTail(config2) {
17792
17792
  let stopped = false;
17793
17793
  let pendingPartial = "";
17794
17794
  const fileCursors = new Map;
17795
+ const replayUntilByFile = new Map;
17795
17796
  function readNew() {
17796
17797
  if (stopped || !currentFile)
17797
17798
  return;
@@ -17800,11 +17801,15 @@ function startSessionTail(config2) {
17800
17801
  if (stat.size < cursor) {
17801
17802
  cursor = 0;
17802
17803
  pendingPartial = "";
17803
- if (currentFile != null)
17804
+ if (currentFile != null) {
17804
17805
  fileCursors.delete(currentFile);
17806
+ replayUntilByFile.delete(currentFile);
17807
+ }
17805
17808
  }
17806
17809
  if (stat.size === cursor)
17807
17810
  return;
17811
+ const chunkStart = cursor;
17812
+ const isReplayChunk = chunkStart < (replayUntilByFile.get(currentFile) ?? 0);
17808
17813
  const buf = Buffer.alloc(stat.size - cursor);
17809
17814
  const fd = openSync(currentFile, "r");
17810
17815
  try {
@@ -17824,7 +17829,7 @@ function startSessionTail(config2) {
17824
17829
  const sid = sessionIdForFile(currentFile);
17825
17830
  for (const ev of events) {
17826
17831
  try {
17827
- onEvent(decorate(ev, sid));
17832
+ onEvent(decorate(isReplayChunk && ev.kind === "model" ? { ...ev, replayed: true } : ev, sid));
17828
17833
  } catch (err) {
17829
17834
  log?.(`session-tail: onEvent threw: ${err.message}`);
17830
17835
  }
@@ -17877,6 +17882,7 @@ function startSessionTail(config2) {
17877
17882
  const size = statSync4(file).size;
17878
17883
  cursor = computeFirstAttachCursor(file, size);
17879
17884
  if (cursor < size) {
17885
+ replayUntilByFile.set(file, size);
17880
17886
  log?.(`session-tail: attached to ${file} (cursor=${cursor}, replaying in-flight turn from offset; size=${size})`);
17881
17887
  } else {
17882
17888
  log?.(`session-tail: attached to ${file} (cursor=${cursor})`);
@@ -31,7 +31,16 @@
31
31
  * `record()` (a much smaller window), `take` finds no record and the duplicate
32
32
  * can still slip through. We deliberately trade that residual window for the
33
33
  * safety guarantee that we NEVER delete a message we cannot positively attribute
34
- * to the reply's own turn (identity-only supersede — see `decideSupersede`).
34
+ * to the reply's own turn (identity-scoped supersede — see `decideSupersede`).
35
+ *
36
+ * #3429 content gate: identity is NECESSARY but not sufficient. Because the
37
+ * flush ends its turn before recording, an async sub-agent handback landing
38
+ * within the TTL resolves the flush-delivered ENDED turn as its owner too —
39
+ * same identity as the turn's own late replay. When the caller supplies the
40
+ * landing reply's text, `decideSupersede` additionally requires it to BE the
41
+ * flushed answer (`flushedAnswerMatchesReply`); otherwise the decision is
42
+ * 'new-content' and the gateway sends fresh (a notifying new message) with the
43
+ * record left intact.
35
44
  *
36
45
  * Pure module: no I/O, no globals, no clock reads beyond the caller-supplied
37
46
  * `now`. Fully unit-testable; the gateway wires the actual delete/send.
@@ -51,9 +60,11 @@ export interface FlushedTurnRecord {
51
60
  /** The Telegram message id(s) the flush posted (edit target + any extra
52
61
  * chunk messages). Superseding deletes all of them. */
53
62
  messageIds: number[]
54
- /** The text the flush delivered retained for diagnostics/logging only. The
55
- * supersede decision NEVER compares text (that is the whole point: it fires
56
- * even when the flushed text differs from the reply text). */
63
+ /** The text the flush delivered. Originally diagnostics-only; since #3429 it
64
+ * also feeds the new-content gate (`flushedAnswerMatchesReply`): an
65
+ * identity-matched reply that is NOT the same answer (neither equal nor a
66
+ * bounded containment) is an async handback and must send fresh instead of
67
+ * editing/deleting the flushed message. */
57
68
  text: string
58
69
  /** Wall-clock ms when recorded. */
59
70
  ts: number
@@ -71,8 +82,67 @@ export interface SupersedeDecision {
71
82
  /** Message ids the gateway must delete before/instead of the fresh send.
72
83
  * Empty when `supersede` is false. */
73
84
  deleteMessageIds: number[]
74
- /** Machine-readable reason (for logs / tests). */
75
- reason: 'supersede' | 'no-record' | 'expired' | 'different-turn'
85
+ /** Machine-readable reason (for logs / tests). `'new-content'` (#3429): the
86
+ * record matched by identity + TTL but the landing reply carries genuinely
87
+ * DIFFERENT content than the flushed answer — an async handback attributed
88
+ * to the flush-delivered ended turn, NOT that turn's own answer landing
89
+ * late. The gateway must send FRESH (a notifying new message), never
90
+ * edit/delete the flushed message; the record is NOT consumed. */
91
+ reason: 'supersede' | 'no-record' | 'expired' | 'different-turn' | 'new-content'
92
+ /** The matched record's flushed text — populated whenever a fresh record for
93
+ * the reply's turn identity was found (`reason` 'supersede' or
94
+ * 'new-content'). The gateway stashes it on the owner turn atom so the
95
+ * answer-delivered latch can make the same content-vs-flush discrimination
96
+ * on the no-record retry/race paths (#3429). */
97
+ recordText?: string
98
+ }
99
+
100
+ /**
101
+ * #3429 — minimum length a CONTAINED text must have for a containment match.
102
+ * The legitimate containment class is a flush that delivered
103
+ * `narration\n\nanswer` being corrected by the clean `answer`-only reply — the
104
+ * contained side is a full answer, comfortably long. A trivially short
105
+ * contained string (e.g. a reply "Done." that happens to appear inside the
106
+ * flushed blob) is NOT positive evidence of the same answer, and a false match
107
+ * here silently edits/suppresses genuinely new content — the exact #3429
108
+ * failure. Below this floor only whitespace-normalized EQUALITY matches; the
109
+ * worst case of declining is a rare duplicate message, which beats a silent
110
+ * drop (the #3426/#3428 precedent).
111
+ */
112
+ export const SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS = 32
113
+
114
+ /** Collapse whitespace runs so flush-pipeline vs reply-pipeline spacing
115
+ * (paragraph spacers, hard-break promotion) never defeats the comparison. */
116
+ function normalizeForMatch(text: string): string {
117
+ return text.replace(/\s+/g, ' ').trim()
118
+ }
119
+
120
+ /**
121
+ * #3429 — is the landing reply the SAME ANSWER the flush already delivered
122
+ * (a late canonical correction), as opposed to genuinely new content (an async
123
+ * handback that merely resolved the flush-delivered ended turn as its owner)?
124
+ *
125
+ * Deterministic string decision, no timing:
126
+ * - whitespace-normalized equality → same answer;
127
+ * - containment either direction (flush = `narration\n\nanswer` ⊇ reply, or
128
+ * reply ⊇ a partially-delivered flush), guarded by
129
+ * `SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS` on the CONTAINED side so a short
130
+ * coincidental substring can never claim a match.
131
+ *
132
+ * A model-REGENERATED paraphrase of the same answer is indistinguishable from
133
+ * new content and therefore sends fresh — a rare duplicate message, the same
134
+ * conscious trade #3428 shipped for the reply-armed latch. A silent
135
+ * drop/edit-in-place of a genuinely new handback is the strictly worse
136
+ * failure.
137
+ */
138
+ export function flushedAnswerMatchesReply(flushedText: string, replyText: string): boolean {
139
+ const flushed = normalizeForMatch(flushedText)
140
+ const reply = normalizeForMatch(replyText)
141
+ if (flushed.length === 0 || reply.length === 0) return false
142
+ if (flushed === reply) return true
143
+ if (reply.length >= SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS && flushed.includes(reply)) return true
144
+ if (flushed.length >= SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS && reply.includes(flushed)) return true
145
+ return false
76
146
  }
77
147
 
78
148
  /**
@@ -93,10 +163,25 @@ export interface SupersedeDecision {
93
163
  * now resolves a last-known turnId for the reply before calling in, so the
94
164
  * common late-replay case still matches by identity rather than relying on the
95
165
  * promiscuous null branch.)
166
+ *
167
+ * #3429 content gate: identity alone is NOT sufficient. The flush ends its
168
+ * turn synchronously BEFORE recording (stream-render `endCurrentTurnAtomic` →
169
+ * `record`), so EVERY superseding reply is a late reply, and an async
170
+ * sub-agent handback landing within the TTL with no live gateway turn resolves
171
+ * the flush-delivered ENDED turn as its owner via the latest-ended tier —
172
+ * exactly the same identity as the turn's own canonical late replay. The
173
+ * observed failure (msgs 10482/10486, 2026-07-20): the handback consumed the
174
+ * record and EDITED the flushed message in place with unrelated new content —
175
+ * Telegram edits do not re-notify, so the handback never surfaced client-side
176
+ * AND the flushed answer was destroyed. When the caller supplies `replyText`
177
+ * and it is NOT the same answer (`flushedAnswerMatchesReply`), the decision is
178
+ * `'new-content'`: no supersede, record left intact for the genuine replay,
179
+ * and the gateway sends the reply FRESH. Callers that omit `replyText`
180
+ * (identity-only legacy shape) keep the pre-#3429 behaviour.
96
181
  */
97
182
  export function decideSupersede(
98
183
  record: FlushedTurnRecord | undefined,
99
- args: { liveTurnId: string | null; now: number; ttlMs?: number },
184
+ args: { liveTurnId: string | null; replyText?: string | null; now: number; ttlMs?: number },
100
185
  ): SupersedeDecision {
101
186
  const ttlMs = args.ttlMs ?? DEFAULT_SUPERSEDE_TTL_MS
102
187
  if (record == null) return { supersede: false, deleteMessageIds: [], reason: 'no-record' }
@@ -110,7 +195,18 @@ export function decideSupersede(
110
195
  if (!sameTurn) {
111
196
  return { supersede: false, deleteMessageIds: [], reason: 'different-turn' }
112
197
  }
113
- return { supersede: true, deleteMessageIds: [...record.messageIds], reason: 'supersede' }
198
+ // #3429 same turn identity, but genuinely different content: an async
199
+ // handback attributed to the flush-delivered ended turn, not the turn's own
200
+ // answer landing late. Never edit/delete the flushed message for it.
201
+ if (args.replyText != null && !flushedAnswerMatchesReply(record.text, args.replyText)) {
202
+ return { supersede: false, deleteMessageIds: [], reason: 'new-content', recordText: record.text }
203
+ }
204
+ return {
205
+ supersede: true,
206
+ deleteMessageIds: [...record.messageIds],
207
+ reason: 'supersede',
208
+ recordText: record.text,
209
+ }
114
210
  }
115
211
 
116
212
  /**
@@ -225,23 +321,31 @@ export class FlushedTurnSupersedeRegistry {
225
321
 
226
322
  /** Decide supersede for a landing reply WITHOUT consuming the record. Selects
227
323
  * the record whose turnId matches the reply's resolved `liveTurnId` (or the
228
- * null-turnId record when `liveTurnId == null`). */
324
+ * null-turnId record when `liveTurnId == null`). `replyText` (#3429) enables
325
+ * the new-content gate; omitting it keeps the identity-only legacy shape. */
229
326
  peek(
230
327
  chatId: string,
231
328
  threadId: number | undefined,
232
- args: { liveTurnId: string | null; now: number },
329
+ args: { liveTurnId: string | null; replyText?: string | null; now: number },
233
330
  ): SupersedeDecision {
234
331
  const rec = this.entries.get(makeKey(chatId, threadId))?.get(turnKey(args.liveTurnId))
235
- return decideSupersede(rec, { liveTurnId: args.liveTurnId, now: args.now, ttlMs: this.ttlMs })
332
+ return decideSupersede(rec, {
333
+ liveTurnId: args.liveTurnId,
334
+ replyText: args.replyText,
335
+ now: args.now,
336
+ ttlMs: this.ttlMs,
337
+ })
236
338
  }
237
339
 
238
340
  /** Decide supersede AND, on a supersede, consume the matched record (so a
239
341
  * second replay of the same reply doesn't try to delete the same — now gone —
240
- * messages again). Returns the same decision `peek` would. */
342
+ * messages again). A `'new-content'` decision (#3429) does NOT consume: the
343
+ * record stays live for the turn's own canonical replay until the TTL.
344
+ * Returns the same decision `peek` would. */
241
345
  take(
242
346
  chatId: string,
243
347
  threadId: number | undefined,
244
- args: { liveTurnId: string | null; now: number },
348
+ args: { liveTurnId: string | null; replyText?: string | null; now: number },
245
349
  ): SupersedeDecision {
246
350
  const lane = makeKey(chatId, threadId)
247
351
  const decision = this.peek(chatId, threadId, args)
@@ -67,10 +67,15 @@ import {
67
67
  parseSetupTokenUrl,
68
68
  readTokenFromCredentialsFile,
69
69
  } from '../../src/auth/manager.js'
70
+ import { PRE_PASTE_RULES } from '../../src/auth/via-claude.js'
70
71
  import type {
71
72
  AddAccountCredentials,
72
73
  AnthropicAddAccountCredentials,
73
74
  } from '../../src/auth/broker/client.js'
75
+ import { isAuthAdmin, runReaddPrecheck } from './auth-command.js'
76
+ import type { ParsedAuthCommand } from './auth-command.js'
77
+ import { getAuthBrokerClient } from './auth-broker-client.js'
78
+ import { chatKey } from './chat-key.js'
74
79
 
75
80
  /* ── Injectable tmux ops (mirrors makeTmuxRunner in src/agents/inject.ts) ─── */
76
81
 
@@ -103,6 +108,19 @@ export interface AuthAddTmuxOps {
103
108
  * the session has ended.
104
109
  */
105
110
  send(socket: string, session: string, text: string): void
111
+ /**
112
+ * `tmux -L <socket> send-keys -t <session> <key>` — a SINGLE key-name
113
+ * dispatch (e.g. `Enter`), with NO `-l` literal prefix and NO pane
114
+ * capture. Used for:
115
+ * - pre-paste picker choreography (theme / login-method Enters) during
116
+ * the URL-poll phase, before any secret is on the pane; and
117
+ * - blind post-paste Enter dispatches to advance past Enter-gated
118
+ * "Logged in / Security notes" screens in the via-claude picker flow.
119
+ * It reads nothing from the pane, so it can never leak the echoed code —
120
+ * this is the deterministic replacement for via-claude's capture-driven
121
+ * post-paste dispatch (see prb-safety.md).
122
+ */
123
+ sendKey(socket: string, session: string, key: string): void
106
124
  /** `tmux -L <socket> has-session -t <session>` — returns true if alive. */
107
125
  hasSession(socket: string, session: string): boolean
108
126
  /** `tmux -L <socket> kill-session -t <session>` — best-effort. */
@@ -151,6 +169,12 @@ export function makeAuthAddTmuxOps(tmuxBin = 'tmux'): AuthAddTmuxOps {
151
169
  stdio: ['pipe', 'pipe', 'pipe'],
152
170
  })
153
171
  },
172
+ sendKey(socket, session, key) {
173
+ // Single key-name dispatch, no -l literal prefix, no capture.
174
+ execFileSync(tmuxBin, ['-L', socket, 'send-keys', '-t', session, key], {
175
+ stdio: ['pipe', 'pipe', 'pipe'],
176
+ })
177
+ },
154
178
  hasSession(socket, session) {
155
179
  try {
156
180
  execFileSync(tmuxBin, ['-L', socket, 'has-session', '-t', session], {
@@ -184,6 +208,20 @@ export function makeAuthAddTmuxOps(tmuxBin = 'tmux'): AuthAddTmuxOps {
184
208
  * TTL matches `REAUTH_INTERCEPT_TTL_MS` (10 minutes); the reaper sweep
185
209
  * in gateway.ts walks both maps each minute.
186
210
  */
211
+ /**
212
+ * Which credential minter drives the flow.
213
+ *
214
+ * - `'via-claude'` (DEFAULT): spawn the bare `claude` login picker, which
215
+ * mints the broad scope set (`org:create_api_key user:profile
216
+ * user:inference user:sessions:claude_code user:mcp_servers
217
+ * user:file_upload`) that `server:` mode agents require at boot. This is
218
+ * the default because `claude setup-token` tokens carry only
219
+ * `user:inference` and are REFUSED by server: agents (via-claude.ts:9-17).
220
+ * - `'setup-token'`: spawn `claude setup-token` (narrow `user:inference`).
221
+ * Kept for completeness / legacy narrow-scope adds.
222
+ */
223
+ export type AuthAddMode = 'via-claude' | 'setup-token'
224
+
187
225
  export interface PendingAuthAddFlow {
188
226
  label: string
189
227
  scratchDir: string
@@ -192,6 +230,21 @@ export interface PendingAuthAddFlow {
192
230
  /** tmux session name (`auth-add-<label>-<hex>`). */
193
231
  tmuxSession: string
194
232
  startedAt: number
233
+ /**
234
+ * True when this flow re-authenticates an EXISTING account label in place
235
+ * (`/auth readd`) — threaded to `addAccountViaBroker(..., { replace: true })`
236
+ * so the broker overwrites the stored credentials instead of rejecting the
237
+ * duplicate. Absent/false is a fresh add. Set by the gateway dispatch.
238
+ */
239
+ replace?: boolean
240
+ /**
241
+ * Minter mode this flow was started in. `submitAccountAuthCode` reads it to
242
+ * decide whether to dispatch blind post-paste Enter key-presses (via-claude
243
+ * picker screens) — setup-token exits after the code so needs none.
244
+ * Optional for back-compat with callers/tests that construct flows directly;
245
+ * absent is treated as `'via-claude'` (the default minter).
246
+ */
247
+ mode?: AuthAddMode
195
248
  }
196
249
  export const pendingAuthAddFlows = new Map<string, PendingAuthAddFlow>()
197
250
 
@@ -299,6 +352,8 @@ export interface StartAccountAuthSessionResult {
299
352
  scratchDir: string
300
353
  tmuxSocket: string
301
354
  tmuxSession: string
355
+ /** The minter mode the session was started in. */
356
+ mode: AuthAddMode
302
357
  }
303
358
 
304
359
  /**
@@ -343,6 +398,11 @@ export async function startAccountAuthSession(
343
398
  agentName?: string
344
399
  /** Override the claude binary name (tests). */
345
400
  claudeBinary?: string
401
+ /**
402
+ * Credential minter. Defaults to `'via-claude'` (broad scope) — see
403
+ * {@link AuthAddMode}. Pass `'setup-token'` for the narrow-scope minter.
404
+ */
405
+ mode?: AuthAddMode
346
406
  } = {},
347
407
  ): Promise<StartAccountAuthSessionResult> {
348
408
  if (process.env.SWITCHROOM_TMUX_SUPERVISOR !== '1' && !opts.tmuxOps) {
@@ -353,10 +413,14 @@ export async function startAccountAuthSession(
353
413
  }
354
414
 
355
415
  const home = opts.home ?? homedir()
356
- const urlTimeoutMs = opts.urlTimeoutMs ?? 12_000
416
+ // 30s (was 12s): the via-claude login picker adds keystroke-choreography
417
+ // latency (theme + login-method Enters) before the URL renders, and an
418
+ // unloaded VM can be slow. via-claude's own default is 20s; 30s is generous.
419
+ const urlTimeoutMs = opts.urlTimeoutMs ?? 30_000
357
420
  const agentName = opts.agentName ?? process.env.SWITCHROOM_AGENT_NAME ?? 'gateway'
358
421
  const tmux = opts.tmuxOps ?? makeAuthAddTmuxOps(opts.tmuxBin)
359
422
  const binary = opts.claudeBinary ?? 'claude'
423
+ const mode: AuthAddMode = opts.mode ?? 'via-claude'
360
424
 
361
425
  const scratchDir = pickScratchDir(label, home)
362
426
  mkdirSync(scratchDir, { recursive: true, mode: 0o700 })
@@ -391,20 +455,31 @@ export async function startAccountAuthSession(
391
455
  if (process.env.CLAUDE_CONFIG_DIR) sessionEnv['CLAUDE_CONFIG_DIR'] = scratchDir // always override
392
456
  if (process.env.XDG_CONFIG_HOME) sessionEnv['XDG_CONFIG_HOME'] = process.env.XDG_CONFIG_HOME
393
457
 
458
+ // Command: bare `claude` for the broad-scope login picker (via-claude), or
459
+ // `claude setup-token` for the narrow-scope minter.
460
+ const sessionCmd = mode === 'via-claude' ? binary : binary + ' setup-token'
461
+ const minterLabel = mode === 'via-claude' ? 'claude login' : 'claude setup-token'
394
462
  try {
395
- tmux.newSession(tmuxSocket, tmuxSession, sessionEnv, binary + ' setup-token')
463
+ tmux.newSession(tmuxSocket, tmuxSession, sessionEnv, sessionCmd)
396
464
  } catch (err) {
397
465
  cleanScratchDir(scratchDir)
398
- throw new Error(`Failed to start tmux session for claude setup-token: ${(err as Error).message}`)
466
+ throw new Error(`Failed to start tmux session for ${minterLabel}: ${(err as Error).message}`)
399
467
  }
400
468
 
401
469
  // Poll capture-pane every 500ms up to the URL timeout.
470
+ //
471
+ // In via-claude mode we ALSO dispatch the pre-paste picker choreography
472
+ // (theme picker → Enter, login-method picker → Enter) each tick, at most
473
+ // once per rule. This runs BEFORE any secret is on the pane, so capturing
474
+ // here is safe. parseSetupTokenUrl matches both the claude.ai/oauth and
475
+ // claude.com/cai/oauth shapes, which is exactly the URL the picker renders.
476
+ const preFired = new Set<string>()
402
477
  const loginUrl = await new Promise<string>((resolve, reject) => {
403
478
  const deadline = setTimeout(() => {
404
479
  clearInterval(ticker)
405
480
  tmux.killSession(tmuxSocket, tmuxSession)
406
481
  cleanScratchDir(scratchDir)
407
- reject(new Error(`claude setup-token did not print an OAuth URL within ${urlTimeoutMs}ms`))
482
+ reject(new Error(`${minterLabel} did not print an OAuth URL within ${urlTimeoutMs}ms`))
408
483
  }, urlTimeoutMs)
409
484
 
410
485
  const ticker = setInterval(() => {
@@ -414,9 +489,20 @@ export async function startAccountAuthSession(
414
489
  clearTimeout(deadline)
415
490
  clearInterval(ticker)
416
491
  cleanScratchDir(scratchDir)
417
- reject(new Error('claude setup-token exited before printing OAuth URL'))
492
+ reject(new Error(`${minterLabel} exited before printing OAuth URL`))
418
493
  return
419
494
  }
495
+ if (mode === 'via-claude') {
496
+ for (const rule of PRE_PASTE_RULES) {
497
+ if (preFired.has(rule.name)) continue
498
+ if (rule.match.test(pane)) {
499
+ preFired.add(rule.name)
500
+ // The pre-paste rules dispatch bare key-names (Enter); use the
501
+ // single-key primitive, not the literal-code `send`.
502
+ for (const key of rule.keys) tmux.sendKey(tmuxSocket, tmuxSession, key)
503
+ }
504
+ }
505
+ }
420
506
  const url = parseSetupTokenUrl(pane)
421
507
  if (url) {
422
508
  clearTimeout(deadline)
@@ -427,7 +513,7 @@ export async function startAccountAuthSession(
427
513
  }, 500)
428
514
  })
429
515
 
430
- return { loginUrl, scratchDir, tmuxSocket, tmuxSession }
516
+ return { loginUrl, scratchDir, tmuxSocket, tmuxSession, mode }
431
517
  }
432
518
 
433
519
  /**
@@ -456,11 +542,24 @@ export async function submitAccountAuthCode(
456
542
  pollIntervalMs?: number
457
543
  pollTimeoutMs?: number
458
544
  tmuxOps?: AuthAddTmuxOps
545
+ /**
546
+ * Blind post-paste Enter schedule (ms after the code paste) for the
547
+ * via-claude picker flow: advances past the Enter-gated "Logged in /
548
+ * Security notes" screens WITHOUT ever capturing the pane (the echoed
549
+ * code is a secret — see prb-safety.md). Defaults derive from
550
+ * `flow.mode`: via-claude → `[1500, 3000, 5000]`, setup-token → `[]`
551
+ * (setup-token exits after the code, so no screens to dismiss). Pass an
552
+ * explicit array to override (tests use tight schedules).
553
+ */
554
+ blindEnterDelaysMs?: number[]
459
555
  } = {},
460
556
  ): Promise<AddAccountCredentials> {
461
557
  const pollIntervalMs = opts.pollIntervalMs ?? 250
462
558
  const pollTimeoutMs = opts.pollTimeoutMs ?? 300_000
463
559
  const tmux = opts.tmuxOps ?? makeAuthAddTmuxOps()
560
+ const mode: AuthAddMode = flow.mode ?? 'via-claude'
561
+ const blindEnterDelaysMs =
562
+ opts.blindEnterDelaysMs ?? (mode === 'via-claude' ? [1500, 3000, 5000] : [])
464
563
 
465
564
  const credentialsPath = join(flow.scratchDir, '.credentials.json')
466
565
 
@@ -475,11 +574,29 @@ export async function submitAccountAuthCode(
475
574
  )
476
575
  }
477
576
 
577
+ // Blind post-paste Enter schedule (via-claude picker). Absolute deadlines;
578
+ // each fires at most once. NEVER reads the pane — sendKey only writes.
579
+ const pasteAt = Date.now()
580
+ const blindEnters = blindEnterDelaysMs.map((d) => ({ at: pasteAt + d, fired: false }))
581
+
478
582
  // Poll filesystem + session liveness only — no capture-pane.
479
583
  const deadline = Date.now() + pollTimeoutMs
480
584
  while (Date.now() < deadline) {
481
585
  await new Promise((r) => setTimeout(r, pollIntervalMs))
482
586
 
587
+ // Dispatch any due blind Enters (best-effort; a dead session just throws
588
+ // and we swallow it — the fs poll below is the real success signal).
589
+ for (const be of blindEnters) {
590
+ if (!be.fired && Date.now() >= be.at) {
591
+ be.fired = true
592
+ try {
593
+ tmux.sendKey(flow.tmuxSocket, flow.tmuxSession, 'Enter')
594
+ } catch {
595
+ // best-effort — session may have already exited on success
596
+ }
597
+ }
598
+ }
599
+
483
600
  if (existsSync(credentialsPath)) {
484
601
  const token = readTokenFromCredentialsFile(credentialsPath)
485
602
  if (token) {
@@ -529,3 +646,95 @@ export function cancelAccountAuthSession(
529
646
  tmux.killSession(flow.tmuxSocket, flow.tmuxSession)
530
647
  cleanScratchDir(flow.scratchDir)
531
648
  }
649
+
650
+ /**
651
+ * `/auth add`, `/auth readd`, `/auth add --replace`, and `/auth cancel`
652
+ * dispatch. Extracted verbatim from gateway.ts (switchroom#2996 ratchet) —
653
+ * behavior identical. Gateway-routed because it drives a scratch-dir-backed
654
+ * `claude setup-token` tmux/OAuth lifecycle the broker client can't model.
655
+ *
656
+ * `reply` sends an HTML message to the originating chat; `escapeHtml` is the
657
+ * gateway's Telegram-HTML escaper. Admin authority is passed through as
658
+ * `isAdmin` (the gateway sources it from the agent's own `admin`/`root` flag).
659
+ */
660
+ export async function handleAuthAddOrCancel(opts: {
661
+ parsed: Extract<ParsedAuthCommand, { kind: 'add' | 'cancel' }>
662
+ isAdmin: boolean
663
+ currentAgent: string
664
+ chatId: string
665
+ threadId: number | null
666
+ reply: (text: string) => Promise<unknown>
667
+ escapeHtml: (text: string) => string
668
+ }): Promise<void> {
669
+ const { parsed, isAdmin, currentAgent, chatId, threadId, reply, escapeHtml } = opts
670
+ if (!isAuthAdmin({ isAdmin })) {
671
+ await reply(
672
+ `**Not authorized.** \`/auth ${parsed.kind}\` is admin-only.\n` +
673
+ `Set \`admin: true\` on this agent in switchroom.yaml to unlock ` +
674
+ `(the same flag that gates \`/agents\`, \`/restart\`, ` +
675
+ `\`/update\` etc.).`,
676
+ )
677
+ return
678
+ }
679
+ // PR3 supergroup-mode: key auth-add flows by (chat, thread) so
680
+ // separate flows in two topics of one supergroup can't collide.
681
+ // In DM chats message_thread_id is undefined → key collapses to
682
+ // `chatId:_`, identical to today's behavior.
683
+ const authAddKey = chatKey(chatId, threadId) as string
684
+ if (parsed.kind === 'cancel') {
685
+ const existing = pendingAuthAddFlows.get(authAddKey)
686
+ if (!existing) {
687
+ await reply("_No pending \`/auth add\` flow in this chat._")
688
+ return
689
+ }
690
+ cancelAccountAuthSession(existing)
691
+ pendingAuthAddFlows.delete(authAddKey)
692
+ await reply("Cancelled.")
693
+ return
694
+ }
695
+ // parsed.kind === 'add'
696
+ if (pendingAuthAddFlows.has(authAddKey)) {
697
+ await reply(
698
+ "_An \`/auth add\` flow is already in progress for this chat. " +
699
+ "Finish the paste, or send \`/auth cancel\` to abort._",
700
+ )
701
+ return
702
+ }
703
+ // Precheck against broker state (readd requires the label to exist; a
704
+ // fresh add requires it NOT to): fail fast before spinning up a tmux/OAuth
705
+ // flow. Best-effort — a broker-unreachable case returns null and
706
+ // addAccountViaBroker enforces.
707
+ const precheckErr = await runReaddPrecheck(
708
+ () => getAuthBrokerClient(currentAgent),
709
+ parsed.label,
710
+ parsed.replace,
711
+ )
712
+ if (precheckErr) {
713
+ await reply(precheckErr)
714
+ return
715
+ }
716
+ try {
717
+ const { loginUrl, scratchDir, tmuxSocket, tmuxSession, mode } = await startAccountAuthSession(parsed.label)
718
+ pendingAuthAddFlows.set(authAddKey, {
719
+ label: parsed.label,
720
+ scratchDir,
721
+ tmuxSocket,
722
+ tmuxSession,
723
+ startedAt: Date.now(),
724
+ replace: parsed.replace,
725
+ mode,
726
+ })
727
+ const verbNoun = parsed.replace ? 'Re-authenticating account' : 'Adding account'
728
+ await reply(
729
+ `**${verbNoun}** \`${parsed.label}\`\n\n` +
730
+ `1. Open this URL on your phone:\n${loginUrl}\n\n` +
731
+ `2. Log into Anthropic, copy the code Claude shows.\n` +
732
+ `3. Paste it back here.\n\n` +
733
+ `Send \`/auth cancel\` to abort.`,
734
+ )
735
+ } catch (err) {
736
+ await reply(
737
+ `**/auth ${parsed.replace ? 'readd' : 'add'} failed:** ${escapeHtml((err as Error)?.message ?? String(err))}`,
738
+ )
739
+ }
740
+ }