switchroom 0.13.56 → 0.13.58

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.
@@ -48930,8 +48930,8 @@ var {
48930
48930
  } = import__.default;
48931
48931
 
48932
48932
  // src/build-info.ts
48933
- var VERSION = "0.13.56";
48934
- var COMMIT_SHA = "821a114e";
48933
+ var VERSION = "0.13.58";
48934
+ var COMMIT_SHA = "20818078";
48935
48935
 
48936
48936
  // src/cli/agent.ts
48937
48937
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.56",
3
+ "version": "0.13.58",
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": {
@@ -31559,6 +31559,109 @@ function isTelegramSurfaceTool(toolName) {
31559
31559
  return suffix === "reply" || suffix === "stream_reply" || suffix === "edit_message" || suffix === "react";
31560
31560
  }
31561
31561
 
31562
+ // draft-transport.ts
31563
+ var DRAFT_STREAM_STATE_KEY = Symbol.for("switchroom.draftStreamState");
31564
+ function getDraftStreamState() {
31565
+ const g = globalThis;
31566
+ let state = g[DRAFT_STREAM_STATE_KEY];
31567
+ if (!state) {
31568
+ state = { nextDraftId: 0 };
31569
+ g[DRAFT_STREAM_STATE_KEY] = state;
31570
+ }
31571
+ return state;
31572
+ }
31573
+ function allocateDraftId() {
31574
+ const state = getDraftStreamState();
31575
+ state.nextDraftId = state.nextDraftId >= 2147483647 ? 1 : state.nextDraftId + 1;
31576
+ return state.nextDraftId;
31577
+ }
31578
+
31579
+ // tool-activity-summary.ts
31580
+ var READ_VERBS = new Set(["read"]);
31581
+ var WRITE_VERBS = new Set(["wrote", "created", "edited"]);
31582
+ function makeEmptyActivityState() {
31583
+ return { counts: {}, order: [], firstToolName: null };
31584
+ }
31585
+ function verbForTool(toolName) {
31586
+ if (!toolName)
31587
+ return null;
31588
+ const mcpMatch = /^mcp__([^_]+)__(.+)$/.exec(toolName);
31589
+ if (mcpMatch && mcpMatch[1] === "switchroom-telegram")
31590
+ return null;
31591
+ const suffix = (mcpMatch ? mcpMatch[2] : toolName).toLowerCase();
31592
+ switch (suffix) {
31593
+ case "read":
31594
+ return "read";
31595
+ case "write":
31596
+ return "created";
31597
+ case "edit":
31598
+ case "multiedit":
31599
+ case "notebookedit":
31600
+ return "edited";
31601
+ case "bash":
31602
+ case "bashoutput":
31603
+ case "killshell":
31604
+ return "ran";
31605
+ case "websearch":
31606
+ case "grep":
31607
+ case "glob":
31608
+ return "searched";
31609
+ case "webfetch":
31610
+ return "fetched";
31611
+ case "task":
31612
+ case "agent":
31613
+ return "dispatched";
31614
+ case "todowrite":
31615
+ case "todoread":
31616
+ return "noted";
31617
+ default:
31618
+ return "used";
31619
+ }
31620
+ }
31621
+ function register(state, toolName) {
31622
+ const verb = verbForTool(toolName);
31623
+ if (!verb)
31624
+ return false;
31625
+ if (state.firstToolName == null)
31626
+ state.firstToolName = toolName;
31627
+ const prior = state.counts[verb] ?? 0;
31628
+ if (prior === 0)
31629
+ state.order.push(verb);
31630
+ state.counts[verb] = prior + 1;
31631
+ return true;
31632
+ }
31633
+ var VERB_PHRASE = {
31634
+ read: { singular: "read a file", plural: "read $N files" },
31635
+ edited: { singular: "edited a file", plural: "edited $N files" },
31636
+ created: { singular: "created a file", plural: "created $N files" },
31637
+ ran: { singular: "ran a command", plural: "ran $N commands" },
31638
+ searched: { singular: "ran a search", plural: "ran $N searches" },
31639
+ fetched: { singular: "fetched a URL", plural: "fetched $N URLs" },
31640
+ dispatched: { singular: "dispatched a sub-agent", plural: "dispatched $N sub-agents" },
31641
+ noted: { singular: "updated the todo list", plural: "updated the todo list ($N edits)" },
31642
+ used: { singular: "used a tool", plural: "used $N tools" }
31643
+ };
31644
+ function formatSummary(state) {
31645
+ const phrases = [];
31646
+ for (const verb of state.order) {
31647
+ const n = state.counts[verb] ?? 0;
31648
+ if (n <= 0)
31649
+ continue;
31650
+ const p = VERB_PHRASE[verb];
31651
+ phrases.push(n === 1 ? p.singular : p.plural.replace("$N", String(n)));
31652
+ }
31653
+ if (phrases.length === 0)
31654
+ return null;
31655
+ const sentence = phrases.join(", ");
31656
+ return sentence.charAt(0).toUpperCase() + sentence.slice(1);
31657
+ }
31658
+ function registerAndRender(state, toolName) {
31659
+ const changed = register(state, toolName);
31660
+ if (!changed)
31661
+ return null;
31662
+ return formatSummary(state);
31663
+ }
31664
+
31562
31665
  // tool-labels.ts
31563
31666
  var MAX_LABEL_CHARS = 60;
31564
31667
  var MAX_BASH_CHARS = 40;
@@ -31863,18 +31966,18 @@ function isDraft429(err) {
31863
31966
  const text = typeof err === "string" ? err : err instanceof Error ? err.message : typeof err === "object" && err != null && ("description" in err) ? typeof err.description === "string" ? err.description : "" : "";
31864
31967
  return /sendMessageDraft/i.test(text);
31865
31968
  }
