switchroom 0.14.15 → 0.14.16

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.
@@ -49413,8 +49413,8 @@ var {
49413
49413
  } = import__.default;
49414
49414
 
49415
49415
  // src/build-info.ts
49416
- var VERSION = "0.14.15";
49417
- var COMMIT_SHA = "e0f95f64";
49416
+ var VERSION = "0.14.16";
49417
+ var COMMIT_SHA = "6f5d9562";
49418
49418
 
49419
49419
  // src/cli/agent.ts
49420
49420
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.15",
3
+ "version": "0.14.16",
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": {
@@ -51140,10 +51140,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51140
51140
  }
51141
51141
 
51142
51142
  // ../src/build-info.ts
51143
- var VERSION = "0.14.15";
51144
- var COMMIT_SHA = "e0f95f64";
51145
- var COMMIT_DATE = "2026-05-30T04:03:13Z";
51146
- var LATEST_PR = 2001;
51143
+ var VERSION = "0.14.16";
51144
+ var COMMIT_SHA = "6f5d9562";
51145
+ var COMMIT_DATE = "2026-05-30T04:37:39Z";
51146
+ var LATEST_PR = 2003;
51147
51147
  var COMMITS_AHEAD_OF_TAG = 0;
51148
51148
 
51149
51149
  // gateway/boot-version.ts
@@ -61331,18 +61331,9 @@ var didOneTimeSetup = false;
61331
61331
  },
