switchroom 0.18.3 → 0.18.6

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 (101) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +2 -1
  3. package/dist/cli/notion-write-pretool.mjs +2 -1
  4. package/dist/cli/switchroom.js +119 -74
  5. package/dist/host-control/main.js +3 -2
  6. package/dist/vault/approvals/kernel-server.js +2 -1
  7. package/dist/vault/broker/server.js +37 -8
  8. package/package.json +1 -1
  9. package/skills/switchroom-release/SKILL.md +78 -0
  10. package/telegram-plugin/auth-snapshot-format.ts +15 -1
  11. package/telegram-plugin/dist/gateway/gateway.js +922 -459
  12. package/telegram-plugin/gateway/gateway.ts +260 -21
  13. package/telegram-plugin/gateway/resolve-person.ts +304 -0
  14. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
  15. package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
  16. package/telegram-plugin/operator-events.ts +21 -0
  17. package/telegram-plugin/pending-work-progress.ts +42 -7
  18. package/telegram-plugin/quota-bar-format.ts +360 -0
  19. package/telegram-plugin/subagent-watcher.ts +27 -9
  20. package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
  21. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  22. package/telegram-plugin/tests/operator-events.test.ts +16 -0
  23. package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
  24. package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
  25. package/telegram-plugin/tests/resolve-person.test.ts +290 -0
  26. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
  27. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
  28. package/telegram-plugin/tests/subagent-watcher.test.ts +61 -0
  29. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
  30. package/telegram-plugin/tests/worker-activity-feed.test.ts +97 -0
  31. package/telegram-plugin/worker-activity-feed.ts +220 -15
  32. package/profiles/default/CLAUDE.md +0 -116
  33. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  34. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  35. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  36. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  37. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  38. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  39. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  40. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  41. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  42. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  43. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  44. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  45. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  46. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  47. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  48. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  49. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  50. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  51. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  52. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  53. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  54. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  55. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  56. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  57. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  58. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  59. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  60. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  61. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  62. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  63. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  64. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  65. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  66. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  67. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  68. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  69. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  70. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  71. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  72. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  73. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  74. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  75. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  76. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  77. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  78. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  79. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  80. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  81. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  82. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  84. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  85. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  87. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  89. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  91. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  93. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  95. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  97. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  98. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  99. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  101. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
