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
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Boot-time-only, chat-scoped resolution of a raw Telegram id/username into
3
+ * a human `person_id` (e.g. "Lisa") for display in the `<channel>` tag's
4
+ * `user` attribute.
5
+ *
6
+ * Design (converged after adversarial review — see the PR description for
7
+ * the tradeoffs, do not re-litigate here):
8
+ *
9
+ * - No MCP tool. Not callable by agents — display-only, gateway-internal.
10
+ * - No hot-reload. `PersonDirectory` is built ONCE at gateway boot from
11
+ * the static in-memory `people.json` the scaffold projected from
12
+ * `switchroom.yaml`'s `users:` block. A config change requires an agent
13
+ * restart to take effect — this file never re-reads anything.
14
+ * - `access.json` (the fail-CLOSED allow-list) is a completely separate
15
+ * concern and is never touched here. This feature is fail-OPEN: an
16
+ * unresolved id/username falls back to today's behavior (the caller
17
+ * keeps using the raw id/username) — it never blocks or denies
18
+ * anything.
19
+ * - Chat-scoped: a resolved name is only ever returned for a chat/group
20
+ * the person is actually a member of. In a DM the chat IS the sender,
21
+ * so resolution always applies. In a group, resolution only applies if
22
+ * the sender's id or username is explicitly present in that group's
23
+ * `allowFrom` (read from the EXISTING `access.json`/`loadAccess()`
24
+ * data — no new membership source). An empty/unset group `allowFrom`
25
+ * means membership can't be positively confirmed, so we conservatively
26
+ * do NOT resolve (the raw id/username is shown instead) — a name safe
27
+ * in a 1:1 DM could be a bigger leak in a shared group.
28
+ * - Per-entry validation: a malformed `users:` entry (duplicate
29
+ * `person_id` claimed by two different keys, empty/invalid
30
+ * `person_id`, no `telegram_ids`) drops ONLY that one entry — it never
31
+ * blanks resolution for the whole fleet. `buildPersonDirectory` never
32
+ * throws; every failure mode is reported via its `dropped` return
33
+ * value instead, so the caller can route it through the existing
34
+ * fleet-alert path (`emitGatewayOperatorEvent` in gateway.ts) at
35
+ * low/config severity — this must never page like a real outage.
36
+ *
37
+ * Fix note (accepted soft mitigation — see docs/configuration.md): there is
38
+ * no automated enforcement that a configured `person_id` stays safe to show
39
+ * if a group's membership changes AFTER the entry is written. This is an
40
+ * operator-discipline convention, not a closed gap. Low severity today —
41
+ * only two `person_id`s are configured fleet-wide.
42
+ */
43
+
44
+ export interface RawPersonEntry {
45
+ /** The `users:` map key (e.g. "lisa") — carried through for alert text. */
46
+ key: string
47
+ person_id: string
48
+ telegram_ids: string[]
49
+ }
50
+
51
+ export interface PersonDirectoryEntry {
52
+ key: string
53
+ personId: string
54
+ /** Normalized (lowercased, no leading "@") telegram ids/usernames. */
55
+ telegramKeys: string[]
56
+ }
57
+
58
+ export interface PersonDirectory {
59
+ /** normalized telegram id/username -> directory entry */
60
+ byTelegramKey: Record<string, PersonDirectoryEntry>
61
+ }
62
+
63
+ export interface DroppedPersonEntry {
64
+ key: string
65
+ reason: string
66
+ /**
67
+ * Name-scrubbed reason CLASS — the same rejection without the embedded
68
+ * `person_id` human-name value or the colliding telegram id. Used to
69
+ * build the broadcast `alertDetail` so a config-warning card (which
70
+ * `emitGatewayOperatorEvent` sends to EVERY `allowFrom` chat, including
71
+ * group chats where the named person may NOT be a member) never surfaces
72
+ * a human name that the display path (`resolvePersonName`) deliberately
73
+ * chat-scopes. The verbose `reason` is still carried for the operator's
74
+ * own stderr `logLine` (private, not broadcast).
75
+ */
76
+ reasonClass: string
77
+ }
78
+
79
+ export interface BuildPersonDirectoryResult {
80
+ directory: PersonDirectory
81
+ dropped: DroppedPersonEntry[]
82
+ }
83
+
84
+ /** Normalize a telegram id or @username for keying: trim, strip a leading
85
+ * "@", lowercase (Telegram usernames are case-insensitive). */
86
+ function normalizeTelegramKey(raw: string): string {
87
+ return raw.trim().replace(/^@/, '').toLowerCase()
88
+ }
89
+
90
+ /**
91
+ * Validate and dedupe raw `users:` `person_id` entries into a lookup
92
+ * directory. Never throws — every rejection is reported via `dropped`
93
+ * instead, per the fail-open / drop-only-that-entry design.
94
+ *
95
+ * Drop reasons:
96
+ * - missing/blank `key` or `person_id`
97
+ * - `telegram_ids` missing or empty (nothing to key the entry by)
98
+ * - `person_id` already claimed by an earlier (different) entry key —
99
+ * first-declared entry wins, the later duplicate is dropped whole
100
+ * (not just the colliding id) so the alert is unambiguous about which
101
+ * entry lost
102
+ * - a `telegram_id`/username already claimed by an earlier (different)
103
+ * entry key — same first-declared-wins convention: the later duplicate
104
+ * is dropped whole so the alert is unambiguous about which entry lost
105
+ * and we never silently last-write-wins a numeric id into the wrong
106
+ * person's name
107
+ */
108
+ export function buildPersonDirectory(entries: readonly RawPersonEntry[]): BuildPersonDirectoryResult {
109
+ const byTelegramKey: Record<string, PersonDirectoryEntry> = {}
110
+ const dropped: DroppedPersonEntry[] = []
111
+ const personIdOwner = new Map<string, string>() // person_id (lowercased) -> owning entry key
112
+ const telegramKeyOwner = new Map<string, string>() // normalized telegram id/username -> owning entry key
113
+
114
+ for (const raw of entries) {
115
+ const key = typeof raw.key === 'string' ? raw.key.trim() : ''
116
+ const personId = typeof raw.person_id === 'string' ? raw.person_id.trim() : ''
117
+
118
+ if (key.length === 0) {
119
+ dropped.push({ key: raw.key || '(unknown)', reason: 'missing users: map key', reasonClass: 'missing users: map key' })
120
+ continue
121
+ }
122
+ if (personId.length === 0) {
123
+ dropped.push({ key, reason: 'empty or missing person_id', reasonClass: 'empty or missing person_id' })
124
+ continue
125
+ }
126
+ if (!Array.isArray(raw.telegram_ids) || raw.telegram_ids.length === 0) {
127
+ dropped.push({ key, reason: 'no telegram_ids to resolve against', reasonClass: 'no telegram_ids to resolve against' })
128
+ continue
129
+ }
130
+
131
+ const personIdLower = personId.toLowerCase()
132
+ const existingOwner = personIdOwner.get(personIdLower)
133
+ if (existingOwner != null && existingOwner !== key) {
134
+ dropped.push({
135
+ key,
136
+ // Verbose reason (stderr logLine only — private to the operator):
137
+ // names the person_id value + the owning config key.
138
+ reason: `duplicate person_id "${personId}" already claimed by users.${existingOwner}`,
139
+ // reasonClass (broadcast alertDetail): scrubs the human-name
140
+ // person_id value; keeps the entry key + collision class. A
141
+ // config-warning card fans out to every allowFrom chat, so the
142
+ // name must not travel further than the display path allows.
143
+ reasonClass: `duplicate person_id already claimed by users.${existingOwner}`,
144
+ })
145
+ continue
146
+ }
147
+ personIdOwner.set(personIdLower, key)
148
+
149
+ const telegramKeys = [...new Set(raw.telegram_ids.map(normalizeTelegramKey).filter((k) => k.length > 0))]
150
+ if (telegramKeys.length === 0) {
151
+ dropped.push({ key, reason: 'telegram_ids contained no usable id/username', reasonClass: 'telegram_ids contained no usable id/username' })
152
+ personIdOwner.delete(personIdLower)
153
+ continue
154
+ }
155
+
156
+ const collidingTelegramKey = telegramKeys.find((tk) => {
157
+ const existingTkOwner = telegramKeyOwner.get(tk)
158
+ return existingTkOwner != null && existingTkOwner !== key
159
+ })
160
+ if (collidingTelegramKey != null) {
161
+ const existingTkOwner = telegramKeyOwner.get(collidingTelegramKey)
162
+ dropped.push({
163
+ key,
164
+ reason: `duplicate telegram_id "${collidingTelegramKey}" already claimed by users.${existingTkOwner}`,
165
+ // Scrub the colliding telegram id/username from the broadcast
166
+ // reason; keep the owning config key (operator-chosen slug).
167
+ reasonClass: `duplicate telegram_id already claimed by users.${existingTkOwner}`,
168
+ })
169
+ personIdOwner.delete(personIdLower)
170
+ continue
171
+ }
172
+
173
+ const entry: PersonDirectoryEntry = { key, personId, telegramKeys }
174
+ for (const tk of telegramKeys) {
175
+ telegramKeyOwner.set(tk, key)
176
+ byTelegramKey[tk] = entry
177
+ }
178
+ }
179
+
180
+ return { directory: { byTelegramKey }, dropped }
181
+ }
182
+
183
+ export interface PersonDirectoryBootResult {
184
+ directory: PersonDirectory
185
+ /** Non-null when the caller should route an alert through the fleet
186
+ * alert path (`emitGatewayOperatorEvent`, kind: 'config-warning'). */
187
+ alertDetail: string | null
188
+ /** Always present — one human-readable line for stderr. */
189
+ logLine: string
190
+ }
191
+
192
+ /**
193
+ * Orchestrate the ONE-TIME boot-time check (requirement: boot-time-only,
194
+ * NOT periodic — call this exactly once, at gateway boot, never on a
195
+ * timer/interval). Extracted as a pure(ish) function — taking the file
196
+ * read as an injected dependency and RETURNING what to log/alert rather
197
+ * than performing the I/O itself — so it's unit-testable without booting
198
+ * the real gateway process.
199
+ *
200
+ * Dead-man's-switch: wraps the whole check in try/catch. If anything
201
+ * throws before completing (including `readEntries` itself), that failure
202
+ * is ALSO surfaced via `alertDetail` — mirrors a past incident where
203
+ * `access.json` validation failed silently in the wrong (fail-open)
204
+ * direction, undetected for hours. `PERSON_DIRECTORY` falls back to an
205
+ * empty directory on crash (fail-open: raw ids/usernames keep showing).
206
+ */
207
+ export function runPersonDirectoryBootCheck(readEntries: () => RawPersonEntry[]): PersonDirectoryBootResult {
208
+ try {
209
+ const rawEntries = readEntries()
210
+ const { directory, dropped } = buildPersonDirectory(rawEntries)
211
+
212
+ if (dropped.length > 0) {
213
+ // Broadcast-safe summary for `alertDetail` (fans out to EVERY
214
+ // allowFrom chat via emitGatewayOperatorEvent, including group chats
215
+ // where the named person may not be a member): built from
216
+ // `reasonClass`, which scrubs the embedded person_id human-name
217
+ // value and the colliding telegram id. The verbose `reason`
218
+ // (names the values) is kept for the operator's own stderr
219
+ // `logLine` — private, never broadcast.
220
+ const alertSummary = dropped.map((d) => `${d.key} (${d.reasonClass})`).join('; ')
221
+ const logSummary = dropped.map((d) => `${d.key} (${d.reason})`).join('; ')
222
+ const plural = dropped.length === 1 ? 'y' : 'ies'
223
+ return {
224
+ directory,
225
+ alertDetail: `person_id: dropped ${dropped.length} malformed users: entr${plural} at boot — ${alertSummary}`,
226
+ logLine: `telegram gateway: person_id boot validation dropped ${dropped.length} entr${plural}: ${logSummary}`,
227
+ }
228
+ }
229
+
230
+ return {
231
+ directory,
232
+ alertDetail: null,
233
+ logLine: rawEntries.length > 0
234
+ ? `telegram gateway: person_id boot validation ok — ${Object.keys(directory.byTelegramKey).length} telegram id/username(s) resolved`
235
+ : `telegram gateway: person_id boot validation ok — no person_id entries configured`,
236
+ }
237
+ } catch (err) {
238
+ const msg = err instanceof Error ? err.message : String(err)
239
+ return {
240
+ directory: { byTelegramKey: {} },
241
+ // Crash alertDetail scrubs the raw error message too — it can
242
+ // carry a filesystem path or other host detail that shouldn't fan
243
+ // out to every allowFrom chat. The full message stays in logLine.
244
+ alertDetail: `person_id boot validation crashed — name resolution disabled this boot (fail-open, raw ids/usernames will show); see gateway stderr for detail`,
245
+ logLine: `telegram gateway: person_id boot validation CRASHED (name resolution disabled this boot, raw ids will show — fail-open): ${msg}`,
246
+ }
247
+ }
248
+ }
249
+
250
+ export interface ResolvePersonOptions {
251
+ telegramId: string
252
+ username?: string | undefined
253
+ isDm: boolean
254
+ /** That chat's/group's configured allowFrom, if any (from access.json). */
255
+ groupAllowFrom?: readonly string[] | undefined
256
+ }
257
+
258
+ /**
259
+ * Resolve a sender's display name for THIS chat, chat-scoped per the
260
+ * module doc above. Returns undefined (fall back to raw id/username) when
261
+ * unresolved OR when membership in a group chat can't be positively
262
+ * confirmed.
263
+ */
264
+ export function resolvePersonName(directory: PersonDirectory, opts: ResolvePersonOptions): string | undefined {
265
+ const idKey = normalizeTelegramKey(opts.telegramId)
266
+ const usernameKey = opts.username ? normalizeTelegramKey(opts.username) : undefined
267
+
268
+ const entry = directory.byTelegramKey[idKey] ?? (usernameKey ? directory.byTelegramKey[usernameKey] : undefined)
269
+ if (!entry) return undefined
270
+
271
+ // DM: the chat IS the sender, so resolution always applies.
272
+ if (opts.isDm) return entry.personId
273
+
274
+ // Group: only resolve if the sender is explicitly present in that
275
+ // chat's allowFrom (the existing membership source) — conservative
276
+ // fallback (undefined) otherwise, since we can't positively confirm
277
+ // membership from an empty/unset list.
278
+ const allowFrom = opts.groupAllowFrom ?? []
279
+ const allowFromNormalized = allowFrom.map(normalizeTelegramKey)
280
+ const memberConfirmed =
281
+ allowFromNormalized.includes(idKey) || (usernameKey != null && allowFromNormalized.includes(usernameKey))
282
+ return memberConfirmed ? entry.personId : undefined
283
+ }
284
+
285
+ /**
286
+ * Fail-open call-site wrapper around `resolvePersonName`, for use at the
287
+ * `handleInbound` call site (gateway.ts). This feature's whole design
288
+ * point is "never blocks or denies anything" (module doc above) — a throw
289
+ * from resolution must fall back to the raw id/username, not abort message
290
+ * handling. Mirrors the same defensive pattern already used for
291
+ * `readPeopleFile` (gateway.ts) and `runPersonDirectoryBootCheck` (this
292
+ * file): catch, fall back, never propagate.
293
+ */
294
+ export function safeResolvePersonName(
295
+ directory: PersonDirectory,
296
+ opts: ResolvePersonOptions,
297
+ rawFallback: string,
298
+ ): string {
299
+ try {
300
+ return resolvePersonName(directory, opts) ?? rawFallback
301
+ } catch {
302
+ return rawFallback
303
+ }
304
+ }
@@ -126,7 +126,27 @@ export function classifyRejection(
126
126
  // handler shut the process down. reconcileStatusPin now absorbs its own
127
127
  // errors (primary fix); this entry is defense-in-depth so ANY leaked
128
128
  // pin-rights 400 from any path is log-only, not fatal.
129
- desc.includes('not enough rights')
129
+ desc.includes('not enough rights') ||
130
+ // 'group chat was upgraded to a supergroup chat' fires when a send
131
+ // targets a basic-group chat_id that Telegram has since migrated to
132
+ // a supergroup (new id format -100xxxxxxxxxx). Telegram sometimes
133
+ // surfaces the replacement id via `error.parameters.migrate_to_chat_id`
134
+ // but not reliably for every send method, so this handler can't always
135
+ // auto-repair the stale id — it can only make the failure non-fatal.
136
+ // This crashed marko's gateway on 2026-07-09 (and recurred from the
137
+ // same stale-id root cause on 2026-06-07 and 2026-06-09): a send to an
138
+ // old pre-migration group id crashed the WHOLE gateway process for
139
+ // every agent/chat, not just the one stale chat. A single unmigrated
140
+ // chat_id in cached state must never be fatal — log it so the stale id
141
+ // can be tracked down and fixed, but keep serving every other chat.
142
+ desc.includes('group chat was upgraded to a supergroup chat') ||
143
+ // Broader class: any 400 whose description signals the target
144
+ // chat_id itself is no longer valid (migrated, deactivated, or
145
+ // otherwise unresolvable). These are all "this one destination is
146
+ // broken", never "the gateway is broken" — same log-only posture as
147
+ // 'chat not found' above.
148
+ desc.includes('chat_id is empty') ||
149
+ desc.includes('group chat was deactivated')
130
150
  ) {
131
151
  return 'log_only'
132
152
  }
