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.
Files changed (125) hide show
  1. package/dist/agent-scheduler/index.js +29 -5
  2. package/dist/auth-broker/index.js +53 -13
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/notion-write-pretool.mjs +29 -5
  5. package/dist/cli/switchroom.js +2636 -1369
  6. package/dist/cli/ui/apple-touch-icon.png +0 -0
  7. package/dist/cli/ui/favicon-32.png +0 -0
  8. package/dist/cli/ui/favicon.ico +0 -0
  9. package/dist/cli/ui/index.html +163 -17
  10. package/dist/host-control/main.js +1248 -342
  11. package/dist/vault/approvals/kernel-server.js +54 -13
  12. package/dist/vault/broker/server.js +163 -114
  13. package/package.json +3 -4
  14. package/profiles/_base/start.sh.hbs +65 -0
  15. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  16. package/profiles/coding/CLAUDE.md.hbs +1 -1
  17. package/profiles/default/CLAUDE.md.hbs +2 -2
  18. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  19. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  20. package/telegram-plugin/bridge/bridge.ts +37 -0
  21. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  22. package/telegram-plugin/dist/bridge/bridge.js +73 -1
  23. package/telegram-plugin/dist/gateway/gateway.js +3603 -1007
  24. package/telegram-plugin/dist/server.js +74 -2
  25. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  26. package/telegram-plugin/gateway/approval-hold.ts +583 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +76 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1482 -165
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  54. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  55. package/telegram-plugin/hooks/hooks.json +9 -0
  56. package/telegram-plugin/inline-keyboard-callbacks.ts +209 -2
  57. package/telegram-plugin/operator-events.ts +23 -0
  58. package/telegram-plugin/package.json +0 -1
  59. package/telegram-plugin/permission-rule.ts +1 -0
  60. package/telegram-plugin/permission-title.ts +1 -0
  61. package/telegram-plugin/retry-api-call.ts +212 -2
  62. package/telegram-plugin/send-gate-degraded.test.ts +443 -0
  63. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  64. package/telegram-plugin/send-gate-observability.ts +355 -0
  65. package/telegram-plugin/send-gate.test.ts +698 -0
  66. package/telegram-plugin/send-gate.ts +982 -0
  67. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  68. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  69. package/telegram-plugin/status-pin-driver.ts +52 -7
  70. package/telegram-plugin/status-pin.ts +81 -0
  71. package/telegram-plugin/subagent-watcher.ts +102 -2
  72. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  73. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  74. package/telegram-plugin/tests/approval-hold-outcome.test.ts +296 -0
  75. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  76. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  77. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  78. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  79. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  80. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  81. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  82. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  83. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  84. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  85. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  86. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  87. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  88. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  89. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +284 -0
  90. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  91. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  92. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  93. package/telegram-plugin/tests/model-command.test.ts +14 -0
  94. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  95. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  96. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  97. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  98. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  99. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  100. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  101. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  102. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  103. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  104. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  105. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  106. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  107. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  108. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  109. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  110. package/telegram-plugin/typing-emitter.ts +224 -0
  111. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  112. package/telegram-plugin/welcome-text.ts +42 -0
  113. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  114. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  115. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  116. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  117. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  118. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  119. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  120. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  121. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  122. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  123. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  124. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  125. 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> = ['ack_text', 'single_use']
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 (meta.ack_text != null || meta.single_use != null) {
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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
 
@@ -30,7 +30,6 @@
30
30
  "@mtcute/node": "^0.30.1",
31
31
  "@secretlint/core": "^12.2.0",
32
32
  "@secretlint/secretlint-rule-preset-recommend": "^12.2.0",
33
- "@secretlint/types": "^12.2.0",
34
33
  "@xterm/headless": "^6.0.0",
35
34
  "grammy": "^1.44",
36
35
  "mdast-util-from-markdown": "^2.0.2",