switchroom 0.18.23 → 0.18.25

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 (45) hide show
  1. package/dist/cli/switchroom.js +59 -11
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/telegram-plugin/dist/bridge/bridge.js +26 -0
  5. package/telegram-plugin/dist/gateway/gateway.js +1608 -841
  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 +524 -16
  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/reply-owner-resolve.ts +160 -0
  15. package/telegram-plugin/session-tail.ts +185 -0
  16. package/telegram-plugin/subagent-watcher.ts +45 -0
  17. package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
  18. package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
  19. package/telegram-plugin/tests/history.test.ts +91 -0
  20. package/telegram-plugin/tests/model-command.test.ts +189 -12
  21. package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
  22. package/telegram-plugin/tests/registry-turns.test.ts +51 -0
  23. package/telegram-plugin/tests/reply-owner-resolve.test.ts +279 -0
  24. package/telegram-plugin/tests/session-model-source.test.ts +11 -0
  25. package/telegram-plugin/tests/session-tail.test.ts +145 -0
  26. package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
  27. package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
  28. package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
  29. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
  30. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +117 -1
  31. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
  32. package/telegram-plugin/tool-activity-summary.ts +54 -3
  33. package/telegram-plugin/worker-activity-feed.ts +222 -10
  34. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
  35. package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
  36. package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
  37. package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
  38. package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
  39. package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
  40. package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
  41. package/vendor/hindsight-memory/scripts/retain.py +299 -143
  42. package/vendor/hindsight-memory/scripts/session_start.py +14 -0
  43. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
  44. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
  45. package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
@@ -88,7 +88,7 @@ import {
88
88
  type TelegraphAccount,
89
89
  } from '../telegraph.js'
90
90
  import { OutboundDedupCache } from '../recent-outbound-dedup.js'
91
- import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
91
+ import { FlushedTurnSupersedeRegistry, DEFAULT_SUPERSEDE_TTL_MS } from '../flushed-turn-supersede.js'
92
92
  import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
