switchroom 0.14.65 → 0.14.66

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.65";
49456
- var COMMIT_SHA = "ff6f75c4";
49455
+ var VERSION = "0.14.66";
49456
+ var COMMIT_SHA = "0f4f029d";
49457
49457
 
49458
49458
  // src/cli/agent.ts
49459
49459
  init_source();
@@ -52027,7 +52027,7 @@ function buildSettingsHooksBlock(p) {
52027
52027
 
52028
52028
  ` + 'Do NOT send a trailing confirmation after your answer \u2014 no "Done.", ' + '"Sent.", "Hope that helps." as a separate message once you have ' + "already replied. Your answer is the last thing the user should " + `see; a follow-up "Done." is dead-air clutter (and the user's ` + `device already pinged on the answer). Stop after the answer.
52029
52029
 
52030
- ` + 'CRITICAL: "answer" means a call to the reply tool ' + "(mcp__switchroom-telegram__reply, or stream_reply with done=true). " + "Your terminal/transcript text is NEVER delivered to Telegram \u2014 the " + "user sees only what you send through the reply tool. After a long " + "tool sequence (scheduling, multi-step research, sub-agent handback), " + "do not let your closing narration stand as the answer: end the turn " + "by passing that narration to the reply tool. No reply tool call = the " + "user got nothing, however much text you wrote.</turn-pacing>";
52030
+ ` + 'CRITICAL: "answer" means a call to the reply tool ' + "(mcp__switchroom-telegram__reply, or stream_reply with done=true). " + "Your terminal/transcript text is NEVER delivered to Telegram \u2014 the " + "user sees only what you send through the reply tool. After a long " + "tool sequence (scheduling, multi-step research, sub-agent handback), " + "do not let your closing narration stand as the answer: end the turn " + "by passing that narration to the reply tool. No reply tool call = the " + "user got nothing, however much text you wrote. Call the reply tool as " + "your FIRST action when you have the answer \u2014 do not write it out as " + "transcript text first and call reply afterward: a framework backstop " + "flushes unsent text after a delay and then your real reply lands late " + "and out of order.</turn-pacing>";
52031
52031
  const switchroomUserPromptSubmit = [
52032
52032
  ...useHotReloadStable ? [
52033
52033
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.65",
3
+ "version": "0.14.66",
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": {
@@ -47936,6 +47936,10 @@ function resolveAnswerThreadId(input) {
47936
47936
  return input.explicitThreadId;
47937
47937
  if (input.originResolved)
47938
47938
  return input.originThreadId;
47939
+ if (input.liveThreadId != null)
47940
+ return input.liveThreadId;
47941
+ if (input.lastEndedResolvedForChat)
47942
+ return input.lastEndedThreadIdForChat;
47939
47943
  return input.liveThreadId;
47940
47944
  }
47941
47945
 
@@ -52759,11 +52763,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
52759
52763
  }
52760
52764
 
52761
52765
  // ../src/build-info.ts
52762
- var VERSION = "0.14.65";
52763
- var COMMIT_SHA = "ff6f75c4";
52764
- var COMMIT_DATE = "2026-06-05T04:09:09Z";
52765
- var LATEST_PR = 2165;
52766
- var COMMITS_AHEAD_OF_TAG = 0;
52766
+ var VERSION = "0.14.66";
52767
+ var COMMIT_SHA = "0f4f029d";
52768
+ var COMMIT_DATE = "2026-06-05T07:05:45Z";
52769
+ var LATEST_PR = 2167;
52770
+ var COMMITS_AHEAD_OF_TAG = 2;
52767
52771
 
52768
52772
  // gateway/boot-version.ts
52769
52773
  function formatRelativeAgo(iso) {
@@ -54061,6 +54065,33 @@ function findTurnByOriginId(originTurnId) {
54061
54065
  return currentTurn;
54062
54066
  return recentTurnsById.get(originTurnId) ?? null;
54063
54067
  }
54068
+ var LATE_REPLY_TOPIC_RECOVERY_ENABLED = process.env.SWITCHROOM_LATE_REPLY_TOPIC_RECOVERY !== "0";
54069
+ function findLatestEndedTurnForChat(chatId) {
54070
+ let latest = null;
54071
+ for (const t of recentTurnsById.values()) {
54072
+ if (t.sessionChatId === chatId)
54073
+ latest = t;
54074
+ }
54075
+ return latest;
54076
+ }
54077
+ function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, liveTurn, surface) {
54078
+ const recovered = LATE_REPLY_TOPIC_RECOVERY_ENABLED && explicitThreadId == null && originTurn == null && liveTurn?.sessionThreadId == null ? findLatestEndedTurnForChat(chatId) : null;
54079
+ const threadId = resolveAnswerThreadId({
54080
+ explicitThreadId,
54081
+ originResolved: originTurn != null,
54082
+ originThreadId: originTurn?.sessionThreadId,
54083
+ liveThreadId: liveTurn?.sessionThreadId,
54084
+ lastEndedResolvedForChat: recovered != null,
54085
+ lastEndedThreadIdForChat: recovered?.sessionThreadId
54086
+ });
54087
+ const via = explicitThreadId != null ? "explicit" : originTurn != null ? "origin" : liveTurn?.sessionThreadId != null ? "live" : recovered != null ? "recovered" : "none";
54088
+ const ownerTurn = originTurn ?? recovered ?? liveTurn;
54089
+ const isSupergroup = chatId.startsWith("-100");
54090
+ const unrouted = isSupergroup && threadId == null;
54091
+ process.stderr.write(`telegram gateway: reply-route surface=${surface} chat=${chatId} resolved_thread=${threadId ?? "-"} via=${via} late=${liveTurn == null} originTurn=${ownerTurn?.turnId ?? "-"} origin_thread=${ownerTurn?.sessionThreadId ?? "-"}` + (via === "recovered" ? " RECOVERED" : "") + (unrouted ? " UNROUTED(supergroup\u2192no-topic)" : "") + `
54092
+ `);
54093
+ return threadId;
54094
+ }
54064
54095
  function closeObligationOnSubstantiveReply(args, liveTurn) {
54065
54096
  if (!OBLIGATION_LEDGER_ENABLED)
54066
54097
  return;
@@ -56291,12 +56322,7 @@ ${url}`;
56291
56322
  if (TURN_ORIGIN_ROUTING_ENABLED) {
56292
56323
  const explicit = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
56293
56324
  const originTurn = findTurnByOriginId(args.origin_turn_id);
56294
- threadId = resolveAnswerThreadId({
56295
- explicitThreadId: Number.isFinite(explicit) ? explicit : undefined,
56296
- originResolved: originTurn != null,
56297
- originThreadId: originTurn?.sessionThreadId,
56298
- liveThreadId: turn?.sessionThreadId
56299
- });
56325
+ threadId = resolveAnswerThreadWithLog(chat_id, Number.isFinite(explicit) ? explicit : undefined, originTurn, turn, "reply");
56300
56326
  } else {
56301
56327
  threadId = resolveThreadId(chat_id, args.message_thread_id ?? (turn?.sessionThreadId != null ? turn.sessionThreadId : undefined));
56302
56328
  }
@@ -56656,12 +56682,7 @@ async function executeStreamReply(args) {
56656
56682
  let injected;
56657
56683
  if (TURN_ORIGIN_ROUTING_ENABLED) {
56658
56684
  const originTurn = findTurnByOriginId(args.origin_turn_id);
56659
- injected = resolveAnswerThreadId({
56660
- explicitThreadId: undefined,
56661
- originResolved: originTurn != null,
56662
- originThreadId: originTurn?.sessionThreadId,
56663
- liveThreadId: turn?.sessionThreadId
56664
- });
56685
+ injected = resolveAnswerThreadWithLog(String(args.chat_id), undefined, originTurn, turn, "stream_reply");
56665
56686
  } else {
56666
56687
  injected = turn?.sessionThreadId;
56667
56688
  }
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { resolveAnswerThreadId } from './answer-thread-resolve.js'
3
+
4
+ describe('resolveAnswerThreadId — precedence', () => {
5
+ it('(1) explicit model thread wins over everything', () => {
6
+ expect(
7
+ resolveAnswerThreadId({
8
+ explicitThreadId: 7,
9
+ originResolved: true,
10
+ originThreadId: 3,
11
+ liveThreadId: 4,
12
+ lastEndedResolvedForChat: true,
13
+ lastEndedThreadIdForChat: 9,
14
+ }),
15
+ ).toBe(7)
16
+ })
17
+
18
+ it('(2) origin turn thread wins over the live turn (the Brevo→Meta fix)', () => {
19
+ expect(
20
+ resolveAnswerThreadId({ originResolved: true, originThreadId: 3, liveThreadId: 4 }),
21
+ ).toBe(3)
22
+ })
23
+
24
+ it('(2) a DM origin (resolved, thread undefined) pins to undefined, not the live thread', () => {
25
+ expect(
26
+ resolveAnswerThreadId({ originResolved: true, originThreadId: undefined, liveThreadId: 4 }),
27
+ ).toBeUndefined()
28
+ })
29
+
30
+ it('(3) no origin → falls back to the live turn thread (legacy #1664)', () => {
31
+ expect(
32
+ resolveAnswerThreadId({ originResolved: false, liveThreadId: 4 }),
33
+ ).toBe(4)
34
+ })
35
+
36
+ // ── tier (4): late-reply topic recovery (2026-06-05) ──────────────────────
37
+ it('(4) no explicit, no origin, NO live turn → recovers the most-recent ended turn thread', () => {
38
+ // The marko bug: a reply that fired after the orphaned-reply backstop ended
39
+ // its turn. Pre-fix this returned undefined (General); now it recovers topic 3.
40
+ expect(
41
+ resolveAnswerThreadId({
42
+ originResolved: false,
43
+ liveThreadId: undefined,
44
+ lastEndedResolvedForChat: true,
45
+ lastEndedThreadIdForChat: 3,
46
+ }),
47
+ ).toBe(3)
48
+ })
49
+
50
+ it('(4) a recovered DM turn (ended, thread undefined) stays threadless', () => {
51
+ expect(
52
+ resolveAnswerThreadId({
53
+ originResolved: false,
54
+ liveThreadId: undefined,
55
+ lastEndedResolvedForChat: true,
56
+ lastEndedThreadIdForChat: undefined,
57
+ }),
58
+ ).toBeUndefined()
59
+ })
60
+
61
+ it('(4) recovery does NOT override a live turn — live thread still wins at tier 3', () => {
62
+ expect(
63
+ resolveAnswerThreadId({
64
+ originResolved: false,
65
+ liveThreadId: 4,
66
+ lastEndedResolvedForChat: true,
67
+ lastEndedThreadIdForChat: 3,
68
+ }),
69
+ ).toBe(4)
70
+ })
71
+
72
+ it('(4) no recovery candidate → legacy result (undefined), unchanged', () => {
73
+ expect(
74
+ resolveAnswerThreadId({
75
+ originResolved: false,
76
+ liveThreadId: undefined,
77
+ lastEndedResolvedForChat: false,
78
+ }),
79
+ ).toBeUndefined()
80
+ })
81
+
82
+ it('pure DM (every tier undefined) → undefined', () => {
83
+ expect(resolveAnswerThreadId({ originResolved: false })).toBeUndefined()
84
+ })
85
+ })
@@ -26,10 +26,14 @@
26
26
  * 3. Else the LIVE turn's thread — but ONLY when the live turn IS the
27
27
  * origin turn (no flip happened) OR no origin turn could be resolved
28
28
  * at all (origin id absent/unknown; legacy / pre-stamp path).
29
- * 4. Else (origin resolved AND it differs from the live turn) we pin to
30
- * the origin thread and explicitly DO NOT fall through to the chat's
31
- * last-seen `chatThreadMap` thread. For answer surfaces the chat
32
- * last-seen heuristic is exactly what produced the wrong-topic bug.
29
+ * 4. Else (no explicit, no origin echoed, no live turn) a LATE reply that
30
+ * fired after its turn already ended (the orphaned-reply backstop case)
31
+ * recover the origin topic from the most-recently-ended turn for this
32
+ * chat. Without this, such a reply defaults to the main chat (General in a
33
+ * supergroup) and its answer vanishes from the topic the user is reading
34
+ * (the 2026-06-05 marko triage). Still NOT the `chatThreadMap` last-seen
35
+ * heuristic — the recovered turn is the chat's own most-recent turn, not
36
+ * whichever topic last received any message.
33
37
  *
34
38
  * The `chatThreadMap` last-seen fallback is preserved for NON-answer
35
39
  * surfaces (`send_typing`, `forward_message`, `progress_update`) by NOT
@@ -53,6 +57,20 @@ export interface AnswerThreadInput {
53
57
  * (no live turn, or a DM live turn). The legacy (#1664) fallback when
54
58
  * no origin turn is resolvable. */
55
59
  liveThreadId?: number | undefined
60
+ /**
61
+ * Late-reply topic recovery (2026-06-05). Thread of the most-recently-ended
62
+ * turn for THIS chat (from `recentTurnsById`), used as a deterministic
63
+ * fallback when the model echoed no `origin_turn_id` AND there is no live
64
+ * turn — the late-reply-after-turn-end case. Without it, a reply that fires
65
+ * after the orphaned-reply backstop closed its turn defaults to the main chat
66
+ * (General topic in a supergroup), so its answer vanishes from the topic the
67
+ * user is reading. Only consulted at tier (4); a DM origin yields undefined,
68
+ * which is correct.
69
+ */
70
+ lastEndedThreadIdForChat?: number | undefined
71
+ /** Whether a recently-ended turn exists for this chat — distinguishes
72
+ * "ended turn exists, DM (thread undefined)" from "no ended turn at all". */
73
+ lastEndedResolvedForChat?: boolean
56
74
  }
57
75
 
58
76
  /**
@@ -75,5 +93,13 @@ export function resolveAnswerThreadId(input: AnswerThreadInput): number | undefi
75
93
  if (input.originResolved) return input.originThreadId
76
94
  // (3) no origin resolved (legacy / pre-stamp / evicted) → fall back to
77
95
  // the live turn's thread, the existing turn-pinned behaviour (#1664).
96
+ if (input.liveThreadId != null) return input.liveThreadId
97
+ // (4) no explicit, no origin echoed, no live turn — a LATE reply that fired
98
+ // after its turn already ended (the orphaned-reply backstop case).
99
+ // Recover the origin topic from the most-recently-ended turn for this
100
+ // chat so the answer lands in the topic it belongs to instead of
101
+ // defaulting to the main chat (General). When no ended turn is known,
102
+ // fall through to liveThreadId (undefined) — the legacy result.
103
+ if (input.lastEndedResolvedForChat) return input.lastEndedThreadIdForChat
78
104
  return input.liveThreadId
79
105
  }
@@ -1888,6 +1888,83 @@ function findTurnByOriginId(originTurnId: string | null | undefined): CurrentTur
1888
1888
  return recentTurnsById.get(originTurnId) ?? null
1889
1889
  }
1890
1890
 
1891
+ // Late-reply topic recovery (2026-06-05 marko triage). Default ON; kill switch
1892
+ // SWITCHROOM_LATE_REPLY_TOPIC_RECOVERY=0 restores the legacy behaviour (a late
1893
+ // reply with no echoed origin and no live turn defaults to General).
1894
+ const LATE_REPLY_TOPIC_RECOVERY_ENABLED =
1895
+ process.env.SWITCHROOM_LATE_REPLY_TOPIC_RECOVERY !== '0'
1896
+
1897
+ /**
1898
+ * The most-recently-started turn for a chat from the bounded recently-ended
1899
+ * registry — the deterministic fallback for a LATE answer reply when the model
1900
+ * echoed no `origin_turn_id` and `currentTurn` has already cleared. Iterates in
1901
+ * insertion order so the last match is the most recent turn for that chat.
1902
+ * Returns null when the chat has no remembered turn (so the caller keeps the
1903
+ * legacy result). NB: this is the chat's own most-recent TURN, not the
1904
+ * `chatThreadMap` last-seen-any-message heuristic that caused the wrong-topic
1905
+ * bug — a late reply almost always belongs to the turn that just ended.
1906
+ */
1907
+ function findLatestEndedTurnForChat(chatId: string): CurrentTurn | null {
1908
+ let latest: CurrentTurn | null = null
1909
+ for (const t of recentTurnsById.values()) {
1910
+ if (t.sessionChatId === chatId) latest = t
1911
+ }
1912
+ return latest
1913
+ }
1914
+
1915
+ /**
1916
+ * Resolve the answer-reply thread AND emit `reply-route` telemetry. The
1917
+ * 2026-06-05 triage showed reply routing was the blind spot: `reply: invoked`
1918
+ * logged only chat + char count, so a late reply landing in the wrong topic was
1919
+ * invisible without hand-correlating raw tg-post threads against turn-lifecycle
1920
+ * timestamps. This wrapper logs, per reply: which precedence tier won (`via`),
1921
+ * the resolved thread, the origin turn + its thread, and whether the reply was
1922
+ * late (turn already ended). `via=recovered` marks a late reply this fix saved
1923
+ * from General; `UNROUTED` flags a supergroup reply that still resolved to no
1924
+ * topic (the residual gap to watch).
1925
+ */
1926
+ function resolveAnswerThreadWithLog(
1927
+ chatId: string,
1928
+ explicitThreadId: number | undefined,
1929
+ originTurn: CurrentTurn | null,
1930
+ liveTurn: CurrentTurn | null,
1931
+ surface: 'reply' | 'stream_reply',
1932
+ ): number | undefined {
1933
+ const recovered =
1934
+ LATE_REPLY_TOPIC_RECOVERY_ENABLED &&
1935
+ explicitThreadId == null &&
1936
+ originTurn == null &&
1937
+ liveTurn?.sessionThreadId == null
1938
+ ? findLatestEndedTurnForChat(chatId)
1939
+ : null
1940
+ const threadId = resolveAnswerThreadId({
1941
+ explicitThreadId,
1942
+ originResolved: originTurn != null,
1943
+ originThreadId: originTurn?.sessionThreadId,
1944
+ liveThreadId: liveTurn?.sessionThreadId,
1945
+ lastEndedResolvedForChat: recovered != null,
1946
+ lastEndedThreadIdForChat: recovered?.sessionThreadId,
1947
+ })
1948
+ const via =
1949
+ explicitThreadId != null ? 'explicit'
1950
+ : originTurn != null ? 'origin'
1951
+ : liveTurn?.sessionThreadId != null ? 'live'
1952
+ : recovered != null ? 'recovered'
1953
+ : 'none'
1954
+ const ownerTurn = originTurn ?? recovered ?? liveTurn
1955
+ const isSupergroup = chatId.startsWith('-100')
1956
+ const unrouted = isSupergroup && threadId == null
1957
+ process.stderr.write(
1958
+ `telegram gateway: reply-route surface=${surface} chat=${chatId} ` +
1959
+ `resolved_thread=${threadId ?? '-'} via=${via} late=${liveTurn == null} ` +
1960
+ `originTurn=${ownerTurn?.turnId ?? '-'} origin_thread=${ownerTurn?.sessionThreadId ?? '-'}` +
1961
+ (via === 'recovered' ? ' RECOVERED' : '') +
1962
+ (unrouted ? ' UNROUTED(supergroup→no-topic)' : '') +
1963
+ '\n',
1964
+ )
1965
+ return threadId
1966
+ }
1967
+
1891
1968
  /**
1892
1969
  * PR2 obligation-ledger CLOSE. Called when a SUBSTANTIVE final answer lands
1893
1970
  * (not a bare interim ack — using finalAnswerSubstantive, the #2141 signal): the
@@ -6522,12 +6599,13 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
6522
6599
  if (TURN_ORIGIN_ROUTING_ENABLED) {
6523
6600
  const explicit = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
6524
6601
  const originTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
6525
- threadId = resolveAnswerThreadId({
6526
- explicitThreadId: Number.isFinite(explicit as number) ? (explicit as number) : undefined,
6527
- originResolved: originTurn != null,
6528
- originThreadId: originTurn?.sessionThreadId,
6529
- liveThreadId: turn?.sessionThreadId,
6530
- })
6602
+ threadId = resolveAnswerThreadWithLog(
6603
+ chat_id,
6604
+ Number.isFinite(explicit as number) ? (explicit as number) : undefined,
6605
+ originTurn,
6606
+ turn,
6607
+ 'reply',
6608
+ )
6531
6609
  } else {
6532
6610
  threadId = resolveThreadId(
6533
6611
  chat_id,
@@ -7178,12 +7256,13 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
7178
7256
  let injected: number | undefined
7179
7257
  if (TURN_ORIGIN_ROUTING_ENABLED) {
7180
7258
  const originTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
7181
- injected = resolveAnswerThreadId({
7182
- explicitThreadId: undefined,
7183
- originResolved: originTurn != null,
7184
- originThreadId: originTurn?.sessionThreadId,
7185
- liveThreadId: turn?.sessionThreadId,
7186
- })
7259
+ injected = resolveAnswerThreadWithLog(
7260
+ String(args.chat_id),
7261
+ undefined,
7262
+ originTurn,
7263
+ turn,
7264
+ 'stream_reply',
7265
+ )
7187
7266
  } else {
7188
7267
  injected = turn?.sessionThreadId
7189
7268
  }
@@ -45,13 +45,15 @@ describe('component 3 — turn-origin reply routing', () => {
45
45
  const fn = gatewaySrc.split('async function executeReply')[1]?.split('\nasync function ')[0] ?? ''
46
46
  expect(fn).toMatch(/TURN_ORIGIN_ROUTING_ENABLED/)
47
47
  expect(fn).toMatch(/findTurnByOriginId\(args\.origin_turn_id/)
48
- expect(fn).toMatch(/resolveAnswerThreadId\(/)
48
+ // The resolution + reply-route telemetry go through resolveAnswerThreadWithLog,
49
+ // which calls the pure resolveAnswerThreadId internally (incl. tier-4 recovery).
50
+ expect(fn).toMatch(/resolveAnswerThread\w*\(/)
49
51
  })
50
52
 
51
53
  it('executeStreamReply resolves the answer thread via the origin turn too', () => {
52
54
  const fn = gatewaySrc.split('async function executeStreamReply')[1]?.split('\nasync function ')[0] ?? ''
53
55
  expect(fn).toMatch(/findTurnByOriginId\(args\.origin_turn_id/)
54
- expect(fn).toMatch(/resolveAnswerThreadId\(/)
56
+ expect(fn).toMatch(/resolveAnswerThread\w*\(/)
55
57
  })
56
58
 
57
59
  it('the reply + stream_reply tool schemas expose origin_turn_id to the model', () => {