switchroom 0.13.63 → 0.13.65

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.
@@ -49083,8 +49083,8 @@ var {
49083
49083
  } = import__.default;
49084
49084
 
49085
49085
  // src/build-info.ts
49086
- var VERSION = "0.13.63";
49087
- var COMMIT_SHA = "9aaa5939";
49086
+ var VERSION = "0.13.65";
49087
+ var COMMIT_SHA = "51894764";
49088
49088
 
49089
49089
  // src/cli/agent.ts
49090
49090
  init_source();
@@ -49940,6 +49940,10 @@ var AGENT_CONFIG_MCP_TOOLS = [
49940
49940
  var HOSTD_MCP_TOOLS = [
49941
49941
  "mcp__hostd__update_check"
49942
49942
  ];
49943
+ var WEBKITE_MCP_TOOLS = [
49944
+ "mcp__webkite",
49945
+ "mcp__webkite__*"
49946
+ ];
49943
49947
  var LEGACY_SWITCHROOM_MCP_TOKENS = ["mcp__switchroom", "mcp__switchroom__*"];
49944
49948
  var LEGACY_HOSTD_BLANKET_TOKENS = ["mcp__hostd", "mcp__hostd__*"];
49945
49949
  var DEFAULT_READ_ONLY_PREAPPROVED_TOOLS = [
@@ -50755,7 +50759,8 @@ function scaffoldAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchro
50755
50759
  ...usesSwitchroomTelegramPlugin(agentConfig) ? SWITCHROOM_TELEGRAM_MCP_TOOLS : [],
50756
50760
  ...hindsightEnabled ? HINDSIGHT_MCP_TOOLS : [],
50757
50761
  ...AGENT_CONFIG_MCP_TOOLS,
50758
- ...HOSTD_MCP_TOOLS
50762
+ ...HOSTD_MCP_TOOLS,
50763
+ ...agentConfig.mcp_servers?.["webkite"] === false ? [] : WEBKITE_MCP_TOOLS
50759
50764
  ]);
50760
50765
  const hindsightAutoRecallEnabled = hindsightEnabled && agentConfig.memory?.auto_recall !== false;
50761
50766
  const hindsightBankId = agentConfig.memory?.collection ?? name;
@@ -50816,7 +50821,8 @@ function scaffoldAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchro
50816
50821
  }
50817
50822
  settings.permissions = settings.permissions ?? {};
50818
50823
  const allow = (Array.isArray(settings.permissions.allow) ? settings.permissions.allow : []).filter((p) => !LEGACY_HOSTD_BLANKET_TOKENS.includes(p));
