switchroom 0.14.67 → 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.
@@ -14593,6 +14593,35 @@ function shouldEmitNotionMcp(agentName, config) {
14593
14593
  return false;
14594
14594
  return true;
14595
14595
  }
14596
+ function normalizeNotionUuid(uuid) {
14597
+ if (typeof uuid !== "string")
14598
+ return null;
14599
+ const stripped = uuid.replace(/-/g, "").toLowerCase();
14600
+ if (!/^[0-9a-f]{32}$/.test(stripped))
14601
+ return null;
14602
+ return stripped;
14603
+ }
14604
+ function agentCanAccessNotionDB(config, agentName, dbUuid) {
14605
+ if (!shouldEmitNotionMcp(agentName, config))
14606
+ return false;
14607
+ const targetNorm = normalizeNotionUuid(dbUuid);
14608
+ if (targetNorm === null)
14609
+ return false;
14610
+ const agentConfig = config.agents?.[agentName];
14611
+ const allowedNames = agentConfig?.notion_workspace?.databases;
14612
+ if (allowedNames === undefined || allowedNames.length === 0) {
14613
+ return true;
14614
+ }
14615
+ const dbMap = config.notion_workspace?.databases ?? {};
14616
+ for (const name of allowedNames) {
14617
+ const uuid = dbMap[name];
14618
+ if (!uuid)
14619
+ continue;
14620
+ if (normalizeNotionUuid(uuid) === targetNorm)
14621
+ return true;
14622
+ }
14623
+ return false;
14624
+ }
14596
14625
  function validateNotionWorkspaceConfig(config) {
14597
14626
  const issues = [];
14598
14627
  const dbMap = config.notion_workspace?.databases ?? {};
@@ -49572,8 +49601,8 @@ var {
49572
49601
  } = import__.default;
49573
49602
 
49574
49603
  // src/build-info.ts
49575
- var VERSION = "0.14.67";
49576
- var COMMIT_SHA = "dcade213";
49604
+ var VERSION = "0.14.68";
49605
+ var COMMIT_SHA = "4f371b79";
49577
49606
 
49578
49607
  // src/cli/agent.ts
49579
49608
  init_source();
@@ -71710,6 +71739,70 @@ async function handleGetGoogleAccounts(config) {
71710
71739
  }
71711
71740
  return out;
71712
71741
  }