93
93
  import {
94
94
  splitCoalescedAttachments,
@@ -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,
@@ -400,7 +406,12 @@ import {
400
406
  import {
401
407
  decideTurnFlush,
402
408
  isTurnFlushSafetyEnabled,
409
+ FLUSH_SUBSTANTIVE_MIN_CHARS,
403
410
  } from '../turn-flush-safety.js'
411
+ import {
412
+ resolveReplyOwnerTurnId,
413
+ decideAnswerLatchSuppression,
414
+ } from '../reply-owner-resolve.js'
404
415
  // PR A — deterministic answer-ready quiescence flush (late-delivery fix).
405
416
  import {
406
417
  AnswerReadyFlushController,
@@ -828,7 +839,10 @@ import {
828
839
  findRecentTurnsForChat,
829
840
  getTurnByKey,
830
841
  markTurnResumed,
842
+ markAnswerRedelivered,
843
+ stampTurnSessionId,
831
844
  reapStaleOpenTurns,
845
+ type Turn,
832
846
  } from '../registry/turns-schema.js'
833
847
  import {
834
848
  buildResumeInterruptedInbound,
@@ -1689,6 +1703,13 @@ let turnsDb: ReturnType<typeof openTurnsDb> | null = null
1689
1703
  // Stashed here; pushed to the spool once it's constructed below. The spool's
1690
1704
  // turn_key-keyed dedup makes a re-stash across multiple restarts a no-op.
1691
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
1692
1713
  // #3038 cross-boot damper: consecutive bridge-dead escalations by PRIOR
1693
1714
  // boots (the consumed marker's `count`; 0 when no fresh marker). Set in
1694
1715
  // the boot block below, consumed by the watchdog constructor further down.
@@ -1823,6 +1844,50 @@ try {
1823
1844
  maxAgeMs: RESUME_MAX_AGE_MS,
1824
1845
  })
1825
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
+
1826
1891
  // Sub-agents that were still in flight (running / stalled — non-terminal)
1827
1892
  // when the turn was killed. Read HERE, at module top, BEFORE the
1828
1893
  // subagent-watcher's boot scan + reaper run: the watcher never deletes
@@ -2036,6 +2101,40 @@ const WORKER_FEED_FALLBACK_LOG_CAP = 256
2036
2101
  * slot, never a live-but-quiet worker. 5 min.
2037
2102
  */
2038
2103
  const WORKER_FEED_STALE_TTL_MARGIN_MS = 5 * 60_000
2104
+ /**
2105
+ * Multiple of the watcher's in-flight terminal cap used to derive the worker-
2106
+ * feed ABSOLUTE row-lifetime cap (`absoluteRowLifetimeCapMs`). Unlike the
2107
+ * silence-keyed `staleWorkerTtlMs` backstop, the absolute cap is anchored to a
2108
+ * row's immutable creation time, so it reaps an immortal card even when the row
2109
+ * keeps receiving `update()` cues that reset `lastUpdateAt` every heartbeat
2110
+ * (the Carrie 5h zombie-pin leak, re-edited 3000+ times, that the silence sweep
2111
+ * could never match). At the 45-min default cap this yields a 3-hour absolute
2112
+ * ceiling — comfortably above any legitimate single worker's lifetime, so it
2113
+ * can only ever bite a genuine leak, while bounding a ghost card well UNDER the
2114
+ * ~5h symptom Ken hit (the initial 8x/6h was too loose). Derived from the same
2115
+ * base as `staleWorkerTtlMs` — the watcher's effective in-flight terminal cap
2116
+ * (`resolveInflightTerminalCapMs()`: the `SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_
2117
+ * CAP_MS` env override, else the 45-min default) — never a bare magic number, so
2118
+ * it tracks the env-level terminal-cap override in lockstep with the silence
2119
+ * backstop. (Both this and `staleWorkerTtlMs` call `resolveInflightTerminalCapMs()`
2120
+ * with NO arg, matching the watcher's OWN effective value here: the gateway does
2121
+ * not pass a config-file `inflightTerminalCapMs` to `startSubagentWatcher`, so
2122
+ * there is no config-file override to thread through — env + default is the
2123
+ * complete override surface on this path.)
2124
+ */
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
+ })()
2039
2138
  const workerFeedOwnerDmFallbackLogged = new Set<string>()
2040
2139
 
2041
2140
  /**
@@ -2950,6 +3049,10 @@ const PENDING_CMD_DRAIN_CAP_MS = 60_000
2950
3049
  // forwarded by the bridge on every session_event — we read occupancy from
2951
3050
  // exactly that file (never an independent findActiveSessionFile re-scan).
2952
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
2953
3056
  // Anti-spam state machine lives in ./proactive-compact (pure, unit
2954
3057
  // tested). `compactDispatching` is a synchronous re-entrancy guard for
2955
3058
  // the async tmux send — purgeReactionTracking can run several times per
@@ -3104,6 +3207,27 @@ type CurrentTurn = {
3104
3207
  // false ONLY at turn start, mirroring `activityEverOpened`'s sticky-true
3105
3208
  // contract.
3106
3209
  finalAnswerEverDelivered: boolean
3210
+ // 2026-07 double-reply-on-DM fix (Part 2 — race backstop). Set true
3211
+ // SYNCHRONOUSLY at turn-flush FIRE time (before the ~500 ms async send and
3212
+ // before `flushedTurnSupersede.record`) when the flush delivers a SUBSTANTIVE
3213
+ // (≥`FLUSH_SUBSTANTIVE_MIN_CHARS`) terminal answer, and also set when a
3214
+ // substantive `reply` sends. It persists on the ended turn in
3215
+ // `recentTurnsById`, so a LATE reply landing in the flush's post-fire
3216
+ // pre-record race window (where `flushedTurnSupersede` finds no record to
3217
+ // delete yet) resolves this turn via the unified owner resolver, sees the
3218
+ // latch already set, and suppresses itself — closing the residual window Part
3219
+ // 1's supersede cannot reach. Scoped to the substantive floor so an interim
3220
+ // sub-floor ack NEITHER sets nor trips it. Reset false at turn start.
3221
+ answerDelivered: boolean
3222
+ // 2026-07 double-reply-on-DM fix (F2 — recency bound). Wall-clock ms the turn
3223
+ // ENDED (stamped once by `endCurrentTurnAtomic`), or null while still live.
3224
+ // The `findLatestEndedTurnForChat` supersede tier carries DESTRUCTIVE
3225
+ // authority (it drives message deletion), so `resolveReplyOwnerTurn` only
3226
+ // honours a latest-ended turn whose `endedAt` is within the supersede TTL —
3227
+ // otherwise a late reply belonging to an OLDER turn could resolve its owner to
3228
+ // a NEWER turn sitting at the registry tail and delete that newer turn's legit
3229
+ // answer. Unbounded routing use of `findLatestEndedTurnForChat` is unaffected.
3230
+ endedAt: number | null
3107
3231
  // #1675 (over-ping safety net): wall-clock ms of the first reply
3108
3232
  // this turn that landed with `disable_notification: false` (a real
3109
3233
  // device ping). The conversational-pacing contract
@@ -3198,6 +3322,21 @@ type CurrentTurn = {
3198
3322
  // sync_retain are suppressed at the hook (computeLabel returns null) and
3199
3323
  // never arrive as tool_label events — excluded automatically.
3200
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>
3201
3340
  // Tool-activity summary — mirrors Claude Code's native chat-UI
3202
3341
  // rendering ("Ran 5 commands, read a file"). Counters are
3203
3342
  // incremented in `case 'tool_use'`; `activityMessageId` holds the
@@ -3608,6 +3747,55 @@ function findLatestEndedTurnForChat(chatId: string): CurrentTurn | null {
3608
3747
  return latest
3609
3748
  }
3610
3749
 
3750
+ /**
3751
+ * 2026-07 double-reply-on-DM fix (Part 1). Resolve the turn that OWNS a landing
3752
+ * reply using the SAME full chain the thread-router uses, so the supersede
3753
+ * resolver can never again diverge from routing (the exact bug: supersede
3754
+ * omitted the quoted-message and latest-ended recoveries, so a DM late reply —
3755
+ * no live turn, no `origin_turn_id` — resolved to a null owner and its flush
3756
+ * message was never superseded → duplicate).
3757
+ *
3758
+ * Precedence (first non-null wins), delegated to the pure
3759
+ * `resolveReplyOwnerTurnId` so the exact precedence is unit-tested:
3760
+ * 1. the live `currentTurn` passed in (null once the flush nulled the atom);
3761
+ * 2. `findTurnByOriginId(origin_turn_id)` — the model echo;
3762
+ * 3. `findTurnByQuotedMessageId(chat_id, reply_to)` — framework-owned quote;
3763
+ * 4. `findLatestEndedTurnForChat(chat_id)` — the chat's last-ended turn.
3764
+ * Returns the CurrentTurn for the winning id (so callers can read its
3765
+ * `answerDelivered` latch), or null when every lookup missed.
3766
+ */
3767
+ function resolveReplyOwnerTurn(
3768
+ liveTurn: CurrentTurn | null,
3769
+ chatId: string,
3770
+ args: Record<string, unknown>,
3771
+ ): CurrentTurn | null {
3772
+ const origin = findTurnByOriginId(args.origin_turn_id as string | undefined)
3773
+ const quoted = findTurnByQuotedMessageId(chatId, args.reply_to)
3774
+ const latestEnded = findLatestEndedTurnForChat(chatId)
3775
+ const byId = new Map<string, CurrentTurn>()
3776
+ // Populate lowest-precedence first so a higher tier's turn wins the id slot
3777
+ // when two lookups resolve the same turn (they carry the same turnId anyway).
3778
+ for (const t of [latestEnded, quoted, origin, liveTurn]) {
3779
+ if (t != null) byId.set(t.turnId, t)
3780
+ }
3781
+ // F2 — bound the DESTRUCTIVE latest-ended tier to the supersede TTL so a stale
3782
+ // latest-ended turn can't inherit deletion authority over a newer turn's flush
3783
+ // record. `endedAt` is null only for a turn still resolvable but not yet ended
3784
+ // (not a supersede risk); leave the age unset then (unbounded) rather than
3785
+ // fabricate one.
3786
+ const latestEndedAgeMs =
3787
+ latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null
3788
+ const winnerId = resolveReplyOwnerTurnId({
3789
+ liveTurnId: liveTurn?.turnId ?? null,
3790
+ originTurnId: origin?.turnId ?? null,
3791
+ quotedTurnId: quoted?.turnId ?? null,
3792
+ latestEndedTurnId: latestEnded?.turnId ?? null,
3793
+ latestEndedAgeMs,
3794
+ latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
3795
+ })
3796
+ return winnerId != null ? (byId.get(winnerId) ?? null) : null
3797
+ }
3798
+
3611
3799
  /**
3612
3800
  * Resolve the answer-reply thread AND emit `reply-route` telemetry. The
3613
3801
  * 2026-06-05 triage showed reply routing was the blind spot: `reply: invoked`
@@ -4842,6 +5030,12 @@ function endCurrentTurnAtomic(
4842
5030
  // the turn got), plus a DEGRADED warning when the turn did tool work but the
4843
5031
  // live feed never opened because its sends failed (the resume-400 signature).
4844
5032
  const turnEndedAt = Date.now()
5033
+ // 2026-07 double-reply-on-DM fix (F2) — stamp the turn's end time so the
5034
+ // `findLatestEndedTurnForChat` supersede tier can be recency-bounded to the
5035
+ // supersede TTL (a stale latest-ended turn must not inherit deletion
5036
+ // authority over a newer turn's flush record). Set once; idempotent on the
5037
+ // deferRecord flush path (which calls this synchronously before its send).
5038
+ turn.endedAt = turnEndedAt
4845
5039
  process.stderr.write(
4846
5040
  `telegram gateway: ${formatTurnLifecycle('clear', 'turn_end', turn, turnEndedAt)}\n`,
4847
5041
  )
@@ -8855,6 +9049,14 @@ async function reconcileStatusPinInner(
8855
9049
  ): Promise<void> {
8856
9050
  if (!PIN_STATUS_WHILE_WORKING) return
8857
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.
8858
9060
  const prev = statusPinState.get(pinKey) ?? null
8859
9061
 
8860
9062
  const runReconcile = () =>
@@ -10355,6 +10557,150 @@ async function deliverCapturedProse(args: {
10355
10557
  }
10356
10558
  }
10357
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
+
10358
10704
  function obligationSweep(): void {
10359
10705
  if (!OBLIGATION_LEDGER_ENABLED) return
10360
10706
  if (!obligationLedger.hasOpen()) return
@@ -11108,6 +11454,29 @@ const ipcServer: IpcServer = createIpcServer({
11108
11454
  // Track the session-tail's attached file for the proactive-
11109
11455
  // compaction occupancy read (see maybeProactiveCompact).
11110
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
+ }
11111
11480
  const ev = msg.event as unknown as SessionEvent
11112
11481
  // #1122/#1126: session events used to be ingested into the pinned progress
11113
11482
  // card here (`progressDriver.ingest`). The card is retired and the driver
@@ -12925,17 +13294,21 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
12925
13294
  // so a fresh turn's answer is never clobbered.
12926
13295
  {
12927
13296
  const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
12928
- // Resolve the turnId this reply belongs to by IDENTITY, not just the live
12929
- // `currentTurn`. A late reply lands with `currentTurn == null` (silence poke
12930
- // cleared it — Bug D) or with a transiently-cleared currentTurn while its own
12931
- // turn is really still the owner (LOW-1); in both cases the turn is still
12932
- // resolvable from the `origin_turn_id` nonce the model echoes back (Tier 2 —
12933
- // the same last-known-turn resolver the chat-routing / obligation code uses).
12934
- // Passing this resolved turnId means supersede matches the flushed record by
12935
- // identity instead of falling back to a null-liveTurnId branch that would
12936
- // otherwise be free to delete a DIFFERENT turn's legitimate message.
12937
- const resolvedTurnId =
12938
- turn?.turnId ?? findTurnByOriginId(args.origin_turn_id as string | undefined)?.turnId ?? null
13297
+ // 2026-07 double-reply-on-DM fix (Part 1) resolve the turn this reply
13298
+ // belongs to by IDENTITY, via the SAME full chain the thread-router uses
13299
+ // (`resolveReplyOwnerTurn`): live `currentTurn`, then the model-echoed
13300
+ // `origin_turn_id`, then the framework-owned quoted message id, then the
13301
+ // chat's most-recently-ended turn. The prior chain stopped at
13302
+ // `currentTurn ?? findTurnByOriginId`, so a DM late reply `currentTurn`
13303
+ // nulled by the flush's synthetic turn_end AND no `origin_turn_id` (a
13304
+ // supergroup-only field) resolved to a null owner. `decideSupersede`
13305
+ // deliberately never lets a null live turn supersede a turnId-bearing flush
13306
+ // record, so message A survived AND the reply shipped message B (the exact
13307
+ // double-send). The quoted / latest-ended recoveries are precisely what the
13308
+ // router already did for the same reply, so unifying here makes the two
13309
+ // resolvers agree and the late-reply supersede fires by identity.
13310
+ const ownerTurn = resolveReplyOwnerTurn(turn, chat_id, args)
13311
+ const resolvedTurnId = ownerTurn?.turnId ?? null
12939
13312
  const decision = flushedTurnSupersede.take(
12940
13313
  chat_id,
12941
13314
  replyThreadId,
@@ -12952,6 +13325,46 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
12952
13325
  { chat_id, verb: 'reply.supersedeFlushed' },
12953
13326
  )
12954
13327
  }
13328
+ } else {
13329
+ // 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race latch.
13330
+ // Supersede found no record. Either there was no flush (normal reply), or
13331
+ // the flush FIRED but has not yet recorded its message ids (the residual
13332
+ // pre-record race Part 1's supersede cannot reach). The flush sets
13333
+ // `answerDelivered = true` synchronously at fire time (before its async
13334
+ // send AND before `record`), and it persists on the ended turn — so when
13335
+ // this LATE, substantive reply resolves its owner turn and sees the latch
13336
+ // already set, the flush's message A is already on its way out and this
13337
+ // reply would ship a duplicate. Suppress it. Scoped to the substantive
13338
+ // ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` floor and the late-reply case so an
13339
+ // interim sub-floor ack, a chunked multi-part answer, or a legitimate
13340
+ // second in-turn substantive reply (live `currentTurn`) is never
13341
+ // suppressed. `isSubstantiveFinalReply` reduces to the ≥200-char test on
13342
+ // the `reply` path (no `done`); pass the model's original notification
13343
+ // intent to mirror the #2533 decoupling call shape.
13344
+ const replySubstantive = isSubstantiveFinalReply({
13345
+ text: rawText,
13346
+ disableNotification: args.disable_notification === true,
13347
+ })
13348
+ const suppressByLatch = decideAnswerLatchSuppression({
13349
+ superseded: false,
13350
+ replySubstantive,
13351
+ isLateReply: turn == null,
13352
+ ownerAnswerDelivered: ownerTurn?.answerDelivered ?? false,
13353
+ })
13354
+ if (suppressByLatch) {
13355
+ process.stderr.write(
13356
+ `telegram gateway: reply: suppressed by answer-delivered latch ` +
13357
+ `(flush already delivered this turn's answer) chatId=${chat_id} ` +
13358
+ `ownerTurnId=${JSON.stringify(resolvedTurnId)}\n`,
13359
+ )
13360
+ return { content: [{ type: 'text', text: 'sent (deduped — answer already delivered via turn-flush)' }] }
13361
+ }
13362
+ // A substantive answer is going out via this reply — set the latch on its
13363
+ // owner turn so a later bridge-replayed / reworded duplicate of the same
13364
+ // answer is caught by the branch above.
13365
+ if (replySubstantive && ownerTurn != null) {
13366
+ ownerTurn.answerDelivered = true
13367
+ }
12955
13368
  }
12956
13369
  }
12957
13370
 
@@ -15848,6 +16261,9 @@ function composeTurnActivity(turn: CurrentTurn, final = false, liveSuffix = ''):
15848
16261
  toolCount: turn.labeledToolCount,
15849
16262
  state: final ? 'done' : 'running',
15850
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,
15851
16267
  }
15852
16268
  return renderActivityFeedWithNested(turn.mirrorLines, childLines, final, liveSuffix, stepCount, header)
15853
16269
  }
@@ -16880,6 +17296,11 @@ function handleSessionEvent(ev: SessionEvent): void {
16880
17296
  finalAnswerSubstantive: false,
16881
17297
  // Sticky latch — reset ONLY here (turn start), never by reopen.
16882
17298
  finalAnswerEverDelivered: false,
17299
+ // 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race
17300
+ // latch, reset at turn start alongside the other answer flags.
17301
+ answerDelivered: false,
17302
+ // 2026-07 double-reply-on-DM fix (F2) — stamped at turn end.
17303
+ endedAt: null,
16883
17304
  firstPingAt: null,
16884
17305
  // Notification ownership (R8 / PR-2): no slot claimed yet, so the
16885
17306
  // "claimer was substantive" flag starts false. Set atomically with
@@ -16900,6 +17321,8 @@ function handleSessionEvent(ev: SessionEvent): void {
16900
17321
  lastAssistantDone: false,
16901
17322
  toolCallCount: 0,
16902
17323
  labeledToolCount: 0,
17324
+ totalTokens: 0,
17325
+ seenUsageMessageIds: new Set<string>(),
16903
17326
  activityMessageId: null,
16904
17327
  activityInFlight: null,
16905
17328
  activityPendingRender: null,
@@ -17081,6 +17504,22 @@ function handleSessionEvent(ev: SessionEvent): void {
17081
17504
  sessionModelSource.noteTranscriptModel(ev.model)
17082
17505
  return
17083
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
+ }
17084
17523
  case 'thinking': {
17085
17524
  // #1067: snapshot the turn atom at handler entry. Even though this
17086
17525
  // handler is sync, the principle is uniform across all event arms
@@ -18113,6 +18552,20 @@ function handleSessionEvent(ev: SessionEvent): void {
18113
18552
  // the silent-end re-prompt. (Belt-and-braces, like the set above —
18114
18553
  // this branch returns before any further tool_label can arrive.)
18115
18554
  turn.finalAnswerSubstantive = true
18555
+ // 2026-07 double-reply-on-DM fix (Part 2) — arm the answer-delivered
18556
+ // race latch NOW, synchronously, BEFORE the ~500 ms async send below and
18557
+ // BEFORE `flushedTurnSupersede.record`. A late reply that lands in the
18558
+ // post-fire pre-record window resolves this turn (via the unified owner
18559
+ // resolver, reading the atom preserved in `recentTurnsById`) and
18560
+ // suppresses itself against this latch — closing the residual race Part
18561
+ // 1's supersede cannot reach. Scoped to a SUBSTANTIVE terminal answer
18562
+ // (the same ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` floor the codebase uses to
18563
+ // recognise a real answer) so a short flush never latches against a
18564
+ // legitimate substantive reply. `capturedText` here is the selected,
18565
+ // normalized flush delivery text.
18566
+ if (capturedText.trim().length >= FLUSH_SUBSTANTIVE_MIN_CHARS) {
18567
+ turn.answerDelivered = true
18568
+ }
18116
18569
 
18117
18570
  // #654 deterministic double-message fix. Hand off the pinned
18118
18571
  // progress card BEFORE state reset so the driver doesn't keep
@@ -18352,6 +18805,22 @@ function handleSessionEvent(ev: SessionEvent): void {
18352
18805
  } catch (err) {
18353
18806
  sendThrew = true
18354
18807
  process.stderr.write(`telegram gateway: turn-flush send failed: ${(err as Error).message}\n`)
18808
+ // 2026-07 double-reply-on-DM fix (F1) — the flush armed
18809
+ // `answerDelivered` synchronously at FIRE time, but the send just
18810
+ // FAILED. When nothing was delivered the supersede record was NOT
18811
+ // written (gated on `sentIds.length > 0` above), so Part 1 can never
18812
+ // fire for this turn — leaving the latch armed would make a genuine
18813
+ // late reply suppress itself and the user would get ZERO messages
18814
+ // (silent answer loss; on main that path still delivers the reply).
18815
+ // Reset the latch so the late reply is NOT suppressed. On a PARTIAL
18816
+ // send (sentIds > 0) the record WAS written, so Part 1's supersede
18817
+ // deletes the partial message A and the reply delivers cleanly —
18818
+ // resetting here is harmless in that case too.
18819
+ // FOLLOW-UP (coordinator to file an issue): a reply suppressed
18820
+ // synchronously DURING the in-flight send that then fails is not
18821
+ // fully closable with a boolean latch — a residual micro-window the
18822
+ // supersede+latch pair cannot eliminate. Not addressed in this PR.
18823
+ turn.answerDelivered = false
18355
18824
  // #1713: backstop send failed — finalize as error so the
18356
18825
  // turn ends cleanly with 😱 rather than leaving it open.
18357
18826
  if (backstopCtrl) backstopCtrl.finalize('error')
@@ -29659,6 +30128,19 @@ void (async () => {
29659
30128
  )
29660
30129
  }
29661
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
+
29662
30144
  // Boot-time pin sweep
29663
30145
  try {
29664
30146
  const bootAccess = loadAccess()
@@ -30102,7 +30584,16 @@ void (async () => {
30102
30584
  // or the turn ended while it kept running — extended autonomous
30103
30585
  // work) is surfaced via the worker feed instead of vanishing.
30104
30586
  const orphanStatusEnabled = isOrphanSubagentStatusEnabled(process.env.SWITCHROOM_ORPHAN_SUBAGENT_STATUS)
30105
- workerActivityFeed?.stop()
30587
+ // Boot/reconnect purge (Ken, PR #3239 review): every row in the
30588
+ // OUTGOING feed is a dead child sub-agent, and its `wk:group:` pins
30589
+ // would otherwise be orphaned — the replacement feed is empty and
30590
+ // never knew those groups, so it can never unpin them, and no full-
30591
+ // boot pin sweep runs on a bare bridge reconnect. Reconcile the old
30592
+ // feed to empty and release all its group pins BEFORE stopping it.
30593
+ if (workerActivityFeed != null) {
30594
+ workerActivityFeed.purgeAllOnBoot()
30595
+ workerActivityFeed.stop()
30596
+ }
30106
30597
  workerActivityFeed = createWorkerActivityFeed({
30107
30598
  // #2669: worker-feed body is raw GFM markdown — send via rich.
30108
30599
  bot: {
@@ -30167,6 +30658,18 @@ void (async () => {
30167
30658
  // force-collapsing a row the terminal signals somehow never
30168
30659
  // removed.
30169
30660
  staleWorkerTtlMs: resolveInflightTerminalCapMs() + WORKER_FEED_STALE_TTL_MARGIN_MS,
30661
+ // ABSOLUTE row-lifetime cap — anchored to a row's creation, immune
30662
+ // to the `lastUpdateAt` reset that lets an immortal-but-updating
30663
+ // row dodge `staleWorkerTtlMs` forever (Carrie 5h zombie pin).
30664
+ // Derived from the same terminal cap so it tracks operator
30665
+ // overrides; 4× → ~3h at the 45-min default.
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,
30170
30673
  // #3207 review: GROUP-level status pin. Workers now coalesce into
30171
30674
  // ONE shared message, so the pin must follow the GROUP lifecycle,
30172
30675
  // not a single worker's — otherwise a sibling's finish unpins a
@@ -30349,7 +30852,7 @@ void (async () => {
30349
30852
  )
30350
30853
  }
30351
30854
  },
30352
- onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs, background: entryBackground }) => {
30855
+ onFinish: ({ agentId, outcome, description, resultText, toolCount, totalTokens, durationMs, background: entryBackground }) => {
30353
30856
  // Reaction promotion: if the parent turn already ended
30354
30857
  // with this (or another) worker still running, its 👍 was
30355
30858
  // deferred (held on ✍️/⚡). Now that a worker finished,
@@ -30419,6 +30922,7 @@ void (async () => {
30419
30922
  description: dispatch.feedDescription,
30420
30923
  lastTool: null,
30421
30924
  toolCount,
30925
+ totalTokens,
30422
30926
  latestSummary: resultText,
30423
30927
  elapsedMs: durationMs,
30424
30928
  state: outcome === 'failed' ? 'failed' : 'done',
@@ -30500,6 +31004,7 @@ void (async () => {
30500
31004
  description: dispatch.feedDescription,
30501
31005
  lastTool: null,
30502
31006
  toolCount,
31007
+ totalTokens,
30503
31008
  latestSummary: resultText,
30504
31009
  elapsedMs: durationMs,
30505
31010
  state: outcome === 'failed' ? 'failed' : 'done',
@@ -30521,6 +31026,7 @@ void (async () => {
30521
31026
  description: dispatch.feedDescription,
30522
31027
  lastTool: null,
30523
31028
  toolCount,
31029
+ totalTokens,
30524
31030
  latestSummary: resultText,
30525
31031
  elapsedMs: durationMs,
30526
31032
  state: outcome === 'failed' ? 'failed' : 'done',
@@ -30611,7 +31117,7 @@ void (async () => {
30611
31117
  // suppresses stale-after-restart delivery (a 4-h-old
30612
31118
  // "still working (5m)" would be a lie). Sweep on handback
30613
31119
  // lives in the `onFinish` block just above.
30614
- 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 }) => {
30615
31121
  let fleetChatId = ''
30616
31122
  try {
30617
31123
  const fleets = progressDriver?.peekAllFleets() ?? []
@@ -30692,6 +31198,7 @@ void (async () => {
30692
31198
  elapsedMs,
30693
31199
  state: 'running',
30694
31200
  model: feedModel,
31201
+ totalTokens,
30695
31202
  },
30696
31203
  wk.threadId,
30697
31204
  )
@@ -30846,6 +31353,7 @@ void (async () => {
30846
31353
  elapsedMs,
30847
31354
  state: 'running',
30848
31355
  model: feedModel,
31356
+ totalTokens,
30849
31357
  },
30850
31358
  wk.threadId,
30851
31359
  )