@@ -33,6 +33,28 @@
33
33
  * delivery obligation.
34
34
  */
35
35
 
36
+ // Verified complete (2026-07-09, adversarial-review follow-up): `reply`
37
+ // and `stream_reply` are the ONLY two MCP tools whose payload is the
38
+ // model's free-text final-answer content reaching the user — the exact
39
+ // scope `final-answer-detect.ts`'s own docstring claims ("plain assistant
40
+ // transcript text instead of a `reply` / `stream_reply` tool call").
41
+ // Cross-checked the full tool surface in `telegram-plugin/bridge/bridge.ts`
42
+ // (`TOOL_SCHEMAS`, kept in sync with `gateway/gateway.ts`): `edit_message`
43
+ // explicitly does NOT ping/deliver a fresh answer (its own description says
44
+ // "send a new reply when a long task completes"); `react`, `pin_message`,
45
+ // `delete_message`, `forward_message`, `send_typing`, `download_attachment`,
46
+ // `get_recent_messages` carry no model-authored answer text at all;
47
+ // `send_checklist` / `send_sticker` / `send_gif` / `ask_user` /
48
+ // `update_checklist` deliver structured/templated content, not the turn's
49
+ // prose answer, and are intentionally a different interaction pattern (a
50
+ // question or a fixed artifact, not "the answer"). `stream_reply` sends its
51
+ // FULL cumulative text snapshot on every call (not incremental chunks —
52
+ // see `stream-reply-handler.ts` docstring), and each call is its own
53
+ // `tool_use` block in the transcript in chronological order, so the
54
+ // "last delivery event wins" walk below already treats a stream's final
55
+ // (`done:true`) call as the qualifying one regardless of how many
56
+ // intermediate non-final `stream_reply` calls preceded it. No gap found;
57
+ // re-verify only if a new outbound-delivery tool is added to bridge.ts.
36
58
  const REPLY_TOOLS = new Set([
37
59
  'mcp__switchroom-telegram__reply',
38
60
  'mcp__switchroom-telegram__stream_reply',
@@ -126,24 +148,47 @@ function buildTurnKey(chatId, threadId) {
126
148
  return `${chatId}:${threadId == null || threadId === 0 ? '_' : threadId}`
127
149
  }
128
150
 
151
+ /**
152
+ * Build the `{ decided: 'block', ... }` result shape, populating
153
+ * `turnKey`/`chatId`/`threadId` from the enqueue envelope when
154
+ * available. Shared by both block branches below.
155
+ *
156
+ * @param {ReturnType<typeof parseChannelEnvelope>} envelope
157
+ * @param {string} reason
158
+ */
159
+ function buildBlockResult(envelope, reason) {
160
+ const block = { decided: 'block', reason }
161
+ if (envelope.chatId) {
162
+ block.chatId = envelope.chatId
163
+ block.threadId = envelope.threadId
164
+ block.turnKey = buildTurnKey(envelope.chatId, envelope.threadId)
165
+ }
166
+ return block
167
+ }
168
+
129
169
  /**
130
170
  * Scan a JSONL transcript and decide whether the current turn ended
131
171
  * with a final reply delivered.
132
172
  *
133
173
  * Returns:
134
- * { decided: 'allow', reason } — qualifying reply OR silent marker found
174
+ * { decided: 'allow', reason } — qualifying reply OR silent marker found,
175
+ * and nothing undelivered was written
176
+ * after it
135
177
  * { decided: 'block', reason, turnKey?, chatId?, threadId? }
136
- * — turn-start found, no qualifying reply,
137
- * no marker. `turnKey`/`chatId`/`threadId`
138
- * populated from the enqueue's channel
139
- * envelope so the hook can write a state
140
- * file shape that matches what the
141
- * gateway's `recordSilentTurnEnd` would
142
- * write keeping the retry-count
143
- * preservation gate at
144
- * `silent-end.ts:114` happy when the
145
- * gateway's later write reads back the
146
- * hook's state.
178
+ * — turn-start found, no qualifying reply
179
+ * delivered (or a qualifying reply
180
+ * happened, but the model kept writing
181
+ * plain-text content afterward that was
182
+ * never sent through a delivery tool).
183
+ * `turnKey`/`chatId`/`threadId` populated
184
+ * from the enqueue's channel envelope so
185
+ * the hook can write a state file shape
186
+ * that matches what the gateway's
187
+ * `recordSilentTurnEnd` would write
188
+ * keeping the retry-count preservation
189
+ * gate at `silent-end.ts:114` happy when
190
+ * the gateway's later write reads back
191
+ * the hook's state.
147
192
  * { decided: 'unknown', reason } — couldn't locate turn-start; caller fail-open
148
193
  *
149
194
  * Turn-start anchor: the most recent `queue-operation`/`enqueue` line
@@ -154,6 +199,38 @@ function buildTurnKey(chatId, threadId) {
154
199
  * edge case where the model replied combined ahead of the second
155
200
  * enqueue's append; accepted residual.)
156
201
  *
202
+ * IMPORTANT — trailing-content check (fixes the "at least once" bug):
203
+ * a naive scan that returns 'allow' on the FIRST qualifying reply it
204
+ * finds is wrong. A turn can legitimately call `reply` early (e.g. a
205
+ * notification-bearing interim ack — `disable_notification` unset/false
206
+ * always qualifies as "final" under `isFinalAnswerReply`, regardless of
207
+ * how short the text is) and then keep working, eventually writing a
208
+ * SUBSTANTIVE plain-text verdict that never goes through `reply` again.
209
+ * The early ack satisfied "reply was called somewhere in this turn",
210
+ * but the user never saw the actual answer. So this scan does NOT
211
+ * short-circuit on the first match: it walks the ENTIRE turn in
212
+ * chronological order, remembers the position of the LAST qualifying
213
+ * delivery event (a final-answer reply/stream_reply call, or an
214
+ * explicit silent-marker), and then checks whether any plain assistant
215
+ * text block appears AFTER that position. If one does, that content was
216
+ * written but never delivered — block, same as the zero-reply case.
217
+ *
218
+ * This deliberately does NOT flag a turn that ends on a delivery
219
+ * tool_use with nothing after it (the normal, healthy shape), nor a
220
+ * turn where all assistant text precedes the final delivering reply
221
+ * (the model narrating before it sends) — only text that comes AFTER
222
+ * the last delivery event counts as a drop.
223
+ *
224
+ * Substance floor (#2956 review): the trailing-text check only BLOCKS
225
+ * when the trailing text is SUBSTANTIVE — at least FINAL_ANSWER_MIN_CHARS
226
+ * (the same bar `isFinalAnswerReply` uses to recognise a real answer). A
227
+ * SHORT trailing pleasantry / closer after a delivered reply ("Let me
228
+ * know if you need anything else.") is not a dropped answer and must not
229
+ * trigger a re-prompt (no-spam / single-answer invariant). A long
230
+ * trailing verdict the model forgot to send still blocks. The floor keeps
231
+ * the "at least once" guarantee for real dropped answers while stopping a
232
+ * false-positive that burned retry budget on healthy turns.
233
+ *
157
234
  * @param {string} jsonl
158
235
  * @returns {{ decided: 'allow' | 'block' | 'unknown', reason: string, turnKey?: string, chatId?: string, threadId?: number | null }}
159
236
  */
@@ -178,8 +255,13 @@ export function scanTurnForFinalReply(jsonl) {
178
255
  return { decided: 'unknown', reason: 'no-turn-start' }
179
256
  }
180
257
 
181
- // 2. Scan forward from the turn start; look for qualifying tool_use
182
- // or silent-marker text.
258
+ // 2. Flatten every assistant content block (text and tool_use) from
259
+ // the turn into a single chronologically-ordered list. Classify
260
+ // each block as it's collected: a "delivery" event (qualifying
261
+ // final-answer reply/stream_reply, or an explicit silent marker —
262
+ // whether emitted as plain text or as a reply-tool payload) or
263
+ // plain undelivered text.
264
+ const blocks = []
183
265
  for (let i = startIdx + 1; i < lines.length; i++) {
184
266
  const line = lines[i]
185
267
  if (!line || line[0] !== '{') continue
@@ -193,16 +275,28 @@ export function scanTurnForFinalReply(jsonl) {
193
275
  const content = obj?.message?.content
194
276
  if (!Array.isArray(content)) continue
195
277
  for (const c of content) {
196
- // Plain assistant text carve-out (#2053): a turn that ends with a
197
- // trailing bare NO_REPLY / HEARTBEAT_OK line emitted as plain
198
- // transcript text, NOT through the reply toolis the model
199
- // explicitly signalling "intentionally silent". The anchored
200
- // SILENT_MARKER_RE below only fires when the ENTIRE reply-tool
201
- // text is the bare marker, so a plain-text prose+NO_REPLY turn
202
- // matched nothing here block → nag → sentinel leak. Treat a
203
- // trailing-marker text block as a valid silent end.
204
- if (c?.type === 'text' && endsWithSilentMarker(String(c.text ?? ''))) {
205
- return { decided: 'allow', reason: 'silent-marker-text' }
278
+ if (c?.type === 'text') {
279
+ // Plain assistant text carve-out (#2053): a turn that ends with
280
+ // a trailing bare NO_REPLY / HEARTBEAT_OK lineemitted as
281
+ // plain transcript text, NOT through the reply tool — is the
282
+ // model explicitly signalling "intentionally silent". Treat a
283
+ // trailing-marker text block as a delivery/silence event;
284
+ // anything else is candidate undelivered content.
285
+ if (endsWithSilentMarker(String(c.text ?? ''))) {
286
+ blocks.push({ kind: 'deliver', reason: 'silent-marker-text' })
287
+ } else if (String(c.text ?? '').trim().length > 0) {
288
+ // Carry the trimmed char count so the trailing-content check
289
+ // (step 3) can apply a substance floor: a SHORT trailing text
290
+ // after a delivered reply (a pleasantry / closer like "Let me
291
+ // know if you need anything else.") is NOT a dropped answer and
292
+ // must not trigger a re-prompt (no-spam invariant). Only
293
+ // SUBSTANTIVE trailing text — at least FINAL_ANSWER_MIN_CHARS,
294
+ // the same bar `isFinalAnswerReply` uses to recognise a real
295
+ // answer — counts as "undelivered content the user was waiting
296
+ // on". #2956 review finding.
297
+ blocks.push({ kind: 'text', chars: String(c.text ?? '').trim().length })
298
+ }
299
+ continue
206
300
  }
207
301
  if (c?.type !== 'tool_use') continue
208
302
  if (!REPLY_TOOLS.has(c.name)) continue
@@ -214,33 +308,63 @@ export function scanTurnForFinalReply(jsonl) {
214
308
  // prose+trailing-marker shape (#2053). Same posture as the
215
309
  // gateway's silent-marker suppression at gateway.ts:6692.
216
310
  if (SILENT_MARKER_RE.test(text.trim()) || endsWithSilentMarker(text)) {
217
- return { decided: 'allow', reason: 'silent-marker' }
311
+ blocks.push({ kind: 'deliver', reason: 'silent-marker' })
312
+ continue
218
313
  }
219
314
  if (isFinalAnswerReply({
220
315
  text,
221
316
  disableNotification: input.disable_notification === true,
222
317
  done: input.done === true,
223
318
  })) {
224
- return { decided: 'allow', reason: 'final-reply' }
319
+ blocks.push({ kind: 'deliver', reason: 'final-reply' })
320
+ continue
225
321
  }
322
+ // Non-qualifying reply call (interim ack) — delivered to the
323
+ // user, but not a "final answer". It's neither a delivery event
324
+ // nor undelivered text, so it doesn't affect the decision either
325
+ // way; simply not pushed.
226
326
  }
227
327
  }
228
328
 
229
- // Cron-fired turns (#2053): a scheduled turn that produced no
230
- // qualifying reply is NOT a delivery failure the user is waiting on —
231
- // nagging it only pushes the model to escape the loop by shoving a
232
- // NO_REPLY sentinel through the reply tool, which leaks to chat. A
233
- // cron turn that genuinely needs to speak will have called reply
234
- // (caught above); otherwise let it end silently.
235
- if (envelope.source === 'cron') {
236
- return { decided: 'allow', reason: 'cron-source' }
329
+ // 3. Find the LAST delivery event's position, then check whether any
330
+ // plain-text block appears strictly after it. This is the fix for
331
+ // the "at least once" bug: a naive scan that stops at the FIRST
332
+ // qualifying reply misses substantive content the model wrote
333
+ // afterward and never (re-)sent.
334
+ let lastAllowBlockIdx = -1
335
+ let lastAllowReason = null
336
+ for (let i = 0; i < blocks.length; i++) {
337
+ if (blocks[i].kind === 'deliver') {
338
+ lastAllowBlockIdx = i
339
+ lastAllowReason = blocks[i].reason
340
+ }
237
341
  }
342
+ const sawUndeliveredTextAfterAllow = blocks
343
+ .slice(lastAllowBlockIdx + 1)
344
+ .some((b) => b.kind === 'text' && (b.chars ?? 0) >= FINAL_ANSWER_MIN_CHARS)
238
345
 
239
- const block = { decided: 'block', reason: 'no-final-reply' }
240
- if (envelope.chatId) {
241
- block.chatId = envelope.chatId
242
- block.threadId = envelope.threadId
243
- block.turnKey = buildTurnKey(envelope.chatId, envelope.threadId)
346
+ if (lastAllowBlockIdx === -1) {
347
+ // No qualifying delivery/silence event anywhere in the turn.
348
+ // Cron-fired turns (#2053): a scheduled turn that produced no
349
+ // qualifying reply is NOT a delivery failure the user is waiting
350
+ // on nagging it only pushes the model to escape the loop by
351
+ // shoving a NO_REPLY sentinel through the reply tool, which leaks
352
+ // to chat. A cron turn that genuinely needs to speak will have
353
+ // called reply (caught above); otherwise let it end silently.
354
+ if (envelope.source === 'cron') {
355
+ return { decided: 'allow', reason: 'cron-source' }
356
+ }
357
+ return buildBlockResult(envelope, 'no-final-reply')
244
358
  }
245
- return block
359
+
360
+ if (sawUndeliveredTextAfterAllow) {
361
+ // A qualifying delivery DID happen somewhere in the turn, but the
362
+ // model kept writing after it and that trailing content was never
363
+ // sent through a delivery tool. This is the "at least once" bug:
364
+ // an early ack (or any qualifying reply) must not amnesty
365
+ // everything written afterward.
366
+ return buildBlockResult(envelope, 'trailing-text-after-reply')
367
+ }
368
+
369
+ return { decided: 'allow', reason: lastAllowReason }
246
370
  }
@@ -26,6 +26,7 @@ export type OperatorEventKind =
26
26
  | 'agent-restarted-unexpectedly'
27
27
  | 'unknown-4xx'
28
28
  | 'unknown-5xx'
29
+ | 'config-warning'
29
30
 
30
31
  export interface OperatorEvent {
31
32
  kind: OperatorEventKind
@@ -375,6 +376,26 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
375
376
  ],
376
377
  },
377
378
  }
379
+
380
+ // Deliberately low-severity framing (ℹ️, Dismiss-only — no Restart /
381
+ // Reauth / Show-logs actions): config-warning is for boot-time config
382
+ // problems (e.g. a dropped `person_id` entry) that must be visible to
383
+ // the operator but MUST NOT read like a real outage or page anyone.
384
+ case 'config-warning':
385
+ return {
386
+ text: [
387
+ `ℹ️ **Config warning** for **${agent}**.`,
388
+ detail ? `_${detail}_` : '',
389
+ `Non-urgent — config will keep working with today's fallback behavior.`,
390
+ ]
391
+ .filter(Boolean)
392
+ .join('\n'),
393
+ keyboard: {
394
+ inline_keyboard: [
395
+ [{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(ev.agent)}` }],
396
+ ],
397
+ },
398
+ }
378
399
  }
379
400
  }
380
401