switchroom 0.12.20 → 0.12.22

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.
@@ -47247,8 +47247,8 @@ var {
47247
47247
  } = import__.default;
47248
47248
 
47249
47249
  // src/build-info.ts
47250
- var VERSION = "0.12.20";
47251
- var COMMIT_SHA = "5191cef3";
47250
+ var VERSION = "0.12.22";
47251
+ var COMMIT_SHA = "332e23e";
47252
47252
 
47253
47253
  // src/cli/agent.ts
47254
47254
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.12.20",
3
+ "version": "0.12.22",
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": {
@@ -26,12 +26,13 @@
26
26
  "test:vitest": "vitest run",
27
27
  "test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/uat/load-env.test.ts",
28
28
  "test:watch": "vitest",
29
- "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs",
29
+ "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs",
30
30
  "lint:tsc": "tsc --noEmit",
31
31
  "lint:plugin-references": "node scripts/check-plugin-references.mjs",
32
32
  "lint:bot-api-wrapping": "bash scripts/check-bot-api-wrapping.sh",
33
33
  "lint:bun-test-imports": "node scripts/check-bun-test-imports.mjs",
34
34
  "lint:no-pii": "node scripts/check-no-pii-secrets.mjs",
35
+ "lint:no-broadcast-delivery": "node scripts/check-no-broadcast-delivery.mjs",
35
36
  "prepublishOnly": "npm run build && npm run lint && npm test"
36
37
  },
