switchroom 0.15.45 → 0.16.5

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 (150) hide show
  1. package/dist/agent-scheduler/index.js +56 -15
  2. package/dist/auth-broker/index.js +383 -97
  3. package/dist/cli/autoaccept-poll.js +4842 -35
  4. package/dist/cli/drive-write-pretool.mjs +7 -4
  5. package/dist/cli/notion-write-pretool.mjs +35 -4
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
  7. package/dist/cli/self-improve-stop.mjs +428 -0
  8. package/dist/cli/switchroom.js +2894 -841
  9. package/dist/host-control/main.js +2685 -207
  10. package/dist/vault/approvals/kernel-server.js +7453 -7413
  11. package/dist/vault/broker/server.js +11428 -11388
  12. package/examples/minimal.yaml +1 -0
  13. package/examples/switchroom.yaml +1 -0
  14. package/package.json +3 -3
  15. package/profiles/_base/start.sh.hbs +97 -1
  16. package/profiles/_shared/execution-discipline.md.hbs +18 -0
  17. package/profiles/default/CLAUDE.md.hbs +0 -19
  18. package/telegram-plugin/.claude-plugin/plugin.json +2 -2
  19. package/telegram-plugin/answer-stream-flag.ts +12 -49
  20. package/telegram-plugin/answer-stream.ts +5 -150
  21. package/telegram-plugin/auth-snapshot-format.ts +280 -48
  22. package/telegram-plugin/auto-fallback-fleet.ts +44 -1
  23. package/telegram-plugin/context-exhaustion.ts +12 -0
  24. package/telegram-plugin/demo-mask.ts +154 -0
  25. package/telegram-plugin/dist/bridge/bridge.js +55 -12
  26. package/telegram-plugin/dist/gateway/gateway.js +2938 -977
  27. package/telegram-plugin/dist/server.js +55 -12
  28. package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
  29. package/telegram-plugin/draft-stream.ts +47 -410
  30. package/telegram-plugin/final-answer-detect.ts +17 -12
  31. package/telegram-plugin/fleet-fallback-resume.ts +131 -0
  32. package/telegram-plugin/format.ts +56 -19
  33. package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
  34. package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
  35. package/telegram-plugin/gateway/auth-command.ts +70 -14
  36. package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
  37. package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
  38. package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
  39. package/telegram-plugin/gateway/current-turn-map.ts +188 -0
  40. package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
  41. package/telegram-plugin/gateway/effort-command.ts +8 -3
  42. package/telegram-plugin/gateway/emission-authority.ts +369 -0
  43. package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
  44. package/telegram-plugin/gateway/gateway.ts +1857 -292
  45. package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
  46. package/telegram-plugin/gateway/model-command.ts +115 -4
  47. package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
  48. package/telegram-plugin/gateway/represent-guard.ts +72 -0
  49. package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
  50. package/telegram-plugin/gateway/status-surface-log.ts +14 -3
  51. package/telegram-plugin/history.ts +33 -11
  52. package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
  53. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
  54. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
  55. package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
  56. package/telegram-plugin/issues-card.ts +4 -0
  57. package/telegram-plugin/model-unavailable.ts +124 -0
  58. package/telegram-plugin/narrative-dedup.ts +69 -0
  59. package/telegram-plugin/over-ping-safety-net.ts +70 -4
  60. package/telegram-plugin/package.json +3 -3
  61. package/telegram-plugin/pending-work-progress.ts +12 -0
  62. package/telegram-plugin/permission-rule.ts +32 -5
  63. package/telegram-plugin/permission-title.ts +152 -9
  64. package/telegram-plugin/quota-check.ts +13 -0
  65. package/telegram-plugin/quota-watch.ts +135 -7
  66. package/telegram-plugin/registry/turns-schema.test.ts +24 -0
  67. package/telegram-plugin/registry/turns-schema.ts +9 -0
  68. package/telegram-plugin/runtime-metrics.ts +13 -0
  69. package/telegram-plugin/session-tail.ts +96 -11
  70. package/telegram-plugin/silence-poke.ts +170 -24
  71. package/telegram-plugin/slot-banner-driver.ts +3 -0
  72. package/telegram-plugin/status-no-truncate.ts +44 -0
  73. package/telegram-plugin/status-reactions.ts +20 -3
  74. package/telegram-plugin/stream-controller.ts +4 -23
  75. package/telegram-plugin/stream-reply-handler.ts +6 -24
  76. package/telegram-plugin/streaming-metrics.ts +91 -0
  77. package/telegram-plugin/subagent-watcher.ts +212 -66
  78. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
  79. package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
  80. package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
  81. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
  82. package/telegram-plugin/tests/answer-stream.test.ts +2 -411
  83. package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
  84. package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
  85. package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
  86. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
  87. package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
  88. package/telegram-plugin/tests/demo-mask.test.ts +127 -0
  89. package/telegram-plugin/tests/draft-stream.test.ts +0 -827
  90. package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
  91. package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
  92. package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
  93. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
  94. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
  95. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
  96. package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
  97. package/telegram-plugin/tests/feed-survival.test.ts +526 -0
  98. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
  99. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
  100. package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
  101. package/telegram-plugin/tests/history.test.ts +60 -0
  102. package/telegram-plugin/tests/model-command.test.ts +134 -0
  103. package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
  104. package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
  105. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
  106. package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
  107. package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
  108. package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
  109. package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
  110. package/telegram-plugin/tests/permission-rule.test.ts +17 -0
  111. package/telegram-plugin/tests/permission-title.test.ts +206 -17
  112. package/telegram-plugin/tests/quota-watch.test.ts +252 -9
  113. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
  114. package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
  115. package/telegram-plugin/tests/represent-guard.test.ts +162 -0
  116. package/telegram-plugin/tests/session-tail.test.ts +147 -3
  117. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
  118. package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
  119. package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
  120. package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
  121. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
  122. package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
  123. package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
  124. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
  125. package/telegram-plugin/tests/telegram-format.test.ts +101 -6
  126. package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
  127. package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
  128. package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
  129. package/telegram-plugin/tests/tool-labels.test.ts +67 -0
  130. package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
  131. package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
  132. package/telegram-plugin/tests/welcome-text.test.ts +32 -3
  133. package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
  134. package/telegram-plugin/tool-activity-summary.ts +375 -58
  135. package/telegram-plugin/turn-liveness-floor.ts +240 -0
  136. package/telegram-plugin/uat/assertions.ts +115 -0
  137. package/telegram-plugin/uat/driver.ts +68 -0
  138. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
  139. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
  140. package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
  141. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
  142. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
  143. package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
  144. package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
  145. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
  146. package/telegram-plugin/welcome-text.ts +13 -1
  147. package/telegram-plugin/worker-activity-feed.ts +157 -82
  148. package/telegram-plugin/draft-transport.ts +0 -122
  149. package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
  150. package/telegram-plugin/tests/draft-transport.test.ts +0 -211
