switchroom 0.14.66 → 0.14.68

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.
@@ -409,7 +409,7 @@
409
409
  <button id="tab-agents" onclick="switchTab('agents')">Agents</button>
410
410
  <button id="tab-accounts" onclick="switchTab('accounts')">Accounts</button>
411
411
  <button id="tab-system" onclick="switchTab('system')">System</button>
412
- <button id="tab-google" onclick="switchTab('google')">Google</button>
412
+ <button id="tab-connections" onclick="switchTab('connections')">Connections</button>
413
413
  <button id="tab-schedule" onclick="switchTab('schedule')">Schedule</button>
414
414
  <button id="tab-approvals" onclick="switchTab('approvals')">Approvals</button>
415
415
  </nav>
@@ -419,7 +419,7 @@
419
419
  <div id="agents" style="display:none" class="loading">Loading agents...</div>
420
420
  <div id="accounts" style="display:none"></div>
421
421
  <div id="system" style="display:none"></div>
422
- <div id="google" style="display:none"></div>
422
+ <div id="connections" style="display:none"></div>
423
423
  <div id="schedule" style="display:none"></div>
424
424
  <div id="approvals" style="display:none"></div>
425
425
  </main>
@@ -493,14 +493,17 @@
493
493
  }
494
494
  }
495
495
 
496
- async function fetchGoogleAccounts() {
496
+ async function fetchConnections() {
497
497
  try {
498
- const res = await fetch(`${API}/api/google-accounts`, { headers: authHeaders() });
499
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
500
- renderGoogleAccounts(await res.json());
498
+ const [google, microsoft, notion] = await Promise.all([
499
+ fetch(`${API}/api/google-accounts`, { headers: authHeaders() }).then(r => r.ok ? r.json() : []),
500
+ fetch(`${API}/api/microsoft-accounts`, { headers: authHeaders() }).then(r => r.ok ? r.json() : []),
501
+ fetch(`${API}/api/notion-workspace`, { headers: authHeaders() }).then(r => r.ok ? r.json() : { configured: false, databases: [] }),
502
+ ]);
503
+ renderConnections({ google, microsoft, notion });
501
504
  clearError();
502
505
  } catch (err) {
503
- showError(`Failed to fetch Google accounts: ${err.message}`);
506
+ showError(`Failed to fetch connections: ${err.message}`);
504
507
  }
505
508
  }
506
509
 
@@ -527,7 +530,7 @@
527
530
  }
528
531
 
529
532
  function switchTab(tab) {
530
- const tabs = ['summary', 'agents', 'accounts', 'system', 'google', 'schedule', 'approvals'];
533
+ const tabs = ['summary', 'agents', 'accounts', 'system', 'connections', 'schedule', 'approvals'];
531
534
  for (const t of tabs) {
532
535
  document.getElementById(`tab-${t}`).classList.toggle('active', tab === t);
533
536
  document.getElementById(t).style.display = tab === t ? '' : 'none';
@@ -535,7 +538,7 @@
535
538
  if (tab === 'summary') fetchSummary();
536
539
  if (tab === 'accounts') fetchAccounts();
537
540
  if (tab === 'system') fetchSystemHealth();
538
- if (tab === 'google') fetchGoogleAccounts();
541
+ if (tab === 'connections') fetchConnections();
539
542
  if (tab === 'schedule') fetchSchedule();
540
543
  if (tab === 'approvals') fetchApprovals();
541
544
  }
@@ -988,37 +991,99 @@
988
991
  return String(ts).replace('T', ' ').replace(/\.\d+Z?$/, '').replace(/Z$/, '');
989
992
  }
990
993
 
