switchroom 0.17.10 → 0.18.6
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/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +29 -2
- package/dist/auth-broker/index.js +6163 -152
- package/dist/cli/notion-write-pretool.mjs +31 -3
- package/dist/cli/switchroom.js +695 -526
- package/dist/host-control/main.js +6184 -173
- package/dist/vault/approvals/kernel-server.js +5893 -165
- package/dist/vault/broker/server.js +6666 -921
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +170 -21
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +37 -25
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/gateway/gateway.js +25025 -9203
- package/telegram-plugin/gateway/activity-card-store.ts +76 -0
- package/telegram-plugin/gateway/gateway.ts +740 -106
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/gateway/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/operator-events.ts +21 -0
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +27 -9
- package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +51 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/operator-events.test.ts +16 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +61 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +97 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/worker-activity-feed.ts +220 -15
- package/telegram-plugin/worktree-watch-cwds.ts +92 -17
- package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
- package/vendor/hindsight-memory/scripts/recall.py +64 -6
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -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.
|
|
4
|
+
"version": "0.18.6",
|
|
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}}}
|
|
25
|
-
"defaultMode": "
|
|
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
|
|
61
|
-
#
|
|
62
|
-
#
|
|
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'
|
|
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
|
-
|
|
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
|
-
#
|
|
915
|
-
#
|
|
916
|
-
#
|
|
917
|
-
#
|
|
918
|
-
#
|
|
919
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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)
|
|
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 ~
|
|
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 ~
|
|
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.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: switchroom-release
|
|
3
|
+
description: "Cut and ship a switchroom release end-to-end: CHANGELOG consolidation, tag, npm publish, image build, and the fleet rollout gate. Use when the user says 'cut a release', 'ship a release', 'release vX', 'publish a new version', 'roll out the latest', or otherwise wants the merged work on main to go live for the fleet. This is the ONLY skill that authorizes a fleet rollout, and it enforces the npm-publish + image-build gates that were historically skipped (v0.18.4/v0.18.5 shipped to the fleet but never hit npm). Do NOT use for adding agents (switchroom-manage), diagnostics (switchroom-health), or a plain `switchroom update` on one agent (switchroom-cli)."
|
|
4
|
+
allowed-tools: Bash(git *) Bash(gh *) Bash(npm view *) Bash(npm pack *) Bash(docker manifest inspect *) Bash(docker buildx imagetools inspect *)
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Switchroom release
|
|
8
|
+
|
|
9
|
+
Cut a release of `switchroom/switchroom` and get it live on the fleet. This is a **gated, ordered checklist** — do not skip steps, do not reorder. The whole point of this skill is that two steps that used to be skipped silently (npm publish, image-build verification) are now **hard gates before rollout**.
|
|
10
|
+
|
|
11
|
+
## What a release actually is
|
|
12
|
+
|
|
13
|
+
- A release = a `vX.Y.Z` **git tag** on `main` (the merge commit of the CHANGELOG PR).
|
|
14
|
+
- The tag is the version source of truth (`scripts/build.mjs:resolveVersion()`). `package.json` `version` is a **stale placeholder by design** — never bump it in a commit (the `//version` comment + #2733 discipline). The uncommitted pack-time bump happens in CI now, not by hand.
|
|
15
|
+
- Cutting the tag fires TWO workflows in parallel on tag push: `docker-images` (builds + pushes the 6 ghcr images) and `npm-publish` (builds, packs, verifies, publishes to npm). **Rollout is gated on BOTH going green.**
|
|
16
|
+
|
|
17
|
+
## Before you start — pre-flight (verify, don't assume)
|
|
18
|
+
|
|
19
|
+
1. `git fetch origin`, confirm `main` is at the commit you want released.
|
|
20
|
+
2. `gh pr list --state open` — confirm no PR meant for this release is still open. Ask the operator if unsure.
|
|
21
|
+
3. Confirm CI on `main` is green (`gh run list --branch main --limit 3`).
|
|
22
|
+
4. Read `CHANGELOG.md` — the `## Unreleased` section is the staging area for this release's notes. If it's empty, there's nothing to release.
|
|
23
|
+
5. Pick the next version: read the latest tag (`git tag --list 'v*' --sort=-v:refname | head -1`) and bump the patch (or minor if the operator asks). Confirm with the operator which.
|
|
24
|
+
|
|
25
|
+
## Step 1 — Consolidate the changelog (the release commit is CHANGELOG-only)
|
|
26
|
+
|
|
27
|
+
- Move the `## Unreleased` entries under a new `## vX.Y.Z — <one-line summary>` heading.
|
|
28
|
+
- The release commit touches **CHANGELOG.md only**. Do NOT bump `package.json` (placeholder discipline).
|
|
29
|
+
- Branch protection blocks direct push to `main`, so: create a `release/vX.Y.Z` branch, push it, open a `chore: release vX.Y.Z` PR (base `main`), arm auto-merge (squash, delete-branch) on green CI.
|
|
30
|
+
|
|
31
|
+
## Step 2 — Cut the tag (on the merge commit, not the PR branch)
|
|
32
|
+
|
|
33
|
+
Once the changelog PR is merged:
|
|
34
|
+
- `git fetch origin && git checkout main && git pull --ff-only`
|
|
35
|
+
- Tag the merge commit and create the GitHub Release:
|
|
36
|
+
- `gh release create vX.Y.Z -R switchroom/switchroom --target main --title 'vX.Y.Z — <summary>' --notes-file <notes-file>`
|
|
37
|
+
- **Notes extraction gotcha (historical):** the naive `awk '/^## vX/,/^## v/' CHANGELOG` range collapses to a single line. Use a start-flag awk: `awk 'f{print} /^## vX\.Y\.Z/{print; f=1} f && /^## v/ && !/^## vX\.Y\.Z/{exit}'` — or extract the section to a temp file by line range.
|
|
38
|
+
- **`gh release create` has been silently dropped in past runs.** After running it, verify: `gh release view vX.Y.Z` must return the release. If it didn't create, re-run.
|
|
39
|
+
|
|
40
|
+
## Step 3 — Wait for BOTH tag-push workflows (hard gates)
|
|
41
|
+
|
|
42
|
+
The tag push triggers `docker-images` AND `npm-publish` in parallel. **Do not proceed to rollout until both are green AND verified.**
|
|
43
|
+
|
|
44
|
+
### Gate A — npm publish (`npm-publish.yml`)
|
|
45
|
+
- `gh run list --workflow=npm-publish.yml --limit 1` — wait for it to reach `completed` / `success`.
|
|
46
|
+
- Verify the publish is live: `npm view switchroom version` must return `X.Y.Z` (not the old version). Retry a few times — npm registry propagation can lag a few seconds.
|
|
47
|
+
- If this workflow fails: **the release is not published.** Do NOT roll. Diagnose (NPM_TOKEN unset? npm 5xx? empty dist?). Re-run via `gh workflow run npm-publish.yml --ref vX.Y.Z` after fixing.
|
|
48
|
+
|
|
49
|
+
### Gate B — docker images (`docker-images.yml`)
|
|
50
|
+
- `gh run list --workflow=docker-images.yml --limit 1` — wait for `completed` / `success`.
|
|
51
|
+
- Verify all 6 images are published: `docker manifest inspect ghcr.io/switchroom/<image>:vX.Y.Z` for `agent`, `auth-broker`, `kernel`, `broker`, `web`, `hostd`. Each must resolve.
|
|
52
|
+
- If any image is missing: do NOT roll — the rollout canary version-assert fails on an unpublished tag. Wait + re-check.
|
|
53
|
+
|
|
54
|
+
**Only when Gate A AND Gate B are green + verified** do you proceed.
|
|
55
|
+
|
|
56
|
+
## Step 4 — Fleet rollout (operator-gated, canary-first)
|
|
57
|
+
|
|
58
|
+
- Fire the rollout via the hostd rollout path (`mcp__hostd__rollout`), which pops an **approval card**. Do NOT roll without the operator tapping approve.
|
|
59
|
+
- Canary discipline: roll the release-critical canary agent first (the test-harness agent, per CLAUDE.md > Release canary discipline), monitor its logs + a smoke check, then stagger the rest per-agent with a `--version` assertion each (guards the `:latest` pull-race).
|
|
60
|
+
- For a release that changes agent runtime behavior (a CLI pin bump, a behavioral template change), offer the operator a canary-first path (one agent → monitor → sweep) vs all-at-once; let them choose.
|
|
61
|
+
|
|
62
|
+
## What you must NOT do
|
|
63
|
+
|
|
64
|
+
- **Never bump `package.json` `version` in a commit.** It's a stale placeholder; the tag is the source of truth and `npm-publish.yml` does the uncommitted pack-time bump.
|
|
65
|
+
- **Never run `npm publish` by hand from the agent container.** You can't reach the operator's npm auth, and the workflow is the reliable path. If the workflow is broken, fix the workflow — don't side-step it.
|
|
66
|
+
- **Never roll the fleet before Gate A (npm) AND Gate B (images) are both green + verified.** A release that's on the fleet but not on npm is the exact regression this skill exists to prevent.
|
|
67
|
+
- **Never push directly to `main`.** The CHANGELOG PR goes through auto-merge on green.
|
|
68
|
+
- **Never force-push `main` or bypass hooks (`--no-verify`).**
|
|
69
|
+
|
|
70
|
+
## If something goes wrong
|
|
71
|
+
|
|
72
|
+
- **npm-publish failed but the tag is already pushed:** fix + `gh workflow run npm-publish.yml --ref vX.Y.Z`. Do NOT roll until it's green + `npm view` confirms.
|
|
73
|
+
- **Images failed but npm succeeded:** the npm package is live but the fleet can't roll yet. Fix the image workflow / re-run. (npm being ahead of images is fine — the CLI is published for npm consumers; the fleet waits on images.)
|
|
74
|
+
- **Rollout started before publish verified (the old bug):** abort the rollout, publish, then re-roll. Do not let a half-published release sit on the fleet.
|
|
75
|
+
|
|
76
|
+
## Operator one-time setup (tell them once, not every release)
|
|
77
|
+
|
|
78
|
+
The `npm-publish.yml` workflow needs an `NPM_TOKEN` repo secret (automation-scoped npm access token with publish rights on `switchroom`). Set it once in repo settings → Secrets and variables → Actions → `NPM_TOKEN`. Without it, Gate A fails loudly on the first release — that's the design (loud > silent).
|
|
@@ -273,6 +273,16 @@ export interface SnapshotRenderOpts {
|
|
|
273
273
|
* Takes precedence over `liveProbedAtMs`.
|
|
274
274
|
*/
|
|
275
275
|
staleCachedAtMs?: number;
|
|
276
|
+
/**
|
|
277
|
+
* True when the live probe returned no usable data for ANY account (probe
|
|
278
|
+
* threw / timed out / returned zero rows) AND nothing was served from
|
|
279
|
+
* cache either. The footer renders `⚠ probe failed — no live data`
|
|
280
|
+
* instead of a false bare `_Live_`. Subscription-honesty: a "Live" footer
|
|
281
|
+
* next to "no data" rows is the exact lie this closes. `staleCachedAtMs`
|
|
282
|
+
* (cache-served data) takes precedence over this; this takes precedence
|
|
283
|
+
* over the bare-`Live` fallback.
|
|
284
|
+
*/
|
|
285
|
+
probeFailed?: boolean;
|
|
276
286
|
/**
|
|
277
287
|
* Demo mode (the `/usage demo` / `/auth demo` suffix). When true, every
|
|
278
288
|
* account label is run through `maskEmail` before rendering so a screen
|
|
@@ -447,30 +457,27 @@ function pctCells(
|
|
|
447
457
|
}
|
|
448
458
|
|
|
449
459
|
/**
|
|
450
|
-
* The
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
460
|
+
* The per-window reset cell (`5h resets` / `7d resets`): the tz-aware absolute
|
|
461
|
+
* reset time for THAT specific window (date shown only when not today) plus the
|
|
462
|
+
* concise relative hint in parens — e.g. `1:20 PM (in 2h)`.
|
|
463
|
+
*
|
|
464
|
+
* Degrades in lockstep with `pctCells` so the percentage cell and its reset cell
|
|
465
|
+
* always agree: no quota → probe-failed message (or `—`); thin probe → `quota
|
|
466
|
+
* unknown`; missing reset on an otherwise-present window → `—`.
|
|
454
467
|
*/
|
|
455
|
-
function
|
|
468
|
+
function windowResetCell(
|
|
469
|
+
snap: AccountSnapshot,
|
|
470
|
+
now: Date,
|
|
471
|
+
tz: string,
|
|
472
|
+
win: '5h' | '7d',
|
|
473
|
+
): string {
|
|
456
474
|
if (!snap.quota) {
|
|
457
|
-
return snap.quotaError ? `probe failed (${snap.quotaError})` : '
|
|
475
|
+
return snap.quotaError ? `probe failed (${snap.quotaError})` : '—';
|
|
458
476
|
}
|
|
459
477
|
if (isProbeThin(snap.quota)) return 'quota unknown';
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
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)})`;
|
|
478
|
+
const reset = win === '5h' ? snap.quota.fiveHourResetAt : snap.quota.sevenDayResetAt;
|
|
479
|
+
if (!reset) return '—';
|
|
480
|
+
return `${formatStatusTime(reset, now, tz)} (in ${formatRelative(reset, now)})`;
|
|
474
481
|
}
|
|
475
482
|
|
|
476
483
|
export function renderAuthSnapshotFormat2(
|
|
@@ -495,8 +502,8 @@ export function renderAuthSnapshotFormat2(
|
|
|
495
502
|
|
|
496
503
|
if (ordered.length > 0) {
|
|
497
504
|
lines.push('');
|
|
498
|
-
lines.push('| State | Account | 5h | 7d |
|
|
499
|
-
lines.push('| --- | --- | --- | --- | --- |');
|
|
505
|
+
lines.push('| State | Account | 5h | 5h resets | 7d | 7d resets |');
|
|
506
|
+
lines.push('| --- | --- | --- | --- | --- | --- |');
|
|
500
507
|
for (const s of ordered) {
|
|
501
508
|
const emoji = HEALTH_EMOJI[classifyHealth(s, now)];
|
|
502
509
|
// Account cell: FULL email, never truncated; active gets a (active) suffix.
|
|
@@ -512,9 +519,10 @@ export function renderAuthSnapshotFormat2(
|
|
|
512
519
|
// tableCell wrap around the finished span.
|
|
513
520
|
const accountCell = `\`${codeSpanSafe(s.isActive ? `${label} (active)` : label)}\``;
|
|
514
521
|
const { five, seven } = pctCells(s, now);
|
|
515
|
-
const
|
|
522
|
+
const fiveReset = windowResetCell(s, now, tz, '5h');
|
|
523
|
+
const sevenReset = windowResetCell(s, now, tz, '7d');
|
|
516
524
|
lines.push(
|
|
517
|
-
`| ${emoji} | ${tableCell(accountCell)} | ${five} | ${seven} | ${tableCell(
|
|
525
|
+
`| ${emoji} | ${tableCell(accountCell)} | ${five} | ${tableCell(fiveReset)} | ${seven} | ${tableCell(sevenReset)} |`,
|
|
518
526
|
);
|
|
519
527
|
}
|
|
520
528
|
}
|
|
@@ -523,11 +531,15 @@ export function renderAuthSnapshotFormat2(
|
|
|
523
531
|
lines.push(`_${recommendation(snapshots, now, opts.demo ?? false)}_`);
|
|
524
532
|
// #2495 Change 2 — a failed probe-on-open renders an explicit "cached Nm
|
|
525
533
|
// ago" warning, never a false live stamp. The degraded variant takes
|
|
526
|
-
// precedence over the live stamp.
|
|
534
|
+
// precedence over the live stamp. A TOTAL probe failure (no rows, no
|
|
535
|
+
// cache) renders an explicit "probe failed" marker — without it the
|
|
536
|
+
// bare-else rendered `_Live_` next to no-data rows (the honesty gap).
|
|
527
537
|
if (opts.staleCachedAtMs != null) {
|
|
528
538
|
lines.push(`_⚠ cached ${formatAgeStamp(opts.staleCachedAtMs, now)}_`);
|
|
529
539
|
} else if (opts.liveProbedAtMs != null) {
|
|
530
540
|
lines.push(`_Live · refreshed ${formatAgeStamp(opts.liveProbedAtMs, now)}_`);
|
|
541
|
+
} else if (opts.probeFailed) {
|
|
542
|
+
lines.push('_⚠ probe failed — no live data_');
|
|
531
543
|
} else {
|
|
532
544
|
lines.push('_Live_');
|
|
533
545
|
}
|