switchroom 0.15.3 → 0.15.5

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.
@@ -102,14 +102,31 @@ export function spoolId(msg: InboundMessage): string {
102
102
  }
103
103
 
104
104
  interface SpoolRecord {
105
- t: 'put' | 'ack'
106
- id: string
105
+ t: 'put' | 'ack' | 'esc'
106
+ /** Present on `put`/`ack` (spoolId). Absent on `esc`. */
107
+ id?: string
107
108
  /** Present only on `put`. The full inbound to replay. */
108
109
  msg?: InboundMessage
109
110
  /** Present only on `put`. Owning agent (replay re-pushes per agent). */
110
111
  agent?: string
111
112
  /** Present only on `put`. ms epoch first-spooled — drives escalation. */
112
113
  firstAt?: number
114
+ /** Present only on `esc` — the chat the give-up notice was/would be
115
+ * posted to, and when. Durably records the per-chat escalation-notice
116
+ * window so a burst of undeliverable inbounds (or a multi-restart
117
+ * outage) produces ONE "couldn't deliver" notice per chat, not one
118
+ * per dropped entry. */
119
+ chat?: string | number
120
+ thread?: string
121
+ at?: number
122
+ }
123
+
124
+ /** Stable per-(chat,thread) key for coalescing give-up notices. */
125
+ function escChatKey(msg: InboundMessage): string {
126
+ const threadRaw = msg.meta?.threadId
127
+ const thread =
128
+ typeof threadRaw === 'string' && threadRaw.length > 0 ? threadRaw : '-'
129
+ return `${msg.chatId}:${thread}`
113
130
  }
114
131
 
