switchroom 0.13.13 → 0.13.14

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.
@@ -76,6 +76,7 @@ import {
76
76
  import { emitRuntimeMetric } from '../runtime-metrics.js'
77
77
  import { classifyInbound } from '../inbound-classifier.js'
78
78
  import * as silencePoke from '../silence-poke.js'
79
+ import * as pendingProgress from '../pending-work-progress.js'
79
80
  import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd } from '../silent-end.js'
80
81
  import { isFinalAnswerReply } from '../final-answer-detect.js'
81
82
  import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
@@ -3149,6 +3150,7 @@ silencePoke.startTimer({
3149
3150
  // Drop silence-poke state and clear turn-active so the next inbound
3150
3151
  // for this chat starts a fresh turn instead of queueing forever.
3151
3152
  silencePoke.endTurn(fbKey)
3153
+ pendingProgress.noteTurnEnd(fbKey)
3152
3154
  purgeReactionTracking(fbKey)
3153
3155
  // Defense-in-depth: the fallback's purgeReactionTracking above
3154
3156
  // clears the canonical statusKey(chatId, threadId) for fbKey
@@ -3206,6 +3208,34 @@ silencePoke.startTimer({
3206
3208
  },
3207
3209
  })
3208
3210
 
3211
+ // #1445 cross-turn pending-async ambient. When a turn ends after the
3212
+ // model dispatched background async work (Agent / Task / Bash run-in-
3213
+ // background) and the model has stopped speaking, keep editing the
3214
+ // model's last reply in place at 60s intervals so the user sees
3215
+ // ambient liveness during the wait. Edits are silent, never spawn a
3216
+ // new pinged message, and stop the moment the user re-engages or the
3217
+ // model synthesises a handback. The full design rationale lives in
3218
+ // `pending-work-progress.ts`'s header docblock. Kill switch:
3219
+ // `SWITCHROOM_DISABLE_PENDING_PROGRESS=1`.
3220
+ pendingProgress.startTimer({
3221
+ editMessage: async (ctx) => {
3222
+ await swallowingApiCall(
3223
+ () =>
3224
+ lockedBot.api.editMessageText(
3225
+ ctx.chatId,
3226
+ ctx.messageId,
3227
+ ctx.newText,
3228
+ ),
3229
+ {
3230
+ chat_id: ctx.chatId,
3231
+ verb: 'pending-progress-edit',
3232
+ ...(ctx.threadId != null ? { threadId: ctx.threadId } : {}),
3233
+ },
3234
+ )
3235
+ },
3236
+ emitMetric: (event) => emitRuntimeMetric(event),
3237
+ })
3238
+
3209
3239
  // Per-agent buffer for synthetic inbounds the gateway couldn't deliver
3210
3240
  // because the bridge wasn't connected at send-time. Drained on
3211
3241
  // bridge-register so a fresh client picks up missed wake-ups before
@@ -3578,6 +3608,22 @@ const ipcServer: IpcServer = createIpcServer({
3578
3608
  label.length > 0 ? label : null,
3579
3609
  Date.now(),
3580
3610
  )
3611
+ // #1445 cross-turn pending-async ambient. Mark the chat as
3612
+ // having dispatched background work this turn so a turn_end
3613
+ // that follows activates the edit-in-place ambient line.
3614
+ // Covers `Agent` / `Task` (the harness-managed async path
3615
+ // — handback channel turn clears it) and `Bash` with
3616
+ // run_in_background:true (model is expected to poll
3617
+ // BashOutput; the ambient ticks until next inbound or the
3618
+ // 30-min budget cap).
3619
+ const evInput = ev.input as { run_in_background?: boolean } | undefined
3620
+ if (
3621
+ ev.toolName === 'Agent'
3622
+ || ev.toolName === 'Task'
3623
+ || (ev.toolName === 'Bash' && evInput?.run_in_background === true)
3624
+ ) {
3625
+ pendingProgress.noteAsyncDispatch(key)
3626
+ }
3581
3627
  }
3582
3628
  } else if (ev.kind === 'tool_result') {
3583
3629
  // #1292: drain the in-flight entry. Idempotent on unknown ids
@@ -4391,6 +4437,22 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4391
4437
  }