@@ -185,6 +185,12 @@ import {
185
185
  import { clearStaleTelegramPollingState } from '../startup-reset.js'
186
186
  import { gatewayStartupRetry } from './startup-network-retry.js'
187
187
  import { writeQuarantineMarker } from './quarantine.js'
188
+ import {
189
+ runPersonDirectoryBootCheck,
190
+ safeResolvePersonName,
191
+ type PersonDirectory,
192
+ type RawPersonEntry,
193
+ } from './resolve-person.js'
188
194
  // RFC H §7.3: auth-dashboard + auth-slot-parser deleted. Three chat
189
195
  // verbs (/auth show | use | rotate) talk to switchroom-auth-broker
190
196
  // via the thin client in src/auth/broker/client.ts.
@@ -721,6 +727,12 @@ const ACCESS_FILE = join(STATE_DIR, 'access.json')
721
727
  const APPROVED_DIR = join(STATE_DIR, 'approved')
722
728
  const ENV_FILE = join(STATE_DIR, '.env')
723
729
  const INBOX_DIR = join(STATE_DIR, 'inbox')
730
+ // person_id name resolution (docs/configuration.md). Sibling to
731
+ // access.json but a COMPLETELY SEPARATE, purely config-derived file —
732
+ // scaffold regenerates it on every reconcile via writeFileSyncIfChanged,
733
+ // the gateway never mutates it, and (unlike access.json) it is read
734
+ // exactly ONCE at boot into PERSON_DIRECTORY below — no hot-reload.
735
+ const PEOPLE_FILE = join(STATE_DIR, 'people.json')
724
736
 
725
737
  /**
726
738
  * Trigger a restart of the agent + gateway pair.
@@ -1224,6 +1236,33 @@ function loadAccess(): Access {
1224
1236
  return BOOT_ACCESS ?? readAccessFile()
1225
1237
  }
1226
1238
 
1239
+ /**
1240
+ * Read `people.json` (the scaffold's plain projection of `users:` entries
1241
+ * that carry a `person_id`). Fail-open: ENOENT or corrupt/malformed JSON
1242
+ * returns an empty array rather than throwing — this feature must never
1243
+ * block startup. Unlike `access.json` this file is never gateway-mutated,
1244
+ * so there's no "move corrupt file aside" concern; the scaffold owns and
1245
+ * regenerates it on every reconcile.
1246
+ */
1247
+ function readPeopleFile(): RawPersonEntry[] {
1248
+ try {
1249
+ const raw = readFileSync(PEOPLE_FILE, 'utf8')
1250
+ const parsed = JSON.parse(raw) as { entries?: unknown }
1251
+ if (!Array.isArray(parsed.entries)) return []
1252
+ return parsed.entries as RawPersonEntry[]
1253
+ } catch {
1254
+ return []
1255
+ }
1256
+ }
1257
+
1258
+ /**
1259
+ * Boot-time-only person-name directory (see resolve-person.ts module doc).
1260
+ * Populated exactly once by the boot-validation block in the startup IIFE
1261
+ * below, then never reassigned or re-read from disk again for the life of
1262
+ * this process — a config change requires an agent restart.
1263
+ */
1264
+ let PERSON_DIRECTORY: PersonDirectory = { byTelegramKey: {} }
1265
+
1227
1266
  function assertAllowedChat(chat_id: string | number): void {
1228
1267
  const id = String(chat_id)
1229
1268
  const access = loadAccess()
@@ -1488,6 +1527,64 @@ function resolveSubagentOriginChat(
1488
1527
  }
1489
1528
  }
1490
1529
 
1530
+ /**
1531
+ * Tracks worker-feed agents whose owner-DM fallback has already been
1532
+ * logged, so `resolveWorkerFeedChat` emits the routing-decision line once
1533
+ * per agent instead of every watcher tick (~1/s). Capped FIFO at 256 — a
1534
+ * late duplicate log is harmless (one extra stderr line), and the cap keeps
1535
+ * the set bounded across a long gateway lifetime even if origin resolution
1536
+ * is persistently broken fleet-wide (history disabled → every nested worker
1537
+ * hits the fallback). A gateway restart clears it.
1538
+ */
1539
+ const WORKER_FEED_FALLBACK_LOG_CAP = 256
1540
+ const workerFeedOwnerDmFallbackLogged = new Set<string>()
1541
+
1542
+ /**
1543
+ * Resolve a worker-feed destination chat with a guaranteed last resort.
1544
+ *
1545
+ * The universal-liveness contract is "any active work has a card, at any
1546
+ * nesting depth, with or without a parent turn open." Origin resolution
1547
+ * (`resolveSubagentOriginChat`) only succeeds when the ancestor turn row is
1548
+ * in `turnsDb` and history is enabled; a depth-2+ worker whose chain can't
1549
+ * be walked (history disabled, row reaped, ancestor never stamped) would
1550
+ * otherwise fall through `fleetChatId || allowFrom[0]` and, if those are
1551
+ * empty too, hit the `chatId.length === 0` skip in `workerActivityFeed.update`
1552
+ * — painting NOTHING, silently. That is the one path most likely to violate
1553
+ * "any and all active work has a card."
1554
+ *
1555
+ * This never returns `''`: the precedence is origin chat → fleet chat →
1556
+ * first allowed chat (the owner DM). The owner DM is the durable floor —
1557
+ * "wrong chat" beats "no card." A one-line routing-decision log flags the
1558
+ * fallback so an operator can see the misroute without it reading as an
1559
+ * error (it isn't one — the card surfaced).
1560
+ */
1561
+ function resolveWorkerFeedChat(
1562
+ agentId: string,
1563
+ fleetChatId: string,
1564
+ ): { chatId: string; threadId?: number } {
1565
+ const origin = resolveSubagentOriginChat(agentId)
1566
+ if (origin != null && origin.chatId.length > 0) return origin
1567
+ if (fleetChatId.length > 0) return { chatId: fleetChatId }
1568
+ const ownerDm = loadAccess().allowFrom[0] ?? ''
1569
+ if (origin == null && fleetChatId.length === 0 && ownerDm.length > 0) {
1570
+ // Routing decision, not a warning: origin resolution failed and no
1571
+ // fleet chat is configured, so the nested worker's card lands in the
1572
+ // owner DM. Logged ONCE per agent so the misroute is auditable
1573
+ // without spamming every tick (the watcher drives onProgress ~1/s).
1574
+ if (!workerFeedOwnerDmFallbackLogged.has(agentId)) {
1575
+ workerFeedOwnerDmFallbackLogged.add(agentId)
1576
+ if (workerFeedOwnerDmFallbackLogged.size > WORKER_FEED_FALLBACK_LOG_CAP) {
1577
+ const oldest = workerFeedOwnerDmFallbackLogged.values().next().value
1578
+ if (oldest != null) workerFeedOwnerDmFallbackLogged.delete(oldest)
1579
+ }
1580
+ process.stderr.write(
1581
+ `telegram gateway: worker-feed origin unresolved agent=${agentId} — routing card to owner DM\n`,
1582
+ )
1583
+ }
1584
+ }
1585
+ return { chatId: ownerDm, threadId: origin?.threadId }
1586
+ }
1587
+
1491
1588
  // ─── Periodic history reaper (#1073) ──────────────────────────────────────
1492
1589
  // The init-time prune in history.ts only touched the `messages` table.
1493
1590
  // `subagents` and `turns` in registry.db grew unbounded — every Agent()
@@ -4070,9 +4167,26 @@ async function resolveCompactCard(
4070
4167
  { chat_id: card.chatId, verb: `proactiveCompact.${kind}` },
4071
4168
  );
4072
4169
  } catch (err) {
4170
+ // Best-effort status-card edit — a transport hiccup (429 / "not
4171
+ // modified" / message gone) is not a liveness-logic error and must
4172
+ // not log a "card edit failed" warning (the warning-as-excuse-for-a-
4173
+ // stale-card anti-pattern). Only a genuine unexpected error warrants
4174
+ // a line; transport classes are silent.
4175
+ const desc = err instanceof Error ? err.message : String(err);
4176
+ const low = desc.toLowerCase();
4177
+ if (
4178
+ low.includes('not modified') ||
4179
+ low.includes('not found') ||
4180
+ low.includes("can't be edited") ||
4181
+ low.includes('cannot be edited') ||
4182
+ low.includes('not enough rights') ||
4183
+ low.includes('429') ||
4184
+ low.includes('retry after')
4185
+ ) {
4186
+ return;
4187
+ }
4073
4188
  process.stderr.write(
4074
- `telegram gateway: proactive-compact ${kind} card edit failed: ` +
4075
- `${err instanceof Error ? err.message : String(err)}\n`,
4189
+ `telegram gateway: proactive-compact ${kind} card edit failed: ${desc}\n`,
4076
4190
  );
4077
4191
  }
