switchroom 0.20.9 → 0.20.10
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/bin/handoff-briefing.sh +57 -5
- package/bin/working-state-reload-hook.sh +262 -0
- package/dist/auth-broker/index.js +19 -1
- package/dist/cli/switchroom.js +31 -4
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +49 -0
- package/telegram-plugin/dist/gateway/gateway.js +13 -4
- package/vendor/hindsight-memory/CHANGELOG.md +31 -0
- package/vendor/hindsight-memory/hooks/hooks.json +2 -1
- package/vendor/hindsight-memory/scripts/session_start.py +35 -8
- package/vendor/hindsight-memory/scripts/tests/test_session_start_durability.py +107 -0
package/bin/handoff-briefing.sh
CHANGED
|
@@ -34,9 +34,32 @@
|
|
|
34
34
|
# - AGENT_DIR — output destination (if HANDOFF_BRIEFING_STDOUT!=1)
|
|
35
35
|
#
|
|
36
36
|
# Usage:
|
|
37
|
-
# handoff-briefing.sh [--stdout]
|
|
37
|
+
# handoff-briefing.sh [--stdout] [--lean]
|
|
38
38
|
#
|
|
39
39
|
# The --stdout flag overrides HANDOFF_BRIEFING_STDOUT=1.
|
|
40
|
+
#
|
|
41
|
+
# LEAN MODE (--lean, alias --mode=compaction)
|
|
42
|
+
# -------------------------------------------
|
|
43
|
+
# The compaction re-seat path (bin/working-state-reload-hook.sh, wired as the
|
|
44
|
+
# SessionStart(compact) hook) invokes this script with --lean so BOTH legacy-
|
|
45
|
+
# and gateway-briefing agents share ONE assembler at the compaction boundary —
|
|
46
|
+
# no copy of the sqlite/recall logic lives in the hook. Lean mode differs from
|
|
47
|
+
# the boot briefing in three deliberate ways:
|
|
48
|
+
#
|
|
49
|
+
# 1. It emits ONLY the recent-Telegram-tail + Hindsight-recall sections.
|
|
50
|
+
# Daily-memory (Source 3) and the "You just restarted at …" header are
|
|
51
|
+
# SKIPPED. Rationale is token duplication, not latency: Claude Code's
|
|
52
|
+
# native compaction summary already preserves the recent turns, so
|
|
53
|
+
# re-injecting the full boot briefing on every compaction of a long
|
|
54
|
+
# session partially triples coverage.
|
|
55
|
+
# 2. It IGNORES the SWITCHROOM_PENDING_* env (boot-time pending-turn scope).
|
|
56
|
+
# Those name the surface that was mid-turn at the PREVIOUS boot; at a
|
|
57
|
+
# compaction hours into a live session they are stale and would brief the
|
|
58
|
+
# WRONG chat surface. Lean mode zeroes them so the python scoper derives
|
|
59
|
+
# the single most-recently-active (chat_id, thread_id) straight from the
|
|
60
|
+
# DB (scope_source=db-latest).
|
|
61
|
+
# 3. It forces stdout and never touches AGENT_DIR (no output file), so it is
|
|
62
|
+
# safe to call from the hook regardless of whether AGENT_DIR is exported.
|
|
40
63
|
|
|
41
64
|
set -u
|
|
42
65
|
|
|
@@ -71,12 +94,32 @@ HINDSIGHT_TIMEOUT="${HANDOFF_BRIEFING_HINDSIGHT_TIMEOUT:-3}"
|
|
|
71
94
|
TARGET_CHAT_ID="${SWITCHROOM_PENDING_CHAT_ID:-}"
|
|
72
95
|
TARGET_THREAD_ID="${SWITCHROOM_PENDING_THREAD_ID:-}"
|
|
73
96
|
|
|
74
|
-
# Determine output mode
|
|
97
|
+
# Determine output + lean mode. Parse every arg (order-independent) so
|
|
98
|
+
# `--lean`, `--stdout`, or both, work regardless of position.
|
|
75
99
|
STDOUT_MODE=0
|
|
76
|
-
|
|
100
|
+
LEAN_MODE=0
|
|
101
|
+
for _arg in "$@"; do
|
|
102
|
+
case "$_arg" in
|
|
103
|
+
--stdout) STDOUT_MODE=1 ;;
|
|
104
|
+
--lean|--mode=compaction) LEAN_MODE=1 ;;
|
|
105
|
+
*) : ;;
|
|
106
|
+
esac
|
|
107
|
+
done
|
|
108
|
+
if [ "${HANDOFF_BRIEFING_STDOUT:-}" = "1" ]; then
|
|
77
109
|
STDOUT_MODE=1
|
|
78
110
|
fi
|
|
79
111
|
|
|
112
|
+
# Lean (compaction) mode: force stdout, and ZERO the pending-turn env scope so
|
|
113
|
+
# the python scoper falls through to db-latest (see LEAN MODE note in header).
|
|
114
|
+
# This is the load-bearing correctness fix for a mid-session compaction: the
|
|
115
|
+
# SWITCHROOM_PENDING_* surface is the previous boot's, not the currently-active
|
|
116
|
+
# chat. Clearing them here (not in the hook) keeps the single scoping code path.
|
|
117
|
+
if [ "$LEAN_MODE" = "1" ]; then
|
|
118
|
+
STDOUT_MODE=1
|
|
119
|
+
TARGET_CHAT_ID=""
|
|
120
|
+
TARGET_THREAD_ID=""
|
|
121
|
+
fi
|
|
122
|
+
|
|
80
123
|
# ── Source 1: Recent Telegram messages ─────────────────────────────────────────
|
|
81
124
|
TELEGRAM_SECTION=""
|
|
82
125
|
if [ -n "$TELEGRAM_STATE" ] && [ -d "$TELEGRAM_STATE" ]; then
|
|
@@ -362,7 +405,9 @@ fi
|
|
|
362
405
|
# dropping today's memory.
|
|
363
406
|
DAILY_SECTION=""
|
|
364
407
|
TODAY=$(TZ="$_TZ_VAL" date +%Y-%m-%d 2>/dev/null || date +%Y-%m-%d 2>/dev/null || true)
|
|
365
|
-
|
|
408
|
+
# Lean/compaction mode SKIPS daily memory (token duplication — the native
|
|
409
|
+
# summary already carries recent context; see LEAN MODE note in header).
|
|
410
|
+
if [ "$LEAN_MODE" != "1" ] && [ -n "$TODAY" ] && [ -n "$WORKSPACE_DIR" ]; then
|
|
366
411
|
DAILY_FILE="$WORKSPACE_DIR/memory/${TODAY}.md"
|
|
367
412
|
if [ -f "$DAILY_FILE" ] && [ -s "$DAILY_FILE" ]; then
|
|
368
413
|
DAILY_CONTENT=$(cat "$DAILY_FILE")
|
|
@@ -391,7 +436,14 @@ if [ -n "$OUTPUT_FILE" ]; then
|
|
|
391
436
|
else
|
|
392
437
|
# stdout / no-AGENT_DIR mode — buffered; print the whole briefing once.
|
|
393
438
|
if [ -n "$STDOUT_BUFFER" ]; then
|
|
394
|
-
|
|
439
|
+
if [ "$LEAN_MODE" = "1" ]; then
|
|
440
|
+
# Lean/compaction: no "You just restarted at …" boot header — this is a
|
|
441
|
+
# mid-conversation compaction, not a restart, and the hook emits its own
|
|
442
|
+
# <compact-recovery> framing. Print only the assembled sections.
|
|
443
|
+
printf '%s\n' "$STDOUT_BUFFER"
|
|
444
|
+
else
|
|
445
|
+
printf '%s\n\n---\n\n%s\n' "$BRIEFING_HEADER" "$STDOUT_BUFFER"
|
|
446
|
+
fi
|
|
395
447
|
fi
|
|
396
448
|
fi
|
|
397
449
|
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# working-state-reload-hook.sh — deliver post-compaction continuation into
|
|
3
|
+
# context immediately after context compaction.
|
|
4
|
+
#
|
|
5
|
+
# WHY THIS EXISTS
|
|
6
|
+
# ---------------
|
|
7
|
+
# Claude Code auto-compacts late in the context window. The native
|
|
8
|
+
# auto-summarizer produces a structured summary of intent/changes/pending
|
|
9
|
+
# work, but it is lossy: fast-moving detail an agent is actively juggling
|
|
10
|
+
# mid-task (a checklist, the current plan, in-flight IDs, the exact "where
|
|
11
|
+
# was I" scratch, recent phrasing) is exactly what a summary flattens or
|
|
12
|
+
# drops. Worse, the model resuming from a summary can read it as a FRESH
|
|
13
|
+
# start and re-greet the user, breaking a conversation the user experiences
|
|
14
|
+
# as unbroken.
|
|
15
|
+
#
|
|
16
|
+
# This hook closes that gap deterministically, in three layers:
|
|
17
|
+
# 1. It ALWAYS emits a short, static recovery/orientation block — for
|
|
18
|
+
# EVERY agent, whether or not it maintains a working-state file. This
|
|
19
|
+
# is the load-bearing default: it tells the model its context was just
|
|
20
|
+
# compacted mid-conversation, that the native summary is lossy, and
|
|
21
|
+
# which concrete recovery tools exist in this environment.
|
|
22
|
+
# 2. If the agent maintains a working-state file AND it is non-empty, the
|
|
23
|
+
# hook additionally appends that file verbatim (with its last-modified
|
|
24
|
+
# time, so a stale/forgotten file is visibly stale rather than silently
|
|
25
|
+
# steering).
|
|
26
|
+
# 3. It emits a LEAN briefing (P1) — a scoped recent Telegram tail + a
|
|
27
|
+
# Hindsight recall — so the compacted session gets fresh-boot PARITY:
|
|
28
|
+
# it picks up the actual conversation, not just the fact it was
|
|
29
|
+
# compacted. This is delegated to handoff-briefing.sh --lean (the SINGLE
|
|
30
|
+
# briefing assembler; no copy of the sqlite/recall logic here), so BOTH
|
|
31
|
+
# legacy- and gateway-briefing agents share one compaction re-seat path.
|
|
32
|
+
# Lean by design: it skips daily-memory/workspace re-render (token
|
|
33
|
+
# duplication — the native summary already keeps recent turns) and
|
|
34
|
+
# ignores SWITCHROOM_PENDING_* to brief the db-latest chat surface rather
|
|
35
|
+
# than a stale pending-turn one. Graceful: if history.db or Hindsight is
|
|
36
|
+
# unavailable it emits what it can (or nothing) and never fails the hook.
|
|
37
|
+
#
|
|
38
|
+
# It is wired as a SessionStart hook with matcher "compact" (see
|
|
39
|
+
# src/agents/scaffold.ts buildSettingsHooksBlock). Per Claude Code's hook
|
|
40
|
+
# contract:
|
|
41
|
+
# - SessionStart fires with source="compact" on auto OR manual compaction,
|
|
42
|
+
# mid-turn, right after the compaction boundary.
|
|
43
|
+
# - Text a SessionStart hook prints to stdout IS added to the model's
|
|
44
|
+
# context (unlike PreCompact stdout, which is NOT injected).
|
|
45
|
+
# So printing here re-seats orientation (and any working state) into context
|
|
46
|
+
# the instant the summary replaces the transcript — no marker file, no
|
|
47
|
+
# gateway round-trip, no waiting for the next user message.
|
|
48
|
+
#
|
|
49
|
+
# The matcher "compact" is load-bearing: it scopes this hook to compaction
|
|
50
|
+
# ONLY. A bare (matcher-less) SessionStart also fires on "startup", "resume",
|
|
51
|
+
# "clear", and "fork", which would inject the recovery block on every boot —
|
|
52
|
+
# noise, and prompt-cache churn. We rely on the matcher AND, belt-and-braces,
|
|
53
|
+
# re-check the `source` field from stdin below so a future Claude Code matcher
|
|
54
|
+
# regression can never turn this into an every-boot inject.
|
|
55
|
+
#
|
|
56
|
+
# THE WORKING-STATE FILE CONVENTION
|
|
57
|
+
# ---------------------------------
|
|
58
|
+
# $TELEGRAM_STATE_DIR/.working-state.md
|
|
59
|
+
# i.e. <agentDir>/telegram/.working-state.md (TELEGRAM_STATE_DIR is exported
|
|
60
|
+
# by start.sh as "<agentDir>/telegram"). An agent maintains this file itself
|
|
61
|
+
# as its durable scratch of "what I'm mid-way through". If the file is absent
|
|
62
|
+
# or empty — the common case for agents that don't use it — the hook simply
|
|
63
|
+
# skips the append; the static recovery block is still emitted. No file is
|
|
64
|
+
# ever created here.
|
|
65
|
+
#
|
|
66
|
+
# PERFORMANCE
|
|
67
|
+
# -----------
|
|
68
|
+
# The static recovery block and working-state append are local-only (a heredoc
|
|
69
|
+
# plus at most one `stat`/`cat` of a small file) — sub-second. The lean
|
|
70
|
+
# briefing (layer 3) adds one local SQLite read and ONE network hop to
|
|
71
|
+
# Hindsight, which handoff-briefing.sh caps (HANDOFF_BRIEFING_HINDSIGHT_TIMEOUT,
|
|
72
|
+
# default 3s). Worst-case runtime is therefore a few seconds, dominated by that
|
|
73
|
+
# cap; the SessionStart(compact) hook's Claude Code timeout is set accordingly
|
|
74
|
+
# in src/agents/scaffold.ts. Latency here is a non-issue by design: compaction
|
|
75
|
+
# itself takes far longer, and the prompt cache is already invalidated by the
|
|
76
|
+
# summary replacing the transcript. (Contrast the hindsight session_start.py
|
|
77
|
+
# SessionStart hook, which times out at its 5s budget on every firing — a
|
|
78
|
+
# separate, independent context-loss cause tracked against the hindsight-memory
|
|
79
|
+
# plugin, NOT fixed here.)
|
|
80
|
+
#
|
|
81
|
+
# Failure modes are all silent: a hook that errors would surface on the issues
|
|
82
|
+
# card via run-hook.sh, but a missing/absent working-state file, an unreadable
|
|
83
|
+
# mtime, a missing history.db, or an unreachable Hindsight is never an error —
|
|
84
|
+
# the recovery block still emits and the hook exits 0.
|
|
85
|
+
|
|
86
|
+
set -u
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Defensive source guard. The matcher "compact" in settings.json already
|
|
90
|
+
# scopes Claude Code to fire this hook only on compaction, but we re-verify
|
|
91
|
+
# the source from the hook's stdin JSON so a matcher regression (or a manual
|
|
92
|
+
# mis-wire) can never cause this to inject on a normal startup/resume/clear/
|
|
93
|
+
# fork boot. If stdin carries a `source` and it is not "compact", exit
|
|
94
|
+
# silently. If there is no stdin (e.g. a unit test invoking the script
|
|
95
|
+
# directly), fall through and trust the matcher.
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
if ! [ -t 0 ]; then
|
|
98
|
+
STDIN_JSON=$(cat 2>/dev/null || true)
|
|
99
|
+
if [ -n "${STDIN_JSON:-}" ]; then
|
|
100
|
+
SOURCE=""
|
|
101
|
+
if command -v jq >/dev/null 2>&1; then
|
|
102
|
+
SOURCE=$(printf '%s' "$STDIN_JSON" | jq -r '.source // empty' 2>/dev/null || true)
|
|
103
|
+
else
|
|
104
|
+
SOURCE=$(printf '%s' "$STDIN_JSON" \
|
|
105
|
+
| grep -o '"source"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
|
106
|
+
| head -1 \
|
|
107
|
+
| sed 's/.*"source"[[:space:]]*:[[:space:]]*"//;s/"$//' 2>/dev/null || true)
|
|
108
|
+
fi
|
|
109
|
+
if [ -n "$SOURCE" ] && [ "$SOURCE" != "compact" ]; then
|
|
110
|
+
exit 0
|
|
111
|
+
fi
|
|
112
|
+
fi
|
|
113
|
+
fi
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Static recovery/orientation block. ALWAYS emitted on a compaction start,
|
|
117
|
+
# for EVERY agent — this is the load-bearing default. Plain stdout from a
|
|
118
|
+
# SessionStart hook IS added to the model's context by Claude Code, so this
|
|
119
|
+
# re-orients the model the instant the native summary replaces the transcript.
|
|
120
|
+
# Deterministic heredoc string: no network, no CLI fork.
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
cat <<'EOF'
|
|
123
|
+
<compact-recovery source="switchroom working-state-reload hook">
|
|
124
|
+
Your context was just COMPACTED mid-conversation. This is NOT a fresh start:
|
|
125
|
+
you are CONTINUING a conversation the user experiences as unbroken. The native
|
|
126
|
+
summary above is lossy — it flattens or drops fast-moving detail (in-flight
|
|
127
|
+
IDs, the exact "where was I", recent phrasing). Do not greet the user or act
|
|
128
|
+
as if starting over; pick up where the conversation left off.
|
|
129
|
+
|
|
130
|
+
Re-orient using the recovery tools in THIS environment before continuing:
|
|
131
|
+
- Telegram chat history: the get_recent_messages MCP tool
|
|
132
|
+
(mcp__switchroom-telegram__get_recent_messages) to re-read what was just
|
|
133
|
+
being discussed.
|
|
134
|
+
- Hindsight memory: recall / reflect (mcp__hindsight__recall,
|
|
135
|
+
mcp__hindsight__reflect) for facts and decisions from earlier sessions.
|
|
136
|
+
- Workspace files for durable task state.
|
|
137
|
+
</compact-recovery>
|
|
138
|
+
EOF
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# Resolve the working-state file. Primary: $TELEGRAM_STATE_DIR (exported by
|
|
142
|
+
# start.sh for telegram-plugin agents). Fallback: derive the conventional
|
|
143
|
+
# telegram state dir from the agent name, so the hook still works if invoked
|
|
144
|
+
# in a context where TELEGRAM_STATE_DIR is not exported. If neither resolves,
|
|
145
|
+
# skip the working-state append — the recovery block above already emitted,
|
|
146
|
+
# and the lean briefing below still runs.
|
|
147
|
+
#
|
|
148
|
+
# NOTE: this is a GUARDED block (not an early `exit`), because the lean
|
|
149
|
+
# post-compaction briefing further down must run for EVERY compaction,
|
|
150
|
+
# including the common case of an agent that keeps no working-state file.
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
STATE_DIR="${TELEGRAM_STATE_DIR:-}"
|
|
153
|
+
if [ -z "$STATE_DIR" ]; then
|
|
154
|
+
AGENT_NAME="${SWITCHROOM_AGENT_NAME:-}"
|
|
155
|
+
if [ -n "$AGENT_NAME" ] && [ -n "${HOME:-}" ]; then
|
|
156
|
+
STATE_DIR="$HOME/.switchroom/agents/$AGENT_NAME/telegram"
|
|
157
|
+
fi
|
|
158
|
+
fi
|
|
159
|
+
|
|
160
|
+
WORKING_STATE_FILE=""
|
|
161
|
+
if [ -n "$STATE_DIR" ]; then
|
|
162
|
+
WORKING_STATE_FILE="$STATE_DIR/.working-state.md"
|
|
163
|
+
fi
|
|
164
|
+
|
|
165
|
+
# Append the working state only when the file resolves AND is non-empty.
|
|
166
|
+
if [ -n "$WORKING_STATE_FILE" ] && [ -s "$WORKING_STATE_FILE" ]; then
|
|
167
|
+
# -------------------------------------------------------------------------
|
|
168
|
+
# Resolve the working-state file's last-modified time so a stale, forgotten
|
|
169
|
+
# file is VISIBLY stale to the model rather than silently steering it. Try a
|
|
170
|
+
# portable sequence: GNU/busybox `stat -c %y`, then BSD/macOS `stat -f %Sm`,
|
|
171
|
+
# then GNU `date -r <file>`. If none work, omit the mtime — never fail the
|
|
172
|
+
# hook over it.
|
|
173
|
+
# -------------------------------------------------------------------------
|
|
174
|
+
MTIME=""
|
|
175
|
+
if MTIME=$(stat -c %y "$WORKING_STATE_FILE" 2>/dev/null) && [ -n "$MTIME" ]; then
|
|
176
|
+
:
|
|
177
|
+
elif MTIME=$(stat -f '%Sm' "$WORKING_STATE_FILE" 2>/dev/null) && [ -n "$MTIME" ]; then
|
|
178
|
+
:
|
|
179
|
+
elif MTIME=$(date -r "$WORKING_STATE_FILE" 2>/dev/null) && [ -n "$MTIME" ]; then
|
|
180
|
+
:
|
|
181
|
+
else
|
|
182
|
+
MTIME=""
|
|
183
|
+
fi
|
|
184
|
+
|
|
185
|
+
# -------------------------------------------------------------------------
|
|
186
|
+
# Append the working state verbatim, wrapped in its own delimiter block. The
|
|
187
|
+
# header line carries the mtime (when resolvable) so a stale file reads as
|
|
188
|
+
# stale.
|
|
189
|
+
# -------------------------------------------------------------------------
|
|
190
|
+
printf '%s\n' '<working-state source="switchroom working-state-reload hook">'
|
|
191
|
+
if [ -n "$MTIME" ]; then
|
|
192
|
+
printf '%s\n' 'The following is your working-state file ('"$WORKING_STATE_FILE"', last updated '"$MTIME"'),'
|
|
193
|
+
else
|
|
194
|
+
printf '%s\n' 'The following is your working-state file ('"$WORKING_STATE_FILE"'),'
|
|
195
|
+
fi
|
|
196
|
+
printf '%s\n' 'reloaded verbatim so in-flight task state survives the summarizer. It may'
|
|
197
|
+
printf '%s\n' 'be stale — reconcile it against the summary and the recovery tools above'
|
|
198
|
+
printf '%s\n' 'before trusting it, then continue.'
|
|
199
|
+
printf '%s\n' '---'
|
|
200
|
+
cat "$WORKING_STATE_FILE"
|
|
201
|
+
printf '\n%s\n' '</working-state>'
|
|
202
|
+
fi
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Lean post-compaction briefing (P1). ADDITIVE on source=compact, after the
|
|
206
|
+
# static recovery block and the optional working-state append. It re-seats the
|
|
207
|
+
# compacted session to fresh-boot PARITY: a scoped recent Telegram tail + a
|
|
208
|
+
# Hindsight recall, so the agent picks up the actual conversation rather than
|
|
209
|
+
# only being TOLD it was compacted.
|
|
210
|
+
#
|
|
211
|
+
# DRY: the assembly is delegated to handoff-briefing.sh --lean — the SINGLE
|
|
212
|
+
# briefing assembler. No copy of the sqlite/recall logic lives here. Lean mode
|
|
213
|
+
# emits ONLY the Telegram-tail + recall (daily-memory and the boot header are
|
|
214
|
+
# skipped — token duplication, the native summary already keeps recent turns),
|
|
215
|
+
# and IGNORES SWITCHROOM_PENDING_* to derive the db-latest surface (a stale
|
|
216
|
+
# pending-turn scope would brief the wrong chat at a mid-session compaction).
|
|
217
|
+
#
|
|
218
|
+
# SHARED PATH: this hook fires on SessionStart(compact) for EVERY agent,
|
|
219
|
+
# regardless of session_continuity.briefing mode (legacy vs gateway), so both
|
|
220
|
+
# modes get identical compaction re-seat through this one path.
|
|
221
|
+
#
|
|
222
|
+
# GRACEFUL: if the assembler script is not found, or emits nothing (no
|
|
223
|
+
# history.db, Hindsight unreachable), the block is simply omitted. The lean
|
|
224
|
+
# briefing NEVER fails the hook — the recovery block above already stands on
|
|
225
|
+
# its own. handoff-briefing.sh caps its only network hop (Hindsight) at a few
|
|
226
|
+
# seconds, so runtime is bounded.
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
BRIEFING_SCRIPT=""
|
|
229
|
+
_HOOK_DIR=$(dirname -- "$0" 2>/dev/null || true)
|
|
230
|
+
if [ -n "$_HOOK_DIR" ] && [ -r "$_HOOK_DIR/handoff-briefing.sh" ]; then
|
|
231
|
+
BRIEFING_SCRIPT="$_HOOK_DIR/handoff-briefing.sh"
|
|
232
|
+
elif command -v handoff-briefing.sh >/dev/null 2>&1; then
|
|
233
|
+
BRIEFING_SCRIPT="handoff-briefing.sh"
|
|
234
|
+
fi
|
|
235
|
+
|
|
236
|
+
if [ -n "$BRIEFING_SCRIPT" ]; then
|
|
237
|
+
# Inner timeout, SHORTER than the 8s Claude Code hook budget, so a slow
|
|
238
|
+
# assembler degrades to "recovery block only" instead of losing everything.
|
|
239
|
+
# Without it, if the assembler runs long (e.g. an operator raises
|
|
240
|
+
# HANDOFF_BRIEFING_HINDSIGHT_TIMEOUT past the hook budget) Claude Code kills
|
|
241
|
+
# the WHOLE hook at 8s and discards ALL stdout — including the near-unkillable
|
|
242
|
+
# <compact-recovery> orientation block already printed above. Capping the
|
|
243
|
+
# assembler at 5s keeps the #4390 recovery floor intact. `timeout` is
|
|
244
|
+
# coreutils (present in the agent image); fall back to an un-timed call if it
|
|
245
|
+
# is somehow unavailable, so the lean briefing still works.
|
|
246
|
+
if command -v timeout >/dev/null 2>&1; then
|
|
247
|
+
LEAN_BRIEFING=$(timeout 5 bash "$BRIEFING_SCRIPT" --lean 2>/dev/null || true)
|
|
248
|
+
else
|
|
249
|
+
LEAN_BRIEFING=$(bash "$BRIEFING_SCRIPT" --lean 2>/dev/null || true)
|
|
250
|
+
fi
|
|
251
|
+
if [ -n "$LEAN_BRIEFING" ]; then
|
|
252
|
+
printf '%s\n' '<compact-briefing source="switchroom working-state-reload hook">'
|
|
253
|
+
printf '%s\n' 'The recent conversation and recalled memory below are re-seated so this'
|
|
254
|
+
printf '%s\n' 'compacted session has the same footing as a fresh boot. Use them to pick up'
|
|
255
|
+
printf '%s\n' 'the thread; reconcile against the native summary above before trusting either.'
|
|
256
|
+
printf '%s\n' '---'
|
|
257
|
+
printf '%s\n' "$LEAN_BRIEFING"
|
|
258
|
+
printf '%s\n' '</compact-briefing>'
|
|
259
|
+
fi
|
|
260
|
+
fi
|
|
261
|
+
|
|
262
|
+
exit 0
|
|
@@ -19820,6 +19820,23 @@ function extractApiErrorMessage(bodyText) {
|
|
|
19820
19820
|
} catch {}
|
|
19821
19821
|
return bodyText.trim().slice(0, 500);
|
|
19822
19822
|
}
|
|
19823
|
+
function extractApiErrorCode(bodyText) {
|
|
19824
|
+
if (!bodyText || bodyText.trim().length === 0)
|
|
19825
|
+
return null;
|
|
19826
|
+
try {
|
|
19827
|
+
const parsed = JSON.parse(bodyText);
|
|
19828
|
+
const code = parsed?.error?.details?.error_code;
|
|
19829
|
+
if (typeof code === "string" && code.trim().length > 0)
|
|
19830
|
+
return code.trim();
|
|
19831
|
+
} catch {}
|
|
19832
|
+
return null;
|
|
19833
|
+
}
|
|
19834
|
+
var ENTITLEMENT_BLOCKED_ERROR_CODES = new Set([
|
|
19835
|
+
"oauth_not_allowed_for_organization"
|
|
19836
|
+
]);
|
|
19837
|
+
function isEntitlementBlockedErrorCode(code) {
|
|
19838
|
+
return code != null && ENTITLEMENT_BLOCKED_ERROR_CODES.has(code);
|
|
19839
|
+
}
|
|
19823
19840
|
function isEntitlementDisabledMessage(message) {
|
|
19824
19841
|
if (!message)
|
|
19825
19842
|
return false;
|
|
@@ -19877,7 +19894,8 @@ async function fetchQuota(opts) {
|
|
|
19877
19894
|
} catch {}
|
|
19878
19895
|
clearTimeout(timeout);
|
|
19879
19896
|
const apiErrorMessage = extractApiErrorMessage(bodyText);
|
|
19880
|
-
const
|
|
19897
|
+
const apiErrorCode = extractApiErrorCode(bodyText);
|
|
19898
|
+
const entitlement = resp.status === 403 && (isEntitlementBlockedErrorCode(apiErrorCode) || isEntitlementDisabledMessage(apiErrorMessage));
|
|
19881
19899
|
return {
|
|
19882
19900
|
ok: false,
|
|
19883
19901
|
reason: `HTTP ${resp.status} from Anthropic (${parsed.reason})`,
|
package/dist/cli/switchroom.js
CHANGED
|
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
|
|
|
2120
2120
|
});
|
|
2121
2121
|
|
|
2122
2122
|
// src/build-info.ts
|
|
2123
|
-
var VERSION = "0.20.
|
|
2123
|
+
var VERSION = "0.20.10", COMMIT_SHA = "ebb54738";
|
|
2124
2124
|
|
|
2125
2125
|
// src/cli/resolve-version.ts
|
|
2126
2126
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -18815,7 +18815,9 @@ function hindsightContainerEnvPairs(opts) {
|
|
|
18815
18815
|
...hindsightLlmBudgetEnv(llm),
|
|
18816
18816
|
["HINDSIGHT_API_WORKER_ID", HINDSIGHT_DEFAULT_WORKER_ID],
|
|
18817
18817
|
...hindsightPerfEnvPairs(llm, litellm, gpu, perf),
|
|
18818
|
-
...hindsightPgEnvPairs(perf)
|
|
18818
|
+
...hindsightPgEnvPairs(perf),
|
|
18819
|
+
["SWITCHROOM_HINDSIGHT_REQUEUE_DEAD_LETTERS", HINDSIGHT_DEFAULT_REQUEUE_DEAD_LETTERS],
|
|
18820
|
+
["SWITCHROOM_HINDSIGHT_REQUEUE_MAX", HINDSIGHT_DEFAULT_REQUEUE_MAX]
|
|
18819
18821
|
];
|
|
18820
18822
|
if (llmProvider === "claude-code") {
|
|
18821
18823
|
pairs.push(["ANTHROPIC_MODEL", llmModel]);
|
|
@@ -19086,6 +19088,8 @@ function generateHindsightComposeSnippet(llm, mirrorDir, litellm, gpu, perf, cpA
|
|
|
19086
19088
|
` - HINDSIGHT_API_WORKER_ID=${HINDSIGHT_DEFAULT_WORKER_ID}`,
|
|
19087
19089
|
...hindsightPerfEnvPairs(llm, litellm, gpu, perf).map(([k, v]) => ` - ${k}=${v}`),
|
|
19088
19090
|
...hindsightPgEnvPairs(perf).map(([k, v]) => ` - ${k}=${v}`),
|
|
19091
|
+
` - SWITCHROOM_HINDSIGHT_REQUEUE_DEAD_LETTERS=${HINDSIGHT_DEFAULT_REQUEUE_DEAD_LETTERS}`,
|
|
19092
|
+
` - SWITCHROOM_HINDSIGHT_REQUEUE_MAX=${HINDSIGHT_DEFAULT_REQUEUE_MAX}`,
|
|
19089
19093
|
` mem_limit: ${HINDSIGHT_DEFAULT_MEM_LIMIT}`,
|
|
19090
19094
|
` mem_reservation: ${HINDSIGHT_DEFAULT_MEM_RESERVATION}`,
|
|
19091
19095
|
` pids_limit: ${HINDSIGHT_DEFAULT_PIDS_LIMIT}`,
|
|
@@ -19184,7 +19188,7 @@ async function ensureHindsightConsumer(configPath, account, uid = HINDSIGHT_DEFA
|
|
|
19184
19188
|
atomicWriteFileSync3(configPath, tail, mode);
|
|
19185
19189
|
return { added: true, reason: "added" };
|
|
19186
19190
|
}
|
|
19187
|
-
var HINDSIGHT_DEFAULT_API_PORT = 18888, HINDSIGHT_DEFAULT_UI_PORT = 9999, HINDSIGHT_DEFAULT_MCP_URL, HINDSIGHT_DEFAULT_API_BASE_URL, HINDSIGHT_CONSUMER_NAME = "hindsight", HINDSIGHT_DEFAULT_WORKER_ID = "switchroom-hindsight", HINDSIGHT_DATA_VOLUME = "switchroom-hindsight-data", HINDSIGHT_DEFAULT_UID = 11000, HINDSIGHT_IMAGE_REPO = "ghcr.io/switchroom/switchroom-hindsight", HINDSIGHT_IMAGE, HINDSIGHT_DEFAULT_MODEL = "claude-sonnet-5", HINDSIGHT_DEFAULT_LITELLM_MODEL = "openrouter/google/gemini-3.1-flash-lite", HINDSIGHT_DEFAULT_MCP_STATELESS = true, HINDSIGHT_BROKER_SOCK_VOLUME, HINDSIGHT_CREDS_MIRROR_VOLUME, HINDSIGHT_CRED_DIR = "/run/claude-creds", HINDSIGHT_RETAIN_CHUNK_SIZE = 3000, HINDSIGHT_DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 16384, HINDSIGHT_RETAIN_MIN_TOKENS_PER_SECOND = 80.6, HINDSIGHT_RETAIN_CLIENT_DEADLINE_S, HINDSIGHT_DEFAULT_MEM_LIMIT = "16g", HINDSIGHT_DEFAULT_MEM_RESERVATION = "4g", HINDSIGHT_DEFAULT_PIDS_LIMIT = 1000, HINDSIGHT_DEFAULT_SHM_SIZE = "2g", HINDSIGHT_HEALTHCHECK_PY = 'import urllib.request,sys; sys.exit(0 if urllib.request.urlopen("http://localhost:8888/health",timeout=4).getcode()==200 else 1)', HINDSIGHT_HEALTHCHECK_CMD, DOCKER_PROBE_TIMEOUT_MS, defaultDockerProbe = (args) => {
|
|
19191
|
+
var HINDSIGHT_DEFAULT_API_PORT = 18888, HINDSIGHT_DEFAULT_UI_PORT = 9999, HINDSIGHT_DEFAULT_MCP_URL, HINDSIGHT_DEFAULT_API_BASE_URL, HINDSIGHT_CONSUMER_NAME = "hindsight", HINDSIGHT_DEFAULT_WORKER_ID = "switchroom-hindsight", HINDSIGHT_DATA_VOLUME = "switchroom-hindsight-data", HINDSIGHT_DEFAULT_UID = 11000, HINDSIGHT_IMAGE_REPO = "ghcr.io/switchroom/switchroom-hindsight", HINDSIGHT_IMAGE, HINDSIGHT_DEFAULT_MODEL = "claude-sonnet-5", HINDSIGHT_DEFAULT_LITELLM_MODEL = "openrouter/google/gemini-3.1-flash-lite", HINDSIGHT_DEFAULT_MCP_STATELESS = true, HINDSIGHT_DEFAULT_REQUEUE_DEAD_LETTERS = "1", HINDSIGHT_DEFAULT_REQUEUE_MAX = "25", HINDSIGHT_BROKER_SOCK_VOLUME, HINDSIGHT_CREDS_MIRROR_VOLUME, HINDSIGHT_CRED_DIR = "/run/claude-creds", HINDSIGHT_RETAIN_CHUNK_SIZE = 3000, HINDSIGHT_DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 16384, HINDSIGHT_RETAIN_MIN_TOKENS_PER_SECOND = 80.6, HINDSIGHT_RETAIN_CLIENT_DEADLINE_S, HINDSIGHT_DEFAULT_MEM_LIMIT = "16g", HINDSIGHT_DEFAULT_MEM_RESERVATION = "4g", HINDSIGHT_DEFAULT_PIDS_LIMIT = 1000, HINDSIGHT_DEFAULT_SHM_SIZE = "2g", HINDSIGHT_HEALTHCHECK_PY = 'import urllib.request,sys; sys.exit(0 if urllib.request.urlopen("http://localhost:8888/health",timeout=4).getcode()==200 else 1)', HINDSIGHT_HEALTHCHECK_CMD, DOCKER_PROBE_TIMEOUT_MS, defaultDockerProbe = (args) => {
|
|
19188
19192
|
try {
|
|
19189
19193
|
return execFileSync2("docker", args, {
|
|
19190
19194
|
stdio: "pipe",
|
|
@@ -31105,6 +31109,18 @@ function buildSettingsHooksBlock(p) {
|
|
|
31105
31109
|
]
|
|
31106
31110
|
}
|
|
31107
31111
|
];
|
|
31112
|
+
const switchroomSessionStart = [
|
|
31113
|
+
{
|
|
31114
|
+
matcher: "compact",
|
|
31115
|
+
hooks: [
|
|
31116
|
+
{
|
|
31117
|
+
type: "command",
|
|
31118
|
+
command: wrap("hook:working-state-reload", `bash "${join12(DOCKER_BIN_PATH, "working-state-reload-hook.sh")}"`),
|
|
31119
|
+
timeout: 8
|
|
31120
|
+
}
|
|
31121
|
+
]
|
|
31122
|
+
}
|
|
31123
|
+
];
|
|
31108
31124
|
if (userHooks) {
|
|
31109
31125
|
return {
|
|
31110
31126
|
...userHooks,
|
|
@@ -31112,6 +31128,10 @@ function buildSettingsHooksBlock(p) {
|
|
|
31112
31128
|
...userHooks.UserPromptSubmit ?? [],
|
|
31113
31129
|
...switchroomUserPromptSubmit
|
|
31114
31130
|
],
|
|
31131
|
+
SessionStart: [
|
|
31132
|
+
...userHooks.SessionStart ?? [],
|
|
31133
|
+
...switchroomSessionStart
|
|
31134
|
+
],
|
|
31115
31135
|
...switchroomPreToolUse.length > 0 ? {
|
|
31116
31136
|
PreToolUse: [
|
|
31117
31137
|
...userHooks.PreToolUse ?? [],
|
|
@@ -31134,6 +31154,7 @@ function buildSettingsHooksBlock(p) {
|
|
|
31134
31154
|
}
|
|
31135
31155
|
return {
|
|
31136
31156
|
UserPromptSubmit: switchroomUserPromptSubmit,
|
|
31157
|
+
SessionStart: switchroomSessionStart,
|
|
31137
31158
|
...switchroomPreToolUse.length > 0 ? { PreToolUse: switchroomPreToolUse } : {},
|
|
31138
31159
|
...switchroomPostToolUse.length > 0 ? { PostToolUse: switchroomPostToolUse } : {},
|
|
31139
31160
|
...switchroomStop.length > 0 ? { Stop: switchroomStop } : {}
|
|
@@ -63671,7 +63692,7 @@ function checkNodeVersion() {
|
|
|
63671
63692
|
}
|
|
63672
63693
|
return { name: "Node 18+", status: "ok", detail: label };
|
|
63673
63694
|
}
|
|
63674
|
-
function findChromium(homeDir = process.env.HOME ?? "", envBrowsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH) {
|
|
63695
|
+
function findChromium(homeDir = process.env.HOME ?? "", envBrowsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH, bakedBrowsersPath = "/opt/playwright/browsers") {
|
|
63675
63696
|
const candidates = [
|
|
63676
63697
|
"chromium",
|
|
63677
63698
|
"chromium-browser",
|
|
@@ -63688,6 +63709,9 @@ function findChromium(homeDir = process.env.HOME ?? "", envBrowsersPath = proces
|
|
|
63688
63709
|
cacheLocations.push(envBrowsersPath);
|
|
63689
63710
|
}
|
|
63690
63711
|
cacheLocations.push(join74(homeDir, ".cache", "ms-playwright"));
|
|
63712
|
+
if (bakedBrowsersPath && bakedBrowsersPath.length > 0) {
|
|
63713
|
+
cacheLocations.push(bakedBrowsersPath);
|
|
63714
|
+
}
|
|
63691
63715
|
for (const cacheDir of cacheLocations) {
|
|
63692
63716
|
if (!existsSync69(cacheDir))
|
|
63693
63717
|
continue;
|
|
@@ -88255,6 +88279,9 @@ function refillNormalizedUtils(q, now) {
|
|
|
88255
88279
|
sevenDayRefilled
|
|
88256
88280
|
};
|
|
88257
88281
|
}
|
|
88282
|
+
var ENTITLEMENT_BLOCKED_ERROR_CODES = new Set([
|
|
88283
|
+
"oauth_not_allowed_for_organization"
|
|
88284
|
+
]);
|
|
88258
88285
|
|
|
88259
88286
|
// src/cli/auth-schedule.ts
|
|
88260
88287
|
function overageReasonBlocks(reason) {
|
|
@@ -21493,7 +21493,7 @@ function allocateAgentUid(name) {
|
|
|
21493
21493
|
}
|
|
21494
21494
|
|
|
21495
21495
|
// src/build-info.ts
|
|
21496
|
-
var VERSION = "0.20.
|
|
21496
|
+
var VERSION = "0.20.10";
|
|
21497
21497
|
|
|
21498
21498
|
// src/setup/hindsight-recall-tunables.ts
|
|
21499
21499
|
var RECALL_DEADLINE_HEADROOM_SECONDS = 2;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
3
|
"//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
|
|
4
|
-
"version": "0.20.
|
|
4
|
+
"version": "0.20.10",
|
|
5
5
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -1692,6 +1692,55 @@ if ! ls -d "$sr_cb_dir"/chromium-*/ >/dev/null 2>&1; then
|
|
|
1692
1692
|
fi
|
|
1693
1693
|
unset sr_cb_dir
|
|
1694
1694
|
|
|
1695
|
+
# Playwright browsers cache seeding. The agent image bakes the fleet-default
|
|
1696
|
+
# Playwright version's chromium at /opt/playwright/browsers (immutable:
|
|
1697
|
+
# root-owned inside a `read_only: true` root fs), but PLAYWRIGHT_BROWSERS_PATH
|
|
1698
|
+
# points at the persistent per-agent HOME cache (Dockerfile.agent ENV) so a
|
|
1699
|
+
# project pinning a DIFFERENT Playwright version can `playwright install` its
|
|
1700
|
+
# matching browser revision — revision-keyed dirs coexist side by side, and
|
|
1701
|
+
# the install survives container recreate via the HOME bind mount. Symlink
|
|
1702
|
+
# each baked revision into the cache so the fleet default (@playwright/mcp,
|
|
1703
|
+
# webapp-testing skill) still resolves with ZERO download; a real
|
|
1704
|
+
# agent-installed dir with the same name always wins over the link. Dangling
|
|
1705
|
+
# links into the bake (stale after an image bump changed the default
|
|
1706
|
+
# revision) are swept first. Dot-entries (.links) are untouched by the glob —
|
|
1707
|
+
# they must stay real + writable so project installs can register there.
|
|
1708
|
+
#
|
|
1709
|
+
# Zero-download depends on TWO things, not just this seeding. (1) Playwright
|
|
1710
|
+
# decides "already installed / skip download" purely by the INSTALLATION_COMPLETE
|
|
1711
|
+
# marker inside each revision dir (playwright-core browserFetcher.ts) — which
|
|
1712
|
+
# resolves fine through these symlinks, so the seed alone is enough at launch.
|
|
1713
|
+
# (2) BUT `playwright install` first GC-sweeps the browsers dir, deleting any
|
|
1714
|
+
# revision not referenced by a `.links/<sha1>` entry. The baked packages
|
|
1715
|
+
# registered their reference in /opt/.../.links at build time, not here, so a
|
|
1716
|
+
# skewed-version project install would otherwise GC these seed links and force a
|
|
1717
|
+
# re-download. Dockerfile.agent sets PLAYWRIGHT_SKIP_BROWSER_GC=1 in the runtime
|
|
1718
|
+
# env to disable that sweep — keep the two in lockstep. (`.links` is still
|
|
1719
|
+
# written by every install; we just never run the removal pass.)
|
|
1720
|
+
if [ -d /opt/playwright/browsers ]; then
|
|
1721
|
+
sr_pw_cache="${PLAYWRIGHT_BROWSERS_PATH:-$HOME/.cache/ms-playwright}"
|
|
1722
|
+
mkdir -p "$sr_pw_cache" 2>/dev/null || true
|
|
1723
|
+
if [ -d "$sr_pw_cache" ] && [ -w "$sr_pw_cache" ]; then
|
|
1724
|
+
for sr_pw_link in "$sr_pw_cache"/*; do
|
|
1725
|
+
if [ -L "$sr_pw_link" ] && [ ! -e "$sr_pw_link" ]; then
|
|
1726
|
+
case "$(readlink "$sr_pw_link")" in
|
|
1727
|
+
/opt/playwright/browsers/*) rm -f "$sr_pw_link" ;;
|
|
1728
|
+
esac
|
|
1729
|
+
fi
|
|
1730
|
+
done
|
|
1731
|
+
for sr_pw_rev in /opt/playwright/browsers/*/; do
|
|
1732
|
+
[ -d "$sr_pw_rev" ] || continue
|
|
1733
|
+
sr_pw_name="$(basename "$sr_pw_rev")"
|
|
1734
|
+
if [ ! -e "$sr_pw_cache/$sr_pw_name" ]; then
|
|
1735
|
+
ln -s "${sr_pw_rev%/}" "$sr_pw_cache/$sr_pw_name" 2>/dev/null || true
|
|
1736
|
+
fi
|
|
1737
|
+
done
|
|
1738
|
+
else
|
|
1739
|
+
echo "WARNING (playwright cache): $sr_pw_cache missing or not writable — baked browsers not linked; playwright-based skills may attempt a fresh ~150MB download" >&2
|
|
1740
|
+
fi
|
|
1741
|
+
unset sr_pw_cache sr_pw_rev sr_pw_name sr_pw_link
|
|
1742
|
+
fi
|
|
1743
|
+
|
|
1695
1744
|
# LiteLLM routing (opt-in, #litellm). When SWITCHROOM_LITELLM is set (compose
|
|
1696
1745
|
# env, gated on litellm.enabled && keyConfirmed), route the unmodified `claude`
|
|
1697
1746
|
# CLI through the operator's LiteLLM proxy at ANTHROPIC_BASE_URL: fetch the
|
|
@@ -7308,6 +7308,12 @@ function refillNormalizedUtils(q, now) {
|
|
|
7308
7308
|
sevenDayRefilled
|
|
7309
7309
|
};
|
|
7310
7310
|
}
|
|
7311
|
+
var ENTITLEMENT_BLOCKED_ERROR_CODES;
|
|
7312
|
+
var init_quota = __esm(() => {
|
|
7313
|
+
ENTITLEMENT_BLOCKED_ERROR_CODES = new Set([
|
|
7314
|
+
"oauth_not_allowed_for_organization"
|
|
7315
|
+
]);
|
|
7316
|
+
});
|
|
7311
7317
|
|
|
7312
7318
|
// ../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/helpers/util.js
|
|
7313
7319
|
var util, objectUtil, ZodParsedType, getParsedType = (data) => {
|
|
@@ -12695,6 +12701,7 @@ function buildSnapshotsFromCachedState(state) {
|
|
|
12695
12701
|
}
|
|
12696
12702
|
var THROTTLING_THRESHOLD_PCT = 80, OVERAGE_EXHAUSTED_REASONS, HEALTH_EMOJI, TABLE_HEALTH_RANK;
|
|
12697
12703
|
var init_auth_snapshot_format = __esm(() => {
|
|
12704
|
+
init_quota();
|
|
12698
12705
|
init_client();
|
|
12699
12706
|
init_demo_mask();
|
|
12700
12707
|
init_card_format();
|
|
@@ -38305,6 +38312,7 @@ function buildSnapshotsFromCachedState2(state7) {
|
|
|
38305
38312
|
}
|
|
38306
38313
|
var THROTTLING_THRESHOLD_PCT2 = 80, OVERAGE_EXHAUSTED_REASONS2, HEALTH_EMOJI2, TABLE_HEALTH_RANK2;
|
|
38307
38314
|
var init_auth_snapshot_format2 = __esm(() => {
|
|
38315
|
+
init_quota();
|
|
38308
38316
|
init_client();
|
|
38309
38317
|
init_demo_mask();
|
|
38310
38318
|
init_card_format();
|
|
@@ -39033,6 +39041,7 @@ function renderUsageCard(snapshots, exhaustedByLabel, opts = {}) {
|
|
|
39033
39041
|
}
|
|
39034
39042
|
var FIVE_HOUR_MS, SEVEN_DAY_MS, BAR_WIDTH = 10, STATUS_LABEL;
|
|
39035
39043
|
var init_quota_bar_format = __esm(() => {
|
|
39044
|
+
init_quota();
|
|
39036
39045
|
init_client();
|
|
39037
39046
|
init_auth_snapshot_format();
|
|
39038
39047
|
init_card_format();
|
|
@@ -102806,10 +102815,10 @@ function startOutboxSweep(deps) {
|
|
|
102806
102815
|
}
|
|
102807
102816
|
|
|
102808
102817
|
// ../src/build-info.ts
|
|
102809
|
-
var VERSION2 = "0.20.
|
|
102810
|
-
var COMMIT_SHA = "
|
|
102811
|
-
var COMMIT_DATE = "2026-08-
|
|
102812
|
-
var LATEST_PR =
|
|
102818
|
+
var VERSION2 = "0.20.10";
|
|
102819
|
+
var COMMIT_SHA = "ebb54738";
|
|
102820
|
+
var COMMIT_DATE = "2026-08-05T10:11:19Z";
|
|
102821
|
+
var LATEST_PR = 4398;
|
|
102813
102822
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
102814
102823
|
|
|
102815
102824
|
// gateway/boot-version.ts
|
|
@@ -54,6 +54,37 @@
|
|
|
54
54
|
|
|
55
55
|
### Changed (switchroom divergence)
|
|
56
56
|
|
|
57
|
+
- **SessionStart hook runs async so its durability work stops being killed
|
|
58
|
+
mid-drain** (`hooks/hooks.json`, `scripts/session_start.py`). The hook does
|
|
59
|
+
the recovery a prior session's abrupt death skipped — drain the
|
|
60
|
+
SessionEnd-queued retains (#1071) and reconcile un-committed transcript turns
|
|
61
|
+
(#3244). Those two carry independent 4s wall-clock budgets
|
|
62
|
+
(`HINDSIGHT_DRAIN_BUDGET_S` / `HINDSIGHT_RECONCILE_BUDGET_S`), each sized in
|
|
63
|
+
isolation against the old synchronous 5s SessionStart timeout, plus a ~2s
|
|
64
|
+
Mode-1 external-server health probe. Summed, they routinely overran 5s, so
|
|
65
|
+
Claude Code SIGKILLed the hook part-way through — truncating exactly the
|
|
66
|
+
durability work it exists to do and letting the pending-retains backlog grow
|
|
67
|
+
(fleet transcripts carry 1000+ `hook_cancelled` attachments for
|
|
68
|
+
`session_start.py`, all `timedOut: true` with `durationMs > 5000`; a
|
|
69
|
+
successful firing injects no `additionalContext` and so leaves no record,
|
|
70
|
+
which made the failures look like ~100% of firings when the true rate is
|
|
71
|
+
unmeasurable from attachments). Setting `"async": true` on the hook (the same
|
|
72
|
+
non-blocking pattern the Stop-event `retain.py` already uses) detaches it from
|
|
73
|
+
the SessionStart critical path: it injects no context, so nothing depends on
|
|
74
|
+
it finishing first and async's dropped context costs nothing. The `timeout`
|
|
75
|
+
ceiling rises 5s → 30s purely as a background-lifetime bound above the ~10s
|
|
76
|
+
summed sub-budgets — it never re-introduces a startup block, because drain and
|
|
77
|
+
reconcile self-cap at their budgets regardless. The drain stays in the hook
|
|
78
|
+
(not deferred wholly to the `hindsight-drain` sidecar, which `start.sh` only
|
|
79
|
+
starts conditionally — when it is absent this hook is the sole backlog path)
|
|
80
|
+
and `reconcile_tail`, which has no sidecar equivalent, stays here and now runs
|
|
81
|
+
to completion; its over-budget remainder still resumes on the next boot.
|
|
82
|
+
Concurrent drain with the sidecar stays safe via `drain_pending`'s exclusive
|
|
83
|
+
`fcntl.flock`. Pinned by `scripts/tests/test_session_start_durability.py`
|
|
84
|
+
(drain + reconcile are still invoked, in order) and
|
|
85
|
+
`tests/hindsight-session-start-async.test.ts` (the async flag + budget-clearing
|
|
86
|
+
ceiling survive the scaffold's hooks-override round-trip).
|
|
87
|
+
|
|
57
88
|
- **Recall/retain hygiene guard-rail batch** (`scripts/recall.py`,
|
|
58
89
|
`scripts/subagent_retain.py`, `scripts/lib/content.py`, `scripts/tests/**`).
|
|
59
90
|
Closes guard-rail debt in the fork's hook scripts before extending them:
|
|
@@ -1,12 +1,39 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""SessionStart hook: health check +
|
|
3
|
-
|
|
4
|
-
Fires once when a Claude Code session begins
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
2
|
+
"""SessionStart hook: health check + durability drain/reconcile.
|
|
3
|
+
|
|
4
|
+
Fires once when a Claude Code session begins (startup / compact / clear).
|
|
5
|
+
This is the Claude Code equivalent of Openclaw's service.start() — verify
|
|
6
|
+
the server is reachable early, then do the durability work that a prior
|
|
7
|
+
session's abrupt death may have skipped: drain SessionEnd-queued retains
|
|
8
|
+
(#1071) and reconcile un-committed transcript turns (#3244).
|
|
9
|
+
|
|
10
|
+
Runs ASYNC (``"async": true`` in hooks/hooks.json), for a reason specific
|
|
11
|
+
to THIS hook: it injects NO additionalContext — every return path is a
|
|
12
|
+
pure side effect — so nothing in the session depends on it finishing
|
|
13
|
+
before the first prompt, and losing async's dropped context costs us
|
|
14
|
+
nothing here (unlike recall.py, which must stay synchronous to inject).
|
|
15
|
+
|
|
16
|
+
Why async matters: drain and reconcile each carry an independent
|
|
17
|
+
wall-clock budget (``HINDSIGHT_DRAIN_BUDGET_S`` / ``HINDSIGHT_RECONCILE_BUDGET_S``,
|
|
18
|
+
4s each) plus a Mode-1 health probe (~2s). Those budgets were each sized
|
|
19
|
+
against the old synchronous 5s SessionStart timeout in ISOLATION, but they
|
|
20
|
+
STACK on one hook: drain(4s) + reconcile(4s, added later in #3244) +
|
|
21
|
+
probe(2s) routinely overran 5s, so the hook was SIGKILLed mid-drain on a
|
|
22
|
+
large share of firings (see the fleet-wide ``hook_cancelled`` transcript
|
|
23
|
+
attachments) — truncating exactly the durability work it exists to do,
|
|
24
|
+
and growing the pending-retains backlog it was meant to clear. Async
|
|
25
|
+
detaches it from the SessionStart critical path so the bounded, resumable
|
|
26
|
+
drain/reconcile can run to completion instead of being cut off. Both are
|
|
27
|
+
crash-safe under an eventual reap anyway: drain holds an ``fcntl.flock``
|
|
28
|
+
the kernel releases on death and re-queues unsent entries, and reconcile
|
|
29
|
+
is idempotent and resumes any over-budget remainder on the next boot.
|
|
30
|
+
|
|
31
|
+
Keeping the drain here (rather than deferring wholly to the hindsight-drain
|
|
32
|
+
sidecar) is deliberate: that sidecar is only CONDITIONALLY started — its
|
|
33
|
+
own "NOT STARTED" branch in start.sh notes that when it is absent the
|
|
34
|
+
memory queue is drained ONLY at session boot — so this hook stays the
|
|
35
|
+
backlog path for agents without the sidecar. reconcile_tail has no sidecar
|
|
36
|
+
equivalent at all and lives only here.
|
|
10
37
|
"""
|
|
11
38
|
|
|
12
39
|
import json
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""The SessionStart hook must still DO its durability work.
|
|
2
|
+
|
|
3
|
+
The hook was made ``"async": true`` (hooks/hooks.json) so a stacked
|
|
4
|
+
drain + reconcile + health-probe budget can no longer overrun the
|
|
5
|
+
SessionStart timeout and get the process SIGKILLed mid-drain. Async only
|
|
6
|
+
helps if the two durability calls are still MADE on the healthy path —
|
|
7
|
+
if a refactor drops one, the queue silently stops draining and abrupt-kill
|
|
8
|
+
turns stop being recovered, which is the exact outage the hook exists to
|
|
9
|
+
prevent and which no attachment record would surface (a successful
|
|
10
|
+
SessionStart hook that injects no context leaves no transcript trace).
|
|
11
|
+
|
|
12
|
+
So this pins the OUTCOME, not the wiring: on a reachable server,
|
|
13
|
+
``session_start.main()`` invokes ``drain_pending.drain`` and then
|
|
14
|
+
``reconcile_tail.reconcile``, in that order (reconcile runs AFTER the
|
|
15
|
+
drain by design — it recovers what SessionEnd never managed to enqueue).
|
|
16
|
+
|
|
17
|
+
Lives under ``scripts/tests/`` because that is the only python test
|
|
18
|
+
directory CI discovers (``ci-tests-python.yml`` runs ``unittest discover``
|
|
19
|
+
from ``vendor/hindsight-memory/scripts``).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import io
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import unittest
|
|
26
|
+
import unittest.mock
|
|
27
|
+
|
|
28
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
29
|
+
if SCRIPTS_DIR not in sys.path:
|
|
30
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
31
|
+
|
|
32
|
+
import drain_pending # noqa: E402
|
|
33
|
+
import reconcile_tail # noqa: E402
|
|
34
|
+
import session_start # noqa: E402
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _ReachableClient:
|
|
38
|
+
"""Stand-in for HindsightClient — reachable, no network."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, *_a, **_kw):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
def health_check(self, timeout=5, retries=3):
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DurabilityWorkStillRunsTest(unittest.TestCase):
|
|
48
|
+
CONFIG = {"autoRetain": True, "autoRecall": True}
|
|
49
|
+
|
|
50
|
+
def _run_main(self, config=None):
|
|
51
|
+
"""Run ``session_start.main()`` against a reachable server with the
|
|
52
|
+
two durability calls stubbed, returning the ordered call log."""
|
|
53
|
+
calls = []
|
|
54
|
+
|
|
55
|
+
def fake_drain(cfg):
|
|
56
|
+
calls.append("drain")
|
|
57
|
+
|
|
58
|
+
def fake_reconcile(cfg, hook_input=None):
|
|
59
|
+
calls.append("reconcile")
|
|
60
|
+
|
|
61
|
+
cfg = dict(self.CONFIG if config is None else config)
|
|
62
|
+
with unittest.mock.patch.object(drain_pending, "drain", fake_drain), \
|
|
63
|
+
unittest.mock.patch.object(reconcile_tail, "reconcile", fake_reconcile), \
|
|
64
|
+
unittest.mock.patch.object(session_start, "load_config", lambda: cfg), \
|
|
65
|
+
unittest.mock.patch.object(
|
|
66
|
+
session_start,
|
|
67
|
+
"get_api_url",
|
|
68
|
+
lambda c, debug_fn=None, allow_daemon_start=True: (
|
|
69
|
+
"http://127.0.0.1:9/none"
|
|
70
|
+
),
|
|
71
|
+
), \
|
|
72
|
+
unittest.mock.patch.object(
|
|
73
|
+
session_start, "HindsightClient", _ReachableClient
|
|
74
|
+
), \
|
|
75
|
+
unittest.mock.patch.object(sys, "stdin", io.StringIO("{}")):
|
|
76
|
+
session_start.main()
|
|
77
|
+
return calls
|
|
78
|
+
|
|
79
|
+
def test_drain_and_reconcile_both_run_on_a_reachable_server(self):
|
|
80
|
+
calls = self._run_main()
|
|
81
|
+
self.assertIn("drain", calls, "queued retains must still be drained")
|
|
82
|
+
self.assertIn(
|
|
83
|
+
"reconcile",
|
|
84
|
+
calls,
|
|
85
|
+
"un-committed abrupt-kill turns must still be reconciled",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def test_reconcile_runs_after_the_drain(self):
|
|
89
|
+
calls = self._run_main()
|
|
90
|
+
self.assertEqual(
|
|
91
|
+
calls,
|
|
92
|
+
["drain", "reconcile"],
|
|
93
|
+
"reconcile recovers what SessionEnd never enqueued, so it must "
|
|
94
|
+
"run AFTER the drain replays what it did",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def test_disabled_memory_skips_both(self):
|
|
98
|
+
"""The control case: with both autoRecall and autoRetain off, the
|
|
99
|
+
hook returns before touching the durability path. Without this the
|
|
100
|
+
assertions above could be satisfied by calls that fire
|
|
101
|
+
unconditionally."""
|
|
102
|
+
calls = self._run_main(config={"autoRetain": False, "autoRecall": False})
|
|
103
|
+
self.assertEqual(calls, [])
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
unittest.main()
|