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.
- package/dist/agent-scheduler/index.js +2 -0
- package/dist/auth-broker/index.js +13 -0
- package/dist/cli/autoaccept-poll.js +2 -0
- package/dist/cli/drive-write-pretool.mjs +2 -0
- package/dist/cli/ms-365-write-pretool.mjs +2 -0
- package/dist/cli/switchroom.js +404 -245
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/telegram-plugin/auth-snapshot-format.ts +104 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1194 -794
- package/telegram-plugin/dist/server.js +8 -2
- package/telegram-plugin/flushed-turn-supersede.ts +117 -13
- package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
- package/telegram-plugin/gateway/auth-command.ts +138 -5
- package/telegram-plugin/gateway/gateway.ts +68 -101
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +203 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/quota-bar-format.ts +60 -12
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
- package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
- package/telegram-plugin/tests/session-model-source.test.ts +142 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
- package/vendor/hindsight-memory/CHANGELOG.md +102 -0
- package/vendor/hindsight-memory/README.md +2 -1
- package/vendor/hindsight-memory/hooks/hooks.json +12 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
- package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
- package/vendor/hindsight-memory/scripts/recall.py +789 -143
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
- package/vendor/hindsight-memory/scripts/retain.py +71 -2
- package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
- package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
- 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-
|
|
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
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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).
|
|
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
|
-
|
|
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,
|
|
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
|
|
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(
|
|
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(
|
|
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
|
+
}
|