switchroom 0.18.8 → 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.
Files changed (93) hide show
  1. package/README.md +2 -2
  2. package/dist/agent-scheduler/index.js +1 -0
  3. package/dist/auth-broker/index.js +198 -13
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +28 -4
  6. package/dist/host-control/main.js +3 -2
  7. package/dist/vault/approvals/kernel-server.js +2 -1
  8. package/dist/vault/broker/server.js +2 -1
  9. package/package.json +1 -1
  10. package/profiles/_base/start.sh.hbs +119 -37
  11. package/profiles/_shared/dev-protocol.md.hbs +42 -0
  12. package/skills/dev-protocol/SKILL.md +131 -0
  13. package/telegram-plugin/README.md +2 -1
  14. package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
  15. package/telegram-plugin/admin-commands/index.ts +6 -1
  16. package/telegram-plugin/bridge/bridge.ts +23 -1
  17. package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
  18. package/telegram-plugin/chat-lock.ts +13 -0
  19. package/telegram-plugin/dist/bridge/bridge.js +24 -1
  20. package/telegram-plugin/dist/gateway/gateway.js +80236 -77465
  21. package/telegram-plugin/dist/server.js +29 -2
  22. package/telegram-plugin/fallback-card-collapse.ts +131 -0
  23. package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
  24. package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
  25. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
  26. package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
  27. package/telegram-plugin/gateway/effort-command.ts +47 -3
  28. package/telegram-plugin/gateway/gateway.ts +2051 -3180
  29. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
  30. package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
  31. package/telegram-plugin/gateway/model-command.ts +94 -8
  32. package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
  33. package/telegram-plugin/gateway/pending-session-command.ts +365 -0
  34. package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
  35. package/telegram-plugin/gateway/permission-timeout.ts +25 -0
  36. package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
  37. package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
  38. package/telegram-plugin/gateway/session-model-file.ts +166 -23
  39. package/telegram-plugin/gateway/stop-command.ts +56 -0
  40. package/telegram-plugin/photo-precheck.ts +201 -0
  41. package/telegram-plugin/quota-watch.ts +141 -2
  42. package/telegram-plugin/registry/subagents-schema.ts +26 -3
  43. package/telegram-plugin/registry/subagents.test.ts +67 -0
  44. package/telegram-plugin/retry-api-call.ts +31 -0
  45. package/telegram-plugin/subagent-watcher.ts +392 -1
  46. package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
  47. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
  48. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
  49. package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
  50. package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
  51. package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
  52. package/telegram-plugin/tests/effort-command.test.ts +59 -2
  53. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
  54. package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
  55. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
  56. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
  57. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
  58. package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
  59. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
  60. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
  61. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
  62. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
  63. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
  64. package/telegram-plugin/tests/model-command.test.ts +46 -3
  65. package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
  66. package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
  67. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
  68. package/telegram-plugin/tests/pending-session-command.test.ts +322 -0
  69. package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
  70. package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
  71. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
  72. package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
  73. package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
  74. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
  75. package/telegram-plugin/tests/quota-watch.test.ts +225 -0
  76. package/telegram-plugin/tests/session-model-file.test.ts +101 -2
  77. package/telegram-plugin/tests/stop-command.test.ts +234 -0
  78. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
  79. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
  80. package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -0
  81. package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
  82. package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
  83. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
  84. package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
  85. package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
  86. package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
  87. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
  88. package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
  89. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
  90. package/telegram-plugin/tests/worker-activity-feed.test.ts +37 -0
  91. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
  92. package/telegram-plugin/welcome-text.ts +4 -3
  93. 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 positively-
1071
- # confirmed `/model X` switch persists as `{{agentDir}}/.session-model`
1072
- # (one-line JSON written by the gateway). Whether THIS boot applies it is
1073
- # decided by the one-shot `.relaunch-model-intent` file, stamped by the
1074
- # gateway immediately before every switchroom-managed bounce:
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
- # fresh (<10 min by embedded ts) "keep" intent → apply the override
1077
- # anything else (revert / absent / stale / corrupt) → delete the override,
1078
- # boot the configured default, and (when an override was actually
1079
- # removed) write a `.session-model-alert` notice naming why.
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
- # Default is REVERT (operator decision 2026-07): a crash, OOM, raw
1082
- # `docker restart`, host reboot, or deploy writes no intent and therefore
1083
- # reverts to the yaml model. Watchdog/recovery bounces run gateway code and
1084
- # stamp "keep" before SIGTERM, so a confirmed switch survives them.
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
- if [ "$_int" = "keep" ] && [ -n "$_int_ts" ] && [ $(( $(date +%s) * 1000 - _int_ts )) -lt 600000 ]; then
1127
- _sm_keep="1"
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 [ -z "$_sm_keep" ]; then
1138
- # No fresh keep intent deliberate restart, crash, external container
1139
- # restart, or deploy. Revert to the configured default and say so.
1140
- rm -f "{{agentDir}}/.session-model"
1141
- _sm_why="${_sm_reason:-no keep intent — crash, external container restart, or deploy}"
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 reverted to the configured default `%s` (%s). Re-issue /model %s to switch back.\n' "$_sm_model" "$_EFFECTIVE_MODEL" "$_sm_why" "$_sm_model" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
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
- rm -f "{{agentDir}}/.session-model"
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. Invalidate + announce.
1159
- rm -f "{{agentDir}}/.session-model"
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 switchroom-managed relaunch${_sm_reason:+ ($_sm_reason)}" >&2
1187
- printf 'Session model override `%s` kept across this relaunch%s. It reverts on /restart, agent restart, crash, or an external container restart; /model default clears it now.\n' "$_sm_model" "${_sm_reason:+ ($_sm_reason)}" > "{{agentDir}}/.session-model-alert" 2>/dev/null || true
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"{{#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}}
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"{{#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}}
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"{{#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}}
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"{{#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}}
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
- | `/stop [name]` | Stop an agent (default: this agent) |
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', 'stop', 'agentstart', 'grant', 'dangerous', 'permissions', 'vault', 'audit']
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', 'stop', 'agentstart', 'reconcile', 'dangerous', 'memory', 'topics']) {
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
- 'stop',
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
  }