switchroom 0.14.18 β†’ 0.14.19

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.
@@ -49416,8 +49416,8 @@ var {
49416
49416
  } = import__.default;
49417
49417
 
49418
49418
  // src/build-info.ts
49419
- var VERSION = "0.14.18";
49420
- var COMMIT_SHA = "dddb8617";
49419
+ var VERSION = "0.14.19";
49420
+ var COMMIT_SHA = "21863276";
49421
49421
 
49422
49422
  // src/cli/agent.ts
49423
49423
  init_source();
@@ -50216,6 +50216,37 @@ a flood. Going quiet mid-work is fine \u2014 going quiet *instead* of
50216
50216
  acknowledging, or *instead* of an update at a real milestone, is the
50217
50217
  black box this exists to prevent.
50218
50218
 
50219
+ ### Formatting \u2014 make it scannable
50220
+
50221
+ \`reply\` and \`stream_reply\` render Markdown as Telegram HTML for you, so
50222
+ \`**bold**\` becomes bold and backtick-wrapped text becomes monospace. Use it.
50223
+
50224
+ - **A one- or two-line conversational reply needs almost no markup.** Keep
50225
+ bold for the single fact that matters, never for decoration. "on it, pulling
50226
+ the logs now" is already perfect.
50227
+ - **A multi-section message needs visual hierarchy or it reads as a flat
50228
+ wall.** When you group several blocks \u2014 a status update, a "where things
50229
+ stand", a summary with distinct buckets, or **the message you post before
50230
+ kicking off a sub-agent or worker** \u2014 give each section a **bold label on
50231
+ its own line** and separate sections with **one blank line**. A row of
50232
+ emoji and bullets at equal weight with no spacing is the plain-text dump to
50233
+ avoid; bold labels + blank lines are what let the eye find the structure.
50234
+ Example shape:
50235
+
50236
+ **Dispatching**
50237
+ Kicking off a worker to crawl the changelog.
50238
+
50239
+ **What it'll do**
50240
+ \u2022 pull every entry since v0.14
50241
+ \u2022 flag anything user-facing
50242
+
50243
+ **Back in** ~2 min with a synthesized summary.
50244
+ - Bullets stay one level deep \u2014 Telegram flattens nested lists awkwardly. Use
50245
+ backtick-wrapped \`inline code\` for filenames, commands, and identifiers.
50246
+ - Don't use Markdown headings (\`#\` / \`##\`) in a reply \u2014 bold the label
50247
+ instead (\`**Blockers**\`, not \`## Blockers\`). Keep lines short; long
50248
+ unwrapped lines are hard to read on a phone.
50249
+
50219
50250
  Every turn that answers a user message ends with a user-visible
50220
50251
  \`reply\` (or \`stream_reply\` done=true) \u2014 Telegram is all the user
50221
50252
  sees; your terminal output never reaches them.`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.18",
3
+ "version": "0.14.19",
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": {
@@ -31645,6 +31645,7 @@ var REACTION_VARIANTS = {
31645
31645
  coding: ["\uD83D\uDC68\u200d\uD83D\uDCBB", "\u270d", "\u26a1"],
31646
31646
  web: ["\u26a1", "\uD83E\uDD14", "\uD83D\uDC4C"],
31647
31647
  compacting: ["\u270d", "\uD83E\uDD14", "\uD83D\uDC40"],
31648
+ awaiting: ["\uD83D\uDE4F", "\uD83E\uDD14", "\uD83D\uDC40"],
31648
31649
  done: ["\uD83D\uDC4D", "\uD83D\uDCAF", "\uD83C\uDF89"],
31649
31650
  error: ["\uD83D\uDE31", "\uD83D\uDE28", "\uD83E\uDD2F"],
31650
31651
  stallSoft: ["\uD83E\uDD71", "\uD83D\uDE34", "\uD83E\uDD14"],
@@ -31697,6 +31698,12 @@ class StatusReactionController {
31697
31698
  setCompacting() {
31698
31699
  this.scheduleState("compacting");
31699
31700
  }
31701
+ setAwaiting() {
31702
+ if (this.finished)
31703
+ return;
31704
+ this.scheduleState("awaiting", { immediate: true, skipStallReset: true });
31705
+ this.clearStallTimers();
31706
+ }
31700
31707
  setError() {
31701
31708
  this.scheduleState("error");
31702
31709
  }
@@ -51237,10 +51244,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51237
51244
  }
51238
51245
 
51239
51246
  // ../src/build-info.ts
51240
- var VERSION = "0.14.18";
51241
- var COMMIT_SHA = "dddb8617";
51242
- var COMMIT_DATE = "2026-05-30T23:35:26Z";
51243
- var LATEST_PR = 2010;
51247
+ var VERSION = "0.14.19";
51248
+ var COMMIT_SHA = "21863276";
51249
+ var COMMIT_DATE = "2026-05-31T00:15:08Z";
51250
+ var LATEST_PR = 2013;
51244
51251
  var COMMITS_AHEAD_OF_TAG = 0;
51245
51252
 
51246
51253
  // gateway/boot-version.ts
@@ -52458,6 +52465,12 @@ function countRunningWorkers() {
52458
52465
  }
52459
52466
  return n;
52460
52467
  }