@@ -12,6 +12,7 @@
12
12
 
13
13
  switchroom:
14
14
  version: 1
15
+ # timezone: "Region/City" # agents' local time + cron evaluation; defaults to UTC if unset
15
16
 
16
17
  telegram:
17
18
  bot_token: "vault:telegram-bot-token"
@@ -26,6 +26,7 @@ switchroom:
26
26
  version: 1
27
27
  agents_dir: ~/.switchroom/agents
28
28
  skills_dir: ~/.switchroom/skills # shared skill pool (symlinked per agent)
29
+ # timezone: "Region/City" # agents' local time + cron evaluation; defaults to UTC if unset
29
30
 
30
31
  telegram:
31
32
  # Single-agent fallback ONLY. This is the bot the one shipped agent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.15.45",
3
+ "version": "0.16.5",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,9 +23,9 @@
23
23
  "build": "node scripts/build.mjs",
24
24
  "build:cli": "node scripts/build.mjs && bun build --compile --target=bun-linux-x64 --minify bin/switchroom.ts --outfile switchroom-linux-amd64",
25
25
  "pretest": "npm run build",
26
- "test": "vitest run && bun test telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
26
+ "test": "vitest run && bun test telegram-plugin/tests/history.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
27
27
  "test:vitest": "vitest run",
28
- "test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
28
+ "test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/server-admin-only-keys.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/litellm/provision-apply-e2e.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/approval-origin.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
29
29
  "test:watch": "vitest",
30
30
  "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-web-subscription-honest.mjs",
31
31
  "lint:tsc": "tsc --noEmit",
@@ -67,6 +67,22 @@ if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER"
67
67
  # lifecycle. Ampersand-backgrounded by callers below.
