switchroom 0.14.62 → 0.14.64

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.
@@ -49452,8 +49452,8 @@ var {
49452
49452
  } = import__.default;
49453
49453
 
49454
49454
  // src/build-info.ts
49455
- var VERSION = "0.14.62";
49456
- var COMMIT_SHA = "3967bb6f";
49455
+ var VERSION = "0.14.64";
49456
+ var COMMIT_SHA = "fb6bbe00";
49457
49457
 
49458
49458
  // src/cli/agent.ts
49459
49459
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.62",
3
+ "version": "0.14.64",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41593,6 +41593,33 @@ function formatReplyToText(text, maxChars) {
41593
41593
  return escapeXmlAttribute(truncated);
41594
41594
  }
41595
41595
 
41596
+ // gateway/auto-classify-mid-turn.ts
41597
+ function sameThread(a, b) {
41598
+ const norm = (t) => t == null || t === 0 ? null : t;
41599
+ return norm(a) === norm(b);
41600
+ }
41601
+ function autoClassifyMidTurnInbound(i) {
41602
+ if (i.isSteerPrefix)
41603
+ return { decision: "steer", reason: "steer_prefix" };
41604
+ if (i.isQueuePrefix)
41605
+ return { decision: "queue", reason: "queue_prefix" };
41606
+ if (!i.priorTurnInFlight)
41607
+ return { decision: "queue", reason: "not_mid_turn" };
41608
+ const recent = i.msSinceLastAgentOutput != null && i.msSinceLastAgentOutput >= 0;
41609
+ if (i.isDm) {
41610
+ if (i.dmSteerWindowMs <= 0)
41611
+ return { decision: "queue", reason: "dm_disabled" };
41612
+ return recent && i.msSinceLastAgentOutput <= i.dmSteerWindowMs ? { decision: "steer", reason: "dm_recent" } : { decision: "queue", reason: "dm_disabled" };
41613
+ }
41614
+ const topicMatch = sameThread(i.incomingThreadId, i.activeTurnThreadId);
41615
+ if (i.topicSteerWindowMs <= 0) {
41616
+ return { decision: "queue", reason: "topic_disabled", sameTopic: topicMatch };
41617
+ }
41618
+ if (!topicMatch)
41619
+ return { decision: "queue", reason: "cross_topic", sameTopic: false };
41620
+ return recent && i.msSinceLastAgentOutput <= i.topicSteerWindowMs ? { decision: "steer", reason: "same_topic_recent", sameTopic: true } : { decision: "queue", reason: "same_topic_stale", sameTopic: true };
41621
+ }
41622
+
41596
41623
  // operator-events.ts
41597
41624
  function renderOperatorEvent(ev) {
41598
41625
  const agent = escHtml(ev.agent);
@@ -47443,14 +47470,31 @@ class ObligationLedger {
47443
47470
  }
47444
47471
  return best;
47445
47472
  }
47446
- decideAtIdle() {
47447
- const o = this.oldest();
47473
+ decideAtIdle(opts) {
47474
+ const o = opts != null && opts.graceMs > 0 ? this.oldestEligible(opts.now, opts.graceMs) : this.oldest();
47448
47475
  if (o === undefined)
47449
47476
  return { action: "none" };
47450
47477
  if (o.representCount >= this.maxRepresents)
47451
47478
  return { action: "escalate", obligation: o };
47452
47479
  return { action: "represent", obligation: o };
47453
47480
  }
47481
+ oldestEligible(now, graceMs) {
47482
+ let best;
47483
+ for (const o of this.open.values()) {
47484
+ if (o.lastTurnEndedAt != null && now - o.lastTurnEndedAt < graceMs)
47485
+ continue;
47486
+ if (best === undefined || o.openedAt < best.openedAt)
47487
+ best = o;
47488
+ }
47489
+ return best;
47490
+ }
47491
+ noteTurnEnded(originTurnId, ts) {
47492
+ const o = this.open.get(originTurnId);
47493
+ if (o === undefined)
47494
+ return;
47495
+ o.lastTurnEndedAt = ts;
47496
+ this.persist();
47497
+ }
47454
47498
  resolveCloseTarget(echoedTurnId, liveTurnId) {
47455
47499
  if (echoedTurnId != null)
47456
47500
  return echoedTurnId;
@@ -47555,6 +47599,35 @@ function withDeadline(p, ms, timeoutMessage) {
47555
47599
  });
47556
47600
  }