115
132
  export interface InboundSpoolFsSeam {
@@ -134,6 +151,14 @@ export interface InboundSpoolOptions {
134
151
  escalateAfterMs?: number
135
152
  /** Rewrite-compact the JSONL once it exceeds this. Default 256 KiB. */
136
153
  compactAtBytes?: number
154
+ /** Coalescing window for the user-facing "couldn't deliver" notice,
155
+ * per chat. The window SLIDES on every escalation attempt (posted or
156
+ * suppressed), so a sustained burst posts exactly one notice and only
157
+ * re-notifies after the burst goes quiet for this long. Must exceed
158
+ * the rate at which undeliverable entries age out (the 15-min
159
+ * `escalateAfterMs` here) or back-to-back attempts wouldn't coalesce.
160
+ * Default 30 min. */
161
+ escalateNoticeCooldownMs?: number
137
162
  }
138
163
 
139
164
  export interface ReplayEntry {
@@ -165,10 +190,20 @@ export interface InboundSpool {
165
190
  * finished could land on top of the handback turn. Tombstones the
166
191
  * dropped entries durably. */
167
192
  dropMatching: (predicate: (id: string) => boolean) => number
168
- /** Escalate+drop entries older than `escalateAfterMs`. Calls
169
- * `onEscalate` once per dropped entry (post the "couldn't deliver"
170
- * card there). Returns the count escalated. Safe to call on a timer. */
171
- sweepEscalations: (onEscalate: (e: ReplayEntry) => void) => number
193
+ /** Escalate+drop entries older than `escalateAfterMs`. Every dropped
194
+ * entry is tombstoned (the promise is retracted deterministically),
195
+ * but the user-facing notice is COALESCED per chat: `onEscalate` is
196
+ * called for every dropped entry with `postNotice` indicating whether
197
+ * to actually post the "couldn't deliver" card. `postNotice` is true
198
+ * only for the first escalation to a given chat within
199
+ * `escalateNoticeCooldownMs` — a burst of undeliverable inbounds (e.g.
200
+ * a synthetic re-created every 15 min while the agent is down, across
201
+ * restarts) yields ONE notice, not one per entry. The window is
202
+ * persisted, so it holds across a gateway restart. Returns the count
203
+ * of entries dropped. Safe to call on a timer. */
204
+ sweepEscalations: (
205
+ onEscalate: (e: ReplayEntry, opts: { postNotice: boolean }) => void,
206
+ ) => number
172
207
  /** Test/observability: count of live (un-acked) ids. */
173
208
  liveCount: () => number
174
209
  }
@@ -179,11 +214,18 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
179
214
  const log = opts.log ?? ((l: string) => process.stderr.write(l))
180
215
  const escalateAfterMs = opts.escalateAfterMs ?? 15 * 60 * 1000
181
216
  const compactAtBytes = opts.compactAtBytes ?? 256 * 1024
217
+ const escalateNoticeCooldownMs = opts.escalateNoticeCooldownMs ?? 30 * 60 * 1000
182
218
 
183
219
  // In-memory projection of the on-disk log, rebuilt from the file at
184
220
  // construction. `live` maps spoolId → the put record (insertion order
185
221
  // preserved via the Map). An `ack` deletes from `live`.
186
222
  const live = new Map<string, { agent: string; msg: InboundMessage; firstAt: number }>()
223
+ // Per-chat last escalation-ATTEMPT time (posted or suppressed). Drives
224
+ // the sliding coalescing window so a burst of give-up escalations posts
225
+ // one notice. Rebuilt from durable `esc` records at construction so the
226
+ // window survives a gateway restart (the actual 2026-06-09 spam: a
227
+ // synthetic re-aged into the bound every 15 min across many restarts).
228
+ const escAttemptByChat = new Map<string, number>()
187
229
 
188
230
  function parseLine(line: string): SpoolRecord | null {
189
231
  const s = line.trim()
@@ -196,7 +238,13 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
196
238
  }
197
239
  if (rec == null || typeof rec !== 'object') return null
198
240
  const r = rec as Record<string, unknown>
199
- if (r.t !== 'put' && r.t !== 'ack') return null
241
+ if (r.t !== 'put' && r.t !== 'ack' && r.t !== 'esc') return null
242
+ if (r.t === 'esc') {
243
+ // esc records key on chat, not a spoolId.
244
+ if (typeof r.chat !== 'string' && typeof r.chat !== 'number') return null
245
+ if (typeof r.at !== 'number') return null
246
+ return r as unknown as SpoolRecord
247
+ }
200
248
  if (typeof r.id !== 'string' || r.id.length === 0) return null
201
249
  if (r.t === 'put') {
202
250
  if (r.msg == null || typeof r.msg !== 'object') return null
@@ -209,6 +257,7 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
209
257
  // Rebuild `live` from the file. Tolerates a torn last line.
210
258
  function hydrate(): void {
211
259
  live.clear()
260
+ escAttemptByChat.clear()
212
261
  if (!fs.existsSync(path)) return
213
262
  let raw = ''
214
263
  try {
@@ -221,13 +270,17 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
221
270
  if (rec == null) continue
222
271
  if (rec.t === 'put') {
223
272
  // Last put for an id wins; an ack later removes it.
224
- live.set(rec.id, {
273
+ live.set(rec.id as string, {
225
274
  agent: rec.agent as string,
226
275
  msg: rec.msg as InboundMessage,
227
276
  firstAt: rec.firstAt as number,
228
277
  })
278
+ } else if (rec.t === 'esc') {
279
+ // Last escalation-attempt time per chat wins (records are in
280
+ // append order). Restores the sliding window across a restart.
281
+ escAttemptByChat.set(`${rec.chat}:${rec.thread ?? '-'}`, rec.at as number)
229
282
  } else {
230
- live.delete(rec.id)
283
+ live.delete(rec.id as string)
231
284
  }
232
285
  }
233
286
  }
@@ -269,6 +322,22 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
269
322
  JSON.stringify({ t: 'put', id, agent: e.agent, msg: e.msg, firstAt: e.firstAt } satisfies SpoolRecord),
270
323
  )
271
324
  }
325
+ // Preserve the latest escalation-attempt time per chat so the sliding
326
+ // coalescing window isn't reset by compaction (which would let the next
327
+ // burst re-spam). One record per chat — bounded by the chat count.
328
+ for (const [key, at] of escAttemptByChat) {
329
+ const sep = key.lastIndexOf(':')
330
+ const chat = key.slice(0, sep)
331
+ const thread = key.slice(sep + 1)
332
+ lines.push(
333
+ JSON.stringify({
334
+ t: 'esc',
335
+ chat,
336
+ ...(thread !== '-' ? { thread } : {}),
337
+ at,
338
+ } satisfies SpoolRecord),
339
+ )
340
+ }
272
341
  const tmp = path + '.compact.tmp'
273
342
  try {
274
343
  fs.writeFileSync(tmp, lines.length ? lines.join('\n') + '\n' : '')
@@ -328,24 +397,46 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
328
397
  return n
329
398
  },
330
399
  sweepEscalations(onEscalate) {
331
- const cutoff = now() - escalateAfterMs
332
- let n = 0
400
+ const tNow = now()
401
+ const cutoff = tNow - escalateAfterMs
402
+ let dropped = 0
403
+ let posted = 0
333
404
  for (const [id, e] of [...live.entries()]) {
334
405
  if (e.firstAt > cutoff) continue
335
406
  live.delete(id)
336
407
  appendRecord({ t: 'ack', id }) // tombstone — promise retracted
408
+ // Coalesce the user-facing notice per chat on a SLIDING window:
409
+ // post only when the last attempt to this chat was longer ago than
410
+ // the cooldown; every attempt (posted or not) slides the window, so
411
+ // a sustained burst stays quiet after the first notice and only
412
+ // re-notifies once the burst goes quiet. Durable via `esc` records.
413
+ const key = escChatKey(e.msg)
414
+ const lastAttempt = escAttemptByChat.get(key)
415
+ const postNotice =
416
+ lastAttempt === undefined || tNow - lastAttempt >= escalateNoticeCooldownMs
417
+ escAttemptByChat.set(key, tNow)
418
+ const threadRaw = e.msg.meta?.threadId
419
+ const thread =
420
+ typeof threadRaw === 'string' && threadRaw.length > 0 ? threadRaw : undefined
421
+ appendRecord({ t: 'esc', chat: e.msg.chatId, thread, at: tNow })
337
422
  try {
338
- onEscalate({ agent: e.agent, msg: e.msg })
423
+ onEscalate({ agent: e.agent, msg: e.msg }, { postNotice })
339
424
  } catch (err) {
340
425
  log(`inbound-spool: onEscalate threw id=${id}: ${(err as Error).message}\n`)
341
426
  }
342
- n++
427
+ if (postNotice) posted++
428
+ dropped++
343
429
  }
344
- if (n > 0) {
345
- log(`inbound-spool: escalated+dropped ${n} undelivered entr${n === 1 ? 'y' : 'ies'} (older than ${escalateAfterMs}ms)\n`)
430
+ if (dropped > 0) {
431
+ const suppressed = dropped - posted
432
+ log(
433
+ `inbound-spool: escalated+dropped ${dropped} undelivered entr${dropped === 1 ? 'y' : 'ies'} ` +
434
+ `(older than ${escalateAfterMs}ms; ${posted} notice${posted === 1 ? '' : 's'} posted` +
435
+ `${suppressed > 0 ? `, ${suppressed} coalesced` : ''})\n`,
436
+ )
346
437
  maybeCompact()
347
438
  }
348
- return n
439
+ return dropped
349
440
  },
350
441
  liveCount() {
351
442
  return live.size
@@ -279,6 +279,11 @@ export async function buildModelMenu(
279
279
  }
280
280
  }
281
281
 
282
+ // claude's ✔ marks the DEFAULT FOR NEW SESSIONS, which is a different axis
283
+ // from the model the agent is running right now (set via --model at launch
284
+ // or a prior session switch). Labelling the ✔ row "Now:" was misleading —
285
+ // it could read "Opus 4.8" while the live session is on Fable. Call it what
286
+ // it is, and tell the operator a switch applies to the live session.
282
287
  const current = discovered.options.find((o) => o.current)
283
288
  const lines: string[] = [`<b>Model — ${deps.escapeHtml(deps.getAgentName())}</b>`]
284
289
  if (discovered.dismissFailed) {
@@ -286,18 +291,33 @@ export async function buildModelMenu(
286
291
  }
287
292
  if (current) {
288
293
  const detail = current.detail ? ` · ${deps.escapeHtml(current.detail)}` : ''
289
- lines.push(`Now: <b>${deps.escapeHtml(current.label)}</b>${detail}`)
294
+ lines.push(`Default (new sessions): <b>${deps.escapeHtml(current.label)}</b>${detail}`)
290
295
  } else {
291
- lines.push('Now: <i>unknown (no ✔ row in picker)</i>')
296
+ lines.push('Default (new sessions): <i>unknown (no ✔ row in picker)</i>')
292
297
  }
293
298
  if (quota) lines.push(`Quota: ${deps.escapeHtml(quota)}`)
294
- lines.push('', 'Tap to switch (applies to the live session):')
299
+ lines.push('', 'Tap a model to switch the <b>live session</b>:')
295
300
  lines.push(PERSIST_NOTE)
296
301
 
297
302
  return { text: lines.join('\n'), html: true, keyboard: menuKeyboard(discovered.options) }
298
303
  }
299
304
 
300
305
  export interface ModelCallbackOutcome {
306
+ /**
307
+ * When true, the caller should ONLY show the toast (`answer`) and leave
308
+ * the existing menu message untouched — used for the mid-turn refusal so
309
+ * the menu keeps its buttons and the operator can simply tap again when
310
+ * the agent goes idle, instead of the menu collapsing to a button-less
311
+ * "try again" line (which read as "nothing happened").
312
+ */
313
+ toastOnly?: boolean
314
+ /**
315
+ * On a successful session switch, the live model name now running (parsed
316
+ * from claude's confirmation, e.g. "Fable 5"). The gateway records this as
317
+ * the session-model override so `/status` reflects what's actually running.
318
+ * Absent on every non-switch outcome.
319
+ */
320
+ selectedModel?: string
301
321
  /** Short toast for answerCallbackQuery. */
302
322
  answer: string
303
323
  /** Replacement dashboard (message edit). */
@@ -321,19 +341,30 @@ export async function handleModelMenuCallback(
321
341
  if (!data.startsWith(MODEL_CALLBACK_SELECT)) {
322
342
  return { answer: 'Unknown action', reply: await buildModelMenu(deps) }
323
343
  }
344
+ // Mid-turn: refuse WITHOUT touching the message. Driving the picker types
345
+ // into claude's input box, which mid-turn would queue "/model" as user
346
+ // text. toastOnly keeps the menu (and its buttons) exactly as-is so the
347
+ // operator just taps again when the agent is idle — no button-less
348
+ // "try again" line that read as a dead menu.
324
349
  if (deps.isBusy()) {
325
- return { answer: 'Agent is mid-turn — try again shortly', reply: busyReply(deps) }
350
+ return {
351
+ answer: '⏳ Agent is mid-turn — tap again when it’s idle',
352
+ reply: busyReply(deps),
353
+ toastOnly: true,
354
+ }
326
355
  }
327
356
 
328
357
  const tag = data.slice(MODEL_CALLBACK_SELECT.length)
329
358
  const discovered = await deps.discover(deps.getAgentName())
330
359
  if (!discovered.ok) {
360
+ // Keep the menu interactive: re-render (falls back to v1 text if even
361
+ // the show path can't discover) with the failure as a banner.
331
362
  return {
332
363
  answer: 'Picker unavailable',
333
- reply: {
334
- text: `❌ Could not open the model picker: ${deps.escapeHtml(discovered.reason)}`,
335
- html: true,
336
- },
364
+ reply: await menuWithBanner(
365
+ deps,
366
+ `❌ Could not open the model picker: ${deps.escapeHtml(discovered.reason)}`,
367
+ ),
337
368
  }
338
369
  }
339
370
  const target = discovered.options.find((o) => labelTag(o.label) === tag)
@@ -342,27 +373,64 @@ export async function handleModelMenuCallback(
342
373
  const fresh = await buildModelMenu(deps)
343
374
  return { answer: 'Model list changed — menu refreshed', reply: fresh }
344
375
  }
345
- if (target.current) {
346
- const fresh = await buildModelMenu(deps)
347
- return { answer: `Already on ${target.label}`, reply: fresh }
348
- }
349
-
376
+ // NOTE: do NOT short-circuit when target.current is set. The picker's ✔
377
+ // marks claude's DEFAULT FOR NEW SESSIONS, which is a DIFFERENT axis from
378
+ // the model the live session is running (set by --model at launch). Tapping
379
+ // the ✔ row to apply that model to the live session is a legitimate switch
380
+ // — e.g. an agent launched on Fable tapping "Default (Opus)". Skipping it
381
+ // here was the "tapped Default, nothing happened" bug. Always drive the
382
+ // selection; claude harmlessly answers "Kept model as X" if it's already
383
+ // the session model.
350
384
  const result = await deps.select(deps.getAgentName(), target.label)
351
385
  if (!result.ok) {
386
+ // Switch failed but the agent is reachable — keep the menu so the
387
+ // operator can retry, with the reason as a banner.
352
388
  return {
353
- answer: 'Switch failed',
354
- reply: {
355
- text: `❌ Switch to <b>${deps.escapeHtml(target.label)}</b> failed: ${deps.escapeHtml(result.reason)}`,
356
- html: true,
357
- },
389
+ answer: 'Switch failed — see the menu',
390
+ reply: await menuWithBanner(
391
+ deps,
392
+ `❌ Switch to <b>${deps.escapeHtml(target.label)}</b> failed: ${deps.escapeHtml(result.reason)}`,
393
+ ),
358
394
  }
359
395
  }
360
396
 
397
+ return {
398
+ answer: deps.escapeHtml(result.confirmation),
399
+ reply: await menuWithBanner(deps, `✅ ${deps.escapeHtml(result.confirmation)}`),
400
+ selectedModel: sessionModelFromConfirmation(result.confirmation) ?? target.label,
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Pull the model NAME out of claude's session-switch confirmation so it can
406
+ * be shown in `/status` as the live session model. claude phrases it as
407
+ * "Set model to <name> for this session only" (or "Switched to <name>").
408
+ * Returns null when the confirmation doesn't carry a recognizable name (the
409
+ * caller falls back to the tapped picker label).
410
+ */
411
+ export function sessionModelFromConfirmation(confirmation: string): string | null {
412
+ const m = /(?:Set model to|Switched to)\s+(.+?)(?:\s+for (?:this|the) session|\s*\(|\s*$)/i.exec(
413
+ confirmation.trim(),
414
+ )
415
+ const name = m?.[1]?.trim()
416
+ return name && name.length > 0 ? name : null
417
+ }
418
+
419
+ /**
420
+ * Re-render the live menu with a one-line banner on top. Used by every
421
+ * post-tap outcome (success, already-default, failure) so the menu ALWAYS
422
+ * keeps its buttons and the operator can act again — the consistent
423
+ * "status line + interactive menu" shape the other dashboards use. Falls
424
+ * back to the banner alone if the menu can't be rebuilt right now.
425
+ */
426
+ async function menuWithBanner(
427
+ deps: ModelMenuDeps & ModelCommandDeps,
428
+ banner: string,
429
+ ): Promise<ModelMenuReply> {
361
430
  const fresh = await buildModelMenu(deps)
362
- const confirmed: ModelMenuReply = {
363
- text: [`✅ ${deps.escapeHtml(result.confirmation)}`, '', fresh.text].join('\n'),
431
+ return {
432
+ text: [banner, '', fresh.text].join('\n'),
364
433
  html: true,
365
434
  ...(fresh.keyboard ? { keyboard: fresh.keyboard } : {}),
366
435
  }
367
- return { answer: result.confirmation, reply: confirmed }
368
436
  }
@@ -285,6 +285,107 @@ describe('inbound-spool — bounded escalation (promise always resolved)', () =>
285
285
  })
286
286
  })
287
287
 
288
+ describe('inbound-spool — give-up notice coalescing (2026-06-09 marko spam)', () => {
289
+ // Helper: drive a sweep, return the list of postNotice flags per dropped entry.
290
+ function sweepFlags(s: ReturnType<typeof createInboundSpool>): boolean[] {
291
+ const flags: boolean[] = []
292
+ s.sweepEscalations((_e, { postNotice }) => flags.push(postNotice))
293
+ return flags
294
+ }
295
+
296
+ it('a burst of undeliverable entries in one chat posts exactly ONE notice', () => {
297
+ const fs = fakeFs()
298
+ let t = 0
299
+ const s = createInboundSpool({
300
+ path: PATH, fs, now: () => t,
301
+ escalateAfterMs: 100, escalateNoticeCooldownMs: 10_000,
302
+ })
303
+ // Three synthetics, same chat, distinct ids (fresh ts → distinct spoolId,
304
+ // the exact churn shape that produced the spam).
305
+ s.put('marko', msg({ messageId: 0, ts: 1, meta: { source: 'cron' } }))
306
+ s.put('marko', msg({ messageId: 0, ts: 2, meta: { source: 'cron' } }))
307
+ s.put('marko', msg({ messageId: 0, ts: 3, meta: { source: 'cron' } }))
308
+ t = 1000 // all older than the 100ms bound
309
+ const flags = sweepFlags(s)
310
+ expect(flags.length).toBe(3) // all three dropped (promise retracted)
311
+ expect(flags.filter(Boolean).length).toBe(1) // ONE notice posted
312
+ expect(s.liveCount()).toBe(0)
313
+ })
314
+
315
+ it('distinct chats each get their own notice', () => {
316
+ const fs = fakeFs()
317
+ let t = 0
318
+ const s = createInboundSpool({ path: PATH, fs, now: () => t, escalateAfterMs: 100 })
319
+ s.put('marko', msg({ chatId: 'A', messageId: 1 }))
320
+ s.put('marko', msg({ chatId: 'B', messageId: 2 }))
321
+ t = 1000
322
+ expect(sweepFlags(s).filter(Boolean).length).toBe(2)
323
+ })
324
+
325
+ it('same chat, different forum topics are coalesced independently', () => {
326
+ const fs = fakeFs()
327
+ let t = 0
328
+ const s = createInboundSpool({ path: PATH, fs, now: () => t, escalateAfterMs: 100 })
329
+ s.put('marko', msg({ chatId: 'A', messageId: 1, meta: { threadId: '3' } }))
330
+ s.put('marko', msg({ chatId: 'A', messageId: 2, meta: { threadId: '4' } }))
331
+ t = 1000
332
+ expect(sweepFlags(s).filter(Boolean).length).toBe(2)
333
+ })
334
+
335
+ it('THE BUG: the coalescing window survives a restart — a re-aged synthetic does not re-spam', () => {
336
+ const fs = fakeFs()
337
+ let t = 0
338
+ const opts = { escalateAfterMs: 100, escalateNoticeCooldownMs: 60_000 }
339
+ // Boot 1: one synthetic ages out → posts the notice.
340
+ const s1 = createInboundSpool({ path: PATH, fs, now: () => t, ...opts })
341
+ s1.put('marko', msg({ messageId: 0, ts: 1, meta: { source: 'cron' } }))
342
+ t = 1000
343
+ expect(sweepFlags(s1)).toEqual([true])
344
+ // Restart. A NEW synthetic (fresh ts → fresh id) lands and ages out within
345
+ // the cooldown. Pre-fix this re-posted every cycle across restarts.
346
+ t = 5000
347
+ const s2 = createInboundSpool({ path: PATH, fs, now: () => t, ...opts })
348
+ s2.put('marko', msg({ messageId: 0, ts: 2, meta: { source: 'cron' } }))
349
+ t = 6000
350
+ expect(sweepFlags(s2)).toEqual([false]) // dropped, but notice SUPPRESSED
351
+ })
352
+
353
+ it('compaction preserves the coalescing window (a post-compaction restart does not re-spam)', () => {
354
+ const fs = fakeFs()
355
+ let t = 0
356
+ // Tiny compact threshold so the next append triggers a rewrite.
357
+ const opts = { escalateAfterMs: 100, escalateNoticeCooldownMs: 60_000, compactAtBytes: 1 }
358
+ const s1 = createInboundSpool({ path: PATH, fs, now: () => t, ...opts })
359
+ s1.put('marko', msg({ messageId: 0, ts: 1, meta: { source: 'cron' } }))
360
+ t = 1000
361
+ expect(sweepFlags(s1)).toEqual([true]) // posts + appends esc; compaction runs
362
+ // After compaction the file must still carry the esc record → a restart
363
+ // hydrates the window → a new re-aged synthetic stays suppressed.
364
+ t = 5000
365
+ const s2 = createInboundSpool({ path: PATH, fs, now: () => t, ...opts })
366
+ s2.put('marko', msg({ messageId: 0, ts: 2, meta: { source: 'cron' } }))
367
+ t = 6000
368
+ expect(sweepFlags(s2)).toEqual([false])
369
+ })
370
+
371
+ it('re-notifies after the burst goes quiet for longer than the cooldown', () => {
372
+ const fs = fakeFs()
373
+ let t = 0
374
+ const s = createInboundSpool({
375
+ path: PATH, fs, now: () => t,
376
+ escalateAfterMs: 100, escalateNoticeCooldownMs: 1000,
377
+ })
378
+ s.put('marko', msg({ messageId: 0, ts: 1, meta: { source: 'cron' } }))
379
+ t = 200
380
+ expect(sweepFlags(s)).toEqual([true]) // first notice
381
+ // Quiet gap longer than the cooldown, then a new stuck synthetic.
382
+ t = 5000
383
+ s.put('marko', msg({ messageId: 0, ts: 2, meta: { source: 'cron' } }))
384
+ t = 5200
385
+ expect(sweepFlags(s)).toEqual([true]) // genuinely new situation → re-notify
386
+ })
387
+ })
388
+
288
389
  describe('inbound-spool — robustness', () => {
289
390
  it('a failing appendFileSync does not throw and keeps in-memory live state', () => {
290
391
  const fs = fakeFs()
@@ -212,6 +212,7 @@ import {
212
212
  buildModelMenu,
213
213
  handleModelMenuCallback,
214
214
  modelSelectCallbackData,
215
+ sessionModelFromConfirmation,
215
216
  MODEL_CALLBACK_REFRESH,
216
217
  type ModelMenuDeps,
217
218
  } from "../gateway/model-command.js";
@@ -316,27 +317,61 @@ describe("handleModelMenuCallback", () => {
316
317
  expect(out.reply.keyboard).toBeDefined();
317
318
  });
318
319
 
319
- it("tapping the current model is a no-op refresh", async () => {
320
+ it("tapping the (default) row STILL drives a switch — ✔ is the new-session default, not the live session model", async () => {
321
+ // OPTIONS marks "Sonnet" current (the ✔). An agent launched on a
322
+ // different model must still be able to apply the ✔ row to its live
323
+ // session — skipping it was the "tapped Default, nothing happened" bug.
320
324
  const { deps, calls } = makeMenuDeps();
321
325
  const out = await handleModelMenuCallback(modelSelectCallbackData("Sonnet"), deps);
322
- expect(calls.select).toEqual([]);
323
- expect(out.answer).toContain("Already on Sonnet");
326
+ expect(calls.select).toEqual(["Sonnet"]);
327
+ expect(out.reply.text).toContain("");
328
+ expect(out.reply.keyboard).toBeDefined();
324
329
  });
325
330
 
326
- it("busy agent → never selects", async () => {
331
+ it("busy agent → toastOnly refusal that leaves the menu untouched", async () => {
327
332
  const { deps, calls } = makeMenuDeps({ isBusy: () => true });
328
333
  const out = await handleModelMenuCallback(modelSelectCallbackData("Haiku"), deps);
329
334
  expect(calls.select).toEqual([]);
330
335
  expect(out.answer).toContain("mid-turn");
336
+ // toastOnly tells the gateway to NOT edit the menu — buttons survive.
337
+ expect(out.toastOnly).toBe(true);
331
338
  });
332
339
 
333
- it("selection failure surfaces the reason", async () => {
340
+ it("selection failure surfaces the reason AND keeps the menu so the operator can retry", async () => {
334
341
  const { deps } = makeMenuDeps({
335
342
  select: async () => ({ ok: false as const, reason: "cursor verification failed" }),
336
343
  });
337
344
  const out = await handleModelMenuCallback(modelSelectCallbackData("Haiku"), deps);
338
- expect(out.answer).toBe("Switch failed");
345
+ expect(out.answer).toContain("failed");
339
346
  expect(out.reply.text).toContain("cursor verification failed");
347
+ // The menu buttons are preserved — a failure no longer collapses the
348
+ // menu to a button-less error (the "nothing happened" bug).
349
+ expect(out.reply.keyboard).toBeDefined();
350
+ });
351
+
352
+ it("a successful switch banners the confirmation, keeps the menu, AND reports the live model for /status", async () => {
353
+ const { deps } = makeMenuDeps({
354
+ select: async () => ({ ok: true as const, confirmation: "Set model to Haiku 4.5 for this session only" }),
355
+ });
356
+ const out = await handleModelMenuCallback(modelSelectCallbackData("Haiku"), deps);
357
+ expect(out.answer).toContain("Haiku 4.5");
358
+ expect(out.reply.text).toContain("✅");
359
+ expect(out.reply.text).toContain("Set model to Haiku 4.5");
360
+ expect(out.reply.keyboard).toBeDefined();
361
+ // The gateway records this so /status reflects the live session model.
362
+ expect(out.selectedModel).toBe("Haiku 4.5");
363
+ });
364
+ });
365
+
366
+ describe("sessionModelFromConfirmation", () => {
367
+ it("pulls the model name from claude's session-switch confirmation", () => {
368
+ expect(sessionModelFromConfirmation("Set model to Fable 5 for this session only")).toBe("Fable 5");
369
+ expect(sessionModelFromConfirmation("Set model to Opus 4.8 (1M context) for this session only")).toBe("Opus 4.8");
370
+ expect(sessionModelFromConfirmation("Switched to Haiku 4.5")).toBe("Haiku 4.5");
371
+ });
372
+ it("returns null when no recognizable name is present", () => {
373
+ expect(sessionModelFromConfirmation("Kept model as Opus 4.8 (default)")).toBeNull();
374
+ expect(sessionModelFromConfirmation("")).toBeNull();
340
375
  });
341
376
 
342
377
  it("mdl:r re-renders the dashboard", async () => {
@@ -82,6 +82,17 @@ describe("formatAgentLine", () => {
82
82
  const out = formatAgentLine({ ...baseMeta, topicName: "Planning", topicEmoji: "🗓" });
83
83
  expect(out).toContain("topic: 🗓 Planning");
84
84
  });
85
+ it("shows the live session model alongside the configured model when a /model switch is active", () => {
86
+ const out = formatAgentLine({ ...baseMeta, model: "claude-fable-5[1m]", sessionModel: "Opus 4.8 (1M context)" });
87
+ // Both surfaces present + agree: configured AND what's actually running.
88
+ expect(out).toContain("<code>claude-fable-5[1m]</code>");
89
+ expect(out).toContain("live session: <code>Opus 4.8 (1M context)</code>");
90
+ });
91
+ it("omits the session line when no override is active", () => {
92
+ expect(formatAgentLine({ ...baseMeta, sessionModel: null })).not.toContain("live session");
93
+ expect(formatAgentLine({ ...baseMeta, sessionModel: "" })).not.toContain("live session");
94
+ expect(formatAgentLine(baseMeta)).not.toContain("live session");
95
+ });
85
96
  it("omits topic when only emoji is set", () => {
86
97
  // topicName null → no topic chunk. Keeps the line clean.
87
98
  expect(formatAgentLine({ ...baseMeta, topicEmoji: "🗓" })).not.toContain("topic");
@@ -66,6 +66,14 @@ export type StatusProbeRow = {
66
66
  export type AgentMetadata = {
67
67
  agentName: string;
68
68
  model: string | null;
69
+ /**
70
+ * Live session-model override set via the `/model` picker (session-only,
71
+ * resets on restart). When present it's what the agent is ACTUALLY running
72
+ * right now, distinct from `model` (the persistent configured model). Null
73
+ * when no session switch is active — then `/status` just shows `model`.
74
+ * Surfaced so `/status` and `/model` never silently disagree.
75
+ */
76
+ sessionModel?: string | null;
69
77
  extendsProfile: string | null;
70
78
  topicName: string | null;
71
79
  topicEmoji: string | null;
@@ -122,7 +130,14 @@ export function formatAgentLine(meta: AgentMetadata): string {
122
130
  const topic = meta.topicName
123
131
  ? ` · topic: ${escapeHtml([meta.topicEmoji, meta.topicName].filter(Boolean).join(" "))}`
124
132
  : "";
125
- return `<b>${escapeHtml(meta.agentName)}</b> · model: <code>${escapeHtml(m)}</code>${topic}`;
133
+ // A live `/model` session switch overrides what's running. Show it next to
134
+ // the configured model so the two surfaces agree (the override resets on
135
+ // restart, when the session reverts to the configured model).
136
+ const session =
137
+ meta.sessionModel && meta.sessionModel.length > 0
138
+ ? ` · live session: <code>${escapeHtml(meta.sessionModel)}</code>`
139
+ : "";
140
+ return `<b>${escapeHtml(meta.agentName)}</b> · model: <code>${escapeHtml(m)}</code>${session}${topic}`;
126
141
  }
127
142
 
128
143
  /**
@@ -1,40 +0,0 @@
1
- # HEARTBEAT.md — Proactive Check-Ins
2
-
3
- This file is read on every turn (it's a dynamic workspace file). Edit it
4
- to tell yourself what to look for when someone (or something) prompts you
5
- with a bare "heartbeat" — a cron firing, a quiet-period nudge, or a
6
- scheduled check-in.
7
-
8
- ## When this fires
9
-
10
- A heartbeat arrives as a user-role message with no real payload — often
11
- just "HEARTBEAT" or "heartbeat check". When that happens:
12
-
13
- 1. Run through the bullets below in order.
14
- 2. If anything needs action, respond normally (and take the action).
15
- 3. If nothing needs action, respond with exactly `HEARTBEAT_OK` on its
16
- own line. The plugin suppresses that as a silent reply — no Telegram
17
- message gets sent, and the user isn't notified.
18
-
19
- ## Things to check (customize per-agent)
20
-
21
- - **New emails / messages:** is there anything in the inbox or
22
- connected channels that looks actionable?
23
- - **Upcoming calendar events:** anything in the next ~2 hours the user
24
- should be reminded of?
25
- - **Long-running tasks:** any background work you kicked off earlier
26
- that might have completed?
27
- - **Today's plan:** anything in `memory/YYYY-MM-DD.md` (today's
28
- daily note, auto-loaded into context by the dynamic workspace hook)
29
- that hasn't been touched?
30
-
31
- ## Guidelines
32
-
33
- - **Respect quiet hours.** If it's late (local time 22:00–08:00),
34
- default to `HEARTBEAT_OK` unless something is genuinely urgent.
35
- - **Don't spam.** If you messaged the user in the last 30 minutes,
36
- `HEARTBEAT_OK` unless there's something new to add.
37
- - **Stay terse.** A heartbeat-initiated message should be one or two
38
- lines, not a paragraph.
39
-
40
- Edit this file to narrow or broaden the check set for this agent.