switchroom 0.19.4 → 0.19.6

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.
Files changed (43) hide show
  1. package/dist/auth-broker/index.js +7 -3
  2. package/dist/cli/autoaccept-poll.js +8 -2
  3. package/dist/cli/switchroom.js +20 -5
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +1 -1
  6. package/profiles/_base/start.sh.hbs +67 -4
  7. package/telegram-plugin/dist/gateway/gateway.js +585 -302
  8. package/telegram-plugin/flushed-turn-supersede.ts +43 -7
  9. package/telegram-plugin/gateway/command-format.ts +253 -0
  10. package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
  11. package/telegram-plugin/gateway/gateway.ts +128 -259
  12. package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
  13. package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
  14. package/telegram-plugin/gateway/outbound-send-path.ts +51 -11
  15. package/telegram-plugin/gateway/pending-inbound-buffer.ts +27 -0
  16. package/telegram-plugin/gateway/session-model-file.ts +13 -0
  17. package/telegram-plugin/gateway/stream-render.ts +18 -1
  18. package/telegram-plugin/gateway/subagent-handback-marker.ts +42 -0
  19. package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
  20. package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
  21. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
  22. package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
  23. package/telegram-plugin/render/line-start-guard.ts +76 -4
  24. package/telegram-plugin/reply-owner-resolve.ts +43 -7
  25. package/telegram-plugin/rich-send.ts +8 -1
  26. package/telegram-plugin/tests/command-format.test.ts +212 -0
  27. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +89 -0
  28. package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
  29. package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
  30. package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
  31. package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -1
  32. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
  33. package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
  34. package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
  35. package/telegram-plugin/tests/reply-owner-resolve.test.ts +74 -0
  36. package/telegram-plugin/tests/send-reply-golden.test.ts +221 -6
  37. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
  38. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
  39. package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
  40. package/telegram-plugin/tests/silent-end.test.ts +60 -5
  41. package/telegram-plugin/tests/stream-render-golden.test.ts +2 -1
  42. package/telegram-plugin/tests/subagent-handback-marker.test.ts +36 -0
  43. package/telegram-plugin/tests/worker-feed-origin-race-defer.test.ts +321 -0
@@ -243,6 +243,7 @@ import {
243
243
  distinctRequestIds,
244
244
  } from './permission-rearm.js'
245
245
  import { sweepPermissionTtl } from './permission-ttl-sweep.js'
246
+ import { hangStalenessMs } from './hang-restart-decision.js'
246
247
  import { createMissedApprovalsStore, type MissedApproval } from './missed-approvals-store.js'