37
38
  "dependencies": {
@@ -31461,7 +31461,8 @@ function createStreamController(cfg) {
31461
31461
 
31462
31462
  // pty-partial-handler.ts
31463
31463
  function streamKey(chatId, threadId) {
31464
- return `${chatId}:${threadId ?? "_"}`;
31464
+ const t = threadId == null || threadId === 0 ? "_" : String(threadId);
31465
+ return `${chatId}:${t}`;
31465
31466
  }
31466
31467
  function handlePtyPartialPure(text, state, deps) {
31467
31468
  if (state.currentSessionChatId == null) {
@@ -31547,7 +31548,8 @@ function buildAccentHeader(accent) {
31547
31548
  }
31548
31549
  }
31549
31550
  function streamKey2(chatId, threadId, lane, turnKey) {
31550
- const base = `${chatId}:${threadId ?? "_"}`;
31551
+ const t = threadId == null || threadId === 0 ? "_" : String(threadId);
31552
+ const base = `${chatId}:${t}`;
31551
31553
  const withLane = lane != null && lane.length > 0 ? `${base}:${lane}` : base;
31552
31554
  return turnKey != null && turnKey.length > 0 ? `${withLane}:${turnKey}` : withLane;
31553
31555
  }
@@ -43803,6 +43805,15 @@ function createPendingPermissionBuffer(opts = {}) {
43803
43805
  };
43804
43806
  }
43805
43807
 
43808
+ // gateway/chat-key.ts
43809
+ function chatKey(chatId, threadId) {
43810
+ const t = threadId == null || threadId === 0 ? "_" : String(threadId);
43811
+ return `${chatId}:${t}`;
43812
+ }
43813
+ function chatKeyWithSuffix(chatId, threadId, suffix) {
43814
+ return `${chatKey(chatId, threadId)}:${suffix}`;
43815
+ }
43816
+
43806
43817
  // gateway/vault-grant-inbound-builders.ts
43807
43818
  function buildVaultGrantApprovedInbound(opts) {
43808
43819
  const ts = opts.nowMs ?? Date.now();
@@ -47115,11 +47126,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
47115
47126
  }
47116
47127
 
47117
47128
  // ../src/build-info.ts
47118
- var VERSION = "0.12.20";
47119
- var COMMIT_SHA = "5191cef3";
47120
- var COMMIT_DATE = "2026-05-20T00:03:17Z";
47121
- var LATEST_PR = 1565;
47122
- var COMMITS_AHEAD_OF_TAG = 6;
47129
+ var VERSION = "0.12.22";
47130
+ var COMMIT_SHA = "332e23e";
47131
+ var COMMIT_DATE = "2026-05-20T02:55:00Z";
47132
+ var LATEST_PR = 1574;
47133
+ var COMMITS_AHEAD_OF_TAG = 2;
47123
47134
 
47124
47135
  // gateway/boot-version.ts
47125
47136
  function formatRelativeAgo(iso) {
@@ -48062,10 +48073,10 @@ var CONTEXT_EXHAUSTION_COOLDOWN_MS = 600000;
48062
48073
  var lastContextExhaustionWarningAt = 0;
48063
48074
  var pendingPtyPartial = null;
48064
48075
  function statusKey(chatId, threadId) {
48065
- return `${chatId}:${threadId ?? "_"}`;
48076
+ return chatKey(chatId, threadId);
48066
48077
  }
48067
48078
  function streamKey3(chatId, threadId) {
48068
- return `${chatId}:${threadId ?? "_"}`;
48079
+ return chatKey(chatId, threadId);
48069
48080
  }
48070
48081
  function purgeReactionTracking(key) {
48071
48082
  const msgInfo = activeReactionMsgIds.get(key);
@@ -50526,7 +50537,7 @@ function resetOrphanedReplyTimeout() {
50526
50537
  }
50527
50538
  }
50528
50539
  function closeActivityLane(chatId, threadId) {
50529
- const key = `${chatId}:${threadId ?? "_"}:activity`;
50540
+ const key = chatKeyWithSuffix(chatId, threadId, "activity");
50530
50541
  const stream = activeDraftStreams.get(key);
50531
50542
  if (stream == null)
50532
50543
  return;
@@ -50535,7 +50546,7 @@ function closeActivityLane(chatId, threadId) {
50535
50546
  stream.finalize().catch(() => {});
50536
50547
  }
50537
50548
  function closeProgressLane(chatId, threadId) {
50538
- const prefix = `${chatId}:${threadId ?? "_"}:progress`;
50549
+ const prefix = chatKeyWithSuffix(chatId, threadId, "progress");
50539
50550
  for (const [key, stream] of activeDraftStreams) {
50540
50551
  if (key.startsWith(prefix)) {
50541
50552
  activeDraftStreams.delete(key);
@@ -50575,7 +50586,8 @@ function handleSessionEvent(ev) {
50575
50586
  currentTurn = next;
50576
50587
  preambleSuppressor.reset();
50577
50588
  if (turnsDb != null) {
50578
- const turnKey = `${ev.chatId}:${ev.threadId ?? "_"}:${startedAt}`;
50589
+ const evThreadIdNum = ev.threadId != null ? Number(ev.threadId) : null;
50590
+ const turnKey = chatKeyWithSuffix(ev.chatId, evThreadIdNum, String(startedAt));
50579
50591
  next.registryKey = turnKey;
50580
50592
  const userPromptPreview = extractUserPromptPreview(ev.rawContent);
50581
50593
  try {
@@ -50808,7 +50820,7 @@ function handleSessionEvent(ev) {
50808
50820
  if (flushDecision.kind === "skip" && flushDecision.reason === "silent-marker") {
50809
50821
  process.stderr.write(`telegram gateway: silent-turn-suppression: chat=${chatId} turnKey=${turn.startedAt} reason=silent-marker
50810
50822
  `);
50811
- const suppressPrefix = `${chatId}:${threadId ?? "_"}:progress`;
50823
+ const suppressPrefix = chatKeyWithSuffix(chatId, threadId, "progress");
50812
50824
  for (const [key] of activeDraftStreams) {
50813
50825
  if (key.startsWith(suppressPrefix)) {
50814
50826
  activeDraftStreams.delete(key);
@@ -51263,6 +51275,7 @@ async function handleInbound(ctx, text, downloadImage, attachment) {
51263
51275
  return;
51264
51276
  }
51265
51277
  const inboundReceivedAt = Date.now();
51278
+ const turnInFlightAtReceipt = activeTurnStartedAt.size > 0;
51266
51279
  const access = result.access;
51267
51280
  const from = ctx.from;
51268
51281
  const chat_id = String(ctx.chat.id);
@@ -51822,7 +51835,7 @@ ${preBlock(write.output)}`;
51822
51835
  };
51823
51836
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
51824
51837
  if (decideInboundDelivery({
51825
- turnInFlight: activeTurnStartedAt.size > 0,
51838
+ turnInFlight: turnInFlightAtReceipt,
51826
51839
  isSteering
51827
51840
  }) === "buffer-until-idle") {
51828
51841
  pendingInboundBuffer.push(selfAgent, inboundMsg);
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Canonical chat-key construction with branded type.
3
+ *
4
+ * The gateway tracks per-chat state (active turn, draft stream, status
5
+ * reactions, progress throttle, etc.) in `Map<string, ...>` instances
6
+ * keyed by a string derived from `(chatId, threadId | null)`. Multiple
7
+ * callsites historically constructed those keys inline as
8
+ * `` `${chatId}:${threadId ?? '_'}` ``. That worked until PR #1564 hit
9
+ * the silence-poke wedge: an `activeTurnStartedAt` entry survived a
10
+ * turn-end because the entry was set under one rendering of the key
11
+ * (`null` thread → `_`) and the cleanup ran against another
12
+ * (`undefined` thread → `_`, but also `0` thread → `0`, which doesn't
13
+ * coalesce). The fallback's `purgeReactionTracking` cleared one
14
+ * statusKey at a time; sibling keys for the same chat persisted, so
15
+ * `activeTurnStartedAt.size > 0` stayed true forever and every
16
+ * subsequent inbound was held mid-turn.
17
+ *
18
+ * This module makes the bug class hard to reintroduce:
19
+ *
20
+ * 1. `chatKey()` is the canonical constructor for "this chat+thread"
21
+ * keys. It collapses `null`, `undefined`, and `0` thread IDs
22
+ * into the same token (`_`). All sites in `gateway.ts`,
23
+ * `stream-reply-handler.ts`, and `pty-partial-handler.ts` route
24
+ * through it (or mirror its expression inline).
25
+ * 2. The returned `ChatKey` is a branded string — a documentation
26
+ * marker for human readers. `telegram-plugin/` is bun-bundled
27
+ * and NOT in `tsconfig.json`'s `include`; `lint:plugin-references`
28
+ * filters tsc output to reference-class errors only (TS2304/2552/
29
+ * 2722/2561). A future opt-in `Map<ChatKey, T>` migration could
30
+ * provide enforcement teeth, but that requires either bringing
31
+ * `telegram-plugin/` into the strict tsc check or rewriting the
32
+ * lint to fail on TS2345 type-mismatch. Today the brand is a
33
+ * nudge, not a fence.
34
+ *
35
+ * `chatKeyWithSuffix()` extends a ChatKey with a lane suffix
36
+ * (`activity`, `progress`, etc.) — same documentation brand.
37
+ *
38
+ * Non-ChatKey keyspaces (chat:message, chat:user, chat:sender:reason)
39
+ * are deliberately NOT branded — they have different shape and
40
+ * different invariants, see `deferredKey` / `inboundCoalesceKey` /
41
+ * the gate-dedup helper in `gateway.ts`.
42
+ */
43
+
44
+ export type ChatKey = string & { readonly __brand: 'ChatKey' }
45
+
46
+ export function chatKey(chatId: string, threadId?: number | null): ChatKey {
47
+ const t = threadId == null || threadId === 0 ? '_' : String(threadId)
48
+ return `${chatId}:${t}` as ChatKey
49
+ }
50
+
51
+ export function chatKeyWithSuffix(
52
+ chatId: string,
53
+ threadId: number | null | undefined,
54
+ suffix: string,
55
+ ): ChatKey {
56
+ return `${chatKey(chatId, threadId)}:${suffix}` as ChatKey
57
+ }
58
+
59
+ /**
60
+ * Extract the chatId portion of a ChatKey. Used by sibling-key sweeps
61
+ * (PR #1564's `purgeStaleTurnsForChat`) that need to enumerate every
62
+ * thread under a single chat.
63
+ */
64
+ export function chatIdOfChatKey(key: ChatKey): string {
65
+ const idx = key.indexOf(':')
66
+ return idx === -1 ? key : key.slice(0, idx)
67
+ }
@@ -253,6 +253,7 @@ import { createInboundSpool } from './inbound-spool.js'
253
253
  import { purgeStaleTurnsForChat } from './turn-state-purge.js'
254
254
  import { decideInboundDelivery } from './inbound-delivery-gate.js'
255
255
  import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
256
+ import { chatKey, chatKeyWithSuffix } from './chat-key.js'
256
257
  import {
257
258
  buildVaultGrantApprovedInbound,
258
259
  buildVaultGrantDeniedInbound,
@@ -1026,6 +1027,7 @@ function checkApprovals(): void {
1026
1027
  try { files = readdirSync(APPROVED_DIR) } catch { return }
1027
1028
  for (const senderId of files) {
1028
1029
  const file = join(APPROVED_DIR, senderId)
1030
+ // allow-raw-bot-api: Paired! sendMessage to DM senderId; no thread_id, cannot trigger THREAD_NOT_FOUND
1029
1031
  void bot.api.sendMessage(senderId, "Paired! Say hi to Claude.").then(
1030
1032
  () => rmSync(file, { force: true }),
1031
1033
  err => {
@@ -1248,12 +1250,18 @@ let lastContextExhaustionWarningAt = 0
1248
1250
 
1249
1251
  let pendingPtyPartial: string | null = null
1250
1252
 
1251
- function statusKey(chatId: string, threadId?: number): string {
1252
- return `${chatId}:${threadId ?? '_'}`
1253
+ // statusKey + streamKey are thin re-exports of the canonical chatKey()
1254
+ // constructor (see telegram-plugin/gateway/chat-key.ts). chatKey()
1255
+ // collapses 0/null/undefined thread IDs to the same token (`_`),
1256
+ // closing the sibling-key class behind #1564 (where one site set the
1257
+ // entry under `null → _` and another cleared under `0 → 0`, leaving
1258
+ // activeTurnStartedAt non-empty forever).
1259
+ function statusKey(chatId: string, threadId?: number | null): string {
1260
+ return chatKey(chatId, threadId)
1253
1261
  }
1254
1262
 
1255
- function streamKey(chatId: string, threadId?: number): string {
1256
- return `${chatId}:${threadId ?? '_'}`
1263
+ function streamKey(chatId: string, threadId?: number | null): string {
1264
+ return chatKey(chatId, threadId)
1257
1265
  }
1258
1266
 
1259
1267
  function purgeReactionTracking(key: string): void {
@@ -2675,6 +2683,7 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
2675
2683
  parse_mode: 'HTML' as const,
2676
2684
  ...(renderedKeyboard ? { reply_markup: renderedKeyboard } : {}),
2677
2685
  }
2686
+ // allow-raw-bot-api: operator-event broadcast loop; opts has no message_thread_id
2678
2687
  void bot.api.sendMessage(chat_id, renderedText, opts as never).catch(e => {
2679
2688
  process.stderr.write(
2680
2689
  `telegram gateway: operator-event send to ${chat_id} failed agent=${agent} kind=${kind}: ${e}\n`,
@@ -3453,6 +3462,7 @@ const ipcServer: IpcServer = createIpcServer({
3453
3462
  .text(`🔁 Always allow ${alwaysRule.label}`, `perm:always:${requestId}`)
3454
3463
  }
3455
3464
  for (const chat_id of access.allowFrom) {
3465
+ // allow-raw-bot-api: permission-request keyboard fan-out; reply_markup-only opts, no thread_id
3456
3466
  void bot.api.sendMessage(chat_id, text, { reply_markup: keyboard }).catch(e => {
3457
3467
  process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}\n`)
3458
3468
  })
@@ -4069,6 +4079,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4069
4079
  stripped.length > 0
4070
4080
  ? stripped
4071
4081
  : '⚠️ (a formatted fragment could not be rendered for Telegram)'
4082
+ // allow-raw-bot-api: plaintext last-resort fallback; wrapping would re-enter the parse-mode policy that just rejected the payload
4072
4083
  const sent = await lockedBot.api.sendMessage(chat_id, plain, plainOpts as never)
4073
4084
  sentIds.push(sent.message_id)
4074
4085
  logOutbound('reply', chat_id, sent.message_id, plain.length, `chunk=${i + 1}/${chunks.length} plaintext-fallback`)
@@ -4090,6 +4101,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4090
4101
  const retryOpts = { ...sendOpts }
4091
4102
  delete (retryOpts as any).message_thread_id
4092
4103
  try {
4104
+ // allow-raw-bot-api: chunk-loop THREAD_NOT_FOUND fallback; thread already dropped, wrapping would re-enter the throw
4093
4105
  const sent = await lockedBot.api.sendMessage(chat_id, chunks[i], retryOpts as never)
4094
4106
  sentIds.push(sent.message_id)
4095
4107
  } catch (retryErr) {
@@ -5392,7 +5404,7 @@ function resetOrphanedReplyTimeout(): void {
5392
5404
  }
5393
5405
 
5394
5406
  function closeActivityLane(chatId: string, threadId: number | undefined): void {
5395
- const key = `${chatId}:${threadId ?? '_'}:activity`
5407
+ const key = chatKeyWithSuffix(chatId, threadId, 'activity')
5396
5408
  const stream = activeDraftStreams.get(key)
5397
5409
  if (stream == null) return
5398
5410
  activeDraftStreams.delete(key)
@@ -5404,7 +5416,7 @@ function closeProgressLane(chatId: string, threadId: number | undefined): void {
5404
5416
  // Progress-card streams include a turnKey suffix in their key
5405
5417
  // (e.g. "chatId:_:progress:chatId:1"). Iterate and match by prefix
5406
5418
  // so the backstop actually finds the stream.
5407
- const prefix = `${chatId}:${threadId ?? '_'}:progress`
5419
+ const prefix = chatKeyWithSuffix(chatId, threadId, 'progress')
5408
5420
  for (const [key, stream] of activeDraftStreams) {
5409
5421
  if (key.startsWith(prefix)) {
5410
5422
  activeDraftStreams.delete(key)
@@ -5460,7 +5472,11 @@ function handleSessionEvent(ev: SessionEvent): void {
5460
5472
  // progress-card-driver's per-chat sequence number (these are two
5461
5473
  // independent identifier schemes and don't need to align).
5462
5474
  if (turnsDb != null) {
5463
- const turnKey = `${ev.chatId}:${ev.threadId ?? '_'}:${startedAt}`
5475
+ // ev.threadId is `string | null` (Telegram emits as string); convert
5476
+ // to number for chatKeyWithSuffix. Number(null) = 0 which canonicalizes
5477
+ // to '_' — same as the explicit `null` branch below.
5478
+ const evThreadIdNum = ev.threadId != null ? Number(ev.threadId) : null
5479
+ const turnKey = chatKeyWithSuffix(ev.chatId, evThreadIdNum, String(startedAt))
5464
5480
  next.registryKey = turnKey
5465
5481
  // Phase 1 of #332: capture first ~200 chars of the user's message.
5466
5482
  const userPromptPreview = extractUserPromptPreview(ev.rawContent)
@@ -5857,7 +5873,7 @@ function handleSessionEvent(ev: SessionEvent): void {
5857
5873
  // Drop progress-card streams without finalising — the normal
5858
5874
  // closeProgressLane call below would call stream.finalize() which
5859
5875
  // sends a final "Done" edit to Telegram. Skip that for silent turns.
5860
- const suppressPrefix = `${chatId}:${threadId ?? '_'}:progress`
5876
+ const suppressPrefix = chatKeyWithSuffix(chatId, threadId, 'progress')
5861
5877
  for (const [key] of activeDraftStreams) {
5862
5878
  if (key.startsWith(suppressPrefix)) {
5863
5879
  activeDraftStreams.delete(key)
@@ -6621,6 +6637,39 @@ async function handleInbound(
6621
6637
  // network RTT) but not a user-perceived end-to-end measurement.
6622
6638
  const inboundReceivedAt = Date.now()
6623
6639
 
6640
+ // #1556 self-blocking fix (v0.12.22): snapshot the live turn-state
6641
+ // BEFORE the fresh-turn branch (line ~7357) sets activeTurnStartedAt
6642
+ // for THIS inbound. The #1556 delivery gate further down asks "is a
6643
+ // turn ALREADY in flight" — but if we read activeTurnStartedAt.size
6644
+ // at the gate, we see the entry this handler just wrote, and buffer
6645
+ // the very message that just started the turn. Symptom: every first
6646
+ // post-restart message in each thread was held 5 minutes until the
6647
+ // silence-poke fallback drained the buffer; the "5-min blank after
6648
+ // restart" wedge documented in
6649
+ // feedback_5min_restart_wedge_violates_vision.md.
6650
+ //
6651
+ // Why ONLY first-after-restart, not every steady-state message: the
6652
+ // fresh-turn branch fires only when `priorActive = activeStatusReactions.get(key)`
6653
+ // returns null (no controller currently running for this chat+thread).
6654
+ // In a live conversation, the controller from the previous turn
6655
+ // typically hasn't been cleared yet when the user's follow-up
6656
+ // arrives (or follow-ups arrive mid-turn, taking the queued path) —
6657
+ // so the else branch at ~7313 is skipped and the .set never fires.
6658
+ // A fresh container after restart has EMPTY `activeStatusReactions`,
6659
+ // so the very first message in each thread is guaranteed to enter
6660
+ // the fresh-turn branch and trigger the self-block.
6661
+ //
6662
+ // Why snapshot, not move-the-set(): the .set() at ~7357 is embedded
6663
+ // in a coupled init bundle (controller, msgIds, signalTracker.reset,
6664
+ // silencePoke.startTurn, the 👀 reaction emit). Moving only the .set
6665
+ // splits the bundle in ways future maintainers will drift; moving
6666
+ // the WHOLE bundle past the gate changes user-visible ack timing
6667
+ // (👀 wouldn't land until after the gate decides to deliver, hiding
6668
+ // an ack on the buffered path). The snapshot is the minimal precise
6669
+ // fix. Phase 2b's state-machine extraction will revisit this
6670
+ // structurally.
6671
+ const turnInFlightAtReceipt = activeTurnStartedAt.size > 0
6672
+
6624
6673
  const access = result.access
6625
6674
  const from = ctx.from!
6626
6675
  const chat_id = String(ctx.chat!.id)
@@ -7530,9 +7579,15 @@ async function handleInbound(
7530
7579
  // idle-drain flush it the instant claude goes idle, where the channel
7531
7580
  // notification submits cleanly as a fresh turn. Steering messages are
7532
7581
  // exempt — reaching claude mid-turn is the whole point of /steer.
7582
+ //
7583
+ // CRITICAL: turnInFlight reads the snapshot taken at receipt above,
7584
+ // not `activeTurnStartedAt.size > 0` live. The fresh-turn branch at
7585
+ // line ~7357 already populated the Map for THIS inbound's turn;
7586
+ // reading the live size here would self-block (see the comment on
7587
+ // turnInFlightAtReceipt for the wedge symptom this fixes).
7533
7588
  if (
7534
7589
  decideInboundDelivery({
7535
- turnInFlight: activeTurnStartedAt.size > 0,
7590
+ turnInFlight: turnInFlightAtReceipt,
7536
7591
  isSteering,
7537
7592
  }) === 'buffer-until-idle'
7538
7593
  ) {
@@ -10978,6 +11033,7 @@ async function grantWizardStep2(ctx: Context, chatId: string, agent: string, wiz
10978
11033
  const kb = buildGrantKeysKeyboard(keys, selected)
10979
11034
  const text = `<b>Grant capability token — Step 2/3</b>\n\nWhich keys for <code>${escapeHtmlForTg(agent)}</code>?\n<i>Tap to toggle; tap Continue when done.</i>`
10980
11035
  if (wizardMsgId != null) {
11036
+ // allow-raw-bot-api: vault grant wizard step 2/3; already .catch-swallows, tap-driven UI re-renders on retry
10981
11037
  await ctx.api.editMessageText(chatId, wizardMsgId, text, { parse_mode: 'HTML', reply_markup: kb }).catch(() => {})
10982
11038
  } else {
10983
11039
  const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
@@ -11001,6 +11057,7 @@ async function grantWizardStep3(ctx: Context, chatId: string, state: Extract<Pen
11001
11057
  const text = `<b>Grant capability token — Step 3/3</b>\n\nKeys for <code>${escapeHtmlForTg(state.agent!)}</code>:\n${keyList}\n\nHow long should this grant be valid?`
11002
11058
  const msgId = state.wizardMsgId
11003
11059
  if (msgId != null) {
11060
+ // allow-raw-bot-api: vault grant wizard step 3/3 (TTL select); already .catch-swallows, tap-driven UI re-renders on retry
11004
11061
  await ctx.api.editMessageText(chatId, msgId, text, { parse_mode: 'HTML', reply_markup: kb }).catch(() => {})
11005
11062
  } else {
11006
11063
  const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
@@ -11025,6 +11082,7 @@ async function grantWizardConfirm(ctx: Context, chatId: string, state: Extract<P
11025
11082
  ].join('\n')
11026
11083
  const msgId = state.wizardMsgId
11027
11084
  if (msgId != null) {
11085
+ // allow-raw-bot-api: vault grant wizard confirm step; already .catch-swallows, tap-driven UI re-renders on retry
11028
11086
  await ctx.api.editMessageText(chatId, msgId, text, { parse_mode: 'HTML', reply_markup: kb }).catch(() => {})
11029
11087
  } else {
11030
11088
  const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
@@ -13462,6 +13520,7 @@ function handleChecklistUpdate(
13462
13520
  ts: new Date(ts * 1000).toISOString(),
13463
13521
  },
13464
13522
  }
13523
+ // allow-broadcast: informational checklist task notification, not turn-driving
13465
13524
  ipcServer.broadcast(inboundMsg)
13466
13525
  process.stderr.write(
13467
13526
  `telegram gateway: checklist ${kind}: chat_id=${chat_id} message_id=${message_id} task_id=${taskId} state=${state} user=${userName}\n`,
@@ -13913,7 +13972,7 @@ async function shutdown(signal: string): Promise<void> {
13913
13972
  // gateway no longer pre-allocates drafts on inbound, so there is
13914
13973
  // nothing to clear at SIGTERM time.
13915
13974
 
13916
- // Notify bridges so they can mark themselves disconnected.
13975
+ // allow-broadcast: informational shutdown notify bridges mark themselves disconnected
13917
13976
  ipcServer.broadcast({ type: 'status', status: 'gateway_shutting_down' })
13918
13977
 
13919
13978
  // Hard force-exit safety net at budget + 5s. systemd's TimeoutStopSec
@@ -94,7 +94,10 @@ export interface PtyHandlerDeps {
94
94
  }
95
95
 
96
96
  function streamKey(chatId: string, threadId?: number): string {
97
- return `${chatId}:${threadId ?? '_'}`
97
+ // Canonical chat-key derivation lives in gateway/chat-key.ts — keep this
98
+ // expression in lockstep (treats 0/null/undefined the same). See #1564.
99
+ const t = threadId == null || threadId === 0 ? '_' : String(threadId)
100
+ return `${chatId}:${t}`
98
101
  }
99
102
 
100
103
  /**
@@ -313,7 +313,12 @@ function streamKey(
313
313
  lane?: string,
314
314
  turnKey?: string,
315
315
  ): string {
316
- const base = `${chatId}:${threadId ?? '_'}`
316
+ // Canonical chat-key derivation lives in gateway/chat-key.ts — keep this
317
+ // expression in lockstep with that helper (treats 0/null/undefined the
318
+ // same), but inline here so this file doesn't introduce a cross-package
319
+ // import for one expression. See #1564 for the sibling-key bug class.
320
+ const t = threadId == null || threadId === 0 ? '_' : String(threadId)
321
+ const base = `${chatId}:${t}`
317
322
  const withLane = lane != null && lane.length > 0 ? `${base}:${lane}` : base
318
323
  return turnKey != null && turnKey.length > 0 ? `${withLane}:${turnKey}` : withLane
319
324
  }
@@ -50,7 +50,11 @@ function statusKey(chatId: string, threadId?: number): string {
50
50
  }
51
51
 
52
52
  function streamKey(chatId: string, threadId?: number): string {
53
- return `${chatId}:${threadId ?? 'default'}`
53
+ // Mirrors production's canonical chat-key derivation (gateway/chat-key.ts):
54
+ // 0/null/undefined thread IDs all collapse to '_'. Diverging sentinels
55
+ // here would let the harness pass with a key shape production rejects.
56
+ const t = threadId == null || threadId === 0 ? '_' : String(threadId)
57
+ return `${chatId}:${t}`
54
58
  }
55
59
 
56
60
  interface PluginState {
@@ -29,7 +29,11 @@ function statusKey(chatId: string, threadId?: number): string {
29
29
  return `${chatId}:${threadId ?? '_'}`
30
30
  }
31
31
  function streamKey(chatId: string, threadId?: number): string {
32
- return `${chatId}:${threadId ?? 'default'}`
32
+ // Mirrors production's canonical chat-key derivation (gateway/chat-key.ts):
33
+ // 0/null/undefined thread IDs all collapse to '_'. Diverging sentinels
34
+ // here would let the harness pass with a key shape production rejects.
35
+ const t = threadId == null || threadId === 0 ? '_' : String(threadId)
36
+ return `${chatId}:${t}`
33
37
  }
34
38
 
35
39
  interface PluginState {
@@ -0,0 +1,157 @@
1
+ /**
2
+ * JTBD scenario — always-on vision: first message after restart replies
3
+ * quickly, NOT 5 minutes later via silence-poke fallback.
4
+ *
5
+ * Vision (`reference/vision.md`, see [[project_vision_reanchor_human_assistants]]):
6
+ * agents are always-on specialist exec-assistants. A 5-min blank
7
+ * window on the first message after restart is what BROKEN feels
8
+ * like to a user trying to use their assistant.
9
+ *
10
+ * ## The wedge this guards against
11
+ *
12
+ * Pre-v0.12.22, every agent restart produced ~5 min blank on the
13
+ * first user message in each thread:
14
+ *
15
+ * 1. User sends msg → handleInbound runs the fresh-turn branch
16
+ * → activeTurnStartedAt.set(key, now) at gateway.ts:7357
17
+ * 2. The #1556 delivery gate further down (~7551) checks
18
+ * activeTurnStartedAt.size > 0 to decide if a turn is "already
19
+ * in flight" — sees the entry it just wrote → buffer-until-idle
20
+ * 3. Inbound stuck in pendingInboundBuffer. Bridge never sees it.
21
+ * Claude never replies. activeTurnStartedAt[key] stays set.
22
+ * 4. ~300s later silence-poke framework-fallback fires, drains
23
+ * the buffer, the reply finally lands — five minutes late.
24
+ *
25
+ * Documented in `feedback_5min_restart_wedge_violates_vision.md`.
26
+ * Fix is a one-line snapshot of the live size at receipt-time before
27
+ * the fresh-turn branch mutates the Map.
28
+ *
29
+ * ## What this UAT asserts
30
+ *
31
+ * After a deliberate restart, the FIRST message in a fresh thread
32
+ * gets a reply within a budget that excludes the silence-poke
33
+ * fallback floor (300s). Concretely we assert < 120s, which is
34
+ * generous for slow LLM replies but well below the wedge symptom.
35
+ *
36
+ * The test also makes a stricter observation log so a future
37
+ * regression that lands BETWEEN "wedge fixed" (~LLM latency) and
38
+ * "wedge present" (~5 min) is visible — e.g. some slow startup
39
+ * path that takes 60-90s would pass the contract but bear
40
+ * investigation.
41
+ *
42
+ * ## Why this scenario specifically
43
+ *
44
+ * - `smoke-dm-reply.test.ts` covers steady-state inbound→reply but
45
+ * does NOT restart the agent first, so the wedge surfaces as a
46
+ * "first message is slow" pattern that the smoke test would
47
+ * silently absorb on warm runs.
48
+ * - `silent-end-recovery-dm.test.ts` covers mid-turn silent-end →
49
+ * no-reply, but at 6 min budget — too generous to catch the
50
+ * 5-min wedge as a regression.
51
+ * - This is the FIRST UAT to explicitly tie the assertion to the
52
+ * "always-on" vision and measure first-after-restart TTFO.
53
+ */
54
+
55
+ import { describe, it, expect, beforeAll } from "vitest";
56
+ import { execSync } from "node:child_process";
57
+ import { spinUp } from "../harness.js";
58
+
59
+ const AGENT = "test-harness";
60
+
61
+ // Budget for the marker-safe restart itself (per
62
+ // feedback_agent_restart_needs_sudo_when_running.md, restart blocks
63
+ // ~30s as the gateway's bridge reattaches).
64
+ const RESTART_BUDGET_MS = 90_000;
65
+
66
+ // Hard contract: first-after-restart reply must land in under 2 min.
67
+ // This is generous for slow LLM replies but well below the 5-min
68
+ // silence-poke fallback floor — a regression of the #1556 wedge
69
+ // would trip on TTFO ≥ 300s and fail the test.
70
+ const HARD_REPLY_BUDGET_MS = 120_000;
71
+
72
+ // Vision-aligned target: real expectation is well under 30s on a
73
+ // healthy fleet. A pass between 30-120s is yellow — covered by the
74
+ // contract but worth logging for forensic visibility.
75
+ const VISION_REPLY_BUDGET_MS = 30_000;
76
+
77
+ function canShellSudo(): boolean {
78
+ try {
79
+ execSync("sudo -n true", { stdio: "ignore", timeout: 2_000 });
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ function restartAgent(name: string): void {
87
+ // Marker-safe restart per memory feedback_compose_rollout.md +
88
+ // feedback_agent_restart_needs_sudo_when_running.md. Apply step
89
+ // self-elevates internally; restart needs the wrapper. We don't
90
+ // call apply here — the agent scaffolds are already current; only
91
+ // the in-memory state needs to reset.
92
+ execSync(
93
+ `sudo -n env PATH=$PATH HOME=$HOME switchroom agent restart ${name} --force`,
94
+ { stdio: ["ignore", "pipe", "pipe"], timeout: RESTART_BUDGET_MS },
95
+ );
96
+ }
97
+
98
+ // This scenario requires NOPASSWD sudo + the switchroom CLI on PATH on
99
+ // the harness host. Skip on CI runners that don't expose those.
100
+ const sudoOk = canShellSudo();
101
+
102
+ (sudoOk ? describe : describe.skip)(
103
+ "uat: always-on after restart",
104
+ () => {
105
+ beforeAll(() => {
106
+ restartAgent(AGENT);
107
+ // Brief settle so the bridge sidecar finishes its reattach
108
+ // before we send the first inbound. The bridge-register log
109
+ // line is the earliest the agent can accept inbound.
110
+ return new Promise((r) => setTimeout(r, 5_000));
111
+ }, RESTART_BUDGET_MS + 10_000);
112
+
113
+ it(
114
+ "first message after fresh restart → reply within 2 min (NOT the 5-min wedge)",
115
+ async () => {
116
+ const sc = await spinUp({ agent: AGENT });
117
+ try {
118
+ const sendStart = Date.now();
119
+ await sc.sendDM("ping — JTBD always-on UAT");
120
+
121
+ const firstReply = await sc.expectMessage(/\S/, {
122
+ from: "bot",
123
+ timeout: HARD_REPLY_BUDGET_MS,
124
+ });
125
+ const ttfo = Date.now() - sendStart;
126
+
127
+ expect(firstReply.text.length).toBeGreaterThan(0);
128
+
129
+ // HARD CONTRACT: the wedge symptom is 300s+ TTFO. Anything
130
+ // ≥ HARD_REPLY_BUDGET_MS (120s) flags a regression of the
131
+ // #1556 self-blocking gate.
132
+ if (ttfo >= HARD_REPLY_BUDGET_MS) {
133
+ throw new Error(
134
+ `[always-on] first-post-restart reply took ${ttfo}ms — ` +
135
+ `matches the #1556 wedge symptom (5-min silence-poke fallback). ` +
136
+ `Vision broken; see feedback_5min_restart_wedge_violates_vision.md`,
137
+ );
138
+ }
139
+ expect(ttfo).toBeLessThan(HARD_REPLY_BUDGET_MS);
140
+
141
+ // Yellow-band log: passes the contract but degraded from the
142
+ // vision target. Worth investigating if this fires regularly.
143
+ if (ttfo >= VISION_REPLY_BUDGET_MS) {
144
+ console.warn(
145
+ `[always-on] first-post-restart TTFO=${ttfo}ms — passed ` +
146
+ `contract (${HARD_REPLY_BUDGET_MS}ms) but slower than the ` +
147
+ `vision target (${VISION_REPLY_BUDGET_MS}ms). Forensic flag.`,
148
+ );
149
+ }
150
+ } finally {
151
+ await sc.tearDown();
152
+ }
153
+ },
154
+ HARD_REPLY_BUDGET_MS + 10_000,
155
+ );
156
+ },
157
+ );