switchroom 0.14.28 → 0.14.29

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.
@@ -49420,8 +49420,8 @@ var {
49420
49420
  } = import__.default;
49421
49421
 
49422
49422
  // src/build-info.ts
49423
- var VERSION = "0.14.28";
49424
- var COMMIT_SHA = "cb64351f";
49423
+ var VERSION = "0.14.29";
49424
+ var COMMIT_SHA = "33f6300c";
49425
49425
 
49426
49426
  // src/cli/agent.ts
49427
49427
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.28",
3
+ "version": "0.14.29",
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": {
@@ -23349,6 +23349,10 @@ function projectSubagentLine(line, agentId, state) {
23349
23349
  events.push({ kind: "sub_agent_text", agentId, text });
23350
23350
  }
23351
23351
  }
23352
+ const stopReason = message?.stop_reason;
23353
+ if (stopReason === "end_turn") {
23354
+ events.push({ kind: "sub_agent_turn_end", agentId });
23355
+ }
23352
23356
  return events;
23353
23357
  }
23354
23358
  if (type === "system" && obj.subtype === "turn_duration") {
@@ -49160,6 +49160,10 @@ function projectSubagentLine(line, agentId, state4) {
49160
49160
  events.push({ kind: "sub_agent_text", agentId, text });
49161
49161
  }
49162
49162
  }
49163
+ const stopReason = message?.stop_reason;
49164
+ if (stopReason === "end_turn") {
49165
+ events.push({ kind: "sub_agent_turn_end", agentId });
49166
+ }
49163
49167
  return events;
49164
49168
  }