247
248
  import {
248
249
  createAlwaysAllowPersistQueue,
@@ -437,6 +438,7 @@ const REPLY_TO_TEXT_MAX = 200
437
438
  // (`silentEndFallbackText`, imported above) so the transport-boundary
438
439
  // tests exercise the real string — see PR #2892.
439
440
  import { splitMarkdownChunks, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, normalizePunctuation, stripExcessBold, hardenCardBreaks, RICH_MESSAGE_MAX_CHARS } from '../format.js'
441
+ import { formatSwitchroomOutput, stripAnsi, escapeHtmlForTg, preBlock, getCommandArgs, hasDemoFlag, assertSafeAgentName, formatAuthOutputForTelegram, buildAuthUrlKeyboard, buildDeferredSecretKeyboard, renderVaultOpFailure, statusIcon, renderAuthCodeOutcome, buildDoctorScopeKeyboard, formatDoctorReport } from './command-format.js'
440
442
  import { richMessage } from '../rich-send.js'
441
443
  import { decideRedeliver, decideRedeliverCapture } from './redelivery-decision.js'
442
444
  import { scrubVoice } from '../text-voice-scrub.js'
@@ -483,8 +485,11 @@ import {
483
485
  } from '../turn-flush-safety.js'
484
486
  import {
485
487
  resolveReplyOwnerTurnId,
488
+ resolveReplyOwnerTier,
489
+ type ReplyOwnerTier,
486
490
  type AnswerDeliveredLatch,
487
491
  } from '../reply-owner-resolve.js'
492
+ import { SubagentHandbackMarker } from './subagent-handback-marker.js'
488
493
  // PR A — deterministic answer-ready quiescence flush (late-delivery fix).
489
494
  import {
490
495
  AnswerReadyFlushController,
@@ -898,6 +903,7 @@ import {
898
903
  TURN_ACTIVE_HARD_TTL_MS,
899
904
  TURN_ACTIVE_IDLE_SWEEP_MS,
900
905
  } from './turn-active-marker.js'
906
+ import { startGatewayHeartbeat } from './gateway-heartbeat.js'
901
907
  import {
902
908
  VERSION,
903
909
  COMMIT_SHA,
@@ -953,7 +959,7 @@ import {
953
959
  import { findAgentProcessInContainer } from './boot-probes.js'
954
960
  import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey, listNonTerminalSubagentsForTurn } from '../registry/subagents-schema.js'
955
961
  import type { InterruptedSubagent } from './resume-inbound-builder.js'
956
- import { resolveWorkerFeedDispatch, handleWorkerResume, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
962
+ import { resolveWorkerFeedDispatch, decideWorkerFeedDestination, handleWorkerResume, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
957
963
  import {
958
964
  resolveSubagentStatusSurface,
959
965
  isOrphanSubagentStatusEnabled,
@@ -2180,6 +2186,29 @@ const WORKER_FEED_GROUP_MESSAGE_LIFETIME_CAP_MS = (() => {
2180
2186
  })()
2181
2187
  const workerFeedOwnerDmFallbackLogged = new Set<string>()
2182
2188
 
2189
+ // Once-per-agent owner-DM-fallback routing line (bounded FIFO set). Shared by
2190
+ // `resolveWorkerFeedChat` and the origin-defer paint path so both log alike.
2191
+ function noteWorkerFeedOwnerDmFallback(agentId: string): void {
2192
+ if (workerFeedOwnerDmFallbackLogged.has(agentId)) return
2193
+ workerFeedOwnerDmFallbackLogged.add(agentId)
2194
+ if (workerFeedOwnerDmFallbackLogged.size > WORKER_FEED_FALLBACK_LOG_CAP) {
2195
+ const oldest = workerFeedOwnerDmFallbackLogged.values().next().value
2196
+ if (oldest != null) workerFeedOwnerDmFallbackLogged.delete(oldest)
2197
+ }
2198
+ process.stderr.write(
2199
+ `telegram gateway: worker-feed origin unresolved agent=${agentId} — routing card to owner DM\n`,
2200
+ )
2201
+ }
2202
+
2203
+ // Per-agent consecutive origin-race deferrals (rationale in
2204
+ // `decideWorkerFeedOriginDefer`, worker-feed-dispatch.ts). Bounds the wait for
2205
+ // the `jsonl_agent_id` backfill; deleted on link, card-exists, and finish/drop
2206
+ // so the map stays bounded.
2207
+ const workerFeedOriginDeferrals = new Map<string, number>()
2208
+ // Watcher ticks ~1/s and the backfill retries ~every 3s, so ~10 ticks is a
2209
+ // comfortable ceiling on the race window before painting anyway.
2210
+ const WORKER_FEED_ORIGIN_DEFER_MAX = 10
2211
+
2183
2212
  /**
2184
2213
  * Resolve a worker-feed destination chat with a guaranteed last resort.
2185
2214
  *
@@ -2202,28 +2231,20 @@ const workerFeedOwnerDmFallbackLogged = new Set<string>()
2202
2231
  function resolveWorkerFeedChat(
2203
2232
  agentId: string,
2204
2233
  fleetChatId: string,
2234
+ // Origin sources threadId; when origin is null the caller can pass the
2235
+ // fallback chat's sibling forum-topic id so a misrouted card still lands
2236
+ // in the origin topic instead of General (#3458 exhausted-defer paint).
2237
+ fallbackThreadId?: number,
2205
2238
  ): { chatId: string; threadId?: number } {
2206
2239
  const origin = resolveSubagentOriginChat(agentId)
2207
2240
  if (origin != null && origin.chatId.length > 0) return origin
2208
- if (fleetChatId.length > 0) return { chatId: fleetChatId }
2241
+ if (fleetChatId.length > 0) return { chatId: fleetChatId, threadId: fallbackThreadId }
2209
2242
  const ownerDm = loadAccess().allowFrom[0] ?? ''
2210
2243
  if (origin == null && fleetChatId.length === 0 && ownerDm.length > 0) {
2211
- // Routing decision, not a warning: origin resolution failed and no
2212
- // fleet chat is configured, so the nested worker's card lands in the
2213
- // owner DM. Logged ONCE per agent so the misroute is auditable
2214
- // without spamming every tick (the watcher drives onProgress ~1/s).
2215
- if (!workerFeedOwnerDmFallbackLogged.has(agentId)) {
2216
- workerFeedOwnerDmFallbackLogged.add(agentId)
2217
- if (workerFeedOwnerDmFallbackLogged.size > WORKER_FEED_FALLBACK_LOG_CAP) {
2218
- const oldest = workerFeedOwnerDmFallbackLogged.values().next().value
2219
- if (oldest != null) workerFeedOwnerDmFallbackLogged.delete(oldest)
2220
- }
2221
- process.stderr.write(
2222
- `telegram gateway: worker-feed origin unresolved agent=${agentId} — routing card to owner DM\n`,
2223
- )
2224
- }
2244
+ // Origin unresolved and no fleet chat the card lands in the owner DM.
2245
+ noteWorkerFeedOwnerDmFallback(agentId)
2225
2246
  }
2226
- return { chatId: ownerDm, threadId: origin?.threadId }
2247
+ return { chatId: ownerDm, threadId: origin?.threadId ?? fallbackThreadId }
2227
2248
  }
2228
2249
 
2229
2250
  // ─── Periodic history reaper (#1073) ──────────────────────────────────────
@@ -2311,6 +2332,13 @@ function checkApprovals(): void {
2311
2332
  }
2312
2333
  }
2313
2334
  if (isGatewayMain && !STATIC) setInterval(checkApprovals, 5000).unref()
2335
+ // Gateway liveness heartbeat — touches `<STATE_DIR>/gateway-heartbeat` while
2336
+ // the gateway lives so the silent-end Stop hook's single-writer election can
2337
+ // confirm the gateway is alive (and WILL run its turn_end delivery) before
2338
+ // electing to allow a turn to end without re-prompting. Allowing into a dead
2339
+ // gateway would drop the answer — the one outcome worse than a duplicate. See
2340
+ // gateway/gateway-heartbeat.ts + hooks/silent-end-scan.mjs.
2341
+ if (isGatewayMain && !STATIC) startGatewayHeartbeat(STATE_DIR)
2314
2342
  // Idle auto-clear: check wall-clock idle every minute; maybeIdleClear no-ops
2315
2343
  // when disabled ('0s'), mid-turn, or already cleared this idle period. The
2316
2344
  // `let` state + maybeIdleClear are hoisted/initialized before this fires.
@@ -3860,6 +3888,14 @@ const recentTurnsById = new Map<string, CurrentTurn>()
3860
3888
  // real message_id the framework stamped, never a model-asserted thread.
3861
3889
  // Evicted in lock-step with recentTurnsById so it can't outgrow it.
3862
3890
  const recentTurnIdBySourceMessageId = new Map<number, string>()
3891
+
3892
+ // fix/backstop-duplicate-reply — per-chat marker of the most recent
3893
+ // gateway-synthesized `subagent_handback` enqueue (logic in
3894
+ // subagent-handback-marker.ts; extracted per the gateway anti-inflation ratchet).
3895
+ const subagentHandbackMarker = new SubagentHandbackMarker()
3896
+ const getLastSubagentHandbackAt = (chatId: string): number | null =>
3897
+ subagentHandbackMarker.lastAt(chatId)
3898
+
3863
3899
  function rememberRecentTurn(turn: CurrentTurn): void {
3864
3900
  recentTurnsById.set(turn.turnId, turn)
3865
3901
  if (turn.sourceMessageId != null) {
@@ -3984,7 +4020,7 @@ function resolveReplyOwnerTurn(
3984
4020
  liveTurn: CurrentTurn | null,
3985
4021
  chatId: string,
3986
4022
  args: Record<string, unknown>,
3987
- ): CurrentTurn | null {
4023
+ ): { turn: CurrentTurn | null; tier: ReplyOwnerTier } {
3988
4024
  const origin = findTurnByOriginId(args.origin_turn_id as string | undefined)
3989
4025
  const quoted = findTurnByQuotedMessageId(chatId, args.reply_to)
3990
4026
  const latestEnded = findLatestEndedTurnForChat(chatId)
@@ -4001,15 +4037,23 @@ function resolveReplyOwnerTurn(
4001
4037
  // fabricate one.
4002
4038
  const latestEndedAgeMs =
4003
4039
  latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null
4004
- const winnerId = resolveReplyOwnerTurnId({
4040
+ const candidates = {
4005
4041
  liveTurnId: liveTurn?.turnId ?? null,
4006
4042
  originTurnId: origin?.turnId ?? null,
4007
4043
  quotedTurnId: quoted?.turnId ?? null,
4008
4044
  latestEndedTurnId: latestEnded?.turnId ?? null,
4009
4045
  latestEndedAgeMs,
4010
4046
  latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
4011
- })
4012
- return winnerId != null ? (byId.get(winnerId) ?? null) : null
4047
+ }
4048
+ // #3429 the WINNING tier travels with the turn. A positive tier
4049
+ // (live/origin/quoted) means the reply is this turn's own answer and the
4050
+ // supersede fires regardless of text; the ambiguous `latest-ended` fallback
4051
+ // keeps the content gate (it cannot tell a late own-reply from an async
4052
+ // sub-agent handback). Both derive from the SAME candidates, so the id and the
4053
+ // tier can never disagree.
4054
+ const tier = resolveReplyOwnerTier(candidates)
4055
+ const winnerId = resolveReplyOwnerTurnId(candidates)
4056
+ return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier }
4013
4057
  }
4014
4058
 
4015
4059
  /**
@@ -9674,6 +9718,15 @@ function gatewayLivenessWiringDeps() {
9674
9718
  trackRedeliveredInbound,
9675
9719
  closeActivityLane,
9676
9720
  closeProgressLane,
9721
+ // Stage B: escalate a mid-tool + marker-stale fallback to a real restart.
9722
+ // No cooldown (see hang-restart-decision.ts § STORM GUARD): the ask-first
9723
+ // resume_watchdog_timeout path is the sole, sufficient storm guard.
9724
+ hangRestart: {
9725
+ stalenessThresholdMs: hangStalenessMs(),
9726
+ request: (reason: string, idleMs: number) => {
9727
+ triggerSelfRestart(getMyAgentName(), `hang-watchdog:${reason} idle=${Math.round(idleMs)}ms`)
9728
+ },
9729
+ },
9677
9730
  }
9678
9731
  }
9679
9732
  export type LivenessWiringDeps = ReturnType<typeof gatewayLivenessWiringDeps>
@@ -9928,6 +9981,10 @@ const pendingInboundBuffer = createPendingInboundBuffer({
9928
9981
  { chat_id: chat, verb: 'inbound-buffer-eviction' },
9929
9982
  )
9930
9983
  },
9984
+ // fix/backstop-duplicate-reply MUST-FIX 2 — stamp the handback marker at the
9985
+ // enqueue chokepoint so a boot-replayed handback (not just the live synthesis
9986
+ // push) populates it. Every `subagent_handback` push funnels through here.
9987
+ onHandbackEnqueue: (chatId, ts) => subagentHandbackMarker.record(chatId, ts),
9931
9988
  })
9932
9989
 
9933
9990
  // PR2 obligation-ledger idle sweep. Re-present an OPEN obligation only at a
@@ -12533,6 +12590,7 @@ function gatewaySendReplyDeps(): SendReplyGatewayDeps {
12533
12590
  resolveAnswerThreadWithLog,
12534
12591
  resolveThreadId,
12535
12592
  getLatestInboundMessageId,
12593
+ getLastSubagentHandbackAt,
12536
12594
  recordOutbound,
12537
12595
  emissionAuthorityFor,
12538
12596
  clearActivitySummary,
@@ -15654,25 +15712,6 @@ function switchroomExecCombined(args: string[], timeoutMs = 15000): string {
15654
15712
  })
15655
15713
  }
15656
15714
 
15657
- // Default truncation budget for CLI output bound for Telegram. The rich-message
15658
- // wire cap is RICH_MESSAGE_MAX_CHARS (32768) post-#2669, not the legacy 4096
15659
- // plain-text limit. Mirrors shared/bot-runtime.ts formatSwitchroomOutput.
15660
- function formatSwitchroomOutput(output: string, maxLen = RICH_MESSAGE_MAX_CHARS): string {
15661
- const trimmed = output.trim()
15662
- if (trimmed.length <= maxLen) return trimmed
15663
- return trimmed.slice(0, maxLen - 20) + '\n... (truncated)'
15664
- }
15665
-
15666
- function stripAnsi(text: string): string {
15667
- return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
15668
- }
15669
-
15670
- // #2669: escape GFM-markdown specials in dynamic values interpolated into
15671
- // rich-message bodies (kept under the legacy name to avoid churn).
15672
- function escapeHtmlForTg(text: string): string {
15673
- return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
15674
- }
15675
-
15676
15715
  // #789 — button-choice-confirmation ("✅ You chose: X") annotation state.
15677
15716
  //
15678
15717
  // Two once-per-process warning dedupe sets keyed by agent slug: one for the
@@ -15684,11 +15723,6 @@ function escapeHtmlForTg(text: string): string {
15684
15723
  const buttonConfirmParseModeWarned = new Set<string>()
15685
15724
  const buttonConfirmSingleUseWarned = new Set<string>()
15686
15725
 
15687
- // Wrap CLI/command output in a fenced code block (content is literal there).
15688
- function preBlock(text: string): string {
15689
- return '```\n' + text.replace(/```/g, '`​``') + '\n```'
15690
- }
15691
-
15692
15726
  type SwitchroomReplyMarkup =
15693
15727
  | InlineKeyboard
15694
15728
  | { force_reply: true; input_field_placeholder?: string; selective?: boolean }
@@ -15785,36 +15819,6 @@ async function deleteSensitiveMessage(
15785
15819
  }
15786
15820
  }
15787
15821
 
15788
- function getCommandArgs(ctx: Context): string {
15789
- const fromMatch = typeof ctx.match === 'string' ? ctx.match.trim() : ''
15790
- if (fromMatch) return fromMatch
15791
- const text = (ctx.msg as { text?: string } | undefined)?.text ?? (ctx.message as { text?: string } | undefined)?.text ?? ''
15792
- const m = text.match(/^\/\S+\s+([\s\S]*)$/)
15793
- return m ? m[1].trim() : ''
15794
- }
15795
-
15796
- /**
15797
- * True when a slash command's argument string carries a trailing `demo`
15798
- * token — the per-command PII-mask modifier for screen recordings
15799
- * (`/usage demo`, `/auth demo`, `/status demo`, `/whoami demo`). Matches
15800
- * `demo` as the last whitespace-delimited token, case-insensitively, so
15801
- * `/auth show demo` and `/usage demo` both flip the flag while a label
15802
- * literally named `demo-foo` does not.
15803
- */
15804
- function hasDemoFlag(args: string): boolean {
15805
- return /(?:^|\s)demo$/i.test(args.trim())
15806
- }
15807
-
15808
- /** Validate that a string looks like a safe agent/resource name.
15809
- * Agent names should be alphanumeric with hyphens/underscores only.
15810
- * This prevents shell metacharacter injection even though both exec
15811
- * functions already handle quoting. Defense in depth. */
15812
- function assertSafeAgentName(name: string): void {
15813
- if (!/^[a-zA-Z0-9_-]{1,64}$/.test(name) && name !== 'all') {
15814
- throw new Error(`invalid agent name: ${name}`)
15815
- }
15816
- }
15817
-
15818
15822
  /**
15819
15823
  * Expand the special `all` keyword into the real agent list for restart-
15820
15824
  * driving. The CLI handles its own expansion for the YAML mutation; this
@@ -16389,114 +16393,6 @@ function scheduleGrantRestart(
16389
16393
  * if the inline button ever fails to render (old client, unusual scope)
16390
16394
  * the user still has a copy-paste-able URL.
16391
16395
  */
16392
- function formatAuthOutputForTelegram(output: string): { text: string; url: string | null } {
16393
- const trimmed = stripAnsi(output).trim()
16394
- const url = trimmed.match(/https:\/\/\S+/)?.[0] ?? null
16395
- const lines = trimmed.split(/\n+/).map(l => l.trim()).filter(Boolean)
16396
- if (!url) return { text: preBlock(formatSwitchroomOutput(trimmed)), url: null }
16397
- // Drop the `switchroom auth code ...` and `switchroom auth cancel ...`
16398
- // CLI hints. In Telegram the user never types those — they just reply
16399
- // with the code (intercepted by the pendingReauthFlows flow above) or
16400
- // tap the inline button. Surfacing shell syntax is confusing noise on
16401
- // a phone.
16402
- const body = lines.filter(line => {
16403
- if (line === url) return false
16404
- if (line.startsWith('switchroom auth code')) return false
16405
- if (line.startsWith('switchroom auth cancel')) return false
16406
- if (line.startsWith("Use 'tmux attach")) return false
16407
- if (line.startsWith('After Claude shows you a browser code')) return false
16408
- if (line.startsWith('Then finish with:')) return false
16409
- if (line.startsWith('Cancel with:')) return false
16410
- return true
16411
- })
16412
- const rendered = body.map(line => {
16413
- if (line.startsWith('Started Claude auth') || line.startsWith('Auth session already running')) return `**${escapeHtmlForTg(line)}**`
16414
- if (line.startsWith('Open this URL')) return `_${escapeHtmlForTg(line)}_`
16415
- return escapeHtmlForTg(line)
16416
- })
16417
- // Mobile-native post-script. Two paths depending on which Anthropic
16418
- // account the user wants to authorize:
16419
- //
16420
- // (a) Button: 🔐 Open Claude auth — opens in Telegram's in-app
16421
- // browser (WebView) on most mobile clients. WebView has its
16422
- // own cookie jar, separate from the user's main browser. Fine
16423
- // when the WebView is already signed into the intended Claude
16424
- // account; wrong when it's signed into a different one.
16425
- //
16426
- // (b) Long-press the URL text at the bottom of this message — every
16427
- // mobile Telegram client exposes "Copy Link" / "Open in
16428
- // Browser" / "Open in Chrome" on long-press. That's the
16429
- // escape hatch when you need to land in your main browser
16430
- // where you control which account is signed in.
16431
- //
16432
- // Why not a copy_text button? We tried. Telegram's CopyTextButton.text
16433
- // field caps at 256 chars and OAuth URLs run ~320–340 chars. Result
16434
- // was BUTTON_COPY_TEXT_INVALID. The long-press-the-URL path achieves
16435
- // the same outcome with no API constraint. See PR #30.
16436
- rendered.push(
16437
- '',
16438
- '👇 Tap **🔐 Open Claude auth** below, then **reply with the browser code**.',
16439
- '',
16440
- '_Wrong Anthropic account getting authorized? Long-press the URL below and choose "Copy Link" or "Open in Browser" — lands in your main browser where the right account is signed in, bypassing Telegram\'s in-app browser cookies._',
16441
- '',
16442
- url,
16443
- )
16444
- return { text: rendered.join('\n'), url }
16445
- }
16446
-
16447
- /**
16448
- * Build the inline keyboard shown under an auth-flow response that has
16449
- * an OAuth URL. Single button:
16450
- *
16451
- * [🔐 Open Claude auth] — `url` button. On mobile Telegram clients
16452
- * this typically opens in the app's in-app
16453
- * browser (WebView).
16454
- *
16455
- * We previously tried adding a `[📋 Copy URL]` button using Telegram's
16456
- * Bot API 7.7 `copy_text` type but it capped at 256 chars for the
16457
- * copyable text. OAuth URLs (~320–340 chars) exceed that and produce
16458
- * `BUTTON_COPY_TEXT_INVALID`. Instead, the message body renders the
16459
- * URL as a tappable link; users long-press the URL text to get native
16460
- * "Copy Link" / "Open in Browser" actions, bypassing the WebView.
16461
- *
16462
- * Defense in depth: this function's output is validated against
16463
- * Telegram's real field-length constraints in
16464
- * `telegram-plugin/tests/auth-url-keyboard-constraints.test.ts` so
16465
- * future changes that breach a limit fail loudly at CI time rather
16466
- * than silently in production.
16467
- */
16468
- function buildAuthUrlKeyboard(authorizeUrl: string): InlineKeyboard {
16469
- return new InlineKeyboard().url('🔐 Open Claude auth', authorizeUrl)
16470
- }
16471
-
16472
- /**
16473
- * Issue #44: inline keyboard offering a one-tap unlock-and-save flow for
16474
- * a deferred secret. The two buttons fire `vd:` callback_data which the
16475
- * dispatcher in `bot.on('callback_query:data')` routes to
16476
- * `handleVaultDeferCallback`.
16477
- *
16478
- * `vd:unlock:<deferKey>` → prompt for passphrase, then auto-write the
16479
- * held secret. Replaces the legacy six-step
16480
- * "/vault list → re-paste" flow.
16481
- * `vd:cancel:<deferKey>` → discard the deferred secret without saving.
16482
- *
16483
- * `deferKey` is `<chat_id>:<message_id>` (the same key as
16484
- * `deferredSecrets.set()`). Telegram limits callback_data to 64 bytes;
16485
- * the prefix + key fits well within that on any realistic chat id.
16486
- */
16487
- function buildDeferredSecretKeyboard(deferKey: string): InlineKeyboard {
16488
- const unlockData = `vd:unlock:${deferKey}`
16489
- const cancelData = `vd:cancel:${deferKey}`
16490
- if (unlockData.length > 64 || cancelData.length > 64) {
16491
- process.stderr.write(
16492
- `telegram gateway: callback_data overflow — deferKey=${deferKey} unlockLen=${unlockData.length} cancelLen=${cancelData.length}\n`,
16493
- )
16494
- throw new Error(`callback_data overflow: deferKey too long (${deferKey.length} chars)`)
16495
- }
16496
- return new InlineKeyboard()
16497
- .text('🔓 Unlock vault & save', unlockData)
16498
- .text('🗑 Discard', cancelData)
16499
- }
16500
16396
 
16501
16397
  async function runSwitchroomAuthCommand(ctx: Context, args: string[], label: string): Promise<void> {
16502
16398
  try {
@@ -16560,27 +16456,6 @@ function runVaultCli(args: string[], passphrase: string, stdinValue?: string): {
16560
16456
  }
16561
16457
  }
16562
16458
 
16563
- /**
16564
- * Render a vault-CLI failure as Telegram HTML. Routes recognised P0a
16565
- * stderr markers (VAULT-SANDBOX-CONTEXT / VAULT-NEEDS-APPROVAL /
16566
- * VAULT-BROKER-UNREACHABLE / VAULT-BROKER-DENIED) through the structured
16567
- * renderer; falls back to a raw pre-block for anything else.
16568
- */
16569
- function renderVaultOpFailure(
16570
- verbLabel: 'list' | 'get' | 'set' | 'delete',
16571
- cliOutput: string,
16572
- key: string | undefined,
16573
- ): string {
16574
- const parsed = parseVaultCliError(cliOutput)
16575
- // Map the gateway-internal op label onto the renderer's verb. 'delete'
16576
- // surfaces in the host hint as `switchroom vault remove <key>` (the
16577
- // canonical CLI name); 'list' has no key.
16578
- const verb = verbLabel === 'delete' ? 'remove' : verbLabel
16579
- const rendered = renderVaultCliError(parsed, { verb, key })
16580
- if (rendered.suppressRaw) return rendered.html
16581
- return `**vault ${verbLabel} failed:**\n${preBlock(cliOutput)}`
16582
- }
16583
-
16584
16459
  async function executeVaultOp(ctx: Context, chatId: string, op: 'list' | 'get' | 'set' | 'delete', key: string | undefined, passphrase: string, setValue: string | undefined): Promise<void> {
16585
16460
  if (op === 'list') {
16586
16461
  const r = runVaultCli(['list'], passphrase)
@@ -16713,34 +16588,6 @@ function switchroomExecJson<T = unknown>(args: string[]): T | null {
16713
16588
  } catch { return null }
16714
16589
  }
16715
16590
 
16716
- function statusIcon(status: string): string {
16717
- if (status === 'active' || status === 'running') return '🟢'
16718
- if (status === 'inactive' || status === 'stopped' || status === 'dead') return '🔴'
16719
- if (status === 'failed') return '⚠️'
16720
- return '⚪'
16721
- }
16722
-
16723
- /**
16724
- * Render an `AuthCodeOutcome` as a user-facing Telegram HTML string.
16725
- * Returns null when the outcome is not present or is `success` (caller
16726
- * can handle success via the existing text path).
16727
- */
16728
- function renderAuthCodeOutcome(outcome: AuthCodeOutcome | null | undefined): string | null {
16729
- if (!outcome || outcome.kind === 'success') return null
16730
- const tail = outcome.paneTailText
16731
- ? `\n_${escapeHtmlForTg(outcome.paneTailText)}_`
16732
- : ''
16733
- switch (outcome.kind) {
16734
- case 'invalid-code':
16735
- case 'expired-code':
16736
- return `Code rejected by Claude — tap **Restart flow** for a fresh URL.${tail}`
16737
- case 'pane-not-ready':
16738
- return `Auth pane not ready — tap **Retry**.`
16739
- case 'timeout':
16740
- return `Still waiting after 2 min — tap **Retry** or check \`switchroom auth list\`.${tail}`
16741
- }
16742
- }
16743
-
16744
16591
  interface AuthCodeJsonResult {
16745
16592
  completed: boolean
16746
16593
  tokenSaved: boolean
@@ -19212,29 +19059,6 @@ let cardToolHandlers!: ReturnType<typeof createCardToolHandlers>
19212
19059
 
19213
19060
 
19214
19061
 
19215
- // Two-button scope picker shown to admin agents (when hostd is
19216
- // reachable) so the operator can run doctor for the WHOLE FLEET
19217
- // (host-side via hostd — has the docker socket) or just THIS agent
19218
- // (in-container, degraded). callback_data is tiny (`dr:fleet` /
19219
- // `dr:self`) — well within Telegram's 64-byte limit.
19220
- function buildDoctorScopeKeyboard(): InlineKeyboard {
19221
- return new InlineKeyboard()
19222
- .text('🩺 Whole fleet', 'dr:fleet')
19223
- .text('🩺 This agent', 'dr:self')
19224
- }
19225
-
19226
- // Shared report prettifier: ANSI-strip + status-glyph swap + pre block.
19227
- // Identical rendering for the in-container and the hostd fleet report.
19228
- function formatDoctorReport(raw: string): string {
19229
- const trimmed = stripAnsi(raw).trim()
19230
- if (!trimmed) return 'doctor: no output'
19231
- const pretty = trimmed
19232
- .replace(/^( *)✓ /gm, '$1🟢 ')
19233
- .replace(/^( *)✗ /gm, '$1🔴 ')
19234
- .replace(/^( *)! /gm, '$1🟡 ')
19235
- return preBlock(formatSwitchroomOutput(pretty))
19236
- }
19237
-
19238
19062
  // In-container `switchroom doctor` — this agent's own (degraded: no
19239
19063
  // docker socket) view. The original /doctor behaviour, unchanged.
19240
19064
  async function renderSelfDoctor(ctx: Context): Promise<void> {
@@ -24397,6 +24221,9 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
24397
24221
  // live worker, collapses the shared card to its terminal summary
24398
24222
  // and unpins it — closing the immortal/unpinned/buried-card leak.
24399
24223
  onTerminalCleanup: (agentId) => {
24224
+ // A worker that terminated while still unlinked never links —
24225
+ // clear its origin-race defer state so the map can't leak.
24226
+ workerFeedOriginDeferrals.delete(agentId)
24400
24227
  try {
24401
24228
  void workerActivityFeed?.terminate(agentId)
24402
24229
  } catch (err) {
@@ -24406,6 +24233,8 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
24406
24233
  }
24407
24234
  },
24408
24235
  onFinish: ({ agentId, outcome, description, resultText, toolCount, totalTokens, durationMs, background: entryBackground }) => {
24236
+ // Clear origin-race defer state — the worker is terminal.
24237
+ workerFeedOriginDeferrals.delete(agentId)
24409
24238
  // Reaction promotion: if the parent turn already ended
24410
24239
  // with this (or another) worker still running, its 👍 was
24411
24240
  // deferred (held on ✍️/⚡). Now that a worker finished,
@@ -24653,6 +24482,9 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
24653
24482
  // The drain only releases at an idle prompt (no active
24654
24483
  // turn), so the handback always lands as a clean fresh
24655
24484
  // turn and never races a turn-in-flight composer (#1556).
24485
+ // The handback marker is stamped inside pendingInboundBuffer.push
24486
+ // (the enqueue chokepoint) so BOTH this live synthesis push and
24487
+ // the boot-replay re-push populate it — see MUST-FIX 2.
24656
24488
  pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? '', decision.inbound)
24657
24489
  process.stderr.write(
24658
24490
  `telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${decision.chatId} resultChars=${resultText.length}\n`,
@@ -24890,11 +24722,48 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
24890
24722
  // resolved (the pinned-card fleet that used to carry the chat
24891
24723
  // is gone — see resolveSubagentOriginChat).
24892
24724
  if (workerFeedEnabled) {
24893
- const wk = resolveWorkerFeedChat(agentId, fleetChatId)
24894
- const wkChat = wk.chatId
24725
+ // Origin-race defer + destination (decideWorkerFeedDestination,
24726
+ // worker-feed-dispatch.ts): the first tick of a fresh sub-agent
24727
+ // can beat the async jsonl_agent_id backfill that links its row
24728
+ // to the origin turn — origin resolves null and the card would be
24729
+ // CREATED in the owner DM, staying visible even after the origin
24730
+ // (supergroup + forum topic) resolves ~3s later. Defer CARD
24731
+ // CREATION until linked; the watcher re-fires and paints in the
24732
+ // right chat+topic. The FULL decision (defer-or-paint AND the
24733
+ // chat/thread resolution `resolveWorkerFeedChat` did) is now one
24734
+ // pure, unit-tested function; the gateway keeps only the defer-map
24735
+ // bookkeeping, the two audit logs, and the feed.update.
24736
+ const dest = decideWorkerFeedDestination({
24737
+ origin: resolveSubagentOriginChat(agentId),
24738
+ cardExists: workerActivityFeed?.has(agentId) === true,
24739
+ priorDeferrals: workerFeedOriginDeferrals.get(agentId) ?? 0,
24740
+ maxDeferrals: WORKER_FEED_ORIGIN_DEFER_MAX,
24741
+ fleetChatId,
24742
+ stampChatId: stampTurn?.sessionChatId,
24743
+ stampThreadId: stampTurn?.sessionThreadId,
24744
+ ownerDm: loadAccess().allowFrom[0] ?? '',
24745
+ })
24746
+ if (dest.action === 'defer') {
24747
+ // No card created this tick; the watcher re-fires and paints
24748
+ // once the jsonl_agent_id backfill links the row to its origin.
24749
+ workerFeedOriginDeferrals.set(agentId, dest.deferrals)
24750
+ return
24751
+ }
24752
+ // Painting: clear defer state so the map can't leak.
24753
+ workerFeedOriginDeferrals.delete(agentId)
24754
+ if (dest.exhausted) {
24755
+ // Bounded-defer exhausted — backfill never linked (history
24756
+ // disabled / row reaped / ancestor never stamped). Paint anyway
24757
+ // so active work stays visible (universal-liveness contract).
24758
+ process.stderr.write(
24759
+ `telegram gateway: worker-feed origin backfill never linked agent=${agentId} after ${dest.deferrals} deferrals — painting card\n`,
24760
+ )
24761
+ }
24762
+ // Card fell to the owner DM — log the misroute once per agent.
24763
+ if (dest.ownerDmFallback) noteWorkerFeedOwnerDmFallback(agentId)
24895
24764
  void workerActivityFeed?.update(
24896
24765
  agentId,
24897
- wkChat,
24766
+ dest.chatId,
24898
24767
  {
24899
24768
  description: dispatch.feedDescription,
24900
24769
  lastTool,
@@ -24908,7 +24777,7 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
24908
24777
  model: feedModel,
24909
24778
  totalTokens,
24910
24779
  },
24911
- wk.threadId,
24780
+ dest.threadId,
24912
24781
  )
24913
24782
  // #3207: the feed pins the group's shared message itself when
24914
24783
  // it first paints (reconcilePin → `wk:group:<feedKey>`); no