switchroom 0.18.6 β†’ 0.18.7

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 (71) hide show
  1. package/dist/agent-scheduler/index.js +1 -0
  2. package/dist/auth-broker/index.js +1 -0
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +269 -56
  6. package/dist/host-control/main.js +2 -1
  7. package/dist/vault/approvals/kernel-server.js +1 -0
  8. package/dist/vault/broker/server.js +1 -0
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +35 -16
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +1937 -580
  15. package/telegram-plugin/dist/server.js +24 -0
  16. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  17. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  18. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  19. package/telegram-plugin/gateway/gateway.ts +1071 -130
  20. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  21. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  22. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  23. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  24. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  25. package/telegram-plugin/gateway/model-command.ts +212 -51
  26. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  27. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  28. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  29. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  30. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  31. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  32. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  33. package/telegram-plugin/model-label.ts +69 -0
  34. package/telegram-plugin/operator-events.ts +24 -0
  35. package/telegram-plugin/permission-diff.ts +128 -0
  36. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  37. package/telegram-plugin/registry/subagents.test.ts +90 -0
  38. package/telegram-plugin/session-tail.ts +28 -0
  39. package/telegram-plugin/silent-end.ts +49 -4
  40. package/telegram-plugin/subagent-watcher.ts +222 -37
  41. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  42. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  43. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  44. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  45. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
  46. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  47. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  48. package/telegram-plugin/tests/model-command.test.ts +202 -42
  49. package/telegram-plugin/tests/model-label.test.ts +64 -0
  50. package/telegram-plugin/tests/operator-events.test.ts +1 -0
  51. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  52. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  53. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  54. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  55. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  56. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  57. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  58. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  59. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  60. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  61. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  62. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  63. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  64. package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
  65. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  66. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  67. package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
  68. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  69. package/telegram-plugin/tool-activity-summary.ts +22 -2
  70. package/telegram-plugin/typing-wrap.ts +72 -25
  71. package/telegram-plugin/worker-activity-feed.ts +9 -0
@@ -126,6 +126,14 @@ import {
126
126
  } from './permission-timeout.js'
127
127
  import { renderVaultRequestAccessCard } from './vault-request-access-card.js'
128
128
  import { createPermissionCardStore, type PersistedPermCard } from './permission-card-store.js'