49165
49169
  if (type === "system" && obj.subtype === "turn_duration") {
@@ -49297,6 +49301,10 @@ function redactSecrets(text) {
49297
49301
  return out;
49298
49302
  }
49299
49303
  // registry/subagents-schema.ts
49304
+ function countRunningBackgroundSubagents(db2) {
49305
+ const row = db2.prepare("SELECT count(*) AS n FROM subagents WHERE background = 1 AND status = 'running'").get();
49306
+ return row?.n ?? 0;
49307
+ }
49300
49308
  function recordSubagentEnd(db2, args) {
49301
49309
  db2.prepare(`
49302
49310
  UPDATE subagents
@@ -50006,6 +50014,15 @@ function startSubagentWatcher(config) {
50006
50014
  },
50007
50015
  getRegistry() {
50008
50016
  return registry;
50017
+ },
50018
+ countRunningBackgroundWorkers() {
50019
+ if (db2 == null)
50020
+ return null;
50021
+ try {
50022
+ return countRunningBackgroundSubagents(db2);
50023
+ } catch {
50024
+ return null;
50025
+ }
50009
50026
  }
50010
50027
  };
50011
50028
  }
@@ -51642,10 +51659,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51642
51659
  }
51643
51660
 
51644
51661
  // ../src/build-info.ts
51645
- var VERSION = "0.14.28";
51646
- var COMMIT_SHA = "cb64351f";
51647
- var COMMIT_DATE = "2026-06-01T04:23:25Z";
51648
- var LATEST_PR = 2049;
51662
+ var VERSION = "0.14.29";
51663
+ var COMMIT_SHA = "33f6300c";
51664
+ var COMMIT_DATE = "2026-06-01T04:54:34Z";
51665
+ var LATEST_PR = 2052;
51649
51666
  var COMMITS_AHEAD_OF_TAG = 0;
51650
51667
 
51651
51668
  // gateway/boot-version.ts
@@ -53063,6 +53080,9 @@ function finalizeStatusReaction(chatId, threadId, reason = "done") {
53063
53080
  purgeReactionTracking(key);
53064
53081
  }
53065
53082
  function countRunningWorkers() {
53083
+ const dbCount = subagentWatcher?.countRunningBackgroundWorkers?.();
53084
+ if (dbCount != null)
53085
+ return dbCount;
53066
53086
  const reg = subagentWatcher?.getRegistry();
53067
53087
  if (reg == null)
53068
53088
  return 0;
@@ -17387,6 +17387,10 @@ function projectSubagentLine(line, agentId, state) {
17387
17387
  events.push({ kind: "sub_agent_text", agentId, text });
17388
17388
  }
17389
17389
  }
17390
+ const stopReason = message?.stop_reason;
17391
+ if (stopReason === "end_turn") {
17392
+ events.push({ kind: "sub_agent_turn_end", agentId });
17393
+ }
17390
17394
  return events;
17391
17395
  }
17392
17396
  if (type === "system" && obj.subtype === "turn_duration") {
@@ -2119,6 +2119,13 @@ function finalizeStatusReaction(
2119
2119
  * fired its `done`/`failed` onFinish no longer counts here.
2120
2120
  */
2121
2121
  function countRunningWorkers(): number {
2122
+ // Prefer the dispatch-time DB count: a background worker's row is INSERTed
2123
+ // `status='running'` when its `Agent` tool_use fires, i.e. BEFORE the parent
2124
+ // turn ends. The registry below is populated by on-disk file discovery, which
2125
+ // lags dispatch by a poll/fswatch tick — so a just-dispatched worker was
2126
+ // invisible to the deferred-done gate and the 👍 promoted prematurely.
2127
+ const dbCount = subagentWatcher?.countRunningBackgroundWorkers?.()
2128
+ if (dbCount != null) return dbCount
2122
2129
  const reg = subagentWatcher?.getRegistry()
2123
2130
  if (reg == null) return 0
2124
2131
  let n = 0
@@ -360,6 +360,41 @@ export function getSubagentByJsonlId(db: SqliteDatabase, jsonlAgentId: string):
360
360
  return row ? mapSubagentRow(row) : null
361
361
  }
362
362
 
363
+ /**
364
+ * Count background subagents that have not yet reached a terminal state.
365
+ *
366
+ * This is the dispatch-time source of truth for "is a background worker still
367
+ * running" — the row is INSERTed with `status='running'` by `recordSubagentStart`
368
+ * the moment the parent's `Agent` tool_use fires (keyed on the `toolu_…` id),
369
+ * which is BEFORE the parent's turn ends. The deferred-done-reaction gate reads
370
+ * this so it holds the 👍 the instant a worker is dispatched, rather than
371
+ * snapshotting the file-discovery registry (which lags dispatch by a poll/fswatch
372
+ * tick and so missed just-dispatched workers — the premature-👍 race).
373
+ *
374
+ * Counts `running` ONLY — `stalled` is deliberately excluded. `stalled` is NOT
375
+ * a terminal status: the reaper (`reapStuckRunningRows`) transitions a row to
376
+ * `stalled`, never to `completed`/`failed`. A genuinely-orphaned background row
377
+ * — one INSERTed at dispatch whose JSONL was never linked, so no activity ever
378
+ * bumped it and the in-memory silent-stall synthesis never terminalised it —
379
+ * sits in `stalled` indefinitely (the 1h reaper TTL is the only thing that
380
+ * moves it off `running`). Counting `stalled` would wedge the deferred 👍 above
381
+ * zero forever for that row (`reaction-defer.ts` `promote()` bails while the
382
+ * count is > 0). A live-but-quiet worker, by contrast, is driven to `completed`
383
+ * by the watcher's terminal paths (end_turn signal OR silent-stall synthesis,
384
+ * both call `recordSubagentEnd`) long before the 1h reaper, and a stalled row
385
+ * that genuinely resumes is flipped back to `running` by `recordSubagentResume`
386
+ * — so excluding `stalled` never releases the 👍 on a worker that's merely
387
+ * paused rather than dead.
388
+ */
389
+ export function countRunningBackgroundSubagents(db: SqliteDatabase): number {
390
+ const row = db
391
+ .prepare(
392
+ "SELECT count(*) AS n FROM subagents WHERE background = 1 AND status = 'running'",
393
+ )
394
+ .get() as { n: number } | undefined
395
+ return row?.n ?? 0
396
+ }
397
+
363
398
  /**
364
399
  * Record that a subagent has reached a terminal state (completed or failed).
365
400
  * Sets `ended_at`, `status`, and optionally `result_summary`.
@@ -28,6 +28,7 @@ import {
28
28
  bumpSubagentActivity,
29
29
  getSubagent,
30
30
  reapStuckRunningRows,
31
+ countRunningBackgroundSubagents,
31
32
  } from './subagents-schema.js'
32
33
 
33
34
  // ---------------------------------------------------------------------------
@@ -182,6 +183,83 @@ describe('recordSubagentStart + recordSubagentEnd happy path', () => {
182
183
  })
183
184
  })
184
185
 
186
+ // ---------------------------------------------------------------------------
187
+ // countRunningBackgroundSubagents — the dispatch-time gate for the
188
+ // deferred-done 👍 reaction. A row counts as "still running" the instant
189
+ // recordSubagentStart inserts it (status='running'), closing the
190
+ // file-discovery registration race that promoted the 👍 prematurely.
191
+ // ---------------------------------------------------------------------------
192
+
193
+ describe('countRunningBackgroundSubagents', () => {
194
+ it('counts a background worker the moment it starts (before any terminal)', () => {
195
+ const db = openFreshSubagentsDbInMemory()
196
+ expect(countRunningBackgroundSubagents(db)).toBe(0)
197
+ recordSubagentStart(db, { id: 'bg-1', background: true, startedAt: 1000 })
198
+ expect(countRunningBackgroundSubagents(db)).toBe(1)
199
+ db.close()
200
+ })
201
+
202
+ it('does NOT count a stalled worker — stalled is the reaper sink, never terminalised', () => {
203
+ // A `stalled` row is NOT terminal and is NOT actively running. The only
204
+ // way a background row reaches `stalled` is the 1h reaper firing on a row
205
+ // that never linked a JSONL (no activity bumps, no silent-stall synthesis
206
+ // to drive it to `completed`) — i.e. an orphaned/dead dispatch. Counting it
207
+ // would wedge the deferred 👍 above zero forever (promote() bails while the
208
+ // count is > 0). A live-but-quiet worker terminalises to `completed` long
209
+ // before the reaper, so `stalled` always means "dead" here.
210
+ const db = openFreshSubagentsDbInMemory()
211
+ recordSubagentStart(db, { id: 'bg-2', background: true, startedAt: 1000 })
212
+ recordSubagentStall(db, { id: 'bg-2', stalledAt: 1500 })
213
+ expect(countRunningBackgroundSubagents(db)).toBe(0)
214
+ db.close()
215
+ })
216
+
217
+ it('a row reaped to stalled does not keep the gate above zero (permanent-👍-hold regression guard)', () => {
218
+ // Regression guard for the orphaned-dispatch wedge: dispatch inserts a
219
+ // `running` row, the JSONL never links, and the reaper transitions it to
220
+ // `stalled`. The deferred-👍 gate must read zero so promote() can fire —
221
+ // counting the reaped row would hold the 👍 forever.
222
+ const db = openFreshSubagentsDbInMemory()
223
+ recordSubagentStart(db, { id: 'bg-orphan', background: true, startedAt: 1000 })
224
+ expect(countRunningBackgroundSubagents(db)).toBe(1)
225
+ const result = reapStuckRunningRows(db, { ttlMs: 500, now: 5000 })
226
+ expect(result.reaped).toBe(1)
227
+ expect(getSubagent(db, 'bg-orphan')!.status).toBe('stalled')
228
+ expect(countRunningBackgroundSubagents(db)).toBe(0)
229
+ db.close()
230
+ })
231
+
232
+ it('re-counts a stalled worker that resumes — recordSubagentResume flips it back to running', () => {
233
+ // A worker that's merely paused (not dead) and resumes JSONL activity is
234
+ // flipped stalled → running by recordSubagentResume, so the gate holds the
235
+ // 👍 again. Excluding `stalled` from the count never releases the 👍 on a
236
+ // worker that's only paused.
237
+ const db = openFreshSubagentsDbInMemory()
238
+ recordSubagentStart(db, { id: 'bg-resume', background: true, startedAt: 1000 })
239
+ recordSubagentStall(db, { id: 'bg-resume', stalledAt: 1500 })
240
+ expect(countRunningBackgroundSubagents(db)).toBe(0)
241
+ recordSubagentResume(db, { id: 'bg-resume', resumedAt: 2000 })
242
+ expect(countRunningBackgroundSubagents(db)).toBe(1)
243
+ db.close()
244
+ })
245
+
246
+ it('drops to zero once the worker reaches a terminal status', () => {
247
+ const db = openFreshSubagentsDbInMemory()
248
+ recordSubagentStart(db, { id: 'bg-3', background: true, startedAt: 1000 })
249
+ expect(countRunningBackgroundSubagents(db)).toBe(1)
250
+ recordSubagentEnd(db, { id: 'bg-3', endedAt: 2000, status: 'completed' })
251
+ expect(countRunningBackgroundSubagents(db)).toBe(0)
252
+ db.close()
253
+ })
254
+
255
+ it('ignores foreground subagents — only background workers gate the reaction', () => {
256
+ const db = openFreshSubagentsDbInMemory()
257
+ recordSubagentStart(db, { id: 'fg-1', background: false, startedAt: 1000 })
258
+ expect(countRunningBackgroundSubagents(db)).toBe(0)
259
+ db.close()
260
+ })
261
+ })
262
+
185
263
  // ---------------------------------------------------------------------------
186
264
  // Test 4 — start → stall → end
187
265
  // ---------------------------------------------------------------------------
@@ -390,6 +390,21 @@ export function projectSubagentLine(
390
390
  events.push({ kind: 'sub_agent_text', agentId, text })
391
391
  }
392
392
  }
393
+ // Authoritative early terminal: a background `Agent` worker's JSONL on
394
+ // claude ≥2.1.156 never writes the `system/turn_duration` line below, so
395
+ // the watcher used to only learn the worker finished via the ~5-min
396
+ // silent-stall synthesis net — leaving the card stuck "running" and the
397
+ // deferred 👍 held for minutes after the work was actually done. The
398
+ // worker DOES write a final assistant message with
399
+ // `stop_reason: 'end_turn'` (a tool-using turn is `'tool_use'` and keeps
400
+ // going), so treat that as the terminal signal. Emitted AFTER the content
401
+ // events so the final text/preamble still renders; the watcher's turn_end
402
+ // handler is guarded on `state === 'running'`, so a later real
403
+ // turn_duration line is a no-op.
404
+ const stopReason = message?.stop_reason as string | undefined
405
+ if (stopReason === 'end_turn') {
406
+ events.push({ kind: 'sub_agent_turn_end', agentId })
407
+ }
393
408
  return events
394
409
  }
395
410
 
@@ -43,7 +43,7 @@ import { homedir } from 'os'
43
43
  import { projectSubagentLine, sanitizeCwdToProjectName, detectErrorInTranscriptLine } from './session-tail.js'
44
44
  import { sanitiseToolArg } from './fleet-state.js'
45
45
  import { escapeHtml, truncate } from './card-format.js'
46
- import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows } from './registry/subagents-schema.js'
46
+ import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows, countRunningBackgroundSubagents } from './registry/subagents-schema.js'
47
47
  import { touchTurnActiveMarker } from './gateway/turn-active-marker.js'
48
48
 
49
49
  // ─── Types ───────────────────────────────────────────────────────────────────
@@ -377,6 +377,13 @@ export interface SubagentWatcherHandle {
377
377
  stop(): void
378
378
  /** Snapshot of current registry for tests/inspection. */
379
379
  getRegistry(): ReadonlyMap<string, WorkerEntry>
380
+ /**
381
+ * Count background workers still in flight, read from the dispatch-time DB
382
+ * (not the file-discovery registry). Returns null when no DB is wired so the
383
+ * caller can fall back to the registry snapshot. Drives the deferred-done
384
+ * reaction gate — see `countRunningBackgroundSubagents`.
385
+ */
386
+ countRunningBackgroundWorkers(): number | null
380
387
  }
381
388
 
382
389
  // ─── Constants ───────────────────────────────────────────────────────────────
@@ -1498,5 +1505,16 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1498
1505
  getRegistry(): ReadonlyMap<string, WorkerEntry> {
1499
1506
  return registry
1500
1507
  },
1508
+
1509
+ countRunningBackgroundWorkers(): number | null {
1510
+ if (db == null) return null
1511
+ try {
1512
+ return countRunningBackgroundSubagents(db)
1513
+ } catch {
1514
+ // A torn/locked DB read must not wedge the reaction gate — fall back
1515
+ // to the registry snapshot by returning null.
1516
+ return null
1517
+ }
1518
+ },
1501
1519
  }
1502
1520
  }
@@ -492,6 +492,49 @@ describe('projectSubagentLine', () => {
492
492
  expect(events).toEqual([{ kind: 'sub_agent_turn_end', agentId: 'X' }])
493
493
  })
494
494
 
495
+ it('emits sub_agent_turn_end after the text when the final assistant message stop_reason is end_turn', () => {
496
+ // Background `Agent` workers (claude ≥2.1.156) never write the
497
+ // system/turn_duration line, only a final assistant message with
498
+ // stop_reason 'end_turn'. That IS the authoritative completion signal —
499
+ // without treating it as terminal the card hung "running" until the
500
+ // ~5-min stall-synthesis net fired (the screenshot bug).
501
+ const st = { hasEmittedStart: true }
502
+ const events = projectSubagentLine(
503
+ JSON.stringify({
504
+ type: 'assistant',
505
+ message: {
506
+ stop_reason: 'end_turn',
507
+ content: [{ type: 'text', text: 'Done. Fixed the bug.' }],
508
+ },
509
+ }),
510
+ 'X',
511
+ st,
512
+ )
513
+ // Text first (so the final summary still renders), turn_end last.
514
+ expect(events).toEqual([
515
+ { kind: 'sub_agent_text', agentId: 'X', text: 'Done. Fixed the bug.' },
516
+ { kind: 'sub_agent_turn_end', agentId: 'X' },
517
+ ])
518
+ })
519
+
520
+ it('does NOT emit sub_agent_turn_end for a tool-using assistant message (stop_reason tool_use)', () => {
521
+ // A mid-run assistant message that calls a tool has stop_reason 'tool_use'
522
+ // and keeps going — it must not be mistaken for completion.
523
+ const st = { hasEmittedStart: true }
524
+ const events = projectSubagentLine(
525
+ JSON.stringify({
526
+ type: 'assistant',
527
+ message: {
528
+ stop_reason: 'tool_use',
529
+ content: [{ type: 'tool_use', id: 'toolu_a', name: 'Read', input: { file_path: '/a' } }],
530
+ },
531
+ }),
532
+ 'X',
533
+ st,
534
+ )
535
+ expect(events.some((e) => e.kind === 'sub_agent_turn_end')).toBe(false)
536
+ })
537
+
495
538
  it('skips malformed lines silently', () => {
496
539
  const st = { hasEmittedStart: false }
497
540
  expect(projectSubagentLine('{not-json', 'X', st)).toEqual([])