47557
47601
 
47602
+ // gateway/escalation-drive.ts
47603
+ function driveEscalation(args) {
47604
+ const { escId, inFlight, ledger, send, maxAttempts, deadlineMs } = args;
47605
+ const log = args.log ?? ((l) => process.stderr.write(l));
47606
+ const wd = args.withDeadlineFn ?? withDeadline;
47607
+ if (inFlight.has(escId))
47608
+ return;
47609
+ const attempt = ledger.markEscalateAttempt(escId);
47610
+ inFlight.add(escId);
47611
+ log(`telegram gateway: obligation escalating origin=${escId} attempt=${attempt}/${maxAttempts}
47612
+ `);
47613
+ return wd(send(), deadlineMs, "obligation escalation send timed out").then(() => {
47614
+ ledger.close(escId);
47615
+ log(`telegram gateway: obligation escalation delivered + closed origin=${escId}
47616
+ `);
47617
+ }).catch((err) => {
47618
+ if (attempt >= maxAttempts) {
47619
+ ledger.close(escId);
47620
+ log(`telegram gateway: obligation escalation PERMANENTLY undeliverable after ${attempt} attempts \u2014 closing best-effort origin=${escId}: ${err}
47621
+ `);
47622
+ } else {
47623
+ log(`telegram gateway: obligation escalation send failed (attempt ${attempt}/${maxAttempts}), retrying next sweep origin=${escId}: ${err}
47624
+ `);
47625
+ }
47626
+ }).finally(() => {
47627
+ inFlight.delete(escId);
47628
+ });
47629
+ }
47630
+
47558
47631
  // gateway/inbound-spool.ts
