switchroom 0.18.11 → 0.18.13
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 +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- 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 +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -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 +88 -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 +1695 -230
- 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/history.ts +51 -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 +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -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/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -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 +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- 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 +173 -18
- 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 +327 -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/callback-query-handlers.test.ts +65 -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/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -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/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -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/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -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/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -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/subagent-watcher-fd-leak.test.ts +275 -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/tests/worktree-watch-cwds.test.ts +215 -1
- 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/telegram-plugin/worktree-watch-cwds.ts +194 -5
- 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
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -178,6 +178,57 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
|
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
// Migration (review finding H5): make INSERT OR REPLACE idempotent for thread-less rows.
|
|
182
|
+
//
|
|
183
|
+
// The table's PRIMARY KEY is (chat_id, thread_id, message_id), but thread_id
|
|
184
|
+
// is nullable and SQLite treats NULL as DISTINCT from NULL in a PK/UNIQUE
|
|
185
|
+
// index. Every DM and every non-topic group message has thread_id = NULL, so
|
|
186
|
+
// two inserts of the same (chat_id, message_id) with NULL thread do NOT
|
|
187
|
+
// conflict — `INSERT OR REPLACE` APPENDS a duplicate row instead of replacing
|
|
188
|
+
// it. On the documented at-least-once boot replay / synthesized-resume
|
|
189
|
+
// re-record, an already-stored message is duplicated, inflating
|
|
190
|
+
// get_recent_messages and the getRecentOutboundCount / hasOutboundDeliveredSince
|
|
191
|
+
// counters that feed the silence / over-ping detectors.
|
|
192
|
+
//
|
|
193
|
+
// Fix: a UNIQUE index over COALESCE(thread_id, '') gives every logical
|
|
194
|
+
// (chat, thread-or-general, message_id) key a NON-null uniqueness value, so
|
|
195
|
+
// REPLACE conflict-resolves and dedupes thread-less rows too. INSERT OR
|
|
196
|
+
// REPLACE resolves against ANY unique index, so no writer change is needed.
|
|
197
|
+
// A forum-topic row (non-null thread) and a general row (NULL thread) that
|
|
198
|
+
// share a message_id keep DISTINCT keys (the topic id vs ''), so they stay
|
|
199
|
+
// separate. Stored thread_id values remain real NULLs, so every read path
|
|
200
|
+
// (`thread_id IS NULL` / `thread_id = ?`) is unchanged.
|
|
201
|
+
const LOGICAL_KEY_INDEX = 'idx_messages_logical_key'
|
|
202
|
+
const logicalKeyIndexExists =
|
|
203
|
+
db
|
|
204
|
+
.prepare(`SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?`)
|
|
205
|
+
.get(LOGICAL_KEY_INDEX) != null
|
|
206
|
+
if (!logicalKeyIndexExists) {
|
|
207
|
+
// De-dupe rows an earlier (pre-fix) build already appended, keeping the
|
|
208
|
+
// NEWEST row per logical key (highest ts, then highest rowid = the last
|
|
209
|
+
// write, which is what INSERT OR REPLACE would have left). This MUST run
|
|
210
|
+
// before the UNIQUE index is created, or CREATE UNIQUE INDEX would fail on
|
|
211
|
+
// the existing duplicates. On a fresh/empty DB it is a harmless no-op.
|
|
212
|
+
db.exec(`
|
|
213
|
+
DELETE FROM messages
|
|
214
|
+
WHERE rowid NOT IN (
|
|
215
|
+
SELECT keep_rowid FROM (
|
|
216
|
+
SELECT rowid AS keep_rowid,
|
|
217
|
+
ROW_NUMBER() OVER (
|
|
218
|
+
PARTITION BY chat_id, COALESCE(thread_id, ''), message_id
|
|
219
|
+
ORDER BY ts DESC, rowid DESC
|
|
220
|
+
) AS rn
|
|
221
|
+
FROM messages
|
|
222
|
+
)
|
|
223
|
+
WHERE rn = 1
|
|
224
|
+
)
|
|
225
|
+
`)
|
|
226
|
+
db.exec(
|
|
227
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS ${LOGICAL_KEY_INDEX} ` +
|
|
228
|
+
`ON messages (chat_id, COALESCE(thread_id, ''), message_id)`,
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
181
232
|
// Readable by owner and others so the web dashboard (different uid than the
|
|
182
233
|
// agent) can stream replies back to Hermes Desktop. The WAL sidecar files
|
|
183
234
|
// (-shm/-wal) are also chmod'd so SQLite readonly opens succeed for uid=1000.
|
|
@@ -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
|
{
|