68
68
  _switchroom_supervise() {
69
69
  local _name="$1"; local _logfile="$2"; shift 2
70
+ # Optional `--oneshot-ok` flag (must directly follow the logfile):
71
+ # marks a sidecar that can LEGITIMATELY complete. For such a sidecar
72
+ # a clean exit 0 means "job done" → stop supervising, no respawn.
73
+ # This is the autoaccept sidecar's case: under
74
+ # SWITCHROOM_WEDGE_WATCHDOG=0 it dispatches the first-run prompts and
75
+ # exits 0 by design (and if the watch loop ever returns cleanly, that
76
+ # too is a completed job, not a crash). Without this flag the old
77
+ # supervisor respawned that clean exit every 60s forever — pure
78
+ # restart/log churn with zero productive work (klanker, #2471 wedge
79
+ # diagnosis: 192 status=0 respawns). A must-always-run sidecar (the
80
+ # gateway, the scheduler) must NOT pass this flag — a clean exit
81
+ # there IS abnormal and must still restart.
82
+ local _oneshot_ok=0
83
+ if [ "${1:-}" = "--oneshot-ok" ]; then
84
+ _oneshot_ok=1; shift
85
+ fi
70
86
  local _cap=60
71
87
  local _delay=1
72
88
  local _attempt=0
@@ -88,6 +104,15 @@ if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER"
88
104
  echo "[supervise] $_name exit 78 (EX_CONFIG) — quarantined, not restarting. Operator action required." >> "$_logfile"
89
105
  return 0
90
106
  fi
107
+ # Clean completion of an opt-in one-shot-capable sidecar: the job
108
+ # finished successfully, so do NOT respawn (respawning a
109
+ # legitimately-completed exit-0 is the churn this guard removes).
110
+ # A non-zero exit still falls through to the indefinite backoff
111
+ # below — a crash always self-heals.
112
+ if [ $_oneshot_ok -eq 1 ] && [ $_exit -eq 0 ]; then
113
+ echo "[supervise] $_name completed cleanly (exit 0) — one-shot-ok, not respawning." >> "$_logfile"
114
+ return 0
115
+ fi
91
116
  # A run that stayed up for at least the backoff cap means the
92
117
  # dependency was healthy and this exit is a fresh transient
93
118
  # blip — reset backoff so recovery latency is minimal. Only
@@ -139,7 +164,13 @@ if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER"
139
164
  # keeps a flaky run from busy-looping. Set SWITCHROOM_WEDGE_WATCHDOG=0
140
165
  # to restore the legacy boot-only single-shot behaviour.
141
166
  if [ -f /opt/switchroom/autoaccept-poll.js ] && command -v bun >/dev/null 2>&1; then
142
- _switchroom_supervise autoaccept /var/log/switchroom/autoaccept.log \
167
+ # --oneshot-ok: this sidecar can legitimately complete (exit 0) — the
168
+ # boot-only mode (SWITCHROOM_WEDGE_WATCHDOG=0) dispatches the first-run
169
+ # prompts then exits, and a clean watch-loop return is also a finished
170
+ # job, not a crash. Marking it one-shot-ok stops the supervisor from
171
+ # respawning that clean exit every 60s forever (the #2471-diagnosed
172
+ # churn). A genuine crash (non-zero) still backs off + retries.
173
+ _switchroom_supervise autoaccept /var/log/switchroom/autoaccept.log --oneshot-ok \
143
174
  bun /opt/switchroom/autoaccept-poll.js "{{name}}" &
144
175
  fi
145
176
 
@@ -287,6 +318,12 @@ export TELEGRAM_STATE_DIR="{{agentDir}}/telegram"
287
318
  # basename(process.cwd()) inside the plugin because Claude Code spawns
288
319
  # MCP servers with cwd = $HOME regardless of the parent's cwd.
289
320
  export SWITCHROOM_AGENT_NAME="{{name}}"
321
+ # SWITCHROOM_AGENT_START_CWD is the agent's real start directory (the dir this
322
+ # script cd's into and where the agent's own CLAUDE.md lives). The
323
+ # repo-context-pretool hook reads it to suppress re-injection of the agent's
324
+ # own CLAUDE.md (already in the system prompt) while still injecting markers
325
+ # for worktree repos the agent navigates into mid-session.
326
+ export SWITCHROOM_AGENT_START_CWD="{{agentDir}}"
290
327
  {{#if switchroomConfigPathQ}}
291
328
  # SWITCHROOM_CONFIG lets the agent's own shell tool invoke `switchroom`
292
329
  # without passing `--config` every time. The telegram-plugin MCP server
@@ -735,6 +772,65 @@ if command -v switchroom >/dev/null 2>&1; then
735
772
  unset sr_wk_pair sr_wk_env sr_wk_key sr_wk_val
736
773
  fi
737
774
 
775
+ # LiteLLM routing (opt-in, #litellm). When SWITCHROOM_LITELLM is set (compose
776
+ # env, gated on litellm.enabled && keyConfirmed), route the unmodified `claude`
777
+ # CLI through the operator's LiteLLM proxy at ANTHROPIC_BASE_URL: fetch the
778
+ # per-agent virtual key from the vault-broker and export it as the
779
+ # x-litellm-api-key Bearer header. The broker enforces the per-key ACL (the
780
+ # agent's standing secrets[] grant added by `switchroom apply`).
781
+ #
782
+ # Subscription-native by construction: claude still sends its Pro/Max OAuth
783
+ # `Authorization: Bearer`, which LiteLLM forwards UNCHANGED to Anthropic
784
+ # (forward_client_headers_to_llm_api). The proxy only meters and applies
785
+ # content-safety guardrails — it never alters the model or Claude's operation.
786
+ # See reference/invariants.md § "Operator-controlled gateway carve-out".
787
+ #
788
+ # FAIL-OPEN (operator decision 2026-06-28): if the key is missing OR the proxy
789
+ # is unreachable at boot, strip the routing env and fall back to the direct
790
+ # broker-OAuth Anthropic path, logging the reason LOUDLY. Availability wins over
791
+ # tracking/guardrails — a proxy outage must never take an agent dark. The
792
+ # tradeoff is an explicit, logged guardrail lapse for that session, not a silent
793
+ # one. Re-probed on every (re)boot, so recovery is automatic.
794
+ #
795
+ # Static tags (x-litellm-customer-id / x-litellm-tags) give per-AGENT
796
+ # attribution in LiteLLM. Per-SESSION is NOT achievable here: claude sends no
797
+ # session id on the wire and ANTHROPIC_CUSTOM_HEADERS is fixed per process — so
798
+ # per-session cost/issue tracking is handled out-of-band by correlating
799
+ # LiteLLM's request log against switchroom's turn ledger, never by mutating the
800
+ # claude protocol.
801
+ if [ -n "$SWITCHROOM_LITELLM" ] && command -v switchroom >/dev/null 2>&1; then
802
+ sr_ll_key="$(switchroom vault get "litellm/$SWITCHROOM_AGENT_NAME/api-key" 2>/dev/null || true)"
803
+ sr_ll_ok=""
804
+ if [ -z "$sr_ll_key" ]; then
805
+ echo "litellm: no virtual key for agent '$SWITCHROOM_AGENT_NAME' — falling back to direct OAuth (no tracking/guardrail)" >&2
806
+ elif command -v curl >/dev/null 2>&1 && [ -n "$ANTHROPIC_BASE_URL" ] \
807
+ && ! curl -fsS -m 5 -o /dev/null "${ANTHROPIC_BASE_URL%/}/health/liveliness" 2>/dev/null; then
808
+ echo "litellm: proxy unreachable at $ANTHROPIC_BASE_URL — falling back to direct OAuth (no tracking/guardrail this session)" >&2
809
+ else
810
+ sr_ll_ok="1"
811
+ fi
812
+ if [ -n "$sr_ll_ok" ]; then
813
+ # Newline-separated Name: Value pairs (claude CLI ANTHROPIC_CUSTOM_HEADERS format).
814
+ # Tags: agent:<name> for per-agent spend tracking; profile:<profile> for
815
+ # fleet-level cost breakdown by role. Per-turn tags (cron vs telegram) are
816
+ # NOT achievable here — ANTHROPIC_CUSTOM_HEADERS is fixed per process and
817
+ # the claude CLI sends no session id. Use scheduler.jsonl ↔ /spend/logs
818
+ # time-window correlation for cron attribution instead.
819
+ export ANTHROPIC_CUSTOM_HEADERS="x-litellm-api-key: Bearer $sr_ll_key
820
+ x-litellm-customer-id: $SWITCHROOM_AGENT_NAME
821
+ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:-default}"
822
+ # Let Claude Code enumerate models from the LiteLLM gateway so non-Claude
823
+ # models configured in the proxy appear in the /model picker and can be
824
+ # selected via --model. Without this, the CLI only knows its bundled list.
825
+ export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1
826
+ else
827
+ # Fail-open: drop every routing var so the claude CLI talks to Anthropic
828
+ # directly on its OAuth credential (subscription path), unproxied.
829
+ unset ANTHROPIC_BASE_URL ANTHROPIC_SMALL_FAST_MODEL SWITCHROOM_LITELLM
830
+ fi
831
+ unset sr_ll_key sr_ll_ok
832
+ fi
833
+
738
834
  {{#if useSwitchroomPlugin}}
739
835
  if [ -n "$APPEND_PROMPT" ]; then
740
836
  exec claude $CONTINUE_FLAG --dangerously-load-development-channels server:switchroom-telegram --plugin-dir "{{securityPluginDir}}"{{#if hindsightEnabled}} --plugin-dir "{{agentDir}}/.claude/plugins/hindsight-memory"{{/if}} $SR_FLEET_ARG{{#if modelQ}} --model {{{modelQ}}}{{/if}}{{#if thinkingEffort}} --effort {{thinkingEffort}}{{/if}}{{#if permissionMode}} --permission-mode {{permissionMode}}{{/if}}{{#if fallbackModelQ}} --fallback-model {{{fallbackModelQ}}}{{/if}} --append-system-prompt "$APPEND_PROMPT"{{#if dangerousMode}} --dangerously-skip-permissions{{/if}}{{#if extraCliArgs}}{{{extraCliArgs}}}{{/if}}
@@ -0,0 +1,18 @@
1
+ ## Grounding — check before you assert
2
+
3
+ Your memory and prior context are leads, not facts. Anything you state as true — about the code, a file, a system's state, a config value, a number, a status, or what you can and can't do — must come from something you actually checked this turn, not from recall, habit, or assumption. When you have a way to look, look.
4
+
5
+ - **Read the ground truth.** Mutable state — file and code contents, git status, config, versions, running services, processes, schedules, permissions — is read live with the right tool, not remembered. "I think it's X" is a lead to verify, not an answer.
6
+ - **Prefer finding over guessing.** If a fact exists somewhere you can reach — the codebase, a system, a doc, the web, a tool's output — go get it before answering. Favor the source over a plausible-sounding recollection.
7
+ - **Don't assert a limit you haven't tested.** Before saying you can't do something — "no access", "operator-only", "not editable", "no handle" — verify it live. A fabricated boundary is as wrong as a fabricated fact, and it stops work that was actually possible.
8
+ - **A weak or empty result isn't a conclusion.** Vary the query, path, command, or source before deciding something doesn't exist or isn't true.
9
+ - **Final answers carry evidence.** Tool output, a file you read, a check you ran, or a named blocker — not "it should work."
10
+
11
+ When you genuinely can't verify something this turn, say so plainly ("I haven't checked this —") instead of stating it as fact. A confident wrong answer costs far more than an honest "let me confirm."
12
+
13
+ ## Execution Bias
14
+
15
+ How you should decide what to do next. These are procedural rules, not vibe.
16
+
17
+ - **Act in-turn.** If the request is actionable, do it this turn. Don't finish with a plan or promise when tools can move it forward.
18
+ - **Non-final turn:** use tools to advance, or ask the one clarifying question that unblocks safe progress. One question, not five.
@@ -31,25 +31,6 @@ You are operating in the **{{topicName}}** {{#if topicEmoji}}{{topicEmoji}} {{/i
31
31
  - **Batch foreseeable approvals; don't drip surprises.** When you can already see that several actions will each need the user's approval, tell them up front which approvals are coming and why. Request independent ones together so they can decide once; for dependent ones (one's input comes from another), say what you're doing first and what approval comes next — a permission card should never arrive out of the blue.
32
32
  - **A timed-out approval isn't a denial.** If a request came back denied only because the user was away (a timeout, not an explicit "no"), don't silently abandon it. When they're back, remind them it's still pending and re-offer it if they still want it.
33
33
 
34
- ## Execution Bias
35
-
36
- How you should decide what to do next. These are procedural rules, not vibe.
37
-
38
- - **Act in-turn.** If the request is actionable, do it this turn. Don't finish with a plan or promise when tools can move it forward.
39
- - **Verify mutable facts before claiming them.** Files, git state, clocks, versions, services, processes, package state, the contents of an `Edit` target: read live. Memory and prior context are not verification sources. "I think the function is at line 200" is not an answer; `Grep`/`Read` is.
40
- - **Final answer needs evidence.** Test/build/lint output, screenshot, inspection, tool output, or a named blocker. "It should work" is not a finalization.
41
- - **Weak or empty tool result is not a conclusion.** Vary the query, path, command, or source before deciding the thing isn't there.
42
- - **Non-final turn:** use tools to advance, or ask the one clarifying question that unblocks safe progress. One question, not five.
43
-
44
- {{!--
45
- Telegram-style guidance (the 5-beat pacing contract) lives in the
46
- fleet invariants file at `~/.switchroom/fleet/switchroom-invariants.md`
47
- and reaches the agent via Claude Code native CLAUDE.md discovery
48
- (`--add-dir ~/.switchroom/fleet`). Do not re-include the
49
- `{{> telegram-style}}` partial here — that would re-introduce the
50
- session-level duplication the prompt redesign removed.
51
- --}}
52
-
53
34
  ## Memory — Hindsight is your single backend
54
35
 
55
36
  **Claude Code's built-in file-based auto-memory is disabled for this agent.** Don't try to write `.md` files under `.claude/projects/.../memory/` or maintain a `MEMORY.md` index — that whole system is off. There's exactly one memory backend: **Hindsight**.
@@ -6,8 +6,8 @@
6
6
  "name": "Ken Thompson",
7
7
  "email": "me@kenthompson.com.au"
8
8
  },
9
- "homepage": "https://github.com/mekenthompson/switchroom",
10
- "repository": "https://github.com/mekenthompson/switchroom",
9
+ "homepage": "https://github.com/switchroom/switchroom",
10
+ "repository": "https://github.com/switchroom/switchroom",
11
11
  "license": "MIT",
12
12
  "keywords": [
13
13
  "telegram",
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Default **OFF** (flipped 2026-06-03, operator request) — see the rationale
5
5
  * on the `ANSWER_STREAM_VISIBLE_ENABLED` gate in `gateway/gateway.ts`. When
6
- * off, the answer lane streams to the invisible compose-box draft and the
6
+ * off, the answer lane stays dormant (no draft, no visible preview) and the
7
7
  * reply tool is the single canonical formatted message — no unformatted
8
8
  * preliminary that flashes and gets deleted. Opt back IN per agent with
9
9
  * `SWITCHROOM_VISIBLE_ANSWER_STREAM=1` (also accepts true/on/yes).
@@ -17,25 +17,6 @@ export function parseVisibleAnswerStreamEnabled(raw: string | undefined): boolea
17
17
  return v === '1' || v === 'true' || v === 'on' || v === 'yes'
18
18
  }
19
19
 
20
- /**
21
- * Draft-answer-lane retirement (2026-06-05). The compose-box draft transport
22
- * (`sendMessageDraft`) is invisible to the mtcute UAT harness, so the live
23
- * answer-stream surface couldn't be tested. Retired by DEFAULT: the answer lane
24
- * now opens a real, observable edit-in-place message instead of the compose-box
25
- * draft (and the onMetric silence-liveness reset from #2169 now fires on visible
26
- * sends in BOTH DMs and supergroups, not just DM drafts). Kill switch
27
- * `SWITCHROOM_DRAFT_ANSWER_LANE=0` (also false/off/no) restores the legacy
28
- * invisible draft.
29
- *
30
- * Returns true when the draft lane is RETIRED (the default — env unset or any
31
- * truthy value); false only for an explicit disable of the retirement.
32
- */
33
- export function parseDraftLaneRetiredEnabled(raw: string | undefined): boolean {
34
- if (raw == null) return true
35
- const v = raw.trim().toLowerCase()
36
- return !(v === '0' || v === 'false' || v === 'off' || v === 'no')
37
- }
38
-
39
20
  /**
40
21
  * `minInitialChars` sentinel meaning "never open a visible chat-timeline
41
22
  * preview" — mirrors the `Number.MAX_SAFE_INTEGER` gate the createAnswerStream
@@ -43,63 +24,45 @@ export function parseDraftLaneRetiredEnabled(raw: string | undefined): boolean {
43
24
  */
44
25
  export const ANSWER_LANE_NEVER_OPENS = Number.MAX_SAFE_INTEGER
45
26
 
46
- export type AnswerLaneState = 'visible' | 'draft' | 'dormant'
27
+ /** The answer-lane rendering state. 'draft' is removed — lane is either
28
+ * visible (opt-in) or dormant (the default: reply tool is the only message). */
29
+ export type AnswerLaneState = 'visible' | 'dormant'
47
30
 
48
31
  export interface AnswerLaneConfig {
49
32
  /** `minInitialChars` for createAnswerStream: `1` opens a visible preview on
50
33
  * the first text chunk; `ANSWER_LANE_NEVER_OPENS` suppresses it. */
51
34
  minInitialChars: number
52
- /** Whether the lane streams to the invisible compose-box draft transport. */
53
- usesDraftTransport: boolean
54
35
  /** Whether a USER-VISIBLE chat-timeline preview opens — i.e. the surface that
55
36
  * flashed (raw preview → formatted reply → preview deleted). This is THE
56
- * regression invariant: it must equal `visibleEnabled`, never depend on the
57
- * draft flag. */
37
+ * regression invariant: it must equal `visibleEnabled`. */
58
38
  opensVisiblePreview: boolean
59
39
  /** Label for the boot log. */
60
40
  state: AnswerLaneState
61
41
  }
62
42
 
63
43
  /**
64
- * Resolve the answer-lane config from the two INDEPENDENT inputs.
44
+ * Resolve the answer-lane config from the single input.
65
45
  *
66
- * The visible PREVIEW (the flash surface) is gated on `visibleEnabled` ALONE;
67
- * draft retirement controls only the TRANSPORT (whether `sendMessageDraft` is
68
- * available). Conflating them was the v0.14.68 regression: retiring the draft
69
- * (the default) forced a visible preview that flashed on every streaming turn,
70
- * re-opening the flash v0.14.52 had removed. The load-bearing invariant —
71
- * `opensVisiblePreview === visibleEnabled` for EVERY `draftFnAvailable` — is
72
- * what this function exists to make total-enumerable (the gateway IIFE is not).
46
+ * The draft transport (`sendMessageDraft`) is permanently retired the lane
47
+ * is either VISIBLE (opt-in via SWITCHROOM_VISIBLE_ANSWER_STREAM=1) or
48
+ * DORMANT (the unconditional default). In dormant mode no preview opens and
49
+ * the reply tool is the single canonical formatted message.
73
50
  *
74
- * visibleEnabled → 'visible' (preview opens, minChars 1)
75
- * !visible, draft transport available → 'draft' (no preview; draft renders)
76
- * !visible, no draft transport → 'dormant' (no preview, no draft:
77
- * the reply tool is the
78
- * only message — the default)
51
+ * visibleEnabled → 'visible' (preview opens on first chunk, minChars 1)
52
+ * !visibleEnabled → 'dormant' (no preview, no draft — reply tool only)
79
53
  */
80
54
  export function resolveAnswerLaneConfig(input: {
81
55
  visibleEnabled: boolean
82
- draftFnAvailable: boolean
83
56
  }): AnswerLaneConfig {
84
57
  if (input.visibleEnabled) {
85
58
  return {
86
59
  minInitialChars: 1,
87
- usesDraftTransport: false,
88
60
  opensVisiblePreview: true,
89
61
  state: 'visible',
90
62
  }
91
63
  }
92
- if (input.draftFnAvailable) {
93
- return {
94
- minInitialChars: ANSWER_LANE_NEVER_OPENS,
95
- usesDraftTransport: true,
96
- opensVisiblePreview: false,
97
- state: 'draft',
98
- }
99
- }
100
64
  return {
101
65
  minInitialChars: ANSWER_LANE_NEVER_OPENS,
102
- usesDraftTransport: false,
103
66
  opensVisiblePreview: false,
104
67
  state: 'dormant',
105
68
  }
@@ -1,11 +1,4 @@
1
1
  import { isSilentFlushMarker } from './turn-flush-safety.js'
2
- import {
3
- DRAFT_METHOD_UNAVAILABLE_RE as _DRAFT_METHOD_UNAVAILABLE_RE,
4
- DRAFT_CHAT_UNSUPPORTED_RE as _DRAFT_CHAT_UNSUPPORTED_RE,
5
- shouldFallbackFromDraftTransport as _shouldFallbackFromDraftTransport,
6
- allocateDraftId as _allocateDraftId,
7
- __resetDraftIdForTests as _resetDraftIdForTests,
8
- } from './draft-transport.js'
9
2
 
10
3
  /**
11
4
  * Answer-lane incremental streaming for long Telegram replies.
@@ -19,9 +12,8 @@ import {
19
12
  *
20
13
  * Design constraints honored:
21
14
  * 1. Separate message ID from the progress card — never overwritten.
22
- * 2. sendMessageDraft for DMs (detected by chatType='private'); regular
23
- * sendMessage+editMessageText for groups/channels. Runtime fallback
24
- * when draft API rejects (DRAFT_METHOD_UNAVAILABLE_RE / DRAFT_CHAT_UNSUPPORTED_RE).
15
+ * 2. sendMessage+editMessageText for all chats (in-place edit-in-place engine).
16
+ * The draft transport (sendMessageDraft) has been permanently retired.
25
17
  * 3. minInitialChars (~50) — don't open the answer lane until enough text
26
18
  * has arrived. Lowered 400 → 50 in #553 PR 3 so short replies
27
19
  * ("yes", "done", "the answer is 42") become visible on the first
@@ -38,21 +30,12 @@ import {
38
30
  * - No finalizable-draft-lifecycle SDK — we implement the loop directly.
39
31
  * - materialize() always sends a fresh message regardless of transport,
40
32
  * to guarantee a push notification on turn completion.
41
- *
42
- * Draft-transport helpers (regex constants, shouldFallbackFromDraftTransport,
43
- * allocateDraftId) live in draft-transport.ts and are re-exported here so
44
- * existing callers that import from this module continue to work.
45
33
  */
46
34
 
47
35
  export const MIN_INITIAL_CHARS = 50
48
36
  export const DEFAULT_THROTTLE_MS = 1000
49
37
  const TELEGRAM_MAX_CHARS = 4096
50
38
 
51
- // Re-export shared constants so existing callers / tests keep working.
52
- export const DRAFT_METHOD_UNAVAILABLE_RE = _DRAFT_METHOD_UNAVAILABLE_RE
53
- export const DRAFT_CHAT_UNSUPPORTED_RE = _DRAFT_CHAT_UNSUPPORTED_RE
54
- export { _shouldFallbackFromDraftTransport as shouldFallbackFromDraftTransport }
55
-
56
39
  /** Called when a late sendMessage/edit resolves after a new turn has started. */
57
40
  export type OnSupersededCallback = (params: {
58
41
  messageId: number
@@ -62,8 +45,6 @@ export type OnSupersededCallback = (params: {
62
45
  export interface AnswerStreamConfig {
63
46
  /** chatId for all API calls */
64
47
  chatId: string
65
- /** True if this is a DM — tries sendMessageDraft first */
66
- isPrivateChat: boolean
67
48
  /** Optional forum thread */
68
49
  threadId?: number
69
50
  /** Minimum chars before opening the answer lane. Default: MIN_INITIAL_CHARS */
@@ -74,16 +55,6 @@ export interface AnswerStreamConfig {
74
55
  replyToMessageId?: number
75
56
 
76
57
  // ── Transport callbacks ────────────────────────────────────────────────
77
- /**
78
- * sendMessageDraft(chatId, draftId, text, params?). Optional — when absent,
79
- * the answer stream falls back immediately to sendMessage+editMessageText.
80
- */
81
- sendMessageDraft?: (
82
- chatId: string,
83
- draftId: number,
84
- text: string,
85
- params?: { message_thread_id?: number; parse_mode?: 'HTML' },
86
- ) => Promise<unknown>
87
58
  sendMessage: (
88
59
  chatId: string,
89
60
  text: string,
@@ -132,7 +103,7 @@ export interface AnswerStreamConfig {
132
103
  * Acceptance #203: answer_lane_update / answer_lane_materialized events.
133
104
  */
134
105
  onMetric?: (ev:
135
- | { kind: 'answer_lane_update'; chatId: string; messageId: number | undefined; charCount: number; transport: 'draft' | 'message' | 'edit' }
106
+ | { kind: 'answer_lane_update'; chatId: string; messageId: number | undefined; charCount: number; transport: 'message' | 'edit' }
136
107
  | { kind: 'answer_lane_materialized'; chatId: string; messageId: number | undefined; suppressed?: boolean }
137
108
  ) => void
138
109
 
@@ -199,19 +170,13 @@ export interface AnswerStreamHandle {
199
170
  retract(): Promise<void>
200
171
  }
201
172
 
202
- // Draft-id allocation now lives in draft-transport.ts (shared with
203
- // draft-stream.ts). Re-alias locally so the rest of this file is unchanged.
204
- const allocateDraftId = _allocateDraftId
205
-
206
173
  export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHandle {
207
174
  const {
208
175
  chatId,
209
- isPrivateChat,
210
176
  threadId,
211
177
  minInitialChars = MIN_INITIAL_CHARS,
212
178
  throttleMs = DEFAULT_THROTTLE_MS,
213
179
  replyToMessageId,
214
- sendMessageDraft: draftApi,
215
180
  sendMessage,
216
181
  editMessageText,
217
182
  onSuperseded,
@@ -225,11 +190,6 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
225
190
 
226
191
  const effectiveThrottle = Math.max(250, throttleMs)
227
192
 
228
- // Draft transport is only used in DMs and only when the API method is available.
229
- const preferDraft = isPrivateChat && draftApi != null
230
- let usesDraftTransport = preferDraft
231
- let draftId = preferDraft ? allocateDraftId() : undefined
232
-
233
193
  // Stream state
234
194
  let streamMsgId: number | undefined
235
195
  let pendingText: string | null = null
@@ -249,62 +209,6 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
249
209
  }
250
210
  }
251
211
 
252
- /**
253
- * Clear the in-progress sendMessageDraft (#1704). When the answer
254
- * stream was using draft transport (DMs), Telegram leaves the draft
255
- * sitting in the user's compose area until something replaces or
256
- * clears it. On Telegram Desktop this blocks the user from typing —
257
- * the compose field is occupied by the bot's draft preview.
258
- *
259
- * Every terminal path on the stream (materialize / stop / retract)
260
- * must clear the draft. Best-effort: a failed clear is logged but
261
- * not re-thrown — the worst case is a transient stale draft that
262
- * Telegram's own 30 s draft expiry eventually mops up.
263
- *
264
- * #1792 — accepts an explicit `targetDraftId` so `forceNewMessage`
265
- * can clear the OLD id before bumping the closure's `draftId`. The
266
- * default reads the live closure, which is what stop() / retract()
267
- * want — clear whatever's current at the time the call lands.
268
- */
269
- async function clearDraftBestEffort(
270
- targetDraftId: number | undefined = draftId,
271
- ): Promise<void> {
272
- if (!usesDraftTransport || draftApi == null || targetDraftId == null) return
273
- try {
274
- const params: { message_thread_id?: number } = {}
275
- if (threadId != null) params.message_thread_id = threadId
276
- await draftApi(
277
- chatId,
278
- targetDraftId,
279
- '',
280
- Object.keys(params).length > 0 ? params : undefined,
281
- )
282
- } catch {
283
- // Best-effort cleanup
284
- }
285
- }
286
-
287
- async function sendDraft(text: string): Promise<boolean> {
288
- if (!draftApi || draftId == null) return false
289
- try {
290
- const params: { message_thread_id?: number } = {}
291
- if (threadId != null) params.message_thread_id = threadId
292
- await draftApi(chatId, draftId, text, Object.keys(params).length > 0 ? params : undefined)
293
- onMetric?.({ kind: 'answer_lane_update', chatId, messageId: streamMsgId, charCount: text.length, transport: 'draft' })
294
- return true
295
- } catch (err) {
296
- if (_shouldFallbackFromDraftTransport(err)) {
297
- warn?.(
298
- `answer-stream: sendMessageDraft rejected — falling back to sendMessage/editMessageText (${err instanceof Error ? err.message : String(err)})`,
299
- )
300
- usesDraftTransport = false
301
- draftId = undefined
302
- return false
303
- }
304
- throw err
305
- }
306
- }
307
-
308
212
  async function sendOrEdit(text: string, gen: number): Promise<void> {
309
213
  if (stopped) return
310
214
  const trimmed = text.trimEnd()
@@ -315,15 +219,6 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
315
219
  lastSentText = trimmed
316
220
 
317
221
  try {
318
- if (usesDraftTransport) {
319
- const ok = await sendDraft(trimmed)
320
- if (!ok) {
321
- // Draft failed with a permanent error → fell back to message transport
322
- // Retry the same text via message transport
323
- await sendOrEditViaMessage(trimmed, gen, prevText)
324
- }
325
- return
326
- }
327
222
  await sendOrEditViaMessage(trimmed, gen, prevText)
328
223
  } catch (err) {
329
224
  // Log but don't crash — transient errors are common
@@ -419,7 +314,7 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
419
314
  if (!trimmed) return
420
315
 
421
316
  // minInitialChars gate: don't open the lane yet
422
- if (streamMsgId == null && !usesDraftTransport && trimmed.length < minInitialChars) return
317
+ if (streamMsgId == null && trimmed.length < minInitialChars) return
423
318
 
424
319
  pendingText = trimmed
425
320
 
@@ -460,9 +355,6 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
460
355
  try { await inFlight } catch { /* ignore */ }
461
356
  }
462
357
 
463
- // Clear draft so Telegram input area doesn't show stale text
464
- await clearDraftBestEffort()
465
-
466
358
  // The text we want to materialize. Prefer pendingText (most recent
467
359
  // snapshot from the model) over lastSentText (what last reached the
468
360
  // wire). They usually match, but if a buffered update was scheduled
@@ -527,7 +419,7 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
527
419
  purpose: 'materialize',
528
420
  }
529
421
  if (threadId != null) sendParams.message_thread_id = threadId
530
- // Don't quote-reply on materialize — the draft stream already established
422
+ // Don't quote-reply on materialize — the stream already established
531
423
  // the reply context visually. A second reply_parameters would create a
532
424
  // nested quote that looks wrong.
533
425
 
@@ -563,21 +455,6 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
563
455
  pendingText = null
564
456
  stopped = false
565
457
  materialized = false
566
- if (usesDraftTransport) {
567
- // #1792: clear the OLD draftId BEFORE rotating. Otherwise the
568
- // stale content stays in the user's compose box until the 30 s
569
- // Telegram draft expiry — the typical caller (gateway.ts mid-
570
- // turn rapid-steer path: `forceNewMessage(); stop();`) cleans
571
- // up the prior turn's stream, so the prior draft's content is
572
- // semantically retracted. Fire-and-forget — forceNewMessage is
573
- // sync; the worst-case failure mode is the same 30 s expiry
574
- // we'd have had without the call.
575
- const staleDraftId = draftId
576
- if (staleDraftId != null) {
577
- void clearDraftBestEffort(staleDraftId)
578
- }
579
- draftId = allocateDraftId()
580
- }
581
458
  log?.(`answer-stream: forceNewMessage (gen=${generation})`)
582
459
  },
583
460
 
@@ -588,14 +465,6 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
588
465
  stop(): void {
589
466
  stopped = true
590
467
  cancelScheduled()
591
- // #1704: clear the compose-box draft. stop() is sync — fire and
592
- // forget. A dropped clear falls back on Telegram's own 30 s
593
- // draft expiry; the worst case is a transient stale preview.
594
- // (#1792: the stale-id-after-rotation hazard is owned by
595
- // forceNewMessage itself now — it clears its own draftId before
596
- // rotating. stop() just clears whatever's current; clearing an
597
- // already-cleared or never-used id is a harmless no-op.)
598
- void clearDraftBestEffort()
599
468
  },
600
469
 
601
470
  async retract(): Promise<void> {
@@ -607,14 +476,6 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
607
476
  if (inFlight) {
608
477
  try { await inFlight } catch { /* ignore */ }
609
478
  }
610
- // #1704: clear the compose-box draft when this stream was using
611
- // draft transport. Without this, Telegram Desktop leaves the
612
- // draft sitting in the user's input area and blocks them from
613
- // typing until the 30 s draft expiry. Awaited so a follow-up
614
- // sendMessage on the same chat doesn't race a stale draft edit.
615
- // (See #1792 note in stop() — forceNewMessage owns its own stale
616
- // id cleanup; retract just clears whatever's current.)
617
- await clearDraftBestEffort()
618
479
  // Delete the preliminary message if one was sent and deleteMessage
619
480
  // is wired. Best-effort: failures are logged but not re-thrown.
620
481
  const msgId = streamMsgId
@@ -633,9 +494,3 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
633
494
  },
634
495
  }
635
496
  }
636
-
637
- /** Reset the draft-id counter for tests. */
638
- /** Reset the shared draft-id counter for tests. Delegates to draft-transport.ts. */
639
- export function __resetDraftIdForTests(): void {
640
- _resetDraftIdForTests()
641
- }