switchroom 0.19.4 → 0.19.5

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 (32) 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 +524 -293
  8. package/telegram-plugin/gateway/command-format.ts +253 -0
  9. package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
  10. package/telegram-plugin/gateway/gateway.ts +97 -255
  11. package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
  12. package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
  13. package/telegram-plugin/gateway/session-model-file.ts +13 -0
  14. package/telegram-plugin/gateway/stream-render.ts +18 -1
  15. package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
  16. package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
  17. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
  18. package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
  19. package/telegram-plugin/render/line-start-guard.ts +76 -4
  20. package/telegram-plugin/rich-send.ts +8 -1
  21. package/telegram-plugin/tests/command-format.test.ts +212 -0
  22. package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
  23. package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
  24. package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
  25. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
  26. package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
  27. package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
  28. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
  29. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
  30. package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
  31. package/telegram-plugin/tests/silent-end.test.ts +60 -5
  32. 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'
@@ -898,6 +900,7 @@ import {
898
900
  TURN_ACTIVE_HARD_TTL_MS,
899
901
  TURN_ACTIVE_IDLE_SWEEP_MS,
900
902
  } from './turn-active-marker.js'
903
+ import { startGatewayHeartbeat } from './gateway-heartbeat.js'
901
904
  import {
902
905
  VERSION,
903
906
  COMMIT_SHA,
@@ -953,7 +956,7 @@ import {
953
956
  import { findAgentProcessInContainer } from './boot-probes.js'
954
957
  import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey, listNonTerminalSubagentsForTurn } from '../registry/subagents-schema.js'
955
958
  import type { InterruptedSubagent } from './resume-inbound-builder.js'
956
- import { resolveWorkerFeedDispatch, handleWorkerResume, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
959
+ import { resolveWorkerFeedDispatch, decideWorkerFeedDestination, handleWorkerResume, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
957
960
  import {
958
961
  resolveSubagentStatusSurface,
959
962
  isOrphanSubagentStatusEnabled,
@@ -2180,6 +2183,29 @@ const WORKER_FEED_GROUP_MESSAGE_LIFETIME_CAP_MS = (() => {
2180
2183
  })()
2181
2184
  const workerFeedOwnerDmFallbackLogged = new Set<string>()
2182
2185
 
2186
+ // Once-per-agent owner-DM-fallback routing line (bounded FIFO set). Shared by
2187
+ // `resolveWorkerFeedChat` and the origin-defer paint path so both log alike.
2188
+ function noteWorkerFeedOwnerDmFallback(agentId: string): void {
2189
+ if (workerFeedOwnerDmFallbackLogged.has(agentId)) return
2190
+ workerFeedOwnerDmFallbackLogged.add(agentId)
2191
+ if (workerFeedOwnerDmFallbackLogged.size > WORKER_FEED_FALLBACK_LOG_CAP) {
2192
+ const oldest = workerFeedOwnerDmFallbackLogged.values().next().value
2193
+ if (oldest != null) workerFeedOwnerDmFallbackLogged.delete(oldest)
2194
+ }
2195
+ process.stderr.write(
2196
+ `telegram gateway: worker-feed origin unresolved agent=${agentId} — routing card to owner DM\n`,
2197
+ )
2198
+ }
2199
+
2200
+ // Per-agent consecutive origin-race deferrals (rationale in
2201
+ // `decideWorkerFeedOriginDefer`, worker-feed-dispatch.ts). Bounds the wait for
2202
+ // the `jsonl_agent_id` backfill; deleted on link, card-exists, and finish/drop
2203
+ // so the map stays bounded.
2204
+ const workerFeedOriginDeferrals = new Map<string, number>()
2205
+ // Watcher ticks ~1/s and the backfill retries ~every 3s, so ~10 ticks is a
2206
+ // comfortable ceiling on the race window before painting anyway.
2207
+ const WORKER_FEED_ORIGIN_DEFER_MAX = 10
2208
+
2183
2209
  /**
2184
2210
  * Resolve a worker-feed destination chat with a guaranteed last resort.
2185
2211
  *
@@ -2202,28 +2228,20 @@ const workerFeedOwnerDmFallbackLogged = new Set<string>()
2202
2228
  function resolveWorkerFeedChat(
2203
2229
  agentId: string,
2204
2230
  fleetChatId: string,
2231
+ // Origin sources threadId; when origin is null the caller can pass the
2232
+ // fallback chat's sibling forum-topic id so a misrouted card still lands
2233
+ // in the origin topic instead of General (#3458 exhausted-defer paint).
2234
+ fallbackThreadId?: number,
2205
2235
  ): { chatId: string; threadId?: number } {
2206
2236
  const origin = resolveSubagentOriginChat(agentId)
2207
2237
  if (origin != null && origin.chatId.length > 0) return origin
2208
- if (fleetChatId.length > 0) return { chatId: fleetChatId }
2238
+ if (fleetChatId.length > 0) return { chatId: fleetChatId, threadId: fallbackThreadId }
2209
2239
  const ownerDm = loadAccess().allowFrom[0] ?? ''
2210
2240
  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
- }
2241
+ // Origin unresolved and no fleet chat the card lands in the owner DM.
2242
+ noteWorkerFeedOwnerDmFallback(agentId)
2225
2243
  }
2226
- return { chatId: ownerDm, threadId: origin?.threadId }
2244
+ return { chatId: ownerDm, threadId: origin?.threadId ?? fallbackThreadId }
2227
2245
  }
2228
2246
 
2229
2247
  // ─── Periodic history reaper (#1073) ──────────────────────────────────────
@@ -2311,6 +2329,13 @@ function checkApprovals(): void {
2311
2329
  }
2312
2330
  }
2313
2331
  if (isGatewayMain && !STATIC) setInterval(checkApprovals, 5000).unref()
2332
+ // Gateway liveness heartbeat — touches `<STATE_DIR>/gateway-heartbeat` while
2333
+ // the gateway lives so the silent-end Stop hook's single-writer election can
2334
+ // confirm the gateway is alive (and WILL run its turn_end delivery) before
2335
+ // electing to allow a turn to end without re-prompting. Allowing into a dead
2336
+ // gateway would drop the answer — the one outcome worse than a duplicate. See
2337
+ // gateway/gateway-heartbeat.ts + hooks/silent-end-scan.mjs.
2338
+ if (isGatewayMain && !STATIC) startGatewayHeartbeat(STATE_DIR)
2314
2339
  // Idle auto-clear: check wall-clock idle every minute; maybeIdleClear no-ops
2315
2340
  // when disabled ('0s'), mid-turn, or already cleared this idle period. The
2316
2341
  // `let` state + maybeIdleClear are hoisted/initialized before this fires.
@@ -9674,6 +9699,15 @@ function gatewayLivenessWiringDeps() {
9674
9699
  trackRedeliveredInbound,
9675
9700
  closeActivityLane,
9676
9701
  closeProgressLane,
9702
+ // Stage B: escalate a mid-tool + marker-stale fallback to a real restart.
9703
+ // No cooldown (see hang-restart-decision.ts § STORM GUARD): the ask-first
9704
+ // resume_watchdog_timeout path is the sole, sufficient storm guard.
9705
+ hangRestart: {
9706
+ stalenessThresholdMs: hangStalenessMs(),
9707
+ request: (reason: string, idleMs: number) => {
9708
+ triggerSelfRestart(getMyAgentName(), `hang-watchdog:${reason} idle=${Math.round(idleMs)}ms`)
9709
+ },
9710
+ },
9677
9711
  }
9678
9712
  }
9679
9713
  export type LivenessWiringDeps = ReturnType<typeof gatewayLivenessWiringDeps>
@@ -15654,25 +15688,6 @@ function switchroomExecCombined(args: string[], timeoutMs = 15000): string {
15654
15688
  })
15655
15689
  }
15656
15690
 
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
15691
  // #789 — button-choice-confirmation ("✅ You chose: X") annotation state.
15677
15692
  //
15678
15693
  // Two once-per-process warning dedupe sets keyed by agent slug: one for the
@@ -15684,11 +15699,6 @@ function escapeHtmlForTg(text: string): string {
15684
15699
  const buttonConfirmParseModeWarned = new Set<string>()
15685
15700
  const buttonConfirmSingleUseWarned = new Set<string>()
15686
15701
 
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
15702
  type SwitchroomReplyMarkup =
15693
15703
  | InlineKeyboard
15694
15704
  | { force_reply: true; input_field_placeholder?: string; selective?: boolean }
@@ -15785,36 +15795,6 @@ async function deleteSensitiveMessage(
15785
15795
  }
15786
15796
  }
15787
15797
 
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
15798
  /**
15819
15799
  * Expand the special `all` keyword into the real agent list for restart-
15820
15800
  * driving. The CLI handles its own expansion for the YAML mutation; this
@@ -16389,114 +16369,6 @@ function scheduleGrantRestart(
16389
16369
  * if the inline button ever fails to render (old client, unusual scope)
16390
16370
  * the user still has a copy-paste-able URL.
16391
16371
  */
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
16372
 
16501
16373
  async function runSwitchroomAuthCommand(ctx: Context, args: string[], label: string): Promise<void> {
16502
16374
  try {
@@ -16560,27 +16432,6 @@ function runVaultCli(args: string[], passphrase: string, stdinValue?: string): {
16560
16432
  }
16561
16433
  }
16562
16434
 
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
16435
  async function executeVaultOp(ctx: Context, chatId: string, op: 'list' | 'get' | 'set' | 'delete', key: string | undefined, passphrase: string, setValue: string | undefined): Promise<void> {
16585
16436
  if (op === 'list') {
16586
16437
  const r = runVaultCli(['list'], passphrase)
@@ -16713,34 +16564,6 @@ function switchroomExecJson<T = unknown>(args: string[]): T | null {
16713
16564
  } catch { return null }
16714
16565
  }
16715
16566
 
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
16567
  interface AuthCodeJsonResult {
16745
16568
  completed: boolean
16746
16569
  tokenSaved: boolean
@@ -19212,29 +19035,6 @@ let cardToolHandlers!: ReturnType<typeof createCardToolHandlers>
19212
19035
 
19213
19036
 
19214
19037
 
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
19038
  // In-container `switchroom doctor` — this agent's own (degraded: no
19239
19039
  // docker socket) view. The original /doctor behaviour, unchanged.
19240
19040
  async function renderSelfDoctor(ctx: Context): Promise<void> {
@@ -24397,6 +24197,9 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
24397
24197
  // live worker, collapses the shared card to its terminal summary
24398
24198
  // and unpins it — closing the immortal/unpinned/buried-card leak.
24399
24199
  onTerminalCleanup: (agentId) => {
24200
+ // A worker that terminated while still unlinked never links —
24201
+ // clear its origin-race defer state so the map can't leak.
24202
+ workerFeedOriginDeferrals.delete(agentId)
24400
24203
  try {
24401
24204
  void workerActivityFeed?.terminate(agentId)
24402
24205
  } catch (err) {
@@ -24406,6 +24209,8 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
24406
24209
  }
24407
24210
  },
24408
24211
  onFinish: ({ agentId, outcome, description, resultText, toolCount, totalTokens, durationMs, background: entryBackground }) => {
24212
+ // Clear origin-race defer state — the worker is terminal.
24213
+ workerFeedOriginDeferrals.delete(agentId)
24409
24214
  // Reaction promotion: if the parent turn already ended
24410
24215
  // with this (or another) worker still running, its 👍 was
24411
24216
  // deferred (held on ✍️/⚡). Now that a worker finished,
@@ -24890,11 +24695,48 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
24890
24695
  // resolved (the pinned-card fleet that used to carry the chat
24891
24696
  // is gone — see resolveSubagentOriginChat).
24892
24697
  if (workerFeedEnabled) {
24893
- const wk = resolveWorkerFeedChat(agentId, fleetChatId)
24894
- const wkChat = wk.chatId
24698
+ // Origin-race defer + destination (decideWorkerFeedDestination,
24699
+ // worker-feed-dispatch.ts): the first tick of a fresh sub-agent
24700
+ // can beat the async jsonl_agent_id backfill that links its row
24701
+ // to the origin turn — origin resolves null and the card would be
24702
+ // CREATED in the owner DM, staying visible even after the origin
24703
+ // (supergroup + forum topic) resolves ~3s later. Defer CARD
24704
+ // CREATION until linked; the watcher re-fires and paints in the
24705
+ // right chat+topic. The FULL decision (defer-or-paint AND the
24706
+ // chat/thread resolution `resolveWorkerFeedChat` did) is now one
24707
+ // pure, unit-tested function; the gateway keeps only the defer-map
24708
+ // bookkeeping, the two audit logs, and the feed.update.
24709
+ const dest = decideWorkerFeedDestination({
24710
+ origin: resolveSubagentOriginChat(agentId),
24711
+ cardExists: workerActivityFeed?.has(agentId) === true,
24712
+ priorDeferrals: workerFeedOriginDeferrals.get(agentId) ?? 0,
24713
+ maxDeferrals: WORKER_FEED_ORIGIN_DEFER_MAX,
24714
+ fleetChatId,
24715
+ stampChatId: stampTurn?.sessionChatId,
24716
+ stampThreadId: stampTurn?.sessionThreadId,
24717
+ ownerDm: loadAccess().allowFrom[0] ?? '',
24718
+ })
24719
+ if (dest.action === 'defer') {
24720
+ // No card created this tick; the watcher re-fires and paints
24721
+ // once the jsonl_agent_id backfill links the row to its origin.
24722
+ workerFeedOriginDeferrals.set(agentId, dest.deferrals)
24723
+ return
24724
+ }
24725
+ // Painting: clear defer state so the map can't leak.
24726
+ workerFeedOriginDeferrals.delete(agentId)
24727
+ if (dest.exhausted) {
24728
+ // Bounded-defer exhausted — backfill never linked (history
24729
+ // disabled / row reaped / ancestor never stamped). Paint anyway
24730
+ // so active work stays visible (universal-liveness contract).
24731
+ process.stderr.write(
24732
+ `telegram gateway: worker-feed origin backfill never linked agent=${agentId} after ${dest.deferrals} deferrals — painting card\n`,
24733
+ )
24734
+ }
24735
+ // Card fell to the owner DM — log the misroute once per agent.
24736
+ if (dest.ownerDmFallback) noteWorkerFeedOwnerDmFallback(agentId)
24895
24737
  void workerActivityFeed?.update(
24896
24738
  agentId,
24897
- wkChat,
24739
+ dest.chatId,
24898
24740
  {
24899
24741
  description: dispatch.feedDescription,
24900
24742
  lastTool,
@@ -24908,7 +24750,7 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
24908
24750
  model: feedModel,
24909
24751
  totalTokens,
24910
24752
  },
24911
- wk.threadId,
24753
+ dest.threadId,
24912
24754
  )
24913
24755
  // #3207: the feed pins the group's shared message itself when
24914
24756
  // it first paints (reconcilePin → `wk:group:<feedKey>`); no