31866
- var DRAFT_STREAM_STATE_KEY = Symbol.for("switchroom.draftStreamState");
31867
- function getDraftStreamState() {
31969
+ var DRAFT_STREAM_STATE_KEY2 = Symbol.for("switchroom.draftStreamState");
31970
+ function getDraftStreamState2() {
31868
31971
  const g = globalThis;
31869
- let state = g[DRAFT_STREAM_STATE_KEY];
31972
+ let state = g[DRAFT_STREAM_STATE_KEY2];
31870
31973
  if (!state) {
31871
31974
  state = { nextDraftId: 0 };
31872
- g[DRAFT_STREAM_STATE_KEY] = state;
31975
+ g[DRAFT_STREAM_STATE_KEY2] = state;
31873
31976
  }
31874
31977
  return state;
31875
31978
  }
31876
- function allocateDraftId() {
31877
- const state = getDraftStreamState();
31979
+ function allocateDraftId2() {
31980
+ const state = getDraftStreamState2();
31878
31981
  state.nextDraftId = state.nextDraftId >= 2147483647 ? 1 : state.nextDraftId + 1;
31879
31982
  return state.nextDraftId;
31880
31983
  }
@@ -31904,7 +32007,7 @@ function createDraftStream(send, edit, config = {}) {
31904
32007
  warn?.('draft-stream: previewTransport="auto" with sendMessageDraft but isPrivateChat undefined \u2014 defaulting to message transport');
31905
32008
  }
31906
32009
  let usesDraftTransport = prefersDraft && draftApi != null;
31907
- let draftId = usesDraftTransport ? allocateDraftId() : undefined;
32010
+ let draftId = usesDraftTransport ? allocateDraftId2() : undefined;
31908
32011
  if (prefersDraft && !usesDraftTransport) {
31909
32012
  warn?.("draft-stream: sendMessageDraft unavailable; falling back to sendMessage/editMessageText");
31910
32013
  }
@@ -32012,7 +32115,7 @@ function createDraftStream(send, edit, config = {}) {
32012
32115
  const newMsgId = await send(chunk);
32013
32116
  messageId = newMsgId;
32014
32117
  persistedTextLen = textToSend.length;
32015
- draftId = allocateDraftId();
32118
+ draftId = allocateDraftId2();
32016
32119
  currentChunkStartedAt = null;
32017
32120
  persistChainFires++;
32018
32121
  sendFires++;
@@ -38328,7 +38431,7 @@ function isSilentFlushMarker(text) {
38328
38431
  var MIN_INITIAL_CHARS = 50;
38329
38432
  var DEFAULT_THROTTLE_MS = 1000;
38330
38433
  var TELEGRAM_MAX_CHARS2 = 4096;
38331
- var allocateDraftId2 = allocateDraftId;
38434
+ var allocateDraftId3 = allocateDraftId2;
38332
38435
  function createAnswerStream(config) {
38333
38436
  const {
38334
38437
  chatId,
@@ -38351,7 +38454,7 @@ function createAnswerStream(config) {
38351
38454
  const effectiveThrottle = Math.max(250, throttleMs);
38352
38455
  const preferDraft = isPrivateChat && draftApi != null;
38353
38456
  let usesDraftTransport = preferDraft;
38354
- let draftId = preferDraft ? allocateDraftId2() : undefined;
38457
+ let draftId = preferDraft ? allocateDraftId3() : undefined;
38355
38458
  let streamMsgId;
38356
38459
  let pendingText = null;
38357
38460
  let lastSentText = "";
@@ -38600,7 +38703,7 @@ function createAnswerStream(config) {
38600
38703
  if (staleDraftId != null) {
38601
38704
  clearDraftBestEffort(staleDraftId);
38602
38705
  }
38603
- draftId = allocateDraftId2();
38706
+ draftId = allocateDraftId3();
38604
38707
  }
38605
38708
  log?.(`answer-stream: forceNewMessage (gen=${generation})`);
38606
38709
  },
@@ -49624,10 +49727,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
49624
49727
  }
49625
49728
 
49626
49729
  // ../src/build-info.ts
49627
- var VERSION = "0.13.56";
49628
- var COMMIT_SHA = "821a114e";
49629
- var COMMIT_DATE = "2026-05-27T20:20:37Z";
49630
- var LATEST_PR = 1923;
49730
+ var VERSION = "0.13.58";
49731
+ var COMMIT_SHA = "20818078";
49732
+ var COMMIT_DATE = "2026-05-27T22:11:23Z";
49733
+ var LATEST_PR = 1928;
49631
49734
  var COMMITS_AHEAD_OF_TAG = 0;
49632
49735
 
49633
49736
  // gateway/boot-version.ts
@@ -53601,6 +53704,74 @@ function closeProgressLane(chatId, threadId) {
53601
53704
  }
53602
53705
  }
53603
53706
  }
53707
+ async function drainActivitySummary(turn) {
53708
+ try {
53709
+ while (turn.activityPendingRender !== turn.activityLastSentRender) {
53710
+ const target = turn.activityPendingRender;
53711
+ if (target == null)
53712
+ break;
53713
+ const html = `<i>${target}</i>`;
53714
+ const chat = turn.sessionChatId;
53715
+ const thread = turn.sessionThreadId;
53716
+ const useDraft = turn.isDm && thread == null && sendMessageDraftFn != null;
53717
+ try {
53718
+ if (useDraft) {
53719
+ if (turn.activityDraftId == null) {
53720
+ turn.activityDraftId = allocateDraftId();
53721
+ }
53722
+ const draftId = turn.activityDraftId;
53723
+ await sendMessageDraftFn(chat, draftId, html, undefined);
53724
+ } else if (turn.activityMessageId == null) {
53725
+ const sent = await robustApiCall(() => bot.api.sendMessage(chat, html, {
53726
+ ...thread != null ? { message_thread_id: thread } : {},
53727
+ parse_mode: "HTML",
53728
+ disable_notification: true
53729
+ }), { chat_id: chat, ...thread != null ? { threadId: thread } : {}, verb: "activity-summary.send" });
53730
+ turn.activityMessageId = sent.message_id;
53731
+ } else {
53732
+ const id = turn.activityMessageId;
53733
+ await robustApiCall(() => bot.api.editMessageText(chat, id, html, { parse_mode: "HTML" }), { chat_id: chat, ...thread != null ? { threadId: thread } : {}, verb: "activity-summary.edit" });
53734
+ }
53735
+ turn.activityLastSentRender = target;
53736
+ } catch (err) {
53737
+ const msg = err instanceof Error ? err.message : String(err);
53738
+ if (!msg.includes("message is not modified")) {
53739
+ process.stderr.write(`telegram gateway: activity-summary drain failed: ${msg}
53740
+ `);
53741
+ }
53742
+ turn.activityLastSentRender = target;
53743
+ }
53744
+ }
53745
+ } finally {
53746
+ turn.activityInFlight = null;
53747
+ }
53748
+ }
53749
+ function clearActivitySummary(turn) {
53750
+ const chat = turn.sessionChatId;
53751
+ const thread = turn.sessionThreadId;
53752
+ const inFlight = turn.activityInFlight ?? Promise.resolve();
53753
+ inFlight.then(async () => {
53754
+ if (turn.activityDraftId != null && sendMessageDraftFn != null) {
53755
+ const draftId = turn.activityDraftId;
53756
+ turn.activityDraftId = null;
53757
+ try {
53758
+ await sendMessageDraftFn(chat, draftId, "", undefined);
53759
+ } catch (err) {
53760
+ process.stderr.write(`telegram gateway: activity-summary draft-clear failed: ${err}
53761
+ `);
53762
+ }
53763
+ } else if (turn.activityMessageId != null) {
53764
+ const id = turn.activityMessageId;
53765
+ turn.activityMessageId = null;
53766
+ try {
53767
+ await robustApiCall(() => bot.api.deleteMessage(chat, id), { chat_id: chat, ...thread != null ? { threadId: thread } : {}, verb: "activity-summary.delete" });
53768
+ } catch (err) {
53769
+ process.stderr.write(`telegram gateway: activity-summary delete failed: ${err}
53770
+ `);
53771
+ }
53772
+ }
53773
+ });
53774
+ }
53604
53775
  function handleSessionEvent(ev) {
53605
53776
  switch (ev.kind) {
53606
53777
  case "enqueue": {
@@ -53634,6 +53805,12 @@ function handleSessionEvent(ev) {
53634
53805
  lastAssistantMsgId: null,
53635
53806
  lastAssistantDone: false,
53636
53807
  toolCallCount: 0,
53808
+ toolActivity: makeEmptyActivityState(),
53809
+ activityMessageId: null,
53810
+ activityDraftId: null,
53811
+ activityInFlight: null,
53812
+ activityPendingRender: null,
53813
+ activityLastSentRender: null,
53637
53814
  answerStream: null,
53638
53815
  isDm: isDmChatId(ev.chatId)
53639
53816
  };
@@ -53693,11 +53870,30 @@ function handleSessionEvent(ev) {
53693
53870
  const ctrl = activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId));
53694
53871
  const name = ev.toolName;
53695
53872
  if (isTelegramReplyTool(name)) {
53873
+ const wasFirstReply = !turn.replyCalled;
53696
53874
  turn.replyCalled = true;
53697
53875
  if (turn.orphanedReplyTimeoutId != null) {
53698
53876
  clearTimeout(turn.orphanedReplyTimeoutId);
53699
53877
  turn.orphanedReplyTimeoutId = null;
53700
53878
  }
53879
+ if (wasFirstReply) {
53880
+ clearActivitySummary(turn);
53881
+ }
53882
+ }
53883
+ if (!turn.replyCalled && !isTelegramSurfaceTool(name)) {
53884
+ const rendered = registerAndRender(turn.toolActivity, name);
53885
+ if (rendered != null) {
53886
+ try {
53887
+ markAckSent();
53888
+ } catch (err) {
53889
+ process.stderr.write(`telegram gateway: activity-summary markAckSent failed: ${err}
53890
+ `);
53891
+ }
53892
+ turn.activityPendingRender = rendered;
53893
+ if (turn.activityInFlight == null) {
53894
+ turn.activityInFlight = drainActivitySummary(turn);
53895
+ }
53896
+ }
53701
53897
  }
53702
53898
  if (!ctrl)
53703
53899
  return;
@@ -53,6 +53,12 @@ import { OutboundDedupCache } from '../recent-outbound-dedup.js'
53
53
  import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
54
54
  import { StatusReactionController } from '../status-reactions.js'
55
55
  import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
56
+ import { allocateDraftId } from '../draft-transport.js'
57
+ import {
58
+ makeEmptyActivityState,
59
+ registerAndRender,
60
+ type ActivityState,
61
+ } from '../tool-activity-summary.js'
56
62
  import { toolLabel } from '../tool-labels.js'
57
63
  import { createTypingWrapper } from '../typing-wrap.js'
58
64
  import { type DraftStreamHandle } from '../draft-stream.js'
@@ -1291,6 +1297,39 @@ type CurrentTurn = {
1291
1297
  // Phase 1 of #332: count of tool_use events in the current turn, for
1292
1298
  // the tool_call_count column in the turns registry.
1293
1299
  toolCallCount: number
1300
+ // Tool-activity summary — mirrors Claude Code's native chat-UI
1301
+ // rendering ("Ran 5 commands, read a file"). Counters are
1302
+ // incremented in `case 'tool_use'`; `activityMessageId` holds the
1303
+ // Telegram message id we send/edit so a single message accumulates
1304
+ // the summary in place. Stops updating once `replyCalled` flips —
1305
+ // the model's own reply lands below the summary as the actual
1306
+ // content.
1307
+ //
1308
+ // Parallel-tool-use coalescing (PR #1926 review): modern Claude
1309
+ // emits multiple tool_uses in a tight synchronous loop (e.g. 3
1310
+ // parallel Reads). Without coalescing, each would see
1311
+ // `activityMessageId == null` and fire its own sendMessage,
1312
+ // producing N messages instead of one editable summary. Pattern
1313
+ // mirrors `telegram-plugin/answer-stream.ts`:
1314
+ // - `activityInFlight` — promise that resolves when the current
1315
+ // send/edit settles. While set, NEW tool_uses just update
1316
+ // `activityState` and `activityPendingRender` and return.
1317
+ // - When the in-flight resolves, it picks the latest
1318
+ // `activityPendingRender`, fires the next send/edit, and
1319
+ // repeats until the pending matches the last-sent.
1320
+ // Result: at most one Telegram call in flight at a time; the
1321
+ // final state always lands.
1322
+ toolActivity: ActivityState
1323
+ activityMessageId: number | null
1324
+ // Draft-transport id when the activity summary is streamed via
1325
+ // sendMessageDraft (DM-only, no thread). Each call to
1326
+ // sendMessageDraft(chat, draftId, text) REPLACES the draft text —
1327
+ // simpler than send+edit. Cleared by `clearActivitySummary` (which
1328
+ // sends an empty draft) when the model's reply takes over.
1329
+ activityDraftId: number | null
1330
+ activityInFlight: Promise<void> | null
1331
+ activityPendingRender: string | null
1332
+ activityLastSentRender: string | null
1294
1333
  // Issue #195 — answer-lane streaming. Lazily created on the first text
1295
1334
  // event of a turn (once enough text has accumulated, the stream itself
1296
1335
  // gates on minInitialChars). Materialized and cleared at turn_end.
@@ -6767,6 +6806,120 @@ function closeProgressLane(chatId: string, threadId: number | undefined): void {
6767
6806
  }
6768
6807
  }
6769
6808
 
6809
+ /**
6810
+ * Drain the tool-activity summary's pending render queue. Single-flight
6811
+ * by construction (caller assigns the returned promise to
6812
+ * `turn.activityInFlight`; while set, new tool_uses only update
6813
+ * `turn.activityPendingRender` and return).
6814
+ *
6815
+ * Transport priority (mirrors the existing answer-stream pattern):
6816
+ *
6817
+ * 1. DM with no thread AND sendMessageDraft API available →
6818
+ * DRAFT TRANSPORT. Each call REPLACES the draft text (no
6819
+ * edit-in-place needed); the user sees a live preview in their
6820
+ * Telegram compose area as the agent works. When the model's
6821
+ * reply tool lands, `clearActivitySummary` sends an empty draft
6822
+ * to wipe it — only the real reply persists.
6823
+ *
6824
+ * 2. Anything else (forum topic, draft API absent) → fall through
6825
+ * to sendMessage + editMessageText. The activity message is a
6826
+ * real chat message; `clearActivitySummary` deletes it when the
6827
+ * reply tool takes over.
6828
+ *
6829
+ * The drain holds a reference to `turn`, so a turn-swap mid-drain
6830
+ * doesn't corrupt the next turn's atom — late writes land on the
6831
+ * captured `turn` (already-completed turn, harmless).
6832
+ */
6833
+ async function drainActivitySummary(turn: CurrentTurn): Promise<void> {
6834
+ try {
6835
+ while (turn.activityPendingRender !== turn.activityLastSentRender) {
6836
+ const target = turn.activityPendingRender
6837
+ if (target == null) break
6838
+ const html = `<i>${target}</i>`
6839
+ const chat = turn.sessionChatId
6840
+ const thread = turn.sessionThreadId
6841
+ // sendMessageDraft doesn't support forum threads.
6842
+ const useDraft = turn.isDm && thread == null && sendMessageDraftFn != null
6843
+ try {
6844
+ if (useDraft) {
6845
+ if (turn.activityDraftId == null) {
6846
+ turn.activityDraftId = allocateDraftId()
6847
+ }
6848
+ const draftId = turn.activityDraftId
6849
+ await sendMessageDraftFn!(chat, draftId, html, undefined)
6850
+ } else if (turn.activityMessageId == null) {
6851
+ const sent = await robustApiCall(
6852
+ () => bot.api.sendMessage(chat, html, {
6853
+ ...(thread != null ? { message_thread_id: thread } : {}),
6854
+ parse_mode: 'HTML',
6855
+ disable_notification: true,
6856
+ }),
6857
+ { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.send' },
6858
+ )
6859
+ turn.activityMessageId = sent.message_id
6860
+ } else {
6861
+ const id = turn.activityMessageId
6862
+ await robustApiCall(
6863
+ () => bot.api.editMessageText(chat, id, html, { parse_mode: 'HTML' }),
6864
+ { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.edit' },
6865
+ )
6866
+ }
6867
+ turn.activityLastSentRender = target
6868
+ } catch (err) {
6869
+ const msg = err instanceof Error ? err.message : String(err)
6870
+ if (!msg.includes('message is not modified')) {
6871
+ process.stderr.write(`telegram gateway: activity-summary drain failed: ${msg}\n`)
6872
+ }
6873
+ // Mark as sent so we don't infinite-loop on a stuck render.
6874
+ turn.activityLastSentRender = target
6875
+ }
6876
+ }
6877
+ } finally {
6878
+ turn.activityInFlight = null
6879
+ }
6880
+ }
6881
+
6882
+ /**
6883
+ * Clear the activity summary when the model's reply tool takes over
6884
+ * as the authoritative surface. Awaits any in-flight render so we
6885
+ * don't race a stale write against the clear, then either sends an
6886
+ * empty draft (clears the compose-area preview) or deletes the
6887
+ * persisted message. Idempotent + best-effort — failure stderr-logs
6888
+ * but does not block.
6889
+ *
6890
+ * Called from `case 'tool_use'` the moment we see a Telegram reply
6891
+ * tool fire, so the user sees the real reply land in the same beat
6892
+ * the summary disappears.
6893
+ */
6894
+ function clearActivitySummary(turn: CurrentTurn): void {
6895
+ const chat = turn.sessionChatId
6896
+ const thread = turn.sessionThreadId
6897
+ const inFlight = turn.activityInFlight ?? Promise.resolve()
6898
+ void inFlight.then(async () => {
6899
+ if (turn.activityDraftId != null && sendMessageDraftFn != null) {
6900
+ const draftId = turn.activityDraftId
6901
+ turn.activityDraftId = null
6902
+ try {
6903
+ // Empty text → Telegram clears the draft.
6904
+ await sendMessageDraftFn(chat, draftId, '', undefined)
6905
+ } catch (err) {
6906
+ process.stderr.write(`telegram gateway: activity-summary draft-clear failed: ${err}\n`)
6907
+ }
6908
+ } else if (turn.activityMessageId != null) {
6909
+ const id = turn.activityMessageId
6910
+ turn.activityMessageId = null
6911
+ try {
6912
+ await robustApiCall(
6913
+ () => bot.api.deleteMessage(chat, id),
6914
+ { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.delete' },
6915
+ )
6916
+ } catch (err) {
6917
+ process.stderr.write(`telegram gateway: activity-summary delete failed: ${err}\n`)
6918
+ }
6919
+ }
6920
+ })
6921
+ }
6922
+
6770
6923
  function handleSessionEvent(ev: SessionEvent): void {
6771
6924
  switch (ev.kind) {
6772
6925
  case 'enqueue': {
@@ -6832,6 +6985,12 @@ function handleSessionEvent(ev: SessionEvent): void {
6832
6985
  lastAssistantMsgId: null,
6833
6986
  lastAssistantDone: false,
6834
6987
  toolCallCount: 0,
6988
+ toolActivity: makeEmptyActivityState(),
6989
+ activityMessageId: null,
6990
+ activityDraftId: null,
6991
+ activityInFlight: null,
6992
+ activityPendingRender: null,
6993
+ activityLastSentRender: null,
6835
6994
  answerStream: null,
6836
6995
  isDm: isDmChatId(ev.chatId),
6837
6996
  }
@@ -6943,11 +7102,57 @@ function handleSessionEvent(ev: SessionEvent): void {
6943
7102
  // Phase tracking removed in #553 PR 5 — phases only fed the
6944
7103
  // placeholder-heartbeat label, which has been retired.
6945
7104
  if (isTelegramReplyTool(name)) {
7105
+ const wasFirstReply = !turn.replyCalled
6946
7106
  turn.replyCalled = true
6947
7107
  if (turn.orphanedReplyTimeoutId != null) {
6948
7108
  clearTimeout(turn.orphanedReplyTimeoutId)
6949
7109
  turn.orphanedReplyTimeoutId = null
6950
7110
  }
7111
+ // The model's real reply takes over as the authoritative
7112
+ // surface. Clear the activity summary — for drafts, send an
7113
+ // empty draft to wipe the compose-area preview; for persisted
7114
+ // messages, delete. The user sees the real reply land in the
7115
+ // same beat the summary disappears.
7116
+ if (wasFirstReply) {
7117
+ clearActivitySummary(turn)
7118
+ }
7119
+ }
7120
+ // Tool-intent surface — companion to the PreToolUse ack-first gate
7121
+ // (#1921). On the FIRST non-reply tool_use of a turn AND only when
7122
+ // Tool-activity summary — same shape Claude Code natively renders
7123
+ // in its CLI/chat UI ("Ran 5 commands, read a file"). The gateway
7124
+ // accumulates non-reply tool_use events into `turn.toolActivity`
7125
+ // and sends ONE Telegram message that edits in place as more tools
7126
+ // land. Stops editing once the model calls `reply` — the summary
7127
+ // line stays as the final state. No model-side prompting; no per-
7128
+ // tool labels. Just surface what's already in the stream.
7129
+ //
7130
+ // Single-flight coalescing (PR #1926 review): modern Claude emits
7131
+ // multiple tool_uses in a synchronous burst (parallel Reads,
7132
+ // Bashes, etc.). All would otherwise race past the message-id
7133
+ // capture and produce N messages. Pattern mirrors answer-stream:
7134
+ // update `activityPendingRender` synchronously here; a single
7135
+ // worker promise drains the pending state, sending or editing
7136
+ // exactly once at a time and re-running until pending matches
7137
+ // the last-sent. Captures `turn` so a late drain after turn-swap
7138
+ // can't corrupt the next turn's atom.
7139
+ if (!turn.replyCalled && !isTelegramSurfaceTool(name)) {
7140
+ const rendered = registerAndRender(turn.toolActivity, name)
7141
+ if (rendered != null) {
7142
+ // Mark the ack-flag synchronously so a PreToolUse hook firing
7143
+ // concurrently for THIS tool call (#1921) sees the flag set
7144
+ // and allows the tool through. The drain runs async; failure
7145
+ // is logged but does not block the model.
7146
+ try {
7147
+ markAckSent()
7148
+ } catch (err) {
7149
+ process.stderr.write(`telegram gateway: activity-summary markAckSent failed: ${err}\n`)
7150
+ }
7151
+ turn.activityPendingRender = rendered
7152
+ if (turn.activityInFlight == null) {
7153
+ turn.activityInFlight = drainActivitySummary(turn)
7154
+ }
7155
+ }
6951
7156
  }
6952
7157
  if (!ctrl) return
6953
7158
  if (isTelegramSurfaceTool(name)) return
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ makeEmptyActivityState,
4
+ register,
5
+ formatSummary,
6
+ registerAndRender,
7
+ verbForTool,
8
+ } from "../tool-activity-summary.js";
9
+
10
+ describe("verbForTool — tool name → past-tense verb", () => {
11
+ it("maps standard CLI tools to readable verbs", () => {
12
+ expect(verbForTool("Read")).toBe("read");
13
+ expect(verbForTool("Write")).toBe("created");
14
+ expect(verbForTool("Edit")).toBe("edited");
15
+ expect(verbForTool("MultiEdit")).toBe("edited");
16
+ expect(verbForTool("NotebookEdit")).toBe("edited");
17
+ expect(verbForTool("Bash")).toBe("ran");
18
+ expect(verbForTool("BashOutput")).toBe("ran");
19
+ expect(verbForTool("WebSearch")).toBe("searched");
20
+ expect(verbForTool("Grep")).toBe("searched");
21
+ expect(verbForTool("Glob")).toBe("searched");
22
+ expect(verbForTool("WebFetch")).toBe("fetched");
23
+ expect(verbForTool("Task")).toBe("dispatched");
24
+ expect(verbForTool("Agent")).toBe("dispatched");
25
+ expect(verbForTool("TodoWrite")).toBe("noted");
26
+ });
27
+
28
+ it("skips user-facing switchroom-telegram tools (those ARE the surface)", () => {
29
+ expect(verbForTool("mcp__switchroom-telegram__reply")).toBeNull();
30
+ expect(verbForTool("mcp__switchroom-telegram__stream_reply")).toBeNull();
31
+ expect(verbForTool("mcp__switchroom-telegram__edit_message")).toBeNull();
32
+ expect(verbForTool("mcp__switchroom-telegram__react")).toBeNull();
33
+ });
34
+
35
+ it("returns 'used' for unknown / non-switchroom MCP tools", () => {
36
+ expect(verbForTool("mcp__google-workspace__list_files")).toBe("used");
37
+ expect(verbForTool("mcp__notion__query_database")).toBe("used");
38
+ expect(verbForTool("SomeFutureUnknownTool")).toBe("used");
39
+ });
40
+
41
+ it("returns null for empty toolName (defensive)", () => {
42
+ expect(verbForTool("")).toBeNull();
43
+ });
44
+ });
45
+
46
+ describe("register + formatSummary — Claude Code-style summary", () => {
47
+ it("formats a single Read as 'Read a file'", () => {
48
+ const s = makeEmptyActivityState();
49
+ register(s, "Read");
50
+ expect(formatSummary(s)).toBe("Read a file");
51
+ });
52
+
53
+ it("formats multiple Reads as 'Read N files'", () => {
54
+ const s = makeEmptyActivityState();
55
+ register(s, "Read");
56
+ register(s, "Read");
57
+ register(s, "Read");
58
+ expect(formatSummary(s)).toBe("Read 3 files");
59
+ });
60
+
61
+ it("formats single Bash as 'Ran a command'", () => {
62
+ const s = makeEmptyActivityState();
63
+ register(s, "Bash");
64
+ expect(formatSummary(s)).toBe("Ran a command");
65
+ });
66
+
67
+ it("formats multiple Bash as 'Ran N commands'", () => {
68
+ const s = makeEmptyActivityState();
69
+ for (let i = 0; i < 5; i++) register(s, "Bash");
70
+ expect(formatSummary(s)).toBe("Ran 5 commands");
71
+ });
72
+
73
+ it("joins multiple verb-classes with commas (first-occurrence order)", () => {
74
+ const s = makeEmptyActivityState();
75
+ // Tools fire in this order: Read → Bash → Edit
76
+ register(s, "Read");
77
+ register(s, "Bash");
78
+ register(s, "Edit");
79
+ // The summary renders chronologically: read, ran, edited.
80
+ expect(formatSummary(s)).toBe("Read a file, ran a command, edited a file");
81
+ });
82
+
83
+ it("matches the Claude Code screenshot examples", () => {
84
+ // "Ran 5 commands, read a file"
85
+ const s1 = makeEmptyActivityState();
86
+ for (let i = 0; i < 5; i++) register(s1, "Bash");
87
+ register(s1, "Read");
88
+ expect(formatSummary(s1)).toBe("Ran 5 commands, read a file");
89
+
90
+ // "Edited a file, read a file, ran a command"
91
+ const s2 = makeEmptyActivityState();
92
+ register(s2, "Edit");
93
+ register(s2, "Read");
94
+ register(s2, "Bash");
95
+ expect(formatSummary(s2)).toBe("Edited a file, read a file, ran a command");
96
+
97
+ // "Created a file, ran a command"
98
+ const s3 = makeEmptyActivityState();
99
+ register(s3, "Write");
100
+ register(s3, "Bash");
101
+ expect(formatSummary(s3)).toBe("Created a file, ran a command");
102
+ });
103
+
104
+ it("returns null when state is empty", () => {
105
+ expect(formatSummary(makeEmptyActivityState())).toBeNull();
106
+ });
107
+
108
+ it("ignores user-facing tools (reply/stream_reply etc.)", () => {
109
+ const s = makeEmptyActivityState();
110
+ register(s, "mcp__switchroom-telegram__reply");
111
+ register(s, "mcp__switchroom-telegram__stream_reply");
112
+ expect(formatSummary(s)).toBeNull(); // nothing tracked
113
+ });
114
+
115
+ it("includes generic 'used' for unknown MCP tools", () => {
116
+ const s = makeEmptyActivityState();
117
+ register(s, "mcp__google-workspace__list_files");
118
+ expect(formatSummary(s)).toBe("Used a tool");
119
+ register(s, "mcp__google-workspace__create_file");
120
+ expect(formatSummary(s)).toBe("Used 2 tools");
121
+ });
122
+
123
+ it("tracks firstToolName for forensic / telemetry use", () => {
124
+ const s = makeEmptyActivityState();
125
+ register(s, "Read");
126
+ register(s, "Bash");
127
+ expect(s.firstToolName).toBe("Read");
128
+ });
129
+ });
130
+
131
+ describe("parallel-tool-use coalescing — render only reflects accumulated state", () => {
132
+ it("synchronous burst of N tool_uses produces the right summary at each step", () => {
133
+ // Modern Claude emits parallel tool_uses in a tight sync loop. The
134
+ // gateway calls register() N times before any async drain runs.
135
+ // After N registers, the rendered string should reflect ALL of them
136
+ // — so when the drain fires once with the latest pendingRender, the
137
+ // sent text is correct and complete.
138
+ const s = makeEmptyActivityState();
139
+ register(s, "Read");
140
+ register(s, "Read");
141
+ register(s, "Read");
142
+ register(s, "Bash");
143
+ register(s, "Bash");
144
+ expect(formatSummary(s)).toBe("Read 3 files, ran 2 commands");
145
+ });
146
+
147
+ it("ordering is preserved across a chronological burst", () => {
148
+ const s = makeEmptyActivityState();
149
+ // Simulates: Bash, then Read, then Bash, then Read, then Edit
150
+ register(s, "Bash");
151
+ register(s, "Read");
152
+ register(s, "Bash");
153
+ register(s, "Read");
154
+ register(s, "Edit");
155
+ // Bash was first, then Read, then Edit. Counts: bash 2, read 2, edit 1.
156
+ expect(formatSummary(s)).toBe(
157
+ "Ran 2 commands, read 2 files, edited a file",
158
+ );
159
+ });
160
+
161
+ it("registerAndRender returns null on user-facing tools (no race contribution)", () => {
162
+ const s = makeEmptyActivityState();
163
+ register(s, "Read");
164
+ // A reply tool fires concurrently — should not enter the activity state.
165
+ expect(
166
+ registerAndRender(s, "mcp__switchroom-telegram__reply"),
167
+ ).toBeNull();
168
+ // State still reflects only the Read.
169
+ expect(formatSummary(s)).toBe("Read a file");
170
+ });
171
+ });
172
+
173
+ describe("registerAndRender — ergonomic full-pipeline call", () => {
174
+ it("returns the updated rendered text on a real tool (chronological)", () => {
175
+ const s = makeEmptyActivityState();
176
+ expect(registerAndRender(s, "Read")).toBe("Read a file");
177
+ // Bash fires AFTER Read — chronological order shows read first.
178
+ expect(registerAndRender(s, "Bash")).toBe(
179
+ "Read a file, ran a command",
180
+ );
181
+ });
182
+
183
+ it("returns null on a surface tool (no-op)", () => {
184
+ const s = makeEmptyActivityState();
185
+ expect(
186
+ registerAndRender(s, "mcp__switchroom-telegram__reply"),
187
+ ).toBeNull();
188
+ // State unchanged
189
+ expect(s.firstToolName).toBeNull();
190
+ });
191
+ });
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Tool-activity summary — Claude Code-style natural-language progress
3
+ * line that batches tool_use events for a turn into a single Telegram
4
+ * message that updates in place.
5
+ *
6
+ * Replaces the per-tool intent surface (#1924). The screenshot from
7
+ * Claude Code's own UI shows lines like:
8
+ *
9
+ * "Ran 5 commands, read a file"
10
+ * "Edited a file, read a file, ran a command"
11
+ *
12
+ * Past tense, comma-joined, singular/plural-aware. One message per
13
+ * "phase" (turn start → first reply), progressively edited as tools
14
+ * accumulate. NOT raw tool calls — descriptions of what the agent has
15
+ * been doing.
16
+ *
17
+ * Why this beats per-tool labels:
18
+ * - One Telegram message per phase (low signal-to-noise vs N
19
+ * messages on a heavy turn)
20
+ * - The user sees ACCUMULATED work in a glanceable form, not a flood
21
+ * - Plays nicely with the existing answer-lane stream that handles
22
+ * the actual reply text
23
+ *
24
+ * Tracking shape: per-turn counters keyed by `verb` (the action class
25
+ * derived from tool name). One counter per verb so the summary line
26
+ * collapses neatly regardless of which specific Read/Bash/WebSearch
27
+ * the model chose. `register()` increments the counter; `formatSummary()`
28
+ * renders the current state.
29
+ */
30
+
31
+ const READ_VERBS = new Set(["read"]);
32
+ const WRITE_VERBS = new Set(["wrote", "created", "edited"]);
33
+
34
+ export type ActivityVerb =
35
+ | "read"
36
+ | "edited"
37
+ | "created"
38
+ | "ran"
39
+ | "searched"
40
+ | "fetched"
41
+ | "dispatched"
42
+ | "noted"
43
+ | "used"; // generic fallback
44
+
45
+ /** Object form so `register()` can mutate; pure functions inside the
46
+ * module work against this shape (easier to unit-test than a Map). */
47
+ export interface ActivityState {
48
+ counts: Partial<Record<ActivityVerb, number>>;
49
+ /** Order verbs were first observed this turn. The summary renders in
50
+ * this order so the line reads as a chronological natural-language
51
+ * account: "edited a file, read a file, ran a command" matches the
52
+ * agent's actual sequence of actions. Stable — once a verb is added
53
+ * to this list, it never moves. */
54
+ order: ActivityVerb[];
55
+ /** First non-trivial tool name observed this turn (for telemetry / future
56
+ * "what kicked this off" forensic). Not used in the rendered summary. */
57
+ firstToolName: string | null;
58
+ }
59
+
60
+ export function makeEmptyActivityState(): ActivityState {
61
+ return { counts: {}, order: [], firstToolName: null };
62
+ }
63
+
64
+ /** Map a tool name → verb. Mirrors the existing `tool-intent-surface.ts`
65
+ * verb table but in past tense. Tools that don't map (or surface tools
66
+ * like reply/stream_reply) return null — the caller skips them. */
67
+ export function verbForTool(toolName: string): ActivityVerb | null {
68
+ if (!toolName) return null;
69
+ const mcpMatch = /^mcp__([^_]+)__(.+)$/.exec(toolName);
70
+ // Skip user-facing Telegram-plugin tools entirely — those ARE the
71
+ // surface, never to be summarised.
72
+ if (mcpMatch && mcpMatch[1] === "switchroom-telegram") return null;
73
+ const suffix = (mcpMatch ? mcpMatch[2] : toolName).toLowerCase();
74
+ switch (suffix) {
75
+ case "read":
76
+ return "read";
77
+ case "write":
78
+ return "created";
79
+ case "edit":
80
+ case "multiedit":
81
+ case "notebookedit":
82
+ return "edited";
83
+ case "bash":
84
+ case "bashoutput":
85
+ case "killshell":
86
+ return "ran";
87
+ case "websearch":
88
+ case "grep":
89
+ case "glob":
90
+ return "searched";
91
+ case "webfetch":
92
+ return "fetched";
93
+ case "task":
94
+ case "agent":
95
+ return "dispatched";
96
+ case "todowrite":
97
+ case "todoread":
98
+ return "noted";
99
+ default:
100
+ return "used";
101
+ }
102
+ }
103
+
104
+ /** Mutates `state` to record one tool_use of `toolName`. Returns true
105
+ * iff the activity state changed (so the caller knows to refresh the
106
+ * rendered summary). */
107
+ export function register(state: ActivityState, toolName: string): boolean {
108
+ const verb = verbForTool(toolName);
109
+ if (!verb) return false;
110
+ if (state.firstToolName == null) state.firstToolName = toolName;
111
+ const prior = state.counts[verb] ?? 0;
112
+ if (prior === 0) state.order.push(verb);
113
+ state.counts[verb] = prior + 1;
114
+ return true;
115
+ }
116
+
117
+ interface VerbPhrase {
118
+ singular: string;
119
+ plural: string;
120
+ }
121
+
122
+ const VERB_PHRASE: Record<ActivityVerb, VerbPhrase> = {
123
+ read: { singular: "read a file", plural: "read $N files" },
124
+ edited: { singular: "edited a file", plural: "edited $N files" },
125
+ created: { singular: "created a file", plural: "created $N files" },
126
+ ran: { singular: "ran a command", plural: "ran $N commands" },
127
+ searched: { singular: "ran a search", plural: "ran $N searches" },
128
+ fetched: { singular: "fetched a URL", plural: "fetched $N URLs" },
129
+ dispatched: { singular: "dispatched a sub-agent", plural: "dispatched $N sub-agents" },
130
+ noted: { singular: "updated the todo list", plural: "updated the todo list ($N edits)" },
131
+ used: { singular: "used a tool", plural: "used $N tools" },
132
+ };
133
+
134
+ /** Render the activity state as a single natural-language line.
135
+ * Verbs are rendered in `state.order` — first-occurrence order — so
136
+ * the line reads chronologically ("edited a file, read a file, ran
137
+ * a command" mirrors the agent's actual action sequence). Returns
138
+ * null when the state is empty (nothing to show yet). */
139
+ export function formatSummary(state: ActivityState): string | null {
140
+ const phrases: string[] = [];
141
+ for (const verb of state.order) {
142
+ const n = state.counts[verb] ?? 0;
143
+ if (n <= 0) continue;
144
+ const p = VERB_PHRASE[verb];
145
+ phrases.push(n === 1 ? p.singular : p.plural.replace("$N", String(n)));
146
+ }
147
+ if (phrases.length === 0) return null;
148
+ // Capitalize first letter so the sentence reads as a statement.
149
+ const sentence = phrases.join(", ");
150
+ return sentence.charAt(0).toUpperCase() + sentence.slice(1);
151
+ }
152
+
153
+ /** Convenience: ergonomic full pipeline for callers that just want
154
+ * "given the new tool name and prior state, give me the updated rendered
155
+ * text or null if nothing changed". Returns null when the tool is a
156
+ * surface tool / no-op (so the caller can skip the Telegram edit). */
157
+ export function registerAndRender(
158
+ state: ActivityState,
159
+ toolName: string,
160
+ ): string | null {
161
+ const changed = register(state, toolName);
162
+ if (!changed) return null;
163
+ return formatSummary(state);
164
+ }