switchroom 0.14.61 → 0.14.63

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.
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { AuthBrokerClient as BrokerClient, type AddAccountCredentials } from '../../src/auth/broker/client.js'
14
+ import type { ProviderName } from '../../src/auth/broker/protocol.js'
14
15
  import type { AuthBrokerClient } from './auth-command.js'
15
16
 
16
17
  /**
@@ -56,21 +57,30 @@ export async function getAuthBrokerClient(
56
57
  }
57
58
 
58
59
  /**
59
- * Add an account via the broker. Used exclusively by the `/auth add`
60
- * chat flow — the narrow {@link AuthBrokerClient} surface in
61
- * `auth-command.ts` deliberately omits `addAccount` because the verb
62
- * is gateway-routed (not handler-routed). Constructs and closes a
63
- * one-shot {@link BrokerClient} so the gateway doesn't need a
64
- * long-lived handle just for this verb.
60
+ * Add an account via the broker. Used by the `/auth add` chat flow
61
+ * (Anthropic) and the `/connect microsoft` device-code flow — the narrow
62
+ * {@link AuthBrokerClient} surface in `auth-command.ts` deliberately
63
+ * omits `addAccount` because the verb is gateway-routed (not
64
+ * handler-routed). Constructs and closes a one-shot {@link BrokerClient}
65
+ * so the gateway doesn't need a long-lived handle just for this verb.
66
+ *
67
+ * `provider` defaults to Anthropic (back-compat with the `/auth add`
68
+ * caller, which omits it). The device-code flow passes `"microsoft"`
69
+ * with a `MicrosoftAddAccountCredentials` payload.
65
70
  */
