switchroom 0.18.9 → 0.18.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +198 -13
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +28 -4
- package/dist/host-control/main.js +3 -2
- package/dist/vault/approvals/kernel-server.js +2 -1
- package/dist/vault/broker/server.js +2 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +119 -37
- package/profiles/_shared/dev-protocol.md.hbs +42 -0
- package/skills/dev-protocol/SKILL.md +131 -0
- package/telegram-plugin/README.md +2 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
- package/telegram-plugin/admin-commands/index.ts +6 -1
- package/telegram-plugin/bridge/bridge.ts +23 -1
- package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
- package/telegram-plugin/chat-lock.ts +13 -0
- package/telegram-plugin/dist/bridge/bridge.js +24 -1
- package/telegram-plugin/dist/gateway/gateway.js +1831 -263
- package/telegram-plugin/dist/server.js +29 -2
- package/telegram-plugin/fallback-card-collapse.ts +131 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
- package/telegram-plugin/gateway/effort-command.ts +47 -3
- package/telegram-plugin/gateway/gateway.ts +1435 -211
- package/telegram-plugin/gateway/model-command.ts +94 -8
- package/telegram-plugin/gateway/pending-session-command.ts +365 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
- package/telegram-plugin/gateway/session-model-file.ts +166 -23
- package/telegram-plugin/gateway/stop-command.ts +56 -0
- package/telegram-plugin/photo-precheck.ts +201 -0
- package/telegram-plugin/quota-watch.ts +141 -2
- package/telegram-plugin/registry/subagents-schema.ts +26 -3
- package/telegram-plugin/registry/subagents.test.ts +67 -0
- package/telegram-plugin/retry-api-call.ts +31 -0
- package/telegram-plugin/subagent-watcher.ts +392 -1
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
- package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
- package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
- package/telegram-plugin/tests/effort-command.test.ts +59 -2
- package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
- package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
- package/telegram-plugin/tests/model-command.test.ts +46 -3
- package/telegram-plugin/tests/pending-session-command.test.ts +322 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
- package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
- package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
- package/telegram-plugin/tests/quota-watch.test.ts +225 -0
- package/telegram-plugin/tests/session-model-file.test.ts +101 -2
- package/telegram-plugin/tests/stop-command.test.ts +234 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +37 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
- package/telegram-plugin/welcome-text.ts +4 -3
- package/telegram-plugin/worker-activity-feed.ts +27 -0
|
@@ -1067,21 +1067,21 @@ fi
|
|
|
1067
1067
|
|
|
1068
1068
|
# --- Session model resolution (durable .session-model + .relaunch-model-intent) ---
|
|
1069
1069
|
#
|
|
1070
|
-
# Contract: reference/rfcs/session-model-stickiness.md. A
|
|
1071
|
-
# confirmed `/model X` switch persists as
|
|
1072
|
-
# (one-line JSON written by the gateway)
|
|
1073
|
-
#
|
|
1074
|
-
#
|
|
1070
|
+
# Contract: reference/rfcs/session-model-stickiness.md (#3039 revision). A
|
|
1071
|
+
# positively-confirmed `/model X` switch persists as
|
|
1072
|
+
# `{{agentDir}}/.session-model` (one-line JSON written by the gateway) and is
|
|
1073
|
+
# honored on EVERY boot — deploy, watchdog bounce, raw `docker restart`,
|
|
1074
|
+
# host reboot, crash. It is cleared only by:
|
|
1075
1075
|
#
|
|
1076
|
-
#
|
|
1077
|
-
#
|
|
1078
|
-
#
|
|
1079
|
-
#
|
|
1076
|
+
# - explicit user action (`/model default` deletes it live; a fresh
|
|
1077
|
+
# (<10 min) explicit "revert" `.relaunch-model-intent` reverts at boot)
|
|
1078
|
+
# - invalidation: corrupt/malformed file, or the configured yaml `model:`
|
|
1079
|
+
# changed since the switch
|
|
1080
1080
|
#
|
|
1081
|
-
#
|
|
1082
|
-
#
|
|
1083
|
-
#
|
|
1084
|
-
#
|
|
1081
|
+
# Every clearing path writes `.session-model-alert`, which the gateway
|
|
1082
|
+
# relays to the operator chat once at boot — an override is never dropped
|
|
1083
|
+
# silently. Default (no intent / stale / corrupt intent) is KEEP (operator
|
|
1084
|
+
# decision 2026-07-11, superseding the earlier revert-by-default).
|
|
1085
1085
|
#
|
|
1086
1086
|
# NB {{{modelQ}}} is already shell-single-quoted by the scaffold (it renders as
|
|
1087
1087
|
# a quoted token, e.g. 'claude-sonnet-5'), so it is assigned BARE here — never
|
|
@@ -1117,14 +1117,18 @@ fi
|
|
|
1117
1117
|
# Freshness clock is the EMBEDDED ts (ms), never file mtime — same clock the
|
|
1118
1118
|
# gateway writes with.
|
|
1119
1119
|
_sm_reason=""
|
|
1120
|
+
_sm_revert=""
|
|
1120
1121
|
if [ -f "{{agentDir}}/.relaunch-model-intent" ]; then
|
|
1121
1122
|
_int_raw="$(cat "{{agentDir}}/.relaunch-model-intent" 2>/dev/null || true)"
|
|
1122
1123
|
rm -f "{{agentDir}}/.relaunch-model-intent"
|
|
1123
1124
|
_int="$(printf '%s' "$_int_raw" | sed -n 's/.*"intent"[[:space:]]*:[[:space:]]*"\([a-z]*\)".*/\1/p')"
|
|
1124
1125
|
_int_ts="$(printf '%s' "$_int_raw" | sed -n 's/.*"ts"[[:space:]]*:[[:space:]]*\([0-9]\{1,\}\).*/\1/p')"
|
|
1125
1126
|
_sm_reason="$(printf '%s' "$_int_raw" | sed -n 's/.*"reason"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
|
|
1126
|
-
|
|
1127
|
-
|
|
1127
|
+
# #3039: boot default is KEEP. Only a FRESH explicit "revert" intent
|
|
1128
|
+
# (stamped by an explicit user/gateway revert path) clears the override.
|
|
1129
|
+
# A stale or corrupt intent counts as no intent → keep.
|
|
1130
|
+
if [ "$_int" = "revert" ] && [ -n "$_int_ts" ] && [ $(( $(date +%s) * 1000 - _int_ts )) -lt 600000 ]; then
|
|
1131
|
+
_sm_revert="1"
|
|
1128
1132
|
fi
|
|
1129
1133
|
unset _int_raw _int _int_ts
|
|
1130
1134
|
fi
|
|
@@ -1134,13 +1138,13 @@ if [ -f "{{agentDir}}/.session-model" ]; then
|
|
|
1134
1138
|
_sm_model="$(printf '%s' "$_smf" | sed -n 's/.*"model"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
|
|
1135
1139
|
_sm_cfg="$(printf '%s' "$_smf" | sed -n 's/.*"configuredDefaultAtWrite"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
|
|
1136
1140
|
_sm_ts="$(printf '%s' "$_smf" | sed -n 's/.*"ts"[[:space:]]*:[[:space:]]*\([0-9]\{1,\}\).*/\1/p')"
|
|
1137
|
-
if [ -
|
|
1138
|
-
#
|
|
1139
|
-
#
|
|
1140
|
-
rm -f "{{agentDir}}/.session-model"
|
|
1141
|
-
_sm_why="${_sm_reason:-
|
|
1141
|
+
if [ -n "$_sm_revert" ]; then
|
|
1142
|
+
# Explicit fresh revert intent — the ONLY boot path that clears a valid
|
|
1143
|
+
# override by design (#3039). Everything else keeps.
|
|
1144
|
+
rm -f "{{agentDir}}/.session-model" "{{agentDir}}/.session-model-kept-notified"
|
|
1145
|
+
_sm_why="${_sm_reason:-explicit revert intent}"
|
|
1142
1146
|
echo "session-model: reverting to configured default '$_EFFECTIVE_MODEL' — $_sm_why (session override '$_sm_model' cleared)" >&2
|
|
1143
|
-
printf 'Session model override `%s` was cleared — this relaunch
|
|
1147
|
+
printf 'Session model override `%s` was cleared as requested (%s) — this relaunch booted the configured default `%s`. Re-issue /model %s to switch back.\n' "$_sm_model" "$_sm_why" "$_EFFECTIVE_MODEL" "$_sm_model" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
|
|
1144
1148
|
unset _sm_why
|
|
1145
1149
|
# Shape gate — kept BYTE-IDENTICAL with MODEL_ARG_RE in
|
|
1146
1150
|
# telegram-plugin/gateway/model-command.ts. `/` is allowed for
|
|
@@ -1151,20 +1155,18 @@ if [ -f "{{agentDir}}/.session-model" ]; then
|
|
|
1151
1155
|
# passes if ANY line matches — a multiline value must never reach
|
|
1152
1156
|
# `claude --model` (parity with parseSessionModel's single-string check).
|
|
1153
1157
|
elif [ "$(printf '%s' "$_sm_model" | wc -c)" -eq 0 ] || [ "$(printf '%s' "$_sm_model" | wc -l)" -ne 0 ] || ! printf '%s' "$_sm_model" | grep -Eq '^[A-Za-z0-9][]A-Za-z0-9._[/-]{0,99}$'; then
|
|
1154
|
-
|
|
1158
|
+
# Invalid carrier (#3039): fall back to the configured default AND tell
|
|
1159
|
+
# the operator once — never a silent stderr-only drop.
|
|
1160
|
+
rm -f "{{agentDir}}/.session-model" "{{agentDir}}/.session-model-kept-notified"
|
|
1155
1161
|
echo "session-model: ignoring malformed .session-model (failed shape gate) — using configured default '$_EFFECTIVE_MODEL'" >&2
|
|
1162
|
+
printf 'Your saved session model override could not be read (invalid or corrupt), so the agent booted on its configured default `%s`. Re-issue /model <name> if you want a different model.\n' "$_EFFECTIVE_MODEL" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
|
|
1156
1163
|
elif [ "$_sm_cfg" != "$_EFFECTIVE_MODEL" ]; then
|
|
1157
1164
|
# switchroom.yaml `model:` changed since the switch → the override is
|
|
1158
|
-
# against a default that no longer exists
|
|
1159
|
-
|
|
1165
|
+
# against a default that no longer exists (it may name a retired model).
|
|
1166
|
+
# Invalidate + announce once (#3039 invalid-carrier fallback).
|
|
1167
|
+
rm -f "{{agentDir}}/.session-model" "{{agentDir}}/.session-model-kept-notified"
|
|
1160
1168
|
echo "session-model: configured default changed ('$_sm_cfg' → '$_EFFECTIVE_MODEL') — clearing session override '$_sm_model'" >&2
|
|
1161
1169
|
printf 'The configured default model changed (`%s` → `%s`), so your session override to `%s` was cleared — the agent booted on the new configured default. Re-issue /model %s if you still want it.\n' "$_sm_cfg" "$_EFFECTIVE_MODEL" "$_sm_model" "$_sm_model" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
|
|
1162
|
-
elif [ -n "$_sm_ts" ] && [ $(( $(date +%s) * 1000 - _sm_ts )) -gt 604800000 ]; then
|
|
1163
|
-
# 7-day staleness bound (belt-and-braces vs version-rollback resurrection
|
|
1164
|
-
# and long-forgotten overrides).
|
|
1165
|
-
rm -f "{{agentDir}}/.session-model"
|
|
1166
|
-
echo "session-model: session override '$_sm_model' is older than 7 days — expiring it, using configured default '$_EFFECTIVE_MODEL'" >&2
|
|
1167
|
-
printf 'Session model override `%s` expired (older than 7 days) — the agent booted on its configured default `%s`. Re-issue /model %s if you still want it.\n' "$_sm_model" "$_EFFECTIVE_MODEL" "$_sm_model" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
|
|
1168
1170
|
else
|
|
1169
1171
|
case "$_sm_model" in
|
|
1170
1172
|
sr-*)
|
|
@@ -1183,13 +1185,93 @@ if [ -f "{{agentDir}}/.session-model" ]; then
|
|
|
1183
1185
|
;;
|
|
1184
1186
|
esac
|
|
1185
1187
|
if [ "$_EFFECTIVE_MODEL" = "$_sm_model" ]; then
|
|
1186
|
-
echo "session-model: keeping session override '$_sm_model' across this
|
|
1187
|
-
|
|
1188
|
+
echo "session-model: keeping session override '$_sm_model' across this relaunch${_sm_reason:+ ($_sm_reason)}" >&2
|
|
1189
|
+
# #3042 item 4: notify the chat ONCE per kept value — a watchdog bounce
|
|
1190
|
+
# loop must not storm the operator with identical "kept" alerts. The
|
|
1191
|
+
# sentinel is cleared on every path that clears the override.
|
|
1192
|
+
if [ "$(cat "{{agentDir}}/.session-model-kept-notified" 2>/dev/null || true)" != "$_sm_model" ]; then
|
|
1193
|
+
printf 'Session model override `%s` kept across this relaunch%s. It persists across restarts and deploys; /model default clears it.\n' "$_sm_model" "${_sm_reason:+ ($_sm_reason)}" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
|
|
1194
|
+
printf '%s\n' "$_sm_model" > "{{agentDir}}/.session-model-kept-notified" 2>/dev/null || true
|
|
1195
|
+
fi
|
|
1188
1196
|
fi
|
|
1189
1197
|
fi
|
|
1190
1198
|
unset _smf _sm_model _sm_cfg _sm_ts
|
|
1191
1199
|
fi
|
|
1192
|
-
unset _sm_keep _sm_reason
|
|
1200
|
+
unset _sm_keep _sm_reason _sm_revert
|
|
1201
|
+
|
|
1202
|
+
# --- Override crashloop self-heal (#3042 review blocker 2b) ---
|
|
1203
|
+
#
|
|
1204
|
+
# Under keep-by-default an invalid-but-shape-valid persisted model (a typo
|
|
1205
|
+
# persisted at shutdown, or a confirmed model later retired) would make every
|
|
1206
|
+
# boot exec `claude --model <bad>` and die with the gateway dead — recovery
|
|
1207
|
+
# would need a host shell. Detect the loop from inside: when an override is
|
|
1208
|
+
# ACTIVE, stamp a boot-attempt counter just before exec; a healthy run makes
|
|
1209
|
+
# the next boot arrive much later (stamp stale → counter resets), while a
|
|
1210
|
+
# fast crashloop re-enters here within seconds (docker backoff caps ≈1 min).
|
|
1211
|
+
# Three consecutive fast boots with the same override → clear the carrier,
|
|
1212
|
+
# boot the configured default, and alert the chat once.
|
|
1213
|
+
_SM_CFG_RECORDED="$(cat "{{agentDir}}/.configured-default-model" 2>/dev/null | tr -d '[:space:]' || true)"
|
|
1214
|
+
if [ -n "$_SM_CFG_RECORDED" ] && [ "$_EFFECTIVE_MODEL" != "$_SM_CFG_RECORDED" ] && [ -f "{{agentDir}}/.session-model" ]; then
|
|
1215
|
+
_bl_now="$(date +%s)"
|
|
1216
|
+
_bl_cnt=0
|
|
1217
|
+
_bl_prev=0
|
|
1218
|
+
if [ -f "{{agentDir}}/.session-model-boot-attempts" ]; then
|
|
1219
|
+
read -r _bl_cnt _bl_prev < "{{agentDir}}/.session-model-boot-attempts" 2>/dev/null || true
|
|
1220
|
+
fi
|
|
1221
|
+
case "$_bl_cnt" in (''|*[!0-9]*) _bl_cnt=0;; esac
|
|
1222
|
+
case "$_bl_prev" in (''|*[!0-9]*) _bl_prev=0;; esac
|
|
1223
|
+
if [ $(( _bl_now - _bl_prev )) -lt 150 ]; then _bl_cnt=$(( _bl_cnt + 1 )); else _bl_cnt=1; fi
|
|
1224
|
+
if [ "$_bl_cnt" -ge 3 ]; then
|
|
1225
|
+
echo "session-model: override '$_EFFECTIVE_MODEL' appears to be crashlooping the session ($_bl_cnt fast boots) — clearing it, booting configured default '$_SM_CFG_RECORDED'" >&2
|
|
1226
|
+
printf 'Session model override `%s` was cleared automatically: the agent failed to stay up %s boots in a row with it active (the model may be invalid or retired). Booting the configured default `%s`. Re-issue /model <name> if you want a different model.\n' "$_EFFECTIVE_MODEL" "$_bl_cnt" "$_SM_CFG_RECORDED" >> "{{agentDir}}/.session-model-alert" 2>/dev/null || true
|
|
1227
|
+
rm -f "{{agentDir}}/.session-model" "{{agentDir}}/.session-model-boot-attempts" "{{agentDir}}/.session-model-kept-notified"
|
|
1228
|
+
_EFFECTIVE_MODEL="$_SM_CFG_RECORDED"
|
|
1229
|
+
else
|
|
1230
|
+
printf '%s %s\n' "$_bl_cnt" "$_bl_now" > "{{agentDir}}/.session-model-boot-attempts" 2>/dev/null || true
|
|
1231
|
+
fi
|
|
1232
|
+
unset _bl_now _bl_cnt _bl_prev
|
|
1233
|
+
else
|
|
1234
|
+
# No active override this boot — a stale counter must not bite a future one.
|
|
1235
|
+
rm -f "{{agentDir}}/.session-model-boot-attempts"
|
|
1236
|
+
fi
|
|
1237
|
+
unset _SM_CFG_RECORDED
|
|
1238
|
+
|
|
1239
|
+
# --- Session effort resolution (durable .session-effort, #3039) ---
|
|
1240
|
+
#
|
|
1241
|
+
# The `/effort` sibling of the block above. A positively-confirmed `/effort`
|
|
1242
|
+
# apply persists `{{agentDir}}/.session-effort` (one-line JSON
|
|
1243
|
+
# {"level","configuredDefaultAtWrite","ts"} written by the gateway). It is
|
|
1244
|
+
# honored on every boot and cleared only by `/effort default` (live delete)
|
|
1245
|
+
# or invalidation here (corrupt file / configured `thinking_effort:` changed)
|
|
1246
|
+
# — each invalidation appends to `.session-model-alert` so the gateway's
|
|
1247
|
+
# boot relay tells the operator once.
|
|
1248
|
+
_EFFECTIVE_EFFORT={{#if thinkingEffort}}'{{thinkingEffort}}'{{else}}''{{/if}}
|
|
1249
|
+
if [ -f "{{agentDir}}/.session-effort" ]; then
|
|
1250
|
+
_sef="$(cat "{{agentDir}}/.session-effort" 2>/dev/null || true)"
|
|
1251
|
+
_se_level="$(printf '%s' "$_sef" | sed -n 's/.*"level"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
|
|
1252
|
+
_se_cfg="$(printf '%s' "$_sef" | sed -n 's/.*"configuredDefaultAtWrite"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"
|
|
1253
|
+
# Allowlist gate — kept in sync with EFFORT_LEVELS in
|
|
1254
|
+
# telegram-plugin/gateway/effort-command.ts. The level is passed verbatim
|
|
1255
|
+
# to `claude --effort`.
|
|
1256
|
+
if [ "$(printf '%s' "$_se_level" | wc -l)" -ne 0 ] || ! printf '%s' "$_se_level" | grep -Eq '^(low|medium|high|xhigh|max)$'; then
|
|
1257
|
+
rm -f "{{agentDir}}/.session-effort"
|
|
1258
|
+
echo "session-effort: ignoring malformed .session-effort (failed allowlist gate) — using configured default '${_EFFECTIVE_EFFORT:-<unset>}'" >&2
|
|
1259
|
+
printf 'Your saved session effort override could not be read (invalid or corrupt), so the agent booted on its configured default effort. Re-issue /effort <level> if you want a different one.\n' >> "{{agentDir}}/.session-model-alert" 2>/dev/null || true
|
|
1260
|
+
elif [ "$_se_cfg" != "$_EFFECTIVE_EFFORT" ]; then
|
|
1261
|
+
rm -f "{{agentDir}}/.session-effort"
|
|
1262
|
+
echo "session-effort: configured thinking_effort changed ('$_se_cfg' → '${_EFFECTIVE_EFFORT:-<unset>}') — clearing session override '$_se_level'" >&2
|
|
1263
|
+
printf 'The configured default effort changed (`%s` → `%s`), so your session effort override `%s` was cleared — the agent booted on the new configured default. Re-issue /effort %s if you still want it.\n' "${_se_cfg:-<unset>}" "${_EFFECTIVE_EFFORT:-<unset>}" "$_se_level" "$_se_level" >> "{{agentDir}}/.session-model-alert" 2>/dev/null || true
|
|
1264
|
+
else
|
|
1265
|
+
_EFFECTIVE_EFFORT="$_se_level"
|
|
1266
|
+
echo "session-effort: keeping session effort override '$_se_level' across this relaunch" >&2
|
|
1267
|
+
fi
|
|
1268
|
+
unset _sef _se_level _se_cfg
|
|
1269
|
+
fi
|
|
1270
|
+
if [ -n "$_EFFECTIVE_EFFORT" ]; then
|
|
1271
|
+
_EFFORT_ARG="--effort $_EFFECTIVE_EFFORT"
|
|
1272
|
+
else
|
|
1273
|
+
_EFFORT_ARG=""
|
|
1274
|
+
fi
|
|
1193
1275
|
|
|
1194
1276
|
# sr-* passthrough→router repoint — ONE post-resolution gate covering BOTH the
|
|
1195
1277
|
# /model override path AND the configured-default path (`model: sr-*` in
|
|
@@ -1222,14 +1304,14 @@ printf '%s\n' "$_EFFECTIVE_MODEL" > "{{agentDir}}/.active-session-model" 2>/dev/
|
|
|
1222
1304
|
|
|
1223
1305
|
{{#if useSwitchroomPlugin}}
|
|
1224
1306
|
if [ -n "$APPEND_PROMPT" ]; then
|
|
1225
|
-
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"
|
|
1307
|
+
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" $_EFFORT_ARG{{#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}}
|
|
1226
1308
|
else
|
|
1227
|
-
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"
|
|
1309
|
+
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" $_EFFORT_ARG{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}}{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
|
|
1228
1310
|
fi
|
|
1229
1311
|
{{else}}
|
|
1230
1312
|
if [ -n "$APPEND_PROMPT" ]; then
|
|
1231
|
-
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"
|
|
1313
|
+
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" $_EFFORT_ARG{{#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}}
|
|
1232
1314
|
else
|
|
1233
|
-
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"
|
|
1315
|
+
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" $_EFFORT_ARG{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}}{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
|
|
1234
1316
|
fi
|
|
1235
1317
|
{{/if}}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
## Development Protocol
|
|
2
|
+
|
|
3
|
+
How development work gets done here — orient, clarify, align, ship, communicate. These are procedural rules for any substantive coding, infra, or debugging task. For the long-form playbook (design reports, adversarial review structure, re-review verdicts), load the bundled `dev-protocol` skill before starting substantive development work.
|
|
4
|
+
|
|
5
|
+
### Orient — ground before you build
|
|
6
|
+
|
|
7
|
+
- **Validate, don't assume.** Read the actual code, config, and system state before forming a theory. Never assert a fact you haven't checked this turn.
|
|
8
|
+
- **Root-cause in real source.** Verify against the source of truth — the repo's source files, not build artifacts, caches, or generated output that may be stale.
|
|
9
|
+
- **Contradicting evidence wins.** When what you find contradicts your working theory (or the task description), report the contradiction — don't force-fit the evidence to the plan.
|
|
10
|
+
- **Cite your ground.** Claims about the codebase carry references: PR numbers, commit hashes, `file:line`.
|
|
11
|
+
|
|
12
|
+
### Clarify vs proceed
|
|
13
|
+
|
|
14
|
+
- **Infer first.** Mine the codebase, history, and prior context before asking. Most "questions" are answerable by reading.
|
|
15
|
+
- **If genuinely unsure, ask ONE question at a time** — the single question that unblocks the most work — not a questionnaire.
|
|
16
|
+
- **Clarify during planning; act autonomously during execution.** Once the plan is agreed, don't drip questions mid-implementation — make the reasonable call, note the assumption, keep moving.
|
|
17
|
+
|
|
18
|
+
### Design-align on larger tasks
|
|
19
|
+
|
|
20
|
+
For tasks that are architecturally significant, cross-cutting, or ambiguous in approach:
|
|
21
|
+
|
|
22
|
+
- **Design report before implementation.** Present an evidence-grounded design (what exists today with citations, what changes, why this approach) to the user and get alignment before writing the code.
|
|
23
|
+
- **Red-team your own plan.** Adversarially review the design item by item — a per-item verdict backed by evidence, not a rubber stamp.
|
|
24
|
+
- **Stage delivery.** Ship as focused, single-concern PRs rather than one omnibus change.
|
|
25
|
+
|
|
26
|
+
### Pipeline — how a change ships
|
|
27
|
+
|
|
28
|
+
- **Branch off fresh main.** Always pull before branching.
|
|
29
|
+
- **Scoped tests + lint locally; CI is the full-suite authority.** Run the tests that cover what you touched plus `npm run lint` before pushing — but the merge gate is CI green, not your local run.
|
|
30
|
+
- **Adversarial review of the diff.** Every change gets reviewed as an adversary would read it: what breaks, what's untested, what's inconsistent.
|
|
31
|
+
- **Fix ALL findings — including lows.** A "low" you skip is a bug you shipped. Then **re-review the fix** before calling it done.
|
|
32
|
+
- **Merge only on CI green.** No exceptions, no "it's probably fine".
|
|
33
|
+
- **Durable fixes over hack patches.** Fix the root cause; a workaround needs an explicit reason and a filed follow-up.
|
|
34
|
+
- **Deterministic mechanisms over model-dependent behavior.** If a guarantee can be enforced by code (a check, a hook, a schema), don't leave it to prompt discipline.
|
|
35
|
+
- **Tests assert outcomes, not just code paths.** A test that exercises the code but wouldn't fail on the bug is not a test.
|
|
36
|
+
|
|
37
|
+
### Communicate while you work
|
|
38
|
+
|
|
39
|
+
- **Consolidated messages.** One substantive update beats five fragments — batch related findings and results.
|
|
40
|
+
- **Always-visible progress.** Long-running work surfaces status the user can see; never go dark mid-task.
|
|
41
|
+
- **No foreground watches over 30 seconds.** Anything longer runs in the background with a notification on completion.
|
|
42
|
+
- **Max 15 parallel sub-agents.** Fan out for genuinely parallel work, but cap the swarm.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dev-protocol
|
|
3
|
+
description: >
|
|
4
|
+
Use when starting substantive development work — a code change, refactor,
|
|
5
|
+
bug fix, infra change, or multi-step debugging task in any repo. Load BEFORE
|
|
6
|
+
writing code: it is the fleet development protocol (orient/ground, clarify
|
|
7
|
+
vs proceed, design-align on larger tasks, the branch→test→review→CI
|
|
8
|
+
pipeline, communication rules). Also use when deciding whether a task needs
|
|
9
|
+
a design report first, when dispatching an adversarial review of a diff, or
|
|
10
|
+
when writing a re-review verdict. Do NOT use for: quick lookups, answering
|
|
11
|
+
questions about code without changing it, or pure conversation.
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Development Protocol — the playbook
|
|
15
|
+
|
|
16
|
+
The always-loaded CLAUDE.md "Development Protocol" section is the summary.
|
|
17
|
+
This skill is the long-form procedure. Work through the five phases in order;
|
|
18
|
+
they are checkpoints, not vibes.
|
|
19
|
+
|
|
20
|
+
## 1. Orient — ground before you build
|
|
21
|
+
|
|
22
|
+
Before forming any theory or plan:
|
|
23
|
+
|
|
24
|
+
- **Read the real source.** The repo's source files at the current HEAD — not
|
|
25
|
+
build artifacts (`dist/`, generated files, caches), not your memory of the
|
|
26
|
+
code, not the task description's paraphrase. If a claim matters, open the
|
|
27
|
+
file.
|
|
28
|
+
- **Verify the root cause, not the first plausible cause.** Reproduce or trace
|
|
29
|
+
the failure to a specific mechanism before fixing. "This line looks wrong"
|
|
30
|
+
is a lead; the fix ships only when you can say *why* it produced the
|
|
31
|
+
observed symptom.
|
|
32
|
+
- **Report contradicting evidence.** If what you find contradicts the task
|
|
33
|
+
description, the ticket, or your own working theory — say so explicitly and
|
|
34
|
+
stop to re-plan. Never force-fit evidence to the plan you already had.
|
|
35
|
+
- **Cite everything.** Claims about the codebase carry `file:line`, commit
|
|
36
|
+
hashes, or PR numbers. "The scaffold appends fragments at
|
|
37
|
+
`src/agents/scaffold.ts:4113`" is a claim; "the scaffold appends fragments
|
|
38
|
+
somewhere" is not.
|
|
39
|
+
|
|
40
|
+
## 2. Clarify vs proceed
|
|
41
|
+
|
|
42
|
+
- **Infer first.** Most questions are answerable from the codebase, git
|
|
43
|
+
history, existing tests, or docs. Exhaust those before asking.
|
|
44
|
+
- **One question at a time.** If genuinely unsure after inferring, ask the
|
|
45
|
+
single question whose answer unblocks the most work. Phrase it as a
|
|
46
|
+
decision with a default: state what you found, the 2–3 viable options, which
|
|
47
|
+
you'd pick and why, and ask for confirmation — e.g. *"The config loader
|
|
48
|
+
supports both YAML and JSON overlays; the task says 'config file' without
|
|
49
|
+
specifying. I'd extend the YAML path since all existing overlays are YAML
|
|
50
|
+
(src/config/merge.ts:88) — confirm, or should JSON be covered too?"* Never
|
|
51
|
+
send a questionnaire.
|
|
52
|
+
- **Phase discipline.** Clarify during *planning*. Once the plan is agreed,
|
|
53
|
+
execute autonomously: make the reasonable call on small ambiguities, record
|
|
54
|
+
the assumption in your report, and keep moving. Mid-execution questions are
|
|
55
|
+
reserved for discoveries that invalidate the plan.
|
|
56
|
+
|
|
57
|
+
## 3. Design-align on larger tasks
|
|
58
|
+
|
|
59
|
+
**Classify the task first.** Treat it as "larger" (design-align before
|
|
60
|
+
implementing) when ANY of these hold:
|
|
61
|
+
|
|
62
|
+
- It changes a public interface, schema, config shape, or on-disk format.
|
|
63
|
+
- It cuts across 3+ modules or touches a load-bearing invariant.
|
|
64
|
+
- Two or more genuinely different approaches exist and the choice is
|
|
65
|
+
expensive to reverse.
|
|
66
|
+
- The task description is a goal ("make X reliable") rather than a change
|
|
67
|
+
("add flag Y").
|
|
68
|
+
- It will land as more than one PR.
|
|
69
|
+
|
|
70
|
+
Small, single-concern, obvious-approach changes skip straight to phase 4.
|
|
71
|
+
|
|
72
|
+
For larger tasks:
|
|
73
|
+
|
|
74
|
+
1. **Design report before code.** Send the user an evidence-grounded report:
|
|
75
|
+
what exists today (with citations), what will change, the chosen approach
|
|
76
|
+
and its rejected alternatives, and the PR staging plan. Get alignment
|
|
77
|
+
before implementation.
|
|
78
|
+
2. **Red-team your own plan adversarially.** Review the design item by item.
|
|
79
|
+
Each item gets a verdict — `SOUND`, `RISK`, or `WRONG` — backed by
|
|
80
|
+
evidence (a file you read, a test you ran, a documented behavior), not
|
|
81
|
+
intuition. Fix every `WRONG` and address every `RISK` before starting.
|
|
82
|
+
3. **Stage delivery as focused single-concern PRs.** One concern per PR:
|
|
83
|
+
reviewable in one sitting, revertable in one command. Never bundle a
|
|
84
|
+
refactor with a behavior change.
|
|
85
|
+
|
|
86
|
+
## 4. Pipeline — how a change ships
|
|
87
|
+
|
|
88
|
+
1. **Branch off fresh main.** `git fetch && git checkout -b <branch> origin/main`.
|
|
89
|
+
2. **Implement with durable fixes.** Fix root causes. A workaround is
|
|
90
|
+
acceptable only with an explicit reason stated and a follow-up filed.
|
|
91
|
+
Prefer deterministic mechanisms (a check, a hook, a schema, a lint gate)
|
|
92
|
+
over model-dependent behavior — if code can enforce the guarantee, don't
|
|
93
|
+
leave it to prompt discipline.
|
|
94
|
+
3. **Tests assert outcomes.** Every test must fail if the bug it guards
|
|
95
|
+
returns. A test that merely exercises the code path without asserting the
|
|
96
|
+
observable outcome is not a test.
|
|
97
|
+
4. **Scoped tests + lint locally.** Run the test files covering what you
|
|
98
|
+
touched, plus the repo's lint gate. Local runs are a fast filter; **CI is
|
|
99
|
+
the full-suite authority** — never claim done off a local run alone.
|
|
100
|
+
5. **Adversarial review of the diff.** Dispatch a reviewer (sub-agent or
|
|
101
|
+
fresh pass) with this structure:
|
|
102
|
+
- Input: the full diff, the task statement, and the design report if one
|
|
103
|
+
exists.
|
|
104
|
+
- Charge: *find reasons this change is wrong* — correctness, missed edge
|
|
105
|
+
cases, untested behavior, inconsistency with surrounding code, docs
|
|
106
|
+
drift, security/data-loss risk.
|
|
107
|
+
- Output: a findings list, each with severity (high/medium/low), the
|
|
108
|
+
evidence (`file:line`), and a concrete fix.
|
|
109
|
+
6. **Fix ALL findings — including lows.** A low you skip is a bug you
|
|
110
|
+
shipped. If a finding is genuinely invalid, rebut it with evidence in
|
|
111
|
+
writing; silence is not a rebuttal.
|
|
112
|
+
7. **Re-review the fix.** The re-review verdict must contain, per original
|
|
113
|
+
finding: the finding ID, what changed (`file:line` of the fix), whether it
|
|
114
|
+
fully resolves the finding (`RESOLVED` / `PARTIAL` / `REBUTTED` with
|
|
115
|
+
evidence), and whether the fix introduced anything new. A bare "fixed" is
|
|
116
|
+
not a verdict.
|
|
117
|
+
8. **Merge only on CI green.** No exceptions. A red or flaky CI run is a
|
|
118
|
+
blocker to investigate, not to override.
|
|
119
|
+
|
|
120
|
+
## 5. Communicate while you work
|
|
121
|
+
|
|
122
|
+
- **Consolidated messages.** Batch related findings and results into one
|
|
123
|
+
substantive update; never send five fragments where one message serves.
|
|
124
|
+
- **Always-visible progress.** Long-running work surfaces status the user can
|
|
125
|
+
see (progress card, interim edit, explicit "still running: X"). Never go
|
|
126
|
+
dark mid-task.
|
|
127
|
+
- **No foreground watches over 30 seconds.** Anything longer — builds, CI
|
|
128
|
+
waits, deploys — runs in the background with a notification on completion.
|
|
129
|
+
Don't block a turn polling.
|
|
130
|
+
- **Max 15 parallel sub-agents.** Fan out for genuinely parallel work
|
|
131
|
+
(independent reviews, independent modules), but cap the swarm at 15.
|
|
@@ -276,7 +276,8 @@ Each plugin instance is bound to one agent (via `SWITCHROOM_AGENT_NAME` set by `
|
|
|
276
276
|
|---------|-------------|
|
|
277
277
|
| `/agents` | List all agents and their status |
|
|
278
278
|
| `/agentstart [name]` | Start an agent (default: this agent) |
|
|
279
|
-
| `/
|
|
279
|
+
| `/agentstop [name]` | Stop an agent's container (default: this agent) |
|
|
280
|
+
| `/stop` | Cancel this agent's in-flight turn (bare `stop` as a message works too) |
|
|
280
281
|
| `/restart [name\|all]` | Restart an agent (default: this agent; pass `all` for every agent) |
|
|
281
282
|
| `/auth` | Show auth/token status |
|
|
282
283
|
| `/topics` | Show topic-to-agent mappings |
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { dirname, resolve } from 'node:path'
|
|
2
5
|
import {
|
|
3
6
|
dispatchAdminCommand,
|
|
4
7
|
parseCommandName,
|
|
@@ -41,12 +44,47 @@ describe('parseCommandName', () => {
|
|
|
41
44
|
|
|
42
45
|
describe('ADMIN_COMMAND_NAMES', () => {
|
|
43
46
|
it('contains the fleet-management admin commands', () => {
|
|
44
|
-
const required = ['agents', 'logs', 'restart', 'update', 'reconcile', '
|
|
47
|
+
const required = ['agents', 'logs', 'restart', 'update', 'reconcile', 'agentstop', 'agentstart', 'grant', 'dangerous', 'permissions', 'vault', 'audit']
|
|
45
48
|
for (const cmd of required) {
|
|
46
49
|
expect(ADMIN_COMMAND_NAMES.has(cmd)).toBe(true)
|
|
47
50
|
}
|
|
48
51
|
})
|
|
49
52
|
|
|
53
|
+
it('replaced container-stop /stop with /agentstop (#3020 / #1394)', () => {
|
|
54
|
+
// 'stop' now cancels the in-flight turn on EVERY agent (like /interrupt),
|
|
55
|
+
// so it must NOT be admin-gated; container stop moved to 'agentstop',
|
|
56
|
+
// which MUST be admin-gated or any forum member could stop containers
|
|
57
|
+
// via non-admin agents (the #1394 hole).
|
|
58
|
+
expect(ADMIN_COMMAND_NAMES.has('stop')).toBe(false)
|
|
59
|
+
expect(ADMIN_COMMAND_NAMES.has('agentstop')).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('stays in sync with the tier-2 command list in docs/architecture.md', () => {
|
|
63
|
+
// The doc's "Fleet-management commands" line is the operator-facing
|
|
64
|
+
// contract; ADMIN_COMMAND_NAMES is the enforcing set. Drift between the
|
|
65
|
+
// two either advertises an ungated verb or hides a gated one.
|
|
66
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
67
|
+
const doc = readFileSync(
|
|
68
|
+
resolve(__dirname, '..', '..', 'docs', 'architecture.md'),
|
|
69
|
+
'utf8',
|
|
70
|
+
)
|
|
71
|
+
const tier2Line = doc
|
|
72
|
+
.split('\n')
|
|
73
|
+
.find(l => l.startsWith('2. **Fleet-management commands**'))
|
|
74
|
+
expect(tier2Line, 'tier-2 line missing from docs/architecture.md').toBeTruthy()
|
|
75
|
+
const documented = new Set(
|
|
76
|
+
[...tier2Line!.matchAll(/`\/([a-z]+)(?:\s+<[^>]+>)?`/g)].map(m => m[1]!),
|
|
77
|
+
)
|
|
78
|
+
// Every documented tier-2 verb is enforced…
|
|
79
|
+
for (const cmd of documented) {
|
|
80
|
+
expect(ADMIN_COMMAND_NAMES.has(cmd), `doc lists /${cmd} but ADMIN_COMMAND_NAMES lacks it`).toBe(true)
|
|
81
|
+
}
|
|
82
|
+
// …and every enforced verb is documented.
|
|
83
|
+
for (const cmd of ADMIN_COMMAND_NAMES) {
|
|
84
|
+
expect(documented.has(cmd), `ADMIN_COMMAND_NAMES has '${cmd}' but the doc tier-2 line lacks /${cmd}`).toBe(true)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
50
88
|
it('does not contain per-agent auth ops (must work without model)', () => {
|
|
51
89
|
// /auth, /reauth, /authfallback are handled by the gateway directly so
|
|
52
90
|
// the user can re-authenticate even when the model is rate-limited or the
|
|
@@ -251,7 +289,7 @@ describe('classifyAdminGate', () => {
|
|
|
251
289
|
})
|
|
252
290
|
})
|
|
253
291
|
it('blocks /agents, /update, /vault, /permissions', () => {
|
|
254
|
-
for (const c of ['agents', 'update', 'vault', 'permissions', '
|
|
292
|
+
for (const c of ['agents', 'update', 'vault', 'permissions', 'agentstop', 'agentstart', 'reconcile', 'dangerous', 'memory', 'topics']) {
|
|
255
293
|
const r = classifyAdminGate(`/${c}`, me)
|
|
256
294
|
expect(r).toEqual({ action: 'block', reason: 'admin-required', cmd: c })
|
|
257
295
|
}
|
|
@@ -49,7 +49,12 @@ export const ADMIN_COMMAND_NAMES = new Set<string>([
|
|
|
49
49
|
'agents',
|
|
50
50
|
'logs',
|
|
51
51
|
'restart',
|
|
52
|
-
|
|
52
|
+
// #3020: container stop is /agentstop (pairs with /agentstart). Plain
|
|
53
|
+
// 'stop' is deliberately ABSENT — it cancels the in-flight turn on every
|
|
54
|
+
// agent (like /interrupt), so admin-gating it would break the kill switch
|
|
55
|
+
// on non-admin agents; and leaving container-stop off this list would let
|
|
56
|
+
// any forum member stop containers via non-admin agents (#1394).
|
|
57
|
+
'agentstop',
|
|
53
58
|
'agentstart',
|
|
54
59
|
'update',
|
|
55
60
|
'reconcile',
|
|
@@ -31,6 +31,7 @@ import { buildEffectiveToolSchemas, LINEAR_ENV } from './tool-filter.js'
|
|
|
31
31
|
import type { InboundMessage, PermissionEvent, StatusEvent } from '../gateway/ipc-protocol.js'
|
|
32
32
|
import { matchesAllowRule } from '../permission-rule.js'
|
|
33
33
|
import { createOutstandingPermissionLedger } from './permission-ledger.js'
|
|
34
|
+
import { appendCrashBreadcrumb } from './crash-breadcrumb.js'
|
|
34
35
|
|
|
35
36
|
installPluginLogger()
|
|
36
37
|
|
|
@@ -107,7 +108,7 @@ const TOOL_SCHEMAS = [
|
|
|
107
108
|
quote: { type: 'boolean', description: 'Opt out of the default quote-reply behavior. Default: true. Pass false to send a bare message with no quote reference. Ignored when reply_to is explicitly set.' },
|
|
108
109
|
message_thread_id: { type: 'string', description: 'Forum topic thread ID. Auto-applied from the last inbound message in the same chat if not specified.' },
|
|
109
110
|
origin_turn_id: { type: 'string', description: 'In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message\'s topic even if another topic\'s turn started meanwhile. Omit in DMs / single-topic chats.' },
|
|
110
|
-
files: { type: 'array', items: { type: 'string' }, description: 'Absolute file paths to attach. Images send as photos; other types as documents. Max 50MB each.' },
|
|
111
|
+
files: { type: 'array', items: { type: 'string' }, description: 'Absolute file paths to attach. Images send as photos; other types as documents. Max 50MB each. Telegram rejects photos with extreme dimensions (aspect ratio over ~10:1, width+height over 10000px, or over 10MB) — very tall images like full-page screenshots are auto-rerouted as documents; crop or split them first if the user should see them inline as photos.' },
|
|
111
112
|
format: { type: 'string', enum: ['html', 'markdownv2', 'text'], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
|
|
112
113
|
disable_web_page_preview: { type: 'boolean', description: 'Disable link preview thumbnails. Default: true.' },
|
|
113
114
|
protect_content: { type: 'boolean', description: 'When true, Telegram prevents the message from being forwarded or saved.' },
|
|
@@ -895,8 +896,29 @@ process.on('SIGINT', () => {
|
|
|
895
896
|
setTimeout(() => process.exit(0), 500)
|
|
896
897
|
})
|
|
897
898
|
|
|
899
|
+
// #3033 — the bridge process must survive stray errors, and when it can't,
|
|
900
|
+
// it must leave a diagnosable trace. Claude Code NEVER respawns a dead MCP
|
|
901
|
+
// server: if this process exits, the reply tool vanishes from the session
|
|
902
|
+
// (`No such tool available`) and only a full container restart recovers the
|
|
903
|
+
// chat surface (2026-07-11 clerk incident — the gateway crashed, its
|
|
904
|
+
// supervisor brought it back in 1s, but the bridge had died in the same
|
|
905
|
+
// window and the agent was mute for 7 minutes until the operator bounced
|
|
906
|
+
// the container). Claude Code also drops MCP-server stderr after startup,
|
|
907
|
+
// so both handlers persist a breadcrumb to STATE_DIR/bridge-crash.log.
|
|
908
|
+
const CRASH_LOG_PATH = join(STATE_DIR, 'bridge-crash.log')
|
|
909
|
+
|
|
898
910
|
process.on('unhandledRejection', (err) => {
|
|
899
911
|
process.stderr.write(`telegram bridge: unhandled rejection: ${err}\n`)
|
|
912
|
+
appendCrashBreadcrumb(CRASH_LOG_PATH, 'unhandledRejection', err)
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
process.on('uncaughtException', (err) => {
|
|
916
|
+
// Log-and-continue, mirroring the unhandledRejection posture. Risky in
|
|
917
|
+
// general, but the alternative is strictly worse here: process death is
|
|
918
|
+
// unrecoverable by design (see above), while the IPC client's reconnect
|
|
919
|
+
// loop can heal any gateway-connection damage on its own.
|
|
920
|
+
process.stderr.write(`telegram bridge: uncaught exception (continuing): ${(err as Error)?.stack ?? err}\n`)
|
|
921
|
+
appendCrashBreadcrumb(CRASH_LOG_PATH, 'uncaughtException', err)
|
|
900
922
|
})
|
|
901
923
|
|
|
902
924
|
async function main(): Promise<void> {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crash-breadcrumb.ts — persistent last-gasp diagnostics for the MCP
|
|
3
|
+
* bridge process (#3033).
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: Claude Code captures an MCP server's stderr only
|
|
6
|
+
* around connection startup — anything the bridge writes later is
|
|
7
|
+
* dropped. In the 2026-07-11 clerk incident the bridge process died
|
|
8
|
+
* within seconds of a gateway crash and the cause was unrecoverable:
|
|
9
|
+
* no stderr, no log, nothing. And a dead bridge is terminal — Claude
|
|
10
|
+
* Code never respawns a failed MCP server, so the chat surface stays
|
|
11
|
+
* down (`No such tool available: mcp__switchroom-telegram__reply`)
|
|
12
|
+
* until a full container restart.
|
|
13
|
+
*
|
|
14
|
+
* This module appends a bounded breadcrumb line to
|
|
15
|
+
* `STATE_DIR/bridge-crash.log` from the bridge's uncaughtException /
|
|
16
|
+
* unhandledRejection handlers so the NEXT incident is diagnosable.
|
|
17
|
+
* Append-only, best-effort, never throws.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { appendFileSync, statSync, renameSync } from 'node:fs'
|
|
21
|
+
|
|
22
|
+
/** Rotate once past ~1MB — a crash-looping bridge must not fill the disk. */
|
|
23
|
+
const MAX_LOG_BYTES = 1024 * 1024
|
|
24
|
+
|
|
25
|
+
export function appendCrashBreadcrumb(
|
|
26
|
+
logPath: string,
|
|
27
|
+
kind: 'uncaughtException' | 'unhandledRejection',
|
|
28
|
+
err: unknown,
|
|
29
|
+
now: Date = new Date(),
|
|
30
|
+
): void {
|
|
31
|
+
try {
|
|
32
|
+
try {
|
|
33
|
+
if (statSync(logPath).size > MAX_LOG_BYTES) renameSync(logPath, `${logPath}.1`)
|
|
34
|
+
} catch { /* first write, or rotation raced — append anyway */ }
|
|
35
|
+
const detail = err instanceof Error ? (err.stack ?? err.message) : String(err)
|
|
36
|
+
// Single line per event (stack newlines folded) — greppable, bounded.
|
|
37
|
+
const folded = detail.replace(/\s*\n\s*/g, ' | ').slice(0, 4000)
|
|
38
|
+
appendFileSync(logPath, `${now.toISOString()} ${kind} pid=${process.pid} ${folded}\n`)
|
|
39
|
+
} catch {
|
|
40
|
+
/* best-effort: a failing breadcrumb must never make the crash worse */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -70,6 +70,19 @@ export function createChatLock(): ChatLock {
|
|
|
70
70
|
const tracked = next.finally(() => {
|
|
71
71
|
if (chains.get(key) === tracked) chains.delete(key)
|
|
72
72
|
})
|
|
73
|
+
// `tracked` is a SECOND promise derived from `next`, retained only in
|
|
74
|
+
// the `chains` map for ordering. The caller awaits `next` (and handles
|
|
75
|
+
// its rejection); `tracked` is picked up by the NEXT queued call via
|
|
76
|
+
// `prior.then(fn, fn)`. But when this is the TAIL call on the key —
|
|
77
|
+
// the common single-reply case — nothing ever attaches a handler to
|
|
78
|
+
// `tracked`, so a rejected `next` surfaces as an UNHANDLED REJECTION
|
|
79
|
+
// and (per the gateway's unhandledRejection policy) crashes + reboots
|
|
80
|
+
// the whole gateway. This bit any failing send that was last in its
|
|
81
|
+
// (chat,thread) lane: sendPhoto PHOTO_INVALID_DIMENSIONS, sendMediaGroup,
|
|
82
|
+
// editMessageText MESSAGE_TOO_LONG, deleteMessage "can't be deleted",
|
|
83
|
+
// etc. Swallow the rejection on the internal tracking promise ONLY —
|
|
84
|
+
// the caller's `next` still carries the real error for normal handling.
|
|
85
|
+
tracked.catch(() => {})
|
|
73
86
|
chains.set(key, tracked)
|
|
74
87
|
return next
|
|
75
88
|
}
|