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.
@@ -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
- if [ "${HANDOFF_BRIEFING_STDOUT:-}" = "1" ] || [ "${1:-}" = "--stdout" ]; then
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
- if [ -n "$TODAY" ] && [ -n "$WORKSPACE_DIR" ]; then
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
- printf '%s\n\n---\n\n%s\n' "$BRIEFING_HEADER" "$STDOUT_BUFFER"
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 entitlement = resp.status === 403 && isEntitlementDisabledMessage(apiErrorMessage);
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})`,
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.20.9", COMMIT_SHA = "63c4c44b";
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.9";
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.9",
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.9";
102810
- var COMMIT_SHA = "63c4c44b";
102811
- var COMMIT_DATE = "2026-08-05T03:51:28Z";
102812
- var LATEST_PR = 4388;
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:
@@ -6,7 +6,8 @@
6
6
  {
7
7
  "type": "command",
8
8
  "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/session_start.py\"",
9
- "timeout": 5
9
+ "async": true,
10
+ "timeout": 30
10
11
  }
11
12
  ]
12
13
  }
@@ -1,12 +1,39 @@
1
1
  #!/usr/bin/env python3
2
- """SessionStart hook: health check + session logging.
3
-
4
- Fires once when a Claude Code session begins. Uses additionalContext
5
- (supported on SessionStart) to inject an initial system note if
6
- Hindsight is available.
7
-
8
- This is the Claude Code equivalent of Openclaw's service.start()
9
- verify the server is reachable early, before the first prompt.
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()