129
+ import { createPendingCardStore, type PersistedApprovalCard } from './pending-card-store.js'
130
+ import {
131
+ buildVaultAccessTimeoutInbound,
132
+ buildVaultSaveTimeoutInbound,
133
+ buildSecretRequestTimeoutInbound,
134
+ buildMentalModelProposeTimeoutInbound,
135
+ } from './approval-timeout-inbound-builders.js'
136
+ import { expirePendingCard, sweepExpiredEntries } from './pending-card-expiry.js'
129
137
  import {
130
138
  isPermissionRearmEnabled,
131
139
  permissionRearmGraceMs,
@@ -134,6 +142,11 @@ import {
134
142
  distinctRequestIds,
135
143
  } from './permission-rearm.js'
136
144
  import { createMissedApprovalsStore, type MissedApproval } from './missed-approvals-store.js'
145
+ import {
146
+ createAlwaysAllowPersistQueue,
147
+ drainAlwaysAllowPersistQueue,
148
+ type AlwaysAllowDrainDeps,
149
+ } from './always-allow-persist-queue.js'
137
150
  import {
138
151
  renderMissedApprovalsDigest,
139
152
  missedApprovalsKeyboard,
@@ -143,6 +156,8 @@ import {
143
156
  import { pickRecoveredPermissionOrigin } from './permission-card-origin.js'
144
157
  import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
145
158
  import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, formatStepSuffix, type SessionActivityHeader } from '../tool-activity-summary.js'
159
+ import { formatModelLabel } from '../model-label.js'
160
+ import { createSessionModelSource } from './session-model-source.js'
146
161
  import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
147
162
  import { REPLY_TOOLS, isDraftOfReply } from '../narrative-dedup.js'
148
163
  import { toolLabel } from '../tool-labels.js'
@@ -396,7 +411,7 @@ import {
396
411
  _resetHostdEnabledCache,
397
412
  } from './hostd-dispatch.js'
398
413
  import { formatUpdateStatusLine } from './update-status-line.js'
399
- import type { HostdRequest } from '../../src/host-control/protocol.js'
414
+ import type { HostdRequest, HostdResponse } from '../../src/host-control/protocol.js'
400
415
  import type { AgentAudit } from '../welcome-text.js'
401
416
  import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
402
417
  import { startWebhookIngestServer } from './webhook-ingest-server.js'
@@ -530,6 +545,7 @@ import type {
530
545
  InjectInboundMessage,
531
546
  SendOutboundMessage,
532
547
  QuotaWallDetectedMessage,
548
+ QueryPendingPermissionMessage,
533
549
  PostSkillProposalMessage,
534
550
  PermissionEvent,
535
551
  RolloutStatusPostMessage,
@@ -561,6 +577,7 @@ import {
561
577
  clearCleanShutdownMarker,
562
578
  shouldSuppressRecoveryBanner,
563
579
  shouldSuppressBootResume,
580
+ parseBootResumeMode,
564
581
  resolveShutdownMarker,
565
582
  DEFAULT_MAX_AGE_MS as CLEAN_SHUTDOWN_MAX_AGE_MS,
566
583
  } from './clean-shutdown-marker.js'
@@ -677,9 +694,11 @@ import {
677
694
  import {
678
695
  buildResumeInterruptedInbound,
679
696
  buildResumeWatchdogReportInbound,
680
- selectResumeBuilder,
697
+ buildResumeDeferredReportInbound,
698
+ decideBootResumeKind,
681
699
  } from './resume-inbound-builder.js'
682
- import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey } from '../registry/subagents-schema.js'
700
+ import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey, listNonTerminalSubagentsForTurn } from '../registry/subagents-schema.js'
701
+ import type { InterruptedSubagent } from './resume-inbound-builder.js'
683
702
  import { resolveWorkerFeedDispatch, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
684
703
  import {
685
704
  resolveSubagentStatusSurface,
@@ -717,12 +736,127 @@ process.on('beforeExit', () => {
717
736
  // ─── Env + state dir ──────────────────────────────────────────────────────
718
737
  const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
719
738
  const permCardStore = createPermissionCardStore(STATE_DIR)
739
+ // Durable store for the four AGENT-INITIATED approval-card families
740
+ // (vault_request_access / vault_request_save / request_secret /
741
+ // mental_model_propose). Persists card METADATA only β€” never any secret value
742
+ // (see pending-card-store.ts secrets-hygiene note). Restored at boot so a
743
+ // post-restart tap on a still-valid card works like a pre-restart tap, and
744
+ // swept in the pendingStateReaper so an unanswered card wakes the parked agent
745
+ // on TTL instead of leaving it parked forever.
746
+ const pendingCardStore = createPendingCardStore(STATE_DIR)
720
747
  // #2862 β€” missed-approvals re-offer. Persisted list of approvals that
721
748
  // TTL-expired while the operator was away; a digest card is posted on the
722
749
  // operator's next activity. Kill switch: SWITCHROOM_MISSED_APPROVAL_REOFFER=0.
723
750
  const missedApprovalsStore = createMissedApprovalsStore(STATE_DIR)
724
751
  const MISSED_APPROVAL_REOFFER_ENABLED =
725
752
  process.env.SWITCHROOM_MISSED_APPROVAL_REOFFER !== '0'
753
+ // #2973 pt.2 β€” durable retry queue for "πŸ” Always allow" persists that
754
+ // fail for a retryable reason (stale config view, rate limit, transient
755
+ // hostd error). Drained at boot (below) and on a periodic timer so a
756
+ // gateway restart mid-persist never loses the retry.
757
+ const alwaysAllowPersistQueue = createAlwaysAllowPersistQueue(STATE_DIR)
758
+
759
+ /** Read a hostd `error_envelope`'s structured `retry_after` fix (if
760
+ * present) as a millisecond delay from now. Returns undefined for any
761
+ * other fix kind / missing envelope / unparsable timestamp β€” callers
762
+ * fall back to plain exponential backoff in that case. */
763
+ function extractRetryAfterMs(resp: HostdResponse): number | undefined {
764
+ const fix = resp.error_envelope?.fix
765
+ if (fix == null || fix.kind !== 'retry_after') return undefined
766
+ const at = Date.parse(fix.retry_at)
767
+ if (Number.isNaN(at)) return undefined
768
+ return Math.max(0, at - Date.now())
769
+ }
770
+
771
+ /**
772
+ * Build a FRESH dependency set for `drainAlwaysAllowPersistQueue` β€” a new
773
+ * factory call per drain pass, not a cached singleton, so every attempt
774
+ * re-reads config from disk (the #2973 stale-container-side-view failure
775
+ * class is fixed by never reusing a snapshot across retries).
776
+ */
777
+ function alwaysAllowDrainDeps(): AlwaysAllowDrainDeps {
778
+ return {
779
+ readConfigText: () => {
780
+ const cfgPath = process.env.SWITCHROOM_CONFIG ?? SWITCHROOM_CONFIG ?? findSwitchroomConfigFile()
781
+ return readFileSync(cfgPath, 'utf8')
782
+ },
783
+ resolveAllowList: (_configText, agentName) => {
784
+ const cfg = loadSwitchroomConfig()
785
+ const rawAgent = cfg.agents?.[agentName]
786
+ if (!rawAgent) return []
787
+ const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
788
+ return (resolved as { tools?: { allow?: string[] } }).tools?.allow ?? []
789
+ },
790
+ isRulePersisted,
791
+ synthesizeDiff: (agentName, rule, configText) =>
792
+ synthesizeAllowRuleDiff({ agentName, rule, configText }),
793
+ dispatchConfigEdit: async (entry, unifiedDiff) => {
794
+ const req: HostdRequest = {
795
+ v: 1,
796
+ op: 'config_propose_edit',
797
+ request_id: hostdRequestId('gw-always-allow-retry'),
798
+ args: {
799
+ unified_diff: unifiedDiff,
800
+ reason: `Operator 'always allow' retry: ${entry.agentName} can ${entry.grantPhrase}`,
801
+ target_path: '/state/config/switchroom.yaml',
802
+ },
803
+ }
804
+ const resp = await tryHostdDispatch(entry.agentName, req, 720_000)
805
+ if (resp === 'not-configured') {
806
+ return { ok: false as const, error: 'hostd not-configured (retry queue requires host_control.enabled)' }
807
+ }
808
+ if (resp.result === 'completed') return { ok: true as const }
809
+ return {
810
+ ok: false as const,
811
+ error: resp.error ?? `hostd ${resp.result}`,
812
+ retryAfterMs: extractRetryAfterMs(resp),
813
+ }
814
+ },
815
+ // #2973 pt.3 β€” loud terminal failure. A card edit doesn't ping the
816
+ // operator (the original permission card was already edited to the
817
+ // "saving durably in background…" interim state); this posts a NEW
818
+ // message instead, via the same operator-event broadcast every other
819
+ // fleet alert uses.
820
+ notifyTerminalFailure: (entry, reason) => {
821
+ emitGatewayOperatorEvent({
822
+ kind: 'always-allow-persist-failed',
823
+ agent: entry.agentName,
824
+ detail: `"${entry.grantPhrase}" (rule \`${entry.rule}\`) β€” ${reason}`,
825
+ suggestedActions: [],
826
+ firstSeenAt: new Date(),
827
+ })
828
+ },
829
+ log: (line) => process.stderr.write(line),
830
+ }
831
+ }
832
+
833
+ /** ~10 min between periodic drain passes β€” frequent enough that a
834
+ * retryable failure (rate limit, transient hostd hiccup) resolves within
835
+ * a reasonable window, infrequent enough to never look like polling. */
836
+ const ALWAYS_ALLOW_DRAIN_INTERVAL_MS = 10 * 60_000
837
+
838
+ /**
839
+ * Boot drain (picks up anything a prior gateway process queued right
840
+ * before a restart β€” success criterion: "restarting the gateway mid-
841
+ * persist does not lose the queued entry") + a periodic timer thereafter.
842
+ * Called once at gateway startup. Every pass is independently
843
+ * fault-tolerant β€” `drainAlwaysAllowPersistQueue` never throws.
844
+ */
845
+ function scheduleAlwaysAllowPersistDrain(): void {
846
+ void drainAlwaysAllowPersistQueue(alwaysAllowPersistQueue, alwaysAllowDrainDeps()).catch((err) => {
847
+ process.stderr.write(
848
+ `telegram gateway: always-allow-persist-queue boot drain failed: ${(err as Error).message}\n`,
849
+ )
850
+ })
851
+ const timer = setInterval(() => {
852
+ void drainAlwaysAllowPersistQueue(alwaysAllowPersistQueue, alwaysAllowDrainDeps()).catch((err) => {
853
+ process.stderr.write(
854
+ `telegram gateway: always-allow-persist-queue periodic drain failed: ${(err as Error).message}\n`,
855
+ )
856
+ })
857
+ }, ALWAYS_ALLOW_DRAIN_INTERVAL_MS)
858
+ timer.unref?.()
859
+ }
726
860
  const ACCESS_FILE = join(STATE_DIR, 'access.json')
727
861
  const APPROVED_DIR = join(STATE_DIR, 'approved')
728
862
  const ENV_FILE = join(STATE_DIR, '.env')
@@ -1383,71 +1517,124 @@ try {
1383
1517
  const pending = findLatestTurnIfInterrupted(turnsDb)
1384
1518
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
1385
1519
  if (pending != null && selfAgent) {
1386
- // Clean-shutdown gate: suppress auto-resume when the prior shutdown was
1387
- // operator/roll/CLI-initiated (clean). A clean-shutdown marker present and
1388
- // fresh means the agent was asked to stop; the "interrupted" turn was
1389
- // abandoned by that decision. Replaying it on every planned restart wastes
1390
- // subscription quota for no user benefit. Only unclean exits (crash/OOM/
1391
- // unexpected kill) should auto-resume.
1520
+ // Boot-resume policy (2026-07, superseding #2585). The block only runs
1521
+ // when `pending` exists β€” i.e. there IS genuinely in-flight work. The
1522
+ // product decision is that a DELIBERATE restart must not silently drop
1523
+ // that work: `session_continuity.boot_resume` (SWITCHROOM_BOOT_RESUME,
1524
+ // default 'in-flight') resumes it even after a clean shutdown. #2585's
1525
+ // quota-saving posture survives only as the opt-in 'never' mode β€” and
1526
+ // even then we deliver a passive REPORT, never silence, because the user
1527
+ // must always be told when in-flight work stopped.
1392
1528
  //
1393
1529
  // NOTE: GATEWAY_CLEAN_SHUTDOWN_MARKER_PATH is defined lower in this file
1394
1530
  // (module-init order); we compute the path inline here using the same
1395
1531
  // formula so we can read it at boot-resume time.
1396
- // SWITCHROOM_BOOT_RESUME_ALWAYS=1 is an escape hatch that restores
1397
- // unconditional resume if needed.
1532
+ // SWITCHROOM_BOOT_RESUME_ALWAYS=1 remains a back-compat escape hatch that
1533
+ // forces unconditional resume regardless of mode.
1398
1534
  const bootResumeMarkerPath =
1399
1535
  process.env.SWITCHROOM_GATEWAY_CLEAN_SHUTDOWN_MARKER ?? join(STATE_DIR, 'clean-shutdown.json')
1400
1536
  const bootResumeCleanMarker = readCleanShutdownMarker(bootResumeMarkerPath)
1401
1537
  const bootResumeForceAlways = process.env.SWITCHROOM_BOOT_RESUME_ALWAYS === '1'
1538
+ const bootResumeMode = parseBootResumeMode(process.env.SWITCHROOM_BOOT_RESUME)
1402
1539
  const bootResumeSuppressed = shouldSuppressBootResume(bootResumeCleanMarker, Date.now(), {
1403
1540
  forceAlways: bootResumeForceAlways,
1541
+ mode: bootResumeMode,
1542
+ })
1543
+
1544
+ // 3h staleness failsafe (operator spec, 2026-06-03): never AUTO-resume
1545
+ // interrupted work older than RESUME_MAX_AGE_MS β€” selectResumeBuilder
1546
+ // downgrades a stale 'resume' to the passive 'report'. Env override
1547
+ // SWITCHROOM_RESUME_MAX_AGE_MS (ms); set very high to disable.
1548
+ const RESUME_MAX_AGE_MS = (() => {
1549
+ const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS)
1550
+ return Number.isFinite(v) && v > 0 ? v : 10_800_000 // 3h
1551
+ })()
1552
+
1553
+ // Decide the inbound kind (pure β€” see decideBootResumeKind). Precedence:
1554
+ // 1. loop-guard β†’ 'defer-loop' (never a second resume of a resume)
1555
+ // 2. suppressed β†’ 'defer-suppressed' (boot_resume: never; notice, not silence)
1556
+ // 3. otherwise β†’ selectResumeBuilder (resume | report | none)
1557
+ const bootResumeKind = decideBootResumeKind({
1558
+ pending,
1559
+ suppressed: bootResumeSuppressed,
1560
+ ageMs: Math.max(0, Date.now() - pending.started_at),
1561
+ maxAgeMs: RESUME_MAX_AGE_MS,
1404
1562
  })
1405
- if (bootResumeSuppressed) {
1563
+
1564
+ // Sub-agents that were still in flight (running / stalled β€” non-terminal)
1565
+ // when the turn was killed. Read HERE, at module top, BEFORE the
1566
+ // subagent-watcher's boot scan + reaper run: the watcher never deletes
1567
+ // these rows (it only flips running→stalled and marks files historical
1568
+ // in-memory), and this accessor already includes 'stalled', so the data
1569
+ // survives either ordering β€” but reading pre-watcher keeps it simplest.
1570
+ // Threaded into ALL the builders below (resume, watchdog report, and the
1571
+ // deferred report) so the session gets an explicit killed-workers block β€”
1572
+ // it must not declare the task done on ghost workers, and even a
1573
+ // suppressed/loop-guarded resume must still NAME the deaths.
1574
+ let interruptedSubagents: InterruptedSubagent[] = []
1575
+ try {
1576
+ interruptedSubagents = listNonTerminalSubagentsForTurn(turnsDb, pending.turn_key).map(
1577
+ (s) => ({ agentType: s.agent_type, description: s.description, status: s.status }),
1578
+ )
1579
+ } catch (err) {
1406
1580
  process.stderr.write(
1407
- `telegram gateway: boot-resume suppressed (clean shutdown` +
1408
- `${bootResumeCleanMarker?.reason ? ` reason=${JSON.stringify(bootResumeCleanMarker.reason)}` : ''}` +
1409
- `) β€” unclean exits still resume turnKey=${pending.turn_key}\n`,
1581
+ `telegram gateway: boot-resume subagent lookup failed (${(err as Error).message}) β€” continuing without worker list\n`,
1410
1582
  )
1411
- } else {
1412
- // 3h staleness failsafe (operator spec, 2026-06-03): never AUTO-resume
1413
- // interrupted work older than RESUME_MAX_AGE_MS β€” selectResumeBuilder
1414
- // downgrades a stale 'resume' to the passive 'report' so the user is told
1415
- // ("I was working on X ~Nh ago") but nothing replays unprompted. Env
1416
- // override SWITCHROOM_RESUME_MAX_AGE_MS (ms); set very high to disable.
1417
- const RESUME_MAX_AGE_MS = (() => {
1418
- const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS)
1419
- return Number.isFinite(v) && v > 0 ? v : 10_800_000 // 3h
1420
- })()
1421
- const kind = selectResumeBuilder(pending.ended_via, {
1422
- ageMs: Math.max(0, Date.now() - pending.started_at),
1423
- maxAgeMs: RESUME_MAX_AGE_MS,
1424
- })
1425
- if (kind === 'resume') {
1426
- bootResumeInbound = { agent: selfAgent, msg: buildResumeInterruptedInbound({ turn: pending }) }
1427
- } else if (kind === 'report') {
1428
- // idleMs: this boot's measured marker age if it just classified this
1429
- // turn; otherwise recover it from the persisted interrupt_reason (a
1430
- // later boot, marker already swept); else fall back to total runtime.
1431
- let idleMs = pending.turn_key === timeoutTurnKey && markerAgeMs != null ? markerAgeMs : null
1432
- if (idleMs == null && pending.interrupt_reason) {
1433
- try {
1434
- const parsed = JSON.parse(pending.interrupt_reason) as { idleMs?: unknown }
1435
- if (typeof parsed.idleMs === 'number' && Number.isFinite(parsed.idleMs)) idleMs = parsed.idleMs
1436
- } catch { /* malformed snapshot β€” fall through */ }
1437
- }
1438
- if (idleMs == null) idleMs = Math.max(0, Date.now() - pending.started_at)
1439
- bootResumeInbound = {
1440
- agent: selfAgent,
1441
- msg: buildResumeWatchdogReportInbound({ turn: pending, idleMs }),
1442
- }
1583
+ }
1584
+
1585
+ if (bootResumeKind === 'resume') {
1586
+ bootResumeInbound = {
1587
+ agent: selfAgent,
1588
+ msg: buildResumeInterruptedInbound({ turn: pending, subagents: interruptedSubagents }),
1443
1589
  }
1444
- if (bootResumeInbound != null) {
1445
- process.stderr.write(
1446
- `telegram gateway: boot-resume queued kind=${kind} turnKey=${pending.turn_key} ` +
1447
- `endedVia=${pending.ended_via ?? 'open'} chat=${pending.chat_id}\n`,
1448
- )
1590
+ } else if (bootResumeKind === 'report') {
1591
+ // idleMs: this boot's measured marker age if it just classified this
1592
+ // turn; otherwise recover it from the persisted interrupt_reason (a
1593
+ // later boot, marker already swept); else fall back to total runtime.
1594
+ let idleMs = pending.turn_key === timeoutTurnKey && markerAgeMs != null ? markerAgeMs : null
1595
+ if (idleMs == null && pending.interrupt_reason) {
1596
+ try {
1597
+ const parsed = JSON.parse(pending.interrupt_reason) as { idleMs?: unknown }
1598
+ if (typeof parsed.idleMs === 'number' && Number.isFinite(parsed.idleMs)) idleMs = parsed.idleMs
1599
+ } catch { /* malformed snapshot β€” fall through */ }
1600
+ }
1601
+ if (idleMs == null) idleMs = Math.max(0, Date.now() - pending.started_at)
1602
+ bootResumeInbound = {
1603
+ agent: selfAgent,
1604
+ msg: buildResumeWatchdogReportInbound({ turn: pending, idleMs, subagents: interruptedSubagents }),
1605
+ }
1606
+ } else if (bootResumeKind === 'defer-loop' || bootResumeKind === 'defer-suppressed') {
1607
+ // Passive deferred-report: work was in flight but we decline to
1608
+ // auto-resume (loop-guard, or boot_resume:never). Silence is never
1609
+ // acceptable here β€” tell the user what was in flight and ask.
1610
+ bootResumeInbound = {
1611
+ agent: selfAgent,
1612
+ msg: buildResumeDeferredReportInbound({
1613
+ turn: pending,
1614
+ reason: bootResumeKind === 'defer-loop' ? 'loop-guard' : 'clean-restart-suppressed',
1615
+ subagents: interruptedSubagents,
1616
+ }),
1449
1617
  }
1450
1618
  }
1619
+
1620
+ if (bootResumeKind === 'defer-suppressed') {
1621
+ process.stderr.write(
1622
+ `telegram gateway: boot-resume suppressed (clean shutdown` +
1623
+ `${bootResumeCleanMarker?.reason ? ` reason=${JSON.stringify(bootResumeCleanMarker.reason)}` : ''}` +
1624
+ `, mode=${bootResumeMode}) β€” passive report delivered for turnKey=${pending.turn_key}\n`,
1625
+ )
1626
+ } else if (bootResumeKind === 'defer-loop') {
1627
+ process.stderr.write(
1628
+ `telegram gateway: boot-resume loop-guard tripped (interrupted turn was itself a resume) ` +
1629
+ `β€” passive report delivered instead of re-resuming turnKey=${pending.turn_key}\n`,
1630
+ )
1631
+ }
1632
+ if (bootResumeInbound != null) {
1633
+ process.stderr.write(
1634
+ `telegram gateway: boot-resume queued kind=${bootResumeKind} mode=${bootResumeMode} ` +
1635
+ `turnKey=${pending.turn_key} endedVia=${pending.ended_via ?? 'open'} chat=${pending.chat_id}\n`,
1636
+ )
1637
+ }
1451
1638
  }
1452
1639
 
1453
1640
  // Diagnostic env file (one-shot, sourced by start.sh) β€” kept for the
@@ -2273,6 +2460,110 @@ function deliverResumeSyntheticOrBuffer(agent: string, inbound: InboundMessage):
2273
2460
  return delivered
2274
2461
  }
2275
2462
 
2463
+ /** Outcome of routing an agent-authored button tap through the turn-safe
2464
+ * delivery machinery. `buffered-mid-turn` and `delivered` both mean "the tap
2465
+ * will be actioned" (the mid-turn case flushes on turn-complete); only
2466
+ * `buffered-bridge-offline` needs the user-facing "agent is restarting" notice. */
2467
+ type ButtonTapDeliveryOutcome = 'delivered' | 'buffered-mid-turn' | 'buffered-bridge-offline'
2468
+
2469
+ /**
2470
+ * Deliver an agent-authored inline-keyboard button tap (`agent:` callback_data)
2471
+ * through the SAME turn-safe machinery as a normal Telegram inbound, instead of
2472
+ * the old raw `sendToAgent` + buffer-only-on-bridge-miss.
2473
+ *
2474
+ * THE BUG (#271 button path, verified 2026-07): the `agent:` callback handler
2475
+ * delivered the synthesized tap inbound with a bare `ipcServer.sendToAgent`,
2476
+ * marked busy, and buffered ONLY when the bridge was offline. It never ran the
2477
+ * #1556 turn gate β€” so a tap landing WHILE a turn is in flight fired the MCP
2478
+ * channel notification mid-turn, typed into the CLI composer, and stranded there
2479
+ * (the lawgpt/marko wedge). It also skipped the pre-send composer clear and the
2480
+ * deliver-until-acked tracking, so a stranded tap was never sweep-redelivered.
2481
+ *
2482
+ * Fix: reuse the resume-synthetic turn gate (mid-turn β†’ `buffer-until-idle`, the
2483
+ * turn-complete hook + idle-drain flush it the instant claude goes idle), the
2484
+ * pre-send composer clear, and the delivery-confirm tracking β€” exactly like the
2485
+ * `handleInbound` fresh-turn path. A button tap carries no `meta.source` and a
2486
+ * non-empty body, so `shouldTrackDelivery` enrols it; we additionally require a
2487
+ * real `meta.message_id` so the `enqueue` ack has something to match (else the
2488
+ * never-drop sweep would storm). The tap UX is unchanged β€” the ack toast, the
2489
+ * single-use keyboard strip, and the bridge-offline spool + restart notice all
2490
+ * stay at the call site; only delivery timing/safety changes.
2491
+ */
2492
+ async function deliverButtonTapInbound(
2493
+ agent: string,
2494
+ inbound: InboundMessage,
2495
+ ): Promise<ButtonTapDeliveryOutcome> {
2496
+ // #1556 turn gate β€” same authoritative "is a turn in flight?" read the
2497
+ // resume-synthetic path uses. Mid-turn β†’ hold in the pending-inbound buffer;
2498
+ // the turn-complete hook + idle-drain timer flush it when claude goes idle,
2499
+ // where it lands cleanly as a fresh turn instead of stranding in the composer.
2500
+ const { decision, reserve } = reserveInboundDelivery({
2501
+ turnInFlight: turnInFlightForGate(),
2502
+ isSteering: false,
2503
+ isInterrupt: false,
2504
+ })
2505
+ if (decision === 'buffer-until-idle') {
2506
+ pendingInboundBuffer.push(agent, inbound)
2507
+ return 'buffered-mid-turn'
2508
+ }
2509
+ // #2917 per-chat FIFO: reserve the chat's busy key SYNCHRONOUSLY β€” before the
2510
+ // composer-clear await below β€” so a concurrent same-chat inbound reaching the
2511
+ // live gate observes this in-flight delivery and buffers behind it. Released
2512
+ // in lockstep below if the send misses (bridge offline).
2513
+ let reservedBusyKey: string | null = null
2514
+ if (reserve && SERIALIZE_INBOUND_DELIVERY_ENABLED) {
2515
+ reservedBusyKey = markClaudeBusyForInbound(inbound)
2516
+ }
2517
+ // Pre-send composer clear (the marko wedge) β€” wipe stale typed-ahead / ghost
2518
+ // text so the channel notification lands at a clean line and auto-submits.
2519
+ // Soft-fail by contract: a clear failure must NEVER block delivery.
2520
+ if (agent) {
2521
+ try {
2522
+ const { clearAgentComposer } = await import('../../src/agents/tmux.js')
2523
+ const cleared = clearAgentComposer({ agentName: agent })
2524
+ if ('error' in cleared) {
2525
+ process.stderr.write(
2526
+ `telegram gateway: button-tap pre-send composer-clear soft-failed agent=${agent}: ${cleared.error} β€” delivering anyway\n`,
2527
+ )
2528
+ }
2529
+ } catch (err) {
2530
+ process.stderr.write(
2531
+ `telegram gateway: button-tap pre-send composer-clear threw agent=${agent}: ${(err as Error).message} β€” delivering anyway\n`,
2532
+ )
2533
+ }
2534
+ }
2535
+ const delivered = ipcServer.sendToAgent(agent, inbound)
2536
+ if (delivered) {
2537
+ const busyKey = reservedBusyKey ?? markClaudeBusyForInbound(inbound)
2538
+ // Track until claude acks via `enqueue` so the deliver-until-acked sweep
2539
+ // re-delivers a tap stranded in the composer. Only when we have a real
2540
+ // message_id to match the ack against β€” otherwise the never-drop loop storms.
2541
+ if (
2542
+ DELIVERY_CONFIRM_ENABLED &&
2543
+ inbound.meta?.message_id != null &&
2544
+ inbound.meta.message_id !== '' &&
2545
+ shouldTrackDelivery({
2546
+ isSteering: false,
2547
+ isInterrupt: false,
2548
+ hasSource: inbound.meta?.source != null,
2549
+ effectiveText: inbound.text,
2550
+ })
2551
+ ) {
2552
+ trackDelivery(deliveryQueue, busyKey, inbound, Date.now(), String(inbound.messageId))
2553
+ }
2554
+ return 'delivered'
2555
+ }
2556
+ // Bridge offline: release the synchronous reservation in lockstep (else the
2557
+ // orphaned busy key gates every later inbound into the buffer), then spool the
2558
+ // tap so it replays on reconnect β€” same behaviour as before this fix.
2559
+ if (reservedBusyKey != null) {
2560
+ claudeBusyKeys.delete(reservedBusyKey)
2561
+ claudeBusyKeySince.delete(reservedBusyKey)
2562
+ }
2563
+ pendingInboundBuffer.push(agent, inbound)
2564
+ return 'buffered-bridge-offline'
2565
+ }
2566
+
2276
2567
  const pendingRestarts = new Map<string, number>() // agentName -> timestamp when restart was requested
2277
2568
 
2278
2569
  // ─── Proactive context compaction (session.max_context_tokens) ──────────
@@ -2502,6 +2793,13 @@ type CurrentTurn = {
2502
2793
  // resume protocol uses this to decide "did the previous turn actually
2503
2794
  // finish a reply, or was it interrupted before commit?".
2504
2795
  lastAssistantDone: boolean
2796
+ // Live model in use for THIS turn, sourced from the main transcript's
2797
+ // `message.model` (the exact resolved model per API call) via the session-tail
2798
+ // `model` event β€” never from config or launch-time state. Updated on change;
2799
+ // undefined until the turn's first assistant line lands. Rendered onto the
2800
+ // activity/liveness card header's metrics line (e.g. "2m Β· 14 tools Β· opus 4.8")
2801
+ // and preferred by /status's buildAgentMetadata over the in-memory override.
2802
+ currentModel?: string
2505
2803
  // Phase 1 of #332: count of tool_use events in the current turn, for
2506
2804
  // the tool_call_count column in the turns registry.
2507
2805
  toolCallCount: number
@@ -2627,6 +2925,16 @@ type CurrentTurn = {
2627
2925
  // is never written and this is exactly the old singleton.
2628
2926
  let currentTurn: CurrentTurn | null = null
2629
2927
  const currentTurnMap = new CurrentTurnMap<CurrentTurn>()
2928
+ // Freshness-aware /status session-model source. Two writers: the session-tail
2929
+ // `model` event (each assistant line's `message.model` β€” ground truth for the
2930
+ // last API call, survives between turns) and the #2982 /model override (set
2931
+ // the instant a switch is confirmed β€” the ONLY truthful source in the
2932
+ // idle-after-switch window, before the next assistant line lands). Every write
2933
+ // is seq-stamped and `resolve()` prefers the NEWER observation, so neither
2934
+ // source can go stale behind the other (session-model-source.ts, pinned by
2935
+ // tests/session-model-source.test.ts). buildAgentMetadata reads resolve();
2936
+ // the /model command paths write via setOverride.
2937
+ const sessionModelSource = createSessionModelSource()
2630
2938
  // Captures the most-recently-started turn's sessionChatId. Unlike currentTurn,
2631
2939
  // this is NOT cleared by the silence poke (firePoke/clearTurnStarted). It lets
2632
2940
  // the Bug B fallback in executeReply route to the correct chat even when the
@@ -5236,17 +5544,26 @@ interface PendingVaultRequestSave {
5236
5544
  why?: string
5237
5545
  /** Unix-ms timestamp; entries are reaped after VAULT_REQUEST_SAVE_TTL_MS. */
5238
5546
  staged_at: number
5547
+ /** Set on entries RESTORED from disk after a gateway restart. The staged
5548
+ * secret `value` is held in memory only (never persisted β€” secrets
5549
+ * hygiene), so a restored entry has an empty value and cannot complete the
5550
+ * write. A Save tap on such a card degrades gracefully: it tells the agent
5551
+ * the value was lost to a restart instead of writing an empty secret. */
5552
+ restoredWithoutValue?: boolean
5239
5553
  }
5240
5554
  const pendingVaultRequestSaves = new Map<string, PendingVaultRequestSave>()
5241
5555
  // Gateway-side reap window for a staged vault-save card. Tracks the operator
5242
5556
  // approval-card lifetime (config-driven, 60-min default) so the reap never
5243
5557
  // races ahead of the card the operator is still looking at.
5244
5558
  const VAULT_REQUEST_SAVE_TTL_MS = approvalTtlMs()
5245
- function sweepPendingVaultRequestSaves(): void {
5246
- const cutoff = Date.now() - VAULT_REQUEST_SAVE_TTL_MS
5247
- for (const [k, v] of pendingVaultRequestSaves) {
5248
- if (v.staged_at < cutoff) pendingVaultRequestSaves.delete(k)
5249
- }
5559
+ function sweepPendingVaultRequestSaves(now = Date.now()): void {
5560
+ sweepExpiredEntries(
5561
+ pendingVaultRequestSaves,
5562
+ (v, n) => v.staged_at < n - VAULT_REQUEST_SAVE_TTL_MS,
5563
+ expireVaultSaveCard,
5564
+ now,
5565
+ cardExpiryLog,
5566
+ )
5250
5567
  }
5251
5568
 
5252
5569
  /**
@@ -5289,11 +5606,14 @@ const pendingVaultRequestAccesses = new Map<string, PendingVaultRequestAccess>()
5289
5606
  // Gateway-side reap window for a staged vault-access card. Tracks the operator
5290
5607
  // approval-card lifetime (config-driven, 60-min default) β€” see approvalTtlMs.
5291
5608
  const VAULT_REQUEST_ACCESS_TTL_MS = approvalTtlMs()
5292
- function sweepPendingVaultRequestAccesses(): void {
5293
- const cutoff = Date.now() - VAULT_REQUEST_ACCESS_TTL_MS
5294
- for (const [k, v] of pendingVaultRequestAccesses) {
5295
- if (v.staged_at < cutoff) pendingVaultRequestAccesses.delete(k)
5296
- }
5609
+ function sweepPendingVaultRequestAccesses(now = Date.now()): void {
5610
+ sweepExpiredEntries(
5611
+ pendingVaultRequestAccesses,
5612
+ (v, n) => v.staged_at < n - VAULT_REQUEST_ACCESS_TTL_MS,
5613
+ expireVaultAccessCard,
5614
+ now,
5615
+ cardExpiryLog,
5616
+ )
5297
5617
  }
5298
5618
 
5299
5619
  /**
@@ -5325,23 +5645,14 @@ const MENTAL_MODEL_PROPOSE_TTL_MS = approvalTtlMs()
5325
5645
  // posted card's keyboard away, so a stale card left in the chat can't be tapped
5326
5646
  // into a "Card expired" answer β€” the operator sees the βŒ› expiry inline instead.
5327
5647
  // Best-effort: card edits are fire-and-forget (the entry is removed regardless).
5328
- function sweepPendingMentalModelProposes(): void {
5329
- const cutoff = Date.now() - MENTAL_MODEL_PROPOSE_TTL_MS
5330
- for (const [k, v] of pendingMentalModelProposes) {
5331
- if (v.staged_at < cutoff) {
5332
- pendingMentalModelProposes.delete(k)
5333
- if (v.card_message_id != null) {
5334
- void lockedBot.api
5335
- .editMessageText(
5336
- v.chat_id,
5337
- v.card_message_id,
5338
- richMessage('βŒ› _This mental-model proposal card expired. Ask the agent to re-propose if it still stands._'),
5339
- { reply_markup: { inline_keyboard: [] } },
5340
- )
5341
- .catch(() => {})
5342
- }
5343
- }
5344
- }
5648
+ function sweepPendingMentalModelProposes(now = Date.now()): void {
5649
+ sweepExpiredEntries(
5650
+ pendingMentalModelProposes,
5651
+ (v, n) => v.staged_at < n - MENTAL_MODEL_PROPOSE_TTL_MS,
5652
+ expireMentalModelProposeCard,
5653
+ now,
5654
+ cardExpiryLog,
5655
+ )
5345
5656
  }
5346
5657
 
5347
5658
  // Sliding-window rate limit for mental-model proposals: at most
@@ -5578,6 +5889,296 @@ function isAutoFallbackCooldownActive(_agentName: string, now: number): boolean
5578
5889
  }
5579
5890
  }
5580
5891
 
5892
+ // ── Agent-initiated approval-card TTL expiry β†’ wake the parked agent ────────
5893
+ //
5894
+ // The four agent-initiated approval-card families (vault_request_access /
5895
+ // vault_request_save / request_secret / mental_model_propose) each park the
5896
+ // requesting agent (it ends its turn to wait for the operator's tap). Before
5897
+ // this, an unanswered card that TTL-expired left the agent parked FOREVER: the
5898
+ // lazy sweep just deleted the in-memory entry and nothing woke the agent. Each
5899
+ // expire* helper mirrors the permission-card timeout path (#2411 / #2862) by
5900
+ // routing through the pure `expirePendingCard` core (pending-card-expiry.ts),
5901
+ // whose ordering + fault-isolation contract is behaviorally pinned by
5902
+ // pending-card-expiry.test.ts:
5903
+ // 1. drop the in-memory entry AND its durable store record FIRST (single-
5904
+ // shot β€” a second tick can never double-fire the wake),
5905
+ // 2. edit the card to a βŒ› expired state + strip its keyboard (best-effort),
5906
+ // 3. record it in missedApprovalsStore BEFORE delivering, so a throwing
5907
+ // deliver can't lose the re-offer for the operator's return,
5908
+ // 4. inject a TIMEOUT-outcome synthetic inbound (turn-gated via
5909
+ // deliverResumeSyntheticOrBuffer, guarded β€” a half-dead IPC socket that
5910
+ // throws on write is contained, never escaping the reaper's setInterval
5911
+ // callback into an uncaughtException gateway shutdown).
5912
+ // Called from BOTH the lazy sweeps (on next stage) and the pendingStateReaper
5913
+ // (every 60s β€” the authoritative timer so an idle gateway still wakes agents).
5914
+
5915
+ async function editCardExpired(chatId: string, messageId: number | undefined, body: string): Promise<void> {
5916
+ if (messageId == null) return
5917
+ await lockedBot.api
5918
+ // allow-raw-bot-api: message-id-targeted edit (no thread to lose); best-effort card-expiry strip from the reaper (no grammy ctx). Dropping reply_markup strips the stale keyboard atomically with the text edit.
5919
+ .editMessageText(chatId, messageId, richMessage(body), { reply_markup: { inline_keyboard: [] } })
5920
+ .catch(() => {})
5921
+ }
5922
+
5923
+ function recordMissedApproval(opts: {
5924
+ stageId: string
5925
+ toolName: string
5926
+ action: string
5927
+ chatId: string
5928
+ threadId?: number
5929
+ now: number
5930
+ }): void {
5931
+ if (!MISSED_APPROVAL_REOFFER_ENABLED) return
5932
+ missedApprovalsStore.add({
5933
+ requestId: opts.stageId,
5934
+ toolName: opts.toolName,
5935
+ action: opts.action,
5936
+ chatId: opts.chatId,
5937
+ threadId: opts.threadId ?? null,
5938
+ timedOutAt: opts.now,
5939
+ })
5940
+ }
5941
+
5942
+ const cardExpiryLog = (msg: string): void => {
5943
+ process.stderr.write(`telegram gateway: ${msg}\n`)
5944
+ }
5945
+
5946
+ function expireVaultAccessCard(stageId: string, v: PendingVaultRequestAccess, now: number): void {
5947
+ const timeoutMinutes = Math.round(VAULT_REQUEST_ACCESS_TTL_MS / 60000)
5948
+ const { delivered } = expirePendingCard({
5949
+ remove: () => {
5950
+ pendingVaultRequestAccesses.delete(stageId)
5951
+ pendingCardStore.remove(stageId)
5952
+ },
5953
+ editCard: () => void editCardExpired(
5954
+ v.chat_id,
5955
+ v.card_message_id,
5956
+ `βŒ› _This vault access request for \`${escapeHtmlForTg(v.key)}\` timed out before you tapped. Ask **${escapeHtmlForTg(v.agent)}** to re-request if it still stands._`,
5957
+ ),
5958
+ buildInbound: () => buildVaultAccessTimeoutInbound({
5959
+ agent: v.agent,
5960
+ chatId: v.chat_id,
5961
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
5962
+ stageId,
5963
+ timeoutMinutes,
5964
+ key: v.key,
5965
+ scope: v.scope,
5966
+ }),
5967
+ deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
5968
+ recordMiss: () => recordMissedApproval({
5969
+ stageId,
5970
+ toolName: 'vault_request_access',
5971
+ action: `grant ${v.agent} ${v.scope} access to \`${v.key}\``,
5972
+ chatId: v.chat_id,
5973
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
5974
+ now,
5975
+ }),
5976
+ log: cardExpiryLog,
5977
+ })
5978
+ process.stderr.write(
5979
+ `telegram gateway: vault_request_access TTL expired β€” wake agent=${v.agent} ` +
5980
+ `key=${v.key} stage=${stageId} delivered=${delivered}\n`,
5981
+ )
5982
+ }
5983
+
5984
+ function expireVaultSaveCard(stageId: string, v: PendingVaultRequestSave, now: number): void {
5985
+ const timeoutMinutes = Math.round(VAULT_REQUEST_SAVE_TTL_MS / 60000)
5986
+ const { delivered } = expirePendingCard({
5987
+ remove: () => {
5988
+ pendingVaultRequestSaves.delete(stageId)
5989
+ pendingCardStore.remove(stageId)
5990
+ },
5991
+ editCard: () => void editCardExpired(
5992
+ v.chat_id,
5993
+ v.card_message_id,
5994
+ `βŒ› _This vault-save card for \`${escapeHtmlForTg(v.key)}\` timed out before you tapped. The secret was NOT stored. Ask **${escapeHtmlForTg(v.agent)}** to re-issue if you still want to save._`,
5995
+ ),
5996
+ buildInbound: () => buildVaultSaveTimeoutInbound({
5997
+ agent: v.agent,
5998
+ chatId: v.chat_id,
5999
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
6000
+ stageId,
6001
+ timeoutMinutes,
6002
+ key: v.key,
6003
+ }),
6004
+ deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
6005
+ recordMiss: () => recordMissedApproval({
6006
+ stageId,
6007
+ toolName: 'vault_request_save',
6008
+ action: `save the secret \`${v.key}\` for ${v.agent}`,
6009
+ chatId: v.chat_id,
6010
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
6011
+ now,
6012
+ }),
6013
+ log: cardExpiryLog,
6014
+ })
6015
+ process.stderr.write(
6016
+ `telegram gateway: vault_request_save TTL expired β€” wake agent=${v.agent} ` +
6017
+ `key=${v.key} stage=${stageId} delivered=${delivered}\n`,
6018
+ )
6019
+ }
6020
+
6021
+ function expireSecretRequestCard(stageId: string, v: PendingSecretRequest, now: number): void {
6022
+ const timeoutMinutes = Math.round(PENDING_SECRET_REQUEST_TTL_MS / 60000)
6023
+ const { delivered } = expirePendingCard({
6024
+ remove: () => {
6025
+ pendingSecretRequests.delete(stageId)
6026
+ pendingCardStore.remove(stageId)
6027
+ },
6028
+ editCard: () => void editCardExpired(
6029
+ v.chat_id,
6030
+ v.card_message_id,
6031
+ `βŒ› _This secret-request card for \`${escapeHtmlForTg(v.key)}\` timed out before you provided it. Ask **${escapeHtmlForTg(v.agent)}** to re-request if it still needs the value._`,
6032
+ ),
6033
+ buildInbound: () => buildSecretRequestTimeoutInbound({
6034
+ agent: v.agent,
6035
+ chatId: v.chat_id,
6036
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
6037
+ stageId,
6038
+ timeoutMinutes,
6039
+ key: v.key,
6040
+ }),
6041
+ deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
6042
+ recordMiss: () => recordMissedApproval({
6043
+ stageId,
6044
+ toolName: 'request_secret',
6045
+ action: `provide the secret \`${v.key}\` for ${v.agent}`,
6046
+ chatId: v.chat_id,
6047
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
6048
+ now,
6049
+ }),
6050
+ log: cardExpiryLog,
6051
+ })
6052
+ process.stderr.write(
6053
+ `telegram gateway: request_secret TTL expired β€” wake agent=${v.agent} ` +
6054
+ `key=${v.key} stage=${stageId} delivered=${delivered}\n`,
6055
+ )
6056
+ }
6057
+
6058
+ function expireMentalModelProposeCard(stageId: string, v: PendingMentalModelPropose, now: number): void {
6059
+ const timeoutMinutes = Math.round(MENTAL_MODEL_PROPOSE_TTL_MS / 60000)
6060
+ const { delivered } = expirePendingCard({
6061
+ remove: () => {
6062
+ pendingMentalModelProposes.delete(stageId)
6063
+ pendingCardStore.remove(stageId)
6064
+ },
6065
+ editCard: () => void editCardExpired(
6066
+ v.chat_id,
6067
+ v.card_message_id,
6068
+ `βŒ› _This mental-model proposal card for \`${escapeHtmlForTg(v.spec.name)}\` timed out before you tapped. Ask **${escapeHtmlForTg(v.agent)}** to re-propose if it still stands._`,
6069
+ ),
6070
+ buildInbound: () => buildMentalModelProposeTimeoutInbound({
6071
+ agent: v.agent,
6072
+ chatId: v.chat_id,
6073
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
6074
+ stageId,
6075
+ timeoutMinutes,
6076
+ name: v.spec.name,
6077
+ }),
6078
+ deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
6079
+ recordMiss: () => recordMissedApproval({
6080
+ stageId,
6081
+ toolName: 'mental_model_propose',
6082
+ action: `declare the mental model \`${v.spec.name}\` for ${v.agent}`,
6083
+ chatId: v.chat_id,
6084
+ ...(v.threadId != null ? { threadId: v.threadId } : {}),
6085
+ now,
6086
+ }),
6087
+ log: cardExpiryLog,
6088
+ })
6089
+ process.stderr.write(
6090
+ `telegram gateway: mental_model_propose TTL expired β€” wake agent=${v.agent} ` +
6091
+ `name=${v.spec.name} stage=${stageId} delivered=${delivered}\n`,
6092
+ )
6093
+ }
6094
+
6095
+ // Run all four agent-initiated approval-card expiry sweeps. Called from the
6096
+ // pendingStateReaper (the authoritative 60s timer). Each family sweep is
6097
+ // per-entry guarded via sweepExpiredEntries, so one throwing expiry (dead IPC
6098
+ // socket, store IO error) can't skip the remaining entries or families.
6099
+ function sweepExpiredApprovalCards(now: number): void {
6100
+ sweepPendingVaultRequestAccesses(now)
6101
+ sweepPendingVaultRequestSaves(now)
6102
+ sweepPendingMentalModelProposes(now)
6103
+ sweepSecretRequests(now)
6104
+ }
6105
+
6106
+ // Boot restore: repopulate the four in-memory approval-card maps from the
6107
+ // durable store so a post-restart tap on a still-valid card resolves normally
6108
+ // (approve β†’ grant + synthetic; deny β†’ denial synthetic) instead of hitting
6109
+ // the "Card expired" tombstone. Entries already past their TTL are left for
6110
+ // the reaper's next tick, which wakes the parked agent via the timeout path.
6111
+ // vault_request_save entries restore WITHOUT their staged value (never
6112
+ // persisted) and are flagged `restoredWithoutValue` so a Save tap degrades
6113
+ // gracefully rather than writing an empty secret.
6114
+ function restorePendingApprovalCards(): number {
6115
+ let restored = 0
6116
+ for (const e of pendingCardStore.loadAll()) {
6117
+ try {
6118
+ if (e.family === 'vault_request_access') {
6119
+ pendingVaultRequestAccesses.set(e.stageId, {
6120
+ agent: e.agent,
6121
+ chat_id: e.chatId,
6122
+ ...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
6123
+ ...(e.threadId != null ? { threadId: e.threadId } : {}),
6124
+ key: e.key,
6125
+ scope: e.scope,
6126
+ ...(e.reason != null ? { reason: e.reason } : {}),
6127
+ ttl_seconds: e.ttlSeconds,
6128
+ staged_at: e.stagedAt,
6129
+ })
6130
+ restored++
6131
+ } else if (e.family === 'vault_request_save') {
6132
+ pendingVaultRequestSaves.set(e.stageId, {
6133
+ agent: e.agent,
6134
+ chat_id: e.chatId,
6135
+ ...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
6136
+ ...(e.threadId != null ? { threadId: e.threadId } : {}),
6137
+ key: e.key,
6138
+ kind: e.kind,
6139
+ value: '', // never persisted β€” secrets hygiene
6140
+ ...(e.why != null ? { why: e.why } : {}),
6141
+ staged_at: e.stagedAt,
6142
+ restoredWithoutValue: true,
6143
+ })
6144
+ restored++
6145
+ } else if (e.family === 'request_secret') {
6146
+ pendingSecretRequests.set(e.stageId, {
6147
+ agent: e.agent,
6148
+ chat_id: e.chatId,
6149
+ ...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
6150
+ ...(e.threadId != null ? { threadId: e.threadId } : {}),
6151
+ key: e.key,
6152
+ ...(e.reason != null ? { reason: e.reason } : {}),
6153
+ staged_at: e.stagedAt,
6154
+ })
6155
+ restored++
6156
+ } else if (e.family === 'mental_model_propose') {
6157
+ pendingMentalModelProposes.set(e.stageId, {
6158
+ agent: e.agent,
6159
+ chat_id: e.chatId,
6160
+ ...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
6161
+ ...(e.threadId != null ? { threadId: e.threadId } : {}),
6162
+ spec: e.spec,
6163
+ ...(e.reason != null ? { reason: e.reason } : {}),
6164
+ staged_at: e.stagedAt,
6165
+ })
6166
+ restored++
6167
+ }
6168
+ } catch (err) {
6169
+ process.stderr.write(
6170
+ `telegram gateway: pending-card restore skipped a malformed entry: ${(err as Error).message}\n`,
6171
+ )
6172
+ }
6173
+ }
6174
+ if (restored > 0) {
6175
+ process.stderr.write(
6176
+ `telegram gateway: restored ${restored} pending approval card(s) from prior gateway session\n`,
6177
+ )
6178
+ }
6179
+ return restored
6180
+ }
6181
+
5581
6182
  // 60-second sweep drops anything past its documented TTL.
5582
6183
  const pendingStateReaper = setInterval(() => {
5583
6184
  const now = Date.now()
@@ -5711,6 +6312,23 @@ const pendingStateReaper = setInterval(() => {
5711
6312
  for (const [k, v] of deferredSecrets) {
5712
6313
  if (now - v.staged_at > DEFERRED_SECRET_TTL_MS) deferredSecrets.delete(k)
5713
6314
  }
6315
+ // Agent-initiated approval cards (vault_request_access / vault_request_save /
6316
+ // request_secret / mental_model_propose): expire past-TTL entries and WAKE
6317
+ // the parked agent (timeout synthetic + missed-approvals re-offer). This is
6318
+ // the authoritative timer β€” before this the only expiry path was a lazy
6319
+ // sweep on the NEXT stage, so an agent that ended its turn to wait on one of
6320
+ // these cards could sit parked forever if no further request ever staged.
6321
+ // try/catch matches the sibling sweepStaleTurnActiveMarker guard: an escaped
6322
+ // throw inside this setInterval callback would reach uncaughtException and
6323
+ // take the WHOLE gateway down (per-entry faults are already contained inside
6324
+ // sweepExpiredEntries/expirePendingCard; this is the outer belt).
6325
+ try {
6326
+ sweepExpiredApprovalCards(now)
6327
+ } catch (err) {
6328
+ process.stderr.write(
6329
+ `telegram gateway: approval-card expiry sweep failed: ${(err as Error).message}\n`,
6330
+ )
6331
+ }
5714
6332
  // #550: sweep a stale turn-active marker. Defence-in-depth for the
5715
6333
  // case where neither the turn_end arm nor onTurnComplete fired (SDK
5716
6334
  // killed before the JSONL turn_duration record, compaction window,
@@ -7396,6 +8014,19 @@ function trackRedeliveredInbound(merged: InboundMessage): void {
7396
8014
  ) {
7397
8015
  return
7398
8016
  }
8017
+ // Button-tap anti-storm guard β€” mirrors the immediate-delivery path in
8018
+ // deliverButtonTapInbound. A tap synthesized without a source message
8019
+ // (`cbMessageId == null` β†’ `messageId: 0`, no meta.message_id) has no id
8020
+ // the `enqueue` ack can ever match, so enrolling it would make the
8021
+ // never-drop sweep re-deliver it until TTL. The immediate path skips
8022
+ // tracking for such taps; a tap that buffered mid-turn and flushed through
8023
+ // here must be skipped identically (asymmetry = a storm on one path only).
8024
+ if (
8025
+ merged.meta?.button_callback === 'true' &&
8026
+ (merged.meta.message_id == null || merged.meta.message_id === '')
8027
+ ) {
8028
+ return
8029
+ }
7399
8030
  const key = chatKey(merged.chatId, merged.threadId != null ? Number(merged.threadId) : null)
7400
8031
  trackDelivery(
7401
8032
  deliveryQueue,
@@ -9261,6 +9892,42 @@ const ipcServer: IpcServer = createIpcServer({
9261
9892
  void fireFleetAutoFallback(msg.agentName, untilMs)
9262
9893
  },
9263
9894
 
9895
+ // Issue #2971 β€” read-only wedge-watchdog probe: is there a live pending
9896
+ // permission request (Telegram approval card) for this agent right now?
9897
+ // Sourced directly from `pendingPermissions` β€” no mutation, no card
9898
+ // posting, just a snapshot read. The watchdog uses this to decide whether
9899
+ // to Esc a shape-persistent permission-prompt TUI or defer to the card /
9900
+ // the #2724 TTL reaper. Always answered synchronously and on the SAME
9901
+ // connection so the watchdog's short (~2s) budget can resolve promptly.
9902
+ onQueryPendingPermission(client: IpcClient, msg: QueryPendingPermissionMessage) {
9903
+ const self = process.env.SWITCHROOM_AGENT_NAME
9904
+ if (self && msg.agentName !== self) {
9905
+ process.stderr.write(
9906
+ `telegram gateway: query_pending_permission rejected β€” agent mismatch (${msg.agentName} != ${self})\n`,
9907
+ )
9908
+ try {
9909
+ client.send({ type: 'pending_permission_status', correlationId: msg.correlationId, pending: false })
9910
+ } catch { /* best effort */ }
9911
+ return
9912
+ }
9913
+ // Any LIVE entry answers the question β€” this gateway serves exactly one
9914
+ // agent, so `pendingPermissions` is already scoped to `msg.agentName`.
9915
+ const [requestId] = pendingPermissions.keys()
9916
+ const pending = pendingPermissions.size > 0
9917
+ try {
9918
+ client.send({
9919
+ type: 'pending_permission_status',
9920
+ correlationId: msg.correlationId,
9921
+ pending,
9922
+ ...(pending && requestId ? { requestId } : {}),
9923
+ })
9924
+ } catch (err) {
9925
+ process.stderr.write(
9926
+ `telegram gateway: query_pending_permission reply failed: ${(err as Error).message}\n`,
9927
+ )
9928
+ }
9929
+ },
9930
+
9264
9931
  // #2670 one-tap self-improvement β€” persist a skill-improvement proposal and
9265
9932
  // post its Approve/Dismiss card. The store transition + apply-injection on
9266
9933
  // Approve are owned by handleSkillProposalCallback (so a gateway restart
@@ -11748,6 +12415,21 @@ async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{
11748
12415
  { threadId, chat_id, verb: 'vault_request_save.card' },
11749
12416
  )
11750
12417
  pending.card_message_id = sent.message_id
12418
+ // Persist card METADATA (never the staged `value` β€” secrets hygiene) so a
12419
+ // gateway restart doesn't strand the parked agent. A restored Save tap can't
12420
+ // complete (value is gone) and degrades to a "value lost to restart" wake-up.
12421
+ pendingCardStore.add({
12422
+ family: 'vault_request_save',
12423
+ stageId,
12424
+ agent: pending.agent,
12425
+ chatId: pending.chat_id,
12426
+ ...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
12427
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
12428
+ key: pending.key,
12429
+ kind: pending.kind,
12430
+ ...(pending.why != null ? { why: pending.why } : {}),
12431
+ stagedAt: pending.staged_at,
12432
+ })
11751
12433
 
11752
12434
  return {
11753
12435
  content: [
@@ -11791,11 +12473,17 @@ const armedSecretCaptures = new Map<string, ArmedSecretCapture>()
11791
12473
  const PENDING_SECRET_REQUEST_TTL_MS = 30 * 60_000 // card lifetime
11792
12474
  const ARMED_SECRET_CAPTURE_TTL_MS = 10 * 60_000 // window to send the value after tapping
11793
12475
 
11794
- function sweepSecretRequests(): void {
11795
- const now = Date.now()
11796
- for (const [k, v] of pendingSecretRequests) {
11797
- if (now - v.staged_at > PENDING_SECRET_REQUEST_TTL_MS) pendingSecretRequests.delete(k)
11798
- }
12476
+ function sweepSecretRequests(now = Date.now()): void {
12477
+ sweepExpiredEntries(
12478
+ pendingSecretRequests,
12479
+ (v, n) => n - v.staged_at > PENDING_SECRET_REQUEST_TTL_MS,
12480
+ expireSecretRequestCard,
12481
+ now,
12482
+ cardExpiryLog,
12483
+ )
12484
+ // armedSecretCaptures is a TRANSIENT post-tap window (never persisted): it's
12485
+ // only set after the operator taps [Provide securely], and the request is
12486
+ // no longer parked-on-a-card. Just drop stale ones β€” no wake needed.
11799
12487
  for (const [k, v] of armedSecretCaptures) {
11800
12488
  if (now - v.armed_at > ARMED_SECRET_CAPTURE_TTL_MS) armedSecretCaptures.delete(k)
11801
12489
  }
@@ -11845,7 +12533,10 @@ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ co
11845
12533
  // Dedupe: one open request per (chat, key). Drop any prior stage for
11846
12534
  // the same target so the operator never sees stacked cards.
11847
12535
  for (const [sid, p] of pendingSecretRequests) {
11848
- if (p.chat_id === chat_id && p.key === key) pendingSecretRequests.delete(sid)
12536
+ if (p.chat_id === chat_id && p.key === key) {
12537
+ pendingSecretRequests.delete(sid)
12538
+ pendingCardStore.remove(sid)
12539
+ }
11849
12540
  }
11850
12541
 
11851
12542
  const stageId = randomBytes(4).toString('hex')
@@ -11867,6 +12558,21 @@ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ co
11867
12558
  { threadId, chat_id, verb: 'request_secret.card' },
11868
12559
  )
11869
12560
  pending.card_message_id = sent.message_id
12561
+ // Persist card metadata so a gateway restart doesn't strand the parked
12562
+ // agent. request_secret holds NO value at staging time (the value arrives
12563
+ // after the operator taps [Provide securely]), so nothing sensitive lands
12564
+ // on disk here.
12565
+ pendingCardStore.add({
12566
+ family: 'request_secret',
12567
+ stageId,
12568
+ agent: pending.agent,
12569
+ chatId: pending.chat_id,
12570
+ ...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
12571
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
12572
+ key: pending.key,
12573
+ ...(pending.reason != null ? { reason: pending.reason } : {}),
12574
+ stagedAt: pending.staged_at,
12575
+ })
11870
12576
 
11871
12577
  return {
11872
12578
  content: [
@@ -11915,6 +12621,7 @@ async function captureProvidedSecret(
11915
12621
  armedSecretCaptures.delete(chat_id)
11916
12622
  const pending = pendingSecretRequests.get(armed.stageId)
11917
12623
  pendingSecretRequests.delete(armed.stageId)
12624
+ pendingCardStore.remove(armed.stageId)
11918
12625
 
11919
12626
  // Delete the raw message FIRST β€” surfaces a warning if it fails.
11920
12627
  if (msgId != null) await deleteSensitiveMessage(chat_id, msgId, 'provided secret value')
@@ -12030,6 +12737,7 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
12030
12737
 
12031
12738
  if (action === 'decline') {
12032
12739
  pendingSecretRequests.delete(stageId)
12740
+ pendingCardStore.remove(stageId)
12033
12741
  armedSecretCaptures.delete(pending.chat_id)
12034
12742
  await ctx.answerCallbackQuery({ text: 'Declined.' }).catch(() => {})
12035
12743
  if (pending.card_message_id != null) {
@@ -12197,12 +12905,27 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
12197
12905
  { threadId, chat_id, verb: 'vault_request_access.card' },
12198
12906
  )
12199
12907
  pending.card_message_id = sent.message_id
12908
+ // Persist card metadata (no secret material β€” this flow stages only the ACL
12909
+ // request) so a gateway restart doesn't strand the parked agent.
12910
+ pendingCardStore.add({
12911
+ family: 'vault_request_access',
12912
+ stageId,
12913
+ agent: pending.agent,
12914
+ chatId: pending.chat_id,
12915
+ ...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
12916
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
12917
+ key: pending.key,
12918
+ scope: pending.scope,
12919
+ ...(pending.reason != null ? { reason: pending.reason } : {}),
12920
+ ttlSeconds: pending.ttl_seconds,
12921
+ stagedAt: pending.staged_at,
12922
+ })
12200
12923
 
12201
12924
  return {
12202
12925
  content: [
12203
12926
  {
12204
12927
  type: 'text',
12205
- text: `vault_request_access: card sent (stage_id=${stageId}, key=${key}, scope=${scopeRaw}). Wait for the operator to tap Approve or Deny β€” do not retry the vault read until you see a confirmation message. If the card times out (10 min) you can re-request.`,
12928
+ text: `vault_request_access: card sent (stage_id=${stageId}, key=${key}, scope=${scopeRaw}). Wait for the operator to tap Approve or Deny β€” do not retry the vault read until you see a confirmation message. If the card times out (${Math.round(VAULT_REQUEST_ACCESS_TTL_MS / 60000)} min) you can re-request.`,
12206
12929
  },
12207
12930
  ],
12208
12931
  }
@@ -12366,6 +13089,19 @@ async function executeMentalModelPropose(args: Record<string, unknown>): Promise
12366
13089
  { threadId, chat_id, verb: 'mental_model_propose.card' },
12367
13090
  )
12368
13091
  pending.card_message_id = sent.message_id
13092
+ // Persist card metadata (the proposed DECLARATION is not secret material) so
13093
+ // a gateway restart doesn't strand the parked agent.
13094
+ pendingCardStore.add({
13095
+ family: 'mental_model_propose',
13096
+ stageId,
13097
+ agent: pending.agent,
13098
+ chatId: pending.chat_id,
13099
+ ...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
13100
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
13101
+ spec: pending.spec,
13102
+ ...(pending.reason != null ? { reason: pending.reason } : {}),
13103
+ stagedAt: pending.staged_at,
13104
+ })
12369
13105
  // Only count a proposal against the rate budget once its card actually
12370
13106
  // posted (validation errors / dupes don't consume the budget).
12371
13107
  mentalModelProposeTimes.push(Date.now())
@@ -12771,6 +13507,7 @@ function composeTurnActivity(turn: CurrentTurn, final = false, liveSuffix = ''):
12771
13507
  elapsedMs: turn.startedAt > 0 ? Date.now() - turn.startedAt : 0,
12772
13508
  toolCount: turn.labeledToolCount,
12773
13509
  state: final ? 'done' : 'running',
13510
+ model: turn.currentModel,
12774
13511
  }
12775
13512
  return renderActivityFeedWithNested(turn.mirrorLines, childLines, final, liveSuffix, stepCount, header)
12776
13513
  }
@@ -13088,6 +13825,7 @@ function openLivenessFeedIfDue(turn: CurrentTurn): void {
13088
13825
  const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ['Working…']
13089
13826
  const livenessHeader: SessionActivityHeader = {
13090
13827
  label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
13828
+ model: turn.currentModel,
13091
13829
  }
13092
13830
  // Liveness card is a single "step" whose start is the turn start, so `age`
13093
13831
  // IS the step's own elapsed. formatStepSuffix keeps the `β†’` line timer-free
@@ -13218,6 +13956,7 @@ function feedHeartbeatTick(): void {
13218
13956
  const age = Date.now() - turn.startedAt
13219
13957
  const livenessHeader: SessionActivityHeader = {
13220
13958
  label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
13959
+ model: turn.currentModel,
13221
13960
  }
13222
13961
  const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ['Working in background…']
13223
13962
  // `subagentAt` is the worker's last ADVANCE β€” the current step's start β€”
@@ -13407,6 +14146,7 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
13407
14146
  const livenessElapsed = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0
13408
14147
  const livenessHeader: SessionActivityHeader = {
13409
14148
  label: 'Agent', elapsedMs: livenessElapsed, toolCount: turn.labeledToolCount, state: 'done',
14149
+ model: turn.currentModel,
13410
14150
  }
13411
14151
  finalHtml = renderActivityFeedWithNested(['Working…'], [], true, '', undefined, livenessHeader)
13412
14152
  }
@@ -13897,6 +14637,22 @@ function handleSessionEvent(ev: SessionEvent): void {
13897
14637
  return
13898
14638
  }
13899
14639
  case 'dequeue': return
14640
+ case 'model': {
14641
+ // Live model capture for the main turn. The session-tail projection
14642
+ // already filtered sentinels (`<synthetic>` compaction lines), so any
14643
+ // value reaching here is a real resolved model id. Record it on the turn
14644
+ // (update-on-change) so the activity/liveness card header and /status
14645
+ // render the model actually serving this turn's API calls β€” transcript-
14646
+ // sourced, never config. Also note it on the freshness-aware session-model
14647
+ // source so a /status query between turns still reflects the last model
14648
+ // (and a fresh assistant line reclaims the source from a /model override).
14649
+ const turn = currentTurn
14650
+ if (turn != null) {
14651
+ turn.currentModel = ev.model
14652
+ }
14653
+ sessionModelSource.noteTranscriptModel(ev.model)
14654
+ return
14655
+ }
13900
14656
  case 'thinking': {
13901
14657
  // #1067: snapshot the turn atom at handler entry. Even though this
13902
14658
  // handler is sync, the principle is uniform across all event arms
@@ -18463,13 +19219,12 @@ function buildAgentAudit(agentName: string): AgentAudit | undefined {
18463
19219
  // broker's fleet-wide `ListStateData` payload via
18464
19220
  // `buildAuthSummaryFromBroker`, with billingType pulled from the
18465
19221
  // agent's `.claude.json` (the broker doesn't track plan tier).
18466
- /**
18467
- * Live session-model override set by the `/model` picker (session-only). Held
18468
- * in gateway memory so it clears on restart, the same point at which claude's
18469
- * session reverts to the configured model β€” keeping `/status` honest without
18470
- * a persisted store. Null when no session switch is active.
18471
- */
18472
- let activeSessionModelOverride: string | null = null
19222
+ // The live session-model override set by the `/model` picker (session-only)
19223
+ // lives on `sessionModelSource` (setOverride/getOverride, declared beside the
19224
+ // currentTurn globals). Held in gateway memory so it clears on restart, the
19225
+ // same point at which claude's session reverts to the configured model β€”
19226
+ // keeping `/status` honest without a persisted store. `resolve()` arbitrates
19227
+ // freshness against the transcript-observed model (#2982 idle-switch window).
18473
19228
 
18474
19229
  async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
18475
19230
  type AgentListResp = {
@@ -18499,7 +19254,18 @@ async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
18499
19254
  return {
18500
19255
  agentName,
18501
19256
  model: a?.model ?? null,
18502
- sessionModel: activeSessionModelOverride,
19257
+ // The FRESHEST session-model observation wins (session-model-source.ts):
19258
+ // the transcript's `message.model` (ground truth for the last API call)
19259
+ // vs the #2982 /model override (the only truthful source in the idle-
19260
+ // after-switch window, before the next assistant line). Both rendered
19261
+ // through formatModelLabel for a consistent short form; an override value
19262
+ // that isn't model-shaped (an already-friendly "Opus 4.8" label) passes
19263
+ // through verbatim. Never sourced from config.
19264
+ sessionModel: (() => {
19265
+ const resolved = sessionModelSource.resolve()
19266
+ if (resolved == null) return null
19267
+ return formatModelLabel(resolved.model) ?? resolved.model
19268
+ })(),
18503
19269
  extendsProfile: (a?.extends ?? a?.template) ?? null,
18504
19270
  topicName: a?.topic_name ?? null,
18505
19271
  topicEmoji: a?.topic_emoji ?? null,
@@ -18730,7 +19496,7 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
18730
19496
  },
18731
19497
  escapeHtml: escapeHtmlForTg,
18732
19498
  preBlock,
18733
- getActiveSessionModel: () => activeSessionModelOverride,
19499
+ getActiveSessionModel: () => sessionModelSource.getOverride(),
18734
19500
  /**
18735
19501
  * Graceful restart for sr-* β†’ Claude model switch. Same mechanism as
18736
19502
  * the /restart command: writes a restart marker (so the post-restart
@@ -18740,9 +19506,18 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
18740
19506
  scheduleRestart: async (reason: string) => {
18741
19507
  const name = getMyAgentName()
18742
19508
  // Debounce: mirror the /restart command's 15 s guard to prevent
18743
- // double-dispatch on a rapid double-tap of /model <claude-alias>.
19509
+ // double-dispatch on a rapid double-tap of /model <claude-alias>. This
19510
+ // path previously `return`ed silently β€” indistinguishable from a
19511
+ // successful dispatch β€” so a model switch caught in the window was a
19512
+ // silent no-op while the caller reported success. THROW a tagged error
19513
+ // so scheduleModelRelaunch can react (keep the in-flight restart's carrier,
19514
+ // tell the operator honestly) instead of falsely claiming the switch stuck.
18744
19515
  const existing = readRestartMarker()
18745
- if (existing && Date.now() - existing.ts < 15_000) return
19516
+ if (existing && Date.now() - existing.ts < 15_000) {
19517
+ const e = new Error('a restart is already in flight β€” try again in ~15s')
19518
+ ;(e as { code?: string }).code = 'restart_in_flight'
19519
+ throw e
19520
+ }
18746
19521
  if (restartCtx) {
18747
19522
  writeRestartMarker({
18748
19523
  chat_id: restartCtx.chatId,
@@ -18792,9 +19567,25 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
18792
19567
  if (!agentDir) throw new Error('agent dir unresolvable β€” cannot write session-model carrier')
18793
19568
  // Carrier: single line, token + newline, no quoting (start.sh strips
18794
19569
  // whitespace and shape-gates). One-shot β€” consumed on the next boot.
19570
+ const prevOverride = sessionModelSource.getOverride()
18795
19571
  writeFileSync(join(agentDir, '.session-model-override'), `${model}\n`, 'utf8')
18796
- activeSessionModelOverride = model
18797
- await deps.scheduleRestart(reason)
19572
+ sessionModelSource.setOverride(model)
19573
+ try {
19574
+ await deps.scheduleRestart(reason)
19575
+ } catch (err) {
19576
+ const carrierPath = join(agentDir, '.session-model-override')
19577
+ // A restart already in flight OWNS the carrier we just wrote β€” it will
19578
+ // consume our token at boot, so the switch is queued, not lost: keep the
19579
+ // carrier + override and let the caller tell the operator "~15s". Any
19580
+ // OTHER dispatch failure means no restart is coming, so roll BOTH back β€”
19581
+ // a lingering carrier/override would lie to /status and mis-launch the
19582
+ // NEXT ordinary restart.
19583
+ if ((err as { code?: string })?.code !== 'restart_in_flight') {
19584
+ try { rmSync(carrierPath, { force: true }) } catch { /* best-effort */ }
19585
+ sessionModelSource.setOverride(prevOverride)
19586
+ }
19587
+ throw err
19588
+ }
18798
19589
  },
18799
19590
  }
18800
19591
  return deps
@@ -18823,6 +19614,15 @@ bot.command('model', async ctx => {
18823
19614
  return
18824
19615
  }
18825
19616
  const reply = await handleModelCommand(parsed, deps)
19617
+ // Record a POSITIVELY-CONFIRMED typed switch so /status reflects what's
19618
+ // actually running β€” the SAME in-memory override the menu callback path sets
19619
+ // (buildAgentMetadata resolves it via sessionModelSource). Only
19620
+ // set on the confirmed inject path; the sr-*/relaunch paths already set the
19621
+ // override inside scheduleModelRelaunch, and an unverified switch carries no
19622
+ // selectedModel so /status is never lied to.
19623
+ if (reply.selectedModel) {
19624
+ sessionModelSource.setOverride(reply.selectedModel)
19625
+ }
18826
19626
  await switchroomReply(ctx, reply.text, { html: reply.html })
18827
19627
  })
18828
19628
 
@@ -21118,6 +21918,7 @@ async function performVaultAccessApproval(
21118
21918
  const visible = await listViaBroker()
21119
21919
  if (visible !== null && visible.includes(pending.key)) {
21120
21920
  pendingVaultRequestAccesses.delete(stageId)
21921
+ pendingCardStore.remove(stageId)
21121
21922
  if (pending.card_message_id != null) {
21122
21923
  await ctx.api
21123
21924
  .editMessageText(
@@ -21216,6 +22017,7 @@ async function performVaultAccessApproval(
21216
22017
  // the agent to re-issue, or the broker error message will tell
21217
22018
  // them the next step.
21218
22019
  pendingVaultRequestAccesses.delete(stageId)
22020
+ pendingCardStore.remove(stageId)
21219
22021
  if (pending.card_message_id != null) {
21220
22022
  await ctx.api
21221
22023
  .editMessageText(
@@ -21247,6 +22049,7 @@ async function performVaultAccessApproval(
21247
22049
  }
21248
22050
 
21249
22051
  pendingVaultRequestAccesses.delete(stageId)
22052
+ pendingCardStore.remove(stageId)
21250
22053
  if (pending.card_message_id != null) {
21251
22054
  const days = Math.round(pending.ttl_seconds / 86400)
21252
22055
  const footer =
@@ -21455,23 +22258,17 @@ async function handleMentalModelProposeCallback(ctx: Context, data: string): Pro
21455
22258
  // this, a card left untapped past its TTL is still resolvable if no fresh
21456
22259
  // proposal has run the sweep β€” an operator could approve a stale proposal.
21457
22260
  if (Date.now() - pending.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS) {
21458
- pendingMentalModelProposes.delete(stageId)
21459
- await ctx.answerCallbackQuery({ text: 'Card expired β€” ask the agent to re-propose.' }).catch(() => {})
21460
- if (pending.card_message_id != null) {
21461
- await ctx.api
21462
- .editMessageText(
21463
- pending.chat_id,
21464
- pending.card_message_id,
21465
- richMessage('βŒ› _This mental-model proposal card expired before you tapped. Ask the agent to re-propose if it still stands._'),
21466
- { reply_markup: { inline_keyboard: [] } },
21467
- )
21468
- .catch(() => {})
21469
- }
22261
+ // Expired between post and tap: route through the shared expiry path so the
22262
+ // parked agent is WOKEN (timeout synthetic + missed-approvals re-offer) and
22263
+ // the durable store entry is cleared β€” not just a silent map delete.
22264
+ expireMentalModelProposeCard(stageId, pending, Date.now())
22265
+ await ctx.answerCallbackQuery({ text: 'Card expired β€” the agent was notified.' }).catch(() => {})
21470
22266
  return
21471
22267
  }
21472
22268
  // Single-shot: remove the pending entry immediately so a double-tap can't
21473
22269
  // resolve twice.
21474
22270
  pendingMentalModelProposes.delete(stageId)
22271
+ pendingCardStore.remove(stageId)
21475
22272
 
21476
22273
  const proposal: MentalModelPendingProposal = {
21477
22274
  agent: pending.agent,
@@ -21613,6 +22410,7 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
21613
22410
 
21614
22411
  if (action === 'deny') {
21615
22412
  pendingVaultRequestAccesses.delete(stageId)
22413
+ pendingCardStore.remove(stageId)
21616
22414
  await ctx.answerCallbackQuery({ text: '🚫 Denied' }).catch(() => {})
21617
22415
  if (pending.card_message_id != null) {
21618
22416
  await ctx.api
@@ -21835,6 +22633,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
21835
22633
 
21836
22634
  if (action === 'discard') {
21837
22635
  pendingVaultRequestSaves.delete(stageId)
22636
+ pendingCardStore.remove(stageId)
21838
22637
  await ctx.answerCallbackQuery({ text: '🚫 Discarded' }).catch(() => {})
21839
22638
  if (pending.card_message_id != null) {
21840
22639
  await ctx.api
@@ -21905,6 +22704,43 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
21905
22704
  // stale "spinning" state on the button while we run the write.
21906
22705
  await ctx.answerCallbackQuery({ text: '⏳ Saving…' }).catch(() => {})
21907
22706
 
22707
+ // Restored-after-restart guard: the staged secret VALUE is held in gateway
22708
+ // memory only and is never persisted (secrets hygiene). If this card was
22709
+ // restored from disk after a gateway restart, the value is gone β€” we CANNOT
22710
+ // complete the write. Degrade gracefully: strip the card, wake the agent
22711
+ // with a save-failed (value-lost) synthetic so it re-requests, and stop.
22712
+ if (pending.restoredWithoutValue || pending.value.length === 0) {
22713
+ pendingVaultRequestSaves.delete(stageId)
22714
+ pendingCardStore.remove(stageId)
22715
+ if (pending.card_message_id != null) {
22716
+ await ctx.api
22717
+ .editMessageText(
22718
+ pending.chat_id,
22719
+ pending.card_message_id,
22720
+ richMessage(`⚠️ _The staged value for \`${escapeHtmlForTg(pending.key)}\` was lost to a gateway restart β€” nothing was saved. Ask **${escapeHtmlForTg(pending.agent)}** to re-issue \`vault_request_save\` if you still want to store it._`),
22721
+ { reply_markup: { inline_keyboard: [] } },
22722
+ )
22723
+ .catch(() => {})
22724
+ }
22725
+ const lostInbound = buildVaultSaveFailedInbound({
22726
+ ctx: {
22727
+ agent: pending.agent,
22728
+ key: pending.key,
22729
+ chat_id: pending.chat_id,
22730
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
22731
+ },
22732
+ stageId,
22733
+ operatorId: senderId,
22734
+ reason: 'staged value lost to a gateway restart β€” re-request the save',
22735
+ })
22736
+ const lDelivered = deliverResumeSyntheticOrBuffer(pending.agent, lostInbound)
22737
+ process.stderr.write(
22738
+ `telegram gateway: vault_request_save value lost to restart β€” wake agent=${pending.agent} ` +
22739
+ `key=${pending.key} stage=${stageId} delivered=${lDelivered}\n`,
22740
+ )
22741
+ return
22742
+ }
22743
+
21908
22744
  // #1115 follow-up: the save-approve flow now mirrors the access-
21909
22745
  // approve flow under telegram-id mode β€” broker `put` accepts
21910
22746
  // `attest_via_posture: true` (server.ts:1448-1500), so the
@@ -21940,6 +22776,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
21940
22776
  .catch(() => {})
21941
22777
  }
21942
22778
  pendingVaultRequestSaves.delete(stageId)
22779
+ pendingCardStore.remove(stageId)
21943
22780
  return
21944
22781
  }
21945
22782
  // defaultVaultWrite spawns `switchroom vault set <key>` with the
@@ -21971,6 +22808,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
21971
22808
  // retry by re-invoking the same MCP tool, but the value will be
21972
22809
  // re-staged with a new ID. Drop the current stage.
21973
22810
  pendingVaultRequestSaves.delete(stageId)
22811
+ pendingCardStore.remove(stageId)
21974
22812
  // Wake the waiting agent with the failure (symmetric with the
21975
22813
  // success/discard paths) so it doesn't assume vault:<key> exists.
21976
22814
  const failReason =
@@ -21996,6 +22834,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
21996
22834
 
21997
22835
  // Success β€” mask the value in the card for visual confirmation.
21998
22836
  pendingVaultRequestSaves.delete(stageId)
22837
+ pendingCardStore.remove(stageId)
21999
22838
  if (pending.card_message_id != null) {
22000
22839
  await ctx.api
22001
22840
  .editMessageText(
@@ -23921,13 +24760,13 @@ bot.on('callback_query:data', async ctx => {
23921
24760
  }
23922
24761
  const didInterimSrEdit = false
23923
24762
  try {
23924
- const prevSessionModel = activeSessionModelOverride
24763
+ const prevSessionModel = sessionModelSource.getOverride()
23925
24764
  const outcome = await handleModelMenuCallback(data, modelDeps)
23926
24765
  // Record a successful session switch so /status reflects what's
23927
24766
  // actually running. In-memory only β†’ clears when the gateway (and thus
23928
24767
  // claude's session) restarts, exactly matching the session-only scope.
23929
24768
  if (outcome.selectedModel) {
23930
- activeSessionModelOverride = outcome.selectedModel
24769
+ sessionModelSource.setOverride(outcome.selectedModel)
23931
24770
  }
23932
24771
  // toastOnly: leave the menu untouched β€” but only if we haven't already
23933
24772
  // cleared its buttons with the interim sr-* edit. If we have, fall
@@ -23949,6 +24788,25 @@ bot.on('callback_query:data', async ctx => {
23949
24788
  { reply_markup: { inline_keyboard: [] } },
23950
24789
  )
23951
24790
  .catch(() => {})
24791
+ // Carry the requested Claude model across the restart via the SAME
24792
+ // `.session-model-override` carrier a Claude β†’ sr-* switch uses β€” otherwise
24793
+ // boot launches the CONFIGURED default and the tapped model is silently
24794
+ // dropped. `selectedModelToken` is a real `claude --model` token (alias or
24795
+ // full claude-* id); a "Default"-row tap yields no token β†’ boot the
24796
+ // configured default (correct). start.sh's LiteLLM-down guard only drops
24797
+ // sr-* overrides, so a Claude token is never dropped.
24798
+ {
24799
+ const agentDir = resolveAgentDirFromEnv()
24800
+ const token = outcome.selectedModelToken
24801
+ if (agentDir && token) {
24802
+ try {
24803
+ writeFileSync(join(agentDir, '.session-model-override'), `${token}\n`, 'utf8')
24804
+ sessionModelSource.setOverride(token)
24805
+ } catch (e) {
24806
+ process.stderr.write(`telegram gateway: sr-to-claude carrier write failed: ${(e as Error)?.message ?? String(e)}\n`)
24807
+ }
24808
+ }
24809
+ }
23952
24810
  // Write the restart marker so the post-restart boot card edits into this chat.
23953
24811
  writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() })
23954
24812
  stampUserRestartReason('user: sr-to-claude model switch (menu)')
@@ -24490,17 +25348,17 @@ bot.on('callback_query:data', async ctx => {
24490
25348
  process.stderr.write(
24491
25349
  `telegram gateway: button_callback chatId=${cbChatId} user=${ctx.from.id} data=${JSON.stringify(agentCb.raw)} btnText=${JSON.stringify(buttonText ?? null)}\n`,
24492
25350
  )
24493
- // Registered-keyed delivery + buffer-on-miss (same fix as the
24494
- // normal-inbound path above): broadcast()/clientCount() lost the
24495
- // tap whenever the bridge was mid-reconnect (clientCount() counts
24496
- // unregistered sockets, so the notice was suppressed AND nothing
24497
- // was actually queued). sendToAgent β†’ pendingInboundBuffer (drained
24498
- // by onClientRegistered) makes the "queued" promise real.
25351
+ // #271 turn-safety: route the tap through the SAME turn-safe delivery
25352
+ // machinery as a normal inbound (deliverButtonTapInbound) instead of a raw
25353
+ // sendToAgent. A tap landing mid-turn now buffers until idle (never strands
25354
+ // in the composer, #1556), a delivered tap is composer-cleared first and
25355
+ // tracked so the redelivery sweep rescues a strand, and a bridge-offline tap
25356
+ // still spools + shows the restart notice below (unchanged UX). The old raw
25357
+ // path (sendToAgent β†’ pendingInboundBuffer, drained by onClientRegistered)
25358
+ // fixed only the bridge-mid-reconnect drop; it never gated on turn state.
24499
25359
  const selfAgentBtn = process.env.SWITCHROOM_AGENT_NAME ?? ''
24500
- const btnDelivered = ipcServer.sendToAgent(selfAgentBtn, inboundMsg)
24501
- if (btnDelivered) markClaudeBusyForInbound(inboundMsg)
24502
- if (!btnDelivered) {
24503
- pendingInboundBuffer.push(selfAgentBtn, inboundMsg)
25360
+ const btnOutcome = await deliverButtonTapInbound(selfAgentBtn, inboundMsg)
25361
+ if (btnOutcome === 'buffered-bridge-offline') {
24504
25362
  // No registered bridge β€” the agent's mid-restart. Tell the user
24505
25363
  // so they don't think the button silently swallowed their tap;
24506
25364
  // the tap is genuinely buffered now and replays on reconnect.
@@ -24729,6 +25587,36 @@ bot.on('callback_query:data', async ctx => {
24729
25587
  process.stderr.write(
24730
25588
  `telegram gateway: always-allow hostd FAILED: ${failReason} (request_id=${request_id})\n`,
24731
25589
  )
25590
+ // #2973 pt.2 β€” enqueue for the durable retry queue UNLESS the
25591
+ // failure is non-retryable (config edits locked β€” retrying
25592
+ // won't help until the operator flips the flag; that case
25593
+ // keeps today's honest "did NOT save" card only). Everything
25594
+ // else (stale config view, transient hostd error, rate limit)
25595
+ // gets picked up by the boot/periodic drain instead of quietly
25596
+ // requiring the operator to notice and re-tap.
25597
+ if (!editLockHint) {
25598
+ try {
25599
+ await alwaysAllowPersistQueue.enqueue({
25600
+ agentName,
25601
+ rule: chosen.rule,
25602
+ grantPhrase,
25603
+ chatId: ctx.chat?.id != null ? String(ctx.chat.id) : undefined,
25604
+ threadId: (ctx.callbackQuery?.message as { message_thread_id?: number } | undefined)?.message_thread_id,
25605
+ error: failReason,
25606
+ })
25607
+ } catch (enqueueErr) {
25608
+ // The retry queue's own write failed (disk full, perms, …) β€”
25609
+ // don't pretend this landed. Fold it into the operator-facing
25610
+ // failReason so the "did NOT save" card is honest about the
25611
+ // retry mechanism ALSO having failed, not just the original
25612
+ // dispatch (#2973 adversarial review pt.2).
25613
+ const enqueueMsg = (enqueueErr as Error).message
25614
+ process.stderr.write(
25615
+ `telegram gateway: always-allow enqueue for retry FAILED: ${enqueueMsg} (request_id=${request_id})\n`,
25616
+ )
25617
+ failReason = `${failReason} (retry queue also failed to persist: ${enqueueMsg})`
25618
+ }
25619
+ }
24732
25620
  }
24733
25621
  }
24734
25622
 
@@ -26619,6 +27507,12 @@ void (async () => {
26619
27507
  }
26620
27508
  }
26621
27509
 
27510
+ // #2973 pt.2 β€” drain any always-allow persists left queued by a
27511
+ // prior gateway process (e.g. one that restarted mid-persist),
27512
+ // then keep draining periodically for the rest of this process's
27513
+ // lifetime.
27514
+ scheduleAlwaysAllowPersistDrain()
27515
+
26622
27516
  void registerSwitchroomBotCommands().catch(() => {})
26623
27517
 
26624
27518
  // #613 fix: pre-warm the chatAvailableReactions cache for every
@@ -26732,6 +27626,18 @@ void (async () => {
26732
27626
  }
26733
27627
  }
26734
27628
 
27629
+ // Restore the four agent-initiated approval-card families from the
27630
+ // durable store so a post-restart tap on a still-valid card resolves
27631
+ // normally instead of hitting the "Card expired" tombstone (and an
27632
+ // already-expired entry gets woken by the reaper's next tick).
27633
+ try {
27634
+ restorePendingApprovalCards()
27635
+ } catch (err) {
27636
+ process.stderr.write(
27637
+ `telegram gateway: pending approval-card restore failed: ${(err as Error).message}\n`,
27638
+ )
27639
+ }
27640
+
26735
27641
  // Boot-time pin sweep
26736
27642
  try {
26737
27643
  const bootAccess = loadAccess()
@@ -26961,8 +27867,9 @@ void (async () => {
26961
27867
  // a phantom session override.
26962
27868
  return resolveMainModel(raw ?? undefined)
26963
27869
  })()
26964
- activeSessionModelOverride =
26965
- launched.length > 0 && launched !== configured ? launched : null
27870
+ sessionModelSource.setOverride(
27871
+ launched.length > 0 && launched !== configured ? launched : null,
27872
+ )
26966
27873
  } catch { /* leave override as-is on a bad read */ }
26967
27874
  }
26968
27875
 
@@ -27276,7 +28183,7 @@ void (async () => {
27276
28183
  // Gated to background completions: foreground sub-agents
27277
28184
  // need nothing here, and 'orphan' is a stale historical-at-
27278
28185
  // boot row, not a fresh completion the user is waiting on.
27279
- onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs }) => {
28186
+ onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs, background: entryBackground }) => {
27280
28187
  // Reaction promotion: if the parent turn already ended
27281
28188
  // with this (or another) worker still running, its πŸ‘ was
27282
28189
  // deferred (held on ✍️/⚑). Now that a worker finished,
@@ -27307,13 +28214,32 @@ void (async () => {
27307
28214
  // (worker-feed-dispatch.ts, pinned by its test). Best-effort:
27308
28215
  // a DB hiccup keeps the watcher's generic label rather than
27309
28216
  // throwing out of the terminal handler.
27310
- let dispatch: WorkerFeedDispatch = resolveWorkerFeedDispatch(null, description)
28217
+ let dispatch: WorkerFeedDispatch = resolveWorkerFeedDispatch(null, description, entryBackground)
27311
28218
  if (turnsDb != null) {
27312
28219
  try {
27313
- dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description)
28220
+ dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description, entryBackground)
27314
28221
  } catch { /* best-effort */ }
27315
28222
  }
27316
- const isBackground = dispatch.isBackground
28223
+ let isBackground = dispatch.isBackground
28224
+ // Fix #1(+#2): the registry row never linked AND the watcher
28225
+ // entry's own cached background flag was never observed
28226
+ // either (both `resolveWorkerFeedDispatch` fallbacks came up
28227
+ // empty) β€” this is the "DB row is unlinked" bug's worst
28228
+ // case. A finished worker with actual narrative result text
28229
+ // is far more likely a dropped background handback than a
28230
+ // legitimate foreground no-op (a foreground sub-agent's
28231
+ // result returns inline as the Task tool result β€” the
28232
+ // gateway wouldn't otherwise need to route anything here).
28233
+ // Degrade to background so the result is delivered instead
28234
+ // of silently lost. Idempotency: this only flips the
28235
+ // dispatch classification for THIS single onFinish call β€”
28236
+ // all the existing dedup/idempotency guards below
28237
+ // (decideSubagentHandback's spool key, completionNotified,
28238
+ // etc.) still apply unchanged, so this cannot cause a
28239
+ // double-handback.
28240
+ if (!dispatch.hasRow && entryBackground == null && resultText.trim().length > 0) {
28241
+ isBackground = true
28242
+ }
27317
28243
  // NESTED (depth-2+) worker terminal: its live status surfaced
27318
28244
  // via the worker feed (see onProgress), so finalize that card
27319
28245
  // cleanly β€” never leave it frozen mid-"β†’ step". But NO user
@@ -27330,6 +28256,10 @@ void (async () => {
27330
28256
  latestSummary: resultText,
27331
28257
  elapsedMs: durationMs,
27332
28258
  state: outcome === 'failed' ? 'failed' : 'done',
28259
+ // Persisted (registry) model β€” the last one the watcher
28260
+ // recorded from the worker's transcript β€” so the terminal
28261
+ // card keeps the model tag even with no live entry.
28262
+ model: dispatch.feedModel ?? undefined,
27333
28263
  })
27334
28264
  reconcileWorkerPin(agentId, null, false)
27335
28265
  }
@@ -27407,6 +28337,7 @@ void (async () => {
27407
28337
  latestSummary: resultText,
27408
28338
  elapsedMs: durationMs,
27409
28339
  state: outcome === 'failed' ? 'failed' : 'done',
28340
+ model: dispatch.feedModel ?? undefined,
27410
28341
  })
27411
28342
  // Status-pin: worker done β€” drop its pin.
27412
28343
  reconcileWorkerPin(agentId, null, false)
@@ -27426,6 +28357,7 @@ void (async () => {
27426
28357
  latestSummary: resultText,
27427
28358
  elapsedMs: durationMs,
27428
28359
  state: outcome === 'failed' ? 'failed' : 'done',
28360
+ model: dispatch.feedModel ?? undefined,
27429
28361
  })
27430
28362
  // Status-pin: worker done β€” drop its pin.
27431
28363
  reconcileWorkerPin(agentId, null, false)
@@ -27512,7 +28444,7 @@ void (async () => {
27512
28444
  // suppresses stale-after-restart delivery (a 4-h-old
27513
28445
  // "still working (5m)" would be a lie). Sweep on handback
27514
28446
  // lives in the `onFinish` block just above.
27515
- onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine }) => {
28447
+ onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model }) => {
27516
28448
  let fleetChatId = ''
27517
28449
  try {
27518
28450
  const fleets = progressDriver?.peekAllFleets() ?? []
@@ -27546,6 +28478,13 @@ void (async () => {
27546
28478
  // never grew past "starting…" (the frozen-card symptom). The
27547
28479
  // foreground nest path below already used this precedence.
27548
28480
  const stepLine = (progressLine != null && progressLine.length > 0) ? progressLine : latestSummary
28481
+ // Live model for the worker card: prefer the transcript-sourced
28482
+ // model on the entry (threaded via onProgress) and fall back to
28483
+ // the dispatch-time model persisted on the registry row
28484
+ // (tool_input.model) until the worker's first assistant line
28485
+ // lands. Undefined when neither is known β€” the card omits it,
28486
+ // never guessing from config.
28487
+ const feedModel = model ?? dispatch.feedModel ?? undefined
27549
28488
  if (!isBackground) {
27550
28489
  // Model A β€” a foreground sub-agent runs inside the parent's
27551
28490
  // turn, so its live narrative nests under the parent's
@@ -27585,6 +28524,7 @@ void (async () => {
27585
28524
  latestSummary: stepLine,
27586
28525
  elapsedMs,
27587
28526
  state: 'running',
28527
+ model: feedModel,
27588
28528
  },
27589
28529
  wk.threadId,
27590
28530
  )?.then(() => reconcileWorkerPin(agentId, wkChat, true))
@@ -27726,6 +28666,7 @@ void (async () => {
27726
28666
  latestSummary: stepLine,
27727
28667
  elapsedMs,
27728
28668
  state: 'running',
28669
+ model: feedModel,
27729
28670
  },
27730
28671
  wk.threadId,
27731
28672
  )?.then(() => reconcileWorkerPin(agentId, wkChat, true))