switchroom 0.18.24 → 0.18.26

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 (54) hide show
  1. package/dist/cli/switchroom.js +59 -11
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +2 -2
  4. package/telegram-plugin/dist/bridge/bridge.js +26 -0
  5. package/telegram-plugin/dist/gateway/gateway.js +1827 -831
  6. package/telegram-plugin/dist/server.js +26 -0
  7. package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
  8. package/telegram-plugin/gateway/gateway.ts +314 -3
  9. package/telegram-plugin/gateway/model-command.ts +188 -56
  10. package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
  11. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
  12. package/telegram-plugin/history.ts +118 -0
  13. package/telegram-plugin/registry/turns-schema.ts +89 -1
  14. package/telegram-plugin/render/code-segments.ts +210 -0
  15. package/telegram-plugin/render/dollar-math-guard.ts +126 -0
  16. package/telegram-plugin/render/emphasis-guard.ts +158 -0
  17. package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
  18. package/telegram-plugin/render/line-start-guard.ts +167 -0
  19. package/telegram-plugin/render/rich-render.ts +7 -0
  20. package/telegram-plugin/rich-send.ts +48 -2
  21. package/telegram-plugin/session-tail.ts +185 -0
  22. package/telegram-plugin/subagent-watcher.ts +45 -0
  23. package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
  24. package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
  25. package/telegram-plugin/tests/history.test.ts +91 -0
  26. package/telegram-plugin/tests/model-command.test.ts +189 -12
  27. package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
  28. package/telegram-plugin/tests/registry-turns.test.ts +51 -0
  29. package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
  30. package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
  31. package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
  32. package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
  33. package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
  34. package/telegram-plugin/tests/session-model-source.test.ts +11 -0
  35. package/telegram-plugin/tests/session-tail.test.ts +145 -0
  36. package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
  37. package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
  38. package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
  39. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
  40. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
  41. package/telegram-plugin/tool-activity-summary.ts +54 -3
  42. package/telegram-plugin/worker-activity-feed.ts +104 -0
  43. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
  44. package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
  45. package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
  46. package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
  47. package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
  48. package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
  49. package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
  50. package/vendor/hindsight-memory/scripts/retain.py +299 -143
  51. package/vendor/hindsight-memory/scripts/session_start.py +14 -0
  52. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
  54. package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
@@ -17357,6 +17357,13 @@ function projectAssistantTextBlocks(content, make) {
17357
17357
  });
17358
17358
  return out;
17359
17359
  }
