switchroom 0.14.24 → 0.14.26

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.24";
49424
- var COMMIT_SHA = "2711d052";
49423
+ var VERSION = "0.14.26";
49424
+ var COMMIT_SHA = "7ef6e93c";
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.24",
3
+ "version": "0.14.26",
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": {
@@ -47505,6 +47505,18 @@ function decideSubagentProgress(input) {
47505
47505
  return { deliver: true, chatId, bucketIdx, inbound };
47506
47506
  }
47507
47507
 
47508
+ // gateway/foreground-nesting.ts
47509
+ function shouldRenderForegroundProgress(g) {
47510
+ return g.nestingEnabled;
47511
+ }
47512
+ function foregroundFinishAction(i) {
47513
+ if (!i.removed)
47514
+ return "none";
47515
+ if (i.replyCalled && i.remainingForeground === 0)
47516
+ return "handoff-clear";
47517
+ return "recompose";
47518
+ }
47519
+
47508
47520
  // gateway/poll-health.ts
47509
47521
  var DEFAULT_LOG = (msg) => {
47510
47522
  process.stderr.write(msg.endsWith(`
@@ -51457,10 +51469,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51457
51469
  }
51458
51470
 
51459
51471
  // ../src/build-info.ts
51460
- var VERSION = "0.14.24";
51461
- var COMMIT_SHA = "2711d052";
51462
- var COMMIT_DATE = "2026-05-31T22:59:44Z";
51463
- var LATEST_PR = 2033;
51472
+ var VERSION = "0.14.26";
51473
+ var COMMIT_SHA = "7ef6e93c";
51474
+ var COMMIT_DATE = "2026-06-01T00:40:24Z";
51475
+ var LATEST_PR = 2040;
51464
51476
  var COMMITS_AHEAD_OF_TAG = 0;
51465
51477
 
51466
51478
  // gateway/boot-version.ts
@@ -61976,12 +61988,22 @@ var didOneTimeSetup = false;
61976
61988
  const isBackground = dispatch.isBackground;
61977
61989
  if (!isBackground) {
61978
61990
  const turn = currentTurn;
61979
- if (turn != null && turn.foregroundSubAgents.delete(agentId) && !turn.replyCalled) {
61980
- const rendered = composeTurnActivity(turn);
61981
- if (rendered != null) {
61982
- turn.activityPendingRender = rendered;
61983
- if (turn.activityInFlight == null) {
61984
- turn.activityInFlight = drainActivitySummary(turn);
61991
+ const removed = turn != null && turn.foregroundSubAgents.delete(agentId);
61992
+ if (turn != null && removed) {
61993
+ const action = foregroundFinishAction({
61994
+ removed,
61995
+ replyCalled: turn.replyCalled,
61996
+ remainingForeground: turn.foregroundSubAgents.size
61997
+ });
61998
+ if (action === "handoff-clear") {
61999
+ clearActivitySummary(turn);
62000
+ } else if (action === "recompose") {
62001
+ const rendered = composeTurnActivity(turn);
62002
+ if (rendered != null) {
62003
+ turn.activityPendingRender = rendered;
62004
+ if (turn.activityInFlight == null) {
62005
+ turn.activityInFlight = drainActivitySummary(turn);
62006
+ }
61985
62007
  }
61986
62008
  }
61987
62009
  }
@@ -62048,10 +62070,13 @@ var didOneTimeSetup = false;
62048
62070
  }
62049
62071
  const isBackground = dispatch.isBackground;
62050
62072
  if (!isBackground) {
62051
- if (!foregroundNestingEnabled)
62052
- return;
62053
62073
  const turn = currentTurn;
62054
- if (turn == null || turn.replyCalled)
62074
+ if (turn == null)
62075
+ return;
62076
+ if (!shouldRenderForegroundProgress({
62077
+ nestingEnabled: foregroundNestingEnabled,
62078
+ replyCalled: turn.replyCalled
62079
+ }))
62055
62080
  return;
62056
62081
  const child = latestSummary.trim().slice(0, 120);
62057
62082
  if (child.length === 0)
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Pure state-transition helpers for Model A foreground sub-agent activity
3
+ * nesting (#2027). Extracted from gateway.ts so the `replyCalled` gate is
4
+ * unit-testable on its own — the exact seam #2027 shipped without, which let
5
+ * the feature silently no-op for every ack-first turn (reply "On it…" then
6
+ * delegate) and go unnoticed in production. See
7
+ * `tests/foreground-nesting.test.ts`.
8
+ *
9
+ * Subscription-honest: every caller is pure jsonl-tail → render, no model
10
+ * call. These functions decide *whether/what* to render; the gateway owns the
11
+ * `currentTurn` mutation and the Telegram edit.
12
+ */
13
+
14
+ export interface ForegroundProgressGate {
15
+ /** `SWITCHROOM_FOREGROUND_SUBAGENT_NESTING !== '0'` — the kill-switch. */
16
+ nestingEnabled: boolean
17
+ /**
18
+ * Whether the parent turn has already emitted a reply. Accepted as input
19
+ * but intentionally NOT a gate: a foreground `Task` is a *blocking* call,
20
+ * so the parent cannot have issued its FINAL answer while the sub-agent is
21
+ * still running — any `replyCalled === true` observed here is therefore an
22
+ * *interim* ack. The pre-fix code bailed on this flag, which is precisely
23
+ * why ack-first turns showed zero live foreground activity.
24
+ */
25
+ replyCalled: boolean
26
+ }
27
+
28
+ /**
29
+ * Whether a foreground sub-agent progress tick should accumulate its narrative
30
+ * and re-render the activity feed. Gated ONLY by the kill-switch — never by
31
+ * `replyCalled` (see the field doc above).
32
+ */
33
+ export function shouldRenderForegroundProgress(g: ForegroundProgressGate): boolean {
34
+ return g.nestingEnabled
35
+ }
36
+
37
+ export type ForegroundFinishAction = 'recompose' | 'handoff-clear' | 'none'
38
+
39
+ export interface ForegroundFinishInput {
40
+ /** Was this agentId actually an active foreground sub-agent we tracked? */
41
+ removed: boolean
42
+ /** Has the parent turn already emitted a reply (an interim ack)? */
43
+ replyCalled: boolean
44
+ /** Foreground sub-agents still active AFTER removing the finished one. */
45
+ remainingForeground: number
46
+ }
47
+
48
+ /**
49
+ * What the gateway should do when a foreground sub-agent finishes:
50
+ *
51
+ * - `'none'` — it wasn't one we were tracking (e.g. background, or a
52
+ * stale boot row); leave the feed alone.
53
+ * - `'handoff-clear'`— it was the LAST foreground sub-agent and the parent
54
+ * has already acked. The re-opened post-ack feed now
55
+ * hands off to the imminent final answer, mirroring the
56
+ * first-reply hand-off. (turn_end is the safety net if
57
+ * this is somehow missed.)
58
+ * - `'recompose'` — collapse the finished sub-agent's block and re-render
59
+ * (pre-ack flow, or other sub-agents still running).
60
+ */
61
+ export function foregroundFinishAction(i: ForegroundFinishInput): ForegroundFinishAction {
62
+ if (!i.removed) return 'none'
63
+ if (i.replyCalled && i.remainingForeground === 0) return 'handoff-clear'
64
+ return 'recompose'
65
+ }
@@ -303,6 +303,10 @@ import {
303
303
  decideSubagentProgress,
304
304
  DEFAULT_PROGRESS_INTERVAL_MS,
305
305
  } from './subagent-progress-inbound-builder.js'
306
+ import {
307
+ shouldRenderForegroundProgress,
308
+ foregroundFinishAction,
309
+ } from './foreground-nesting.js'
306
310
  import { createPollHealthCheck, type PollHealthCheckHandle } from './poll-health.js'
307
311
  import type {
308
312
  ToolCallMessage,
@@ -17843,16 +17847,26 @@ void (async () => {
17843
17847
  // tool result, so there's no handback to deliver. Reaction
17844
17848
  // promotion already ran above.
17845
17849
  const turn = currentTurn
17846
- if (
17847
- turn != null &&
17848
- turn.foregroundSubAgents.delete(agentId) &&
17849
- !turn.replyCalled
17850
- ) {
17851
- const rendered = composeTurnActivity(turn)
17852
- if (rendered != null) {
17853
- turn.activityPendingRender = rendered
17854
- if (turn.activityInFlight == null) {
17855
- turn.activityInFlight = drainActivitySummary(turn)
17850
+ const removed = turn != null && turn.foregroundSubAgents.delete(agentId)
17851
+ if (turn != null && removed) {
17852
+ const action = foregroundFinishAction({
17853
+ removed,
17854
+ replyCalled: turn.replyCalled,
17855
+ remainingForeground: turn.foregroundSubAgents.size,
17856
+ })
17857
+ if (action === 'handoff-clear') {
17858
+ // Post-ack: the last foreground sub-agent finished and
17859
+ // the parent will now produce its answer inline. Hand
17860
+ // the re-opened feed off to the answer, mirroring the
17861
+ // first-reply clear (turn_end is the safety net).
17862
+ clearActivitySummary(turn)
17863
+ } else if (action === 'recompose') {
17864
+ const rendered = composeTurnActivity(turn)
17865
+ if (rendered != null) {
17866
+ turn.activityPendingRender = rendered
17867
+ if (turn.activityInFlight == null) {
17868
+ turn.activityInFlight = drainActivitySummary(turn)
17869
+ }
17856
17870
  }
17857
17871
  }
17858
17872
  }
@@ -17972,9 +17986,17 @@ void (async () => {
17972
17986
  // activity draft rather than a separate worker message. Pure
17973
17987
  // jsonl-tail → render (no model call), inside the
17974
17988
  // subscription-honest boundary.
17975
- if (!foregroundNestingEnabled) return // kill-switch: skip overhead
17976
17989
  const turn = currentTurn
17977
- if (turn == null || turn.replyCalled) return
17990
+ if (turn == null) return
17991
+ // Render regardless of `replyCalled` — a foreground Task
17992
+ // blocks the parent, so any reply seen while it runs is an
17993
+ // interim ack, never the final answer. Gating on replyCalled
17994
+ // (pre-#2032) made ack-first turns show zero live foreground
17995
+ // activity. Kill-switch lives in the predicate.
17996
+ if (!shouldRenderForegroundProgress({
17997
+ nestingEnabled: foregroundNestingEnabled,
17998
+ replyCalled: turn.replyCalled,
17999
+ })) return
17978
18000
  const child = latestSummary.trim().slice(0, 120)
17979
18001
  if (child.length === 0) return
17980
18002
  let narrative = turn.foregroundSubAgents.get(agentId)
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Regression guard for the Model A foreground sub-agent nesting gate (#2032).
3
+ *
4
+ * #2027 shipped foreground nesting but gated every render path on
5
+ * `turn.replyCalled`. Because the framework's ack-first pattern replies
6
+ * "On it…" FIRST and then delegates, `replyCalled` was already true before any
7
+ * foreground sub-agent ran — so the feature silently produced ZERO live
8
+ * foreground activity for the exact case it was built for, and that went
9
+ * unnoticed because #2027 only tested the pure renderer, never the gate.
10
+ *
11
+ * These tests pin the gate decisions directly so the replyCalled-independence
12
+ * can never regress silently again.
13
+ */
14
+ import { describe, it, expect } from 'vitest'
15
+ import {
16
+ shouldRenderForegroundProgress,
17
+ foregroundFinishAction,
18
+ } from '../gateway/foreground-nesting.js'
19
+ import { renderActivityFeedWithNested } from '../tool-activity-summary.js'
20
+
21
+ describe('shouldRenderForegroundProgress', () => {
22
+ it('renders even after the parent has acked (the #2027 blindspot)', () => {
23
+ // THE regression guard: ack-first sets replyCalled=true before the
24
+ // foreground sub-agent runs. It MUST still render.
25
+ expect(
26
+ shouldRenderForegroundProgress({ nestingEnabled: true, replyCalled: true }),
27
+ ).toBe(true)
28
+ })
29
+
30
+ it('renders before any reply (pre-ack flow, unchanged)', () => {
31
+ expect(
32
+ shouldRenderForegroundProgress({ nestingEnabled: true, replyCalled: false }),
33
+ ).toBe(true)
34
+ })
35
+
36
+ it('is independent of replyCalled — the flag never flips the outcome', () => {
37
+ const on = shouldRenderForegroundProgress({ nestingEnabled: true, replyCalled: true })
38
+ const off = shouldRenderForegroundProgress({ nestingEnabled: true, replyCalled: false })
39
+ expect(on).toBe(off)
40
+ })
41
+
42
+ it('honours the kill-switch regardless of replyCalled', () => {
43
+ expect(
44
+ shouldRenderForegroundProgress({ nestingEnabled: false, replyCalled: false }),
45
+ ).toBe(false)
46
+ expect(
47
+ shouldRenderForegroundProgress({ nestingEnabled: false, replyCalled: true }),
48
+ ).toBe(false)
49
+ })
50
+ })
51
+
52
+ describe('foregroundFinishAction', () => {
53
+ it('hands off to the answer when the last sub-agent finishes post-ack', () => {
54
+ expect(
55
+ foregroundFinishAction({ removed: true, replyCalled: true, remainingForeground: 0 }),
56
+ ).toBe('handoff-clear')
57
+ })
58
+
59
+ it('recomposes when other foreground sub-agents are still running post-ack', () => {
60
+ expect(
61
+ foregroundFinishAction({ removed: true, replyCalled: true, remainingForeground: 1 }),
62
+ ).toBe('recompose')
63
+ })
64
+
65
+ it('recomposes pre-ack (original behaviour preserved)', () => {
66
+ expect(
67
+ foregroundFinishAction({ removed: true, replyCalled: false, remainingForeground: 0 }),
68
+ ).toBe('recompose')
69
+ })
70
+
71
+ it('does nothing for an agent it was not tracking', () => {
72
+ expect(
73
+ foregroundFinishAction({ removed: false, replyCalled: true, remainingForeground: 0 }),
74
+ ).toBe('none')
75
+ })
76
+ })
77
+
78
+ describe('end-to-end render shape under ack-first', () => {
79
+ it('produces a live nested block from a post-ack foreground narrative', () => {
80
+ // marko's real scenario: parent acked with no prior steps (empty mirror),
81
+ // then a foreground researcher emits progress. The gate now ALLOWS this
82
+ // render; prove the render is a real, non-empty nested feed with a live
83
+ // bold "→ current" line — i.e. the user would actually see activity.
84
+ const html = renderActivityFeedWithNested(
85
+ [],
86
+ [
87
+ 'searching Unsplash CC0 desk',
88
+ 'checking license on 4 candidates',
89
+ 'ranking by resolution',
90
+ ],
91
+ )
92
+ expect(html).not.toBeNull()
93
+ // newest child is the live, bold current step
94
+ expect(html).toContain('<b>→ ranking by resolution</b>')
95
+ // an earlier child is present as a done/italic step
96
+ expect(html).toContain('checking license on 4 candidates')
97
+ })
98
+ })
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Foreground sub-agent live activity nesting (#2032) — UAT.
3
+ *
4
+ * Background (#2027): a FOREGROUND sub-agent (Agent/Task WITHOUT
5
+ * run_in_background) runs INSIDE the parent turn, blocking it. Its live steps
6
+ * were meant to nest into the parent's activity-summary feed (Model A). But
7
+ * every render path bailed on `turn.replyCalled`, and the framework's
8
+ * ack-first pattern replies "On it…" FIRST — so the sub-agent ran with
9
+ * replyCalled already true and NOTHING ever painted. #2027 only tested the
10
+ * pure renderer, so the regression shipped silently (the "marko's researcher
11
+ * showed no Telegram activity" report). #2032 renders foreground progress
12
+ * regardless of replyCalled.
13
+ *
14
+ * This scenario forces the exact broken shape end-to-end:
15
+ * 1. Prompt the agent to send a quick ack FIRST (sets replyCalled=true),
16
+ * 2. THEN dispatch a FOREGROUND sub-agent (run_in_background:false) that
17
+ * narrates ~10 paced steps so its jsonl keeps ticking under the
18
+ * test-harness 5s stall floor and the feed can paint + edit,
19
+ * 3. then report done.
20
+ *
21
+ * Asserts the load-bearing proof: an activity-summary feed message appears
22
+ * carrying the NESTED foreground marker ("↳") with the sub-agent's narration —
23
+ * i.e. live foreground activity surfaced AFTER the ack. Pre-#2032 this message
24
+ * never existed. Logs every observed body so a human can read the real UX.
25
+ *
26
+ * NOT a draft: the activity-summary feed is a real sendMessage/editMessageText
27
+ * (gateway.ts drainActivitySummary), so mtcute can observe it — unlike the
28
+ * answer-stream draft, which mtcute cannot see.
29
+ */
30
+
31
+ import { describe, expect, it } from "vitest";
32
+ import { spinUp } from "../harness.js";
33
+
34
+ // Same paced-narration discipline as jtbd-worker-activity-feed-dm: each step
35
+ // is its own Bash call with a one-line narration so the sub-agent emits a
36
+ // `sub_agent_text` line every ~2s — under SWITCHROOM_SUBAGENT_STALL_MS=5000,
37
+ // so the watcher never synth-terminates it mid-flight (which would suppress
38
+ // onProgress and the feed would never paint). run_in_background:false makes it
39
+ // FOREGROUND — the whole point.
40
+ const FG_DISPATCH_PROMPT =
41
+ `First, immediately send me a one-line acknowledgement that you're starting ` +
42
+ `(just "On it — running a check now."). Then use the Agent tool with ` +
43
+ `subagent_type "general-purpose" and run_in_background: false (a FOREGROUND ` +
44
+ `sub-agent) with this exact task: "Do eight steps, ONE AT A TIME, k = 1 ` +
45
+ `through 8. Before each step write a brief one-sentence narration of what ` +
46
+ `you are about to do, then run \`sleep 2\` via the Bash tool, then run ` +
47
+ `\`echo step-k\` via the Bash tool (substitute the real number for k). Run ` +
48
+ `every sleep and every echo as its OWN separate Bash call — never batch or ` +
49
+ `chain them with && — and narrate before each so progress surfaces ` +
50
+ `incrementally. Do not stop early; complete all eight steps, then return a ` +
51
+ `one-line summary." Wait for the foreground sub-agent to finish, then send ` +
52
+ `me a brief reply telling me it's done.`;
53
+
54
+ // The nested foreground block renders each child line prefixed with "↳"
55
+ // (NESTED_PREFIX = " ↳ "), newest as a bold "→ …" current step. Telegram
56
+ // strips the bold but keeps the literal ↳ / → glyphs in message text.
57
+ const NESTED_RE = /↳/;
58
+
59
+ describe("uat: foreground sub-agent live activity nesting (#2032)", () => {
60
+ it(
61
+ "surfaces nested foreground activity in the feed AFTER the ack-first reply",
62
+ async () => {
63
+ const sc = await spinUp({ agent: "test-harness" });
64
+ try {
65
+ await sc.sendDM(FG_DISPATCH_PROMPT);
66
+
67
+ // Ack-first reply — establishes replyCalled=true BEFORE the
68
+ // foreground sub-agent runs. This is the condition that broke #2027.
69
+ const ack = await sc.expectMessage(/.+/, {
70
+ from: "bot",
71
+ timeout: 60_000,
72
+ });
73
+ console.log(`[fg-activity UAT] ack-first reply: ${JSON.stringify(ack.text)}`);
74
+
75
+ // The activity-summary feed carrying the NESTED foreground narrative.
76
+ // First paint waits for the sub-agent to dispatch + narrate (~8s
77
+ // first-paint throttle), so give it a generous window. Its presence
78
+ // is the load-bearing proof of the fix: post-ack foreground activity.
79
+ const feed = await sc.expectMessage(NESTED_RE, {
80
+ from: "bot",
81
+ timeout: 90_000,
82
+ });
83
+ console.log(
84
+ `[fg-activity UAT] nested feed paint (id=${feed.messageId}): ${JSON.stringify(feed.text)}`,
85
+ );
86
+ expect(feed.messageId).toBeGreaterThan(0);
87
+ expect(feed.text).toMatch(NESTED_RE);
88
+
89
+ // Live edit: re-fetch the SAME message after the throttle + a few
90
+ // sub-agent steps. Body should change as the nested narrative
91
+ // advances — proving it's a live feed, not a one-shot post. Soft:
92
+ // log either way; the nested-paint above is the load-bearing proof.
93
+ const before = feed.text;
94
+ await new Promise((r) => setTimeout(r, 10_000));
95
+ const mid = await sc.driver.getMessage(sc.botUserId, feed.messageId);
96
+ console.log(
97
+ `[fg-activity UAT] same feed after 10s (id=${feed.messageId}): ${JSON.stringify(mid?.text ?? null)}`,
98
+ );
99
+
100
+ // Final answer — the parent resumes after the foreground sub-agent
101
+ // returns and reports done. The feed hands off (clears) to this
102
+ // answer. Confirms the turn completes cleanly, not wedged.
103
+ const done = await sc.expectMessage(/done|complete|finished|step-8|wrapped/i, {
104
+ from: "bot",
105
+ timeout: 120_000,
106
+ });
107
+ console.log(`[fg-activity UAT] final answer: ${JSON.stringify(done.text)}`);
108
+ expect(done.text.length).toBeGreaterThan(0);
109
+ // Did the nested narrative actually move while in flight?
110
+ if (mid?.text != null) {
111
+ console.log(
112
+ `[fg-activity UAT] body moved in-flight: ${mid.text !== before}`,
113
+ );
114
+ }
115
+ } finally {
116
+ await sc.tearDown();
117
+ }
118
+ },
119
+ 300_000,
120
+ );
121
+ });