switchroom 0.12.4 → 0.12.5

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.
@@ -46894,8 +46894,8 @@ var {
46894
46894
  } = import__.default;
46895
46895
 
46896
46896
  // src/build-info.ts
46897
- var VERSION = "0.12.4";
46898
- var COMMIT_SHA = "a10797c4";
46897
+ var VERSION = "0.12.5";
46898
+ var COMMIT_SHA = "bab28d7e";
46899
46899
 
46900
46900
  // src/cli/agent.ts
46901
46901
  init_source();
@@ -48276,6 +48276,7 @@ function buildWorkspaceContext(args) {
48276
48276
  botToken: resolvedBotToken ?? rawBotToken,
48277
48277
  forumChatId: telegramConfig.forum_chat_id,
48278
48278
  dangerousMode: agentConfig.dangerous_mode === true,
48279
+ admin: agentConfig.admin === true,
48279
48280
  useSwitchroomPlugin: usesSwitchroomTelegramPlugin(agentConfig),
48280
48281
  useHotReloadStable: agentConfig.channels?.telegram?.hotReloadStable === true,
48281
48282
  telegramEnabledFlag: agentConfig.channels?.telegram?.enabled === false ? "false" : "true",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.12.4",
3
+ "version": "0.12.5",
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": {
@@ -42575,6 +42575,27 @@ async function tryHostdDispatch(agentName3, req) {
42575
42575
  function hostdRequestId(prefix) {
42576
42576
  return `${prefix}-${Date.now()}-${randomBytes3(4).toString("hex")}`;
42577
42577
  }
42578
+ async function hostdGetStatusOnce(agentName3, targetRequestId) {
42579
+ if (!isHostdEnabled())
42580
+ return "not-configured";
42581
+ const sockPath = hostdSocketPath(agentName3);
42582
+ if (!existsSync19(sockPath))
42583
+ return "not-configured";
42584
+ try {
42585
+ const resp = await hostdRequest({ socketPath: sockPath, timeoutMs: 3000 }, {
42586
+ v: 1,
42587
+ op: "get_status",
42588
+ request_id: hostdRequestId("gw-poke-status"),
42589
+ args: { target_request_id: targetRequestId }
42590
+ });
42591
+ if (resp.result === "denied" || resp.result === "error") {
42592
+ return "unavailable";
42593
+ }
42594
+ return resp;
42595
+ } catch {
42596
+ return "unavailable";
42597
+ }
42598
+ }
42578
42599
  async function pollHostdStatus(agentName3, targetRequestId, opts) {
42579
42600
  if (!isHostdEnabled())
42580
42601
  return "not-configured";
@@ -42622,6 +42643,32 @@ function warnLegacySpawnIfHostdDisabled(verb) {
42622
42643
  `);
42623
42644
  }
42624
42645
 
42646
+ // gateway/update-status-line.ts
42647
+ function latestHostdPhase(tail) {
42648
+ if (!tail)
42649
+ return null;
42650
+ let phase = null;
42651
+ for (const raw of tail.split(`
42652
+ `)) {
42653
+ const m = /^\s*\u25b8\s*(.+?)\s*$/.exec(raw);
42654
+ if (m && m[1])
42655
+ phase = m[1];
42656
+ }
42657
+ return phase;
42658
+ }
42659
+ function elapsedMin(startedAt, now) {
42660
+ return Math.max(1, Math.round((now - startedAt) / 60000));
42661
+ }
42662
+ function formatUpdateStatusLine(resp, startedAt, now) {
42663
+ const mins = elapsedMin(startedAt, now);
42664
+ const tail = `Recreating the fleet (including me) \u2014 I'll report the result here when it's done.`;
42665
+ if (resp.result !== "started") {
42666
+ return `\u23f3 Fleet update finishing \u2014 hostd reported \`${resp.result}\` (~${mins}m). ${tail}`;
42667
+ }
42668
+ const phase = latestHostdPhase(resp.stdout_tail);
42669
+ return phase ? `\u23f3 Fleet update in progress \u2014 phase: ${phase} (~${mins}m). ${tail}` : `\u23f3 Fleet update in progress \u2014 starting (~${mins}m). ${tail}`;
42670
+ }
42671
+
42625
42672
  // gateway/boot-sweep-filter.ts
42626
42673
  function shouldSweepChatAtBoot(chatId) {
42627
42674
  const n = Number(chatId);
@@ -46559,11 +46606,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
46559
46606
  }
46560
46607
 
46561
46608
  // ../src/build-info.ts
46562
- var VERSION = "0.12.4";
46563
- var COMMIT_SHA = "a10797c4";
46564
- var COMMIT_DATE = "2026-05-18T11:07:19Z";
46565
- var LATEST_PR = 1511;
46566
- var COMMITS_AHEAD_OF_TAG = 2;
46609
+ var VERSION = "0.12.5";
46610
+ var COMMIT_SHA = "bab28d7e";
46611
+ var COMMIT_DATE = "2026-05-18T12:38:38Z";
46612
+ var LATEST_PR = 1514;
46613
+ var COMMITS_AHEAD_OF_TAG = 3;
46567
46614
 
46568
46615
  // gateway/boot-version.ts
46569
46616
  function formatRelativeAgo(iso) {
@@ -48168,12 +48215,25 @@ function ensureIssuesCard(chatId, threadId) {
48168
48215
  }
48169
48216
  }
48170
48217
  }
48218
+ var inFlightUpdate = null;
48171
48219
  startTimer({
48172
48220
  emitMetric: (event) => {
48173
48221
  emitRuntimeMetric(event);
48174
48222
  },
48175
48223
  onFrameworkFallback: async (ctx) => {
48176
- const text = formatFrameworkFallbackText(ctx.fallbackKind, ctx.silenceMs, ctx.inFlightTools);
48224
+ let text = null;
48225
+ const upd = inFlightUpdate;
48226
+ if (upd != null) {
48227
+ try {
48228
+ const st = await hostdGetStatusOnce(getMyAgentName(), upd.requestId);
48229
+ if (st !== "not-configured" && st !== "unavailable") {
48230
+ text = formatUpdateStatusLine(st, upd.startedAt, Date.now());
48231
+ }
48232
+ } catch {}
48233
+ }
48234
+ if (text == null) {
48235
+ text = formatFrameworkFallbackText(ctx.fallbackKind, ctx.silenceMs, ctx.inFlightTools);
48236
+ }
48177
48237
  try {
48178
48238
  await robustApiCall(() => bot.api.sendMessage(ctx.chatId, text, {
48179
48239
  ...ctx.threadId != null ? { message_thread_id: ctx.threadId } : {},
@@ -52078,33 +52138,38 @@ The gateway will restart as part of the recreate step; watch for the post-restar
52078
52138
  return;
52079
52139
  }
52080
52140
  if (hostdResp.result === "started") {
52141
+ inFlightUpdate = { requestId: updateRequestId, startedAt: Date.now() };
52081
52142
  (async () => {
52082
- const terminal = await pollHostdStatus(getMyAgentName(), updateRequestId, {
52083
- timeoutMs: 60000
52084
- });
52085
- if (terminal === "not-configured")
52086
- return;
52087
- if (terminal.result === "completed")
52088
- return;
52089
- clearRestartMarker();
52090
- const errBody = terminal.error ?? terminal.stderr_tail ?? terminal.stdout_tail ?? "(no error tail returned)";
52091
- const editedText = `\uD83D\uDE80 <b>update started</b> \u2014 <b>FAILED</b> via hostd ` + `(result=${escapeHtmlForTg(terminal.result)}):
52143
+ try {
52144
+ const terminal = await pollHostdStatus(getMyAgentName(), updateRequestId, {
52145
+ timeoutMs: 60000
52146
+ });
52147
+ if (terminal === "not-configured")
52148
+ return;
52149
+ if (terminal.result === "completed")
52150
+ return;
52151
+ clearRestartMarker();
52152
+ const errBody = terminal.error ?? terminal.stderr_tail ?? terminal.stdout_tail ?? "(no error tail returned)";
52153
+ const editedText = `\uD83D\uDE80 <b>update started</b> \u2014 <b>FAILED</b> via hostd ` + `(result=${escapeHtmlForTg(terminal.result)}):
52092
52154
  ` + preBlock(errBody);
52093
- if (ackId != null) {
52094
- try {
52095
- await robustApiCall(() => lockedBot.api.editMessageText(chatId, ackId, editedText, {
52096
- parse_mode: "HTML",
52097
- link_preview_options: { is_disabled: true }
52098
- }), { verb: "update.poll.editAck" });
52099
- } catch {
52155
+ if (ackId != null) {
52156
+ try {
52157
+ await robustApiCall(() => lockedBot.api.editMessageText(chatId, ackId, editedText, {
52158
+ parse_mode: "HTML",
52159
+ link_preview_options: { is_disabled: true }
52160
+ }), { verb: "update.poll.editAck" });
52161
+ } catch {
52162
+ try {
52163
+ await switchroomReply(ctx, editedText, { html: true });
52164
+ } catch {}
52165
+ }
52166
+ } else {
52100
52167
  try {
52101
52168
  await switchroomReply(ctx, editedText, { html: true });
52102
52169
  } catch {}
52103
52170
  }
52104
- } else {
52105
- try {
52106
- await switchroomReply(ctx, editedText, { html: true });
52107
- } catch {}
52171
+ } finally {
52172
+ inFlightUpdate = null;
52108
52173
  }
52109
52174
  })();
52110
52175
  return;
@@ -233,9 +233,11 @@ import {
233
233
  hostdRequestId,
234
234
  hostdWillBeUsed,
235
235
  pollHostdStatus,
236
+ hostdGetStatusOnce,
236
237
  warnLegacySpawnIfHostdDisabled,
237
238
  _resetHostdEnabledCache,
238
239
  } from './hostd-dispatch.js'
240
+ import { formatUpdateStatusLine } from './update-status-line.js'
239
241
  import type { HostdRequest } from '../../src/host-control/protocol.js'
240
242
  import type { AgentAudit } from '../welcome-text.js'
241
243
  import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
@@ -2567,22 +2569,50 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
2567
2569
  // the framework itself sends a user-visible "still working… / still
2568
2570
  // thinking…" message. Honours SWITCHROOM_DISABLE_SILENCE_POKE=1 kill
2569
2571
  // switch (no-op if set).
2572
+ // Set when this gateway dispatches an `update_apply` to hostd that
2573
+ // returns `started`; cleared when the dispatch poll resolves (terminal
2574
+ // / not-configured / timeout). While set, the framework silence
2575
+ // fallback substitutes hostd's real phase + elapsed for the generic
2576
+ // "still working…" text — deterministic, model-free, the klanker
2577
+ // incident fix. In-memory only; a gateway recreate naturally resets it.
2578
+ let inFlightUpdate: { requestId: string; startedAt: number } | null = null
2579
+
2570
2580
  silencePoke.startTimer({
2571
2581
  emitMetric: (event) => {
2572
2582
  // Re-emit through the unified runtime-metrics fan-out (PostHog + JSONL).
2573
2583
  emitRuntimeMetric(event)
2574
2584
  },
2575
2585
  onFrameworkFallback: async (ctx) => {
2576
- // Wording is load-bearing extracted to `formatFrameworkFallbackText`
2577
- // in `silence-poke.ts` so it can be snapshot-tested in isolation
2578
- // (CC-4 in `docs/status-ask-cause-classes.md`). Derives "N min" suffix
2579
- // from `ctx.silenceMs` so the wording stays honest if the 300s
2580
- // threshold is tuned.
2581
- const text = silencePoke.formatFrameworkFallbackText(
2582
- ctx.fallbackKind,
2583
- ctx.silenceMs,
2584
- ctx.inFlightTools,
2585
- )
2586
+ // Deterministic in-flight update status (klanker incident). If this
2587
+ // gateway dispatched an update_apply that's still running, the
2588
+ // recurring framework fallback carries hostd's REAL phase + elapsed
2589
+ // instead of the content-free "still working…". No model: pure
2590
+ // get_status snapshot → pure formatter. Any hostd unavailability
2591
+ // degrades silently to the existing generic text (zero regression).
2592
+ let text: string | null = null
2593
+ const upd = inFlightUpdate
2594
+ if (upd != null) {
2595
+ try {
2596
+ const st = await hostdGetStatusOnce(getMyAgentName(), upd.requestId)
2597
+ if (st !== 'not-configured' && st !== 'unavailable') {
2598
+ text = formatUpdateStatusLine(st, upd.startedAt, Date.now())
2599
+ }
2600
+ } catch {
2601
+ /* degrade to generic fallback below */
2602
+ }
2603
+ }
2604
+ if (text == null) {
2605
+ // Wording is load-bearing — extracted to `formatFrameworkFallbackText`
2606
+ // in `silence-poke.ts` so it can be snapshot-tested in isolation
2607
+ // (CC-4 in `docs/status-ask-cause-classes.md`). Derives "N min" suffix
2608
+ // from `ctx.silenceMs` so the wording stays honest if the 300s
2609
+ // threshold is tuned.
2610
+ text = silencePoke.formatFrameworkFallbackText(
2611
+ ctx.fallbackKind,
2612
+ ctx.silenceMs,
2613
+ ctx.inFlightTools,
2614
+ )
2615
+ }
2586
2616
  try {
2587
2617
  await robustApiCall(
2588
2618
  () => bot.api.sendMessage(ctx.chatId, text, {
@@ -8594,6 +8624,10 @@ bot.command('update', async ctx => {
8594
8624
  return
8595
8625
  }
8596
8626
  if (hostdResp.result === 'started') {
8627
+ // Mark in-flight so the framework silence fallback renders hostd's
8628
+ // real phase + elapsed (deterministic, model-free) instead of the
8629
+ // content-free "still working…" — the klanker incident fix.
8630
+ inFlightUpdate = { requestId: updateRequestId, startedAt: Date.now() }
8597
8631
  // RFC C §5.3: long-running mutation. Poll get_status until terminal
8598
8632
  // or until the recreate kills this gateway (whichever happens first).
8599
8633
  // The success signal is the post-restart greeting card edited into
@@ -8602,6 +8636,7 @@ bot.command('update', async ctx => {
8602
8636
  // doesn't leave the operator staring at the orphan "🚀 update started"
8603
8637
  // ack indefinitely. Live repro: PR #1305.
8604
8638
  void (async () => {
8639
+ try {
8605
8640
  // 60s budget: RFC C §5.3 specs `apply` at 30s and `update_apply`
8606
8641
  // at 60s. Image pulls + scaffold regeneration dominate the wall
8607
8642
  // clock for update_apply, hence the larger budget. The poll
@@ -8648,6 +8683,11 @@ bot.command('update', async ctx => {
8648
8683
  await switchroomReply(ctx, editedText, { html: true })
8649
8684
  } catch {}
8650
8685
  }
8686
+ } finally {
8687
+ // Poll resolved (terminal / completed / not-configured /
8688
+ // timeout) — stop substituting in-flight update status.
8689
+ inFlightUpdate = null
8690
+ }
8651
8691
  })()
8652
8692
  return
8653
8693
  }
@@ -124,6 +124,44 @@ export function hostdRequestId(prefix: string): string {
124
124
  return `${prefix}-${Date.now()}-${randomBytes(4).toString("hex")}`;
125
125
  }
126
126
 
127
+ /**
128
+ * Single-shot `get_status` snapshot for `targetRequestId` (NOT a
129
+ * poll-until-terminal — {@link pollHostdStatus} does that). Used by
130
+ * the framework silence-fallback to render a deterministic in-flight
131
+ * status line. Returns `"not-configured"` (hostd off / socket absent)
132
+ * or `"unavailable"` (wire error / hostd couldn't answer) so the
133
+ * caller can cleanly fall back to the generic fallback text — never
134
+ * throws, never blocks the fallback.
135
+ */
136
+ export async function hostdGetStatusOnce(
137
+ agentName: string,
138
+ targetRequestId: string,
139
+ ): Promise<HostdResponse | "not-configured" | "unavailable"> {
140
+ if (!isHostdEnabled()) return "not-configured";
141
+ const sockPath = hostdSocketPath(agentName);
142
+ if (!existsSync(sockPath)) return "not-configured";
143
+ try {
144
+ const resp = await hostdRequest(
145
+ { socketPath: sockPath, timeoutMs: 3000 },
146
+ {
147
+ v: 1,
148
+ op: "get_status",
149
+ request_id: hostdRequestId("gw-poke-status"),
150
+ args: { target_request_id: targetRequestId },
151
+ },
152
+ );
153
+ // hostd answered but couldn't resolve the entry → treat as
154
+ // unavailable so we degrade to the generic fallback, not a
155
+ // confusing "error" status line.
156
+ if (resp.result === "denied" || resp.result === "error") {
157
+ return "unavailable";
158
+ }
159
+ return resp;
160
+ } catch {
161
+ return "unavailable";
162
+ }
163
+ }
164
+
127
165
  /**
128
166
  * Poll hostd's `get_status` verb until the target request reaches a
129
167
  * terminal state (`completed` / `error` / `denied`) or the caller's
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Deterministic, model-free status line for an in-flight hostd
3
+ * `update_apply`. Pure function over hostd's `get_status` response —
4
+ * no model, no I/O. Used by the gateway's framework silence-fallback
5
+ * so the recurring "is it still going?" message carries hostd's
6
+ * actual phase + elapsed instead of the content-free
7
+ * "still working… no update from agent in N min" (the klanker
8
+ * incident: a multi-minute fleet update with zero visible status).
9
+ *
10
+ * The hostd CLI prints step banners as `▸ <step>` lines (pull-images,
11
+ * apply-config, recreate-containers, doctor, …) into stdout; hostd
12
+ * surfaces the last 4 KiB as `stdout_tail`. The phase is the LAST such
13
+ * banner — a deterministic string parse, no interpretation.
14
+ */
15
+
16
+ export interface UpdateStatusResponse {
17
+ result: string;
18
+ stdout_tail?: string;
19
+ stderr_tail?: string;
20
+ }
21
+
22
+ /** Latest `▸ <step>` banner in the tail, or null if none yet. */
23
+ export function latestHostdPhase(tail: string | undefined): string | null {
24
+ if (!tail) return null;
25
+ let phase: string | null = null;
26
+ for (const raw of tail.split("\n")) {
27
+ const m = /^\s*▸\s*(.+?)\s*$/.exec(raw);
28
+ if (m && m[1]) phase = m[1];
29
+ }
30
+ return phase;
31
+ }
32
+
33
+ function elapsedMin(startedAt: number, now: number): number {
34
+ return Math.max(1, Math.round((now - startedAt) / 60_000));
35
+ }
36
+
37
+ /**
38
+ * Compose the deterministic status line. `resp` is hostd's get_status
39
+ * for the in-flight update_apply; `startedAt` is when the gateway
40
+ * dispatched it; `now` is the current time.
41
+ */
42
+ export function formatUpdateStatusLine(
43
+ resp: UpdateStatusResponse,
44
+ startedAt: number,
45
+ now: number,
46
+ ): string {
47
+ const mins = elapsedMin(startedAt, now);
48
+ const tail = `Recreating the fleet (including me) — I'll report the result here when it's done.`;
49
+
50
+ // Terminal-but-gateway-still-alive: recreate hasn't killed us yet, or
51
+ // it failed before recreate. The dedicated terminal/boot path owns the
52
+ // final verdict; here we just stop claiming "in progress".
53
+ if (resp.result !== "started") {
54
+ return `⏳ Fleet update finishing — hostd reported \`${resp.result}\` (~${mins}m). ${tail}`;
55
+ }
56
+
57
+ const phase = latestHostdPhase(resp.stdout_tail);
58
+ return phase
59
+ ? `⏳ Fleet update in progress — phase: ${phase} (~${mins}m). ${tail}`
60
+ : `⏳ Fleet update in progress — starting (~${mins}m). ${tail}`;
61
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ latestHostdPhase,
4
+ formatUpdateStatusLine,
5
+ } from '../gateway/update-status-line.js'
6
+
7
+ const T0 = 1_000_000_000_000
8
+
9
+ describe('latestHostdPhase', () => {
10
+ it('returns null for empty / no-banner tail', () => {
11
+ expect(latestHostdPhase(undefined)).toBeNull()
12
+ expect(latestHostdPhase('')).toBeNull()
13
+ expect(latestHostdPhase('Applying switchroom config...\nWrote compose')).toBeNull()
14
+ })
15
+
16
+ it('returns the LAST ▸ banner (deterministic, last wins)', () => {
17
+ const tail =
18
+ '▸ pull-images\n some docker output\n▸ apply-config\n▸ recreate-containers\n'
19
+ expect(latestHostdPhase(tail)).toBe('recreate-containers')
20
+ })
21
+
22
+ it('tolerates leading/trailing whitespace around the banner', () => {
23
+ expect(latestHostdPhase(' ▸ doctor \n')).toBe('doctor')
24
+ })
25
+ })
26
+
27
+ describe('formatUpdateStatusLine', () => {
28
+ it('in-progress with a phase: shows phase + elapsed + report-back tail', () => {
29
+ const line = formatUpdateStatusLine(
30
+ { result: 'started', stdout_tail: '▸ pull-images\n▸ apply-config\n' },
31
+ T0,
32
+ T0 + 2 * 60_000 + 5_000, // ~2m
33
+ )
34
+ expect(line).toContain('Fleet update in progress')
35
+ expect(line).toContain('phase: apply-config')
36
+ expect(line).toContain('~2m')
37
+ expect(line).toMatch(/report the result here/i)
38
+ expect(line).not.toMatch(/still working|no update from agent/i)
39
+ })
40
+
41
+ it('in-progress before any banner: "starting"', () => {
42
+ const line = formatUpdateStatusLine(
43
+ { result: 'started', stdout_tail: 'Image ... Pulling' },
44
+ T0,
45
+ T0 + 30_000,
46
+ )
47
+ expect(line).toContain('Fleet update in progress')
48
+ expect(line).toContain('starting')
49
+ expect(line).not.toContain('phase:')
50
+ expect(line).toContain('~1m') // min 1m floor
51
+ })
52
+
53
+ it('elapsed rounds to nearest minute, floored at 1', () => {
54
+ expect(formatUpdateStatusLine({ result: 'started' }, T0, T0 + 5_000)).toContain('~1m')
55
+ expect(
56
+ formatUpdateStatusLine({ result: 'started' }, T0, T0 + 7 * 60_000 + 40_000),
57
+ ).toContain('~8m')
58
+ })
59
+
60
+ it('terminal-but-gateway-alive: stops claiming in-progress, defers to verdict path', () => {
61
+ const line = formatUpdateStatusLine(
62
+ { result: 'error', stderr_tail: 'boom' },
63
+ T0,
64
+ T0 + 3 * 60_000,
65
+ )
66
+ expect(line).toContain('finishing')
67
+ expect(line).toContain('`error`')
68
+ expect(line).not.toContain('in progress')
69
+ })
70
+ })