switchroom 0.18.7 → 0.18.8

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.
Files changed (54) hide show
  1. package/dist/cli/switchroom.js +905 -758
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/profiles/_base/start.sh.hbs +111 -34
  5. package/skills/switchroom-runtime/SKILL.md +2 -0
  6. package/telegram-plugin/dist/gateway/gateway.js +1403 -657
  7. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  8. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  9. package/telegram-plugin/gateway/boot-card.ts +27 -0
  10. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  11. package/telegram-plugin/gateway/gateway.ts +564 -85
  12. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  13. package/telegram-plugin/gateway/model-command.ts +23 -11
  14. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  15. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  16. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  17. package/telegram-plugin/hooks/hooks.json +10 -10
  18. package/telegram-plugin/hooks/run-hook.sh +84 -0
  19. package/telegram-plugin/model-unavailable.ts +26 -0
  20. package/telegram-plugin/pty-partial-handler.ts +39 -0
  21. package/telegram-plugin/render/rich-render.ts +79 -1
  22. package/telegram-plugin/retry-api-call.ts +62 -0
  23. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  24. package/telegram-plugin/silence-poke.ts +14 -0
  25. package/telegram-plugin/stream-controller.ts +156 -38
  26. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  27. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  28. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  29. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  30. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  31. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  32. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
  33. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  34. package/telegram-plugin/tests/model-command.test.ts +2 -2
  35. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  36. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  37. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  38. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  39. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  40. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  41. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  42. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  43. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  44. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  45. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  46. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  47. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  48. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  49. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  50. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  51. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  52. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  53. package/telegram-plugin/voice-ondemand.ts +25 -1
  54. package/telegram-plugin/voice-send.ts +154 -0
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Telegram per-bot flood-wait circuit breaker (issue #2923).
3
+ *
4
+ * When a burst of outbound sends trips Telegram's per-bot-token flood limit,
5
+ * the API returns `429 { retry_after: N }` — a SERVER-SIDE ban on the bot
6
+ * token that no client-side lever clears early (observed retry_after ~4116s,
7
+ * ~68 min). The failure is misleading: the container is `Up`, the gateway is
8
+ * polling, inbound works — but every outbound send is rejected. Worse, each
9
+ * `docker restart` posts a fresh boot/config card = another send INTO the
10
+ * open window, which can reset/extend the flood counter. A local, recoverable
11
+ * disk-full problem thereby amplifies into a remote, unrecoverable ban.
12
+ *
13
+ * This breaker persists the flood-wait window to disk so that:
14
+ * - `retryApiCall`'s `onFloodWait` hook records it the moment a 429 is seen;
15
+ * - a restart-time NON-ESSENTIAL send (boot card, config summary) consults
16
+ * `isFloodWaitActive` and SKIPS while the ban is open, so a restart during
17
+ * a flood-wait doesn't feed the counter and prolong the ban.
18
+ *
19
+ * The state file is tiny JSON under the agent's telegram state dir. All the
20
+ * logic is pure + injectable so it unit tests without a real clock or bot.
21
+ */
22
+
23
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
24
+ import { dirname, join } from 'node:path'
25
+
26
+ export interface FloodWaitState {
27
+ /** Epoch ms at which the flood-wait window expires. */
28
+ untilTs: number
29
+ /** The retry_after (seconds) Telegram reported, for diagnostics. */
30
+ retryAfterSec: number
31
+ /** Epoch ms the window was (re)recorded. */
32
+ recordedTs: number
33
+ }
34
+
35
+ /** Default marker filename inside the telegram state dir. */
36
+ export const FLOOD_STATE_FILE = 'flood-wait.json'
37
+
38
+ /**
39
+ * Resolve the flood-wait marker path from a telegram state dir. Kept as a
40
+ * helper so callers share one location.
41
+ */
42
+ export function floodStatePath(stateDir: string): string {
43
+ return join(stateDir, FLOOD_STATE_FILE)
44
+ }
45
+
46
+ /**
47
+ * Compute the flood-wait state for an observed `retry_after`. Extends (never
48
+ * shrinks) an existing window: if a fresh 429 reports a shorter remaining ban
49
+ * than we already recorded, we keep the longer expiry — the server is the
50
+ * authority and being conservative avoids sending back into an open window.
51
+ */
52
+ export function computeFloodWait(
53
+ prior: FloodWaitState | null,
54
+ retryAfterSec: number,
55
+ now: number,
56
+ ): FloodWaitState {
57
+ const candidate = now + Math.max(0, retryAfterSec) * 1000
58
+ const untilTs = prior && prior.untilTs > candidate ? prior.untilTs : candidate
59
+ return { untilTs, retryAfterSec, recordedTs: now }
60
+ }
61
+
62
+ /** Remaining ban time in ms (0 when no active window). */
63
+ export function floodWaitRemainingMs(state: FloodWaitState | null, now: number): number {
64
+ if (!state) return 0
65
+ return Math.max(0, state.untilTs - now)
66
+ }
67
+
68
+ /** True while the flood-wait ban is still open. */
69
+ export function isFloodWaitActive(state: FloodWaitState | null, now: number): boolean {
70
+ return floodWaitRemainingMs(state, now) > 0
71
+ }
72
+
73
+ /** Read persisted flood state; null on absence / parse failure. */
74
+ export function readFloodState(path: string): FloodWaitState | null {
75
+ try {
76
+ if (!existsSync(path)) return null
77
+ const raw = JSON.parse(readFileSync(path, 'utf-8')) as Partial<FloodWaitState>
78
+ if (typeof raw.untilTs !== 'number') return null
79
+ return {
80
+ untilTs: raw.untilTs,
81
+ retryAfterSec: typeof raw.retryAfterSec === 'number' ? raw.retryAfterSec : 0,
82
+ recordedTs: typeof raw.recordedTs === 'number' ? raw.recordedTs : 0,
83
+ }
84
+ } catch {
85
+ return null
86
+ }
87
+ }
88
+
89
+ /** Persist flood state (best-effort — a write failure must not crash the send path). */
90
+ export function writeFloodState(path: string, state: FloodWaitState): void {
91
+ try {
92
+ mkdirSync(dirname(path), { recursive: true })
93
+ writeFileSync(path, JSON.stringify(state), { mode: 0o600 })
94
+ } catch {
95
+ /* best-effort */
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Build the `onFloodWait` callback for `createRetryApiCall`, wired to persist
101
+ * (and extend) the window at `path`. Reads current state, merges the new
102
+ * retry_after, writes it back.
103
+ */
104
+ export function makeFloodWaitRecorder(
105
+ path: string,
106
+ now: () => number = Date.now,
107
+ ): (retryAfterSec: number) => void {
108
+ return (retryAfterSec: number) => {
109
+ const t = now()
110
+ const next = computeFloodWait(readFloodState(path), retryAfterSec, t)
111
+ writeFloodState(path, next)
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Decide whether a NON-ESSENTIAL restart-time send (boot card, config
117
+ * summary) should be suppressed because a flood-wait is active. Returns the
118
+ * remaining ms when suppressed (>0), or 0 to proceed. Reads state fresh so a
119
+ * concurrently-updated window is honoured.
120
+ */
121
+ export function suppressNonEssentialSendMs(path: string, now: number): number {
122
+ return floodWaitRemainingMs(readFloodState(path), now)
123
+ }
@@ -43,6 +43,11 @@
43
43
  * failure than one frozen orphan. A benign-400 (message already
44
44
  * deleted/vanished, or "not modified") is NOT counted as a finalize — nothing
45
45
  * was delivered — see the `vanished` tally in `runActivityCardBootReaper`.
46
+ *
47
+ * The UNPIN half is different (#3001): unpinning is idempotent, so it gets
48
+ * AT-LEAST-ONCE (capped) semantics — a failed unpin re-persists the record
49
+ * with `finalizeAttempted: true` + an `unpinAttempts` counter so the next boot
50
+ * retries ONLY the unpin (never the edit), up to BOOT_UNPIN_MAX_ATTEMPTS.
46
51
  */
47
52
 
48
53
  export interface ActivityCardStoreFsSeam {
@@ -73,8 +78,21 @@ export interface ActivityCardRecord {
73
78
  * forever). Optional so a v1-shape record (pre-unpin-tracking) still
74
79
  * loads and degrades to "don't attempt an unpin", never a crash. */
75
80
  pinned?: boolean
81
+ /** Set when the boot reaper retains a record ONLY to retry its failed
82
+ * unpin (#3001). The finalizing edit stays AT-MOST-ONCE: a retained
83
+ * record's edit was already attempted, so the next boot skips the edit
84
+ * and only retries the (idempotent) unpin. */
85
+ finalizeAttempted?: boolean
86
+ /** Boot-reaper unpin retry counter (#3001); the record is forfeited at
87
+ * BOOT_UNPIN_MAX_ATTEMPTS. Absent = 0. */
88
+ unpinAttempts?: number
76
89
  }
77
90
 
91
+ /** Cap on cross-boot unpin retries — same rationale as the status-pin
92
+ * store's BOOT_UNPIN_MAX_ATTEMPTS (bound a permanently-undeliverable
93
+ * unpin; unpins themselves are idempotent so retrying is safe). */
94
+ export const BOOT_UNPIN_MAX_ATTEMPTS = 5
95
+
78
96
  interface SnapshotEnvelope {
79
97
  v: 1
80
98
  cards: ActivityCardRecord[]
@@ -91,7 +109,9 @@ function isCardRow(x: unknown): x is ActivityCardRecord {
91
109
  (o.threadId === null || typeof o.threadId === 'number') &&
92
110
  typeof o.activityMessageId === 'number' &&
93
111
  typeof o.startedAt === 'number' &&
94
- (o.pinned === undefined || typeof o.pinned === 'boolean')
112
+ (o.pinned === undefined || typeof o.pinned === 'boolean') &&
113
+ (o.finalizeAttempted === undefined || typeof o.finalizeAttempted === 'boolean') &&
114
+ (o.unpinAttempts === undefined || typeof o.unpinAttempts === 'number')
95
115
  )
96
116
  }
97
117
 
@@ -245,32 +265,57 @@ export async function runActivityCardBootReaper(args: {
245
265
  // turn that upserted a fresh card under the same turnKey mid-reap keeps
246
266
  // its own (different-id) record.
247
267
  clearActivityCardRecord(args.path, args.fs, record.turnKey, record.activityMessageId, log)
248
- try {
249
- // Count a finalize ONLY when the edit actually landed. robustApiCall
250
- // resolves to undefined on a benign-400 (the card was already deleted or
251
- // the chat is gone) — that delivered nothing, so it is a `vanished`
252
- // orphan, not a `finalized` one. Counting it as finalized (the pre-fix
253
- // behaviour) over-reported the guarantee in the boot log.
254
- const res = await args.finalizeCard(record)
255
- if (res != null) finalized++
256
- else vanished++
257
- } catch (err) {
258
- log(
259
- `activity-card-store: boot reaper finalize failed ` +
260
- `(chat=${record.chatId} msg=${record.activityMessageId}): ` +
261
- `${(err as Error).message}\n`,
262
- )
268
+ // The finalizing EDIT stays at-most-once: skip it for a record retained by
269
+ // a prior boot purely to retry its failed unpin (finalizeAttempted).
270
+ if (!record.finalizeAttempted) {
271
+ try {
272
+ // Count a finalize ONLY when the edit actually landed. robustApiCall
273
+ // resolves to undefined on a benign-400 (the card was already deleted or
274
+ // the chat is gone) — that delivered nothing, so it is a `vanished`
275
+ // orphan, not a `finalized` one. Counting it as finalized (the pre-fix
276
+ // behaviour) over-reported the guarantee in the boot log.
277
+ const res = await args.finalizeCard(record)
278
+ if (res != null) finalized++
279
+ else vanished++
280
+ } catch (err) {
281
+ log(
282
+ `activity-card-store: boot reaper finalize failed ` +
283
+ `(chat=${record.chatId} msg=${record.activityMessageId}): ` +
284
+ `${(err as Error).message}\n`,
285
+ )
286
+ }
263
287
  }
264
288
  if (record.pinned) {
265
289
  try {
266
290
  await args.unpinCard(record)
267
291
  unpinned++
268
292
  } catch (err) {
293
+ // Retry-safe unpin (#3001): unlike the edit (at-most-once by design),
294
+ // an unpin is idempotent — so a failed one is RE-PERSISTED with an
295
+ // attempt counter and retried on the next boot, up to the cap, instead
296
+ // of being forfeited. `finalizeAttempted` guarantees the retained
297
+ // record can never re-run its edit.
298
+ const attempts = (record.unpinAttempts ?? 0) + 1
269
299
  log(
270
300
  `activity-card-store: boot reaper unpin failed ` +
271
- `(chat=${record.chatId} msg=${record.activityMessageId}): ` +
272
- `${(err as Error).message}\n`,
301
+ `(chat=${record.chatId} msg=${record.activityMessageId} ` +
302
+ `attempt=${attempts}): ${(err as Error).message}\n`,
273
303
  )
304
+ if (attempts < BOOT_UNPIN_MAX_ATTEMPTS) {
305
+ writeActivityCardRecord(
306
+ args.path,
307
+ args.fs,
308
+ { ...record, finalizeAttempted: true, unpinAttempts: attempts },
309
+ log,
310
+ )
311
+ } else {
312
+ log(
313
+ `activity-card-store: boot reaper FORFEITING card unpin after ` +
314
+ `${attempts} failed attempts ` +
315
+ `(chat=${record.chatId} msg=${record.activityMessageId}) — ` +
316
+ `will not retry again\n`,
317
+ )
318
+ }
274
319
  }
275
320
  }
276
321
  }
@@ -69,6 +69,7 @@ import {
69
69
  } from './config-snapshot.js'
70
70
  import { join } from 'path'
71
71
  import { bootCardChatKey, loadBootCardMsgId, saveBootCardMsgId } from './boot-card-msgid.js'
72
+ import { suppressNonEssentialSendMs } from '../flood-circuit-breaker.js'
72
73
  import { loadConfig as _loadSwitchroomConfig } from '../../src/config/loader.js'
73
74
  import { resolveAgentConfig as _resolveAgentConfig } from '../../src/config/merge.js'
74
75
 
@@ -605,6 +606,16 @@ export interface RunProbesOpts {
605
606
  * behaviour.
606
607
  */
607
608
  bootCardStatePath?: string
609
+ /**
610
+ * #2923 flood-wait circuit breaker. Path to the persisted flood-wait
611
+ * marker. When a Telegram per-bot flood ban is active, posting a boot card
612
+ * on restart is a NON-ESSENTIAL send straight into the open window that can
613
+ * reset/extend the ban — so if this path shows an active flood-wait, the
614
+ * boot card is SUPPRESSED (logged, not sent). Omit to always post.
615
+ */
616
+ floodStatePath?: string
617
+ /** Injectable clock for the flood-wait check (tests). Defaults to Date.now. */
618
+ nowMs?: () => number
608
619
  }
609
620
 
610
621
  /** Run all six probes concurrently with their own per-probe timeouts.
@@ -652,6 +663,22 @@ export async function startBootCard(
652
663
  const setTimeoutFn = opts.setTimeoutImpl ?? setTimeout
653
664
  const settleMs = opts.settleWindowMs ?? SETTLE_WINDOW_MS
654
665
 
666
+ // #2923 circuit breaker: if a per-bot Telegram flood ban is active, do NOT
667
+ // post the boot card. It's a non-essential restart-time send straight into
668
+ // the open flood window — the exact thing that resets/extends the ban and
669
+ // keeps the agent mute for longer. Skip loudly (log) and return a no-op.
670
+ if (opts.floodStatePath != null) {
671
+ const now = (opts.nowMs ?? Date.now)()
672
+ const remainingMs = suppressNonEssentialSendMs(opts.floodStatePath, now)
673
+ if (remainingMs > 0) {
674
+ logger(
675
+ `telegram gateway: boot-card: SUPPRESSED — Telegram flood-wait active for ~${Math.round(remainingMs / 1000)}s; ` +
676
+ `not posting a restart card into the open ban window (issue #2923)\n`,
677
+ )
678
+ return { messageId: -1, complete: () => {} }
679
+ }
680
+ }
681
+
655
682
  // Render and post the bare ack line immediately. The user gets
656
683
  // confirmation that the agent is back without waiting on probes.
657
684
  const ackText = renderBootCard({
@@ -0,0 +1,106 @@
1
+ // #2995 — mid-flight busy ack.
2
+ //
3
+ // A mid-turn inbound that is neither a steer nor an interrupt is buffered
4
+ // until the running turn goes idle (`buffer-until-idle`). When the turn is
5
+ // sitting inside ONE long blocking tool call (`gh pr checks --watch`, a
6
+ // long `sleep`, a slow build), that wait can be minutes — from the phone
7
+ // the question reads as ignored. The user's message got only a 👀 reaction
8
+ // and then silence until the blocking step returned (observed live
9
+ // 2026-07-10: a trivial "are any agents working?" waited 2 minutes behind
10
+ // a `--watch`).
11
+ //
12
+ // This module is the PURE half of the fix: a deterministic, model-free
13
+ // decision — should the gateway post a silent "⏳ Queued — currently
14
+ // inside <tool> …" card into the inbound's own chat/topic right now? No
15
+ // tokens, no model calls; the gateway supplies live readings (delivery-gate
16
+ // decision, tool-flight state, current step age, dedupe state) and this
17
+ // module answers. Extracted so the policy is unit-testable without the
18
+ // gateway IIFE (same pattern as `interrupt-defer.ts` / `feed-reopen-gate.ts`).
19
+ //
20
+ // Wording contract: the buffered-path card MUST say "Queued" — the
21
+ // steer-or-queue job spec (`reference/jobs/steer-or-queue-mid-flight.md`)
22
+ // requires the chosen classification to be visible in the chat, never
23
+ // inferred. A steer-path card must NOT say "Queued" (it wasn't queued);
24
+ // it says the steer was noted and will fold in at the step boundary.
25
+
26
+ /** How the inbound was classified/handled by the delivery gate. Only the
27
+ * two mid-turn shapes are ack-eligible; a plain fresh-turn `deliver`
28
+ * needs no busy ack (the turn starts immediately). */
29
+ export type BusyAckGateDecision = 'buffer-until-idle' | 'steer' | 'deliver'
30
+
31
+ /**
32
+ * Minimum age of the CURRENT tool step before a busy ack fires. Below
33
+ * this the step is about to return anyway and the buffered inbound will
34
+ * flush in a beat — an ack would fire when the agent was seconds from
35
+ * answering (the job spec's explicit Bad bullet). 12s sits well above the
36
+ * common fast-tool envelope (reads/greps/short bashes finish in <5s) and
37
+ * well below the "reads as ignored" horizon (~30s+); it also matches the
38
+ * UAT latency promise (visible ack <10s of the ping, for a step that has
39
+ * already proven itself long).
40
+ */
41
+ export const BUSY_ACK_STEP_AGE_THRESHOLD_MS = 12_000
42
+
43
+ export interface BusyAckDecisionInput {
44
+ /** Delivery-gate outcome for this inbound ('steer' when it was
45
+ * delivered mid-turn as a steering amend). */
46
+ gateDecision: BusyAckGateDecision
47
+ /** Live `ToolFlightTracker.isMidToolCall()` reading — at least one
48
+ * top-level tool call currently open. */
49
+ midToolCall: boolean
50
+ /** Age (ms) of the LONGEST-running in-flight tool step, or null when no
51
+ * step is tracked (e.g. the turn is thinking between tools). */
52
+ stepAgeMs: number | null
53
+ /** True when a busy-ack/queued-status card already exists (or was
54
+ * already posted this turn) for the inbound's chat/topic — at most one
55
+ * card per turn per chat/topic; a second ping gets no second card. */
56
+ alreadyAcked: boolean
57
+ }
58
+
59
+ /**
60
+ * Pure decision: post the busy-ack card now?
61
+ *
62
+ * - only for mid-turn shapes (buffered, or delivered as a steer)
63
+ * - only while genuinely mid-tool-call
64
+ * - only once the current step is older than the threshold (a young
65
+ * step returns soon; the normal flush covers it)
66
+ * - at most once per turn per chat/topic (dedupe)
67
+ */
68
+ export function shouldPostBusyAck(input: BusyAckDecisionInput): boolean {
69
+ if (input.gateDecision !== 'buffer-until-idle' && input.gateDecision !== 'steer') return false
70
+ if (!input.midToolCall) return false
71
+ if (input.stepAgeMs == null || input.stepAgeMs < BUSY_ACK_STEP_AGE_THRESHOLD_MS) return false
72
+ if (input.alreadyAcked) return false
73
+ return true
74
+ }
75
+
76
+ export interface BusyAckTextInput {
77
+ gateDecision: 'buffer-until-idle' | 'steer'
78
+ /** Bare tool name as tracked (e.g. "Bash"). Null when unknown. */
79
+ toolName: string | null
80
+ /** Natural-language descriptor from the PreToolUse sidecar's
81
+ * `toolLabel()` (e.g. `sleep 90`), or null. */
82
+ toolLabel: string | null
83
+ }
84
+
85
+ /**
86
+ * Render the card text. Deterministic, no model. The buffered variant
87
+ * says "Queued" (classification-visibility invariant); the steer variant
88
+ * says the steer is noted — never "Queued".
89
+ *
90
+ * Deliberately carries NO elapsed figure: the card is posted once and
91
+ * never re-rendered while the blocking step runs, so any point-in-time
92
+ * number ("2m elapsed") would silently go stale on screen — a decaying
93
+ * claim on a card whose whole point is honesty. The activity name alone
94
+ * is time-invariant.
95
+ */
96
+ export function formatBusyAckText(input: BusyAckTextInput): string {
97
+ const name = input.toolName ?? 'a long-running step'
98
+ const activity =
99
+ input.toolLabel != null && input.toolLabel.length > 0
100
+ ? `${name}: ${input.toolLabel}`
101
+ : name
102
+ if (input.gateDecision === 'steer') {
103
+ return `⏳ Steer noted — currently inside \`${activity}\`; I'll fold it in when this step finishes.`
104
+ }
105
+ return `⏳ Queued — currently inside \`${activity}\`; I'll answer when this step finishes.`
106
+ }