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.
- package/dist/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +2 -1
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +119 -74
- package/dist/host-control/main.js +3 -2
- package/dist/vault/approvals/kernel-server.js +2 -1
- package/dist/vault/broker/server.js +37 -8
- package/package.json +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +15 -1
- package/telegram-plugin/dist/gateway/gateway.js +922 -459
- package/telegram-plugin/gateway/gateway.ts +260 -21
- package/telegram-plugin/gateway/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/operator-events.ts +21 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/subagent-watcher.ts +27 -9
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/operator-events.test.ts +16 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +61 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +97 -0
- package/telegram-plugin/worker-activity-feed.ts +220 -15
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- 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
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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.
|
|
182
|
-
//
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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 line — emitted 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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
|