switchroom 0.17.10 → 0.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +27 -1
  3. package/dist/auth-broker/index.js +6161 -151
  4. package/dist/cli/notion-write-pretool.mjs +29 -2
  5. package/dist/cli/switchroom.js +578 -454
  6. package/dist/host-control/main.js +6182 -172
  7. package/dist/vault/approvals/kernel-server.js +5891 -164
  8. package/dist/vault/broker/server.js +6597 -881
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +170 -21
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +2 -2
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  18. package/telegram-plugin/context-exhaustion.ts +124 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +24086 -8727
  20. package/telegram-plugin/gateway/activity-card-store.ts +76 -0
  21. package/telegram-plugin/gateway/gateway.ts +480 -85
  22. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  23. package/telegram-plugin/gateway/model-command.ts +70 -10
  24. package/telegram-plugin/package.json +6 -0
  25. package/telegram-plugin/quota-watch.ts +4 -6
  26. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  27. package/telegram-plugin/registry/turns-schema.ts +78 -0
  28. package/telegram-plugin/render/ir.ts +209 -0
  29. package/telegram-plugin/render/parse.ts +363 -0
  30. package/telegram-plugin/render/render.ts +440 -0
  31. package/telegram-plugin/render/rich-render.ts +72 -0
  32. package/telegram-plugin/stream-controller.ts +14 -3
  33. package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
  34. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  35. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  36. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  37. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
  38. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  39. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  40. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  41. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  42. package/telegram-plugin/tests/model-command.test.ts +193 -16
  43. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  44. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  45. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  46. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  47. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  48. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  49. package/telegram-plugin/tests/render/render.test.ts +436 -0
  50. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  51. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  52. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
  53. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  54. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  55. package/telegram-plugin/worktree-watch-cwds.ts +92 -17
  56. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  57. package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
  58. package/vendor/hindsight-memory/scripts/recall.py +64 -6
  59. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  60. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  61. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
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.17.10",
4
+ "version": "0.18.3",
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": {
@@ -21,8 +21,8 @@
21
21
  {
22
22
  "permissions": {
23
23
  "allow": {{{json permissionAllow}}},
24
- "deny": {{{json toolsDeny}}}{{#if defaultModeAcceptEdits}},
25
- "defaultMode": "acceptEdits"{{/if}}
24
+ "deny": {{{json toolsDeny}}},
25
+ "defaultMode": "{{defaultMode}}"
26
26
  }{{#if hindsightEnabled}},
27
27
  "autoMemoryEnabled": false{{/if}}{{#if mcpServers}},
28
28
  "mcpServers": {{{json mcpServers}}}
@@ -33,6 +33,19 @@ if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER"
33
33
  # same path the rest of start.sh + the MCP sidecar expects.
34
34
  export TELEGRAM_STATE_DIR="{{agentDir}}/telegram"
35
35
 
36
+ # SWITCHROOM_AGENT_NAME is the canonical "which agent am I" identity. It is
37
+ # normally supplied by the container env (compose.ts) so it is already
38
+ # present here, but the authoritative inner-pass export lives at line ~387,
39
+ # AFTER the gateway fork below. On any runtime where the compose env is NOT
40
+ # the source (non-docker / local dev), the forked gateway daemon would then
41
+ # start with the var UNSET — which collapses its worktree-ownership filter
42
+ # (worktree-watch-cwds.ts) to [], so a worktree-isolated background
43
+ # sub-agent gets NO live progress feed for its whole run (#1116 / #2893).
44
+ # Hoist the export here, before the fork, so the gateway ALWAYS has its
45
+ # identity on the fast path regardless of runtime. Idempotent with the
46
+ # compose env and the inner-pass export. Pinned by the scaffold-order test.
47
+ export SWITCHROOM_AGENT_NAME="{{name}}"
48
+
36
49
  # Gateway-consumed env MUST be exported HERE, before the gateway fork
37
50
  # below. The gateway daemon reads channels.telegram.* knobs (and any
38
51
  # agent env) from process.env at startup — e.g. SWITCHROOM_TG_STREAM_
@@ -57,9 +70,17 @@ if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER"
57
70
  # ANTHROPIC_CUSTOM_HEADERS exported BEFORE the gateway fork below, or
58
71
  # discoverSrModels() returns [] and /model never shows OpenRouter entries.
59
72
  #
60
- # Mirrors the INNER block's fail-open logic exactly:
61
- # - missing key → log + skip export (gateway talks direct OAuth)
62
- # - proxy down → log + strip ALL routing env (fail-open)
73
+ # Mirrors the INNER block's split boot contract EXACTLY (see the long note
74
+ # at the inner block for the full rationale). This OUTER pass runs BEFORE
75
+ # `exec tmux`, so whatever it does to the routing env is inherited by the
76
+ # inner pass — which is precisely why the old permanent strip-on-unreachable
77
+ # was so damaging: it removed SWITCHROOM_LITELLM here, and the inner
78
+ # self-heal block is gated on SWITCHROOM_LITELLM, so the entire inner fix
79
+ # was dead on the real docker path. The two boot failure modes:
80
+ # - MISSING KEY → log LOUDLY + fail-open (strip routing; gateway direct OAuth)
81
+ # - PROXY UNREACHABLE → log LOUDLY + KEEP routing in place (NO strip) so it
82
+ # survives into the inner pass and self-heals when litellm returns. Boot
83
+ # is non-fatal either way.
63
84
  # - ANTHROPIC_CUSTOM_HEADERS already set → skip (idempotent)
64
85
  #
65
86
  # SWITCHROOM_AGENT_NAME is injected by compose env (compose.ts:1816)
@@ -67,8 +88,9 @@ if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER"
67
88
  if [ -n "${SWITCHROOM_LITELLM:-}" ] && [ -z "$ANTHROPIC_CUSTOM_HEADERS" ] && command -v switchroom >/dev/null 2>&1; then
68
89
  sr_ll_key="$(switchroom vault get "litellm/$SWITCHROOM_AGENT_NAME/api-key" 2>/dev/null || true)"
69
90
  sr_ll_ok=""
91
+ sr_ll_unreachable=""
70
92
  if [ -z "$sr_ll_key" ]; then
71
- echo "litellm(outer): no virtual key for agent '$SWITCHROOM_AGENT_NAME' gateway will use direct OAuth (no tracking/guardrail)" >&2
93
+ echo "litellm(outer): WARNING — no virtual key for agent '$SWITCHROOM_AGENT_NAME'; gateway falling back to direct Anthropic OAuth (untracked, unguarded)." >&2
72
94
  elif command -v curl >/dev/null 2>&1 && [ -n "$ANTHROPIC_BASE_URL" ]; then
73
95
  # Bounded retry probe (co-boot race fix, 2026-07): when the whole stack
74
96
  # co-boots, the litellm proxy's heavy Python app is often not yet healthy
@@ -85,7 +107,12 @@ if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER"
85
107
  sleep 3
86
108
  done
87
109
  if [ -z "$sr_ll_up" ]; then
88
- echo "litellm(outer): proxy unreachable at ${SWITCHROOM_LITELLM_BASE:-$ANTHROPIC_BASE_URL} after 120s of retries falling back to direct OAuth (no tracking/guardrail this session)" >&2
110
+ # Proxy unreachable but the key is present. Do NOT strip: leave routing
111
+ # pointed at litellm so it survives into the inner (tmux) pass and
112
+ # self-heals once the proxy is back (socat forwarder reconnects
113
+ # per-connection). Boot continues.
114
+ sr_ll_unreachable="1"
115
+ echo "litellm(outer): WARNING — proxy unreachable at ${SWITCHROOM_LITELLM_BASE:-$ANTHROPIC_BASE_URL} after 120s of retries; gateway discovery will FAIL until it recovers — routing LEFT IN PLACE so it self-heals. NOT falling back to untracked direct Anthropic OAuth." >&2
89
116
  else
90
117
  sr_ll_ok="1"
91
118
  fi
@@ -98,10 +125,17 @@ if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER"
98
125
  x-litellm-customer-id: $SWITCHROOM_AGENT_NAME
99
126
  x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:-default}"
100
127
  export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1
128
+ elif [ -n "$sr_ll_unreachable" ]; then
129
+ # Proxy unreachable at boot: routing intentionally LEFT IN PLACE (loud
130
+ # warning already emitted) so it survives into the inner pass and
131
+ # self-heals once litellm returns. NO unset — the permanent strip here
132
+ # was what made the inner self-heal fix inert on docker.
133
+ :
101
134
  else
135
+ # Missing-key fail-open: gateway talks direct OAuth, unproxied.
102
136
  unset ANTHROPIC_BASE_URL ANTHROPIC_SMALL_FAST_MODEL SWITCHROOM_LITELLM SWITCHROOM_LITELLM_BASE
103
137
  fi
104
- unset sr_ll_key sr_ll_ok
138
+ unset sr_ll_key sr_ll_ok sr_ll_unreachable
105
139
  fi
106
140
 
107
141
  # Tiny in-process supervisor: runs cmd in a respawn loop with
@@ -911,12 +945,21 @@ fi
911
945
  # content-safety guardrails — it never alters the model or Claude's operation.
912
946
  # See reference/invariants.md § "Operator-controlled gateway carve-out".
913
947
  #
914
- # FAIL-OPEN (operator decision 2026-06-28): if the key is missing OR the proxy
915
- # is unreachable at boot, strip the routing env and fall back to the direct
916
- # broker-OAuth Anthropic path, logging the reason LOUDLY. Availability wins over
917
- # tracking/guardrails a proxy outage must never take an agent dark. The
918
- # tradeoff is an explicit, logged guardrail lapse for that session, not a silent
919
- # one. Re-probed on every (re)boot, so recovery is automatic.
948
+ # TWO boot failure modes, handled DIFFERENTLY (2026-07-08 was a single
949
+ # fail-open path that silently took an agent off-proxy for its whole lifetime):
950
+ # - MISSING KEY (no per-agent virtual key in the vault): fail-open strip the
951
+ # routing env and fall back to the direct broker-OAuth Anthropic path,
952
+ # logging LOUDLY. Without a key there is nothing to authenticate to the
953
+ # proxy with, so there is no metered route to self-heal into.
954
+ # - PROXY UNREACHABLE at boot (key present, liveliness probe fails within the
955
+ # retry window): do NOT strip. Leave ANTHROPIC_BASE_URL / SWITCHROOM_LITELLM*
956
+ # pointed at the proxy (the socat forwarder at 127.0.0.1:4010 self-heals
957
+ # per-connection) and emit a LOUD warning. Claude traffic FAILS+retries until
958
+ # the proxy returns, then routes through it automatically — the metered path
959
+ # stays the metered path. We deliberately do NOT silently fall back to
960
+ # untracked direct Anthropic on a transient blip: an agent that boots during
961
+ # a brief litellm outage previously never routed through litellm again until
962
+ # a manual restart. Re-probed on every (re)boot.
920
963
  #
921
964
  # Static tags (x-litellm-customer-id / x-litellm-tags) give per-AGENT
922
965
  # attribution in LiteLLM. Per-SESSION is NOT achievable here: claude sends no
@@ -924,11 +967,22 @@ fi
924
967
  # per-session cost/issue tracking is handled out-of-band by correlating
925
968
  # LiteLLM's request log against switchroom's turn ledger, never by mutating the
926
969
  # claude protocol.
970
+ # _LITELLM_OK is read by the session-model-override block below: an sr-*
971
+ # (LiteLLM/OpenRouter) session override must be DROPPED when the proxy is
972
+ # unreachable at boot, or claude would 4xx an unknown model against Anthropic.
973
+ # Empty by default (proxy not confirmed live); set to "1" only on a successful
974
+ # liveliness probe. Deliberately NOT unset with the sr_ll_* scratch vars.
975
+ _LITELLM_OK=""
927
976
  if [ -n "${SWITCHROOM_LITELLM:-}" ] && command -v switchroom >/dev/null 2>&1; then
928
977
  sr_ll_key="$(switchroom vault get "litellm/$SWITCHROOM_AGENT_NAME/api-key" 2>/dev/null || true)"
929
978
  sr_ll_ok=""
979
+ sr_ll_unreachable=""
930
980
  if [ -z "$sr_ll_key" ]; then
931
- echo "litellm: no virtual key for agent '$SWITCHROOM_AGENT_NAME' — falling back to direct OAuth (no tracking/guardrail)" >&2
981
+ echo "==================== litellm FAIL-OPEN ====================" >&2
982
+ echo "WARNING: no litellm virtual key for agent '$SWITCHROOM_AGENT_NAME'." >&2
983
+ echo "WARNING: falling back to direct Anthropic OAuth (untracked, unguarded)" >&2
984
+ echo "WARNING: — cannot authenticate to the proxy without a key." >&2
985
+ echo "==========================================================" >&2
932
986
  elif command -v curl >/dev/null 2>&1 && [ -n "$ANTHROPIC_BASE_URL" ]; then
933
987
  # Bounded retry probe (co-boot race fix, 2026-07): when the whole stack
934
988
  # co-boots, the litellm proxy's heavy Python app is often not yet healthy
@@ -945,7 +999,16 @@ if [ -n "${SWITCHROOM_LITELLM:-}" ] && command -v switchroom >/dev/null 2>&1; th
945
999
  sleep 3
946
1000
  done
947
1001
  if [ -z "$sr_ll_up" ]; then
948
- echo "litellm: proxy unreachable at ${SWITCHROOM_LITELLM_BASE:-$ANTHROPIC_BASE_URL} after 120s of retries falling back to direct OAuth (no tracking/guardrail this session)" >&2
1002
+ # Proxy unreachable at boot but the key is present. Do NOT strip routing:
1003
+ # leave it pointed at litellm so the fleet self-heals once the proxy is
1004
+ # back (the socat forwarder at 127.0.0.1:4010 reconnects per-connection).
1005
+ sr_ll_unreachable="1"
1006
+ echo "==================== litellm UNREACHABLE ====================" >&2
1007
+ echo "WARNING: litellm proxy unreachable at ${SWITCHROOM_LITELLM_BASE:-$ANTHROPIC_BASE_URL} after 120s of retries;" >&2
1008
+ echo "WARNING: Claude traffic will FAIL until it recovers — routing LEFT IN PLACE" >&2
1009
+ echo "WARNING: so it self-heals (socat forwarder reconnects per-connection)." >&2
1010
+ echo "WARNING: NOT falling back to untracked direct Anthropic OAuth." >&2
1011
+ echo "============================================================" >&2
949
1012
  else
950
1013
  sr_ll_ok="1"
951
1014
  fi
@@ -954,6 +1017,7 @@ if [ -n "${SWITCHROOM_LITELLM:-}" ] && command -v switchroom >/dev/null 2>&1; th
954
1017
  sr_ll_ok="1"
955
1018
  fi
956
1019
  if [ -n "$sr_ll_ok" ]; then
1020
+ _LITELLM_OK="1"
957
1021
  # Newline-separated Name: Value pairs (claude CLI ANTHROPIC_CUSTOM_HEADERS format).
958
1022
  # Tags: agent:<name> for per-agent spend tracking; profile:<profile> for
959
1023
  # fleet-level cost breakdown by role. Per-turn tags (cron vs telegram) are
@@ -967,24 +1031,109 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
967
1031
  # models configured in the proxy appear in the /model picker and can be
968
1032
  # selected via --model. Without this, the CLI only knows its bundled list.
969
1033
  export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1
1034
+ elif [ -n "$sr_ll_unreachable" ]; then
1035
+ # Proxy unreachable at boot: routing intentionally LEFT IN PLACE (loud
1036
+ # warning already emitted above) so it self-heals when litellm returns.
1037
+ # _LITELLM_OK stays empty so any sr-* session model override is still
1038
+ # dropped below (claude would 4xx an unknown model against a down endpoint).
1039
+ # NO unset here — that permanent strip was the bug this branch fixes.
1040
+ :
970
1041
  else
971
- # Fail-open: drop every routing var so the claude CLI talks to Anthropic
972
- # directly on its OAuth credential (subscription path), unproxied.
1042
+ # Missing key fail-open: drop every routing var so the claude CLI talks to
1043
+ # Anthropic directly on its OAuth credential (subscription path), unproxied.
973
1044
  unset ANTHROPIC_BASE_URL ANTHROPIC_SMALL_FAST_MODEL SWITCHROOM_LITELLM SWITCHROOM_LITELLM_BASE
974
1045
  fi
975
- unset sr_ll_key sr_ll_ok
1046
+ unset sr_ll_key sr_ll_ok sr_ll_unreachable
1047
+ fi
1048
+
1049
+ # --- Session-only model override (carrier: .session-model-override) ---
1050
+ #
1051
+ # The Telegram `/model <sr-*>` switch can't use claude's in-REPL `/model`
1052
+ # picker for non-Anthropic (LiteLLM/OpenRouter `sr-*`) models — the CLI's
1053
+ # native picker rejects unknown ids ("Model not found"). Instead the gateway
1054
+ # writes the chosen model token to `.session-model-override` and gracefully
1055
+ # restarts the agent; THIS boot picks it up and launches `claude --model
1056
+ # <token>` directly (no picker validation at launch — LiteLLM routes it).
1057
+ #
1058
+ # One-shot + session-only: the carrier is consumed (rm) on this one boot
1059
+ # regardless of validity, so the next restart reverts to the configured
1060
+ # default `{{{modelQ}}}`. Mirrors the `.force-fresh-session` marker's
1061
+ # gateway↔start.sh round-trip. See telegram-plugin/gateway/model-command.ts.
1062
+ # NB {{{modelQ}}} is already shell-single-quoted by the scaffold (it renders as
1063
+ # a quoted token, e.g. 'claude-sonnet-5'), so it is assigned BARE here — never
1064
+ # inside additional double quotes, which would embed the literal quote chars in
1065
+ # the value and break `claude --model`.
1066
+ _EFFECTIVE_MODEL={{{modelQ}}}
1067
+ if [ -f "{{agentDir}}/.session-model-override" ]; then
1068
+ _override="$(tr -d '[:space:]' < "{{agentDir}}/.session-model-override" 2>/dev/null || true)"
1069
+ rm -f "{{agentDir}}/.session-model-override"
1070
+ # Shape gate — kept aligned with MODEL_ARG_RE in telegram-plugin/gateway/model-command.ts.
1071
+ # `/` is allowed for OpenRouter-style `sr-vendor/model` ids; it is not a shell
1072
+ # metachar inside the double-quoted `claude --model "$_EFFECTIVE_MODEL"` usage below.
1073
+ if printf '%s' "$_override" | grep -Eq '^[A-Za-z0-9][]A-Za-z0-9._[/-]{0,99}$'; then
1074
+ _EFFECTIVE_MODEL="$_override"
1075
+ # LiteLLM-down guard for an sr-* OVERRIDE only (operator decision 2026-07):
1076
+ # with the proxy unreachable at boot there IS a Claude configured default to
1077
+ # fall back to, so DROP the override rather than boot on an unreachable sr-*
1078
+ # model that would 4xx against Anthropic — log LOUDLY and write an alert
1079
+ # sentinel the gateway turns into a Telegram message. (The passthrough→router
1080
+ # repoint for a LIVE sr-* — override OR configured default — is done once,
1081
+ # post-resolution, below.)
1082
+ case "$_override" in
1083
+ sr-*)
1084
+ if [ -z "$_LITELLM_OK" ]; then
1085
+ _EFFECTIVE_MODEL={{{modelQ}}}
1086
+ echo "session-model: LiteLLM proxy unreachable at boot — DROPPING sr-* override '$_override', booting on configured default '$_EFFECTIVE_MODEL' instead (would otherwise 4xx against Anthropic)" >&2
1087
+ echo "session-model: the requested sr-* model is UNAVAILABLE this session; re-issue /model $_override once LiteLLM is reachable" >&2
1088
+ printf 'LiteLLM proxy was unreachable at boot, so the session-only model switch to `%s` was dropped — the agent booted on its configured default `%s` instead. Re-issue /model %s once LiteLLM is reachable.\n' "$_override" "$_EFFECTIVE_MODEL" "$_override" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
1089
+ fi
1090
+ ;;
1091
+ esac
1092
+ else
1093
+ echo "session-model: ignoring malformed .session-model-override (failed shape gate) — using configured default '$_EFFECTIVE_MODEL'" >&2
1094
+ fi
1095
+ unset _override
976
1096
  fi
977
1097
 
1098
+ # sr-* passthrough→router repoint — ONE post-resolution gate covering BOTH the
1099
+ # /model override path AND the configured-default path (`model: sr-*` in
1100
+ # switchroom.yaml, which has NO override carrier). sr-* (OpenRouter) models route
1101
+ # by MODEL NAME through LiteLLM's model-mapped root. The default ANTHROPIC_BASE_URL
1102
+ # points at the `/anthropic` PASSTHROUGH (raw byte-forward to api.anthropic.com,
1103
+ # chosen to dodge the Opus SSE re-chunk stall for Claude traffic) — but the
1104
+ # passthrough is model-agnostic and ships EVERY request to Anthropic regardless of
1105
+ # --model, so an sr-* model 4xxs "model not found". Repoint at the LiteLLM router
1106
+ # root so claude's /v1/messages hits the model router → OpenRouter. Claude
1107
+ # (default/override) sessions are untouched — they keep the passthrough.
1108
+ #
1109
+ # Guarded on the proxy being reachable at boot ($_LITELLM_OK). If it is NOT, we
1110
+ # leave routing exactly as compose/start.sh set it (compose already points a
1111
+ # non-Claude default at the router root) and run degraded until litellm returns —
1112
+ # there is NO Claude default to fall back to for a persistent sr-* agent, and
1113
+ # that (loud-warned, non-fatal) degraded state is the correct behavior.
1114
+ case "$_EFFECTIVE_MODEL" in
1115
+ sr-*)
1116
+ if [ -n "$_LITELLM_OK" ] && [ -n "$SWITCHROOM_LITELLM_BASE" ]; then
1117
+ export ANTHROPIC_BASE_URL="$SWITCHROOM_LITELLM_BASE"
1118
+ echo "session-model: effective model '$_EFFECTIVE_MODEL' is sr-* — routing via LiteLLM model router '$SWITCHROOM_LITELLM_BASE' (off /anthropic passthrough) so it reaches OpenRouter" >&2
1119
+ fi
1120
+ ;;
1121
+ esac
1122
+ # Record the EFFECTIVE launched model so the gateway can re-hydrate its
1123
+ # in-memory session-model state after this restart, keeping /status and the
1124
+ # welcome card honest. Overwrite (not consumed) — the gateway reads it at boot.
1125
+ printf '%s\n' "$_EFFECTIVE_MODEL" > "{{agentDir}}/.active-session-model" 2>/dev/null || true
1126
+
978
1127
  {{#if useSwitchroomPlugin}}
979
1128
  if [ -n "$APPEND_PROMPT" ]; then
980
- exec claude $CONTINUE_FLAG --dangerously-load-development-channels server:switchroom-telegram --plugin-dir "{{securityPluginDir}}"{{#if hindsightEnabled}} --plugin-dir "{{agentDir}}/.claude/plugins/hindsight-memory"{{/if}} $SR_FLEET_ARG{{#if modelQ}} --model {{{modelQ}}}{{/if}}{{#if thinkingEffort}} --effort {{thinkingEffort}}{{/if}}{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}} --append-system-prompt "$APPEND_PROMPT"{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
1129
+ exec claude $CONTINUE_FLAG --dangerously-load-development-channels server:switchroom-telegram --plugin-dir "{{securityPluginDir}}"{{#if hindsightEnabled}} --plugin-dir "{{agentDir}}/.claude/plugins/hindsight-memory"{{/if}} $SR_FLEET_ARG --model "$_EFFECTIVE_MODEL"{{#if thinkingEffort}} --effort {{thinkingEffort}}{{/if}}{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}} --append-system-prompt "$APPEND_PROMPT"{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
981
1130
  else
982
- exec claude $CONTINUE_FLAG --dangerously-load-development-channels server:switchroom-telegram --plugin-dir "{{securityPluginDir}}"{{#if hindsightEnabled}} --plugin-dir "{{agentDir}}/.claude/plugins/hindsight-memory"{{/if}} $SR_FLEET_ARG{{#if modelQ}} --model {{{modelQ}}}{{/if}}{{#if thinkingEffort}} --effort {{thinkingEffort}}{{/if}}{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}}{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
1131
+ exec claude $CONTINUE_FLAG --dangerously-load-development-channels server:switchroom-telegram --plugin-dir "{{securityPluginDir}}"{{#if hindsightEnabled}} --plugin-dir "{{agentDir}}/.claude/plugins/hindsight-memory"{{/if}} $SR_FLEET_ARG --model "$_EFFECTIVE_MODEL"{{#if thinkingEffort}} --effort {{thinkingEffort}}{{/if}}{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}}{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
983
1132
  fi
984
1133
  {{else}}
985
1134
  if [ -n "$APPEND_PROMPT" ]; then
986
- exec claude $CONTINUE_FLAG --channels plugin:telegram@claude-plugins-official --plugin-dir "{{securityPluginDir}}"{{#if hindsightEnabled}} --plugin-dir "{{agentDir}}/.claude/plugins/hindsight-memory"{{/if}} $SR_FLEET_ARG{{#if modelQ}} --model {{{modelQ}}}{{/if}}{{#if thinkingEffort}} --effort {{thinkingEffort}}{{/if}}{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}} --append-system-prompt "$APPEND_PROMPT"{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
1135
+ exec claude $CONTINUE_FLAG --channels plugin:telegram@claude-plugins-official --plugin-dir "{{securityPluginDir}}"{{#if hindsightEnabled}} --plugin-dir "{{agentDir}}/.claude/plugins/hindsight-memory"{{/if}} $SR_FLEET_ARG --model "$_EFFECTIVE_MODEL"{{#if thinkingEffort}} --effort {{thinkingEffort}}{{/if}}{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}} --append-system-prompt "$APPEND_PROMPT"{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
987
1136
  else
988
- exec claude $CONTINUE_FLAG --channels plugin:telegram@claude-plugins-official --plugin-dir "{{securityPluginDir}}"{{#if hindsightEnabled}} --plugin-dir "{{agentDir}}/.claude/plugins/hindsight-memory"{{/if}} $SR_FLEET_ARG{{#if modelQ}} --model {{{modelQ}}}{{/if}}{{#if thinkingEffort}} --effort {{thinkingEffort}}{{/if}}{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}}{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
1137
+ exec claude $CONTINUE_FLAG --channels plugin:telegram@claude-plugins-official --plugin-dir "{{securityPluginDir}}"{{#if hindsightEnabled}} --plugin-dir "{{agentDir}}/.claude/plugins/hindsight-memory"{{/if}} $SR_FLEET_ARG --model "$_EFFECTIVE_MODEL"{{#if thinkingEffort}} --effort {{thinkingEffort}}{{/if}}{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}}{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
989
1138
  fi
990
1139
  {{/if}}
@@ -41,7 +41,7 @@ You are a senior software engineering agent. You write, review, debug, and archi
41
41
  Claude Code's file-based auto-memory is disabled. Use **Hindsight** MCP tools:
42
42
 
43
43
  - `mcp__hindsight__recall` — search past memories. Auto-fires on every message.
44
- - `mcp__hindsight__retain` — store important facts. Auto-retains transcript every ~10 turns.
44
+ - `mcp__hindsight__retain` — store important facts. Auto-retains every turn (chunked, a small ~3-turn window each time), so it's prompt and cheap.
45
45
  - `mcp__hindsight__reflect` — synthesize across memories for complex queries.
46
46
  - `mcp__hindsight__create_mental_model` — maintain semantic summaries (e.g. "codebase architecture").
47
47
 
@@ -35,7 +35,7 @@ Hindsight is a memory bank with semantic search, knowledge graph, entity resolut
35
35
 
36
36
  ### Day-to-day tools
37
37
  - `mcp__hindsight__recall` — semantic-search the bank for relevant past memories. Auto-fires on every inbound user message via the plugin's UserPromptSubmit hook (you'll see "Relevant memories from past conversations" in your context). Call manually when you need a more specific query than the auto-fired one.
38
- - `mcp__hindsight__retain` — store a new memory. The plugin automatically retains the conversation transcript every ~10 turns via the Stop hook, so you usually don't need this. Call manually for significant decisions, corrections, or facts you want immediately searchable.
38
+ - `mcp__hindsight__retain` — store a new memory. The plugin auto-retains every turn via the Stop hook, but in chunked mode each retain only processes a small recent window (~3 turns) so it captures memory promptly and survives restarts without re-sending the whole transcript, and you usually don't need this. Call manually for significant decisions, corrections, or facts you want immediately searchable.
39
39
  - `mcp__hindsight__reflect` — Hindsight's LLM-powered "answer this query using the bank's content + directives". Use when the user asks a question that requires synthesis across multiple past memories.
40
40
 
41
41
  ### Mental Models
@@ -64,7 +64,7 @@ Don't retain:
64
64
  - Sensitive content the user explicitly asked you to not remember
65
65
  - Things already in a mental model — they'll be re-derived from underlying memories
66
66
 
67
- The plugin's auto-retain (Stop hook) handles transcript-level storage on a 10-turn cadence, so you don't need to manually retain everything. Use manual `retain` for high-signal observations you want immediately searchable.
67
+ The plugin's auto-retain (Stop hook) fires every turn, but in chunked mode each retain only processes a small recent window (~3 turns) — so storage stays prompt and cheap and survives restarts without re-sending the whole transcript, and you don't need to manually retain everything. Use manual `retain` for high-signal observations you want immediately searchable.
68
68
 
69
69
  ## Sub-Agent Delegation
70
70
 
@@ -39,7 +39,7 @@ Hindsight is a memory bank with semantic search, knowledge graph, entity resolut
39
39
 
40
40
  ### Day-to-day tools
41
41
  - `mcp__hindsight__recall` — semantic-search the bank for relevant past memories. Auto-fires on every inbound user message via the plugin's UserPromptSubmit hook (you'll see "Relevant memories from past conversations" in your context). Call manually when you need a more specific query than the auto-fired one.
42
- - `mcp__hindsight__retain` — store a new memory. The plugin automatically retains the conversation transcript every ~10 turns via the Stop hook, so you usually don't need this. Call manually for significant decisions, corrections, or facts you want immediately searchable.
42
+ - `mcp__hindsight__retain` — store a new memory. The plugin auto-retains every turn via the Stop hook, but in chunked mode each retain only processes a small recent window (~3 turns) so it captures memory promptly and survives restarts without re-sending the whole transcript, and you usually don't need this. Call manually for significant decisions, corrections, or facts you want immediately searchable.
43
43
  - `mcp__hindsight__reflect` — Hindsight's LLM-powered "answer this query using the bank's content + directives". Use when the user asks a question that requires synthesis across multiple past memories.
44
44
 
45
45
  ### Mental Models
@@ -68,7 +68,7 @@ Don't retain:
68
68
  - Sensitive content the user explicitly asked you to not remember
69
69
  - Things already in a mental model — they'll be re-derived from underlying memories
70
70
 
71
- The plugin's auto-retain (Stop hook) handles transcript-level storage on a 10-turn cadence, so you don't need to manually retain everything. Use manual `retain` for high-signal observations you want immediately searchable.
71
+ The plugin's auto-retain (Stop hook) fires every turn, but in chunked mode each retain only processes a small recent window (~3 turns) — so storage stays prompt and cheap and survives restarts without re-sending the whole transcript, and you don't need to manually retain everything. Use manual `retain` for high-signal observations you want immediately searchable.
72
72
 
73
73
  ## Sub-Agent Delegation
74
74
 
@@ -40,7 +40,7 @@ You help the user stay organized, prepared, and focused on high-leverage work. Y
40
40
  Claude Code's file-based auto-memory is disabled. Use **Hindsight** MCP tools:
41
41
 
42
42
  - `mcp__hindsight__recall` — search past memories. Auto-fires every message.
43
- - `mcp__hindsight__retain` — store important facts. Auto-retains every ~10 turns.
43
+ - `mcp__hindsight__retain` — store important facts. Auto-retains every turn (chunked, a small ~3-turn window each time), so it's prompt and cheap.
44
44
  - `mcp__hindsight__create_mental_model` — maintain models for "contacts", "active projects", "user preferences".
45
45
 
46
46
  Save proactively: contacts and their roles, scheduling preferences, project status, decisions with rationale, communication templates. Only Hindsight memories survive compaction.
@@ -34,7 +34,7 @@ Recommend the user consult a professional for: persistent pain/injury, medical c
34
34
  Claude Code's file-based auto-memory is disabled. Use **Hindsight** MCP tools:
35
35
 
36
36
  - `mcp__hindsight__recall` — search past memories. Auto-fires on every message.
37
- - `mcp__hindsight__retain` — store important facts. Auto-retains every ~10 turns.
37
+ - `mcp__hindsight__retain` — store important facts. Auto-retains every turn (chunked, a small ~3-turn window each time), so it's prompt and cheap.
38
38
  - `mcp__hindsight__create_mental_model` — maintain a "fitness profile" mental model.
39
39
 
40
40
  Save proactively: workout logs, goals, PRs, preferences, patterns (e.g. "poor sleep on Sundays"), injuries/limitations. Only Hindsight memories survive session compaction.
@@ -447,30 +447,27 @@ function pctCells(
447
447
  }
448
448
 
449
449
  /**
450
- * The Status cell: for a blocked account, when the binding window comes back
451
- * (`back <when>`); for a healthy/throttling account, when the soonest window
452
- * refills (`refills <when>`). `<when>` is the tz-aware absolute time (date shown
453
- * only when not today) plus the concise relative hint in parens.
450
+ * The per-window reset cell (`5h resets` / `7d resets`): the tz-aware absolute
451
+ * reset time for THAT specific window (date shown only when not today) plus the
452
+ * concise relative hint in parens e.g. `1:20 PM (in 2h)`.
453
+ *
454
+ * Degrades in lockstep with `pctCells` so the percentage cell and its reset cell
455
+ * always agree: no quota → probe-failed message (or `—`); thin probe → `quota
456
+ * unknown`; missing reset on an otherwise-present window → `—`.
454
457
  */
455
- function statusCell(snap: AccountSnapshot, now: Date, tz: string): string {
458
+ function windowResetCell(
459
+ snap: AccountSnapshot,
460
+ now: Date,
461
+ tz: string,
462
+ win: '5h' | '7d',
463
+ ): string {
456
464
  if (!snap.quota) {
457
- return snap.quotaError ? `probe failed (${snap.quotaError})` : 'probe failed';
465
+ return snap.quotaError ? `probe failed (${snap.quotaError})` : '';
458
466
  }
459
467
  if (isProbeThin(snap.quota)) return 'quota unknown';
460
- const q = snap.quota;
461
- const health = classifyHealth(snap, now);
462
- if (health === 'blocked') {
463
- const win = bindingWindow(q);
464
- const reset = win === '5h' ? q.fiveHourResetAt : q.sevenDayResetAt;
465
- if (!reset) return 'back — (reset unknown)';
466
- return `back ${formatStatusTime(reset, now, tz)} (in ${formatRelative(reset, now)})`;
467
- }
468
- // healthy / throttling — show the soonest refill across both windows.
469
- const fiveIn = q.fiveHourResetAt ? q.fiveHourResetAt.getTime() - now.getTime() : Infinity;
470
- const sevenIn = q.sevenDayResetAt ? q.sevenDayResetAt.getTime() - now.getTime() : Infinity;
471
- const soonest = fiveIn <= sevenIn ? q.fiveHourResetAt : q.sevenDayResetAt;
472
- if (!soonest) return 'refills —';
473
- return `refills ${formatStatusTime(soonest, now, tz)} (in ${formatRelative(soonest, now)})`;
468
+ const reset = win === '5h' ? snap.quota.fiveHourResetAt : snap.quota.sevenDayResetAt;
469
+ if (!reset) return '—';
470
+ return `${formatStatusTime(reset, now, tz)} (in ${formatRelative(reset, now)})`;
474
471
  }
475
472
 
476
473
  export function renderAuthSnapshotFormat2(
@@ -495,8 +492,8 @@ export function renderAuthSnapshotFormat2(
495
492
 
496
493
  if (ordered.length > 0) {
497
494
  lines.push('');
498
- lines.push('| State | Account | 5h | 7d | Status |');
499
- lines.push('| --- | --- | --- | --- | --- |');
495
+ lines.push('| State | Account | 5h | 5h resets | 7d | 7d resets |');
496
+ lines.push('| --- | --- | --- | --- | --- | --- |');
500
497
  for (const s of ordered) {
501
498
  const emoji = HEALTH_EMOJI[classifyHealth(s, now)];
502
499
  // Account cell: FULL email, never truncated; active gets a (active) suffix.
@@ -512,9 +509,10 @@ export function renderAuthSnapshotFormat2(
512
509
  // tableCell wrap around the finished span.
513
510
  const accountCell = `\`${codeSpanSafe(s.isActive ? `${label} (active)` : label)}\``;
514
511
  const { five, seven } = pctCells(s, now);
515
- const status = statusCell(s, now, tz);
512
+ const fiveReset = windowResetCell(s, now, tz, '5h');
513
+ const sevenReset = windowResetCell(s, now, tz, '7d');
516
514
  lines.push(
517
- `| ${emoji} | ${tableCell(accountCell)} | ${five} | ${seven} | ${tableCell(status)} |`,
515
+ `| ${emoji} | ${tableCell(accountCell)} | ${five} | ${tableCell(fiveReset)} | ${seven} | ${tableCell(sevenReset)} |`,
518
516
  );
519
517
  }
520
518
  }
@@ -48,3 +48,127 @@ export function shouldArmOrphanedReplyTimeout(params: {
48
48
  !params.progressCardActive
49
49
  )
50
50
  }
51
+
52
+ /**
53
+ * Default "recently streaming" window (ms) for the orphaned-reply liveness
54
+ * stamp. The gateway overrides this from
55
+ * SWITCHROOM_ORPHANED_REPLY_STREAM_WINDOW_MS; this constant is the fallback and
56
+ * the value the LivenessTracker tests pin against.
57
+ */
58
+ export const ORPHANED_REPLY_STREAM_WINDOW_MS = 120_000
59
+
60
+ /**
61
+ * Result of a fuse-expiry decision.
62
+ *
63
+ * - `rearm` — re-arm the fuse (keep the turn alive) instead of
64
+ * firing the synthetic turn_end backstop.
65
+ * - `countsAgainstCap`— this rearm (or the fire it turned into at the cap) was
66
+ * a working/recently-streaming rearm, i.e. subject to the
67
+ * ORPHANED_REPLY_MAX_REARMS cap. Human-wait rearms are
68
+ * uncapped and report `false` here.
69
+ */
70
+ export interface OrphanedReplyExpiryDecision {
71
+ rearm: boolean
72
+ countsAgainstCap: boolean
73
+ }
74
+
75
+ /**
76
+ * Per-turn liveness tracker for the orphaned-reply backstop.
77
+ *
78
+ * WHY THIS EXISTS (the thinking-pause fix):
79
+ * The orphaned-reply fuse (ORPHANED_REPLY_TIMEOUT_MS = 30 s) used to be reset
80
+ * ONLY by `tool_label` and `text` stream events. During a long model
81
+ * reasoning pause the gateway sees NO such events (thinking events carry no
82
+ * text), so the fuse ran down and force-ended a genuinely-live turn mid-work
83
+ * ("orphaned-reply timeout (30000ms) — forcing backstop" ~1 ms before
84
+ * turn_end, with minutes of real work still to come).
85
+ *
86
+ * THE FIX:
87
+ * Stamp `lastStreamEventAt` on ANY genuine stream event (via onStreamEvent,
88
+ * called from the gateway dispatcher entry). If a genuine event arrived
89
+ * within `windowMs` (default 120 s) the turn is "recently streaming" and the
90
+ * fuse re-arms instead of firing — a reasoning pause is survivable while a
91
+ * genuine multi-minute hang (no events at all) still fires.
92
+ *
93
+ * This is a small, pure, side-effect-free seam so the decision logic is unit
94
+ * testable without the full gateway. The gateway holds one instance per
95
+ * CurrentTurn (so per-turn identity / supersession semantics are unchanged) and
96
+ * delegates: the dispatcher calls onStreamEvent; the fire callback calls
97
+ * decideOnExpiry; the defensive turn_end guard reads recentlyStreaming.
98
+ */
99
+ export class LivenessTracker {
100
+ /** Wall-clock (ms) of the last genuine stream event for this turn. */
101
+ lastStreamEventAt: number
102
+ /**
103
+ * How many times the fuse re-armed on a working / recently-streaming
104
+ * expiry. Bounded by ORPHANED_REPLY_MAX_REARMS. Zeroed by onStreamEvent
105
+ * whenever a genuine stream event lands, so the cap only bites CONSECUTIVE
106
+ * silent expiries (a genuinely wedged single tool with no stream at all).
107
+ */
108
+ orphanedReplyRearmCount = 0
109
+
110
+ constructor(startedAt: number) {
111
+ this.lastStreamEventAt = startedAt
112
+ }
113
+
114
+ /**
115
+ * Stamp liveness for a genuine stream event and reset the rearm counter.
116
+ *
117
+ * F4 predicate: everything EXCEPT the synthetic `turn_end` re-entry
118
+ * (durationMs === -1, the fire callback's own re-dispatch) counts as a
119
+ * genuine stream event. Stamping a REAL turn_end (durationMs >= 0) is
120
+ * harmless — the turn is ending anyway.
121
+ *
122
+ * The counter reset lives HERE (driven by the dispatcher), NOT in the fuse
123
+ * re-arm path — otherwise every rearm would zero its own counter and nullify
124
+ * the 20-cap.
125
+ */
126
+ onStreamEvent(kind: string, durationMs: number | undefined, now: number): void {
127
+ if (kind === 'turn_end' && durationMs === -1) return
128
+ this.lastStreamEventAt = now
129
+ this.orphanedReplyRearmCount = 0
130
+ }
131
+
132
+ /** True iff a genuine stream event landed within `windowMs` of `now`. */
133
+ recentlyStreaming(now: number, windowMs: number): boolean {
134
+ return now - this.lastStreamEventAt < windowMs
135
+ }
136
+
137
+ /**
138
+ * Decide what to do when the orphaned-reply fuse expires.
139
+ *
140
+ * Rearm when the turn is working OR recently streaming OR a human is being
141
+ * waited on. Working / recently-streaming rearms count against `maxRearms`
142
+ * (so a genuinely wedged single tool that never streams still surfaces after
143
+ * the cap). Human-wait rearms are uncapped (the human simply hasn't tapped
144
+ * yet). Once the cap is hit with nothing else keeping the turn alive, fire
145
+ * (rearm:false) — matching the existing fail-safe.
146
+ *
147
+ * Mutates `orphanedReplyRearmCount` on each rearm.
148
+ */
149
+ decideOnExpiry(opts: {
150
+ working: boolean
151
+ humanWaiting: boolean
152
+ now: number
153
+ windowMs: number
154
+ maxRearms: number
155
+ }): OrphanedReplyExpiryDecision {
156
+ // Human-wait rearms are uncapped (unchanged from prior behaviour).
157
+ if (opts.humanWaiting) {
158
+ this.orphanedReplyRearmCount++
159
+ return { rearm: true, countsAgainstCap: false }
160
+ }
161
+ const recently = this.recentlyStreaming(opts.now, opts.windowMs)
162
+ if (opts.working || recently) {
163
+ if (this.orphanedReplyRearmCount < opts.maxRearms) {
164
+ this.orphanedReplyRearmCount++
165
+ return { rearm: true, countsAgainstCap: true }
166
+ }
167
+ // Cap reached with only working/recently-streaming keeping it alive:
168
+ // fire to surface the wedge.
169
+ return { rearm: false, countsAgainstCap: true }
170
+ }
171
+ // Nothing keeping the turn alive → fire (fail-safe).
172
+ return { rearm: false, countsAgainstCap: false }
173
+ }
174
+ }