switchroom 0.19.40 → 0.19.42

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 (37) hide show
  1. package/dist/agent-scheduler/index.js +22 -2
  2. package/dist/auth-broker/index.js +22 -2
  3. package/dist/cli/notion-write-pretool.mjs +22 -2
  4. package/dist/cli/switchroom.js +810 -241
  5. package/dist/host-control/main.js +94 -19
  6. package/dist/vault/approvals/kernel-server.js +22 -2
  7. package/dist/vault/broker/server.js +22 -2
  8. package/package.json +1 -1
  9. package/telegram-plugin/bridge/bridge.ts +2 -2
  10. package/telegram-plugin/dist/bridge/bridge.js +2 -2
  11. package/telegram-plugin/dist/gateway/gateway.js +332 -124
  12. package/telegram-plugin/dist/server.js +2 -2
  13. package/telegram-plugin/gateway/approval-callback-consume-record.test.ts +132 -0
  14. package/telegram-plugin/gateway/checklist-fallback.ts +370 -0
  15. package/telegram-plugin/gateway/gateway.ts +76 -76
  16. package/telegram-plugin/gateway/liveness-wiring.ts +12 -0
  17. package/telegram-plugin/gateway/model-command.ts +21 -109
  18. package/telegram-plugin/gateway/stream-render.ts +57 -0
  19. package/telegram-plugin/gateway/turn-record-status.ts +80 -0
  20. package/telegram-plugin/tests/checklist-fallback.test.ts +317 -0
  21. package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +134 -0
  22. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -6
  23. package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +3 -0
  24. package/telegram-plugin/tests/turn-record-status.test.ts +62 -0
  25. package/vendor/hindsight-memory/CHANGELOG.md +28 -0
  26. package/vendor/hindsight-memory/docs/measurements/subagent-volume-gate-3994.md +84 -0
  27. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +254 -40
  28. package/vendor/hindsight-memory/scripts/lib/content.py +11 -2
  29. package/vendor/hindsight-memory/scripts/recall.py +18 -3
  30. package/vendor/hindsight-memory/scripts/subagent_retain.py +59 -38
  31. package/vendor/hindsight-memory/scripts/tests/data/replay_volume_gate_3994.py +295 -0
  32. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +109 -0
  33. package/vendor/hindsight-memory/scripts/tests/test_overlap_tokens.py +135 -0
  34. package/vendor/hindsight-memory/scripts/tests/test_recall_no_lexical_gate.py +20 -23
  35. package/vendor/hindsight-memory/scripts/tests/test_recall_query_shaping.py +48 -0
  36. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +94 -1
  37. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain_learnings.py +98 -42
@@ -35,6 +35,7 @@ import {
35
35
  type AskUserOutcome,
36
36
  } from '../ask-user.js'
37
37
  import { redactAskUserFields, redactChecklistFields } from '../outbound-field-redact.js'
38
+ import { createChecklistStore, checklistStoreKey, performSendChecklist, performUpdateChecklist, sendChecklistToolText, updateChecklistToolText } from './checklist-fallback.js'
38
39
  import { parseInterruptMarker } from '../interrupt-marker.js'
