switchroom 0.14.28 → 0.14.30

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.30";
49424
+ var COMMIT_SHA = "84f50bd2";
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.30",
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") {
@@ -38932,7 +38932,7 @@ var EDIT_INTERVAL_MS = 60000;
38932
38932
  var POLL_INTERVAL_MS = 5000;
38933
38933
  var MAX_LIFETIME_MS = 30 * 60000;
38934
38934
  var TELEGRAM_MSG_CAP2 = 4000;
38935
- var SUFFIX_RE = /\n\n\u2014 still working \(\d+m\)$/;
38935
+ var SUFFIX_RE = /\n\n\u2014 still working \(\d+m\)( \u00b7 message me anytime, I'll keep you posted)?$/;
38936
38936
  var stateByKey = new Map;
38937
38937
  var timer2 = null;
38938
38938
  var activeDeps2 = null;
@@ -39044,7 +39044,7 @@ function tick2(now) {
39044
39044
  const minutes = Math.max(1, Math.round(elapsed / 60000));
39045
39045
  const suffix = `
39046
39046
 
39047
- \u2014 still working (${minutes}m)`;
39047
+ \u2014 still working (${minutes}m) \u00b7 message me anytime, I'll keep you posted`;
39048
39048
  const newText = s.anchorOriginalText + suffix;
39049
39049
  if (newText.length > TELEGRAM_MSG_CAP2) {
39050
39050
  s.lastEditAt = now;
@@ -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.30";
51663
+ var COMMIT_SHA = "84f50bd2";
51664
+ var COMMIT_DATE = "2026-06-01T06:07:00Z";
51665
+ var LATEST_PR = 2058;
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
@@ -35,7 +35,11 @@
35
35
  * turn_end with pending+anchor → activate the timer for the key
36
36
  * tick (every 5s, edit every → editMessageText against the anchor
37
37
  * EDIT_INTERVAL_MS) appending/refreshing the suffix
38
- * " — still working (Nm)"
38
+ * " — still working (Nm) · message me
39
+ * anytime, I'll keep you posted"
40
+ * (the reachability clause signals the
41
+ * agent is still listening while a
42
+ * background worker runs — issue PR3)
39
43
  * inbound user message → clear (user re-engaged or moved on)
40
44
  * subagent_handback inject → clear (model about to re-engage)
41
45
  * MAX_LIFETIME_MS budget cap → clear (give up; 30 min default)
@@ -70,10 +74,13 @@ export const TELEGRAM_MSG_CAP = 4000
70
74
  /**
71
75
  * Regex matching the suffix we append. Used to strip a prior suffix
72
76
  * before appending the next one. The (\d+) covers "1m" / "12m" / etc.
77
+ * The reachability clause is optional so anchors carrying a pre-v0.14.30
78
+ * suffix (no clause) are still stripped during a rolling upgrade.
73
79
  * Kept anchored to end-of-string so it only matches OUR suffix, not
74
80
  * something the model happened to write.
75
81
  */
76
- const SUFFIX_RE = /\n\n— still working \(\d+m\)$/
82
+ const SUFFIX_RE =
83
+ /\n\n— still working \(\d+m\)( · message me anytime, I'll keep you posted)?$/
77
84
 
78
85
  export interface PendingProgressEditCtx {
79
86
  chatId: string
@@ -380,7 +387,7 @@ function tick(now: number): void {
380
387
  // user-visible counter reads honestly (we only edit at intervals
381
388
  // ≥ EDIT_INTERVAL_MS = 60s).
382
389
  const minutes = Math.max(1, Math.round(elapsed / 60_000))
383
- const suffix = `\n\n— still working (${minutes}m)`
390
+ const suffix = `\n\n— still working (${minutes}m) · message me anytime, I'll keep you posted`
384
391
  const newText = s.anchorOriginalText + suffix
385
392
 
386
393
  if (newText.length > TELEGRAM_MSG_CAP) {
@@ -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
  }
@@ -139,7 +139,7 @@ describe('pending-work-progress', () => {
139
139
  expect(cap.edits).toHaveLength(1)
140
140
  expect(cap.edits[0].messageId).toBe(100)
141
141
  expect(cap.edits[0].newText).toBe(
142
- 'Background sleep running; awaiting completion.\n\n— still working (1m)',
142
+ "Background sleep running; awaiting completion.\n\n— still working (1m) · message me anytime, I'll keep you posted",
143
143
  )
144
144
 
145
145
  // Tick at 3 intervals total — second edit, "3m".
@@ -148,7 +148,7 @@ describe('pending-work-progress', () => {
148
148
  await flush()
149
149
  expect(cap.edits).toHaveLength(2)
150
150
  expect(cap.edits[1].newText).toBe(
151
- 'Background sleep running; awaiting completion.\n\n— still working (3m)',
151
+ "Background sleep running; awaiting completion.\n\n— still working (3m) · message me anytime, I'll keep you posted",
152
152
  )
153
153
  })
154
154
 
@@ -168,7 +168,25 @@ describe('pending-work-progress', () => {
168
168
  await flush()
169
169
  // The new edit should be based on 'worker dispatched' alone.
170
170
  expect(cap.edits[0].newText).toBe(
171
- 'worker dispatched\n\n— still working (1m)',
171
+ "worker dispatched\n\n— still working (1m) · message me anytime, I'll keep you posted",
172
+ )
173
+ })
174
+
175
+ it('strips a prior NEW-shape suffix (with reachability clause) too', async () => {
176
+ const cap = setup()
177
+ startTurn(KEY)
178
+ noteAsyncDispatch(KEY)
179
+ noteOutbound(KEY, {
180
+ messageId: 100,
181
+ text:
182
+ "worker dispatched\n\n— still working (12m) · message me anytime, I'll keep you posted",
183
+ })
184
+ noteTurnEnd(KEY)
185
+ cap.now = EDIT_INTERVAL_MS
186
+ __tickForTests(cap.now)
187
+ await flush()
188
+ expect(cap.edits[0].newText).toBe(
189
+ "worker dispatched\n\n— still working (1m) · message me anytime, I'll keep you posted",
172
190
  )
173
191
  })
174
192
 
@@ -338,7 +356,7 @@ describe('pending-work-progress', () => {
338
356
  expect(cap.edits).toHaveLength(1)
339
357
  expect(cap.edits[0].parseMode).toBe('HTML')
340
358
  expect(cap.edits[0].newText).toBe(
341
- '<b>Worker back.</b> Both blockers fixed.\n\n— still working (1m)',
359
+ "<b>Worker back.</b> Both blockers fixed.\n\n— still working (1m) · message me anytime, I'll keep you posted",
342
360
  )
343
361
  })
344
362
 
@@ -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([])
@@ -81,7 +81,8 @@ interface TrailEntry {
81
81
  text: string;
82
82
  }
83
83
 
84
- const SUFFIX_RE = /\n\n— still working \(\d+m\)$/;
84
+ const SUFFIX_RE =
85
+ /\n\n— still working \(\d+m\)( · message me anytime, I'll keep you posted)?$/;
85
86
 
86
87
  function pad(s: string, n: number): string {
87
88
  return s.length >= n ? s : s + " ".repeat(n - s.length);
@@ -40,7 +40,8 @@ const PROMPT =
40
40
  `the bash, send that one HTML reply, end your turn. When it finishes ` +
41
41
  `much later, reply with the single word "done".`;
42
42
 
43
- const SUFFIX_RE = /\n\n— still working \(\d+m\)$/;
43
+ const SUFFIX_RE =
44
+ /\n\n— still working \(\d+m\)( · message me anytime, I'll keep you posted)?$/;
44
45
 
45
46
  describe("uat: pending-progress edit preserves HTML formatting (#1698 regression gate)", () => {
46
47
  it(