4392
4438
  }
4393
4439
 
4440
+ // #1445 cross-turn pending-async ambient. Capture the last text
4441
+ // chunk as the anchor — if this turn ends with a pending async
4442
+ // dispatch, the framework edits THIS message in place every 60s
4443
+ // with a `— still working (Nm)` suffix until the user re-engages.
4444
+ // Multi-chunk replies: anchor is the LAST chunk (edits append to
4445
+ // the visually-trailing message; earlier chunks are left intact).
4446
+ if (sentIds.length === chunks.length && chunks.length > 0) {
4447
+ const anchorMsgId = sentIds[chunks.length - 1]
4448
+ if (typeof anchorMsgId === 'number') {
4449
+ pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
4450
+ messageId: anchorMsgId,
4451
+ text: chunks[chunks.length - 1],
4452
+ })
4453
+ }
4454
+ }
4455
+
4394
4456
  // #273: when files is 2-10 photos, batch them into a single
4395
4457
  // sendMediaGroup album rather than N separate sendPhoto calls. The
4396
4458
  // user's device fires one notification for the album instead of N
@@ -4715,6 +4777,15 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
4715
4777
  const sChatId = args.chat_id as string
4716
4778
  const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
4717
4779
  outboundDedup.record(sChatId, sThreadId, args.text as string, Date.now())
4780
+ // #1445 cross-turn pending-async ambient. The terminal stream_reply
4781
+ // (done=true) is the user-visible anchor for any cross-turn wait
4782
+ // that follows. Capture it so if this turn ends with a pending
4783
+ // async dispatch, the framework edits THIS message in place at
4784
+ // intervals.
4785
+ pendingProgress.noteOutbound(statusKey(sChatId, sThreadId), {
4786
+ messageId: result.messageId,
4787
+ text: args.text as string,
4788
+ })
4718
4789
  }
4719
4790
  // #1664 — mark the turn's final answer as delivered. For stream_reply a
4720
4791
  // call with done=true IS the final answer by definition (the model