71742
+ async function handleGetMicrosoftAccounts(config) {
71743
+ const live = new Map;
71744
+ try {
71745
+ await withAuthBrokerClient(async (client2) => {
71746
+ const data = await client2.listMicrosoftAccounts();
71747
+ for (const a of data.accounts) {
71748
+ live.set(a.account.toLowerCase(), {
71749
+ expiresAt: a.expiresAt,
71750
+ scope: a.scope,
71751
+ clientId: a.clientId,
71752
+ accountType: a.accountType
71753
+ });
71754
+ }
71755
+ });
71756
+ } catch (err) {
71757
+ if (!(err instanceof AuthBrokerUnreachableError))
71758
+ throw err;
71759
+ }
71760
+ const cfgAccounts = config.microsoft_accounts ?? {};
71761
+ const keys = new Set([
71762
+ ...Object.keys(cfgAccounts).map((k) => k.toLowerCase()),
71763
+ ...live.keys()
71764
+ ]);
71765
+ const out = [];
71766
+ for (const key of [...keys].sort()) {
71767
+ const cfg = cfgAccounts[key];
71768
+ const l = live.get(key);
71769
+ out.push({
71770
+ account: key,
71771
+ expiresAt: l?.expiresAt ?? null,
71772
+ scope: l?.scope ?? null,
71773
+ clientId: l?.clientId ?? null,
71774
+ accountType: l?.accountType ?? null,
71775
+ enabledFor: cfg?.enabled_for ? [...cfg.enabled_for].sort() : [],
71776
+ brokerKnown: l != null
71777
+ });
71778
+ }
71779
+ return out;
71780
+ }
71781
+ function handleGetNotionWorkspace(config) {
71782
+ const nw = config.notion_workspace;
71783
+ if (!nw) {
71784
+ return { configured: false, vaultKey: null, databases: [], fullAccessAgents: [] };
71785
+ }
71786
+ const declared = nw.databases ?? {};
71787
+ const agentNames = Object.keys(config.agents ?? {});
71788
+ const databases = Object.entries(declared).map(([name, id]) => {
71789
+ const enabledFor = agentNames.filter((n) => agentCanAccessNotionDB(config, n, id)).sort();
71790
+ return { name, id, enabledFor };
71791
+ }).sort((a, b) => a.name.localeCompare(b.name));
71792
+ const agentsRaw = config.agents ?? {};
71793
+ const fullAccessAgents = agentNames.filter((n) => {
71794
+ if (!shouldEmitNotionMcp(n, config))
71795
+ return false;
71796
+ const dbs = agentsRaw[n]?.notion_workspace?.databases;
71797
+ return dbs === undefined || dbs.length === 0;
71798
+ }).sort();
71799
+ return {
71800
+ configured: true,
71801
+ vaultKey: nw.vault_key ?? null,
71802
+ databases,
71803
+ fullAccessAgents
71804
+ };
71805
+ }
71713
71806
  function handleGetSchedule(config) {
71714
71807
  const entries = collectScheduleEntries(config);
71715
71808
  const agentsDir = resolveAgentsDir(config);
@@ -72387,6 +72480,12 @@ function parseRoute(pathname, method) {
72387
72480
  if (method === "GET" && pathname === "/api/google-accounts") {
72388
72481
  return { handler: "getGoogleAccounts", params: {} };
72389
72482
  }
72483
+ if (method === "GET" && pathname === "/api/microsoft-accounts") {
72484
+ return { handler: "getMicrosoftAccounts", params: {} };
72485
+ }
72486
+ if (method === "GET" && pathname === "/api/notion-workspace") {
72487
+ return { handler: "getNotionWorkspace", params: {} };
72488
+ }
72390
72489
  if (method === "GET" && pathname === "/api/schedule") {
72391
72490
  return { handler: "getSchedule", params: {} };
72392
72491
  }
@@ -72512,6 +72611,10 @@ function startWebServer(config, port, hostname = "127.0.0.1", configPath) {
72512
72611
  return (async () => jsonResponse(await handleGetSystemHealth()))();
72513
72612
  case "getGoogleAccounts":
72514
72613
  return (async () => jsonResponse(await handleGetGoogleAccounts(config)))();
72614
+ case "getMicrosoftAccounts":
72615
+ return (async () => jsonResponse(await handleGetMicrosoftAccounts(config)))();
72616
+ case "getNotionWorkspace":
72617
+ return jsonResponse(handleGetNotionWorkspace(config));
72515
72618
  case "getSchedule":
72516
72619
  return jsonResponse(handleGetSchedule(config));
72517
72620
  case "getApprovals":
@@ -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.67",
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
+ }
@@ -39761,6 +39761,12 @@ function parseVisibleAnswerStreamEnabled(raw) {
39761
39761
  const v = raw.trim().toLowerCase();
39762
39762
  return v === "1" || v === "true" || v === "on" || v === "yes";
39763
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
+ }
39764
39770
 
39765
39771
  // pty-tail.ts
39766
39772
  var import_headless = __toESM(require_xterm_headless(), 1);
@@ -52770,11 +52776,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
52770
52776
  }
52771
52777
 
52772
52778
  // ../src/build-info.ts
52773
- var VERSION = "0.14.67";
52774
- var COMMIT_SHA = "dcade213";
52775
- var COMMIT_DATE = "2026-06-05T08:22:01Z";
52776
- var LATEST_PR = 2171;
52777
- var COMMITS_AHEAD_OF_TAG = 4;
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;
52778
52784
 
52779
52785
  // gateway/boot-version.ts
52780
52786
  function formatRelativeAgo(iso) {
@@ -53574,6 +53580,7 @@ var TOPIC_ID = process.env.TELEGRAM_TOPIC_ID ? Number(process.env.TELEGRAM_TOPIC
53574
53580
  var AGENT_ADMIN = process.env.SWITCHROOM_AGENT_ADMIN === "true";
53575
53581
  var bot = new import_grammy9.Bot(TOKEN);
53576
53582
  installTgPostLogger(bot);
53583
+ var DRAFT_ANSWER_LANE_RETIRED = parseDraftLaneRetiredEnabled(process.env.SWITCHROOM_DRAFT_ANSWER_LANE);
53577
53584
  var _rawSendMessageDraft = bot.api.raw.sendMessageDraft;
53578
53585
  var GRAMMY_VERSION = (() => {
53579
53586
  try {
@@ -53583,7 +53590,7 @@ var GRAMMY_VERSION = (() => {
53583
53590
  return "unknown";
53584
53591
  }
53585
53592
  })();
53586
- 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({
53587
53594
  chat_id: Number(chatId),
53588
53595
  draft_id: draftId,
53589
53596
  text,
@@ -58106,7 +58113,7 @@ function handleSessionEvent(ev) {
58106
58113
  chatId: turn.sessionChatId,
58107
58114
  isPrivateChat: turn.isDm,
58108
58115
  threadId: turn.sessionThreadId,
58109
- ...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 },
58110
58117
  sendMessage: async (chatId, text, params) => {
58111
58118
  const tid = params?.message_thread_id;
58112
58119
  const silent = params?.purpose !== "materialize";
@@ -58238,7 +58245,7 @@ function handleSessionEvent(ev) {
58238
58245
  const stream = turn.answerStream;
58239
58246
  const streamedMsgId = stream.messageId();
58240
58247
  const streamedFinalText = turn.capturedText.join("").trim();
58241
- 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) {
58242
58249
  turn.answerStream = null;
58243
58250
  streamFinalizedAsAnswer = true;
58244
58251
  turn.finalAnswerDelivered = true;
@@ -64574,7 +64581,7 @@ var didOneTimeSetup = false;
64574
64581
  }
64575
64582
  }
64576
64583
  }
64577
- 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}
64578
64585
  `);
64579
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"}
64580
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),
@@ -9545,7 +9557,13 @@ function handleSessionEvent(ev: SessionEvent): void {
9545
9557
  // General). With the gate unreachable the only posted message is
9546
9558
  // the canonical reply. (The gate is bypassed for DM draft
9547
9559
  // transport, so DM draft streaming is unaffected.)
9548
- ...(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
9549
9567
  ? { minInitialChars: 1 }
9550
9568
  : { sendMessageDraft: sendMessageDraftFn, minInitialChars: Number.MAX_SAFE_INTEGER }),
9551
9569
  // #1075: route through robustApiCall so flood-wait,
@@ -9835,7 +9853,13 @@ function handleSessionEvent(ev: SessionEvent): void {
9835
9853
  const streamedMsgId = stream.messageId()
9836
9854
  const streamedFinalText = turn.capturedText.join('').trim()
9837
9855
  if (
9838
- 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)
9839
9863
  && !turn.replyCalled
9840
9864
  && streamedMsgId != null
9841
9865
  && streamedFinalText.length > 0
@@ -20565,7 +20589,7 @@ void (async () => {
20565
20589
  }
20566
20590
  }
20567
20591
 
20568
- 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`)
20569
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`)
20570
20594
  runnerHandle = run(bot, {
20571
20595
  runner: {
@@ -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
+ })