39
40
  import {
40
41
  ToolFlightTracker,
@@ -456,7 +457,7 @@ import {
456
457
  type SendReplyGatewayDeps,
457
458
  type DeliverCapturedProseDeps,
458
459
  } from './outbound-send-path.js'
459
- import { handleSessionEvent as handleSessionEventCore } from './stream-render.js'
460
+ import { handleSessionEvent as handleSessionEventCore, drainParkedTurnStartsForChat } from './stream-render.js'
460
461
  import { createNarrativeLane } from './narrative-lane.js'
461
462
  import {
462
463
  parseAgentCallback,
@@ -1383,65 +1384,29 @@ let _rawEditMessageChecklist: unknown
1383
1384
  /** True when the connected Telegram Bot API supports native checklists. Set in initGatewayBot() (#2996 P0b). */
1384
1385
  let CHECKLIST_API_AVAILABLE = false
1385
1386
 
1386
- /**
1387
- * Send a native Telegram checklist message.
1388
- * Wraps bot.api.raw.sendChecklist with string→number coercion (chat_id) and
1389
- * a 30-task cap enforced before the API call.
1390
- */
1391
- async function rawSendChecklist(args: {
1392
- chat_id: string
1393
- title: string
1394
- tasks: Array<{ text: string; done?: boolean }>
1395
- message_thread_id?: number
1396
- reply_to_message_id?: number
1397
- protect_content?: boolean
1398
- }): Promise<{ message_id: number }> {
1399
- if (!CHECKLIST_API_AVAILABLE) {
1400
- throw new Error('sendChecklist is not available in this grammY/Telegram Bot API version')
1401
- }
1402
- const MAX_TASKS = 30
1403
- if (args.tasks.length > MAX_TASKS) {
1404
- throw new Error(`checklist exceeds ${MAX_TASKS}-task limit (got ${args.tasks.length})`)
1405
- }
1406
- const result = await (_rawSendChecklist as (p: Record<string, unknown>) => Promise<{ message_id: number }>)({
1407
- chat_id: Number(args.chat_id),
1408
- title: args.title,
1409
- tasks: args.tasks.map(t => ({ text: t.text, ...(t.done != null ? { is_completed: t.done } : {}) })),
1410
- ...(args.message_thread_id != null ? { message_thread_id: args.message_thread_id } : {}),
1411
- ...(args.reply_to_message_id != null ? { reply_to_message_id: args.reply_to_message_id } : {}),
1412
- ...(args.protect_content === true ? { protect_content: true } : {}),
1413
- })
1387
+ /** bot.api.raw.sendChecklist with a payload pre-built by checklist-fallback.ts
1388
+ * (nested `checklist` object, per-task integer ids, business_connection_id —
1389
+ * the old flat shape got `400: parameter "checklist" is required`). */
1390
+ async function rawSendChecklist(payload: Record<string, unknown>): Promise<{ message_id: number }> {
1391
+ if (!CHECKLIST_API_AVAILABLE) throw new Error('sendChecklist is not available in this grammY/Telegram Bot API version')
1392
+ const result = await (_rawSendChecklist as (p: Record<string, unknown>) => Promise<{ message_id: number }>)(payload)
1414
1393
  return { message_id: result.message_id }
1415
1394
  }
1416
1395
 
1417
- /**
1418
- * Edit (patch) an existing Telegram checklist message.
1419
- * Supports updating title, adding/removing tasks, and marking tasks done/undone.
1420
- * Task objects with an `id` field target existing tasks; those without are added.
1421
- */
1422
- async function rawEditMessageChecklist(args: {
1423
- chat_id: string
1424
- message_id: string
1425
- title?: string
1426
- tasks?: Array<{ id?: string; text?: string; done?: boolean }>
1427
- }): Promise<void> {
1428
- if (!CHECKLIST_API_AVAILABLE) {
1429
- throw new Error('editMessageChecklist is not available in this grammY/Telegram Bot API version')
1430
- }
1431
- await (_rawEditMessageChecklist as (p: Record<string, unknown>) => Promise<unknown>)({
1432
- chat_id: Number(args.chat_id),
1433
- message_id: Number(args.message_id),
1434
- ...(args.title != null ? { title: args.title } : {}),
1435
- ...(args.tasks != null
1436
- ? {
1437
- tasks: args.tasks.map(t => ({
1438
- ...(t.id != null ? { id: Number(t.id) } : {}),
1439
- ...(t.text != null ? { text: t.text } : {}),
1440
- ...(t.done != null ? { is_completed: t.done } : {}),
1441
- })),
1442
- }
1443
- : {}),
1444
- })
1396
+ /** bot.api.raw.editMessageChecklist with a pre-built payload (the native edit REPLACES the whole checklist, so checklist-fallback.ts sends full state). */
1397
+ async function rawEditMessageChecklist(payload: Record<string, unknown>): Promise<void> {
1398
+ if (!CHECKLIST_API_AVAILABLE) throw new Error('editMessageChecklist is not available in this grammY/Telegram Bot API version')
1399
+ await (_rawEditMessageChecklist as (p: Record<string, unknown>) => Promise<unknown>)(payload)
1400
+ }
1401
+
1402
+ /** Per-message checklist state — update_checklist re-renders from it (process-local; post-restart updates degrade gracefully). */
1403
+ const checklistStore = createChecklistStore()
1404
+
1405
+ /** Native checklists are Telegram-Business-only (sendChecklist REQUIRES a business_connection_id).
1406
+ * No fleet config plumbing exists yet; this env var is the opt-in — unset (the normal case) means text fallback. */
1407
+ function resolveBusinessConnectionId(): string | undefined {
1408
+ const v = process.env.SWITCHROOM_TELEGRAM_BUSINESS_CONNECTION_ID?.trim()
1409
+ return v ? v : undefined
1445
1410
  }
1446
1411
 
1447
1412
  const chatLock = createChatLock()
@@ -5038,7 +5003,7 @@ function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
5038
5003
  startedAt: turn.startedAt,
5039
5004
  toolCallCount: turn.toolCallCount ?? 0,
5040
5005
  turnId: turn.turnId,
5041
- finalAnswerDelivered: turn.finalAnswerDelivered,
5006
+ finalAnswerDelivered: turn.finalAnswerDelivered, replyCalled: turn.replyCalled, // replyCalled: honest delivery-route signal (turn-record-status.ts computeTurnRoute)
5042
5007
  deliveryOutcome: turn.deliveryOutcome, landedUnconfirmed: turn.landedUnconfirmed,
5043
5008
  },
5044
5009
  endedAt,
@@ -9704,14 +9669,14 @@ function gatewayLivenessWiringDeps() {
9704
9669
  turnLiveForItsTopic,
9705
9670
  endCurrentTurnForKey,
9706
9671
  // Getter, not an eager read: `pendingInboundBuffer` (const) is declared
9707
- // LATER in this module (~9863) than the module-load `startTimer` call that
9708
- // invokes this builder (isGatewayMain path). A by-value capture here is a
9709
- // temporal-dead-zone ReferenceError that crash-loops every gateway boot
9710
- // (#3392 regression). Deferring to a getter matching getInboundSpool /
9711
- // getTurnsDb / ipcServer — means the binding is only read when the
9712
- // fallback actually fires, well after module eval.
9672
+ // LATER than the module-load `startTimer` call that invokes this builder,
9673
+ // so a by-value capture is a temporal-dead-zone ReferenceError that
9674
+ // crash-loops boot (#3392). A getter (matching getInboundSpool / getTurnsDb
9675
+ // / ipcServer) is read only when the fallback fires, well after module eval.
9713
9676
  getPendingInboundBuffer: () => pendingInboundBuffer,
9714
9677
  trackRedeliveredInbound,
9678
+ // Two-state desync unwedge (#3927): drain the parked store the fallback else leaves latched — see liveness-wiring + stream-render drainParkedTurnStartsForChat.
9679
+ drainParkedTurnStarts: (chatId: string, threadId: number | null): number => drainParkedTurnStartsForChat(gatewayStreamRenderDeps(), chatId, threadId != null ? String(threadId) : null).length,
9715
9680
  closeActivityLane,
9716
9681
  closeProgressLane,
9717
9682
  // Stage B: escalate a mid-tool + marker-stale fallback to a real restart.
@@ -12083,17 +12048,35 @@ async function executeSendChecklist(args: Record<string, unknown>): Promise<{ co
12083
12048
  (t) => redactOutboundText(t, 'send_checklist'),
12084
12049
  )
12085
12050
 
12086
- const sent = await rawSendChecklist({
12087
- chat_id,
12088
- title: redactedTitle!,
12089
- tasks: redactedTasks!,
12051
+ // Graceful degradation: native sendChecklist is Business-account-only, so
12052
+ // ordinary chats (the fleet norm) get a formatted text render instead of a
12053
+ // raw Telegram 400 (rationale + orchestration in checklist-fallback.ts).
12054
+ const literal = (loadAccess().parseMode ?? 'html') === 'text'
12055
+ const sendOpts = {
12090
12056
  ...(threadId != null ? { message_thread_id: threadId } : {}),
12091
- ...(replyTo != null ? { reply_to_message_id: replyTo } : {}),
12057
+ ...(replyTo != null ? { reply_parameters: { message_id: replyTo } } : {}),
12092
12058
  ...(protectContent ? { protect_content: true } : {}),
12093
- })
12059
+ }
12060
+ const result = await performSendChecklist({
12061
+ businessConnectionId: resolveBusinessConnectionId(),
12062
+ nativeAvailable: CHECKLIST_API_AVAILABLE,
12063
+ sendNative: rawSendChecklist,
12064
+ sendText: (text) => robustApiCall(
12065
+ (): Promise<{ message_id: number }> =>
12066
+ literal
12067
+ // allow-raw-bot-api: literal checklist text-fallback send routed through robustApiCall
12068
+ ? lockedBot.api.sendMessage(chat_id, text, sendOpts as never)
12069
+ // allow-raw-bot-api: checklist text-fallback send routed through robustApiCall
12070
+ : lockedBot.api.sendRichMessage(chat_id, richMessage(text), sendOpts as never),
12071
+ { verb: 'sendMessage', chat_id, threadId },
12072
+ ),
12073
+ literalText: literal,
12074
+ log: (l) => process.stderr.write(`telegram gateway: ${l}`),
12075
+ }, { title: redactedTitle!, tasks: redactedTasks!, chatId: Number(chat_id), replyToMessageId: replyTo, protectContent })
12094
12076
 
12095
- process.stderr.write(`telegram gateway: send_checklist: sent chatId=${chat_id} messageId=${sent.message_id} tasks=${tasks.length}\n`)
12096
- return { content: [{ type: 'text', text: `checklist sent (id: ${sent.message_id})` }] }
12077
+ checklistStore.set(checklistStoreKey(chat_id, result.message_id), result.state)
12078
+ process.stderr.write(`telegram gateway: send_checklist: sent chatId=${chat_id} messageId=${result.message_id} tasks=${tasks.length} mode=${result.mode}\n`)
12079
+ return { content: [{ type: 'text', text: sendChecklistToolText(result) }] }
12097
12080
  }
12098
12081
 
12099
12082
  /**
@@ -12180,10 +12163,27 @@ async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{
12180
12163
  (t) => redactOutboundText(t, 'update_checklist'),
12181
12164
  )
12182
12165
 
12183
- await rawEditMessageChecklist({ chat_id, message_id, title: redactedTitle, tasks: redactedTasks })
12184
-
12185
- process.stderr.write(`telegram gateway: update_checklist: updated chatId=${chat_id} messageId=${message_id}\n`)
12186
- return { content: [{ type: 'text', text: `checklist updated (id: ${message_id})` }] }
12166
+ // Graceful degradation (checklist-fallback.ts): the patch is applied to the
12167
+ // stored per-message state and the message re-rendered in the mode it was
12168
+ // sent in. Failures come back structured, never as a raw Telegram 400.
12169
+ const literal = (loadAccess().parseMode ?? 'html') === 'text'
12170
+ const key = checklistStoreKey(chat_id, message_id)
12171
+ const result = await performUpdateChecklist({
12172
+ state: checklistStore.get(key),
12173
+ businessConnectionId: resolveBusinessConnectionId(),
12174
+ nativeAvailable: CHECKLIST_API_AVAILABLE,
12175
+ editNative: rawEditMessageChecklist,
12176
+ // allow-raw-bot-api: checklist text-fallback edit routed through robustApiCall
12177
+ editText: async (text) => { await robustApiCall(() => lockedBot.api.editMessageText(chat_id, Number(message_id), literal ? text : richMessage(text))) },
12178
+ literalText: literal,
12179
+ log: (l) => process.stderr.write(`telegram gateway: ${l}`),
12180
+ chatId: Number(chat_id),
12181
+ messageId: Number(message_id),
12182
+ }, { title: redactedTitle, tasks: redactedTasks })
12183
+
12184
+ if (result.ok) checklistStore.set(key, result.state)
12185
+ process.stderr.write(`telegram gateway: update_checklist: ${result.ok ? `updated mode=${result.mode}` : `failed reason=${result.reason}`} chatId=${chat_id} messageId=${message_id}\n`)
12186
+ return { content: [{ type: 'text', text: updateChecklistToolText(result, Number(message_id)) }] }
12187
12187
  }
12188
12188
 
12189
12189
  /**
@@ -62,6 +62,7 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
62
62
  endCurrentTurnForKey,
63
63
  getPendingInboundBuffer,
64
64
  trackRedeliveredInbound,
65
+ drainParkedTurnStarts,
65
66
  closeActivityLane,
66
67
  closeProgressLane,
67
68
  hangRestart,
@@ -593,6 +594,16 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
593
594
  getInboundSpool(),
594
595
  trackRedeliveredInbound,
595
596
  )
597
+ // Two-state desync unwedge. `pendingInboundBuffer` (above) is NOT where a
598
+ // wedge-behind-a-dead-lock message sits: an already-`enqueue`d message lives
599
+ // in stream-render's `parkedTurnStarts`, and the busy park gate stays latched
600
+ // while that store is non-empty. A hung REPL never emits the `dequeue`/
601
+ // `remove` that drain it, so drain it HERE, scoped to the wedged chat/thread,
602
+ // routing each envelope back through the real `dequeue` turn-start path. The
603
+ // count joins the `drained_buffered` field below so the log stops reading a
604
+ // misleading `0/0` while real user messages were stranded. Guarded getter:
605
+ // absent (older deps) → 0, so the fallback degrades to its prior behaviour.
606
+ const fbDrainedParked = drainParkedTurnStarts?.(fbChatId, fbThreadId ?? null) ?? 0
596
607
  process.stderr.write(
597
608
  `telegram gateway: silence-poke framework-fallback ended wedged turn ` +
598
609
  `chat=${fbChatId} thread=${ctx.threadId ?? '-'} silence_ms=${ctx.silenceMs} ` +
@@ -601,6 +612,7 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
601
612
  // keyed liveness check skipped the teardown, so the field lied.
602
613
  `currentTurn_nulled=${tearsDownLiveTurn} ` +
603
614
  `drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}` +
615
+ `${fbDrainedParked > 0 ? ` drained_parked=${fbDrainedParked}` : ''}` +
604
616
  `${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ''}` +
605
617
  `${fbExtraPurge.purged.length > 0 ? ` extra_keys_purged=${fbExtraPurge.purged.length}` : ''}\n`,
606
618
  )
@@ -34,6 +34,27 @@ import {
34
34
  type ModelPickerOption,
35
35
  } from '../../src/agents/model-picker.js'
36
36
  import { assessThinkingEffortRisk } from '../../src/config/thinking-effort-risk.js'
37
+ // The model-alias tables + expansion helpers live in the shared
38
+ // `src/agents/model-aliases.ts` module so the gateway `/model` path and
39
+ // scaffold-time config-default resolution expand from ONE table (#3998).
40
+ // Imported for this module's internal use and re-exported below to preserve the
41
+ // existing `model-command.js` public API (every importer keeps its entrypoint).
42
+ import {
43
+ CLAUDE_MODEL_ALIASES,
44
+ SR_MODEL_ALIASES,
45
+ expandClaudeAlias,
46
+ expandSrAlias,
47
+ canonicalModelToken,
48
+ expandModelAlias,
49
+ } from '../../src/agents/model-aliases.js'
50
+ export {
51
+ CLAUDE_MODEL_ALIASES,
52
+ SR_MODEL_ALIASES,
53
+ expandClaudeAlias,
54
+ expandSrAlias,
55
+ canonicalModelToken,
56
+ expandModelAlias,
57
+ }
37
58
 
38
59
  /**
39
60
  * Aliases the claude CLI resolves natively (`claude --help`: "an alias for
@@ -51,34 +72,6 @@ import { assessThinkingEffortRisk } from '../../src/config/thinking-effort-risk.
51
72
  */
52
73
  export const MODEL_ALIASES = ['opus', 'sonnet', 'haiku', 'fable', 'default'] as const
53
74
 
54
- /**
55
- * Short SWITCHROOM-side spellings for pinned **Anthropic** model ids. These are
56
- * expanded to the full `claude-*` id on the `/model` path BEFORE any
57
- * Claude-vs-external classification runs, so what reaches the `.session-model`
58
- * carrier (and therefore `claude --model`) is always the canonical id — the CLI
59
- * never sees the short spelling.
60
- *
61
- * Deliberately NOT `SR_MODEL_ALIASES`: that map means "external / OpenRouter /
62
- * the Anthropic OAuth header is NOT forwarded". These targets are Anthropic
63
- * OAuth-passthrough models, so putting them there would flip the
64
- * header-forwarding + external-billing classification and surface them under the
65
- * "🌐 External models" keyboard page with an `srFriendlyLabel`.
66
- *
67
- * Also NOT `MODEL_ALIASES`: that list is the aliases the claude CLI resolves
68
- * *itself*, and its members double as FAMILY tokens in `modelFamilyToken` /
69
- * `canonicalClaudeToken` / `servedModelMatchesRequested`. A pinned-id shortcut
70
- * is neither.
71
- *
72
- * NB the repo prefers family aliases over pinned ids (an alias tracks the
73
- * current flagship, a pinned id goes stale) — these exist because an operator
74
- * sometimes wants a specific pinned Opus, and typing `claude-opus-4-8` on a
75
- * phone is hostile. Keys are matched case-insensitively.
76
- */
77
- export const CLAUDE_MODEL_ALIASES: Record<string, string> = {
78
- opus48: 'claude-opus-4-8',
79
- 'opus-4-8': 'claude-opus-4-8',
80
- }
81
-
82
75
  /**
83
76
  * Shape gate for the model argument. This string is typed literally
84
77
  * into the agent's tmux pane, so the gate is strict by construction:
@@ -1166,35 +1159,6 @@ export const SR_MODEL_LABELS: Record<string, string> = {
1166
1159
  'sr-gpt-5.6-luna': 'GPT-5.6 Luna',
1167
1160
  }
1168
1161
 
1169
- /**
1170
- * Short text-command aliases for sr-* models. These let the operator type
1171
- * `/model flash`, `/model codex`, etc. instead of the full `sr-*` id.
1172
- * Expanded in handleModelCommand before injection; the full sr-* id is what
1173
- * reaches the agent session and LiteLLM.
1174
- */
1175
- export const SR_MODEL_ALIASES: Record<string, string> = {
1176
- flash: 'sr-gemini-2.5-flash',
1177
- gemini: 'sr-gemini-2.5-pro',
1178
- deepseek: 'sr-deepseek-v3',
1179
- r1: 'sr-deepseek-r1',
1180
- glm: 'sr-glm-5',
1181
- codex: 'sr-codex-5.5',
1182
- // Flagship keyboard buttons for the three providers added 2026-07-19. Each
1183
- // points at that provider's top tier; the cheaper tiers (grok-4.3,
1184
- // kimi-k2.7-code, gpt-5.6-terra/luna) stay typeable via `/model sr-<id>` and
1185
- // keep a friendly label above, matching the "labels are a superset of buttons"
1186
- // design (SR_MODEL_LABELS doc comment).
1187
- grok: 'sr-grok-4.5',
1188
- kimi: 'sr-kimi-k3',
1189
- gpt: 'sr-gpt-5.6-sol',
1190
- // Per-tier buttons for the GPT-5.6 line so each is a one-word shortcut
1191
- // (`gpt` stays the flagship-Sol default). Added 2026-07-19.
1192
- sol: 'sr-gpt-5.6-sol',
1193
- terra: 'sr-gpt-5.6-terra',
1194
- luna: 'sr-gpt-5.6-luna',
1195
- }
1196
-
1197
- /** Expand a short alias (case-insensitive) to its full sr-* id, or return the original. */
1198
1162
  /**
1199
1163
  * #3042 review blocker 2a: can `token` be trusted for a boot-applied
1200
1164
  * `.session-model` carrier persist WITHOUT a live confirmation from claude?
@@ -1230,58 +1194,6 @@ export function isOfflineTrustedModelToken(token: string): boolean {
1230
1194
  return Object.values(CLAUDE_MODEL_ALIASES).includes(lower)
1231
1195
  }
1232
1196
 
1233
- export function expandSrAlias(arg: string): string {
1234
- return SR_MODEL_ALIASES[arg.toLowerCase()] ?? arg
1235
- }
1236
-
1237
- /**
1238
- * The ONE canonical form of a model token — what `claude --model` is handed and
1239
- * what every downstream gate compares against.
1240
- *
1241
- * Two normalizations, both narrow:
1242
- * - **trim** — unconditional. `unvalidatedIdCaveat` already trimmed while
1243
- * expansion did not, so the two disagreed on a padded token.
1244
- * - **lowercase, `claude-*` ONLY.** Every real Anthropic model id is lowercase
1245
- * and a phone autocapitalizes, so `/model Claude-Opus-4-8` used to be launched
1246
- * VERBATIM as an unvalidated id while the caveat exemption (which lowercases)
1247
- * suppressed the warning — a clean green ack for a token the caveat was
1248
- * written for. Non-`claude-` tokens are left alone: `sr-*` ids are matched
1249
- * case-insensitively by their own maps, and rewriting an arbitrary external
1250
- * id's case is not ours to do.
1251
- *
1252
- * Applied by `expandModelAlias`, so canonicalization happens on every `/model`
1253
- * path (typed apply, mid-turn queue, menu tap, queued-across-restart persist)
1254
- * whether or not the token hit a shortcut map — one canonical form flows to
1255
- * `claude --model`, and `unvalidatedIdCaveat` sees exactly that form.
1256
- */
1257
- export function canonicalModelToken(arg: string): string {
1258
- const trimmed = arg.trim()
1259
- const lower = trimmed.toLowerCase()
1260
- return lower.startsWith('claude-') ? lower : trimmed
1261
- }
1262
-
1263
- /** Expand a short pinned-Claude spelling (case-insensitive) to its full `claude-*` id. */
1264
- export function expandClaudeAlias(arg: string): string {
1265
- return CLAUDE_MODEL_ALIASES[arg.trim().toLowerCase()] ?? arg
1266
- }
1267
-
1268
- /**
1269
- * The ONE expansion every `/model` argument goes through, on every path that
1270
- * consumes a user-supplied token (typed apply, mid-turn queue, menu tap,
1271
- * queued-across-restart persist). Claude shortcuts resolve first, then sr-*
1272
- * shortcuts; the two key sets are disjoint by construction (a Claude shortcut
1273
- * never expands to an `sr-*` id and vice versa), so order only documents intent.
1274
- * The result is `canonicalModelToken`-normalized, so a miss can no longer emit a
1275
- * verbatim mixed-case `claude-*` id that the caveat exemption then mis-matches.
1276
- *
1277
- * Expanding HERE — before `isClaudeModel` / `isSrModel` / `externalModelNames`
1278
- * ever see the token — is what keeps a pinned-Claude shortcut on the Anthropic
1279
- * OAuth passthrough instead of being misread as an external route.
1280
- */
1281
- export function expandModelAlias(arg: string): string {
1282
- return canonicalModelToken(expandSrAlias(expandClaudeAlias(arg)))
1283
- }
1284
-
1285
1197
  export function srFriendlyLabel(srName: string): string {
1286
1198
  return SR_MODEL_LABELS[srName] ?? srName.replace(/^sr-/, '').replace(/-/g, ' ')
1287
1199
  }
@@ -232,6 +232,63 @@ export function __parkedTurnStartCountForTest(): number {
232
232
  return parkedTurnStarts.length
233
233
  }
234
234
 
235
+ /**
236
+ * Silence-fallback unwedge for the parked store (the two-state desync fix).
237
+ *
238
+ * The park gate in `case 'enqueue'` treats the session as busy while EITHER a
239
+ * live `currentTurn` exists OR `parkedTurnStarts` is non-empty. The 300 s
240
+ * framework-fallback (`liveness-wiring.ts` onFrameworkFallback) that recovers a
241
+ * hung turn nulls `currentTurn` and redelivers `pendingInboundBuffer` — but
242
+ * before this it NEVER touched `parkedTurnStarts`. When the claude REPL hangs
243
+ * mid-turn the CLI's own `dequeue` / `remove` events (the only real drains of
244
+ * this store) never arrive, so a leftover parked envelope latches the busy gate
245
+ * permanently and every later inbound parks unseen until a manual container
246
+ * restart (gymbro parked msgs 4502→4504 behind a dead lock until SIGTERM). The
247
+ * `drained_buffered=0/0` fallback log was honest-but-misleading: it counted the
248
+ * empty `pendingInboundBuffer`, not this store, where the real messages sat.
249
+ *
250
+ * This drains the parked envelopes for ONE chat/thread — scoped, never global,
251
+ * so a fallback fired for chat A cannot release chat B's queued messages — and
252
+ * hands each back into turn processing through the EXACT `beginTurn` path a real
253
+ * `dequeue` uses. Envelopes are re-begun in arrival order (oldest first) so the
254
+ * NEWEST, the message the user is actually waiting on, ends up the live turn and
255
+ * the older ones are cleanly superseded (never silently dropped). The store is
256
+ * kept module-encapsulated: callers get a function, not the array. Returns the
257
+ * drained envelopes so the caller can log an honest count. Idempotent w.r.t. a
258
+ * late CLI event: once drained, a subsequent `dequeue` finds an empty store and
259
+ * `takeParkedTurnStart` returns null, so a message can never be double-started.
260
+ */
261
+ export function drainParkedTurnStartsForChat(
262
+ deps: StreamRenderDeps,
263
+ chatId: string | null,
264
+ threadId: string | null,
265
+ ): TurnStartEnvelope[] {
266
+ const wantThread = envThreadIdNum(threadId)
267
+ const drained: ParkedTurnStart[] = []
268
+ // Splice out the matching entries, preserving arrival order (oldest first).
269
+ for (let i = 0; i < parkedTurnStarts.length; ) {
270
+ const entry = parkedTurnStarts[i]!
271
+ if (entry.chatId === chatId && envThreadIdNum(entry.threadId) === wantThread) {
272
+ drained.push(entry)
273
+ parkedTurnStarts.splice(i, 1)
274
+ } else {
275
+ i++
276
+ }
277
+ }
278
+ for (const env of drained) {
279
+ process.stderr.write(
280
+ `telegram gateway: parked-turn-start drained on silence-fallback ` +
281
+ `chat=${env.chatId ?? '-'} thread=${envThreadIdNum(env.threadId) ?? '-'} ` +
282
+ `msg=${env.messageId ?? '-'}\n`,
283
+ )
284
+ // Reuse the dequeue turn-start path verbatim — beginTurn adopts the parked
285
+ // envelope's queued card (Part B) so the frozen "⏳ Queued" surface becomes
286
+ // the live progress card rather than lingering.
287
+ beginTurn(deps, env)
288
+ }
289
+ return drained
290
+ }
291
+
235
292
  // ─── Queued-turn card send / finalize (Part B) ───────────────────────────────
236
293
  // These use the ALREADY-injected `bot` + `robustApiCall` deps, so the whole
237
294
  // feature lives in stream-render.ts with zero new wiring in gateway.ts. No
@@ -27,6 +27,38 @@ export type DeliveryOutcome = 'delivered' | 'failed' | 'suppressed'
27
27
  /** The status strings written to turns.jsonl. `send_failed` is new in PR B. */
28
28
  export type TurnStatus = 'complete' | 'no_reply' | 'send_failed'
29
29
 
30
+ /**
31
+ * How this turn's answer actually reached (or failed to reach) the user — the
32
+ * honest delivery route, recorded alongside `status` so the fleet-health
33
+ * detector can tell a flush-recovered turn apart from a genuine silent no-op.
34
+ *
35
+ * - `reply` — the reply tool delivered the answer (the normal path), OR the
36
+ * flush short-circuited because reply had already delivered.
37
+ * - `stream` — the answer landed via the streaming/draft path without a reply
38
+ * tool call (final answer delivered, `replyCalled` false).
39
+ * - `flush` — a turn-flush / outbox-sweep backstop delivered the answer after
40
+ * the reply tool was bypassed (terminal prose, `tools:0`). This is
41
+ * the case that used to masquerade as a silent no-op.
42
+ * - `none` — nothing reached the user (send failed, or a genuine no-reply).
43
+ */
44
+ export type TurnRoute = 'reply' | 'stream' | 'flush' | 'none'
45
+
46
+ /**
47
+ * Epoch of when the honest `route` field shipped on turns.jsonl rows — a FIXED
48
+ * literal cutoff the fleet-health detector uses to age out the pre-route legacy
49
+ * backlog (rows written before this field existed carry no `route`, so the
50
+ * detector cannot classify them and must not escalate them as silent no-ops).
51
+ *
52
+ * UNIX SECONDS, not milliseconds: turns.jsonl `ts` is written as
53
+ * `Math.floor(endedAt / 1000)` (see `buildTurnRecord`), and the detector
54
+ * compares this constant against that seconds-valued `ts`. It intentionally
55
+ * mirrors the units of `SILENT_NOOP_FLOOR_TS` in `src/fleet-health/detect.ts`.
56
+ *
57
+ * Value = 2026-07-31T00:00:00Z (`date -u -d @1785456000`). Fixed, not rolling:
58
+ * a rolling window would hide a genuine ongoing regression that drops the field.
59
+ */
60
+ export const ROUTE_FIELD_SHIP_TS = 1_785_456_000
61
+
30
62
  /**
31
63
  * Derive the recorded turn status from the turn's flags.
32
64
  *
@@ -58,6 +90,41 @@ export function computeTurnStatus(turn: {
58
90
  }
59
91
  }
60
92
 
93
+ /**
94
+ * Derive the honest delivery `route` from the SAME resolved delivery state
95
+ * `computeTurnStatus` reads — never speculative. `deliveryOutcome` (when
96
+ * present) is authoritative; when absent we fall back to the legacy
97
+ * `finalAnswerDelivered` / `replyCalled` reading, exactly as `computeTurnStatus`
98
+ * does for `status`.
99
+ *
100
+ * failed → none (nothing reached the user)
101
+ * delivered → flush (a backstop send delivered the answer)
102
+ * suppressed → reply (flush short-circuited; reply already delivered)
103
+ * undefined (legacy) → finalAnswerDelivered
104
+ * ? (replyCalled ? reply : stream)
105
+ * : none
106
+ */
107
+ export function computeTurnRoute(turn: {
108
+ finalAnswerDelivered: boolean
109
+ replyCalled: boolean
110
+ deliveryOutcome?: DeliveryOutcome
111
+ }): TurnRoute {
112
+ switch (turn.deliveryOutcome) {
113
+ case 'failed':
114
+ return 'none'
115
+ case 'delivered':
116
+ return 'flush'
117
+ case 'suppressed':
118
+ return 'reply'
119
+ default:
120
+ return turn.finalAnswerDelivered
121
+ ? turn.replyCalled
122
+ ? 'reply'
123
+ : 'stream'
124
+ : 'none'
125
+ }
126
+ }
127
+
61
128
  /**
62
129
  * Resolve a backstop send's outcome from what actually happened on the wire.
63
130
  * A throw is a failure; a no-throw send that delivered fewer chunks than it
@@ -148,6 +215,13 @@ export interface TurnRecordRow {
148
215
  tools: number
149
216
  status: TurnStatus
150
217
  turn_id: string
218
+ /**
219
+ * The honest delivery route for this turn (see `TurnRoute`). Recorded on every
220
+ * row so the fleet-health detector can distinguish a flush-recovered turn
221
+ * (`route: 'flush'`, answer delivered by a backstop after the reply tool was
222
+ * bypassed) from a genuine silent no-op (`route: 'none'`). Always emitted.
223
+ */
224
+ route: TurnRoute
151
225
  /**
152
226
  * How many landed message ids of this turn's backstop delivery the read-back
153
227
  * probe never corroborated (`sentIds` minus the confirmed subset). OMITTED
@@ -178,6 +252,7 @@ export function buildTurnRecord(
178
252
  toolCallCount: number
179
253
  turnId: string
180
254
  finalAnswerDelivered: boolean
255
+ replyCalled?: boolean
181
256
  deliveryOutcome?: DeliveryOutcome
182
257
  landedUnconfirmed?: number
183
258
  },
@@ -190,6 +265,11 @@ export function buildTurnRecord(
190
265
  tools: turn.toolCallCount ?? 0,
191
266
  status: computeTurnStatus(turn),
192
267
  turn_id: turn.turnId,
268
+ route: computeTurnRoute({
269
+ finalAnswerDelivered: turn.finalAnswerDelivered,
270
+ replyCalled: turn.replyCalled ?? false,
271
+ deliveryOutcome: turn.deliveryOutcome,
272
+ }),
193
273
  // Emitted ONLY when non-zero (see `TurnRecordRow.landed_unconfirmed`).
194
274
  ...(turn.landedUnconfirmed != null && turn.landedUnconfirmed > 0
195
275
  ? { landed_unconfirmed: turn.landedUnconfirmed }