switchroom 0.18.10 → 0.18.12
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 +29 -5
- package/dist/auth-broker/index.js +53 -13
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/notion-write-pretool.mjs +29 -5
- package/dist/cli/switchroom.js +2636 -1369
- package/dist/cli/ui/apple-touch-icon.png +0 -0
- package/dist/cli/ui/favicon-32.png +0 -0
- package/dist/cli/ui/favicon.ico +0 -0
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +1248 -342
- package/dist/vault/approvals/kernel-server.js +54 -13
- package/dist/vault/broker/server.js +163 -114
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +73 -1
- package/telegram-plugin/dist/gateway/gateway.js +3603 -1007
- package/telegram-plugin/dist/server.js +74 -2
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/gateway/approval-hold.ts +583 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +76 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1482 -165
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +209 -2
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/send-gate-degraded.test.ts +443 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +698 -0
- package/telegram-plugin/send-gate.ts +982 -0
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +102 -2
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +296 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +284 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the dispatch-claim Stop hook (#396) — extracted so
|
|
3
|
+
* unit tests can exercise the scan logic without spawning the .mjs
|
|
4
|
+
* subprocess (mirrors the `silent-end-scan.mjs` extraction pattern).
|
|
5
|
+
*
|
|
6
|
+
* The defect (#396): the model sends a reply that TELLS the user it has
|
|
7
|
+
* dispatched work ("Dispatching a worker now to fix this") but the turn
|
|
8
|
+
* contains no `Agent`/`Task` tool call and no backgrounded `Bash` — the
|
|
9
|
+
* narration was never backed by an actual dispatch. The silent-end Stop
|
|
10
|
+
* gate (#1664) does not catch this: that turn HAS a qualifying final
|
|
11
|
+
* reply, so it passes untouched.
|
|
12
|
+
*
|
|
13
|
+
* Mechanism (Stop-hook only — the design comment on #396 drops the
|
|
14
|
+
* 2026-04 PreToolUse deny; a pre-send deny would fire before the Agent
|
|
15
|
+
* call exists and block legitimate reply-then-dispatch flows):
|
|
16
|
+
* 1. Walk the current turn's transcript slice (same turn-anchor walk as
|
|
17
|
+
* `silent-end-scan.mjs:scanTurnForFinalReply`), skipping
|
|
18
|
+
* `isSidechain:true` lines. A sub-agent's own reply/dispatch lines
|
|
19
|
+
* leak into the parent transcript with that marker; they are neither
|
|
20
|
+
* the parent's claim NOR the parent's dispatch, so they are skipped
|
|
21
|
+
* for BOTH detections.
|
|
22
|
+
* 2. Collect the text of qualifying `reply`/`stream_reply` tool_use
|
|
23
|
+
* calls (reuse `REPLY_TOOLS`).
|
|
24
|
+
* 3. Detect whether the turn actually dispatched: any non-sidechain
|
|
25
|
+
* `Agent`/`Task` tool_use, OR a `Bash` tool_use with
|
|
26
|
+
* `run_in_background: true`.
|
|
27
|
+
* 4. Block only when a reply text makes a first-person dispatch
|
|
28
|
+
* commitment AND the turn dispatched nothing.
|
|
29
|
+
*
|
|
30
|
+
* Registry-check decision (design comment "belt-and-braces" step 4):
|
|
31
|
+
* The design offered two options — (a) query the subagent-tracker
|
|
32
|
+
* `registry.db` for a dispatch row in this turn window, or (b) skip the
|
|
33
|
+
* registry and rely on transcript-only detection, in which case
|
|
34
|
+
* "past-tense phrasing must be excluded by the regex". We take (b). The
|
|
35
|
+
* transcript already carries the ground truth for THIS turn's dispatches
|
|
36
|
+
* (the `Agent`/`Task` tool_use blocks are in the same JSONL slice we
|
|
37
|
+
* walk), so a second sqlite round-trip from a dependency-free .mjs hook
|
|
38
|
+
* would be redundant and awkward. The registry's real value in the design
|
|
39
|
+
* was clearing PAST-tense references to earlier-turn dispatches; we
|
|
40
|
+
* achieve the same by EXCLUDING pure past-tense verb forms from the
|
|
41
|
+
* commitment regex (see DISPATCH_COMMIT_RE below) so "the worker I
|
|
42
|
+
* dispatched earlier" never trips the gate in the first place. This keeps
|
|
43
|
+
* the hook self-contained and deterministic (no cross-process DB read on
|
|
44
|
+
* the reply path).
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
// Same two MCP tools whose payload is the model's free-text answer text
|
|
48
|
+
// reaching the user (verified complete in silent-end-scan.mjs's header).
|
|
49
|
+
const REPLY_TOOLS = new Set([
|
|
50
|
+
'mcp__switchroom-telegram__reply',
|
|
51
|
+
'mcp__switchroom-telegram__stream_reply',
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
// Dispatch tool names — Claude Code emits the sub-agent dispatch tool under
|
|
55
|
+
// either the legacy `Agent` or the newer `Task` name depending on version
|
|
56
|
+
// (same dual-name recognition subagent-tracker-pretool.mjs uses).
|
|
57
|
+
const DISPATCH_TOOLS = new Set(['Agent', 'Task'])
|
|
58
|
+
|
|
59
|
+
// Silent marker (NO_REPLY / HEARTBEAT_OK + optional trailing punct) — a turn
|
|
60
|
+
// whose only reply is a bare silent marker made no claim about anything.
|
|
61
|
+
// Matches silent-end-scan.mjs SILENT_MARKER_RE.
|
|
62
|
+
const SILENT_MARKER_RE = /^(NO_REPLY|HEARTBEAT_OK)[\s.!?]*$/i
|
|
63
|
+
|
|
64
|
+
// First-person dispatch-commitment regex. Seed list from the #396 design
|
|
65
|
+
// comment:
|
|
66
|
+
// \b(dispatch(ing|ed)?|launch(ing|ed)?|spinning up|kick(ing|ed) off|
|
|
67
|
+
// delegat(ing|ed)|spawn(ing|ed)?)\b[^.?!]{0,60}\b(worker|researcher|
|
|
68
|
+
// reviewer|sub-?agent|agent|task)\b
|
|
69
|
+
//
|
|
70
|
+
// DEVIATION (documented above): because we skip the registry belt-and-
|
|
71
|
+
// braces check, we drop the PURE PAST-TENSE alternatives (`dispatched`,
|
|
72
|
+
// `launched`, `kicked off`, `delegated`, `spawned`) from the verb group.
|
|
73
|
+
// Present/progressive/bare-infinitive forms ("dispatching a worker",
|
|
74
|
+
// "I'll dispatch a worker", "spinning up a researcher") are commitments
|
|
75
|
+
// about THIS turn; a bare past-tense reference ("the worker I dispatched
|
|
76
|
+
// earlier") is about a prior turn and must not trip the gate. Keeping only
|
|
77
|
+
// the non-past forms is the transcript-only substitute for the registry's
|
|
78
|
+
// past-tense clearing.
|
|
79
|
+
const DISPATCH_COMMIT_RE =
|
|
80
|
+
/\b(?:dispatch(?:ing)?|launch(?:ing)?|spinning up|kick(?:ing)? off|delegat(?:e|ing)|spawn(?:ing)?)\b[^.?!]{0,60}?\b(?:worker|researcher|reviewer|sub-?agent|agent|task)\b/i
|
|
81
|
+
|
|
82
|
+
// Interrogative / conditional exclusion. A sentence offering to dispatch
|
|
83
|
+
// ("want me to dispatch a worker?", "I could spin up a reviewer if you
|
|
84
|
+
// like") is not a commitment that a dispatch HAPPENED — it is a question
|
|
85
|
+
// or a conditional, so it must not block. Seed list from the design
|
|
86
|
+
// comment: '?', could|should|would|want me to|shall I|if you.
|
|
87
|
+
const CONDITIONAL_RE = /\?|\b(?:could|should|would|want me to|shall i|if you)\b/i
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* True when `text` (one reply's payload) contains at least one SENTENCE
|
|
91
|
+
* that makes a first-person dispatch commitment and is not interrogative
|
|
92
|
+
* or conditional.
|
|
93
|
+
*
|
|
94
|
+
* Split into sentences first (retaining the trailing `.?!` terminator) so
|
|
95
|
+
* the conditional exclusion is scoped to the sentence carrying the verb —
|
|
96
|
+
* a later unrelated sentence with a '?' must not amnesty a real earlier
|
|
97
|
+
* commitment, and vice versa.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} text
|
|
100
|
+
* @returns {boolean}
|
|
101
|
+
*/
|
|
102
|
+
export function replyClaimsDispatch(text) {
|
|
103
|
+
if (typeof text !== 'string' || text.length === 0) return false
|
|
104
|
+
// Bare silent markers carry no claim.
|
|
105
|
+
if (SILENT_MARKER_RE.test(text.trim())) return false
|
|
106
|
+
const sentences = text.match(/[^.?!]+[.?!]*/g) || [text]
|
|
107
|
+
for (const sentence of sentences) {
|
|
108
|
+
if (!DISPATCH_COMMIT_RE.test(sentence)) continue
|
|
109
|
+
if (CONDITIONAL_RE.test(sentence)) continue
|
|
110
|
+
return true
|
|
111
|
+
}
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Compute a stable per-turn signature from the enqueue anchor line's raw
|
|
117
|
+
* content string (the inbound message envelope). Used by the hook's
|
|
118
|
+
* 1-retry budget so the budget resets on a genuinely new turn (a new
|
|
119
|
+
* enqueue line with a new message_id) but is preserved across the
|
|
120
|
+
* re-prompt of the SAME turn (same enqueue anchor — no new inbound).
|
|
121
|
+
*
|
|
122
|
+
* A tiny djb2 hash keeps the state file small and dependency-free.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} content
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
export function turnSignature(content) {
|
|
128
|
+
const s = typeof content === 'string' ? content : ''
|
|
129
|
+
let h = 5381
|
|
130
|
+
for (let i = 0; i < s.length; i++) {
|
|
131
|
+
h = ((h << 5) + h + s.charCodeAt(i)) >>> 0
|
|
132
|
+
}
|
|
133
|
+
return `t${h.toString(36)}`
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Scan a JSONL transcript and decide whether the current turn told the
|
|
138
|
+
* user it dispatched work without actually dispatching anything.
|
|
139
|
+
*
|
|
140
|
+
* Returns:
|
|
141
|
+
* { decided: 'allow', reason } — no unbacked dispatch claim
|
|
142
|
+
* { decided: 'block', reason, turnSig } — a reply claimed a dispatch
|
|
143
|
+
* but the turn dispatched
|
|
144
|
+
* nothing; `turnSig` anchors
|
|
145
|
+
* the hook's retry budget
|
|
146
|
+
* { decided: 'unknown', reason } — no turn-start anchor found;
|
|
147
|
+
* caller fails open
|
|
148
|
+
*
|
|
149
|
+
* Turn-start anchor: the most recent `queue-operation`/`enqueue` line
|
|
150
|
+
* (identical to `scanTurnForFinalReply`). Fail-open on any structural
|
|
151
|
+
* miss — a mis-firing gate must never wedge the reply path.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} jsonl
|
|
154
|
+
* @returns {{ decided: 'allow' | 'block' | 'unknown', reason: string, turnSig?: string }}
|
|
155
|
+
*/
|
|
156
|
+
export function scanTurnForDispatchClaim(jsonl) {
|
|
157
|
+
if (typeof jsonl !== 'string' || jsonl.length === 0) {
|
|
158
|
+
return { decided: 'unknown', reason: 'empty-transcript' }
|
|
159
|
+
}
|
|
160
|
+
const lines = jsonl.split('\n')
|
|
161
|
+
|
|
162
|
+
// 1. Walk backward to the most-recent enqueue (the turn-start anchor).
|
|
163
|
+
let startIdx = -1
|
|
164
|
+
let enqueueContent = ''
|
|
165
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
166
|
+
const line = lines[i]
|
|
167
|
+
if (!line || line[0] !== '{') continue
|
|
168
|
+
let obj
|
|
169
|
+
try { obj = JSON.parse(line) } catch { continue }
|
|
170
|
+
if (obj?.type === 'queue-operation' && obj.operation === 'enqueue') {
|
|
171
|
+
startIdx = i
|
|
172
|
+
enqueueContent = typeof obj.content === 'string' ? obj.content : ''
|
|
173
|
+
break
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (startIdx < 0) {
|
|
177
|
+
return { decided: 'unknown', reason: 'no-turn-start' }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// 2. Walk the turn forward, skipping sub-agent (isSidechain) lines for
|
|
181
|
+
// BOTH claim detection and dispatch detection. Collect reply texts;
|
|
182
|
+
// note whether the turn dispatched anything real.
|
|
183
|
+
let dispatched = false
|
|
184
|
+
const replyTexts = []
|
|
185
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
186
|
+
const line = lines[i]
|
|
187
|
+
if (!line || line[0] !== '{') continue
|
|
188
|
+
let obj
|
|
189
|
+
try { obj = JSON.parse(line) } catch { continue }
|
|
190
|
+
// Sidechain (sub-agent) lines are not the parent's claim NOR the
|
|
191
|
+
// parent's dispatch — skip entirely.
|
|
192
|
+
if (obj?.isSidechain === true) continue
|
|
193
|
+
if (obj?.type !== 'assistant') continue
|
|
194
|
+
const content = obj?.message?.content
|
|
195
|
+
if (!Array.isArray(content)) continue
|
|
196
|
+
for (const c of content) {
|
|
197
|
+
if (c?.type !== 'tool_use') continue
|
|
198
|
+
const name = c.name
|
|
199
|
+
if (DISPATCH_TOOLS.has(name)) {
|
|
200
|
+
dispatched = true
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
if (name === 'Bash') {
|
|
204
|
+
const input = c.input ?? {}
|
|
205
|
+
if (input.run_in_background === true) dispatched = true
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
if (REPLY_TOOLS.has(name)) {
|
|
209
|
+
const input = c.input ?? {}
|
|
210
|
+
replyTexts.push(String(input.text ?? ''))
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 3. If the turn actually dispatched work, the claim (if any) is backed
|
|
217
|
+
// — allow, regardless of reply phrasing.
|
|
218
|
+
if (dispatched) {
|
|
219
|
+
return { decided: 'allow', reason: 'dispatched' }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 4. No dispatch happened. Block iff a reply made a first-person
|
|
223
|
+
// dispatch commitment that was neither interrogative nor conditional.
|
|
224
|
+
const claimed = replyTexts.some((t) => replyClaimsDispatch(t))
|
|
225
|
+
if (claimed) {
|
|
226
|
+
return { decided: 'block', reason: 'claim-without-dispatch', turnSig: turnSignature(enqueueContent) }
|
|
227
|
+
}
|
|
228
|
+
return { decided: 'allow', reason: 'no-claim' }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Pure 1-retry-budget helper for the Stop hook. Mirrors the
|
|
233
|
+
* `silent-end-interrupt-stop.mjs` MAX_RETRIES pattern but with its own
|
|
234
|
+
* state shape, keyed on the per-turn signature so the budget resets on a
|
|
235
|
+
* new turn and is honoured across the re-prompt of the same turn.
|
|
236
|
+
*
|
|
237
|
+
* Returns:
|
|
238
|
+
* { block: true, nextState } — budget available; write nextState and block
|
|
239
|
+
* { block: false } — budget exhausted for this turn; fail open
|
|
240
|
+
*
|
|
241
|
+
* @param {{ turnSig?: string, retryCount?: number } | null | undefined} existingState
|
|
242
|
+
* @param {string} turnSig
|
|
243
|
+
* @param {number} maxRetries
|
|
244
|
+
* @returns {{ block: boolean, nextState?: { turnSig: string, retryCount: number, timestamp: number } }}
|
|
245
|
+
*/
|
|
246
|
+
export function applyRetryBudget(existingState, turnSig, maxRetries) {
|
|
247
|
+
const prev = existingState && typeof existingState === 'object' ? existingState : {}
|
|
248
|
+
// Only carry the counter forward when it belongs to the SAME turn;
|
|
249
|
+
// otherwise this is a fresh turn and the budget starts at 0.
|
|
250
|
+
const prevCount =
|
|
251
|
+
prev.turnSig === turnSig && typeof prev.retryCount === 'number' ? prev.retryCount : 0
|
|
252
|
+
if (prevCount >= maxRetries) {
|
|
253
|
+
return { block: false }
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
block: true,
|
|
257
|
+
nextState: { turnSig, retryCount: prevCount + 1, timestamp: Date.now() },
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Stop hook (#396) — deterministic guardrail against "narrate a dispatch
|
|
4
|
+
* without executing it".
|
|
5
|
+
*
|
|
6
|
+
* The defect: the model replies "Dispatching a worker now to fix this",
|
|
7
|
+
* the user sees a confident hand-off, but the turn contains no `Agent` /
|
|
8
|
+
* `Task` tool call and no backgrounded `Bash` — nothing was actually
|
|
9
|
+
* dispatched. The #1664 silent-end Stop gate does NOT catch this: that
|
|
10
|
+
* turn HAS a qualifying final reply, so it passes untouched.
|
|
11
|
+
*
|
|
12
|
+
* This hook, at Stop, scans the just-finished turn's transcript slice for
|
|
13
|
+
* a reply that makes a first-person dispatch commitment while the turn
|
|
14
|
+
* dispatched nothing, and re-prompts the model exactly once to actually
|
|
15
|
+
* dispatch (or send a correction reply). See `dispatch-claim-scan.mjs`
|
|
16
|
+
* for the pure decision logic and the registry-check rationale.
|
|
17
|
+
*
|
|
18
|
+
* Protocol (Claude Code Stop hook v1):
|
|
19
|
+
* Input: JSON on stdin — { session_id, transcript_path, ... }
|
|
20
|
+
* Output: exit 0 + empty stdout → allow stop.
|
|
21
|
+
* exit 0 + JSON stdout { decision: "block", reason } → re-prompt.
|
|
22
|
+
*
|
|
23
|
+
* Fail-open on EVERY error path (no transcript / unreadable / no
|
|
24
|
+
* turn-start anchor / state-file failure) — consistent with every sibling
|
|
25
|
+
* reply-path hook. A mis-firing gate must never loop a session; the
|
|
26
|
+
* 1-retry budget bounds the block direction on top of that.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
30
|
+
import { join } from 'node:path'
|
|
31
|
+
import { homedir } from 'node:os'
|
|
32
|
+
|
|
33
|
+
import { scanTurnForDispatchClaim, applyRetryBudget } from './dispatch-claim-scan.mjs'
|
|
34
|
+
|
|
35
|
+
// Exactly one re-prompt per turn (#396 design). A second Stop fire on the
|
|
36
|
+
// same turn (same turnSig) exhausts the budget and fails open.
|
|
37
|
+
const MAX_RETRIES = 1
|
|
38
|
+
|
|
39
|
+
function readStdin() {
|
|
40
|
+
try {
|
|
41
|
+
return readFileSync(0, 'utf8')
|
|
42
|
+
} catch {
|
|
43
|
+
return ''
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getStateDir() {
|
|
48
|
+
return process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function main() {
|
|
52
|
+
const raw = readStdin().trim()
|
|
53
|
+
if (!raw) process.exit(0)
|
|
54
|
+
|
|
55
|
+
let event
|
|
56
|
+
try {
|
|
57
|
+
event = JSON.parse(raw)
|
|
58
|
+
} catch {
|
|
59
|
+
process.exit(0)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const transcriptPath = event?.transcript_path
|
|
63
|
+
if (!transcriptPath || typeof transcriptPath !== 'string' || !existsSync(transcriptPath)) {
|
|
64
|
+
process.exit(0)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let jsonl
|
|
68
|
+
try {
|
|
69
|
+
jsonl = readFileSync(transcriptPath, 'utf8')
|
|
70
|
+
} catch (err) {
|
|
71
|
+
process.stderr.write(
|
|
72
|
+
`[dispatch-claim] failed to read transcript ${transcriptPath}: ${err.message}\n`,
|
|
73
|
+
)
|
|
74
|
+
process.exit(0)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const decision = scanTurnForDispatchClaim(jsonl)
|
|
78
|
+
|
|
79
|
+
// 'allow' (no unbacked claim) and 'unknown' (no turn-start anchor) both
|
|
80
|
+
// allow the stop.
|
|
81
|
+
if (decision.decided !== 'block') {
|
|
82
|
+
process.exit(0)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 1-retry budget, keyed on the per-turn signature so a second Stop fire
|
|
86
|
+
// on the SAME turn fails open while a genuinely new turn starts fresh.
|
|
87
|
+
const statePath = join(getStateDir(), 'dispatch-claim-pending.json')
|
|
88
|
+
|
|
89
|
+
let state = {}
|
|
90
|
+
if (existsSync(statePath)) {
|
|
91
|
+
try {
|
|
92
|
+
state = JSON.parse(readFileSync(statePath, 'utf8'))
|
|
93
|
+
} catch {
|
|
94
|
+
state = {}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const budget = applyRetryBudget(state, decision.turnSig, MAX_RETRIES)
|
|
99
|
+
if (!budget.block) {
|
|
100
|
+
process.stderr.write(
|
|
101
|
+
`[dispatch-claim] retry budget exhausted for turn ${decision.turnSig} — allowing stop\n`,
|
|
102
|
+
)
|
|
103
|
+
process.exit(0)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
writeFileSync(statePath, JSON.stringify(budget.nextState), 'utf8')
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// Fail-open: a retry-count write failure must not loop the session.
|
|
110
|
+
process.stderr.write(`[dispatch-claim] failed to update state file: ${err.message}\n`)
|
|
111
|
+
process.exit(0)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
process.stderr.write(
|
|
115
|
+
`[dispatch-claim] blocking stop to re-prompt agent (reason=${decision.reason} turn=${decision.turnSig})\n`,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
process.stdout.write(
|
|
119
|
+
JSON.stringify({
|
|
120
|
+
decision: 'block',
|
|
121
|
+
reason:
|
|
122
|
+
'Your reply told the user you dispatched work, but no Agent/Task call ' +
|
|
123
|
+
'ran this turn. Actually dispatch it now, or send a correction reply.',
|
|
124
|
+
}),
|
|
125
|
+
)
|
|
126
|
+
process.exit(0)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
main()
|
|
@@ -91,6 +91,15 @@
|
|
|
91
91
|
}
|
|
92
92
|
]
|
|
93
93
|
},
|
|
94
|
+
{
|
|
95
|
+
"hooks": [
|
|
96
|
+
{
|
|
97
|
+
"type": "command",
|
|
98
|
+
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.sh\" node \"${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-claim-stop.mjs\"",
|
|
99
|
+
"timeout": 5
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
},
|
|
94
103
|
{
|
|
95
104
|
"hooks": [
|
|
96
105
|
{
|
|
@@ -60,10 +60,25 @@ export interface AgentButtonMeta {
|
|
|
60
60
|
* removes the entire keyboard to prevent double-fire.
|
|
61
61
|
*/
|
|
62
62
|
single_use?: boolean
|
|
63
|
+
/**
|
|
64
|
+
* Per-message override for the button-choice-confirmation annotation
|
|
65
|
+
* (#789). When true, tapping annotates the source message body with a
|
|
66
|
+
* "✅ You chose: <label> · HH:MM" line even if the agent default is off;
|
|
67
|
+
* when false, annotation is skipped even if the agent default is on.
|
|
68
|
+
* Undefined falls back to the agent's
|
|
69
|
+
* channels.telegram.button_choice_confirmation.enabled default. Only takes
|
|
70
|
+
* effect on single-use keyboards (re-tappable keyboards are never
|
|
71
|
+
* annotated).
|
|
72
|
+
*/
|
|
73
|
+
inline_keyboard_confirm?: boolean
|
|
63
74
|
}
|
|
64
75
|
|
|
65
76
|
/** Fields the gateway adds to button objects — not valid Telegram API fields. */
|
|
66
|
-
const AGENT_META_FIELDS: ReadonlyArray<keyof AgentButtonMeta> = [
|
|
77
|
+
const AGENT_META_FIELDS: ReadonlyArray<keyof AgentButtonMeta> = [
|
|
78
|
+
'ack_text',
|
|
79
|
+
'single_use',
|
|
80
|
+
'inline_keyboard_confirm',
|
|
81
|
+
]
|
|
67
82
|
|
|
68
83
|
/**
|
|
69
84
|
* Wrap every callback_data field in a 2D inline-keyboard with the
|
|
@@ -116,7 +131,14 @@ export function extractAgentButtonMeta(
|
|
|
116
131
|
const meta: AgentButtonMeta = {}
|
|
117
132
|
if (typeof btn.ack_text === 'string') meta.ack_text = btn.ack_text
|
|
118
133
|
if (typeof btn.single_use === 'boolean') meta.single_use = btn.single_use
|
|
119
|
-
if (
|
|
134
|
+
if (typeof btn.inline_keyboard_confirm === 'boolean') {
|
|
135
|
+
meta.inline_keyboard_confirm = btn.inline_keyboard_confirm
|
|
136
|
+
}
|
|
137
|
+
if (
|
|
138
|
+
meta.ack_text != null ||
|
|
139
|
+
meta.single_use != null ||
|
|
140
|
+
meta.inline_keyboard_confirm != null
|
|
141
|
+
) {
|
|
120
142
|
out.set(btn.callback_data, meta)
|
|
121
143
|
}
|
|
122
144
|
}
|
|
@@ -149,6 +171,191 @@ export function parseAgentCallback(data: string): { raw: string } | null {
|
|
|
149
171
|
return { raw: data.slice(AGENT_CALLBACK_PREFIX.length) }
|
|
150
172
|
}
|
|
151
173
|
|
|
174
|
+
// ─── #789 button-choice-confirmation ("✅ You chose: X") ──────────────────
|
|
175
|
+
//
|
|
176
|
+
// When a user taps an agent-emitted single-use inline_keyboard button, the
|
|
177
|
+
// gateway can annotate the source message body with a
|
|
178
|
+
// "✅ You chose: <label> · HH:MM" line so the chat surface is
|
|
179
|
+
// self-documenting (mirrors the ask_user finalize UX). The DECISION and the
|
|
180
|
+
// rendered text are pure functions here so they can be unit-tested against
|
|
181
|
+
// the exact payload the gateway ships — the gateway owns only the Telegram
|
|
182
|
+
// I/O and the once-per-process warning dedupe.
|
|
183
|
+
|
|
184
|
+
/** Per-agent button-choice-confirmation config (projected into access.json). */
|
|
185
|
+
export interface ButtonChoiceConfirmationConfig {
|
|
186
|
+
enabled?: boolean
|
|
187
|
+
format?: string
|
|
188
|
+
timezone?: 'gateway' | 'utc'
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Default annotation template. `{label}` and `{time}` are substituted. */
|
|
192
|
+
export const BUTTON_CONFIRM_DEFAULT_FORMAT = '✅ You chose: {label} · {time}'
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* HTML-entity escaper for the annotation payload (shipped with
|
|
196
|
+
* parse_mode: 'HTML'). Escapes exactly the three characters Telegram's HTML
|
|
197
|
+
* parser treats specially — `&`, `<`, `>` — so arbitrary button labels and
|
|
198
|
+
* source-message text can never 400 the editMessageText call. `&` is escaped
|
|
199
|
+
* first so freshly produced entities aren't double-escaped. This is NOT the
|
|
200
|
+
* GFM-markdown escaper (#2669) — that one escapes backticks/underscores and
|
|
201
|
+
* would garble text (`Do_it` → `Do\_it`) under HTML parse mode.
|
|
202
|
+
*/
|
|
203
|
+
export function escapeHtmlEntities(s: string): string {
|
|
204
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Dedup-strip regex: matches a prior DEFAULT-shape annotation appended to the
|
|
209
|
+
* body so a retap replaces rather than duplicates it. The `<b>`/`</b>` tags
|
|
210
|
+
* are optional because Telegram returns `message.text` as PLAIN text (entity
|
|
211
|
+
* markup lives in `message.entities`, not in the text) — on a real retap the
|
|
212
|
+
* prior annotation arrives without tags. The `s` (dotAll) flag tolerates a
|
|
213
|
+
* stray newline inside an older, un-stripped label (belt-and-braces alongside
|
|
214
|
+
* the newline-stripping applied to the label on the way in). NOTE: dedup only
|
|
215
|
+
* recognizes the DEFAULT annotation shape — a custom
|
|
216
|
+
* button_choice_confirmation.format that diverges from it accumulates one
|
|
217
|
+
* line per retap instead of replacing.
|
|
218
|
+
*/
|
|
219
|
+
export const BUTTON_CONFIRM_STRIP_RE =
|
|
220
|
+
/\n\n✅ You chose: (?:<b>)?.*(?:<\/b>)? · \d{2}:\d{2}$/s
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Build the confirmation line. `{label}` → the tapped button's text with
|
|
224
|
+
* newlines collapsed to spaces, trimmed to 60 chars (mirrors the toast trim),
|
|
225
|
+
* HTML-entity-escaped (`&`, `<`, `>`) and wrapped in `<b>`; `{time}` →
|
|
226
|
+
* HH:MM in the chosen timezone. `now`/`escapeLabel` are injectable for
|
|
227
|
+
* deterministic tests; `escapeLabel` defaults to the real
|
|
228
|
+
* {@link escapeHtmlEntities}. Substitutions use replacer FUNCTIONS so
|
|
229
|
+
* String.replace special patterns (`$&`, `$'`, …) in labels are inert.
|
|
230
|
+
*/
|
|
231
|
+
export function buildButtonConfirmation(args: {
|
|
232
|
+
template: string
|
|
233
|
+
label: string
|
|
234
|
+
timezone: 'gateway' | 'utc'
|
|
235
|
+
escapeLabel?: (s: string) => string
|
|
236
|
+
now?: Date
|
|
237
|
+
}): string {
|
|
238
|
+
const escape = args.escapeLabel ?? escapeHtmlEntities
|
|
239
|
+
const cleanLabel = args.label.replace(/\n/g, ' ').slice(0, 60)
|
|
240
|
+
const now = args.now ?? new Date()
|
|
241
|
+
const hh = args.timezone === 'utc'
|
|
242
|
+
? String(now.getUTCHours()).padStart(2, '0')
|
|
243
|
+
: String(now.getHours()).padStart(2, '0')
|
|
244
|
+
const mm = args.timezone === 'utc'
|
|
245
|
+
? String(now.getUTCMinutes()).padStart(2, '0')
|
|
246
|
+
: String(now.getMinutes()).padStart(2, '0')
|
|
247
|
+
return args.template
|
|
248
|
+
.replace('{label}', () => `<b>${escape(cleanLabel)}</b>`)
|
|
249
|
+
.replace('{time}', () => `${hh}:${mm}`)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Outcome of the annotate-or-strip decision for a single tap. */
|
|
253
|
+
export interface TapAnnotationResult {
|
|
254
|
+
/** True → gateway should editMessageText with `text` (and strip keyboard). */
|
|
255
|
+
annotate: boolean
|
|
256
|
+
/** Annotated body text, present iff `annotate` is true. */
|
|
257
|
+
text?: string
|
|
258
|
+
/** True → gateway should emit the once-per-process parseMode warning. */
|
|
259
|
+
warnParseMode: boolean
|
|
260
|
+
/** True → gateway should emit the once-per-process single_use-mismatch warning. */
|
|
261
|
+
warnSingleUseMismatch: boolean
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Pure decision for the #789 annotation. Resolves the per-message override
|
|
266
|
+
* (the tapped button's `inline_keyboard_confirm`) over the agent default,
|
|
267
|
+
* gates on single-use + presence of body text + a resolvable label + the
|
|
268
|
+
* `html` parse mode, and renders the annotated body (with any prior
|
|
269
|
+
* default-shape annotation stripped so a retap replaces it). Returns
|
|
270
|
+
* `annotate:false` for every skip path; the caller still performs the
|
|
271
|
+
* historical keyboard-only strip when the keyboard is single-use.
|
|
272
|
+
*
|
|
273
|
+
* The base body is HTML-entity-escaped too (Telegram hands us `message.text`
|
|
274
|
+
* as plain text, so `&`/`<`/`>` in it would otherwise 400 the HTML edit).
|
|
275
|
+
* Known limitation: because the body is rebuilt from `message.text`, any
|
|
276
|
+
* entities/formatting (bold, links, …) on the original message are lost on
|
|
277
|
+
* annotation. Documented in the config schema + CHANGELOG.
|
|
278
|
+
*/
|
|
279
|
+
export function resolveTapAnnotation(args: {
|
|
280
|
+
perMessageOverride?: boolean
|
|
281
|
+
singleUse: boolean
|
|
282
|
+
config?: ButtonChoiceConfirmationConfig
|
|
283
|
+
parseMode: 'html' | 'markdownv2' | 'text'
|
|
284
|
+
sourceText?: string
|
|
285
|
+
label?: string
|
|
286
|
+
escapeLabel?: (s: string) => string
|
|
287
|
+
now?: Date
|
|
288
|
+
}): TapAnnotationResult {
|
|
289
|
+
const shouldAnnotate = args.perMessageOverride ?? args.config?.enabled ?? false
|
|
290
|
+
const warnSingleUseMismatch = shouldAnnotate && !args.singleUse
|
|
291
|
+
|
|
292
|
+
const wantAnnotate =
|
|
293
|
+
shouldAnnotate &&
|
|
294
|
+
args.singleUse &&
|
|
295
|
+
args.sourceText != null &&
|
|
296
|
+
args.label != null
|
|
297
|
+
if (!wantAnnotate) {
|
|
298
|
+
return { annotate: false, warnParseMode: false, warnSingleUseMismatch }
|
|
299
|
+
}
|
|
300
|
+
if (args.parseMode !== 'html') {
|
|
301
|
+
return { annotate: false, warnParseMode: true, warnSingleUseMismatch }
|
|
302
|
+
}
|
|
303
|
+
const escape = args.escapeLabel ?? escapeHtmlEntities
|
|
304
|
+
// Strip a prior annotation from the RAW text first (Telegram delivers it
|
|
305
|
+
// un-tagged), then entity-escape the remainder for the HTML edit.
|
|
306
|
+
const base = escape(
|
|
307
|
+
(args.sourceText as string).replace(BUTTON_CONFIRM_STRIP_RE, ''),
|
|
308
|
+
)
|
|
309
|
+
const formatted = buildButtonConfirmation({
|
|
310
|
+
template: args.config?.format ?? BUTTON_CONFIRM_DEFAULT_FORMAT,
|
|
311
|
+
label: args.label as string,
|
|
312
|
+
timezone: args.config?.timezone ?? 'gateway',
|
|
313
|
+
escapeLabel: escape,
|
|
314
|
+
...(args.now != null ? { now: args.now } : {}),
|
|
315
|
+
})
|
|
316
|
+
return {
|
|
317
|
+
annotate: true,
|
|
318
|
+
text: `${base}\n\n${formatted}`,
|
|
319
|
+
warnParseMode: false,
|
|
320
|
+
warnSingleUseMismatch,
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Perform the annotation edit against Telegram, with a keyboard-strip
|
|
326
|
+
* fallback: if the editMessageText 400s/rejects for ANY reason (over-long
|
|
327
|
+
* body, HTML edge case, message too old, …), we still strip the inline
|
|
328
|
+
* keyboard via editMessageReplyMarkup so single-use protection holds even
|
|
329
|
+
* though the button meta has already been consumed. Extracted here (with the
|
|
330
|
+
* two Telegram calls injected) so the fallback is unit-testable.
|
|
331
|
+
*
|
|
332
|
+
* Returns 'annotated' | 'stripped-fallback' | 'failed' for observability.
|
|
333
|
+
*/
|
|
334
|
+
export async function applyTapAnnotationEdit(io: {
|
|
335
|
+
editMessageText: (text: string, other: {
|
|
336
|
+
parse_mode: 'HTML'
|
|
337
|
+
reply_markup: { inline_keyboard: never[] }
|
|
338
|
+
}) => Promise<unknown>
|
|
339
|
+
editMessageReplyMarkup: (other: {
|
|
340
|
+
reply_markup: { inline_keyboard: never[] }
|
|
341
|
+
}) => Promise<unknown>
|
|
342
|
+
}, text: string): Promise<'annotated' | 'stripped-fallback' | 'failed'> {
|
|
343
|
+
try {
|
|
344
|
+
await io.editMessageText(text, {
|
|
345
|
+
parse_mode: 'HTML',
|
|
346
|
+
reply_markup: { inline_keyboard: [] },
|
|
347
|
+
})
|
|
348
|
+
return 'annotated'
|
|
349
|
+
} catch {
|
|
350
|
+
try {
|
|
351
|
+
await io.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } })
|
|
352
|
+
return 'stripped-fallback'
|
|
353
|
+
} catch {
|
|
354
|
+
return 'failed'
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
152
359
|
/**
|
|
153
360
|
* Convenience: validate + wrap in one call. Returns either the
|
|
154
361
|
* wrapped keyboard or a structured error list — caller throws so the
|
|
@@ -28,6 +28,7 @@ export type OperatorEventKind =
|
|
|
28
28
|
| 'unknown-5xx'
|
|
29
29
|
| 'config-warning'
|
|
30
30
|
| 'always-allow-persist-failed'
|
|
31
|
+
| 'mental-model-persist-failed'
|
|
31
32
|
|
|
32
33
|
export interface OperatorEvent {
|
|
33
34
|
kind: OperatorEventKind
|
|
@@ -420,6 +421,28 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
|
|
|
420
421
|
],
|
|
421
422
|
},
|
|
422
423
|
}
|
|
424
|
+
|
|
425
|
+
// #2975 Stage 1 — an operator-APPROVED mental-model persist hit the
|
|
426
|
+
// config_propose_edit rate limit, and the ONE scheduled retry at the
|
|
427
|
+
// window-open time ALSO failed. MUST be a NEW message (not a card edit):
|
|
428
|
+
// the proposal card was already edited to the "applying at HH:MM" state
|
|
429
|
+
// and card edits don't ping, so a silent edit here would leave the lost
|
|
430
|
+
// approval unnoticed. Ask the operator to re-propose so it isn't dropped.
|
|
431
|
+
case 'mental-model-persist-failed':
|
|
432
|
+
return {
|
|
433
|
+
text: [
|
|
434
|
+
`⚠️ **${agent}**'s approved mental model didn't save.`,
|
|
435
|
+
detail ? `_${detail}_` : '',
|
|
436
|
+
`The retry after the rate window also failed — ask the agent to re-propose it.`,
|
|
437
|
+
]
|
|
438
|
+
.filter(Boolean)
|
|
439
|
+
.join('\n'),
|
|
440
|
+
keyboard: {
|
|
441
|
+
inline_keyboard: [
|
|
442
|
+
[{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(ev.agent)}` }],
|
|
443
|
+
],
|
|
444
|
+
},
|
|
445
|
+
}
|
|
423
446
|
}
|
|
424
447
|
}
|
|
425
448
|
|