50819
- for (const t of [...AGENT_CONFIG_MCP_TOOLS, ...HOSTD_MCP_TOOLS]) {
50824
+ const webkiteAllowTools = agentConfig.mcp_servers?.["webkite"] === false ? [] : WEBKITE_MCP_TOOLS;
50825
+ for (const t of [...AGENT_CONFIG_MCP_TOOLS, ...HOSTD_MCP_TOOLS, ...webkiteAllowTools]) {
50820
50826
  if (!allow.includes(t))
50821
50827
  allow.push(t);
50822
50828
  }
@@ -51373,16 +51379,14 @@ function buildSettingsHooksBlock(p) {
51373
51379
  const useHotReloadStable = agentConfig.channels?.telegram?.hotReloadStable === true;
51374
51380
  const turnPacingDirective = "<turn-pacing>You are messaging a human via Telegram. The framework " + "automatically shows the user a live preview in their compose area as " + 'you work \u2014 they see "Read a file", "Ran 2 commands", etc. as your ' + `tool_use events stream. You do NOT need to ack manually.
51375
51381
 
51376
- ` + 'Do NOT call the reply tool with placeholder acks like "on it", ' + '"good question \u2014 one sec", "let me dig in", "checking now", etc. ' + "Those add chat clutter on top of the activity preview the user is " + "already seeing. The activity preview clears the moment you send a " + `real reply.
51377
-
51378
- ` + `Call reply only when you have something substantive to deliver:
51379
- ` + ` - The actual answer (any length \u2014 short or long)
51380
- ` + ` - A genuine question back to the user
51381
- ` + " - A real mid-work milestone or pivot that changes what the user " + 'should expect (e.g. "halfway through \u2014 found an unexpected issue, ' + `want me to continue?"). Not "still working".
51382
+ ` + "ALWAYS reply to a message the user sends you. A direct message " + 'expects a response: a greeting ("hi", "hey", "you there?") gets a ' + "greeting back; a thanks gets a brief acknowledgement; a question " + "gets an answer. NEVER end a turn with NO_REPLY when the user has " + "just sent you something \u2014 NO_REPLY is only for genuine non-prompts " + `(a system-synthesized event you have already fully handled).
51382
51383
 
51383
- ` + "For trivial one-sentence answers: just reply with the answer. The " + `reply IS the answer, not an ack.
51384
+ ` + "What you should NOT do is send a placeholder ack BEFORE doing the " + 'work \u2014 no "on it", "good question \u2014 one sec", "let me dig in", ' + '"checking now". Those add chat clutter on top of the activity ' + "preview the user already sees, and the preview clears the moment " + `your real reply lands. Do not ack-then-answer; just answer.
51384
51385
 
51385
- ` + "For complex tool-driven work: go straight to the tools. The compose-" + "area preview is the ambient liveness signal. Reply once you have " + "the answer or a real reason to break in.</turn-pacing>";
51386
+ ` + `So:
51387
+ ` + " - Trivial / social message \u2192 reply once, briefly, in your voice. " + `The reply IS the response.
51388
+ ` + ` - Question with a short answer \u2192 just reply with the answer.
51389
+ ` + " - Complex tool-driven work \u2192 go straight to the tools (the " + "compose-area preview is the ambient liveness signal), then reply " + 'once with the answer or a genuine mid-work pivot ("halfway ' + 'through \u2014 found an unexpected issue, want me to continue?"). Not ' + '"still working".</turn-pacing>';
51386
51390
  const switchroomUserPromptSubmit = [
51387
51391
  ...useHotReloadStable ? [
51388
51392
  {
@@ -51541,7 +51545,8 @@ function reconcileAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchr
51541
51545
  ...usesSwitchroomTelegramPlugin(agentConfig) ? SWITCHROOM_TELEGRAM_MCP_TOOLS : [],
51542
51546
  ...hindsightEnabled ? HINDSIGHT_MCP_TOOLS : [],
51543
51547
  ...AGENT_CONFIG_MCP_TOOLS,
51544
- ...HOSTD_MCP_TOOLS
51548
+ ...HOSTD_MCP_TOOLS,
51549
+ ...agentConfig.mcp_servers?.["webkite"] === false ? [] : WEBKITE_MCP_TOOLS
51545
51550
  ]);
51546
51551
  const desiredDeny = dedupe2([
51547
51552
  ...tools.deny ?? [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.63",
3
+ "version": "0.13.65",
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": {
@@ -49716,10 +49716,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
49716
49716
  }
49717
49717
 
49718
49718
  // ../src/build-info.ts
49719
- var VERSION = "0.13.63";
49720
- var COMMIT_SHA = "9aaa5939";
49721
- var COMMIT_DATE = "2026-05-28T03:28:57Z";
49722
- var LATEST_PR = 1945;
49719
+ var VERSION = "0.13.65";
49720
+ var COMMIT_SHA = "51894764";
49721
+ var COMMIT_DATE = "2026-05-28T05:00:08Z";
49722
+ var LATEST_PR = 1950;
49723
49723
  var COMMITS_AHEAD_OF_TAG = 0;
49724
49724
 
49725
49725
  // gateway/boot-version.ts
@@ -51489,6 +51489,13 @@ var ANSWER_STREAM_VISIBLE_ENABLED = (() => {
51489
51489
  return false;
51490
51490
  return true;
51491
51491
  })();
51492
+ var DRAFT_MIRROR_ENABLED = (() => {
51493
+ const raw = process.env.SWITCHROOM_DRAFT_MIRROR;
51494
+ if (raw == null)
51495
+ return false;
51496
+ const v = raw.trim().toLowerCase();
51497
+ return !(v === "0" || v === "false" || v === "off" || v === "no");
51498
+ })();
51492
51499
  var progressDriver = null;
51493
51500
  var unpinProgressCardForChat = null;
51494
51501
  var getPinnedProgressCardMessageId = null;
@@ -53856,7 +53863,7 @@ function handleSessionEvent(ev) {
53856
53863
  clearActivitySummary(turn);
53857
53864
  }
53858
53865
  }
53859
- if (!turn.replyCalled && !isTelegramSurfaceTool(name)) {
53866
+ if (!DRAFT_MIRROR_ENABLED && !turn.replyCalled && !isTelegramSurfaceTool(name)) {
53860
53867
  const rendered = registerAndRender(turn.toolActivity, name);
53861
53868
  if (rendered != null) {
53862
53869
  turn.activityPendingRender = rendered;
@@ -53884,7 +53891,7 @@ function handleSessionEvent(ev) {
53884
53891
  chatId: turn.sessionChatId,
53885
53892
  isPrivateChat: turn.isDm,
53886
53893
  threadId: turn.sessionThreadId,
53887
- ...ANSWER_STREAM_VISIBLE_ENABLED ? { minInitialChars: 1 } : { sendMessageDraft: sendMessageDraftFn },
53894
+ ...DRAFT_MIRROR_ENABLED ? { sendMessageDraft: sendMessageDraftFn } : ANSWER_STREAM_VISIBLE_ENABLED ? { minInitialChars: 1 } : { sendMessageDraft: sendMessageDraftFn },
53888
53895
  sendMessage: async (chatId, text, params) => {
53889
53896
  const tid = params?.message_thread_id;
53890
53897
  const silent = params?.purpose !== "materialize";
@@ -3192,6 +3192,26 @@ const ANSWER_STREAM_VISIBLE_ENABLED = (() => {
3192
3192
  if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false
3193
3193
  return true
3194
3194
  })()
3195
+
3196
+ // Draft-mirror preview (RFC docs/rfcs/draft-mirror-preview.md), Phase 1.
3197
+ // When enabled, the model's prose narration streams into the ephemeral
3198
+ // compose-area draft (sendMessageDraft) instead of a visible real
3199
+ // message — a live "what's it doing" preview that clears when the
3200
+ // reply lands. Default OFF (canary flag). When on it (a) forces the
3201
+ // answer-stream onto draft transport regardless of
3202
+ // ANSWER_STREAM_VISIBLE_ENABLED, and (b) suppresses the activity-summary
3203
+ // tool-count draft so the two don't collide on the single per-chat
3204
+ // draft slot. Delivery on a no-reply turn is owned by turn-flush
3205
+ // (decideTurnFlush → capturedText fresh send), NOT answer-stream
3206
+ // materialize() — which is dead on the draft-only path (streamMsgId
3207
+ // stays null, so its turn-end gate is false). Kill switch:
3208
+ // SWITCHROOM_DRAFT_MIRROR unset/0/false/off/no.
3209
+ const DRAFT_MIRROR_ENABLED = (() => {
3210
+ const raw = process.env.SWITCHROOM_DRAFT_MIRROR
3211
+ if (raw == null) return false
3212
+ const v = raw.trim().toLowerCase()
3213
+ return !(v === '0' || v === 'false' || v === 'off' || v === 'no')
3214
+ })()
3195
3215
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
3196
3216
  const progressDriver: any = null
3197
3217
  const unpinProgressCardForChat: ((chatId: string, threadId: number | undefined) => void) | null = null
@@ -7110,7 +7130,13 @@ function handleSessionEvent(ev: SessionEvent): void {
7110
7130
  // exactly once at a time and re-running until pending matches
7111
7131
  // the last-sent. Captures `turn` so a late drain after turn-swap
7112
7132
  // can't corrupt the next turn's atom.
7113
- if (!turn.replyCalled && !isTelegramSurfaceTool(name)) {
7133
+ // DRAFT_MIRROR (RFC draft-mirror-preview, Phase 1): the model's
7134
+ // prose narration owns the single per-chat draft slot. Suppress
7135
+ // the activity-summary tool-count draft so the two don't collide
7136
+ // (Telegram shows one draft per chat — the later write clobbers
7137
+ // the earlier). The activity-summary code stays intact for the
7138
+ // kill-switch path; it's retired for good only in Phase 4.
7139
+ if (!DRAFT_MIRROR_ENABLED && !turn.replyCalled && !isTelegramSurfaceTool(name)) {
7114
7140
  const rendered = registerAndRender(turn.toolActivity, name)
7115
7141
  if (rendered != null) {
7116
7142
  turn.activityPendingRender = rendered
@@ -7158,13 +7184,20 @@ function handleSessionEvent(ev: SessionEvent): void {
7158
7184
  chatId: turn.sessionChatId,
7159
7185
  isPrivateChat: turn.isDm,
7160
7186
  threadId: turn.sessionThreadId,
7161
- // #869-Phase1 visible-answer-stream: omit the draft API so
7162
- // the lane uses the real sendMessage / editMessageText path
7163
- // and edits a user-visible chat-timeline message instead
7164
- // of the invisible compose-box draft.
7165
- ...(ANSWER_STREAM_VISIBLE_ENABLED
7166
- ? { minInitialChars: 1 }
7167
- : { sendMessageDraft: sendMessageDraftFn }),
7187
+ // Transport selection:
7188
+ // - DRAFT_MIRROR (RFC draft-mirror-preview, Phase 1): force
7189
+ // the ephemeral compose-area draft so narration is a
7190
+ // clears-on-reply preview. Wins over visible-answer-stream.
7191
+ // No-reply delivery is owned by turn-flush, not materialize.
7192
+ // - else #869-Phase1 visible-answer-stream: omit the draft
7193
+ // API so the lane edits a user-visible chat-timeline
7194
+ // message (minInitialChars:1 opens it on the first chunk).
7195
+ // - else legacy: draft transport.
7196
+ ...(DRAFT_MIRROR_ENABLED
7197
+ ? { sendMessageDraft: sendMessageDraftFn }
7198
+ : ANSWER_STREAM_VISIBLE_ENABLED
7199
+ ? { minInitialChars: 1 }
7200
+ : { sendMessageDraft: sendMessageDraftFn }),
7168
7201
  // #1075: route through robustApiCall so flood-wait,
7169
7202
  // benign-400, and THREAD_NOT_FOUND are handled uniformly
7170
7203
  // instead of crashing the answer-stream loop on a deleted
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Greeting reply scenario — driver DMs the test bot a bare "hi", bot
3
+ * MUST reply (not NO_REPLY).
4
+ *
5
+ * Regression gate for the v0.13.61 turn-pacing v3 over-correction: the
6
+ * "don't ack" directive made the model classify a bare greeting as
7
+ * "not substantive" and end the turn with NO_REPLY, leaving the user
8
+ * staring at silence. v4 (this fix) re-asserts "always reply to a
9
+ * direct message; a greeting gets a greeting."
10
+ *
11
+ * Runs against real Telegram. Same env requirements as
12
+ * smoke-dm-reply.test.ts. Invoke via `bun run test:uat greeting`.
13
+ */
14
+
15
+ import { describe, it, expect } from "vitest";
16
+ import { spinUp } from "../harness.js";
17
+
18
+ describe("uat: greeting gets a reply (v4 turn-pacing regression gate)", () => {
19
+ it(
20
+ "driver DMs a bare 'hi' and the bot replies within 60s (not NO_REPLY)",
21
+ async () => {
22
+ const sc = await spinUp({ agent: "test-harness" });
23
+
24
+ try {
25
+ const started = Date.now();
26
+ await sc.sendDM("hi");
27
+
28
+ // A greeting should come back fast — well under the silence-poke
29
+ // soft window. 60s budget tolerates a cold turn but a NO_REPLY
30
+ // (the bug) would blow past it via the 300s framework fallback.
31
+ const reply = await sc.expectMessage(/.+/, {
32
+ from: "bot",
33
+ timeout: 60_000,
34
+ });
35
+ const elapsed = Date.now() - started;
36
+
37
+ expect(reply.text.length).toBeGreaterThan(0);
38
+ expect(reply.senderUserId).toBe(sc.botUserId);
39
+ // The bug path replies only after the 300s framework fallback.
40
+ // A real greeting reply lands fast; assert it beat the fallback.
41
+ expect(elapsed).toBeLessThan(60_000);
42
+ } finally {
43
+ await sc.tearDown();
44
+ }
45
+ },
46
+ 90_000,
47
+ );
48
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * JTBD scenario — the agent fetches the web via webkite, transparently.
3
+ *
4
+ * Validates the v0.13.62/63 webkite rollout end-to-end through real
5
+ * Telegram: the user sends a URL and asks about its content WITHOUT
6
+ * ever naming "webkite". The agent must:
7
+ *
8
+ * 1. Reach for webkite on its own (the native WebFetch/WebSearch
9
+ * tools are denied fleet-wide — see scaffold.ts
10
+ * WEBKITE_FLEET_DENY_TOOLS — so the ONLY way the agent can answer
11
+ * a "read this URL" prompt is via the webkite_* MCP tools). If the
12
+ * agent returns the page's content, webkite did the work by
13
+ * construction — there is no other web-fetch tool available.
14
+ *
15
+ * 2. Render JavaScript. The target is `quotes.toscrape.com/js/`, a
16
+ * purpose-built scraping-practice SPA whose quotes are injected by
17
+ * JS at runtime. A raw HTTP fetch (what the old WebFetch did) sees
18
+ * an empty page — `curl` returns zero `class="quote"` nodes. Only
19
+ * a JS-executing renderer (webkite → cloakbrowser headless
20
+ * Chromium) produces the visible quote text. So a correct quote in
21
+ * the reply is positive proof that JS rendering happened.
22
+ *
23
+ * The first quote on that page is Einstein's "The world as we have
24
+ * created it is a process of our thinking…". We assert the reply names
25
+ * Einstein AND carries a recognizable fragment of that quote.
26
+ *
27
+ * ## What this catches that other UATs don't
28
+ *
29
+ * - `jtbd-fast-trivial-dm` proves the agent replies fast, but never
30
+ * touches a tool. This is the first UAT that forces a real web fetch.
31
+ * - The in-container `webkite read` smoke proves the binary works, but
32
+ * not that the *model* chooses webkite unprompted over a denied
33
+ * WebFetch, nor that the full inbound→claude→MCP→outbound path works.
34
+ *
35
+ * ## Failure modes this guards against
36
+ *
37
+ * - A regression that re-enables WebFetch (the model might fetch raw
38
+ * HTML and miss the JS-rendered quotes → wrong/empty answer).
39
+ * - webkite MCP not wired / not trusted (agent says it can't browse).
40
+ * - cloakbrowser broken (agent returns the empty static page → no
41
+ * quote, or a "page had no content" apology).
42
+ * - The glibc regression that the v0.13.62 canary caught (webkite
43
+ * dead-on-arrival → agent can't browse at all).
44
+ */
45
+
46
+ import { describe, it, expect } from "vitest";
47
+ import { spinUp } from "../harness.js";
48
+
49
+ const AGENT = "test-harness";
50
+
51
+ // JS-rendered scraping-practice page. Quotes exist ONLY after JS runs;
52
+ // a raw fetch sees none. Stable, purpose-built, no auth.
53
+ const JS_URL = "https://quotes.toscrape.com/js/";
54
+
55
+ // Deliberately does NOT mention webkite, fetch, browser, or any tool —
56
+ // a natural "read this for me" ask. The agent must pick the tool.
57
+ const PROMPT =
58
+ `Open ${JS_URL} and tell me the exact text of the very first quote ` +
59
+ `on the page and who said it. Just the quote and the author.`;
60
+
61
+ // The first quote's author + a distinctive fragment of its text.
62
+ const EXPECTED_AUTHOR = /einstein/i;
63
+ const EXPECTED_FRAGMENT =
64
+ /world as we have created it|process of our thinking|changing our thinking/i;
65
+
66
+ // Phrases that would indicate the agent FAILED to browse (fell back to
67
+ // "I can't access the web" or got the empty static page).
68
+ const CANT_BROWSE = [
69
+ /can.?t (access|browse|open|reach|fetch)/i,
70
+ /unable to (access|browse|open|reach|fetch)/i,
71
+ /no content|empty page|couldn.?t (find|load)/i,
72
+ /don.?t have (web|internet|browsing)/i,
73
+ ];
74
+
75
+ describe("uat: agent fetches the web via webkite (JS page, unprompted)", () => {
76
+ it(
77
+ "URL prompt → agent returns JS-rendered content (proves webkite + cloakbrowser)",
78
+ async () => {
79
+ const sc = await spinUp({ agent: AGENT });
80
+ try {
81
+ await sc.sendDM(PROMPT);
82
+
83
+ // Generous budget: a real cloakbrowser render of an SPA is
84
+ // slower than a trivial reply (Chromium spawn + JS execution).
85
+ const reply = await sc.expectMessage(EXPECTED_FRAGMENT, {
86
+ from: "bot",
87
+ timeout: 90_000,
88
+ });
89
+
90
+ // Positive proof: the JS-gated quote text came back.
91
+ expect(reply.text).toMatch(EXPECTED_FRAGMENT);
92
+ // And the author — confirms it parsed the actual quote, not noise.
93
+ expect(reply.text).toMatch(EXPECTED_AUTHOR);
94
+
95
+ // Negative proof: no "I can't browse" fallback. (WebFetch is
96
+ // denied, so a failure to use webkite surfaces as an apology,
97
+ // not a wrong fetch.)
98
+ const failedToBrowse = CANT_BROWSE.some((re) => re.test(reply.text));
99
+ expect(
100
+ failedToBrowse,
101
+ `agent reply looks like a can't-browse fallback: ${JSON.stringify(reply.text.slice(0, 300))}`,
102
+ ).toBe(false);
103
+
104
+ console.log(
105
+ `[webkite-read] agent returned JS-rendered quote via webkite — ` +
106
+ `WebFetch denied, cloakbrowser rendered the SPA. ` +
107
+ `reply: ${JSON.stringify(reply.text.slice(0, 200))}`,
108
+ );
109
+ } finally {
110
+ await sc.tearDown();
111
+ }
112
+ },
113
+ 120_000,
114
+ );
115
+ });