switchroom 0.15.3 → 0.15.4
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.
- package/bin/turn-pacing-hook.sh +112 -0
- package/bin/workspace-dynamic-hook.sh +105 -15
- package/bin/workspace-stable-hook.sh +2 -2
- package/dist/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +2 -1
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +442 -394
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +2 -1
- package/dist/vault/broker/server.js +2 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +2 -2
- package/telegram-plugin/dist/gateway/gateway.js +100 -39
- package/telegram-plugin/gateway/gateway.ts +45 -9
- package/telegram-plugin/gateway/inbound-spool.ts +107 -16
- package/telegram-plugin/gateway/model-command.ts +89 -21
- package/telegram-plugin/tests/inbound-spool.test.ts +101 -0
- package/telegram-plugin/tests/model-command.test.ts +41 -6
- package/telegram-plugin/tests/welcome-text.test.ts +11 -0
- package/telegram-plugin/welcome-text.ts +16 -1
- package/profiles/default/workspace/HEARTBEAT.md.hbs +0 -40
|
@@ -102,14 +102,31 @@ export function spoolId(msg: InboundMessage): string {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
interface SpoolRecord {
|
|
105
|
-
t: 'put' | 'ack'
|
|
106
|
-
|
|
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`.
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
|
|
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
|
|
332
|
-
|
|
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
|
-
|
|
427
|
+
if (postNotice) posted++
|
|
428
|
+
dropped++
|
|
343
429
|
}
|
|
344
|
-
if (
|
|
345
|
-
|
|
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
|
|
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(`
|
|
294
|
+
lines.push(`Default (new sessions): <b>${deps.escapeHtml(current.label)}</b>${detail}`)
|
|
290
295
|
} else {
|
|
291
|
-
lines.push('
|
|
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
|
|
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 {
|
|
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
|
-
|
|
335
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
356
|
-
|
|
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
|
-
|
|
363
|
-
text: [
|
|
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
|
|
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.
|
|
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 →
|
|
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).
|
|
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
|
-
|
|
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.
|