4078
4192
  }
@@ -12896,7 +13010,22 @@ async function drainActivitySummary(
12896
13010
  turn.activityLastSentRender = target
12897
13011
  } catch (err) {
12898
13012
  const msg = err instanceof Error ? err.message : String(err)
12899
- if (!msg.includes('message is not modified')) {
13013
+ const low = msg.toLowerCase()
13014
+ // Transport-class failures (429, message gone, "not modified") must
13015
+ // NOT inflate `activityDrainFailures` — that counter flags a turn as
13016
+ // DEGRADED on turn-end, and a transient rate-limit or an
13017
+ // already-deleted message is not a logic defect. Counting them would
13018
+ // false-flag a healthy turn. "not modified" is success; gone/429 are
13019
+ // retried or harmless. Only a genuine send/open failure counts.
13020
+ const isTransport =
13021
+ low.includes('not modified') ||
13022
+ low.includes('not found') ||
13023
+ low.includes("can't be edited") ||
13024
+ low.includes('cannot be edited') ||
13025
+ low.includes('not enough rights') ||
13026
+ low.includes('429') ||
13027
+ low.includes('retry after')
13028
+ if (!isTransport) {
12900
13029
  turn.activityDrainFailures += 1
12901
13030
  // Surface the failing anchor + topic: the resume-400 bug fed a
12902
13031
  // fabricated 13-digit message_id as the reply anchor here, so every
@@ -13246,7 +13375,23 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
13246
13375
  { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.delete' },
13247
13376
  )
13248
13377
  } catch (err) {
13249
- process.stderr.write(`telegram gateway: activity-summary delete failed: ${err}\n`)
13378
+ // Best-effort teardown of a status card. A transport-class failure
13379
+ // (message already deleted, chat gone, 429) is not a liveness-logic
13380
+ // error — "message to delete not found" is the desired end state
13381
+ // here, and a 429 on a teardown delete is retried by robustApiCall.
13382
+ // Stay silent on those; warn only on a genuinely unexpected error.
13383
+ const msg = err instanceof Error ? err.message : String(err)
13384
+ const low = msg.toLowerCase()
13385
+ if (
13386
+ low.includes('not modified') ||
13387
+ low.includes('not found') ||
13388
+ low.includes('not enough rights') ||
13389
+ low.includes('429') ||
13390
+ low.includes('retry after')
13391
+ ) {
13392
+ return
13393
+ }
13394
+ process.stderr.write(`telegram gateway: activity-summary delete failed: ${msg}\n`)
13250
13395
  }
13251
13396
  return
13252
13397
  }
@@ -13272,10 +13417,26 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
13272
13417
  { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.finalize' },
13273
13418
  )
13274
13419
  } catch (err) {
13420
+ // Same transport-class discipline as the delete path: the card
13421
+ // finalize is a best-effort liveness edit. "not modified" = the card
13422
+ // already shows the finalized body (success); "not found" / "not
13423
+ // enough rights" = the message is gone (nothing to finalize); 429 is
13424
+ // retried by robustApiCall. None of those are liveness-logic errors
13425
+ // and none warrant a stderr warning. Warn only on the unexpected.
13275
13426
  const msg = err instanceof Error ? err.message : String(err)
13276
- if (!msg.includes('message is not modified')) {
13277
- process.stderr.write(`telegram gateway: activity-summary finalize failed: ${msg}\n`)
13427
+ const low = msg.toLowerCase()
13428
+ if (
13429
+ low.includes('not modified') ||
13430
+ low.includes('not found') ||
13431
+ low.includes("can't be edited") ||
13432
+ low.includes('cannot be edited') ||
13433
+ low.includes('not enough rights') ||
13434
+ low.includes('429') ||
13435
+ low.includes('retry after')
13436
+ ) {
13437
+ return
13278
13438
  }
13439
+ process.stderr.write(`telegram gateway: activity-summary finalize failed: ${msg}\n`)
13279
13440
  }
13280
13441
  })