@@ -5728,6 +5799,25 @@ function handleSessionEvent(ev: SessionEvent): void {
5728
5799
  // Drain any orphaned typing-wrap entries left over from a crashed
5729
5800
  // prior turn before resetting focus.
5730
5801
  typingWrapper.drainAll()
5802
+ if (ev.chatId) {
5803
+ // #1445 cross-turn pending-async ambient — backstop for the
5804
+ // `handleInbound` path's `clearPending('inbound')`. The
5805
+ // inbound path covers real user messages, but synthesised
5806
+ // wakes (subagent-handback channel turn, cron fires, vault
5807
+ // grant resumes, restart markers) push directly to
5808
+ // `pendingInboundBuffer` and bypass `handleInbound`. The
5809
+ // `enqueue` session-event fires for EVERY fresh turn atom
5810
+ // regardless of source — clearing here drops any prior turn's
5811
+ // ambient before the new turn's `noteOutbound` lands. The
5812
+ // call is idempotent so it's safe to fire in addition to the
5813
+ // inbound-path clear (for the real-inbound case, this is a
5814
+ // no-op because state was already deleted by then).
5815
+ const enqThreadId = ev.threadId != null ? Number(ev.threadId) : undefined
5816
+ pendingProgress.clearPending(
5817
+ statusKey(ev.chatId, enqThreadId),
5818
+ 'handback',
5819
+ )
5820
+ }
5731
5821
  if (ev.chatId) {
5732
5822
  // Issue #195: if a previous turn left an answer-lane stream open
5733
5823
  // (rapid steer/queue), force it to a new generation so its in-flight
@@ -6045,6 +6135,7 @@ function handleSessionEvent(ev: SessionEvent): void {
6045
6135
  // full message above). Match the pattern used at the regular
6046
6136
  // turn-end path (line ~5039) and the wedged-turn path (~5290).
6047
6137
  silencePoke.endTurn(ceKey)
6138
+ pendingProgress.noteTurnEnd(ceKey)
6048
6139
  // Issue #195: tear down the answer-lane stream on context-exhaustion
6049
6140
  // bail-out. The user is being told the session needs /restart, so any
6050
6141
  // partially-streamed answer would be misleading.
@@ -6230,6 +6321,7 @@ function handleSessionEvent(ev: SessionEvent): void {
6230
6321
  try { removeTurnActiveMarker(STATE_DIR) } catch { /* best-effort */ }
6231
6322
  signalTracker.clear(tKey)
6232
6323
  silencePoke.endTurn(tKey)
6324
+ pendingProgress.noteTurnEnd(tKey)
6233
6325
  }
6234
6326
  lastPtyPreviewByChat.delete(statusKey(chatId, threadId))
6235
6327
  pendingPtyPartial = null
@@ -6304,6 +6396,7 @@ function handleSessionEvent(ev: SessionEvent): void {
6304
6396
  const tKey = statusKey(chatId, threadId)
6305
6397
  signalTracker.clear(tKey)
6306
6398
  silencePoke.endTurn(tKey)
6399
+ pendingProgress.noteTurnEnd(tKey)
6307
6400
  }
6308
6401
 
6309
6402
  void (async () => {
@@ -6550,6 +6643,7 @@ function handleSessionEvent(ev: SessionEvent): void {
6550
6643
  }
6551
6644
  signalTracker.clear(tKey)
6552
6645
  silencePoke.endTurn(tKey)
6646
+ pendingProgress.noteTurnEnd(tKey)
6553
6647
  }
6554
6648
  lastPtyPreviewByChat.delete(statusKey(chatId, threadId))
6555
6649
  pendingPtyPartial = null
@@ -7772,6 +7866,18 @@ async function handleInbound(
7772
7866
  // the framework can nudge the model if it goes quiet past the
7773
7867
  // soft / firm thresholds.
7774
7868
  silencePoke.startTurn(statusKey(chat_id, messageThreadId), Date.now())
7869
+ // #1445 cross-turn pending-async ambient. A new turn starting
7870
+ // (user inbound, synthesised wake, or handback channel) is the
7871
+ // signal that the model is about to re-engage — clear any
7872
+ // pending-progress edits anchored to the *prior* turn's
7873
+ // outbound so the framework stops talking over the new turn.
7874
+ // clearPending drops the per-key state outright, so the new
7875
+ // turn's `tool_use(Agent|Task|Bash bg)` + outbound capture
7876
+ // afresh via `noteAsyncDispatch` / `noteOutbound`.
7877
+ pendingProgress.clearPending(
7878
+ statusKey(chat_id, messageThreadId),
7879
+ 'inbound',
7880
+ )
7775
7881
  // Human-feel UX: hold a continuous `typing…` indicator for the
7776
7882
  // WHOLE turn, not just the split-second a reply is transmitted.
7777
7883
  // A person you message shows as typing the entire time they
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Cross-turn pending-async progress — issue #1445.
3
+ *
4
+ * When a turn ends with pending background async work (the model
5
+ * dispatched `Agent` / `Task` and ended its turn before the worker
6
+ * returned), keep editing the model's last reply *in place* at
7
+ * intervals so the user sees ambient liveness during the wait — without
8
+ * any new pinged messages and without re-introducing the retired
9
+ * progress card.
10
+ *
11
+ * Background data justifying this module (2026-05-23 forensic + UAT):
12
+ *
13
+ * - silence-poke success rate is 0–7% across hundreds of fires
14
+ * (finn: 0/78, clerk: 6/91, klanker: 5/158) — the polite levels
15
+ * reach the model as `<system-reminder>`s piggybacked on the next
16
+ * tool result, so they (a) only land if the model is actively
17
+ * cycling tools, (b) compete with hundreds of other tokens, and (c)
18
+ * only ever exist while the turn is open. The 300s framework
19
+ * fallback is the only user-visible silence-poke output, and its
20
+ * first job is to *kill the wedged turn*.
21
+ *
22
+ * - The dominant user-visible failure mode (issue #1445) is in fact
23
+ * cross-turn: the model calls `Agent` (or `Bash` with
24
+ * `run_in_background:true`), sends one ack reply that pings, then
25
+ * ends the turn. The silence-poke ladder is *gone* the moment
26
+ * endTurn() fires. The user then sees nothing for 10–30+ minutes
27
+ * until the worker returns. A live UAT confirmed: a deliberate
28
+ * `sleep 350` prompt produced one `[PING] Background sleep running;
29
+ * awaiting completion notification.` at +19s and the turn ended.
30
+ *
31
+ * Mechanism:
32
+ *
33
+ * tool_use(Agent|Task) → mark chat key `pending=true`
34
+ * outbound reply → capture anchor (messageId, text)
35
+ * turn_end with pending+anchor → activate the timer for the key
36
+ * tick (every 5s, edit every → editMessageText against the anchor
37
+ * EDIT_INTERVAL_MS) appending/refreshing the suffix
38
+ * " — still working (Nm)"
39
+ * inbound user message → clear (user re-engaged or moved on)
40
+ * subagent_handback inject → clear (model about to re-engage)
41
+ * MAX_LIFETIME_MS budget cap → clear (give up; 30 min default)
42
+ *
43
+ * Single shared timer for the whole gateway — like silence-poke's
44
+ * `tick()`, the per-key cost is O(map size) per poll. The poll
45
+ * interval is short (5s) but edits are spaced at EDIT_INTERVAL_MS so
46
+ * the Telegram bot.api editMessageText rate stays well under limits.
47
+ *
48
+ * Edits are plain text (no parseMode). The suffix is appended to the
49
+ * model's authored text; on subsequent edits the prior suffix is
50
+ * stripped before re-appending so the message never accumulates
51
+ * duplicate suffixes.
52
+ *
53
+ * Kill switch: `SWITCHROOM_DISABLE_PENDING_PROGRESS=1` disables the
54
+ * whole subsystem. The conversational-pacing prompt is unaffected.
55
+ */
56
+
57
+ export const EDIT_INTERVAL_MS = 60_000
58
+ export const POLL_INTERVAL_MS = 5_000
59
+ export const MAX_LIFETIME_MS = 30 * 60_000
60
+ /** Telegram message length limit is 4096; budget headroom for the
61
+ * suffix and any escape expansion. If the anchor text plus suffix
62
+ * would exceed this, we skip the edit (the user still sees the
63
+ * original) rather than truncate the model's authored prose. */
64
+ export const TELEGRAM_MSG_CAP = 4000
65
+
66
+ /**
67
+ * Regex matching the suffix we append. Used to strip a prior suffix
68
+ * before appending the next one. The (\d+) covers "1m" / "12m" / etc.
69
+ * Kept anchored to end-of-string so it only matches OUR suffix, not
70
+ * something the model happened to write.
71
+ */
72
+ const SUFFIX_RE = /\n\n— still working \(\d+m\)$/
73
+
74
+ export interface PendingProgressEditCtx {
75
+ chatId: string
76
+ threadId: number | null
77
+ messageId: number
78
+ newText: string
79
+ }
80
+
81
+ /**
82
+ * Discriminated union — kept structurally identical to the
83
+ * `pending_progress_*` variants in `runtime-metrics.ts:RuntimeMetricEvent`
84
+ * so the gateway's `emitMetric: emitRuntimeMetric` wire-up typechecks
85
+ * cleanly with no cast. `started` carries only the chat key; `edited`
86
+ * always carries the cumulative elapsed time; `cleared` carries an
87
+ * optional elapsed + the reason (`inbound` | `handback` | `timeout` |
88
+ * `manual`).
89
+ */
90
+ export type PendingProgressMetric =
91
+ | { kind: 'pending_progress_started'; chatKey: string }
92
+ | { kind: 'pending_progress_edited'; chatKey: string; elapsedMs: number }
93
+ | {
94
+ kind: 'pending_progress_cleared'
95
+ chatKey: string
96
+ elapsedMs?: number
97
+ reason?: string
98
+ }
99
+
100
+ export interface PendingProgressDeps {
101
+ editMessage: (ctx: PendingProgressEditCtx) => Promise<void>
102
+ emitMetric?: (event: PendingProgressMetric) => void
103
+ /** Optional clock override for tests. */
104
+ nowMs?: () => number
105
+ /** Optional poll interval override for tests. */
106
+ pollIntervalMs?: number
107
+ }
108
+
109
+ interface State {
110
+ /** True after a `tool_use(Agent|Task)` was observed for this key in
111
+ * the current turn. Cleared on next turn start. */
112
+ pending: boolean
113
+ /** The captured anchor — last outbound reply message_id for this
114
+ * key. */
115
+ anchorMessageId: number | null
116
+ /** The captured anchor text — what the model wrote, *minus* any
117
+ * prior pending-progress suffix. Used as the base for every edit. */
118
+ anchorOriginalText: string
119
+ /** Wall-clock ms when the cross-turn ambient state was *activated*
120
+ * (at turn_end with pending+anchor). null before activation. */
121
+ activatedAt: number | null
122
+ /** Wall-clock ms of last edit fire — gates the EDIT_INTERVAL_MS
123
+ * cadence. null until first edit fires. */
124
+ lastEditAt: number | null
125
+ }
126
+
127
+ const stateByKey = new Map<string, State>()
128
+ let timer: ReturnType<typeof setInterval> | null = null
129
+ let activeDeps: PendingProgressDeps | null = null
130
+
131
+ function enabled(): boolean {
132
+ const v = process.env.SWITCHROOM_DISABLE_PENDING_PROGRESS
133
+ return !(v === '1' || v === 'true')
134
+ }
135
+
136
+ function nowMs(): number {
137
+ return activeDeps?.nowMs ? activeDeps.nowMs() : Date.now()
138
+ }
139
+
140
+ function ensure(key: string): State {
141
+ let s = stateByKey.get(key)
142
+ if (!s) {
143
+ s = {
144
+ pending: false,
145
+ anchorMessageId: null,
146
+ anchorOriginalText: '',
147
+ activatedAt: null,
148
+ lastEditAt: null,
149
+ }
150
+ stateByKey.set(key, s)
151
+ }
152
+ return s
153
+ }
154
+
155
+ /**
156
+ * Fresh turn — reset the per-turn `pending` flag and the per-turn
157
+ * anchor. The cross-turn `activated` state is per-PRIOR-turn and is
158
+ * cleared by the explicit clear paths (`clearPending` with reason
159
+ * `inbound` / `handback` / `timeout`), not by a new turn. The gateway
160
+ * wires those clears at TWO sites for full coverage:
161
+ *
162
+ * 1. `handleInbound` (real user message) → `clearPending('inbound')`
163
+ * — the fast path; fires the moment the gateway sees an inbound,
164
+ * before the new turn atom is even built.
165
+ * 2. `handleSessionEvent` `enqueue` case (every fresh turn atom)
166
+ * → `clearPending('handback')` — the backstop covering
167
+ * synthesised wakes (subagent-handback, cron, vault grant,
168
+ * restart marker) that push directly to `pendingInboundBuffer`
169
+ * and bypass `handleInbound`. Idempotent w/r/t the first clear.
170
+ *
171
+ * `startTurn` itself only matters if the state map already has an
172
+ * entry for `key` — which post-fix is impossible (the clears
173
+ * delete it). Kept for test ergonomics and as defence-in-depth.
174
+ */
175
+ export function startTurn(key: string): void {
176
+ if (!enabled()) return
177
+ const s = stateByKey.get(key)
178
+ if (s == null) return
179
+ // Only the per-turn fields reset. activatedAt/lastEditAt belong to
180
+ // the prior turn's pending-progress and are cleared separately.
181
+ s.pending = false
182
+ s.anchorMessageId = null
183
+ s.anchorOriginalText = ''
184
+ }
185
+
186
+ /**
187
+ * Mark this chat as having dispatched async background work in the
188
+ * current turn. Idempotent. Called when the gateway sees a `tool_use`
189
+ * for `Agent` or `Task`.
190
+ */
191
+ export function noteAsyncDispatch(key: string): void {
192
+ if (!enabled()) return
193
+ ensure(key).pending = true
194
+ }
195
+
196
+ /**
197
+ * Capture an outbound reply as a candidate anchor for cross-turn
198
+ * editing. Called on every successful bot reply send. If a prior
199
+ * pending-progress suffix is present in the text (rare — should only
200
+ * happen if we sent something to ourselves), strip it before storing
201
+ * so subsequent edits don't double-suffix.
202
+ */
203
+ export function noteOutbound(
204
+ key: string,
205
+ opts: { messageId: number; text: string },
206
+ ): void {
207
+ if (!enabled()) return
208
+ const s = ensure(key)
209
+ s.anchorMessageId = opts.messageId
210
+ s.anchorOriginalText = opts.text.replace(SUFFIX_RE, '')
211
+ }
212
+
213
+ /**
214
+ * Called at turn_end. If the turn had a pending async dispatch AND
215
+ * captured an anchor, activate the cross-turn ambient state — the
216
+ * timer will start editing.
217
+ *
218
+ * If pending=false OR no anchor was captured, drop the state entry
219
+ * entirely (nothing for us to do).
220
+ */
221
+ export function noteTurnEnd(key: string): void {
222
+ if (!enabled()) return
223
+ const s = stateByKey.get(key)
224
+ if (s == null) return
225
+ if (s.pending && s.anchorMessageId != null) {
226
+ s.activatedAt = nowMs()
227
+ // lastEditAt is null so the first edit fires after one full
228
+ // EDIT_INTERVAL_MS from activation — not immediately.
229
+ s.lastEditAt = s.activatedAt
230
+ activeDeps?.emitMetric?.({
231
+ kind: 'pending_progress_started',
232
+ chatKey: key,
233
+ })
234
+ } else {
235
+ stateByKey.delete(key)
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Clear pending-progress for a chat — reasons:
241
+ * 'inbound' — user sent a new message, they're re-engaged
242
+ * 'handback' — switchroom injected a subagent_handback channel turn
243
+ * 'timeout' — exceeded MAX_LIFETIME_MS
244
+ * 'manual' — test / debug
245
+ */
246
+ export function clearPending(
247
+ key: string,
248
+ reason: 'inbound' | 'handback' | 'timeout' | 'manual',
249
+ ): void {
250
+ if (!stateByKey.has(key)) return
251
+ const s = stateByKey.get(key)!
252
+ const elapsed = s.activatedAt != null ? nowMs() - s.activatedAt : 0
253
+ stateByKey.delete(key)
254
+ activeDeps?.emitMetric?.({
255
+ kind: 'pending_progress_cleared',
256
+ chatKey: key,
257
+ elapsedMs: elapsed,
258
+ reason,
259
+ })
260
+ }
261
+
262
+ /**
263
+ * Start the shared interval timer. Idempotent. Honours the kill
264
+ * switch — no-op when disabled.
265
+ */
266
+ export function startTimer(deps: PendingProgressDeps): void {
267
+ if (!enabled()) return
268
+ if (timer != null) return
269
+ activeDeps = deps
270
+ const interval = deps.pollIntervalMs ?? POLL_INTERVAL_MS
271
+ timer = setInterval(() => tick(nowMs()), interval)
272
+ if (typeof timer.unref === 'function') timer.unref()
273
+ }
274
+
275
+ /** Stop the timer. Idempotent. */
276
+ export function stopTimer(): void {
277
+ if (timer != null) {
278
+ clearInterval(timer)
279
+ timer = null
280
+ }
281
+ activeDeps = null
282
+ }
283
+
284
+ /**
285
+ * Parse `<chatId>:<threadIdOrEmpty>` back into structured fields,
286
+ * matching the `statusKey` shape used throughout the gateway.
287
+ */
288
+ function parseKey(key: string): { chatId: string; threadId: number | null } {
289
+ const idx = key.indexOf(':')
290
+ if (idx < 0) return { chatId: key, threadId: null }
291
+ const chatId = key.slice(0, idx)
292
+ const tail = key.slice(idx + 1)
293
+ if (tail === '' || tail === 'undefined') return { chatId, threadId: null }
294
+ const n = Number(tail)
295
+ return { chatId, threadId: Number.isFinite(n) ? n : null }
296
+ }
297
+
298
+ function tick(now: number): void {
299
+ if (activeDeps == null) return
300
+ for (const [key, s] of stateByKey.entries()) {
301
+ if (s.activatedAt == null || s.anchorMessageId == null) continue
302
+
303
+ const elapsed = now - s.activatedAt
304
+ if (elapsed >= MAX_LIFETIME_MS) {
305
+ clearPending(key, 'timeout')
306
+ continue
307
+ }
308
+
309
+ const sinceEdit = s.lastEditAt == null ? 0 : now - s.lastEditAt
310
+ if (sinceEdit < EDIT_INTERVAL_MS) continue
311
+
312
+ // Build suffix from elapsed wall-clock. Always at least 1m so the
313
+ // user-visible counter reads honestly (we only edit at intervals
314
+ // ≥ EDIT_INTERVAL_MS = 60s).
315
+ const minutes = Math.max(1, Math.round(elapsed / 60_000))
316
+ const suffix = `\n\n— still working (${minutes}m)`
317
+ const newText = s.anchorOriginalText + suffix
318
+
319
+ if (newText.length > TELEGRAM_MSG_CAP) {
320
+ // Don't truncate the model's prose — just skip this edit.
321
+ // The previous edit (or the original) is still visible.
322
+ s.lastEditAt = now
323
+ continue
324
+ }
325
+
326
+ const { chatId, threadId } = parseKey(key)
327
+ s.lastEditAt = now
328
+
329
+ const editCtx: PendingProgressEditCtx = {
330
+ chatId,
331
+ threadId,
332
+ messageId: s.anchorMessageId,
333
+ newText,
334
+ }
335
+ // Fire-and-forget so a slow edit doesn't block the tick loop.
336
+ // Errors are logged but never bubble (a 429 / "message not modified"
337
+ // / chat-deleted is a soft failure).
338
+ void Promise.resolve()
339
+ .then(() => activeDeps!.editMessage(editCtx))
340
+ .then(() => {
341
+ activeDeps!.emitMetric?.({
342
+ kind: 'pending_progress_edited',
343
+ chatKey: key,
344
+ elapsedMs: elapsed,
345
+ })
346
+ })
347
+ .catch((err) => {
348
+ process.stderr.write(
349
+ `pending-work-progress: edit failed key=${key} ` +
350
+ `msg=${editCtx.messageId}: ${(err as Error).message}\n`,
351
+ )
352
+ })
353
+ }
354
+ }
355
+
356
+ // ─── Test helpers ─────────────────────────────────────────────────────────
357
+
358
+ /** Test-only: drive one tick deterministically. */
359
+ export function __tickForTests(now: number): void {
360
+ tick(now)
361
+ }
362
+
363
+ /** Test-only: install deps without starting the real timer. */
364
+ export function __setDepsForTests(deps: PendingProgressDeps | null): void {
365
+ activeDeps = deps
366
+ }
367
+
368
+ /** Test-only: peek at per-key state. */
369
+ export function __getStateForTests(key: string): State | undefined {
370
+ return stateByKey.get(key)
371
+ }
372
+
373
+ /** Test-only: full reset. */
374
+ export function __resetAllForTests(): void {
375
+ stateByKey.clear()
376
+ stopTimer()
377
+ }
@@ -104,6 +104,26 @@ export type RuntimeMetricEvent =
104
104
  fallback_kind: 'working' | 'thinking'
105
105
  silence_ms: number
106
106
  }
107
+ /**
108
+ * #1445 cross-turn pending-async ambient lifecycle. `started` fires
109
+ * when a turn ends with a captured anchor AND a pending Agent/Task/
110
+ * Bash-background dispatch — i.e. the framework will now edit the
111
+ * model's last reply in place every ~60s until cleared. `edited`
112
+ * fires on each successful in-place edit; `elapsed_ms` is how long
113
+ * ambient has been running for this chat. `cleared` fires when
114
+ * ambient stops — `reason` says why (inbound / handback / timeout).
115
+ * Targets: edited/started ratio is the "still alive minutes per
116
+ * activation" health proxy; cleared.reason='inbound' should
117
+ * dominate (model + user resolving naturally).
118
+ */
119
+ | { kind: 'pending_progress_started'; chatKey: string }
120
+ | { kind: 'pending_progress_edited'; chatKey: string; elapsedMs: number }
121
+ | {
122
+ kind: 'pending_progress_cleared'
123
+ chatKey: string
124
+ elapsedMs?: number
125
+ reason?: string
126
+ }
107
127
 
108
128
  /**
109
129
  * The JSONL sink lives under the runtime state dir so it's per-agent