991
- function renderGoogleAccounts(rows) {
992
- const container = document.getElementById('google');
993
- if (!rows || rows.length === 0) {
994
- container.innerHTML = '<div class="loading">No Google accounts. Add one under <code>google_accounts:</code> in switchroom.yaml and run <code>switchroom auth google account add</code>.</div>';
995
- return;
994
+ const _dimC = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
995
+
996
+ // One OAuth-account card (Google or Microsoft same shape; Microsoft
997
+ // adds an account-type pill).
998
+ function renderOAuthAccountCard(a, opts) {
999
+ const expires = a.expiresAt ? formatTimestamp(a.expiresAt) : _dimC('—');
1000
+ const known = a.brokerKnown
1001
+ ? '<span class="usage-pill primary">slot present</span>'
1002
+ : '<span style="color:var(--yellow)">config-only (no broker slot)</span>';
1003
+ const acl = (a.enabledFor && a.enabledFor.length)
1004
+ ? a.enabledFor.map(escapeHtml).join(', ')
1005
+ : _dimC('no agents enabled');
1006
+ const typePill = (opts && opts.showType && a.accountType)
1007
+ ? `<span class="usage-pill" style="margin-left:.4rem">${escapeHtml(a.accountType)}</span>`
1008
+ : '';
1009
+ return `
1010
+ <div class="account-card">
1011
+ <div class="account-card-header">
1012
+ <div class="account-label">${escapeHtml(a.account)}${typePill}</div>
1013
+ <span style="margin-left:auto">${known}</span>
1014
+ </div>
1015
+ <div class="account-usage"><label style="color:var(--text-dim);opacity:.7">Enabled for: </label>${acl}</div>
1016
+ <div class="card-meta" style="padding:0">
1017
+ <div class="meta-item"><label>Expires </label><span>${expires}</span></div>
1018
+ <div class="meta-item" title="${a.scope ? escapeHtml(a.scope) : ''}"><label>Scope </label><span>${a.scope ? escapeHtml(a.scope.split(' ').length + ' scope(s)') : _dimC('—')}</span></div>
1019
+ <div class="meta-item"><label>Client </label><span>${a.clientId ? escapeHtml(a.clientId.slice(0, 16) + '…') : _dimC('—')}</span></div>
1020
+ </div>
1021
+ </div>`;
1022
+ }
1023
+
1024
+ function _connectionSection(title, emptyHtml, cardsHtml) {
1025
+ return `
1026
+ <div style="margin-bottom:1.5rem">
1027
+ <h3 style="margin:0 0 .6rem;font-size:.95rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.04em">${title}</h3>
1028
+ ${cardsHtml ? `<div class="accounts-grid">${cardsHtml}</div>` : `<div class="loading" style="padding:.8rem">${emptyHtml}</div>`}
1029
+ </div>`;
1030
+ }
1031
+
1032
+ // Unified external-connections view: Google + Microsoft accounts and
1033
+ // the Notion workspace, each showing which agents have access.
1034
+ function renderConnections(data) {
1035
+ const container = document.getElementById('connections');
1036
+ const google = data.google || [];
1037
+ const microsoft = data.microsoft || [];
1038
+ const notion = data.notion || { configured: false, databases: [] };
1039
+
1040
+ const googleSection = _connectionSection(
1041
+ 'Google',
1042
+ 'No Google accounts. Add one under <code>google_accounts:</code> and run <code>switchroom auth google account add</code>.',
1043
+ google.map(a => renderOAuthAccountCard(a, { showType: false })).join(''),
1044
+ );
1045
+
1046
+ const microsoftSection = _connectionSection(
1047
+ 'Microsoft 365',
1048
+ 'No Microsoft accounts. Connect one from Telegram with <code>/connect microsoft</code> (admin DM), or <code>switchroom auth microsoft account add</code>.',
1049
+ microsoft.map(a => renderOAuthAccountCard(a, { showType: true })).join(''),
1050
+ );
1051
+
1052
+ let notionCards = '';
1053
+ if (notion.configured) {
1054
+ notionCards = (notion.databases || []).map(db => {
1055
+ const acl = (db.enabledFor && db.enabledFor.length)
1056
+ ? db.enabledFor.map(escapeHtml).join(', ')
1057
+ : _dimC('no agents enabled');
1058
+ return `
1059
+ <div class="account-card">
1060
+ <div class="account-card-header">
1061
+ <div class="account-label">${escapeHtml(db.name)}</div>
1062
+ <span style="margin-left:auto" class="usage-pill">database</span>
1063
+ </div>
1064
+ <div class="account-usage"><label style="color:var(--text-dim);opacity:.7">Enabled for: </label>${acl}</div>
1065
+ <div class="card-meta" style="padding:0">
1066
+ <div class="meta-item"><label>DB id </label><span>${escapeHtml(db.id.slice(0, 12))}…</span></div>
1067
+ </div>
1068
+ </div>`;
1069
+ }).join('');
996
1070
  }
997
- const dim = (s) => `<span style="color:var(--text-dim)">${escapeHtml(s)}</span>`;
998
- const cards = rows.map(a => {
999
- const expires = a.expiresAt ? formatTimestamp(a.expiresAt) : dim('');
1000
- const scope = a.scope ? escapeHtml(a.scope) : dim('broker offline');
1001
- const known = a.brokerKnown
1002
- ? '<span class="usage-pill primary">slot present</span>'
1003
- : '<span style="color:var(--yellow)">config-only (no broker slot)</span>';
1004
- const acl = (a.enabledFor && a.enabledFor.length)
1005
- ? a.enabledFor.map(escapeHtml).join(', ')
1006
- : dim('no agents enabled');
1007
- return `
1008
- <div class="account-card">
1009
- <div class="account-card-header">
1010
- <div class="account-label">${escapeHtml(a.account)}</div>
1011
- <span style="margin-left:auto">${known}</span>
1012
- </div>
1013
- <div class="account-usage"><label style="color:var(--text-dim);opacity:.7">Enabled for: </label>${acl}</div>
1014
- <div class="card-meta" style="padding:0">
1015
- <div class="meta-item"><label>Expires </label><span>${expires}</span></div>
1016
- <div class="meta-item" title="${a.scope ? escapeHtml(a.scope) : ''}"><label>Scope </label><span>${a.scope ? escapeHtml(a.scope.split(' ').length + ' scope(s)') : dim('—')}</span></div>
1017
- <div class="meta-item"><label>Client </label><span>${a.clientId ? escapeHtml(a.clientId.slice(0, 16) + '…') : dim('—')}</span></div>
1018
- </div>
1071
+ const notionTitle = 'Notion' + (notion.configured && notion.vaultKey ? ` <span style="text-transform:none;font-weight:400">· token <code>${escapeHtml(notion.vaultKey)}</code></span>` : '');
1072
+ const fullAccessBanner = (notion.fullAccessAgents && notion.fullAccessAgents.length)
1073
+ ? `<div style="margin:0 0 .6rem;font-size:.85rem;color:var(--text-dim)">Full access (all databases): ${notion.fullAccessAgents.map(escapeHtml).join(', ')}</div>`
1074
+ : '';
1075
+ const notionBody = notionCards
1076
+ ? `<div class="accounts-grid">${notionCards}</div>`
1077
+ : `<div class="loading" style="padding:.8rem">${notion.configured
1078
+ ? 'Notion configured but no databases declared. Add them under <code>notion_workspace.databases:</code>.'
1079
+ : 'Notion not configured. See <code>docs/notion-integration.md</code>.'}</div>`;
1080
+ const notionSection = `
1081
+ <div style="margin-bottom:1.5rem">
1082
+ <h3 style="margin:0 0 .6rem;font-size:.95rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.04em">${notionTitle}</h3>
1083
+ ${fullAccessBanner}${notionBody}
1019
1084
  </div>`;
1020
- }).join('');
1021
- container.innerHTML = `<div class="accounts-grid">${cards}</div>`;
1085
+
1086
+ container.innerHTML = googleSection + microsoftSection + notionSection;
1022
1087
  }
1023
1088
 
1024
1089
  function renderSchedule(data) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.66",
3
+ "version": "0.14.68",
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": {
@@ -16,3 +16,22 @@ export function parseVisibleAnswerStreamEnabled(raw: string | undefined): boolea
16
16
  const v = raw.trim().toLowerCase()
17
17
  return v === '1' || v === 'true' || v === 'on' || v === 'yes'
18
18
  }
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
+ }
@@ -39034,6 +39034,13 @@ function noteOutbound2(key, now) {
39034
39034
  s.lastOutboundAt = now;
39035
39035
  s.fallbackFired = false;
39036
39036
  }
39037
+ function noteProduction(key, now) {
39038
+ const s = state2.get(key);
39039
+ if (s == null)
39040
+ return;
39041
+ s.lastOutboundAt = now;
39042
+ s.fallbackFired = false;
39043
+ }
39037
39044
  function noteThinking(key, now) {
39038
39045
  const s = state2.get(key);
39039
39046
  if (s == null)
@@ -39754,6 +39761,12 @@ function parseVisibleAnswerStreamEnabled(raw) {
39754
39761
  const v = raw.trim().toLowerCase();
39755
39762
  return v === "1" || v === "true" || v === "on" || v === "yes";
39756
39763
  }
39764
+ function parseDraftLaneRetiredEnabled(raw) {
39765
+ if (raw == null)
39766
+ return true;
39767
+ const v = raw.trim().toLowerCase();
39768
+ return !(v === "0" || v === "false" || v === "off" || v === "no");
39769
+ }
39757
39770
 
39758
39771
  // pty-tail.ts
39759
39772
  var import_headless = __toESM(require_xterm_headless(), 1);
@@ -52763,11 +52776,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
52763
52776
  }
52764
52777
 
52765
52778
  // ../src/build-info.ts
52766
- var VERSION = "0.14.66";
52767
- var COMMIT_SHA = "0f4f029d";
52768
- var COMMIT_DATE = "2026-06-05T07:05:45Z";
52769
- var LATEST_PR = 2167;
52770
- var COMMITS_AHEAD_OF_TAG = 2;
52779
+ var VERSION = "0.14.68";
52780
+ var COMMIT_SHA = "4f371b79";
52781
+ var COMMIT_DATE = "2026-06-05T10:24:42Z";
52782
+ var LATEST_PR = 2174;
52783
+ var COMMITS_AHEAD_OF_TAG = 3;
52771
52784
 
52772
52785
  // gateway/boot-version.ts
52773
52786
  function formatRelativeAgo(iso) {
@@ -53567,6 +53580,7 @@ var TOPIC_ID = process.env.TELEGRAM_TOPIC_ID ? Number(process.env.TELEGRAM_TOPIC
53567
53580
  var AGENT_ADMIN = process.env.SWITCHROOM_AGENT_ADMIN === "true";
53568
53581
  var bot = new import_grammy9.Bot(TOKEN);
53569
53582
  installTgPostLogger(bot);
53583
+ var DRAFT_ANSWER_LANE_RETIRED = parseDraftLaneRetiredEnabled(process.env.SWITCHROOM_DRAFT_ANSWER_LANE);
53570
53584
  var _rawSendMessageDraft = bot.api.raw.sendMessageDraft;
53571
53585
  var GRAMMY_VERSION = (() => {
53572
53586
  try {
@@ -53576,7 +53590,7 @@ var GRAMMY_VERSION = (() => {
53576
53590
  return "unknown";
53577
53591
  }
53578
53592
  })();
53579
- var sendMessageDraftFn = typeof _rawSendMessageDraft === "function" ? (chatId, draftId, text, params) => _rawSendMessageDraft({
53593
+ var sendMessageDraftFn = !DRAFT_ANSWER_LANE_RETIRED && typeof _rawSendMessageDraft === "function" ? (chatId, draftId, text, params) => _rawSendMessageDraft({
53580
53594
  chat_id: Number(chatId),
53581
53595
  draft_id: draftId,
53582
53596
  text,
@@ -54075,7 +54089,7 @@ function findLatestEndedTurnForChat(chatId) {
54075
54089
  return latest;
54076
54090
  }
54077
54091
  function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, liveTurn, surface) {
54078
- const recovered = LATE_REPLY_TOPIC_RECOVERY_ENABLED && explicitThreadId == null && originTurn == null && liveTurn?.sessionThreadId == null ? findLatestEndedTurnForChat(chatId) : null;
54092
+ const recovered = LATE_REPLY_TOPIC_RECOVERY_ENABLED && explicitThreadId == null && originTurn == null && liveTurn == null ? findLatestEndedTurnForChat(chatId) : null;
54079
54093
  const threadId = resolveAnswerThreadId({
54080
54094
  explicitThreadId,
54081
54095
  originResolved: originTurn != null,
@@ -55260,6 +55274,7 @@ function parsePositiveMsEnv(name, fallbackMs) {
55260
55274
  var SILENCE_FALLBACK_MS = parsePositiveMsEnv("SWITCHROOM_SILENCE_FALLBACK_MS", 300000);
55261
55275
  var SILENCE_FALLBACK_HARD_MS = parsePositiveMsEnv("SWITCHROOM_SILENCE_FALLBACK_HARD_MS", 900000);
55262
55276
  var SILENCE_DEFER_INFLIGHT_TOOLS = process.env.SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS === "1";
55277
+ var SILENCE_LIVENESS_PRODUCTION = process.env.SWITCHROOM_SILENCE_LIVENESS_PRODUCTION !== "0";
55263
55278
  startTimer({
55264
55279
  thresholdsMs: { fallback: SILENCE_FALLBACK_MS, fallbackHardCeiling: SILENCE_FALLBACK_HARD_MS },
55265
55280
  deferFallbackWhileToolInFlight: SILENCE_DEFER_INFLIGHT_TOOLS,
@@ -55351,8 +55366,11 @@ startTimer({
55351
55366
  const sib = silenceMsForKey(siblingKey, fbNow);
55352
55367
  return sib == null || sib >= DEFAULT_THRESHOLDS.fallback;
55353
55368
  });
55354
- if (turnMatchesFallback && currentTurn === wedgedTurn)
55369
+ if (turnMatchesFallback && currentTurn === wedgedTurn && wedgedTurn != null) {
55370
+ process.stderr.write(`telegram gateway: ${formatTurnLifecycle("clear", "silence_fallback", wedgedTurn, Date.now())}
55371
+ `);
55355
55372
  currentTurn = null;
55373
+ }
55356
55374
  try {
55357
55375
  clearSilentEndState(fbKey);
55358
55376
  } catch {}
@@ -58076,6 +58094,9 @@ function handleSessionEvent(ev) {
58076
58094
  const rendered = appendActivityLabel(turn.mirrorLines, ev.label);
58077
58095
  if (rendered != null) {
58078
58096
  turn.lastToolLabelAt = Date.now();
58097
+ if (SILENCE_LIVENESS_PRODUCTION && currentTurn === turn) {
58098
+ noteProduction(statusKey(turn.sessionChatId, turn.sessionThreadId), Date.now());
58099
+ }
58079
58100
  turn.activityPendingRender = composeTurnActivity(turn) ?? rendered;
58080
58101
  if (turn.activityInFlight == null) {
58081
58102
  turn.activityInFlight = drainActivitySummary(turn);
@@ -58092,7 +58113,7 @@ function handleSessionEvent(ev) {
58092
58113
  chatId: turn.sessionChatId,
58093
58114
  isPrivateChat: turn.isDm,
58094
58115
  threadId: turn.sessionThreadId,
58095
- ...ANSWER_STREAM_VISIBLE_ENABLED ? { minInitialChars: 1 } : { sendMessageDraft: sendMessageDraftFn, minInitialChars: Number.MAX_SAFE_INTEGER },
58116
+ ...ANSWER_STREAM_VISIBLE_ENABLED || DRAFT_ANSWER_LANE_RETIRED ? { minInitialChars: 1 } : { sendMessageDraft: sendMessageDraftFn, minInitialChars: Number.MAX_SAFE_INTEGER },
58096
58117
  sendMessage: async (chatId, text, params) => {
58097
58118
  const tid = params?.message_thread_id;
58098
58119
  const silent = params?.purpose !== "materialize";
@@ -58130,6 +58151,9 @@ function handleSessionEvent(ev) {
58130
58151
  logStreamingEvent(metricEv);
58131
58152
  if (currentTurn === turn) {
58132
58153
  noteSignal(statusKey(turn.sessionChatId, turn.sessionThreadId), Date.now());
58154
+ if (SILENCE_LIVENESS_PRODUCTION) {
58155
+ noteProduction(statusKey(turn.sessionChatId, turn.sessionThreadId), Date.now());
58156
+ }
58133
58157
  }
58134
58158
  },
58135
58159
  checkDedup: (text) => {
@@ -58221,7 +58245,7 @@ function handleSessionEvent(ev) {
58221
58245
  const stream = turn.answerStream;
58222
58246
  const streamedMsgId = stream.messageId();
58223
58247
  const streamedFinalText = turn.capturedText.join("").trim();
58224
- if (ANSWER_STREAM_VISIBLE_ENABLED && !turn.replyCalled && streamedMsgId != null && streamedFinalText.length > 0) {
58248
+ if ((ANSWER_STREAM_VISIBLE_ENABLED || DRAFT_ANSWER_LANE_RETIRED) && !turn.replyCalled && streamedMsgId != null && streamedFinalText.length > 0) {
58225
58249
  turn.answerStream = null;
58226
58250
  streamFinalizedAsAnswer = true;
58227
58251
  turn.finalAnswerDelivered = true;
@@ -64557,7 +64581,7 @@ var didOneTimeSetup = false;
64557
64581
  }
64558
64582
  }
64559
64583
  }
64560
- process.stderr.write(`telegram gateway: answer-stream draft transport=${sendMessageDraftFn != null ? "available" : "unavailable"} grammy=${GRAMMY_VERSION}
64584
+ process.stderr.write(`telegram gateway: answer-stream lane=${DRAFT_ANSWER_LANE_RETIRED ? "visible(draft-retired)" : ANSWER_STREAM_VISIBLE_ENABLED ? "visible" : "draft"} draftFn=${sendMessageDraftFn != null ? "available" : "off"} grammy=${GRAMMY_VERSION}
64561
64585
  `);
64562
64586
  process.stderr.write(`telegram gateway: starting bot polling pid=${process.pid} agent=${process.env.SWITCHROOM_AGENT_NAME ?? "-"} stateDir=${STATE_DIR} historyEnabled=${HISTORY_ENABLED} streamMode=${process.env.SWITCHROOM_TG_STREAM_MODE ?? "checklist"}
64563
64587
  `);
@@ -98,7 +98,7 @@ import * as pendingProgress from '../pending-work-progress.js'
98
98
  import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd } from '../silent-end.js'
99
99
  import { isFinalAnswerReply, isSubstantiveFinalReply } from '../final-answer-detect.js'
100
100
  import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
101
- import { parseVisibleAnswerStreamEnabled } from '../answer-stream-flag.js'
101
+ import { parseVisibleAnswerStreamEnabled, parseDraftLaneRetiredEnabled } from '../answer-stream-flag.js'
102
102
  import { type SessionEvent } from '../session-tail.js'
103
103
  import {
104
104
  shouldSuppressToolActivity,
@@ -678,6 +678,14 @@ const AGENT_ADMIN = process.env.SWITCHROOM_AGENT_ADMIN === 'true'
678
678
  const bot = new Bot(TOKEN)
679
679
  installTgPostLogger(bot)
680
680
 
681
+ // Draft-answer-lane retirement (2026-06-05): default RETIRED so the live answer
682
+ // lane uses a real, mtcute-observable message instead of the invisible
683
+ // compose-box draft. Declared HERE (above the boot-probe block) because
684
+ // `sendMessageDraftFn` below reads it — keep it above its first use to avoid a
685
+ // temporal-dead-zone ReferenceError at boot. Kill switch
686
+ // SWITCHROOM_DRAFT_ANSWER_LANE=0 restores the legacy draft.
687
+ const DRAFT_ANSWER_LANE_RETIRED = parseDraftLaneRetiredEnabled(process.env.SWITCHROOM_DRAFT_ANSWER_LANE)
688
+
681
689
  // ─── sendMessageDraft boot probe ──────────────────────────────────────────
682
690
  // grammY 1.x exposes all Telegram Bot API methods through bot.api.raw.
683
691
  // bot.api.sendMessageDraft (the typed wrapper) takes chat_id as number, but
@@ -695,7 +703,11 @@ const GRAMMY_VERSION: string = (() => {
695
703
  const sendMessageDraftFn: (
696
704
  (chatId: string, draftId: number, text: string, params?: { message_thread_id?: number; parse_mode?: 'HTML' }) => Promise<unknown>
697
705
  ) | undefined =
698
- typeof _rawSendMessageDraft === 'function'
706
+ // When the draft lane is retired (default), force this undefined so BOTH
707
+ // consumers (the answer-stream config + the stream_reply handler) drop the
708
+ // draft transport and fall back to visible message transport — the single
709
+ // chokepoint for the retirement.
710
+ !DRAFT_ANSWER_LANE_RETIRED && typeof _rawSendMessageDraft === 'function'
699
711
  ? (chatId, draftId, text, params) =>
700
712
  (_rawSendMessageDraft as (args: Record<string, unknown>) => Promise<unknown>)({
701
713
  chat_id: Number(chatId),
@@ -1930,11 +1942,17 @@ function resolveAnswerThreadWithLog(
1930
1942
  liveTurn: CurrentTurn | null,
1931
1943
  surface: 'reply' | 'stream_reply',
1932
1944
  ): number | undefined {
1945
+ // Recover ONLY for a genuinely LATE reply — no live turn at all. Gating on
1946
+ // `liveTurn?.sessionThreadId == null` (the original) also fired for a
1947
+ // threadless DM that still had a live turn, marking every DM reply
1948
+ // `via=recovered`/RECOVERED in the telemetry (routing result unchanged —
1949
+ // DM → undefined — but it drowned the real supergroup recoveries the marker
1950
+ // exists to surface). `liveTurn == null` is the precise late-reply condition.
1933
1951
  const recovered =
1934
1952
  LATE_REPLY_TOPIC_RECOVERY_ENABLED &&
1935
1953
  explicitThreadId == null &&
1936
1954
  originTurn == null &&
1937
- liveTurn?.sessionThreadId == null
1955
+ liveTurn == null
1938
1956
  ? findLatestEndedTurnForChat(chatId)
1939
1957
  : null
1940
1958
  const threadId = resolveAnswerThreadId({
@@ -4673,6 +4691,12 @@ function parsePositiveMsEnv(name: string, fallbackMs: number): number {
4673
4691
  const SILENCE_FALLBACK_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FALLBACK_MS', 300_000)
4674
4692
  const SILENCE_FALLBACK_HARD_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FALLBACK_HARD_MS', 900_000)
4675
4693
  const SILENCE_DEFER_INFLIGHT_TOOLS = process.env.SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS === '1'
4694
+ // Production-liveness (2026-06-05 UAT finding). Count an activity-feed render or
4695
+ // an answer-stream draft update as liveness for the silence clock, so a long
4696
+ // tool/composition turn that's visibly producing doesn't trip the 300s fallback
4697
+ // and null currentTurn mid-work. Default ON; SWITCHROOM_SILENCE_LIVENESS_PRODUCTION=0
4698
+ // restores the legacy "only a real reply resets the clock" behaviour.
4699
+ const SILENCE_LIVENESS_PRODUCTION = process.env.SWITCHROOM_SILENCE_LIVENESS_PRODUCTION !== '0'
4676
4700
 
4677
4701
  silencePoke.startTimer({
4678
4702
  thresholdsMs: { fallback: SILENCE_FALLBACK_MS, fallbackHardCeiling: SILENCE_FALLBACK_HARD_MS },
@@ -4889,7 +4913,16 @@ silencePoke.startTimer({
4889
4913
  // returns null and the regular teardown short-circuits. Without
4890
4914
  // this, the late event would re-emit `turn_ended` AND clobber
4891
4915
  // whatever fresh turn the next inbound started.
4892
- if (turnMatchesFallback && currentTurn === wedgedTurn) currentTurn = null
4916
+ if (turnMatchesFallback && currentTurn === wedgedTurn && wedgedTurn != null) {
4917
+ // Status-surface observability: emit the lifecycle CLEAR for the
4918
+ // silence-poke teardown so a fallback-nulled turn has a turn-lifecycle
4919
+ // line like every other clear path (the framework-fallback line below is
4920
+ // its own format — this makes the dark-out greppable in the same shape).
4921
+ process.stderr.write(
4922
+ `telegram gateway: ${formatTurnLifecycle('clear', 'silence_fallback', wedgedTurn, Date.now())}\n`,
4923
+ )
4924
+ currentTurn = null
4925
+ }
4893
4926
  // Best-effort: clear any pending silent-end marker so the Stop hook
4894
4927
  // doesn't double-block when claude eventually exits the wedged turn.
4895
4928
  try {
@@ -9452,6 +9485,16 @@ function handleSessionEvent(ev: SessionEvent): void {
9452
9485
  // the " · Ns" elapsed restarts from this step (and the feed itself just
9453
9486
  // advanced, so it isn't stale).
9454
9487
  turn.lastToolLabelAt = Date.now()
9488
+ // Production-liveness: a NEW model-driven activity label is genuine
9489
+ // liveness (the model emitted a new step), so reset the silence-poke
9490
+ // clock — this is the safe site, NOT drainActivitySummary, because the
9491
+ // framework feedHeartbeatTick also drains (climbing-elapsed re-renders)
9492
+ // and would falsely reset the clock forever on a hung-mid-tool turn,
9493
+ // reintroducing the #1556 dangling-turn wedge. Only the model emitting a
9494
+ // fresh label reaches here.
9495
+ if (SILENCE_LIVENESS_PRODUCTION && currentTurn === turn) {
9496
+ silencePoke.noteProduction(statusKey(turn.sessionChatId, turn.sessionThreadId), Date.now())
9497
+ }
9455
9498
  // Recompose so any active foreground sub-agent's nested block (Model A)
9456
9499
  // is preserved when the parent appends its own step. composeTurnActivity
9457
9500
  // == the flat render when no foreground sub-agent is active.
@@ -9514,7 +9557,13 @@ function handleSessionEvent(ev: SessionEvent): void {
9514
9557
  // General). With the gate unreachable the only posted message is
9515
9558
  // the canonical reply. (The gate is bypassed for DM draft
9516
9559
  // transport, so DM draft streaming is unaffected.)
9517
- ...(ANSWER_STREAM_VISIBLE_ENABLED
9560
+ // Draft retired (default) OR visible explicitly on → a real
9561
+ // edit-in-place message (minInitialChars:1, no draft): observable by
9562
+ // the UAT and the onMetric silence-liveness reset fires on visible
9563
+ // sends in DMs AND supergroups. Legacy draft only when the kill
9564
+ // switch re-enables it (DRAFT_ANSWER_LANE_RETIRED=false), which also
9565
+ // restores sendMessageDraftFn above.
9566
+ ...(ANSWER_STREAM_VISIBLE_ENABLED || DRAFT_ANSWER_LANE_RETIRED
9518
9567
  ? { minInitialChars: 1 }
9519
9568
  : { sendMessageDraft: sendMessageDraftFn, minInitialChars: Number.MAX_SAFE_INTEGER }),
9520
9569
  // #1075: route through robustApiCall so flood-wait,
@@ -9612,6 +9661,15 @@ function handleSessionEvent(ev: SessionEvent): void {
9612
9661
  statusKey(turn.sessionChatId, turn.sessionThreadId),
9613
9662
  Date.now(),
9614
9663
  )
9664
+ // Production-liveness: a draft update is the agent visibly
9665
+ // composing — reset the silence-poke clock so a long
9666
+ // compose-only turn (no tools, no reply yet) isn't torn down.
9667
+ if (SILENCE_LIVENESS_PRODUCTION) {
9668
+ silencePoke.noteProduction(
9669
+ statusKey(turn.sessionChatId, turn.sessionThreadId),
9670
+ Date.now(),
9671
+ )
9672
+ }
9615
9673
  }
9616
9674
  },
9617
9675
  // #646 — wire the shared outboundDedup into the answer-stream
@@ -9795,7 +9853,13 @@ function handleSessionEvent(ev: SessionEvent): void {
9795
9853
  const streamedMsgId = stream.messageId()
9796
9854
  const streamedFinalText = turn.capturedText.join('').trim()
9797
9855
  if (
9798
- ANSWER_STREAM_VISIBLE_ENABLED
9856
+ // Broadened for draft retirement: a text-only no-reply turn that
9857
+ // streamed a VISIBLE preview must materialize a pinged final answer +
9858
+ // delete the preview. Without this, the retired-default path would
9859
+ // fall into the else-branch retract() and delete the user's only copy
9860
+ // of the answer (a lost-answer bug). The reply-tool branch still hits
9861
+ // retract() → single canonical formatted reply, no flash.
9862
+ (ANSWER_STREAM_VISIBLE_ENABLED || DRAFT_ANSWER_LANE_RETIRED)
9799
9863
  && !turn.replyCalled
9800
9864
  && streamedMsgId != null
9801
9865
  && streamedFinalText.length > 0
@@ -20525,7 +20589,7 @@ void (async () => {
20525
20589
  }
20526
20590
  }
20527
20591
 
20528
- process.stderr.write(`telegram gateway: answer-stream draft transport=${sendMessageDraftFn != null ? 'available' : 'unavailable'} grammy=${GRAMMY_VERSION}\n`)
20592
+ process.stderr.write(`telegram gateway: answer-stream lane=${DRAFT_ANSWER_LANE_RETIRED ? 'visible(draft-retired)' : (ANSWER_STREAM_VISIBLE_ENABLED ? 'visible' : 'draft')} draftFn=${sendMessageDraftFn != null ? 'available' : 'off'} grammy=${GRAMMY_VERSION}\n`)
20529
20593
  process.stderr.write(`telegram gateway: starting bot polling pid=${process.pid} agent=${process.env.SWITCHROOM_AGENT_NAME ?? '-'} stateDir=${STATE_DIR} historyEnabled=${HISTORY_ENABLED} streamMode=${process.env.SWITCHROOM_TG_STREAM_MODE ?? 'checklist'}\n`)
20530
20594
  runnerHandle = run(bot, {
20531
20595
  runner: {
@@ -196,6 +196,31 @@ export function noteOutbound(key: string, now: number): void {
196
196
  s.fallbackFired = false
197
197
  }
198
198
 
199
+ /**
200
+ * Record observable PRODUCTION that isn't a final reply — an activity-feed
201
+ * render (`→/✓` edit-in-place message) or an answer-stream draft update. Resets
202
+ * the silence clock exactly like a reply.
203
+ *
204
+ * Why this exists (2026-06-05): the header's "only a real reply counts; tool
205
+ * churn / the model ripping through 20 tool calls is still SILENT to the user"
206
+ * rule predates the live activity feed (#2162) and the compose draft. Those
207
+ * surfaces ARE user-visible now, so a turn actively rendering them is NOT
208
+ * silent — yet the 300s fallback (which nulls `currentTurn` and kills the very
209
+ * feed/draft the user is watching) still fired on a long tool/composition turn,
210
+ * darkening the live status mid-work. Counting production as liveness makes the
211
+ * fallback fire only on GENUINE silence (no reply, no feed, no draft, no tool
212
+ * events for the window) — a real wedge. A wedged agent produces nothing
213
+ * observable, so its clock is never reset and it still recovers.
214
+ *
215
+ * No-op when the kill switch is on or the key has no turn.
216
+ */
217
+ export function noteProduction(key: string, now: number): void {
218
+ const s = state.get(key)
219
+ if (s == null) return
220
+ s.lastOutboundAt = now
221
+ s.fallbackFired = false
222
+ }
223
+
199
224
  /**
200
225
  * Record a `thinking` session event. Used to pick "still thinking…" vs
201
226
  * "still working…" wording for the 300s framework fallback.
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { describe, it, expect } from 'vitest'
9
- import { parseVisibleAnswerStreamEnabled } from '../answer-stream-flag.js'
9
+ import { parseVisibleAnswerStreamEnabled, parseDraftLaneRetiredEnabled } from '../answer-stream-flag.js'
10
10
 
11
11
  describe('parseVisibleAnswerStreamEnabled — default OFF, opt-in', () => {
12
12
  it('defaults OFF when unset', () => {
@@ -25,3 +25,21 @@ describe('parseVisibleAnswerStreamEnabled — default OFF, opt-in', () => {
25
25
  }
26
26
  })
27
27
  })
28
+
29
+ describe('parseDraftLaneRetiredEnabled — default RETIRED (2026-06-05), kill-switch off', () => {
30
+ it('defaults to RETIRED (true) when unset — the draft lane is gone by default', () => {
31
+ expect(parseDraftLaneRetiredEnabled(undefined)).toBe(true)
32
+ })
33
+
34
+ it('stays RETIRED for any non-disable value (including unrecognized)', () => {
35
+ for (const v of ['1', 'true', 'on', 'yes', '', ' ', 'whatever', 'retired']) {
36
+ expect(parseDraftLaneRetiredEnabled(v)).toBe(true)
37
+ }
38
+ })
39
+
40
+ it('restores the legacy draft (false) ONLY on an explicit disable (case/space-insensitive)', () => {
41
+ for (const v of ['0', 'false', 'off', 'no', ' FALSE ', 'Off', 'NO']) {
42
+ expect(parseDraftLaneRetiredEnabled(v)).toBe(false)
43
+ }
44
+ })
45
+ })
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Draft-answer-lane retirement — gateway wiring guards (2026-06-05).
3
+ *
4
+ * The retirement switches the live answer lane from the invisible compose-box
5
+ * draft to a real, mtcute-observable edit-in-place message, default-on. The
6
+ * design review flagged two ways this silently breaks (gateway IIFE can't be
7
+ * instantiated in-process, so these are source-level assertions, same pattern as
8
+ * silence-liveness-wiring.test):
9
+ *
10
+ * PRIMARY: drop sendMessageDraftFn but FORGET to flip minInitialChars to 1 →
11
+ * the lane becomes a total no-op (the MAX gate never opens it), losing ALL
12
+ * answer-lane status AND the #2169 onMetric silence-liveness reset.
13
+ * SECONDARY: flip the lane to visible but FORGET to broaden the
14
+ * materialize-as-answer guard → a text-only no-reply turn falls into retract()
15
+ * and deletes the user's only copy of the answer (a lost-answer bug).
16
+ */
17
+ import { describe, it, expect } from 'vitest'
18
+ import { readFileSync } from 'node:fs'
19
+ import { resolve } from 'node:path'
20
+
21
+ const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
22
+
23
+ describe('draft-retirement wiring', () => {
24
+ it('sendMessageDraftFn is gated on the retirement (the single chokepoint)', () => {
25
+ expect(gatewaySrc).toMatch(/!DRAFT_ANSWER_LANE_RETIRED && typeof _rawSendMessageDraft === 'function'/)
26
+ })
27
+
28
+ it('DRAFT_ANSWER_LANE_RETIRED is declared before its first use (no TDZ at boot)', () => {
29
+ const declIdx = gatewaySrc.indexOf('const DRAFT_ANSWER_LANE_RETIRED =')
30
+ const firstUseIdx = gatewaySrc.indexOf('!DRAFT_ANSWER_LANE_RETIRED && typeof _rawSendMessageDraft')
31
+ expect(declIdx).toBeGreaterThan(0)
32
+ expect(firstUseIdx).toBeGreaterThan(declIdx)
33
+ })
34
+
35
+ it('PRIMARY GUARD: retired lane uses minInitialChars:1 (visible), never the MAX no-op gate', () => {
36
+ // The config must pick the {minInitialChars:1} branch when retired, so the
37
+ // lane actually opens a real message. The MAX branch is draft-only (legacy).
38
+ expect(gatewaySrc).toMatch(/ANSWER_STREAM_VISIBLE_ENABLED \|\| DRAFT_ANSWER_LANE_RETIRED\s*\n?\s*\?\s*\{ minInitialChars: 1 \}/)
39
+ })
40
+
41
+ it('SECONDARY GUARD: the materialize-as-answer guard is broadened in lockstep', () => {
42
+ // A text-only no-reply turn must materialize (ping + delete preview), not
43
+ // retract() the answer away.
44
+ expect(gatewaySrc).toMatch(/\(ANSWER_STREAM_VISIBLE_ENABLED \|\| DRAFT_ANSWER_LANE_RETIRED\)\s*\n?\s*&& !turn\.replyCalled/)
45
+ })
46
+
47
+ it('the #2169 onMetric silence-liveness reset is preserved (fires on visible sends now)', () => {
48
+ const onMetric = (gatewaySrc.split('onMetric: (metricEv) => {')[1] ?? '').split('\n },')[0]
49
+ expect(onMetric).toMatch(/silencePoke\.noteProduction/)
50
+ expect(onMetric).toMatch(/currentTurn === turn/)
51
+ })
52
+ })