13281
13442
  }
@@ -16650,12 +16811,28 @@ async function handleInbound(
16650
16811
  TOPIC_FRAMING_ENABLED && messageThreadId != null
16651
16812
  ? 'This message belongs to the current topic only — answer ONLY this question, in this topic. Do not also answer a pending message from another topic.'
16652
16813
  : undefined
16814
+ // person_id name resolution (docs/configuration.md, resolve-person.ts):
16815
+ // chat-scoped, boot-time-static lookup — falls back to today's raw
16816
+ // id/username behavior (fail-open) whenever unresolved or when group
16817
+ // membership can't be positively confirmed from access.json's allowFrom.
16818
+ // Never touches access.json itself and never blocks/denies anything.
16819
+ const rawUser = from.username ?? String(from.id)
16820
+ const displayUser = safeResolvePersonName(
16821
+ PERSON_DIRECTORY,
16822
+ {
16823
+ telegramId: String(from.id),
16824
+ username: from.username,
16825
+ isDm: isDmChatId(chat_id),
16826
+ groupAllowFrom: access.groups[chat_id]?.allowFrom,
16827
+ },
16828
+ rawUser,
16829
+ )
16653
16830
  const inboundMsg: InboundMessage = {
16654
16831
  type: 'inbound',
16655
16832
  chatId: chat_id,
16656
16833
  ...(messageThreadId != null ? { threadId: messageThreadId } : {}),
16657
16834
  messageId: msgId ?? 0,
16658
- user: from.username ?? String(from.id),
16835
+ user: displayUser,
16659
16836
  userId: from.id,
16660
16837
  ts: ctx.message?.date ?? Math.floor(Date.now() / 1000),
16661
16838
  text: effectiveText,
@@ -16670,7 +16847,7 @@ async function handleInbound(
16670
16847
  meta: {
16671
16848
  chat_id,
16672
16849
  ...(msgId != null ? { message_id: String(msgId) } : {}),
16673
- user: from.username ?? String(from.id),
16850
+ user: displayUser,
16674
16851
  user_id: String(from.id),
16675
16852
  ts: new Date((ctx.message?.date ?? 0) * 1000).toISOString(),
16676
16853
  ...(messageThreadId != null ? { message_thread_id: String(messageThreadId) } : {}),
@@ -22841,7 +23018,15 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
22841
23018
  tz,
22842
23019
  now: renderNow,
22843
23020
  demo: refreshDemo,
22844
- ...(staleCachedAtMs != null ? { staleCachedAtMs } : { liveProbedAtMs: renderNow.getTime() }),
23021
+ // Honesty backstop (same as /usage): a TOTAL probe failure (zero
23022
+ // result rows, nothing served from cache) renders an explicit
23023
+ // "probe failed" marker instead of a false "Live" footer next to
23024
+ // no-data rows.
23025
+ ...(staleCachedAtMs != null
23026
+ ? { staleCachedAtMs }
23027
+ : probeResp.results.length > 0
23028
+ ? { liveProbedAtMs: renderNow.getTime() }
23029
+ : { probeFailed: true }),
22845
23030
  })
22846
23031
  const kbRows = buildSnapshotKeyboard(snapshots, { now: renderNow, demo: refreshDemo })
22847
23032
  const inline_keyboard = kbRows.map((row) =>
@@ -23307,21 +23492,49 @@ bot.command('usage', async ctx => {
23307
23492
  // flight; a TTL-hit or failed-probe fallback is tagged served:"cache",
23308
23493
  // which we surface as a "⚠ cached Nm ago" footer instead of a false
23309
23494
  // live stamp.
23495
+ const renderNow = new Date()
23310
23496
  const probeResp = await client.probeQuota(state.accounts.map((a) => a.label)).catch(() => ({ results: [] }))
23311
23497
  const { quotas, staleCachedAtMs } = zipProbeResults(
23312
23498
  state.accounts.map((a) => a.label),
23313
23499
  probeResp.results,
23314
23500
  )
23315
- const { renderAuthSnapshotFormat2, buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
23501
+ const { buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
23316
23502
  '../auth-snapshot-format.js'
23317
23503
  )
23318
- const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? 'UTC'
23504
+ const { renderUsageCard } = await import('../quota-bar-format.js')
23319
23505
  const snapshots = buildSnapshotsFromState(state, quotas)
23320
- const text = renderAuthSnapshotFormat2(snapshots, {
23321
- tz,
23322
- now: new Date(),
23506
+ // /usage renders the compact quota-bar block (at-a-glance headroom +
23507
+ // pace tick per window) plus a two-line footer — the actionable
23508
+ // cross-account recommendation verdict and the freshness marker (`⚠
23509
+ // cached Nm ago` when the probe was served stale from cache, else
23510
+ // `Live · refreshed Nm ago`). The old Format 2 table
23511
+ // (renderAuthSnapshotFormat2) is no longer appended here (operator
23512
+ // call, 2026-07-10): the two views were redundant and the table
23513
+ // doubled the message length. Every per-window reset the table
23514
+ // carried has an equivalent in the bar rows' `pct% / <time-left>`
23515
+ // segment, just relative instead of absolute — no info lost; the
23516
+ // recommendation + cached/live footer the table used to carry are
23517
+ // preserved by renderUsageCard.
23518
+ const exhaustedByLabel = new Map<string, boolean>(
23519
+ state.accounts.map((a) => [a.label, a.exhausted]),
23520
+ )
23521
+ const text = renderUsageCard(snapshots, exhaustedByLabel, {
23522
+ now: renderNow,
23323
23523
  demo,
23324
- ...(staleCachedAtMs != null ? { staleCachedAtMs } : { liveProbedAtMs: Date.now() }),
23524
+ // #2495 Change 2 a TTL-hit / failed-probe fallback is tagged
23525
+ // served:"cache"; surface it as `⚠ cached Nm ago` instead of a
23526
+ // false live stamp. Otherwise stamp the live refresh time.
23527
+ // Honesty backstop: a TOTAL probe failure (the .catch above
23528
+ // returned `{results: []}` and nothing was served from cache)
23529
+ // must render an explicit "probe failed" marker, NOT a false
23530
+ // "Live" stamp next to "⚠️ no data" rows. Without this the
23531
+ // footer claimed "Live · refreshed 0s ago" while every account
23532
+ // row said "no data — probe failed" (#2959 review finding).
23533
+ ...(staleCachedAtMs != null
23534
+ ? { staleCachedAtMs }
23535
+ : probeResp.results.length > 0
23536
+ ? { liveProbedAtMs: renderNow.getTime() }
23537
+ : { probeFailed: true }),
23325
23538
  })
23326
23539
  // Preserve the Switch/Refresh/usage/Add inline keyboard on the
23327
23540
  // rich-message render — the table card carries the same actions the
@@ -26380,6 +26593,32 @@ void (async () => {
26380
26593
 
26381
26594
  if (!didOneTimeSetup) {
26382
26595
  didOneTimeSetup = true
26596
+
26597
+ // person_id name resolution (docs/configuration.md, resolve-person.ts):
26598
+ // ONE-TIME boot validation, NOT periodic. `PERSON_DIRECTORY` is
26599
+ // built exactly once, here, from the static in-memory `people.json`
26600
+ // the scaffold projected from `switchroom.yaml`'s `users:` block —
26601
+ // a config change requires an agent restart, this never re-reads.
26602
+ // The orchestration (per-entry validation + dead-man's-switch) is
26603
+ // in the pure, unit-tested `runPersonDirectoryBootCheck` — this
26604
+ // block only performs the resulting I/O (stderr log + the EXISTING
26605
+ // fleet-alert path at low/config severity, kind: 'config-warning',
26606
+ // which must never page like a real outage).
26607
+ {
26608
+ const bootCheck = runPersonDirectoryBootCheck(readPeopleFile)
26609
+ PERSON_DIRECTORY = bootCheck.directory
26610
+ process.stderr.write(bootCheck.logLine + '\n')
26611
+ if (bootCheck.alertDetail != null) {
26612
+ emitGatewayOperatorEvent({
26613
+ kind: 'config-warning',
26614
+ agent: process.env.SWITCHROOM_AGENT_NAME ?? '-',
26615
+ detail: bootCheck.alertDetail,
26616
+ suggestedActions: [],
26617
+ firstSeenAt: new Date(),
26618
+ })
26619
+ }
26620
+ }
26621
+
26383
26622
  void registerSwitchroomBotCommands().catch(() => {})
26384
26623
 
26385
26624
  // #613 fix: pre-warm the chatAvailableReactions cache for every
@@ -27334,8 +27573,8 @@ void (async () => {
27334
27573
  orphanStatusEnabled,
27335
27574
  })
27336
27575
  if (surface === 'worker-feed') {
27337
- const origin = resolveSubagentOriginChat(agentId)
27338
- const wkChat = origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? '')
27576
+ const wk = resolveWorkerFeedChat(agentId, fleetChatId)
27577
+ const wkChat = wk.chatId
27339
27578
  void workerActivityFeed?.update(
27340
27579
  agentId,
27341
27580
  wkChat,
@@ -27347,7 +27586,7 @@ void (async () => {
27347
27586
  elapsedMs,
27348
27587
  state: 'running',
27349
27588
  },
27350
- origin?.threadId,
27589
+ wk.threadId,
27351
27590
  )?.then(() => reconcileWorkerPin(agentId, wkChat, true))
27352
27591
  return
27353
27592
  }
@@ -27472,8 +27711,8 @@ void (async () => {
27472
27711
  // resolved (the pinned-card fleet that used to carry the chat
27473
27712
  // is gone — see resolveSubagentOriginChat).
27474
27713
  if (workerFeedEnabled) {
27475
- const origin = resolveSubagentOriginChat(agentId)
27476
- const wkChat = origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? '')
27714
+ const wk = resolveWorkerFeedChat(agentId, fleetChatId)
27715
+ const wkChat = wk.chatId
27477
27716
  void workerActivityFeed?.update(
27478
27717
  agentId,
27479
27718
  wkChat,
@@ -27488,7 +27727,7 @@ void (async () => {
27488
27727
  elapsedMs,
27489
27728
  state: 'running',
27490
27729
  },
27491
- origin?.threadId,
27730
+ wk.threadId,
27492
27731
  )?.then(() => reconcileWorkerPin(agentId, wkChat, true))
27493
27732
  return
27494
27733
  }