61332
61332
  onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs }) => {
61333
61333
  deferredDoneReactions.promote();
61334
- if (workerFeedEnabled) {
61335
- workerActivityFeed.finish(agentId, {
61336
- description,
61337
- lastTool: null,
61338
- toolCount,
61339
- latestSummary: resultText,
61340
- elapsedMs: durationMs,
61341
- state: outcome === "failed" ? "failed" : "done"
61342
- });
61343
- }
61344
61334
  let fleetChatId = "";
61345
61335
  let isBackground = false;
61336
+ let dispatchDesc = "";
61346
61337
  try {
61347
61338
  const fleets = progressDriver?.peekAllFleets() ?? [];
61348
61339
  for (const f of fleets) {
@@ -61354,11 +61345,23 @@ var didOneTimeSetup = false;
61354
61345
  } catch {}
61355
61346
  if (turnsDb != null) {
61356
61347
  try {
61357
- const row = turnsDb.prepare("SELECT background FROM subagents WHERE jsonl_agent_id = ?").get(agentId);
61358
- if (row != null)
61348
+ const row = turnsDb.prepare("SELECT background, description FROM subagents WHERE jsonl_agent_id = ?").get(agentId);
61349
+ if (row != null) {
61359
61350
  isBackground = row.background === 1;
61351
+ dispatchDesc = row.description ?? "";
61352
+ }
61360
61353
  } catch {}
61361
61354
  }
61355
+ if (workerFeedEnabled) {
61356
+ workerActivityFeed.finish(agentId, {
61357
+ description: dispatchDesc || description,
61358
+ lastTool: null,
61359
+ toolCount,
61360
+ latestSummary: resultText,
61361
+ elapsedMs: durationMs,
61362
+ state: outcome === "failed" ? "failed" : "done"
61363
+ });
61364
+ }
61362
61365
  const decision = decideSubagentHandback({
61363
61366
  handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
61364
61367
  outcome,
@@ -61394,6 +61397,7 @@ var didOneTimeSetup = false;
61394
61397
  onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount }) => {
61395
61398
  let fleetChatId = "";
61396
61399
  let isBackground = false;
61400
+ let dispatchDesc = "";
61397
61401
  try {
61398
61402
  const fleets = progressDriver?.peekAllFleets() ?? [];
61399
61403
  for (const f of fleets) {
@@ -61405,16 +61409,18 @@ var didOneTimeSetup = false;
61405
61409
  } catch {}
61406
61410
  if (turnsDb != null) {
61407
61411
  try {
61408
- const row = turnsDb.prepare("SELECT background FROM subagents WHERE jsonl_agent_id = ?").get(agentId);
61409
- if (row != null)
61412
+ const row = turnsDb.prepare("SELECT background, description FROM subagents WHERE jsonl_agent_id = ?").get(agentId);
61413
+ if (row != null) {
61410
61414
  isBackground = row.background === 1;
61415
+ dispatchDesc = row.description ?? "";
61416
+ }
61411
61417
  } catch {}
61412
61418
  }
61413
61419
  if (!isBackground)
61414
61420
  return;
61415
61421
  if (workerFeedEnabled) {
61416
61422
  workerActivityFeed.update(agentId, fleetChatId || (loadAccess().allowFrom[0] ?? ""), {
61417
- description,
61423
+ description: dispatchDesc || description,
61418
61424
  lastTool,
61419
61425
  toolCount,
61420
61426
  latestSummary,
@@ -17396,21 +17396,6 @@ void (async () => {
17396
17396
  // handback gating below, so it must run before any early
17397
17397
  // return. Cheap no-op when nothing is deferred.
17398
17398
  deferredDoneReactions.promote()
17399
- // #PR2 live worker-feed: force the terminal recap edit on
17400
- // the worker's live message. No-op when no message was ever
17401
- // posted (trivial workers stay silent; handback covers them).
17402
- // 'orphan' is a stale boot row, not a fresh completion — map
17403
- // it to 'done' so an already-posted message still finalizes.
17404
- if (workerFeedEnabled) {
17405
- void workerActivityFeed.finish(agentId, {
17406
- description,
17407
- lastTool: null,
17408
- toolCount,
17409
- latestSummary: resultText,
17410
- elapsedMs: durationMs,
17411
- state: outcome === 'failed' ? 'failed' : 'done',
17412
- })
17413
- }
17414
17399
  // IO: resolve the fleet chat id and the background flag.
17415
17400
  // The DECISION (gating + inbound build) is delegated to
17416
17401
  // the pure `decideSubagentHandback` so it is unit-tested
@@ -17418,6 +17403,10 @@ void (async () => {
17418
17403
  // `subagent-handback-decision.test.ts`.
17419
17404
  let fleetChatId = ''
17420
17405
  let isBackground = false
17406
+ // Dispatch-time task description from the registry row —
17407
+ // see the onProgress note; used for the feed's terminal recap
17408
+ // header so it matches the running header ("· <real task>").
17409
+ let dispatchDesc = ''
17421
17410
  try {
17422
17411
  const fleets = progressDriver?.peekAllFleets() ?? []
17423
17412
  for (const f of fleets) {
@@ -17433,11 +17422,29 @@ void (async () => {
17433
17422
  if (turnsDb != null) {
17434
17423
  try {
17435
17424
  const row = turnsDb
17436
- .prepare('SELECT background FROM subagents WHERE jsonl_agent_id = ?')
17437
- .get(agentId) as { background: number } | undefined
17438
- if (row != null) isBackground = row.background === 1
17425
+ .prepare('SELECT background, description FROM subagents WHERE jsonl_agent_id = ?')
17426
+ .get(agentId) as { background: number; description: string | null } | undefined
17427
+ if (row != null) {
17428
+ isBackground = row.background === 1
17429
+ dispatchDesc = row.description ?? ''
17430
+ }
17439
17431
  } catch { /* best-effort */ }
17440
17432
  }
17433
+ // #PR2 live worker-feed: force the terminal recap edit on
17434
+ // the worker's live message. No-op when no message was ever
17435
+ // posted (trivial workers stay silent; handback covers them).
17436
+ // 'orphan' is a stale boot row, not a fresh completion — map
17437
+ // it to 'done' so an already-posted message still finalizes.
17438
+ if (workerFeedEnabled) {
17439
+ void workerActivityFeed.finish(agentId, {
17440
+ description: dispatchDesc || description,
17441
+ lastTool: null,
17442
+ toolCount,
17443
+ latestSummary: resultText,
17444
+ elapsedMs: durationMs,
17445
+ state: outcome === 'failed' ? 'failed' : 'done',
17446
+ })
17447
+ }
17441
17448
 
17442
17449
  const decision = decideSubagentHandback({
17443
17450
  handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
@@ -17511,6 +17518,11 @@ void (async () => {
17511
17518
  onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount }) => {
17512
17519
  let fleetChatId = ''
17513
17520
  let isBackground = false
17521
+ // The watcher's `description` is its 'sub-agent' default (it
17522
+ // never reassigns it from the worker jsonl). The dispatch-time
17523
+ // task description lives in the registry row — prefer it so the
17524
+ // feed header reads "🔧 Worker · <real task>" not "· sub-agent".
17525
+ let dispatchDesc = ''
17514
17526
  try {
17515
17527
  const fleets = progressDriver?.peekAllFleets() ?? []
17516
17528
  for (const f of fleets) {
@@ -17523,9 +17535,12 @@ void (async () => {
17523
17535
  if (turnsDb != null) {
17524
17536
  try {
17525
17537
  const row = turnsDb
17526
- .prepare('SELECT background FROM subagents WHERE jsonl_agent_id = ?')
17527
- .get(agentId) as { background: number } | undefined
17528
- if (row != null) isBackground = row.background === 1
17538
+ .prepare('SELECT background, description FROM subagents WHERE jsonl_agent_id = ?')
17539
+ .get(agentId) as { background: number; description: string | null } | undefined
17540
+ if (row != null) {
17541
+ isBackground = row.background === 1
17542
+ dispatchDesc = row.description ?? ''
17543
+ }
17529
17544
  } catch { /* best-effort */ }
17530
17545
  }
17531
17546
  if (!isBackground) return // skip overhead for foreground
@@ -17541,7 +17556,7 @@ void (async () => {
17541
17556
  agentId,
17542
17557
  fleetChatId || (loadAccess().allowFrom[0] ?? ''),
17543
17558
  {
17544
- description,
17559
+ description: dispatchDesc || description,
17545
17560
  lastTool,
17546
17561
  toolCount,
17547
17562
  latestSummary,
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Live worker-activity feed (#2000) — UAT.
3
+ *
4
+ * A *background* sub-agent decouples from the parent turn; when the turn
5
+ * ends nothing surfaces its ongoing jsonl activity and a long worker
6
+ * reads as silence. The feed (flag `SWITCHROOM_WORKER_ACTIVITY_FEED=1`,
7
+ * set on the test-harness agent for this run) posts ONE regular Telegram
8
+ * message per background worker and edits it in place — current tool +
9
+ * short summary + elapsed — finalizing with a recap on completion.
10
+ *
11
+ * This scenario dispatches a real background worker (~60s of paced
12
+ * sleep/echo work, so it narrates between tools and the feed can paint
13
+ * + edit), then asserts:
14
+ *
15
+ * 1. a worker-feed message appears (🔧 Worker · …), distinct from the
16
+ * parent's ack reply — proving background activity surfaces after
17
+ * the parent turn closed;
18
+ * 2. the message edits in place while work is in flight (body changes
19
+ * across a window) — proving it's live, not a one-shot post;
20
+ * 3. it finalizes to the terminal recap (✅ Worker done · … / N tools).
21
+ *
22
+ * It logs every observed body so a human can read the real rendered UX.
23
+ *
24
+ * Prompt is the deterministic Option-1 dispatch from
25
+ * `bg-sub-agent-dispatch-dm.test.ts` (naming the Agent tool + arg keeps
26
+ * the model from running the sleeps inline via Bash).
27
+ */
28
+
29
+ import { describe, expect, it } from "vitest";
30
+ import { spinUp } from "../harness.js";
31
+
32
+ // The worker must keep its jsonl ticking faster than the *test-harness*
33
+ // stall window (SWITCHROOM_SUBAGENT_STALL_MS=5000 /
34
+ // SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS=10000 in switchroom.yaml — see
35
+ // PR #1110): a worker silent for >15s gets a *synthesized* terminal
36
+ // turn_end mid-flight, which flips the watcher entry to `done` and
37
+ // suppresses every later onProgress (the feed then never paints). Long
38
+ // silent `sleep 20`s tripped exactly that. So we drive ~10 short steps,
39
+ // each its own Bash call with a one-line narration, keeping the gap
40
+ // between jsonl emissions ~2s — well under the 5s stall floor — for
41
+ // ~30-40s total: long enough to clear the 8s first-paint, throttle, and
42
+ // land several in-place edits before the real end_turn.
43
+ const BG_DISPATCH_PROMPT =
44
+ `Use the Agent tool with subagent_type "general-purpose" and ` +
45
+ `run_in_background: true to dispatch a worker with this exact task: ` +
46
+ `"Do ten steps, ONE AT A TIME, k = 1 through 10. Before each step ` +
47
+ `write a brief one-sentence narration of what you are about to do, ` +
48
+ `then run \`sleep 2\` via the Bash tool, then run \`echo step-k\` via ` +
49
+ `the Bash tool (substitute the real number for k). Run every sleep and ` +
50
+ `every echo as its OWN separate Bash call — never batch or chain them ` +
51
+ `with && — and narrate before each so progress surfaces incrementally. ` +
52
+ `Do not stop early; complete all ten steps." After dispatching, send a ` +
53
+ `brief reply saying you've kicked off the background worker so I can ` +
54
+ `watch its progress.`;
55
+
56
+ // The feed header rendered in Telegram: "🔧 Worker · <desc>" (running)
57
+ // or "✅ Worker done · …" / "⚠️ Worker failed · …" (terminal).
58
+ const WORKER_FEED_RE = /🔧\s*Worker|Worker done|Worker failed|⚡/i;
59
+ const WORKER_DONE_RE = /✅\s*Worker done|⚠️\s*Worker failed/i;
60
+
61
+ describe("uat: live worker-activity feed (#2000)", () => {
62
+ it(
63
+ "surfaces a background worker as a live, editing message that finalizes",
64
+ async () => {
65
+ const sc = await spinUp({ agent: "test-harness" });
66
+ try {
67
+ await sc.sendDM(BG_DISPATCH_PROMPT);
68
+
69
+ // Parent ack — some bot reply so we know the parent turn closed.
70
+ const ack = await sc.expectMessage(/.+/, {
71
+ from: "bot",
72
+ timeout: 45_000,
73
+ });
74
+ console.log(`[worker-feed UAT] parent ack: ${JSON.stringify(ack.text)}`);
75
+
76
+ // The worker-feed message. May arrive after the parent ack since
77
+ // first-paint waits for the worker to run ~8s and narrate.
78
+ const feed = await sc.expectMessage(WORKER_FEED_RE, {
79
+ from: "bot",
80
+ timeout: 75_000,
81
+ });
82
+ console.log(
83
+ `[worker-feed UAT] first feed paint (id=${feed.messageId}): ${JSON.stringify(feed.text)}`,
84
+ );
85
+ expect(feed.messageId).toBeGreaterThan(0);
86
+
87
+ // Live edit: snapshot, wait past the throttle + a heartbeat, and
88
+ // re-fetch the SAME message. Body should change as work advances.
89
+ // Soft: a very terse worker might narrate only once; we still
90
+ // require the terminal recap below, which is the load-bearing
91
+ // proof. Log either way so the real cadence is visible.
92
+ const before = feed.text;
93
+ await new Promise((r) => setTimeout(r, 12_000));
94
+ const mid = await sc.driver.getMessage(sc.botUserId, feed.messageId);
95
+ console.log(
96
+ `[worker-feed UAT] after 12s (id=${feed.messageId}): ${JSON.stringify(mid?.text ?? null)}`,
97
+ );
98
+ expect(mid, "worker-feed message vanished mid-flight").not.toBeNull();
99
+
100
+ // Terminal recap — poll the same message until it flips to the
101
+ // done/failed header. Generous budget: ~60s of work + finalize.
102
+ let doneText: string | null = null;
103
+ const deadline = Date.now() + 120_000;
104
+ while (Date.now() < deadline) {
105
+ const m = await sc.driver.getMessage(sc.botUserId, feed.messageId);
106
+ if (m != null && WORKER_DONE_RE.test(m.text)) {
107
+ doneText = m.text;
108
+ break;
109
+ }
110
+ await new Promise((r) => setTimeout(r, 5_000));
111
+ }
112
+ console.log(
113
+ `[worker-feed UAT] terminal (id=${feed.messageId}): ${JSON.stringify(doneText)}`,
114
+ );
115
+ expect(doneText, "worker-feed never reached a terminal recap").not.toBeNull();
116
+ expect(doneText!).toMatch(/tools?|tool ·/i);
117
+ // Did the body actually move between first paint and terminal?
118
+ expect(doneText).not.toBe(before);
119
+ } finally {
120
+ await sc.tearDown();
121
+ }
122
+ },
123
+ 240_000,
124
+ );
125
+ });