66
71
  export async function addAccountViaBroker(
67
72
  label: string,
68
73
  credentials: AddAccountCredentials,
69
- opts: { replace?: boolean } = {},
74
+ opts: { replace?: boolean; provider?: ProviderName } = {},
70
75
  ): Promise<{ label: string; expiresAt?: number }> {
71
76
  const broker = new BrokerClient()
72
77
  try {
73
- return await broker.addAccount(label, credentials, opts.replace)
78
+ return await broker.addAccount(
79
+ label,
80
+ credentials,
81
+ opts.replace,
82
+ opts.provider,
83
+ )
74
84
  } finally {
75
85
  await broker.close()
76
86
  }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * auto-classify-mid-turn.ts — deterministic, model-free classification of a
3
+ * mid-turn inbound into STEER (amend the in-flight turn) vs QUEUE (new task),
4
+ * using TOPIC-vs-active-turn + reply-RECENCY as proxies for intent. No model
5
+ * inference: the gateway must decide at inbound time while the single CLI is
6
+ * busy.
7
+ *
8
+ * Today a no-prefix mid-turn message always QUEUES (the default flipped
9
+ * 2026-04-17 away from the blunt "everything steers" — see
10
+ * reference/steer-or-queue-mid-flight.md). This module is the basis for a
11
+ * smarter default. It ships first in SHADOW mode (the gateway logs what it WOULD
12
+ * decide but still queues), to gather real-world data — how often mid-turn
13
+ * messages are same-topic continuations vs cross-topic new tasks, and the
14
+ * recency distribution — before any behaviour flips on.
15
+ *
16
+ * Signal strength (be honest):
17
+ * - TOPIC (supergroup): STRONG + structural. A message in a DIFFERENT forum
18
+ * topic than the active turn is, by the supergroup-mode invariant, a separate
19
+ * conversation → queue. This needs no timing guess.
20
+ * - RECENCY: weaker. Within the same topic it cannot tell "also do X" (steer)
21
+ * from "new question, same topic" (queue) — only a tight window + the
22
+ * visible/correctable UX (the JTBD doc) makes auto-steer acceptable, and
23
+ * that is gated separately. The recency clock is the agent's LAST OUTPUT
24
+ * (msSinceLastAgentOutput), NOT turn age: a long actively-narrating worker
25
+ * turn must not read "stale".
26
+ * - DM: no topic at all → timing-only (the pre-2026-04-17 regime that
27
+ * over-steered). DM auto-steer is OFF by default (window 0).
28
+ *
29
+ * Pure (no gateway imports) ⇒ unit-testable.
30
+ */
31
+
32
+ export type MidTurnClass = 'steer' | 'queue'
33
+
34
+ export type MidTurnReason =
35
+ | 'steer_prefix'
36
+ | 'queue_prefix'
37
+ | 'not_mid_turn'
38
+ | 'cross_topic'
39
+ | 'same_topic_recent'
40
+ | 'same_topic_stale'
41
+ | 'dm_recent'
42
+ | 'dm_disabled'
43
+ | 'topic_disabled'
44
+
45
+ export interface AutoClassifyInput {
46
+ /** Explicit `/steer`|`/s` prefix present — the user's stated intent, authoritative. */
47
+ isSteerPrefix: boolean
48
+ /** Explicit `/queue`|`/q` prefix present. */
49
+ isQueuePrefix: boolean
50
+ /** Is a turn in flight for this chat/thread? (no → not our decision). */
51
+ priorTurnInFlight: boolean
52
+ /** DM (no forum topics) → timing-only. */
53
+ isDm: boolean
54
+ /** Incoming message's thread id (undefined/null in a DM). */
55
+ incomingThreadId: number | null | undefined
56
+ /** The in-flight turn's thread id (currentTurn.sessionThreadId). */
57
+ activeTurnThreadId: number | null | undefined
58
+ /** ms since the agent's LAST visible output in this chat/thread; null when no
59
+ * output has been recorded (cold topic) → treated as not-recent. */
60
+ msSinceLastAgentOutput: number | null
61
+ /** Auto-steer recency window in a DM. 0 (default) = DM auto-steer OFF. */
62
+ dmSteerWindowMs: number
63
+ /** Auto-steer recency window in a supergroup topic. 0 = topic auto-steer OFF. */
64
+ topicSteerWindowMs: number
65
+ }
66
+
67
+ export interface AutoClassifyResult {
68
+ decision: MidTurnClass
69
+ reason: MidTurnReason
70
+ /** Whether the incoming message is in the SAME thread as the active turn
71
+ * (canonicalized). Undefined when not applicable (no prefix-free mid-turn). */
72
+ sameTopic?: boolean
73
+ }
74
+
75
+ /** Canonical thread compare, matching chatKey's collapse (null/undefined/0 → same
76
+ * "no-thread" bucket) — never raw === on raw ids (the silence-poke key-mismatch
77
+ * bug class). */
78
+ function sameThread(a: number | null | undefined, b: number | null | undefined): boolean {
79
+ const norm = (t: number | null | undefined): number | null => (t == null || t === 0 ? null : t)
80
+ return norm(a) === norm(b)
81
+ }
82
+
83
+ /**
84
+ * Classify a mid-turn inbound. Precedence: explicit prefix → not-mid-turn →
85
+ * DM(timing) / supergroup(topic then timing). Defaults bias to QUEUE (the safe,
86
+ * reversible, current behaviour); STEER only on a strong/recent signal AND its
87
+ * window enabled.
88
+ */
89
+ export function autoClassifyMidTurnInbound(i: AutoClassifyInput): AutoClassifyResult {
90
+ // Explicit prefixes always win — the user's stated intent is authoritative.
91
+ if (i.isSteerPrefix) return { decision: 'steer', reason: 'steer_prefix' }
92
+ if (i.isQueuePrefix) return { decision: 'queue', reason: 'queue_prefix' }
93
+ // No turn in flight → caller starts a fresh turn (not a steer/queue decision).
94
+ if (!i.priorTurnInFlight) return { decision: 'queue', reason: 'not_mid_turn' }
95
+
96
+ const recent =
97
+ i.msSinceLastAgentOutput != null && i.msSinceLastAgentOutput >= 0
98
+
99
+ if (i.isDm) {
100
+ // DM: no topic → timing-only. Default OFF (dmSteerWindowMs 0).
101
+ if (i.dmSteerWindowMs <= 0) return { decision: 'queue', reason: 'dm_disabled' }
102
+ return recent && i.msSinceLastAgentOutput! <= i.dmSteerWindowMs
103
+ ? { decision: 'steer', reason: 'dm_recent' }
104
+ : { decision: 'queue', reason: 'dm_disabled' }
105
+ }
106
+
107
+ // Supergroup: topic identity is the PRIMARY signal.
108
+ const topicMatch = sameThread(i.incomingThreadId, i.activeTurnThreadId)
109
+ if (i.topicSteerWindowMs <= 0) {
110
+ return { decision: 'queue', reason: 'topic_disabled', sameTopic: topicMatch }
111
+ }
112
+ // Different topic than the in-flight turn → ALWAYS queue (a separate
113
+ // conversation; never steer it into the wrong topic's turn).
114
+ if (!topicMatch) return { decision: 'queue', reason: 'cross_topic', sameTopic: false }
115
+ // Same topic: steer only if recent enough; else a new question → queue.
116
+ return recent && i.msSinceLastAgentOutput! <= i.topicSteerWindowMs
117
+ ? { decision: 'steer', reason: 'same_topic_recent', sameTopic: true }
118
+ : { decision: 'queue', reason: 'same_topic_stale', sameTopic: true }
119
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * escalation-drive.ts — the obligation-ledger escalation step, extracted from
3
+ * obligationSweep so the hang → bounded → terminal behaviour is EXECUTABLE in a
4
+ * test with a fake hanging send. That path is unreachable by the two harnesses
5
+ * we have: mtcute can't make Telegram's API hang, and the synchronous property
6
+ * test can't model a promise that never settles — yet it is exactly the path the
7
+ * total proof flagged as the determinism hole. This file makes the real drive
8
+ * logic the sweep uses testable in isolation (escalation-drive.test.ts), so the
9
+ * fix is verified by execution, not only by reasoning + review.
10
+ *
11
+ * Invariant it upholds: a single escalation attempt
12
+ * - is guarded so a sweep tick cannot fire a second concurrent send for the
13
+ * same obligation while one is awaiting;
14
+ * - bounds the send with `withDeadline`, so the chain ALWAYS settles within
15
+ * `deadlineMs` ⇒ the in-flight flag ALWAYS clears (a hung send becomes a
16
+ * bounded reject, handled like any other failed attempt);
17
+ * - closes the obligation ONLY after a successful send; a transient failure
18
+ * leaves it OPEN (retried next sweep); a permanent failure
19
+ * (attempt ≥ maxAttempts) closes best-effort — so repeated hung/failed sends
20
+ * reach a terminal in a bounded number of sweeps, never an infinite loop.
21
+ */
22
+ import { withDeadline } from './with-deadline.js'
23
+
24
+ /** The slice of the ledger the escalation step needs. */
25
+ export interface EscalationLedger {
26
+ markEscalateAttempt(originTurnId: string): number
27
+ close(originTurnId: string | null | undefined): boolean
28
+ }
29
+
30
+ export interface DriveEscalationArgs {
31
+ escId: string
32
+ /** Set of origin ids with an escalation send in flight (concurrency guard). */
33
+ inFlight: Set<string>
34
+ ledger: EscalationLedger
35
+ /** Perform the operator-nudge send (already thread-fallback-wrapped). May hang. */
36
+ send: () => Promise<unknown>
37
+ /** Attempts before a permanent failure closes best-effort. */
38
+ maxAttempts: number
39
+ /** Bound on a single send so a hang can't leak the in-flight flag. */
40
+ deadlineMs: number
41
+ log?: (line: string) => void
42
+ /** Injectable for tests; defaults to the real withDeadline. */
43
+ withDeadlineFn?: typeof withDeadline
44
+ }
45
+
46
+ /**
47
+ * Drive one escalation attempt. Returns the settling chain promise (so tests can
48
+ * await it) or `undefined` if the call was a no-op because a send is already in
49
+ * flight for `escId`. obligationSweep calls this as `void driveEscalation(...)`.
50
+ */
51
+ export function driveEscalation(args: DriveEscalationArgs): Promise<void> | undefined {
52
+ const { escId, inFlight, ledger, send, maxAttempts, deadlineMs } = args
53
+ const log = args.log ?? ((l: string) => process.stderr.write(l))
54
+ const wd = args.withDeadlineFn ?? withDeadline
55
+ if (inFlight.has(escId)) return undefined // a send is already awaiting
56
+ const attempt = ledger.markEscalateAttempt(escId)
57
+ inFlight.add(escId)
58
+ log(`telegram gateway: obligation escalating origin=${escId} attempt=${attempt}/${maxAttempts}\n`)
59
+ return wd(send(), deadlineMs, 'obligation escalation send timed out')
60
+ .then(() => {
61
+ ledger.close(escId)
62
+ log(`telegram gateway: obligation escalation delivered + closed origin=${escId}\n`)
63
+ })
64
+ .catch((err: unknown) => {
65
+ if (attempt >= maxAttempts) {
66
+ ledger.close(escId)
67
+ log(
68
+ `telegram gateway: obligation escalation PERMANENTLY undeliverable after ${attempt} attempts — closing best-effort origin=${escId}: ${err}\n`,
69
+ )
70
+ } else {
71
+ log(
72
+ `telegram gateway: obligation escalation send failed (attempt ${attempt}/${maxAttempts}), retrying next sweep origin=${escId}: ${err}\n`,
73
+ )
74
+ }
75
+ })
76
+ .finally(() => {
77
+ inFlight.delete(escId)
78
+ })
79
+ }