17360
+ function sumUsageTokens(usage) {
17361
+ if (usage == null || typeof usage !== "object")
17362
+ return 0;
17363
+ const u = usage;
17364
+ const n = (v) => typeof v === "number" && Number.isFinite(v) ? v : 0;
17365
+ return n(u.input_tokens) + n(u.output_tokens) + n(u.cache_creation_input_tokens);
17366
+ }
17360
17367
  function assistantLineCarriesAnswerSurface(content) {
17361
17368
  if (!Array.isArray(content))
17362
17369
  return false;
@@ -17412,6 +17419,15 @@ function projectTranscriptLine(line) {
17412
17419
  if (typeof mainModel === "string" && !isModelSentinel(mainModel)) {
17413
17420
  events.push({ kind: "model", model: mainModel });
17414
17421
  }
17422
+ const mainUsageTotal = sumUsageTokens(message?.usage);
17423
+ if (mainUsageTotal > 0) {
17424
+ const mainMsgId = message?.id;
17425
+ events.push({
17426
+ kind: "usage",
17427
+ messageId: typeof mainMsgId === "string" ? mainMsgId : null,
17428
+ totalTokens: mainUsageTotal
17429
+ });
17430
+ }
17415
17431
  const textEvents = projectAssistantTextBlocks(content, (text, blockIndex, lastInMessage) => ({ kind: "text", text, blockIndex, lastInMessage }));
17416
17432
  content.forEach((c, i) => {
17417
17433
  const ct = c.type;
@@ -17518,6 +17534,16 @@ function projectSubagentLine(line, agentId, state) {
17518
17534
  if (typeof subModel === "string" && !isModelSentinel(subModel)) {
17519
17535
  events.push({ kind: "sub_agent_model", agentId, model: subModel });
17520
17536
  }
17537
+ const subUsageTotal = sumUsageTokens(message?.usage);
17538
+ if (subUsageTotal > 0) {
17539
+ const subMsgId = message?.id;
17540
+ events.push({
17541
+ kind: "sub_agent_usage",
17542
+ agentId,
17543
+ messageId: typeof subMsgId === "string" ? subMsgId : null,
17544
+ totalTokens: subUsageTotal
17545
+ });
17546
+ }
17521
17547
  const textEvents = projectAssistantTextBlocks(content, (text, blockIndex, lastInMessage) => ({
17522
17548
  kind: "sub_agent_text",
17523
17549
  agentId,
@@ -52,6 +52,7 @@ import {
52
52
  import {
53
53
  buildVaultGrantApprovedInbound,
54
54
  buildVaultGrantApprovedCardText,
55
+ normalizeGrantReason,
55
56
  buildVaultGrantDeniedInbound,
56
57
  buildVaultSaveCompletedInbound,
57
58
  buildVaultSaveFailedInbound,
@@ -786,6 +787,10 @@ async function performVaultAccessApproval(
786
787
  pendingCardStore.remove(stageId)
787
788
  if (pending.card_message_id != null) {
788
789
  const days = Math.round(pending.ttl_seconds / 86400)
790
+ // Normalize + cap the agent-supplied reason to a single line BEFORE
791
+ // escaping, so the value passed into the card builder is already
792
+ // safe to render inside the `_Reason: …_` italic clause.
793
+ const reasonNormalized = normalizeGrantReason(pending.reason)
789
794
  const footer =
790
795
  getVaultApprovalAuthMode() === 'telegram-id'
791
796
  ? `\n_Approver verified by Telegram identity — broker auto-unlocked at startup._`
@@ -801,6 +806,8 @@ async function performVaultAccessApproval(
801
806
  key: pending.key,
802
807
  days,
803
808
  grantId: id,
809
+ reasonEscaped:
810
+ reasonNormalized.length > 0 ? escapeHtmlForTg(reasonNormalized) : undefined,
804
811
  footer,
805
812
  }),
806
813
  ),
@@ -243,7 +243,11 @@ import { isFinalAnswerReply, isSubstantiveFinalReply, FINAL_ANSWER_MIN_CHARS } f
243
243
  import { deriveTurnRole, decideTerminalReason, parsePostAnswerLivenessMs, evaluatePostAnswerLiveness, type LoopRole } from '../turn-liveness-floor.js'
244
244
  import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
245
245
  import { parseVisibleAnswerStreamEnabled, resolveAnswerLaneConfig } from '../answer-stream-flag.js'
246
- import { type SessionEvent } from '../session-tail.js'
246
+ import {
247
+ type SessionEvent,
248
+ projectTrailingAnswerFromTranscript,
249
+ getProjectsDirForCwd,
250
+ } from '../session-tail.js'
247
251
  import {
248
252
  shouldSuppressToolActivity,
249
253
  } from '../pty-tail.js'
@@ -303,6 +307,7 @@ import {
303
307
  checkpointWal as checkpointHistoryWal,
304
308
  pruneMessagesOlderThanDays,
305
309
  hasOutboundDeliveredSince,
310
+ hasOutboundWithText,
306
311
  } from '../history.js'
307
312
  import {
308
313
  runRegistryReaper,
@@ -352,6 +357,7 @@ const REPLY_TO_TEXT_MAX = 200
352
357
  // tests exercise the real string — see PR #2892.
353
358
  import { splitMarkdownChunks, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, normalizePunctuation, stripExcessBold, escapeMarkdown, hardenCardBreaks, RICH_MESSAGE_MAX_CHARS } from '../format.js'
354
359
  import { richMessage } from '../rich-send.js'
360
+ import { decideRedeliver, decideRedeliverCapture } from './redelivery-decision.js'
355
361
  import { scrubVoice } from '../text-voice-scrub.js'
356
362
  import {
357
363
  normalizeOutboundBody,
@@ -833,7 +839,10 @@ import {
833
839
  findRecentTurnsForChat,
834
840
  getTurnByKey,
835
841
  markTurnResumed,
842
+ markAnswerRedelivered,
843
+ stampTurnSessionId,
836
844
  reapStaleOpenTurns,
845
+ type Turn,
837
846
  } from '../registry/turns-schema.js'
838
847
  import {
839
848
  buildResumeInterruptedInbound,
@@ -1694,6 +1703,13 @@ let turnsDb: ReturnType<typeof openTurnsDb> | null = null
1694
1703
  // Stashed here; pushed to the spool once it's constructed below. The spool's
1695
1704
  // turn_key-keyed dedup makes a re-stash across multiple restarts a no-op.
1696
1705
  let bootResumeInbound: { agent: string; msg: InboundMessage } | null = null
1706
+ // Crash-survival redelivery candidate, captured during the boot-resume block
1707
+ // (module init, BEFORE the Telegram client connects) and consumed by
1708
+ // `maybeRedeliverUndeliveredAnswer` in the one-time-setup block AFTER
1709
+ // `bot.api.getMe()` succeeds — so the framed recovered-answer send is sequenced
1710
+ // after the client is ready, never fired mid module-init. `null` when there is
1711
+ // no interrupted turn to consider.
1712
+ let pendingRedelivery: { turn: Turn; maxAgeMs: number } | null = null
1697
1713
  // #3038 cross-boot damper: consecutive bridge-dead escalations by PRIOR
1698
1714
  // boots (the consumed marker's `count`; 0 when no fresh marker). Set in
1699
1715
  // the boot block below, consumed by the watchdog constructor further down.
@@ -1828,6 +1844,50 @@ try {
1828
1844
  maxAgeMs: RESUME_MAX_AGE_MS,
1829
1845
  })
1830
1846
 
1847
+ // Crash-survival redelivery (deterministic, zero-token): capture THIS
1848
+ // interrupted turn as a redelivery candidate. The actual re-project + framed
1849
+ // send happens later, AFTER the Telegram client connects (see
1850
+ // `maybeRedeliverUndeliveredAnswer`, fired from the one-time-setup block).
1851
+ // Gated on the same `pending` as the resume synthetic but a SEPARATE concern:
1852
+ // resume re-runs the model on unfinished work; redelivery just re-sends the
1853
+ // finished answer the model already produced and the crash swallowed.
1854
+ //
1855
+ // MUTUAL EXCLUSION with resume (double-send guard): redelivery is captured
1856
+ // ONLY when this turn will NOT be re-run by the resume path — i.e.
1857
+ // `bootResumeKind !== 'resume'`. When the kind IS 'resume', the model re-runs
1858
+ // the interrupted work and emits a FRESH answer that supersedes the recovered
1859
+ // draft; redelivering as well would send the same answer twice (once framed
1860
+ // "Recovered from an interrupted turn:", once fresh). The other kinds do NOT
1861
+ // auto-re-answer, so redelivery is the correct (and only) send there:
1862
+ // - 'report' (watchdog-timeout): synthetic only ASKS retry — no auto re-answer
1863
+ // - 'defer-suppressed' (boot_resume: never): no synthetic re-run at all
1864
+ // - 'defer-loop' (resume-of-a-resume guard): no re-run
1865
+ // - 'none' (nothing queued): redelivery is the sole recovery
1866
+ // This gate is deterministic in code, not prompt-dependent.
1867
+ //
1868
+ // Eligibility floor: only a turn with a durably-stamped `session_id` (pinned
1869
+ // live via the session-event path) qualifies — without it we cannot resolve
1870
+ // the EXACT transcript, and a most-recent-mtime heuristic could shadow a
1871
+ // fresh boot session's file. The mutual-exclusion-with-resume rule and this
1872
+ // floor live together in the pure `decideRedeliverCapture` predicate.
1873
+ const redeliverCapture = decideRedeliverCapture({
1874
+ willBeResumed: bootResumeKind === 'resume',
1875
+ hasSessionId: Boolean(pending.session_id),
1876
+ })
1877
+ if (redeliverCapture.capture) {
1878
+ pendingRedelivery = { turn: pending, maxAgeMs: RESUME_MAX_AGE_MS }
1879
+ } else if (redeliverCapture.skipReason === 'will-be-resumed') {
1880
+ process.stderr.write(
1881
+ `telegram gateway: crash-redelivery suppressed — interrupted turnKey=${pending.turn_key} will be ` +
1882
+ `RESUMED (bootResumeKind=resume); the fresh re-answer supersedes the recovered draft (no double-send)\n`,
1883
+ )
1884
+ } else {
1885
+ process.stderr.write(
1886
+ `telegram gateway: crash-redelivery skipped — interrupted turnKey=${pending.turn_key} has no ` +
1887
+ `pinned session_id (pre-feature turn or no session event seen); cannot resolve exact transcript\n`,
1888
+ )
1889
+ }
1890
+
1831
1891
  // Sub-agents that were still in flight (running / stalled — non-terminal)
1832
1892
  // when the turn was killed. Read HERE, at module top, BEFORE the
1833
1893
  // subagent-watcher's boot scan + reaper run: the watcher never deletes
@@ -2063,6 +2123,18 @@ const WORKER_FEED_STALE_TTL_MARGIN_MS = 5 * 60_000
2063
2123
  * complete override surface on this path.)
2064
2124
  */
2065
2125
  const WORKER_FEED_ABSOLUTE_ROW_LIFETIME_CAP_MULTIPLE = 4
2126
+ /**
2127
+ * ABSOLUTE reused-group-MESSAGE lifetime cap (invisible-worker-cards fix,
2128
+ * 2026-07-15). Bounds how long the shared worker-feed message is reused before
2129
+ * it is force-rotated to a fresh message, re-establishing the pin surface so a
2130
+ * card whose pin was lost out-of-band (and whose in-memory claim went stale)
2131
+ * cannot stay scroll-buried indefinitely. Conservative default 60 min; env
2132
+ * `SWITCHROOM_WORKER_FEED_GROUP_MESSAGE_LIFETIME_CAP_MS` overrides for tuning.
2133
+ */
2134
+ const WORKER_FEED_GROUP_MESSAGE_LIFETIME_CAP_MS = (() => {
2135
+ const v = Number(process.env.SWITCHROOM_WORKER_FEED_GROUP_MESSAGE_LIFETIME_CAP_MS)
2136
+ return Number.isFinite(v) && v > 0 ? v : 60 * 60_000
2137
+ })()
2066
2138
  const workerFeedOwnerDmFallbackLogged = new Set<string>()
2067
2139
 
2068
2140
  /**
@@ -2977,6 +3049,10 @@ const PENDING_CMD_DRAIN_CAP_MS = 60_000
2977
3049
  // forwarded by the bridge on every session_event — we read occupancy from
2978
3050
  // exactly that file (never an independent findActiveSessionFile re-scan).
2979
3051
  let lastSessionActiveFile: string | null = null
3052
+ // Crash-survival redelivery (#session-id pin): the last turn_key whose
3053
+ // `session_id` we durably stamped, so the hot session-event path stamps once
3054
+ // per turn instead of issuing a guarded UPDATE on every event.
3055
+ let lastSessionStampedTurnKey: string | null = null
2980
3056
  // Anti-spam state machine lives in ./proactive-compact (pure, unit
2981
3057
  // tested). `compactDispatching` is a synchronous re-entrancy guard for
2982
3058
  // the async tmux send — purgeReactionTracking can run several times per
@@ -3246,6 +3322,21 @@ type CurrentTurn = {
3246
3322
  // sync_retain are suppressed at the hook (computeLabel returns null) and
3247
3323
  // never arrive as tool_label events — excluded automatically.
3248
3324
  labeledToolCount: number
3325
+ // Running total of the PARENT agent's OWN token usage this turn, summed from
3326
+ // the main-tier session-tail `usage` events (input + output + cache_creation
3327
+ // per assistant message, via sumUsageTokens; cache_read is excluded —
3328
+ // replayed cached context, not new work). Rendered on the
3329
+ // 🤖 turn-activity card's metrics line (`… · N tok · model`). This is the
3330
+ // parent alone — sub-agent tokens are NOT folded in here (they report on
3331
+ // their own worker-feed rows; summing them would double-count). 0 until the
3332
+ // turn's first assistant line carries a usage block.
3333
+ totalTokens: number
3334
+ // Dedup guard for `totalTokens`: Claude Code persists one logical assistant
3335
+ // message as MULTIPLE JSONL lines sharing one `message.id`, each stamped with
3336
+ // the SAME `usage` block. We fold a given `message.id` exactly once (same
3337
+ // idempotency as the sub-agent watcher's `seenUsageMessageIds`). A null/absent
3338
+ // messageId is un-dedupable and always counted.
3339
+ seenUsageMessageIds: Set<string>
3249
3340
  // Tool-activity summary — mirrors Claude Code's native chat-UI
3250
3341
  // rendering ("Ran 5 commands, read a file"). Counters are
3251
3342
  // incremented in `case 'tool_use'`; `activityMessageId` holds the
@@ -8958,6 +9049,14 @@ async function reconcileStatusPinInner(
8958
9049
  ): Promise<void> {
8959
9050
  if (!PIN_STATUS_WHILE_WORKING) return
8960
9051
  if (chatId.length === 0) return
9052
+ // NOTE (invisible-worker-cards review, intentionally left): this reconcile is
9053
+ // NOT serialized per pinKey — it snapshots `prev` then awaits. Two edits that
9054
+ // fire `syncPin` in the same microtask window after a dropped claim can both
9055
+ // read `prev=null` and both issue a `pinChatMessage` for the SAME id. That is
9056
+ // benign and self-healing: re-pinning an already-pinned id is idempotent on
9057
+ // Telegram, and the first reconcile to set the claim makes every subsequent
9058
+ // edit a no-op — it converges in one round, never a storm. A per-key mutex
9059
+ // would remove the duplicate pin but adds lock complexity for zero UX gain.
8961
9060
  const prev = statusPinState.get(pinKey) ?? null
8962
9061
 
8963
9062
  const runReconcile = () =>
@@ -10458,6 +10557,150 @@ async function deliverCapturedProse(args: {
10458
10557
  }
10459
10558
  }
10460
10559
 
10560
+ /**
10561
+ * Crash-survival redelivery — the LIVE boot send (deterministic, zero model
10562
+ * tokens). Consumes the `pendingRedelivery` candidate captured during module
10563
+ * init and, if the interrupted turn's finished answer never reached the user,
10564
+ * re-projects it from the durable transcript and sends it FRAMED as a recovered
10565
+ * draft.
10566
+ *
10567
+ * ── Ordering guarantee (why this is NOT called during module init) ───────────
10568
+ * The Telegram raw send (`bot.api.sendRichMessage`) requires a CONNECTED client.
10569
+ * The boot-resume block that computes `pendingRedelivery` runs at module top,
10570
+ * BEFORE `bot.api.getMe()` and the grammy runner start — sending there would
10571
+ * fire against an unconnected client (or block the connect). So the candidate is
10572
+ * only STASHED at module init; this function is invoked exactly once from the
10573
+ * `didOneTimeSetup` block AFTER `getMe()` resolves — i.e. after the client is
10574
+ * connected and ready. This sequencing is a code-path guarantee, not prompt
10575
+ * discipline: the call site is unreachable until the poll loop has authenticated.
10576
+ *
10577
+ * ── Invariants honored ──────────────────────────────────────────────────────
10578
+ * - at-most-once per interruption: the durable text-identity oracle
10579
+ * (`hasOutboundWithText`, scoped to this turn's `started_at`) skips when the
10580
+ * answer already went out, and `markAnswerRedelivered` is stamped SYNCHRONOUSLY
10581
+ * after the send resolves (first-write-wins). The send also writes its own
10582
+ * `role='assistant'` history row, so a re-restart before the marker commits is
10583
+ * still caught by the oracle (residual send→row race documented on the marker).
10584
+ * - RESUME_MAX_AGE_MS (3h) staleness, empty-text, trailing-not-text, and
10585
+ * already-delivered/already-redelivered are all enforced by `decideRedeliver`.
10586
+ * - isApiErrorMessage lines are suppressed at the projector (never resurfaced).
10587
+ * - only trailing TEXT after the last tool_use is redelivered (mid-tool preamble
10588
+ * is refused by `trailingIsText`).
10589
+ */
10590
+ async function maybeRedeliverUndeliveredAnswer(): Promise<void> {
10591
+ const candidate = pendingRedelivery
10592
+ pendingRedelivery = null // consume once, regardless of outcome
10593
+ if (candidate == null || turnsDb == null) return
10594
+ const { turn, maxAgeMs } = candidate
10595
+ const sessionId = turn.session_id
10596
+ if (!sessionId) return
10597
+
10598
+ // Resolve the EXACT transcript from the pinned session id (never a
10599
+ // most-recent-mtime scan, which a fresh boot session's file could shadow).
10600
+ let transcriptText: string
10601
+ try {
10602
+ const projectsDir = getProjectsDirForCwd()
10603
+ const path = join(projectsDir, `${sessionId}.jsonl`)
10604
+ if (!existsSync(path)) {
10605
+ process.stderr.write(
10606
+ `telegram gateway: crash-redelivery — transcript not found for turnKey=${turn.turn_key} ` +
10607
+ `session=${sessionId} (${path}); skipping\n`,
10608
+ )
10609
+ return
10610
+ }
10611
+ transcriptText = readFileSync(path, 'utf8')
10612
+ } catch (err) {
10613
+ process.stderr.write(
10614
+ `telegram gateway: crash-redelivery — transcript read failed turnKey=${turn.turn_key}: ${(err as Error).message}\n`,
10615
+ )
10616
+ return
10617
+ }
10618
+
10619
+ const projected = projectTrailingAnswerFromTranscript(transcriptText)
10620
+ const threadIdNum =
10621
+ turn.thread_id != null && turn.thread_id !== '' ? Number(turn.thread_id) : undefined
10622
+ const threadIdForOracle: number | null = threadIdNum != null && Number.isFinite(threadIdNum) ? threadIdNum : null
10623
+
10624
+ const decision = decideRedeliver({
10625
+ capturedText: projected.text,
10626
+ trailingIsText: projected.trailingIsText,
10627
+ // Durable text-identity oracle, scoped to THIS turn's window (`started_at`)
10628
+ // so an unrelated earlier turn's message can never false-positive-suppress.
10629
+ hasDeliveredText: HISTORY_ENABLED
10630
+ ? hasOutboundWithText(turn.chat_id, projected.text, threadIdForOracle, turn.started_at)
10631
+ : false,
10632
+ alreadyRedelivered: turn.answer_redelivered_at != null,
10633
+ ageMs: Math.max(0, Date.now() - turn.started_at),
10634
+ maxAgeMs,
10635
+ })
10636
+
10637
+ if (!decision.redeliver || decision.framedText == null) {
10638
+ process.stderr.write(
10639
+ `telegram gateway: crash-redelivery skipped turnKey=${turn.turn_key} reason=${decision.skipReason ?? 'unknown'}\n`,
10640
+ )
10641
+ return
10642
+ }
10643
+
10644
+ const chatId = turn.chat_id
10645
+ const out = redactOutboundText(decision.framedText, 'crash_redelivery')
10646
+ const chunks = splitMarkdownChunks(out, RICH_MESSAGE_MAX_CHARS)
10647
+ const sentIds: number[] = []
10648
+ try {
10649
+ let liveThreadId: number | undefined = threadIdNum != null && Number.isFinite(threadIdNum) ? threadIdNum : undefined
10650
+ for (const c of chunks) {
10651
+ const sent = await retryWithThreadFallback(
10652
+ robustApiCall,
10653
+ (tid) => {
10654
+ // Built as a variable (not an inline literal) so excess-property
10655
+ // checks don't reject `link_preview_options` on sendRichMessage's
10656
+ // narrow Other<> type — mirrors the captured-prose / turn-flush sites.
10657
+ const opts = {
10658
+ link_preview_options: { is_disabled: true },
10659
+ ...(tid != null ? { message_thread_id: tid } : {}),
10660
+ }
10661
+ return bot.api.sendRichMessage(chatId, richMessage(c), opts)
10662
+ },
10663
+ { threadId: liveThreadId, chat_id: chatId, verb: 'crash-redelivery.sendMessage' },
10664
+ )
10665
+ if (liveThreadId != null && (sent as { message_thread_id?: number }).message_thread_id == null) {
10666
+ liveThreadId = undefined
10667
+ }
10668
+ sentIds.push(sent.message_id)
10669
+ }
10670
+ // Record the send as a real assistant delivery so the durable oracle catches
10671
+ // a duplicate if we crash before the marker commits (self-idempotent).
10672
+ if (HISTORY_ENABLED && sentIds.length > 0) {
10673
+ try {
10674
+ recordOutbound({
10675
+ chat_id: chatId,
10676
+ thread_id: threadIdForOracle,
10677
+ message_ids: sentIds,
10678
+ texts: chunks,
10679
+ })
10680
+ } catch {}
10681
+ }
10682
+ // Stamp the at-most-once marker SYNCHRONOUSLY after the send resolves.
10683
+ try {
10684
+ markAnswerRedelivered(turnsDb, turn.turn_key)
10685
+ } catch (err) {
10686
+ process.stderr.write(
10687
+ `telegram gateway: crash-redelivery markAnswerRedelivered failed turnKey=${turn.turn_key}: ${(err as Error).message}\n`,
10688
+ )
10689
+ }
10690
+ process.stderr.write(
10691
+ `telegram gateway: crash-redelivery — delivered recovered answer (${out.length} chars, ` +
10692
+ `${chunks.length} chunk(s)) for turnKey=${turn.turn_key} chat=${chatId}\n`,
10693
+ )
10694
+ } catch (err) {
10695
+ // Send failed — leave the marker UNSTAMPED so a later restart retries rather
10696
+ // than silently dropping the recovered answer.
10697
+ process.stderr.write(
10698
+ `telegram gateway: crash-redelivery send failed turnKey=${turn.turn_key}: ${(err as Error).message} ` +
10699
+ `— left un-stamped for a later retry\n`,
10700
+ )
10701
+ }
10702
+ }
10703
+
10461
10704
  function obligationSweep(): void {
10462
10705
  if (!OBLIGATION_LEDGER_ENABLED) return
10463
10706
  if (!obligationLedger.hasOpen()) return
@@ -11211,6 +11454,29 @@ const ipcServer: IpcServer = createIpcServer({
11211
11454
  // Track the session-tail's attached file for the proactive-
11212
11455
  // compaction occupancy read (see maybeProactiveCompact).
11213
11456
  if (msg.activeFile) lastSessionActiveFile = msg.activeFile
11457
+ // Crash-survival redelivery: durably pin the claude session id onto the
11458
+ // current turn's row the first time we see a session event for it, WHILE
11459
+ // the turn is live (so a later crash preserves it). Boot redelivery then
11460
+ // resolves the EXACT `<sessionId>.jsonl` for the interrupted turn rather
11461
+ // than a most-recent-mtime heuristic that a fresh boot session shadows.
11462
+ // First-write-wins in SQL (`session_id IS NULL`); the in-memory guard just
11463
+ // avoids a redundant UPDATE on every event of the same turn.
11464
+ if (turnsDb != null && msg.activeFile != null) {
11465
+ const stampKey = currentTurn?.registryKey ?? null
11466
+ if (stampKey != null && stampKey !== lastSessionStampedTurnKey) {
11467
+ const sessionId = basename(msg.activeFile).replace(/\.jsonl$/, '')
11468
+ if (sessionId) {
11469
+ try {
11470
+ stampTurnSessionId(turnsDb, stampKey, sessionId)
11471
+ lastSessionStampedTurnKey = stampKey
11472
+ } catch (err) {
11473
+ process.stderr.write(
11474
+ `telegram gateway: stampTurnSessionId failed turnKey=${stampKey}: ${(err as Error).message}\n`,
11475
+ )
11476
+ }
11477
+ }
11478
+ }
11479
+ }
11214
11480
  const ev = msg.event as unknown as SessionEvent
11215
11481
  // #1122/#1126: session events used to be ingested into the pinned progress
11216
11482
  // card here (`progressDriver.ingest`). The card is retired and the driver
@@ -15995,6 +16261,9 @@ function composeTurnActivity(turn: CurrentTurn, final = false, liveSuffix = ''):
15995
16261
  toolCount: turn.labeledToolCount,
15996
16262
  state: final ? 'done' : 'running',
15997
16263
  model: turn.currentModel,
16264
+ // The parent's OWN running token total → `· N tok` on the metrics line.
16265
+ // 0 → tokenSegment omits it (clean, same as the worker feed).
16266
+ totalTokens: turn.totalTokens,
15998
16267
  }
15999
16268
  return renderActivityFeedWithNested(turn.mirrorLines, childLines, final, liveSuffix, stepCount, header)
16000
16269
  }
@@ -17052,6 +17321,8 @@ function handleSessionEvent(ev: SessionEvent): void {
17052
17321
  lastAssistantDone: false,
17053
17322
  toolCallCount: 0,
17054
17323
  labeledToolCount: 0,
17324
+ totalTokens: 0,
17325
+ seenUsageMessageIds: new Set<string>(),
17055
17326
  activityMessageId: null,
17056
17327
  activityInFlight: null,
17057
17328
  activityPendingRender: null,
@@ -17233,6 +17504,22 @@ function handleSessionEvent(ev: SessionEvent): void {
17233
17504
  sessionModelSource.noteTranscriptModel(ev.model)
17234
17505
  return
17235
17506
  }
17507
+ case 'usage': {
17508
+ // Fold the parent agent's OWN per-message token usage into the turn's
17509
+ // running total, deduped by message.id (one logical assistant message can
17510
+ // land as several JSONL lines sharing one id + usage block). Rendered on
17511
+ // the 🤖 turn-activity card's metrics line. Sub-agent tokens are NOT
17512
+ // folded here — they surface on their own worker-feed rows; summing them
17513
+ // would double-count. A null messageId is un-dedupable → always counted.
17514
+ const turn = currentTurn
17515
+ if (turn == null) return
17516
+ if (ev.messageId != null) {
17517
+ if (turn.seenUsageMessageIds.has(ev.messageId)) return
17518
+ turn.seenUsageMessageIds.add(ev.messageId)
17519
+ }
17520
+ turn.totalTokens += ev.totalTokens
17521
+ return
17522
+ }
17236
17523
  case 'thinking': {
17237
17524
  // #1067: snapshot the turn atom at handler entry. Even though this
17238
17525
  // handler is sync, the principle is uniform across all event arms
@@ -29841,6 +30128,19 @@ void (async () => {
29841
30128
  )
29842
30129
  }
29843
30130
 
30131
+ // Crash-survival redelivery — LIVE boot send. Now that `bot.api.getMe()`
30132
+ // has resolved (the Telegram client is connected and authenticated), it
30133
+ // is safe to fire the framed recovered-answer send for an interrupted
30134
+ // turn whose finished answer the crash swallowed. Fire-and-forget: it is
30135
+ // self-contained, must not block the rest of one-time setup, and is a
30136
+ // no-op when there is no eligible candidate. See
30137
+ // `maybeRedeliverUndeliveredAnswer` for the ordering guarantee + invariants.
30138
+ void maybeRedeliverUndeliveredAnswer().catch((err) => {
30139
+ process.stderr.write(
30140
+ `telegram gateway: crash-redelivery boot send errored: ${(err as Error).message}\n`,
30141
+ )
30142
+ })
30143
+
29844
30144
  // Boot-time pin sweep
29845
30145
  try {
29846
30146
  const bootAccess = loadAccess()
@@ -30364,6 +30664,12 @@ void (async () => {
30364
30664
  // Derived from the same terminal cap so it tracks operator
30365
30665
  // overrides; 4× → ~3h at the 45-min default.
30366
30666
  absoluteRowLifetimeCapMs: resolveInflightTerminalCapMs() * WORKER_FEED_ABSOLUTE_ROW_LIFETIME_CAP_MULTIPLE,
30667
+ // ABSOLUTE reused-group-MESSAGE lifetime cap (invisible-worker-
30668
+ // cards fix): force-rotate the shared message past this age while
30669
+ // workers overlap continuously, so a card that lost its pin
30670
+ // out-of-band re-establishes the pin surface via the first-paint
30671
+ // path instead of living buried on one immortal message.
30672
+ groupMessageLifetimeCapMs: WORKER_FEED_GROUP_MESSAGE_LIFETIME_CAP_MS,
30367
30673
  // #3207 review: GROUP-level status pin. Workers now coalesce into
30368
30674
  // ONE shared message, so the pin must follow the GROUP lifecycle,
30369
30675
  // not a single worker's — otherwise a sibling's finish unpins a
@@ -30546,7 +30852,7 @@ void (async () => {
30546
30852
  )
30547
30853
  }
30548
30854
  },
30549
- onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs, background: entryBackground }) => {
30855
+ onFinish: ({ agentId, outcome, description, resultText, toolCount, totalTokens, durationMs, background: entryBackground }) => {
30550
30856
  // Reaction promotion: if the parent turn already ended
30551
30857
  // with this (or another) worker still running, its 👍 was
30552
30858
  // deferred (held on ✍️/⚡). Now that a worker finished,
@@ -30616,6 +30922,7 @@ void (async () => {
30616
30922
  description: dispatch.feedDescription,
30617
30923
  lastTool: null,
30618
30924
  toolCount,
30925
+ totalTokens,
30619
30926
  latestSummary: resultText,
30620
30927
  elapsedMs: durationMs,
30621
30928
  state: outcome === 'failed' ? 'failed' : 'done',
@@ -30697,6 +31004,7 @@ void (async () => {
30697
31004
  description: dispatch.feedDescription,
30698
31005
  lastTool: null,
30699
31006
  toolCount,
31007
+ totalTokens,
30700
31008
  latestSummary: resultText,
30701
31009
  elapsedMs: durationMs,
30702
31010
  state: outcome === 'failed' ? 'failed' : 'done',
@@ -30718,6 +31026,7 @@ void (async () => {
30718
31026
  description: dispatch.feedDescription,
30719
31027
  lastTool: null,
30720
31028
  toolCount,
31029
+ totalTokens,
30721
31030
  latestSummary: resultText,
30722
31031
  elapsedMs: durationMs,
30723
31032
  state: outcome === 'failed' ? 'failed' : 'done',
@@ -30808,7 +31117,7 @@ void (async () => {
30808
31117
  // suppresses stale-after-restart delivery (a 4-h-old
30809
31118
  // "still working (5m)" would be a lie). Sweep on handback
30810
31119
  // lives in the `onFinish` block just above.
30811
- onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model, skeleton }) => {
31120
+ onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, totalTokens, progressLine, model, skeleton }) => {
30812
31121
  let fleetChatId = ''
30813
31122
  try {
30814
31123
  const fleets = progressDriver?.peekAllFleets() ?? []
@@ -30889,6 +31198,7 @@ void (async () => {
30889
31198
  elapsedMs,
30890
31199
  state: 'running',
30891
31200
  model: feedModel,
31201
+ totalTokens,
30892
31202
  },
30893
31203
  wk.threadId,
30894
31204
  )
@@ -31043,6 +31353,7 @@ void (async () => {
31043
31353
  elapsedMs,
31044
31354
  state: 'running',
31045
31355
  model: feedModel,
31356
+ totalTokens,
31046
31357
  },
31047
31358
  wk.threadId,
31048
31359
  )