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.
- package/dist/cli/switchroom.js +59 -11
- package/dist/host-control/main.js +1 -1
- package/package.json +2 -2
- package/telegram-plugin/dist/bridge/bridge.js +26 -0
- package/telegram-plugin/dist/gateway/gateway.js +1827 -831
- package/telegram-plugin/dist/server.js +26 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
- package/telegram-plugin/gateway/gateway.ts +314 -3
- package/telegram-plugin/gateway/model-command.ts +188 -56
- package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
- package/telegram-plugin/history.ts +118 -0
- package/telegram-plugin/registry/turns-schema.ts +89 -1
- package/telegram-plugin/render/code-segments.ts +210 -0
- package/telegram-plugin/render/dollar-math-guard.ts +126 -0
- package/telegram-plugin/render/emphasis-guard.ts +158 -0
- package/telegram-plugin/render/inline-pairs-guard.ts +171 -0
- package/telegram-plugin/render/line-start-guard.ts +167 -0
- package/telegram-plugin/render/rich-render.ts +7 -0
- package/telegram-plugin/rich-send.ts +48 -2
- package/telegram-plugin/session-tail.ts +185 -0
- package/telegram-plugin/subagent-watcher.ts +45 -0
- package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
- package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
- package/telegram-plugin/tests/history.test.ts +91 -0
- package/telegram-plugin/tests/model-command.test.ts +189 -12
- package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
- package/telegram-plugin/tests/registry-turns.test.ts +51 -0
- package/telegram-plugin/tests/render/dollar-math-guard.test.ts +162 -0
- package/telegram-plugin/tests/render/emphasis-guard.test.ts +205 -0
- package/telegram-plugin/tests/render/guard-composition.test.ts +138 -0
- package/telegram-plugin/tests/render/inline-pairs-guard.test.ts +171 -0
- package/telegram-plugin/tests/render/line-start-guard.test.ts +164 -0
- package/telegram-plugin/tests/session-model-source.test.ts +11 -0
- package/telegram-plugin/tests/session-tail.test.ts +145 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
- package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
- package/telegram-plugin/tool-activity-summary.ts +54 -3
- package/telegram-plugin/worker-activity-feed.ts +104 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
- package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
- package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
- package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
- package/vendor/hindsight-memory/scripts/retain.py +299 -143
- package/vendor/hindsight-memory/scripts/session_start.py +14 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
- 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 {
|
|
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
|
)
|