47559
47632
  function spoolId(msg) {
47560
47633
  if (msg.meta?.source === "subagent_handback" && typeof msg.meta?.subagent_jsonl_id === "string" && msg.meta.subagent_jsonl_id.length > 0) {
@@ -52647,10 +52720,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
52647
52720
  }
52648
52721
 
52649
52722
  // ../src/build-info.ts
52650
- var VERSION = "0.14.62";
52651
- var COMMIT_SHA = "3967bb6f";
52652
- var COMMIT_DATE = "2026-06-04T11:27:55Z";
52653
- var LATEST_PR = 2153;
52723
+ var VERSION = "0.14.64";
52724
+ var COMMIT_SHA = "fb6bbe00";
52725
+ var COMMIT_DATE = "2026-06-04T23:21:00Z";
52726
+ var LATEST_PR = 2161;
52654
52727
  var COMMITS_AHEAD_OF_TAG = 0;
52655
52728
 
52656
52729
  // gateway/boot-version.ts
@@ -53848,11 +53921,30 @@ var _deliveryTimeoutParsed = _deliveryTimeoutRaw != null && _deliveryTimeoutRaw
53848
53921
  var DELIVERY_CONFIRM_TIMEOUT_MS = Number.isFinite(_deliveryTimeoutParsed) && _deliveryTimeoutParsed > 0 ? _deliveryTimeoutParsed : 15000;
53849
53922
  var DELIVERY_CONFIRM_SWEEP_MS = 5000;
53850
53923
  var deliveryQueue = createDeliveryQueue();
53851
- var OBLIGATION_LEDGER_ENABLED = process.env.SWITCHROOM_OBLIGATION_LEDGER === "1";
53924
+ var OBLIGATION_LEDGER_ENABLED = process.env.SWITCHROOM_OBLIGATION_LEDGER !== "0";
53852
53925
  var OBLIGATION_REPRESENT_MAX = 2;
53853
53926
  var OBLIGATION_SWEEP_MS = 5000;
53854
53927
  var OBLIGATION_ESCALATE_MAX = 3;
53855
53928
  var OBLIGATION_ESCALATE_SEND_DEADLINE_MS = 45000;
53929
+ var OBLIGATION_ESCALATE_GRACE_MS = (() => {
53930
+ const raw = process.env.SWITCHROOM_OBLIGATION_ESCALATE_GRACE_MS;
53931
+ if (raw == null || raw === "")
53932
+ return 45000;
53933
+ const n = Number(raw);
53934
+ return Number.isFinite(n) && n >= 0 ? n : 45000;
53935
+ })();
53936
+ var AUTOCLASSIFY_MIDTURN_SHADOW = process.env.SWITCHROOM_AUTOCLASSIFY_MIDTURN_SHADOW !== "0";
53937
+ var lastAgentOutputAt = new Map;
53938
+ var LAST_OUTPUT_MAX_KEYS = 512;
53939
+ function noteAgentOutputAt(key, ts) {
53940
+ lastAgentOutputAt.delete(key);
53941
+ lastAgentOutputAt.set(key, ts);
53942
+ if (lastAgentOutputAt.size > LAST_OUTPUT_MAX_KEYS) {
53943
+ const oldest = lastAgentOutputAt.keys().next().value;
53944
+ if (oldest !== undefined)
53945
+ lastAgentOutputAt.delete(oldest);
53946
+ }
53947
+ }
53856
53948
  var OBLIGATION_STORE_PATH = join35(STATE_DIR, "obligations.json");
53857
53949
  var obligationStoreFs = {
53858
53950
  readFileSync: (p) => readFileSync36(p, "utf8"),
@@ -54002,6 +54094,17 @@ function reapQueuedStatus(chatId, thread) {
54002
54094
  }
54003
54095
  var toolFlightTracker = new ToolFlightTracker;
54004
54096
  var pendingDeferredInterrupt = null;
54097
+ function cancelInterruptedObligation() {
54098
+ if (!OBLIGATION_LEDGER_ENABLED)
54099
+ return;
54100
+ const turn = currentTurn;
54101
+ if (turn == null)
54102
+ return;
54103
+ if (obligationLedger.close(turn.turnId)) {
54104
+ process.stderr.write(`telegram gateway: obligation cancelled by interrupt origin=${turn.turnId}
54105
+ `);
54106
+ }
54107
+ }
54005
54108
  async function fireDeferredInterrupt(reason) {
54006
54109
  const pending2 = pendingDeferredInterrupt;
54007
54110
  if (pending2 == null)
@@ -54025,6 +54128,7 @@ async function fireDeferredInterrupt(reason) {
54025
54128
  process.stderr.write(`telegram gateway: deferred-interrupt SIGINT failed: ${err.message}
54026
54129
  `);
54027
54130
  }
54131
+ cancelInterruptedObligation();
54028
54132
  const delivered = ipcServer.sendToAgent(pending2.agentName, pending2.inboundMsg);
54029
54133
  if (delivered) {
54030
54134
  markClaudeBusyForInbound(pending2.inboundMsg);
@@ -54163,8 +54267,12 @@ function endCurrentTurnAtomic(turn) {
54163
54267
  if (currentTurn !== turn)
54164
54268
  return;
54165
54269
  currentTurn = null;
54166
- if (OBLIGATION_LEDGER_ENABLED && turn.finalAnswerDelivered) {
54167
- obligationLedger.close(turn.turnId);
54270
+ if (OBLIGATION_LEDGER_ENABLED) {
54271
+ if (turn.finalAnswerDelivered) {
54272
+ obligationLedger.close(turn.turnId);
54273
+ } else {
54274
+ obligationLedger.noteTurnEnded(turn.turnId, Date.now());
54275
+ }
54168
54276
  }
54169
54277
  if (turn.noReplyDrainTimer != null) {
54170
54278
  clearTimeout(turn.noReplyDrainTimer);
@@ -55253,7 +55361,7 @@ function obligationSweep() {
55253
55361
  if (turnInFlightForGate())
55254
55362
  return;
55255
55363
  const agent = process.env.SWITCHROOM_AGENT_NAME ?? "";
55256
- const decision = obligationLedger.decideAtIdle();
55364
+ const decision = obligationLedger.decideAtIdle(OBLIGATION_ESCALATE_GRACE_MS > 0 ? { now: Date.now(), graceMs: OBLIGATION_ESCALATE_GRACE_MS } : undefined);
55257
55365
  const o = decision.obligation;
55258
55366
  if (decision.action === "none" || o == null)
55259
55367
  return;
@@ -55261,35 +55369,20 @@ function obligationSweep() {
55261
55369
  if (pendingInboundBuffer.depth(agent) > 0)
55262
55370
  return;
55263
55371
  pendingInboundBuffer.push(agent, buildObligationRepresentInbound(o, Date.now()));
55264
- const attempt2 = obligationLedger.markRepresented(o.originTurnId);
55265
- process.stderr.write(`telegram gateway: obligation re-presented origin=${o.originTurnId} attempt=${attempt2}/${OBLIGATION_REPRESENT_MAX}
55372
+ const attempt = obligationLedger.markRepresented(o.originTurnId);
55373
+ process.stderr.write(`telegram gateway: obligation re-presented origin=${o.originTurnId} attempt=${attempt}/${OBLIGATION_REPRESENT_MAX}
55266
55374
  `);
55267
55375
  return;
55268
55376
  }
55269
- if (obligationEscalateInFlight.has(o.originTurnId))
55270
- return;
55271
- const escId = o.originTurnId;
55272
- const attempt = obligationLedger.markEscalateAttempt(escId);
55273
- obligationEscalateInFlight.add(escId);
55274
- process.stderr.write(`telegram gateway: obligation escalating (exhausted ${OBLIGATION_REPRESENT_MAX} re-presents) origin=${escId} attempt=${attempt}/${OBLIGATION_ESCALATE_MAX}
55275
- `);
55276
- withDeadline(retryWithThreadFallback(robustApiCall, (tid) => bot.api.sendMessage(o.chatId, obligationEscalationText(o), {
55277
- ...tid != null ? { message_thread_id: tid } : {}
55278
- }), { threadId: o.threadId, chat_id: o.chatId, verb: "obligation.escalate" }), OBLIGATION_ESCALATE_SEND_DEADLINE_MS, "obligation escalation send timed out").then(() => {
55279
- obligationLedger.close(escId);
55280
- process.stderr.write(`telegram gateway: obligation escalation delivered + closed origin=${escId}
55281
- `);
55282
- }).catch((err) => {
55283
- if (attempt >= OBLIGATION_ESCALATE_MAX) {
55284
- obligationLedger.close(escId);
55285
- process.stderr.write(`telegram gateway: obligation escalation PERMANENTLY undeliverable after ${attempt} attempts \u2014 closing best-effort origin=${escId}: ${err}
55286
- `);
55287
- } else {
55288
- process.stderr.write(`telegram gateway: obligation escalation send failed (attempt ${attempt}/${OBLIGATION_ESCALATE_MAX}), retrying next sweep origin=${escId}: ${err}
55289
- `);
55290
- }
55291
- }).finally(() => {
55292
- obligationEscalateInFlight.delete(escId);
55377
+ driveEscalation({
55378
+ escId: o.originTurnId,
55379
+ inFlight: obligationEscalateInFlight,
55380
+ ledger: obligationLedger,
55381
+ send: () => retryWithThreadFallback(robustApiCall, (tid) => bot.api.sendMessage(o.chatId, obligationEscalationText(o), {
55382
+ ...tid != null ? { message_thread_id: tid } : {}
55383
+ }), { threadId: o.threadId, chat_id: o.chatId, verb: "obligation.escalate" }),
55384
+ maxAttempts: OBLIGATION_ESCALATE_MAX,
55385
+ deadlineMs: OBLIGATION_ESCALATE_SEND_DEADLINE_MS
55293
55386
  });
55294
55387
  }
55295
55388
  if (!STATIC && OBLIGATION_LEDGER_ENABLED) {
@@ -56201,6 +56294,8 @@ ${url}`;
56201
56294
  });
56202
56295
  noteOutbound(statusKey(chat_id, threadId), Date.now());
56203
56296
  noteOutbound2(statusKey(chat_id, threadId), Date.now());
56297
+ if (AUTOCLASSIFY_MIDTURN_SHADOW)
56298
+ noteAgentOutputAt(statusKey(chat_id, threadId), Date.now());
56204
56299
  shadowEmit({ kind: "modelOutbound", key: statusKey(chat_id, threadId), at: Date.now() });
56205
56300
  if (isFinalAnswerReply({ text: rawText, disableNotification })) {
56206
56301
  clearSilentEndState(statusKey(chat_id, threadId));
@@ -58654,6 +58749,7 @@ async function handleInbound(ctx, text, downloadImage, attachment, extraAttachme
58654
58749
  process.stderr.write(`telegram gateway: interrupt-marker SIGINT failed: ${err.message}
58655
58750
  `);
58656
58751
  }
58752
+ cancelInterruptedObligation();
58657
58753
  }
58658
58754
  if (interrupt.emptyBody) {
58659
58755
  await swallowingApiCall(() => bot.api.sendMessage(chat_id, "\u26A1 Interrupted. Send your replacement instruction now.", messageThreadId != null ? { message_thread_id: messageThreadId } : {}), {
@@ -59020,6 +59116,23 @@ ${preBlock(write.output)}`;
59020
59116
  isSteering = priorTurnInFlight && isSteerPrefix;
59021
59117
  if (priorTurnInFlight)
59022
59118
  priorTurnStartedAt = activeTurnStartedAt.get(key);
59119
+ if (AUTOCLASSIFY_MIDTURN_SHADOW && priorTurnInFlight) {
59120
+ const lastOut = lastAgentOutputAt.get(key);
59121
+ const msSinceOut = lastOut != null ? Date.now() - lastOut : null;
59122
+ const shadow = autoClassifyMidTurnInbound({
59123
+ isSteerPrefix,
59124
+ isQueuePrefix: isQueuedPrefix,
59125
+ priorTurnInFlight,
59126
+ isDm: isDmChatId(chat_id),
59127
+ incomingThreadId: messageThreadId ?? null,
59128
+ activeTurnThreadId: currentTurn?.sessionThreadId ?? null,
59129
+ msSinceLastAgentOutput: msSinceOut,
59130
+ dmSteerWindowMs: 0,
59131
+ topicSteerWindowMs: 8000
59132
+ });
59133
+ process.stderr.write(`telegram gateway: autoclassify-shadow chat_id=${chat_id} would=${shadow.decision} reason=${shadow.reason} same_topic=${shadow.sameTopic ?? "-"} ms_since_out=${msSinceOut ?? "-"} actual=${isSteering ? "steer" : "queue"}
59134
+ `);
59135
+ }
59023
59136
  if (access.statusReactions !== false) {
59024
59137
  if (isSteering) {
59025
59138
  bot.api.setMessageReaction(chat_id, msgId, [{ type: "emoji", emoji: "\uD83E\uDD1D" }]).catch(() => {});
@@ -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
+ }