52468
+ function resumeReactionAfterVerdict() {
52469
+ const turn = currentTurn;
52470
+ if (turn == null)
52471
+ return;
52472
+ activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId))?.setThinking();
52473
+ }
52461
52474
  function resolveThreadId(chat_id, explicit) {
52462
52475
  if (explicit != null)
52463
52476
  return Number(explicit);
@@ -52891,6 +52904,7 @@ var pendingStateReaper = setInterval(() => {
52891
52904
  for (const [k, v] of pendingPermissions) {
52892
52905
  if (now - v.startedAt > PERMISSION_TTL_MS) {
52893
52906
  dispatchPermissionVerdict({ type: "permission", requestId: k, behavior: "deny" });
52907
+ resumeReactionAfterVerdict();
52894
52908
  process.stderr.write(`telegram gateway: permission TTL expired \u2014 auto-deny request=${k} tool=${v.tool_name} (no operator response in ${Math.round(PERMISSION_TTL_MS / 60000)}m)
52895
52909
  `);
52896
52910
  pendingPermissions.delete(k);
@@ -53532,6 +53546,9 @@ var ipcServer = createIpcServer({
53532
53546
  `);
53533
53547
  });
53534
53548
  }
53549
+ if (activeTurn != null) {
53550
+ activeStatusReactions.get(statusKey(activeTurn.sessionChatId, activeTurn.sessionThreadId))?.setAwaiting();
53551
+ }
53535
53552
  },
53536
53553
  onHeartbeat(_client, _msg) {},
53537
53554
  onScheduleRestart(client3, msg) {
@@ -56283,6 +56300,7 @@ async function handleInbound(ctx, text, downloadImage, attachment) {
56283
56300
  requestId: request_id,
56284
56301
  behavior
56285
56302
  });
56303
+ resumeReactionAfterVerdict();
56286
56304
  if (msgId != null) {
56287
56305
  const emoji = behavior === "allow" ? "\u2705" : "\u274C";
56288
56306
  bot.api.setMessageReaction(chat_id, msgId, [
@@ -57988,6 +58006,7 @@ async function handlePermissionSlash(ctx, behavior) {
57988
58006
  return;
57989
58007
  }
57990
58008
  dispatchPermissionVerdict({ type: "permission", requestId: request_id, behavior });
58009
+ resumeReactionAfterVerdict();
57991
58010
  pendingPermissions.delete(request_id);
57992
58011
  process.stderr.write(`[telegram gateway] slash-${behavior} request_id=${request_id} tool=${details.tool_name} by=${senderId}
57993
58012
  `);
@@ -60284,6 +60303,7 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
60284
60303
  behavior: "allow",
60285
60304
  rule: chosen.rule
60286
60305
  });
60306
+ resumeReactionAfterVerdict();
60287
60307
  let durable = false;
60288
60308
  let legacy = false;
60289
60309
  let failReason = "";
@@ -60385,7 +60405,9 @@ ${editLabel}` : editLabel,
60385
60405
  return;
60386
60406
  }
60387
60407
  pendingPermissions.delete(request_id);
60388
- const label = behavior === "allow" ? "\u2705 Allowed" : "\u274C Denied";
60408
+ const resumeAgent = process.env.SWITCHROOM_AGENT_NAME;
60409
+ const resumeBeat = resumeAgent ? `\u25B6\uFE0F ${escapeHtmlForTg(resumeAgent)} resuming\u2026` : "\u25B6\uFE0F resuming\u2026";
60410
+ const label = `${behavior === "allow" ? "\u2705 Allowed" : "\u274C Denied"} \xB7 ${resumeBeat}`;
60389
60411
  const msg = ctx.callbackQuery?.message;
60390
60412
  const baseText = msg && "text" in msg && msg.text ? escapeHtmlForTg(msg.text) : "";
60391
60413
  await finalizeCallback(ctx, {
@@ -60400,6 +60422,7 @@ ${label}` : label,
60400
60422
  requestId: request_id,
60401
60423
  behavior
60402
60424
  });
60425
+ resumeReactionAfterVerdict();
60403
60426
  }
60404
60427
  });
60405
60428
  });
@@ -1954,6 +1954,24 @@ function paintStatusReactionError(chatId: string, threadId: number | undefined):
1954
1954
  ctrl.setError()
1955
1955
  }
1956
1956
 
1957
+ /**
1958
+ * Flip the current turn's status reaction off πŸ™ (awaiting-approval) back
1959
+ * to a working glyph once a permission verdict has been dispatched. The
1960
+ * turn was suspended *inside* the bridge's permission call, so `currentTurn`
1961
+ * still points at it; the verdict un-parks claude and it resumes the SAME
1962
+ * turn. `setThinking()` re-arms the stall watchdog that `setAwaiting()`
1963
+ * suspended, so a genuine post-approval hang still promotes to πŸ₯±/😨, and
1964
+ * it is replaced by the real tool glyph (✍/⚑) as soon as the resumed turn
1965
+ * fires its next PreToolUse. Non-terminal β€” πŸ‘ still waits for `turn_end`.
1966
+ */
1967
+ function resumeReactionAfterVerdict(): void {
1968
+ const turn = currentTurn
1969
+ if (turn == null) return
1970
+ activeStatusReactions
1971
+ .get(statusKey(turn.sessionChatId, turn.sessionThreadId))
1972
+ ?.setThinking()
1973
+ }
1974
+
1957
1975
  function resolveThreadId(chat_id: string, explicit?: string | number | null): number | undefined {
1958
1976
  if (explicit != null) return Number(explicit)
1959
1977
  return chatThreadMap.get(chat_id)
@@ -2876,6 +2894,9 @@ const pendingStateReaper = setInterval(() => {
2876
2894
  // dispatchPermissionVerdict so it's buffered+redelivered too if
2877
2895
  // the bridge is also offline at sweep time.
2878
2896
  dispatchPermissionVerdict({ type: 'permission', requestId: k, behavior: 'deny' })
2897
+ // The auto-deny un-parks the suspended turn β€” flip πŸ™ β†’ working so
2898
+ // it doesn't sit on the awaiting glyph (or stall) after the timeout.
2899
+ resumeReactionAfterVerdict()
2879
2900
  process.stderr.write(
2880
2901
  `telegram gateway: permission TTL expired β€” auto-deny request=${k} ` +
2881
2902
  `tool=${v.tool_name} (no operator response in ` +
@@ -4227,6 +4248,16 @@ const ipcServer: IpcServer = createIpcServer({
4227
4248
  process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}\n`)
4228
4249
  })
4229
4250
  }
4251
+ // Park the turn's status reaction on πŸ™ (awaiting your tap) and
4252
+ // suspend the stall watchdog β€” a turn blocked on the operator is not
4253
+ // stalled, so it must not degrade to πŸ₯±/😨 while the card sits
4254
+ // unanswered. The verdict path (`resumeReactionAfterVerdict`) flips it
4255
+ // back to a working state the instant you tap.
4256
+ if (activeTurn != null) {
4257
+ activeStatusReactions
4258
+ .get(statusKey(activeTurn.sessionChatId, activeTurn.sessionThreadId))
4259
+ ?.setAwaiting()
4260
+ }
4230
4261
  },
4231
4262
 
4232
4263
  onHeartbeat(_client: IpcClient, _msg: HeartbeatMessage) {
@@ -8924,6 +8955,7 @@ async function handleInbound(
8924
8955
  requestId: request_id,
8925
8956
  behavior,
8926
8957
  })
8958
+ resumeReactionAfterVerdict()
8927
8959
  if (msgId != null) {
8928
8960
  const emoji = behavior === 'allow' ? 'βœ…' : '❌'
8929
8961
  void bot.api.setMessageReaction(chat_id, msgId, [
@@ -11759,6 +11791,7 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
11759
11791
  }
11760
11792
  // Forward to connected bridges β€” same IPC the button handler uses.
11761
11793
  dispatchPermissionVerdict({ type: 'permission', requestId: request_id, behavior })
11794
+ resumeReactionAfterVerdict()
11762
11795
  pendingPermissions.delete(request_id)
11763
11796
  process.stderr.write(
11764
11797
  `[telegram gateway] slash-${behavior} request_id=${request_id} tool=${details.tool_name} by=${senderId}\n`,
@@ -15409,6 +15442,10 @@ bot.on('callback_query:data', async ctx => {
15409
15442
  behavior: 'allow',
15410
15443
  rule: chosen.rule,
15411
15444
  })
15445
+ // The turn resumes now (independent of the host persistence round-trip
15446
+ // below). Un-park πŸ™ β†’ working immediately so the operator sees the
15447
+ // agent continue while hostd writes the durable rule.
15448
+ resumeReactionAfterVerdict()
15412
15449
 
15413
15450
  // (3) Decide the persistence path. tryHostdDispatch returns
15414
15451
  // "not-configured" when host_control is disabled or the per-agent
@@ -15562,7 +15599,16 @@ bot.on('callback_query:data', async ctx => {
15562
15599
 
15563
15600
  // Forward permission decision to connected bridges
15564
15601
  pendingPermissions.delete(request_id)
15565
- const label = behavior === 'allow' ? 'βœ… Allowed' : '❌ Denied'
15602
+ // Deterministic "▢️ resuming…" beat (framework-posted, not model text):
15603
+ // the verdict un-parks the suspended turn, so confirm to the operator
15604
+ // that the agent received it and is continuing β€” closing the "is it
15605
+ // working or did my tap do nothing?" gap. Allow and deny both resume the
15606
+ // turn (deny just hands claude a refusal it then handles).
15607
+ const resumeAgent = process.env.SWITCHROOM_AGENT_NAME
15608
+ const resumeBeat = resumeAgent
15609
+ ? `▢️ ${escapeHtmlForTg(resumeAgent)} resuming…`
15610
+ : '▢️ resuming…'
15611
+ const label = `${behavior === 'allow' ? 'βœ… Allowed' : '❌ Denied'} Β· ${resumeBeat}`
15566
15612
  // HTML-escape the source text β€” same hazard as the scope-commit and
15567
15613
  // recent-denial paths above. The permission card body
15568
15614
  // (formatPermissionCardBody) appends claude-supplied `description`
@@ -15590,6 +15636,9 @@ bot.on('callback_query:data', async ctx => {
15590
15636
  requestId: request_id,
15591
15637
  behavior: behavior as 'allow' | 'deny',
15592
15638
  })
15639
+ // Un-park the status reaction: πŸ™ β†’ working, re-arming the stall
15640
+ // watchdog that setAwaiting() suspended.
15641
+ resumeReactionAfterVerdict()
15593
15642
  },
15594
15643
  })
15595
15644
  })
@@ -53,6 +53,7 @@ export type ReactionState =
53
53
  | 'web'
54
54
  | 'tool'
55
55
  | 'compacting'
56
+ | 'awaiting'
56
57
  | 'done'
57
58
  | 'error'
58
59
  | 'stallSoft'
@@ -78,6 +79,7 @@ export const REACTION_VARIANTS: Record<ReactionState, string[]> = {
78
79
  coding: ['πŸ‘¨β€πŸ’»', '✍', '⚑'], // WORKING: writing / running code
79
80
  web: ['⚑', 'πŸ€”', 'πŸ‘Œ'], // WORKING: lookup in motion
80
81
  compacting:['✍', 'πŸ€”', 'πŸ‘€'],
82
+ awaiting: ['πŸ™', 'πŸ€”', 'πŸ‘€'], // BLOCKED ON HUMAN: parked on a permission card
81
83
  done: ['πŸ‘', 'πŸ’―', 'πŸŽ‰'], // FINISHED: turn_end fired
82
84
  error: ['😱', '😨', '🀯'], // NON-TERMINAL β€” recovery allowed
83
85
  stallSoft: ['πŸ₯±', '😴', 'πŸ€”'],
@@ -180,6 +182,22 @@ export class StatusReactionController {
180
182
  this.scheduleState('compacting')
181
183
  }
182
184
 
185
+ /**
186
+ * πŸ™ β€” the turn is parked on a human decision (a permission card is
187
+ * waiting for the operator to tap Allow/Deny). Immediate, non-terminal,
188
+ * and crucially SUSPENDS the stall watchdog: a turn blocked on the
189
+ * operator is not stalled, so it must NOT promote to πŸ₯±/😨 while the
190
+ * card sits unanswered. The next working transition (setTool /
191
+ * setThinking, fired when the verdict resumes the turn) re-arms the
192
+ * watchdog normally. Bypasses debounce so πŸ™ lands as soon as the card
193
+ * is posted.
194
+ */
195
+ setAwaiting(): void {
196
+ if (this.finished) return
197
+ this.scheduleState('awaiting', { immediate: true, skipStallReset: true })
198
+ this.clearStallTimers()
199
+ }
200
+
183
201
  /**
184
202
  * 😱 β€” non-terminal error indicator. Paints the error emoji but does
185
203
  * NOT end the controller β€” recovery to a working state is permitted
@@ -341,6 +341,75 @@ describe('StatusReactionController', () => {
341
341
  expect(calls).toEqual(['πŸ‘€'])
342
342
  })
343
343
 
344
+ // setAwaiting(): park on πŸ™ while a permission card waits for the
345
+ // operator. A turn blocked on a human is NOT stalled, so the watchdog
346
+ // must stay quiet β€” but it re-arms once the verdict resumes work.
347
+ describe('setAwaiting() β€” park on a human permission decision', () => {
348
+ it('emits πŸ™ immediately (bypasses debounce)', async () => {
349
+ const { emit, calls } = makeEmitter()
350
+ const ctrl = new StatusReactionController(emit)
351
+ ctrl.setQueued()
352
+ await flush()
353
+ ctrl.setAwaiting()
354
+ await flush()
355
+ expect(calls).toEqual(['πŸ‘€', 'πŸ™'])
356
+ })
357
+
358
+ it('suppresses stall promotion (no πŸ₯±/😨) while the card sits unanswered', async () => {
359
+ const { emit, calls } = makeEmitter()
360
+ const ctrl = new StatusReactionController(emit)
361
+ ctrl.setQueued()
362
+ ctrl.setTool('Bash') // working: πŸ‘¨β€πŸ’»
363
+ vi.advanceTimersByTime(3500)
364
+ await flush()
365
+ ctrl.setAwaiting()
366
+ await flush()
367
+ // Well past both stall thresholds β€” awaiting must not yawn or panic.
368
+ vi.advanceTimersByTime(120000)
369
+ await flush()
370
+ expect(calls).not.toContain('πŸ₯±')
371
+ expect(calls).not.toContain('😨')
372
+ expect(calls[calls.length - 1]).toBe('πŸ™')
373
+ })
374
+
375
+ it('re-arms the stall watchdog once a working transition resumes the turn', async () => {
376
+ const { emit, calls } = makeEmitter()
377
+ const ctrl = new StatusReactionController(emit)
378
+ ctrl.setQueued()
379
+ await flush()
380
+ ctrl.setAwaiting()
381
+ await flush()
382
+ vi.advanceTimersByTime(120000) // long human wait β€” no stall
383
+ await flush()
384
+ expect(calls).toEqual(['πŸ‘€', 'πŸ™'])
385
+
386
+ // Verdict dispatched β†’ gateway calls setThinking() to un-park.
387
+ ctrl.setThinking()
388
+ vi.advanceTimersByTime(3500)
389
+ await flush()
390
+ expect(calls).toEqual(['πŸ‘€', 'πŸ™', 'πŸ€”'])
391
+
392
+ // A genuine post-approval hang must still promote to πŸ₯± β€” the
393
+ // watchdog was re-armed by the resuming transition.
394
+ vi.advanceTimersByTime(30000)
395
+ await flush()
396
+ expect(calls).toContain('πŸ₯±')
397
+ })
398
+
399
+ it('is a no-op after finalize (cannot resurrect a finished controller)', async () => {
400
+ const { emit, calls } = makeEmitter()
401
+ const ctrl = new StatusReactionController(emit)
402
+ ctrl.setQueued()
403
+ ctrl.finalize('done')
404
+ await flush()
405
+ const snapshot = [...calls]
406
+ ctrl.setAwaiting()
407
+ vi.advanceTimersByTime(5000)
408
+ await flush()
409
+ expect(calls).toEqual(snapshot)
410
+ })
411
+ })
412
+
344
413
  // hold(): freeze on a WORKING glyph while background sub-agent workers
345
414
  // outlive the parent turn, deferring the terminal πŸ‘ (worker-reaction fix).
346
415
  describe('hold() β€” defer πŸ‘ while a background worker runs', () => {