switchroom 0.20.3 → 0.20.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.
Files changed (28) hide show
  1. package/dist/cli/switchroom.js +10256 -1233
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +3 -3
  4. package/skills/switchroom-release/SKILL.md +3 -2
  5. package/telegram-plugin/dist/gateway/gateway.js +452 -99
  6. package/telegram-plugin/edit-flood-fuse.ts +332 -14
  7. package/telegram-plugin/gateway/feed-open-gate.ts +28 -8
  8. package/telegram-plugin/gateway/feed-reopen-gate.ts +88 -6
  9. package/telegram-plugin/gateway/gateway.ts +130 -104
  10. package/telegram-plugin/gateway/narrative-lane.ts +19 -0
  11. package/telegram-plugin/gateway/progress-fallback-cap.ts +195 -0
  12. package/telegram-plugin/gateway/stream-render.ts +67 -12
  13. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +13 -0
  14. package/telegram-plugin/gateway/subagent-origin-surface.ts +181 -0
  15. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +7 -0
  16. package/telegram-plugin/registry/subagents-schema.ts +61 -0
  17. package/telegram-plugin/registry/turns-schema.ts +26 -0
  18. package/telegram-plugin/silence-poke.ts +80 -0
  19. package/telegram-plugin/tests/edit-flood-fuse-cosmetic-fairness.test.ts +229 -0
  20. package/telegram-plugin/tests/feed-open-gate.test.ts +42 -0
  21. package/telegram-plugin/tests/feed-reopen-gate.test.ts +114 -0
  22. package/telegram-plugin/tests/progress-cap.test.ts +182 -0
  23. package/telegram-plugin/tests/progress-fallback-cap.test.ts +91 -0
  24. package/telegram-plugin/tests/progress-update.test.ts +108 -12
  25. package/telegram-plugin/tests/silence-poke-card-render.test.ts +300 -0
  26. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +46 -0
  27. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +26 -0
  28. package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +304 -0
@@ -204,6 +204,45 @@
204
204
  * on the persisted path exactly the "a nudge and a 4.4h ban are the same
205
205
  * signal" defect that #3856 had just fixed on the in-memory path.
206
206
  *
207
+ * ── 2026-08 (#4300): one shared cosmetic bucket starved the watched card ──
208
+ * Point 2's `cosmeticPerChatMaxPerWindow` (6/60s) is ONE bucket shared across
209
+ * every cosmetic surface in a chat — the primary activity card, the worker /
210
+ * sub-agent card, and answer-stream draft edits. With two live cards plus a
211
+ * draft contending, the surface a user actually watches — the primary activity
212
+ * card — worst-cased well past 60s between permitted frames, purely from
213
+ * intra-cosmetic contention (not event starvation: the 6s heartbeat is trying
214
+ * to repaint). AIMD tightening halves the bucket and makes it worse. Two
215
+ * changes, both confined to this file, restore fairness WITHOUT raising the
216
+ * wire rate (so 429 risk is unchanged) and WITHOUT touching the reply reserve:
217
+ *
218
+ * 9. **Per-`message_id` fair-share of the cosmetic pool.** The shared bucket
219
+ * is split into a FLOOR lane — each distinct cosmetic `message_id` gets a
220
+ * guaranteed `cosmeticFloorPerWindow` edits/window (default 1/30s), up to
221
+ * `cosmeticFloorSlots` ids at once — and a REMAINDER lane (the rest of the
222
+ * pool), still shared and AIMD-reduced. The two caps SUM to
223
+ * `cosmeticPerChatMaxPerWindow`, so nothing new reaches the wire; a lone
224
+ * card still bursts to `cosmeticPerMessageMaxPerWindow` (its floor + the
225
+ * whole remainder). No surface can starve another, and no primary-vs-not
226
+ * discriminator is needed — the floor is symmetric per message id. Stale
227
+ * ids self-evict through the existing window LRU. See
228
+ * {@link createEditFloodFuse}'s `awaitCosmeticChatFair`.
229
+ * 10. **An AIMD tighten floor on the watched card.** The per-message cosmetic
230
+ * ceiling and the floor lane are both held at `cosmeticFloorPerWindow`
231
+ * (1/30s) no matter how many 429s tighten the pool — the same "floor the
232
+ * thing users watch" pattern as `perChatCriticalMinPerWindow`, so a flood
233
+ * backoff slows a card without freezing it. The aggregate cosmetic floor
234
+ * under maximum tightening is `cosmeticFloorPerWindow * cosmeticFloorSlots`
235
+ * (4/60s by default), which sits at the BOTTOM of the 4-6/min band the
236
+ * 2026-07-27 chat provably survived for hours — a deliberate, bounded
237
+ * relaxation of "cosmetic sheds to zero", reversible via the kill-switch
238
+ * `SWITCHROOM_FEED_FAIR_SHARE=0` (flag OFF is byte-identical to the old
239
+ * single shared bucket). The reply-starvation guarantee is UNTOUCHED: a
240
+ * floored cosmetic edit still charges `perChatTotalMaxPerWindow` and still
241
+ * yields to a real reply at that tier — the floor governs cosmetic-vs-
242
+ * cosmetic only. When the fuse defers a cosmetic edit past
243
+ * `throttleNoticeMs` (45s) it raises a `'throttled'` `onTrip` signal so the
244
+ * lag is observable rather than a silent freeze.
245
+ *
207
246
  * Every ceiling is operator-overridable via env — see
208
247
  * {@link editFloodFuseConfigFromEnv}. Previously none of them were, and until
209
248
  * #3885 the tightening CURVE (`maxTightenLevel`, `tightenFactor`) still was
@@ -332,6 +371,55 @@ export interface EditFloodFuseConfig {
332
371
  * own per-message ceiling still add up to a flood.
333
372
  */
334
373
  cosmeticPerChatMaxPerWindow?: number
374
+ /**
375
+ * PER-`message_id` FAIRNESS inside the shared cosmetic per-chat pool. Default
376
+ * true. When true, `cosmeticPerChatMaxPerWindow` is not a single contended
377
+ * bucket every cosmetic surface races for — instead each distinct cosmetic
378
+ * `message_id` is guaranteed a small floor of the pool (see
379
+ * `cosmeticFloorPerWindow` / `cosmeticFloorSlots`) that the OTHER cosmetic
380
+ * surfaces cannot consume, and only the REMAINDER stays shared/contended as
381
+ * before. This is the anti-starvation fix (#4300): with two live cosmetic
382
+ * cards (the primary activity card + a worker/sub-agent card) plus answer
383
+ * draft edits all charging one 6/60s bucket, the primary card worst-cased
384
+ * well past 60s between permitted frames purely from intra-cosmetic
385
+ * contention. Flag OFF is byte-identical to the pre-fix single shared bucket.
386
+ *
387
+ * SAFETY: the floor is carved FROM `cosmeticPerChatMaxPerWindow`, never added
388
+ * on top, so the wire rate is unchanged (429 risk does not rise) and the 40%
389
+ * per-chat reply reserve (`perChatReplyReserve`, on `perChatTotalMaxPerWindow`)
390
+ * is untouched — a floored cosmetic edit STILL yields to a real reply at the
391
+ * shared total tier. This governs cosmetic-vs-cosmetic only.
392
+ */
393
+ cosmeticFairShareEnabled?: boolean
394
+ /**
395
+ * The per-`message_id` guaranteed floor, in edits per `perChatWindowMs`,
396
+ * carved from `cosmeticPerChatMaxPerWindow`. Default 2 = 1 edit / 30s. This is
397
+ * ALSO the AIMD tighten floor: a floored cosmetic message never drops below
398
+ * this rate no matter how many 429s tighten the pool (mirrors
399
+ * `perChatCriticalMinPerWindow` — "floor the thing users watch"), so a flood
400
+ * backoff can slow a watched card but not freeze it. Set 0 to disable the
401
+ * floor while leaving fair-share keying on.
402
+ */
403
+ cosmeticFloorPerWindow?: number
404
+ /**
405
+ * How many DISTINCT cosmetic `message_id`s may each hold a guaranteed floor
406
+ * concurrently. Default 2 — the two sustained mid-turn cards (primary
407
+ * activity + worker feed). `cosmeticFloorPerWindow * cosmeticFloorSlots` is
408
+ * the slice reserved from the pool; the rest is the shared remainder, so this
409
+ * is clamped so the reserve can never exceed the pool. Additional concurrent
410
+ * cosmetic surfaces (e.g. an answer draft) contend the remainder exactly as
411
+ * today. Stale ids self-evict via the existing window LRU, so the tracked set
412
+ * cannot grow unbounded.
413
+ */
414
+ cosmeticFloorSlots?: number
415
+ /**
416
+ * How long a COSMETIC edit may sit deferred before the fuse emits a
417
+ * `'throttled'` `onTrip` signal, so the lag is observable (and a card can
418
+ * render a "updates throttled (flood backoff)" line) rather than freezing
419
+ * silently. Default 45000ms. The render itself is a gateway concern; the fuse
420
+ * only raises the signal.
421
+ */
422
+ throttleNoticeMs?: number
335
423
  /**
336
424
  * Shared per-chat allowance counting EVERY admitted call — edits and sends,
337
425
  * every class. Default 20 per 60s, matching Telegram's own per-chat/group
@@ -431,7 +519,7 @@ export interface EditFloodFuseConfig {
431
519
  onTrip?: (info: {
432
520
  method: string
433
521
  key: string
434
- action: 'deferred' | 'dropped' | 'superseded'
522
+ action: 'deferred' | 'dropped' | 'superseded' | 'throttled'
435
523
  cls: OutboundClass
436
524
  }) => void
437
525
  }
@@ -444,6 +532,14 @@ export interface EditFloodFuseStats {
444
532
  dropped: number
445
533
  /** Edits dropped because a NEWER edit to the same message arrived. */
446
534
  superseded: number
535
+ /**
536
+ * COSMETIC edits that crossed `throttleNoticeMs` while deferred — the direct
537
+ * measure of a card the flood backoff is visibly slowing. Non-zero here is
538
+ * the signal a "throttled" indicator should render for.
539
+ */
540
+ throttled: number
541
+ /** Whether per-`message_id` cosmetic fair-share is in force (the #4300 fix). */
542
+ cosmeticFairShareEnabled: boolean
447
543
  /** 429s observed (each one tightens the ceilings). */
448
544
  floodObserved: number
449
545
  /** Whether a tightened ceiling is in force right now. */
@@ -504,6 +600,23 @@ export const EDIT_FLOOD_FUSE_DEFAULTS = {
504
600
  * sits at the top of the survived band and less than half the banned rate.
505
601
  */
506
602
  cosmeticPerChatMaxPerWindow: 6,
603
+ /**
604
+ * 2/60s = 1 edit / 30s per distinct cosmetic message_id, guaranteed and
605
+ * AIMD-immune. Sized so a LONE card still bursts to the full
606
+ * `cosmeticPerMessageMaxPerWindow` (4): its 2 floor slots + the 2-slot
607
+ * remainder (6 - 2*2) = 4. Two live cards each keep 1/30s and share the
608
+ * remaining 2; a third cosmetic surface contends the remainder as before.
609
+ * The aggregate cosmetic floor under maximum tightening is therefore
610
+ * `cosmeticFloorPerWindow * cosmeticFloorSlots` = 4/60s, which sits at the
611
+ * bottom of the 4-6/min band the incident chat provably survived for hours
612
+ * (see this file's 2026-07-27 note) — enough to keep a watched card from
613
+ * freezing, low enough not to re-earn a ban. Disable via the kill-switch to
614
+ * restore shed-cosmetic-to-zero under pressure.
615
+ */
616
+ cosmeticFloorPerWindow: 2,
617
+ cosmeticFloorSlots: 2,
618
+ /** A cosmetic edit deferred longer than this raises a `'throttled'` signal. */
619
+ throttleNoticeMs: 45_000,
507
620
  /**
508
621
  * 20/60s. Telegram's documented per-group ceiling, and it meters sends and
509
622
  * edits together — so this is the only window that reflects the real budget.
@@ -579,6 +692,13 @@ export function editFloodFuseConfigFromEnv(
579
692
  }
580
693
  assign('cosmeticPerMessageMaxPerWindow', envInt(env.SWITCHROOM_FEED_EDIT_MAX_PER_MSG_PER_MIN))
581
694
  assign('cosmeticPerChatMaxPerWindow', envInt(env.SWITCHROOM_FEED_EDIT_MAX_PER_CHAT_PER_MIN))
695
+ // #4300 — per-message_id cosmetic fair-share. Kill-switch OFF restores the
696
+ // pre-fix single shared cosmetic bucket byte-for-byte; the floor/slots/notice
697
+ // knobs let an operator retune the reserved slice without a redeploy.
698
+ if (env.SWITCHROOM_FEED_FAIR_SHARE === '0') cfg.cosmeticFairShareEnabled = false
699
+ assign('cosmeticFloorPerWindow', envInt(env.SWITCHROOM_FEED_EDIT_FLOOR_PER_MSG_PER_MIN))
700
+ assign('cosmeticFloorSlots', envInt(env.SWITCHROOM_FEED_EDIT_FLOOR_SLOTS))
701
+ assign('throttleNoticeMs', envInt(env.SWITCHROOM_FEED_THROTTLE_NOTICE_MS))
582
702
  assign('perChatTotalMaxPerWindow', envInt(env.SWITCHROOM_CHAT_TOTAL_MAX_PER_MIN))
583
703
  assign('perChatReplyReserve', envInt(env.SWITCHROOM_CHAT_REPLY_RESERVE))
584
704
  assign('perChatCriticalMinPerWindow', envInt(env.SWITCHROOM_CHAT_CRITICAL_MIN_PER_MIN))
@@ -665,6 +785,23 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
665
785
  perMessageMax, config.cosmeticPerMessageMaxPerWindow ?? D.cosmeticPerMessageMaxPerWindow)
666
786
  const cosmeticPerChatMax = Math.min(
667
787
  perChatEditMax, config.cosmeticPerChatMaxPerWindow ?? D.cosmeticPerChatMaxPerWindow)
788
+ // #4300 — per-message_id fair-share of the cosmetic per-chat pool.
789
+ const cosmeticFairShareEnabled = config.cosmeticFairShareEnabled ?? true
790
+ // The per-message guaranteed floor, capped by the per-message cosmetic
791
+ // ceiling (a floor above the message's own max would be incoherent).
792
+ const cosmeticFloorPerWindow = Math.min(
793
+ cosmeticPerMessageMax,
794
+ Math.max(0, config.cosmeticFloorPerWindow ?? D.cosmeticFloorPerWindow))
795
+ // The reserved slice = floor * slots, clamped so it can never exceed the pool
796
+ // (a reserve larger than the pool would leave a negative remainder). This is
797
+ // the aggregate cap that bounds how many distinct ids hold a floor at once.
798
+ const cosmeticFloorAggMax = Math.min(
799
+ cosmeticPerChatMax,
800
+ cosmeticFloorPerWindow * Math.max(0, config.cosmeticFloorSlots ?? D.cosmeticFloorSlots))
801
+ // What is left of the pool after the reserve — the still-shared, still-AIMD
802
+ // remainder. Cosmetic edits beyond a message's floor contend here as before.
803
+ const cosmeticRemainderMax = Math.max(0, cosmeticPerChatMax - cosmeticFloorAggMax)
804
+ const throttleNoticeMs = Math.max(0, config.throttleNoticeMs ?? D.throttleNoticeMs)
668
805
  const perChatTotalMax = config.perChatTotalMaxPerWindow ?? D.perChatTotalMaxPerWindow
669
806
  const perChatWindowMs = config.perChatWindowMs ?? D.perChatWindowMs
670
807
  // Clamped so a misconfigured reserve can never eat more than the whole
@@ -704,6 +841,8 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
704
841
  meteredByDefault: 0,
705
842
  /** Calls with no `chat_id`, charged to the token window only. */
706
843
  chatless: 0,
844
+ /** Cosmetic edits that crossed `throttleNoticeMs` while deferred (#4300). */
845
+ throttled: 0,
707
846
  }
708
847
  /**
709
848
  * Compounding 429 backoff. `tightenLevel` is the number of multiplicative
@@ -807,6 +946,25 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
807
946
  return Math.max(eff, Math.min(base, perChatCriticalMin))
808
947
  }
809
948
 
949
+ /**
950
+ * The PER-MESSAGE cosmetic ceiling, with the #4300 AIMD tighten floor. Same
951
+ * shape as the `critical` floor in `classCeiling`: no amount of 429 tightening
952
+ * may drop a cosmetic card below `cosmeticFloorPerWindow` (default 1 edit/30s)
953
+ * on its own message tier, so the flood backoff slows a watched card without
954
+ * freezing it. Capped by `base` so it only ever raises a tightened ceiling
955
+ * back towards the configured max, never above it. Flag OFF ⇒ plain `ceiling`,
956
+ * i.e. byte-identical to the pre-fix behaviour.
957
+ *
958
+ * This floors ONLY cosmetic-vs-AIMD; the reply-starvation guarantee is on a
959
+ * different tier (`cosmeticTotalMax` / `perChatTotalMax`) and is untouched, so
960
+ * a floored cosmetic edit still yields to a real reply.
961
+ */
962
+ function cosmeticMessageCeiling(now: number): number {
963
+ const eff = ceiling(cosmeticPerMessageMax, now)
964
+ if (!cosmeticFairShareEnabled) return eff
965
+ return Math.max(eff, Math.min(cosmeticPerMessageMax, cosmeticFloorPerWindow))
966
+ }
967
+
810
968
  /**
811
969
  * The shared per-chat budget COSMETIC traffic may reach, re-derived against
812
970
  * the EFFECTIVE ceiling (#3885).
@@ -971,6 +1129,144 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
971
1129
  if (i >= 0) w.ts.splice(i, 1)
972
1130
  }
973
1131
 
1132
+ /**
1133
+ * A per-wait tracker that raises the `'throttled'` signal once (#4300) when a
1134
+ * COSMETIC call has been continuously deferred longer than `throttleNoticeMs`.
1135
+ * The first `tick(now)` records the defer start; each subsequent `tick` fires
1136
+ * the signal exactly once on crossing the threshold. `sleepCap(now)` returns
1137
+ * the ms until that threshold so the wait loop can clamp its sleep and wake to
1138
+ * fire the notice — WITHOUT this, a single wait that runs straight to the defer
1139
+ * deadline (`sleep(deadline - now)`) would skip past the threshold and never
1140
+ * signal. Once fired, `sleepCap` returns Infinity (no more clamping). Both are
1141
+ * no-ops for non-cosmetic calls and a zero/disabled threshold. Rendering the
1142
+ * visible indicator is a gateway concern reading `onTrip`/`stats().throttled`;
1143
+ * the fuse only signals.
1144
+ */
1145
+ function makeThrottleNotice(
1146
+ method: string, key: string, cls: OutboundClass,
1147
+ ): { tick: (now: number) => void; sleepCap: (now: number) => number } {
1148
+ // The 'throttled' marker is part of the #4300 fair-share feature, so the
1149
+ // kill-switch also disables it — leaving flag-OFF byte-identical to the
1150
+ // pre-fix behaviour (no new signal, no extra threshold wakeup).
1151
+ if (!cosmeticFairShareEnabled || cls !== 'cosmetic' || throttleNoticeMs <= 0) {
1152
+ return { tick: () => {}, sleepCap: () => Number.POSITIVE_INFINITY }
1153
+ }
1154
+ let firstAt = Number.NaN
1155
+ let fired = false
1156
+ return {
1157
+ tick: (now: number): void => {
1158
+ if (Number.isNaN(firstAt)) firstAt = now
1159
+ if (fired || now - firstAt < throttleNoticeMs) return
1160
+ fired = true
1161
+ counters.throttled++
1162
+ onTrip?.({ method, key, action: 'throttled', cls })
1163
+ },
1164
+ sleepCap: (now: number): number => {
1165
+ if (fired || Number.isNaN(firstAt)) return Number.POSITIVE_INFINITY
1166
+ return Math.max(1, firstAt + throttleNoticeMs - now)
1167
+ },
1168
+ }
1169
+ }
1170
+
1171
+ /**
1172
+ * Fair admission to the per-chat COSMETIC pool, keyed per `message_id` (#4300).
1173
+ *
1174
+ * The single shared `ce:${chat}` bucket every cosmetic surface raced for is
1175
+ * replaced (when `cosmeticFairShareEnabled`) by two lanes drawn from the SAME
1176
+ * `cosmeticPerChatMax` budget — so the wire rate, the 429 risk, and the reply
1177
+ * reserve are all unchanged:
1178
+ *
1179
+ * - a FLOOR lane: each distinct cosmetic `message_id` is guaranteed up to
1180
+ * `cosmeticFloorPerWindow` edits/window (`cmf:${chat}:${msg}`), bounded in
1181
+ * aggregate to `cosmeticFloorAggMax` = floor*slots (`cfa:${chat}`) so only
1182
+ * that many distinct ids hold a floor at once. This lane is AIMD-IMMUNE —
1183
+ * it is the anti-starvation + anti-freeze guarantee, and 429 tightening
1184
+ * cannot take a watched card below it.
1185
+ * - a REMAINDER lane (`cer:${chat}`, cap `cosmeticRemainderMax`, AIMD-reduced)
1186
+ * — the still-shared, still-contended part. A lone card uses its floor AND
1187
+ * the whole remainder, so it still bursts to `cosmeticPerMessageMax`.
1188
+ *
1189
+ * The two caps sum to `cosmeticPerChatMax`, so a floor edit and a remainder
1190
+ * edit can never both be admitted beyond the pool. Stale message windows
1191
+ * self-evict via the existing `evict` LRU, so the distinct-id set is bounded.
1192
+ *
1193
+ * Returns the reserved [key, ts] pairs (to hand to the caller's give-back on a
1194
+ * later-tier denial), or null when the edit is dropped. Same drop/late-release
1195
+ * semantics as the `'drop'` mode of {@link awaitRoom}: an over-budget frame is
1196
+ * dropped unless it is the LONE frame for its message, in which case it is
1197
+ * released late against the bounded overshoot budget.
1198
+ */
1199
+ async function awaitCosmeticChatFair(
1200
+ chat: string, msg: string, method: string, cls: OutboundClass,
1201
+ dropGuard: () => boolean, deadline: number, lateReleaseKey: string,
1202
+ ): Promise<Array<[string, number]> | null> {
1203
+ const fMsgKey = `cmf:${chat}:${msg}`
1204
+ const fAggKey = `cfa:${chat}`
1205
+ const remKey = `cer:${chat}`
1206
+ const fw = win(fMsgKey)
1207
+ const aw = win(fAggKey)
1208
+ const rw = win(remKey)
1209
+ let counted = false
1210
+ const throttle = makeThrottleNotice(method, remKey, cls)
1211
+ for (;;) {
1212
+ const now = clock.now()
1213
+ // Floor lane is free only when BOTH the per-message and the aggregate
1214
+ // floor windows have room; those caps are constants (AIMD-immune).
1215
+ const wFloor = Math.max(
1216
+ waitFor(fw, now, perChatWindowMs, cosmeticFloorPerWindow),
1217
+ waitFor(aw, now, perChatWindowMs, cosmeticFloorAggMax),
1218
+ )
1219
+ const remCap = cosmeticRemainderMax <= 0 ? 0 : ceiling(cosmeticRemainderMax, now)
1220
+ const wRem = waitFor(rw, now, perChatWindowMs, remCap)
1221
+ if (wFloor === 0) {
1222
+ fw.ts.push(now); aw.ts.push(now)
1223
+ return [[fMsgKey, now], [fAggKey, now]]
1224
+ }
1225
+ if (remCap > 0 && wRem === 0) {
1226
+ rw.ts.push(now)
1227
+ return [[remKey, now]]
1228
+ }
1229
+ if (now >= deadline) {
1230
+ // Over budget at the deadline: drop, unless this is the LONE frame for
1231
+ // its message (nothing newer will repaint it), which is released late
1232
+ // against the shared overshoot budget — identical to `awaitRoom`'s
1233
+ // per-chat drop mode, so a card's terminal frame never freezes it.
1234
+ if (dropGuard()) {
1235
+ counters.dropped++
1236
+ onTrip?.({ method, key: remKey, action: 'dropped', cls })
1237
+ return null
1238
+ }
1239
+ const lw = win(lateReleaseKey)
1240
+ prune(lw, now, perChatWindowMs)
1241
+ if (lw.ts.length >= ceiling(lateReleaseMax, now)) {
1242
+ counters.dropped++
1243
+ onTrip?.({ method, key: remKey, action: 'dropped', cls })
1244
+ return null
1245
+ }
1246
+ lw.ts.push(now)
1247
+ // Release into the remainder lane when it exists, else the floor lane —
1248
+ // either way the slot is recorded so the window reflects the wire.
1249
+ if (cosmeticRemainderMax > 0) {
1250
+ rw.ts.push(now)
1251
+ return [[remKey, now]]
1252
+ }
1253
+ fw.ts.push(now); aw.ts.push(now)
1254
+ return [[fMsgKey, now], [fAggKey, now]]
1255
+ }
1256
+ if (!counted) {
1257
+ counters.deferred++
1258
+ counted = true
1259
+ onTrip?.({ method, key: remKey, action: 'deferred', cls })
1260
+ }
1261
+ throttle.tick(now)
1262
+ // Both lanes are per-chat keys (a "newer" frame is usually a DIFFERENT
1263
+ // message), so this tier never supersedes — it waits for the sooner of the
1264
+ // two lanes to open, bounded by the shared deadline and clamped so the
1265
+ // loop wakes to raise the 'throttled' notice at its threshold.
1266
+ await clock.sleep(Math.min(Math.min(wFloor, wRem), deadline - now, throttle.sleepCap(now)))
1267
+ }
1268
+ }
1269
+
974
1270
  /**
975
1271
  * Wait for room on `key` and RESERVE the slot on success. Reserving inside
976
1272
  * the same synchronous step as the check is what makes the ceiling hold
@@ -1027,6 +1323,7 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
1027
1323
  ): Promise<number | null> {
1028
1324
  const w = win(key)
1029
1325
  let counted = false
1326
+ const throttle = makeThrottleNotice(method, key, cls)
1030
1327
  for (;;) {
1031
1328
  const now = clock.now()
1032
1329
  const wait = waitFor(w, now, windowMs, maxFor(now))
@@ -1067,6 +1364,10 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
1067
1364
  counted = true
1068
1365
  onTrip?.({ method, key, action: 'deferred', cls })
1069
1366
  }
1367
+ throttle.tick(now)
1368
+ // Clamp the sleep so a single long wait wakes to raise the 'throttled'
1369
+ // notice at its threshold rather than sleeping straight to the deadline.
1370
+ const nap = Math.min(wait, deadline - now, throttle.sleepCap(now))
1070
1371
  // Last-write-wins: a newer edit to the same message kills this one. Only
1071
1372
  // valid on the per-MESSAGE tier — on a per-chat key the "newer" edit is
1072
1373
  // usually to a DIFFERENT message, and killing an unrelated card's edit is
@@ -1077,7 +1378,7 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
1077
1378
  const superseded = new Promise<void>((resolve) => {
1078
1379
  w.waiter = { kill: () => { killed = true; resolve() } }
1079
1380
  })
1080
- await Promise.race([clock.sleep(Math.min(wait, deadline - now)), superseded])
1381
+ await Promise.race([clock.sleep(nap), superseded])
1081
1382
  if (killed) {
1082
1383
  counters.superseded++
1083
1384
  onTrip?.({ method, key, action: 'superseded', cls })
@@ -1085,7 +1386,7 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
1085
1386
  }
1086
1387
  w.waiter = null
1087
1388
  } else {
1088
- await clock.sleep(Math.min(wait, deadline - now))
1389
+ await clock.sleep(nap)
1089
1390
  }
1090
1391
  }
1091
1392
  }
@@ -1178,7 +1479,9 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
1178
1479
  const msgSlot = await awaitRoom(
1179
1480
  msgKey, perMessageWindowMs,
1180
1481
  cls === 'cosmetic'
1181
- ? (t) => ceiling(cosmeticPerMessageMax, t)
1482
+ // #4300: cosmetic per-message tier carries the AIMD tighten floor
1483
+ // (1 edit/30s) so a 429 cannot freeze a watched card at this tier.
1484
+ ? (t) => cosmeticMessageCeiling(t)
1182
1485
  : (t) => classCeiling(perMessageMax, cls, t),
1183
1486
  method, 'supersede', cls, dropGuard, deadline, lateKey,
1184
1487
  )
@@ -1186,16 +1489,29 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
1186
1489
  const reserved: Array<[string, number]> = [[msgKey, msgSlot]]
1187
1490
  const giveBack = (): void => { for (const [k, at] of reserved) unreserve(k, at) }
1188
1491
 
1189
- const chatKey = `ce:${chat}`
1190
- const chatSlot = await awaitRoom(
1191
- chatKey, perChatWindowMs,
1192
- cls === 'cosmetic'
1193
- ? (t) => ceiling(cosmeticPerChatMax, t)
1194
- : (t) => classCeiling(perChatEditMax, cls, t),
1195
- method, 'drop', cls, dropGuard, deadline, lateKey,
1196
- )
1197
- if (chatSlot === null) { giveBack(); return DROPPED_RESULT as unknown as R }
1198
- reserved.push([chatKey, chatSlot])
1492
+ // Per-chat COSMETIC pool. #4300: when fair-share is on, admit through the
1493
+ // per-`message_id` floor/remainder lanes so one cosmetic surface cannot
1494
+ // starve another; the two lanes sum to `cosmeticPerChatMax`, so the wire
1495
+ // rate is unchanged. Flag OFF (or a non-cosmetic edit) keeps the single
1496
+ // shared `ce:${chat}` bucket byte-for-byte.
1497
+ if (cls === 'cosmetic' && cosmeticFairShareEnabled) {
1498
+ const fairSlots = await awaitCosmeticChatFair(
1499
+ chat, msg, method, cls, dropGuard, deadline, lateKey!,
1500
+ )
1501
+ if (fairSlots === null) { giveBack(); return DROPPED_RESULT as unknown as R }
1502
+ reserved.push(...fairSlots)
1503
+ } else {
1504
+ const chatKey = `ce:${chat}`
1505
+ const chatSlot = await awaitRoom(
1506
+ chatKey, perChatWindowMs,
1507
+ cls === 'cosmetic'
1508
+ ? (t) => ceiling(cosmeticPerChatMax, t)
1509
+ : (t) => classCeiling(perChatEditMax, cls, t),
1510
+ method, 'drop', cls, dropGuard, deadline, lateKey,
1511
+ )
1512
+ if (chatSlot === null) { giveBack(); return DROPPED_RESULT as unknown as R }
1513
+ reserved.push([chatKey, chatSlot])
1514
+ }
1199
1515
 
1200
1516
  // Shared per-chat budget — the one that mirrors Telegram's real
1201
1517
  // metering. A non-cosmetic edit is RELEASED late rather than dropped:
@@ -1271,6 +1587,8 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
1271
1587
  deferred: counters.deferred,
1272
1588
  dropped: counters.dropped,
1273
1589
  superseded: counters.superseded,
1590
+ throttled: counters.throttled,
1591
+ cosmeticFairShareEnabled,
1274
1592
  floodObserved: counters.floodObserved,
1275
1593
  tightened: isTightened(now),
1276
1594
  tightenLevel: levelAt(now),
@@ -184,6 +184,21 @@ export interface FeedOpenInput {
184
184
  * this see no behaviour change.
185
185
  */
186
186
  postAnswerSubagentActivity?: boolean
187
+ /**
188
+ * Post-substantive feed reopen (long multi-phase MAIN-agent turns). True when
189
+ * the turn delivered a substantive final answer EARLY and then kept doing real
190
+ * tool work — `>= SUBSTANTIVE_REOPEN_MIN_LABELS` post-answer tool labels have
191
+ * arrived (`feed-reopen-gate.ts` → `decideFeedReopen().liftLeverOne`). The
192
+ * sibling of `postAnswerSubagentActivity`, but for the foreground agent's own
193
+ * post-answer tool labels rather than a sub-agent watcher.
194
+ *
195
+ * When true AND `producer === 'tool'`, Lever 1's blanket post-answer block is
196
+ * lifted so a fresh activity card may open below the delivered reply to show
197
+ * the still-working agent. Idle producers (`liveness`, `narrative`) stay
198
+ * blocked. Defaults to `false` (Lever 1 fully active) — callers that don't
199
+ * pass it see no behaviour change.
200
+ */
201
+ postAnswerMainActivity?: boolean
187
202
  }
188
203
 
189
204
  /**
@@ -194,13 +209,15 @@ export interface FeedOpenInput {
194
209
  * - crossTurnAnswerDelivered → false: lever 4. A cross-turn synthetic surface
195
210
  * whose exchange already delivered a substantive answer in an EARLIER turn;
196
211
  * no card may open below it (any producer). Checked FIRST.
197
- * - finalAnswerEverDelivered && !postAnswerSubagentActivity → false: lever 1.
198
- * A substantive final already landed THIS turn; no card may open below it.
199
- * Exception: when `postAnswerSubagentActivity === true` AND `producer ===
200
- * 'tool'`, Lever 1 is lifted so the background-agent liveness heartbeat can
201
- * surface a card below the reply showing the watcher's real new activity.
202
- * Idle producers ('liveness', 'narrative') stay blocked — no card opens from
203
- * wall-clock alone after the final answer.
212
+ * - finalAnswerEverDelivered && !(postAnswerSubagentActivity ||
213
+ * postAnswerMainActivity) false: lever 1. A substantive final already
214
+ * landed THIS turn; no card may open below it. Exception: when EITHER
215
+ * post-answer activity signal is true AND `producer === 'tool'`, Lever 1 is
216
+ * lifted so a card can surface below the reply the background-agent
217
+ * liveness heartbeat (`postAnswerSubagentActivity`) OR the foreground
218
+ * agent's own still-working post-answer tool labels (`postAnswerMainActivity`,
219
+ * the post-substantive feed reopen). Idle producers ('liveness', 'narrative')
220
+ * stay blocked — no card opens from wall-clock alone after the final answer.
204
221
  * - producer 'narrative': always allowed when pre-answer (lever 5 is INERT —
205
222
  * Lever 2 / clearActivitySummary guarantees reply-is-last ordering instead).
206
223
  * - producer 'tool' or 'liveness' → true (unless lever 1/4).
@@ -216,7 +233,10 @@ export function mayOpenActivityCard(input: FeedOpenInput): boolean {
216
233
  // (Fix 2 / #2587 supersede). Only 'tool' is exempted so idle liveness and
217
234
  // narrative producers remain blocked after the final answer.
218
235
  if (input.finalAnswerEverDelivered) {
219
- if (input.postAnswerSubagentActivity && input.producer === 'tool') return true
236
+ if (
237
+ (input.postAnswerSubagentActivity || input.postAnswerMainActivity)
238
+ && input.producer === 'tool'
239
+ ) return true
220
240
  return false
221
241
  }
222
242
  // Lever 5 — INERT (see module comment above). Pre-answer narrative may now
@@ -66,6 +66,37 @@
66
66
  * (correct — the user got only an ack, no answer).
67
67
  * 3. the feed gate itself — this module.
68
68
  *
69
+ * ## Post-SUBSTANTIVE reopen (long multi-phase turns)
70
+ *
71
+ * The ACK-only refinement above deliberately keeps the feed dark after a
72
+ * GENUINE final answer — routine post-answer housekeeping (a memory write /
73
+ * TodoWrite) should not resurrect the card. But that same gate blanks the
74
+ * feed for a legitimately DIFFERENT shape: a turn that delivers a substantive
75
+ * reply EARLY ("Here's the plan — starting now") and then keeps doing real
76
+ * tool work for 10+ minutes. The user sees the reply, then goes completely
77
+ * dark for the rest of the turn even though the agent is visibly working.
78
+ *
79
+ * The post-substantive reopen closes that gap WITHOUT reintroducing the
80
+ * duplicate-answer hazard the ACK-only refinement fixed:
81
+ * - It fires only after `SUBSTANTIVE_REOPEN_MIN_LABELS` (>= 2) post-answer
82
+ * tool labels have arrived, so a genuinely-final single-reply turn (0-1
83
+ * housekeeping tools) never flaps the card.
84
+ * - Crucially it does NOT clear `finalAnswerDelivered` (the ack path does):
85
+ * clearing it would trip the turn-end silent-end re-prompt (`turn-end-
86
+ * gate.ts`: `finalAnswerDelivered === false → reprompt`) → the exact
87
+ * duplicate-answer failure the ACK-only guard exists to prevent. The
88
+ * substantive answer STAYS delivered; only the visual feed reopens.
89
+ * - Because the sticky lever-1 latch stays true (it is never cleared by a
90
+ * reopen), the caller must LIFT lever 1 for this drain so the fresh card opens
91
+ * BELOW the delivered reply — exactly the exemption the post-answer
92
+ * sub-agent liveness path already uses (`feed-open-gate.ts`,
93
+ * `postAnswerSubagentActivity`). The outcome flags this via `liftLeverOne`.
94
+ *
95
+ * Gated by its own flag `SWITCHROOM_FEED_REOPEN_AFTER_SUBSTANTIVE` (default
96
+ * ON), passed by the caller as `reopenAfterSubstantiveEnabled`. When that flag
97
+ * is off (or omitted), the substantive branch returns `false` exactly as
98
+ * before — byte-identical legacy behaviour.
99
+ *
69
100
  * ## Kill switch
70
101
  *
71
102
  * `SWITCHROOM_FEED_REOPEN_AFTER_ACK=0` reverts to the legacy behaviour: a
@@ -74,6 +105,12 @@
74
105
  * passes `enabled` here.
75
106
  */
76
107
 
108
+ /** Number of post-substantive-answer tool labels that must arrive before the
109
+ * activity feed re-opens for a still-working turn. Two (not one) so a
110
+ * genuinely-final single-reply turn with a stray bit of post-answer
111
+ * housekeeping (0-1 tools) never flaps the card. */
112
+ export const SUBSTANTIVE_REOPEN_MIN_LABELS = 2
113
+
77
114
  export interface FeedReopenInput {
78
115
  /** Whether the turn has already been classified as having delivered its
79
116
  * final answer (`turn.finalAnswerDelivered`). On an ack-first turn this
@@ -90,6 +127,19 @@ export interface FeedReopenInput {
90
127
  /** Kill-switch state. When false the reopen behaviour is OFF and a tool
91
128
  * label after `finalAnswerDelivered` is dropped (legacy). */
92
129
  enabled: boolean
130
+ /** Post-SUBSTANTIVE reopen flag (`SWITCHROOM_FEED_REOPEN_AFTER_SUBSTANTIVE`,
131
+ * default ON). When true, a turn that delivered a substantive final answer
132
+ * and then kept doing tool work may RE-OPEN the feed once
133
+ * `postSubstantiveToolLabelCount >= SUBSTANTIVE_REOPEN_MIN_LABELS`. Omitted
134
+ * / false → the substantive branch returns false exactly as before (legacy).
135
+ * Distinct from `enabled` (the ack-first kill switch) so the two behaviours
136
+ * can be toggled independently. */
137
+ reopenAfterSubstantiveEnabled?: boolean
138
+ /** Count of tool labels that have arrived SINCE the substantive final answer
139
+ * was delivered this turn (`turn.postSubstantiveToolLabelCount`). Only
140
+ * consulted on the substantive branch; the reopen fires at
141
+ * `>= SUBSTANTIVE_REOPEN_MIN_LABELS`. Omitted → treated as 0 (no reopen). */
142
+ postSubstantiveToolLabelCount?: number
93
143
  }
94
144
 
95
145
  /**
@@ -99,10 +149,12 @@ export interface FeedReopenInput {
99
149
  *
100
150
  * - !finalAnswerDelivered → false: the feed was never gated off; the normal
101
151
  * append/drain path applies (no reopen needed).
102
- * - finalAnswerDelivered && finalAnswerSubstantive → false: the prior final
103
- * was a genuine answer (not an ack). Post-answer housekeeping tool work
104
- * must NOT reopen — keep the legacy gate so the silent-end re-prompt and
105
- * the #2137 drain see the delivered final correctly.
152
+ * - finalAnswerDelivered && finalAnswerSubstantive: the prior final was a
153
+ * genuine answer (not an ack). Reopen ONLY under the post-substantive path
154
+ * `reopenAfterSubstantiveEnabled` AND `>= SUBSTANTIVE_REOPEN_MIN_LABELS`
155
+ * post-answer tool labels (a still-working multi-phase turn). Otherwise keep
156
+ * the legacy gate so post-answer housekeeping does not flap the card and the
157
+ * silent-end re-prompt / #2137 drain see the delivered final correctly.
106
158
  * - finalAnswerDelivered && !enabled (kill switch off) → false: legacy
107
159
  * behaviour, the label is dropped by the caller.
108
160
  * - finalAnswerDelivered && !finalAnswerSubstantive && enabled → true: the
@@ -110,7 +162,14 @@ export interface FeedReopenInput {
110
162
  */
111
163
  export function shouldReopenFeedAfterAck(input: FeedReopenInput): boolean {
112
164
  if (!input.finalAnswerDelivered) return false
113
- if (input.finalAnswerSubstantive) return false
165
+ if (input.finalAnswerSubstantive) {
166
+ // Post-substantive reopen: a genuine answer landed, then the model kept
167
+ // doing tool work. Reopen only under the dedicated flag AND once enough
168
+ // post-answer labels have arrived that this is plainly a still-working
169
+ // turn, not a single-reply turn with a stray housekeeping tool.
170
+ if (input.reopenAfterSubstantiveEnabled !== true) return false
171
+ return (input.postSubstantiveToolLabelCount ?? 0) >= SUBSTANTIVE_REOPEN_MIN_LABELS
172
+ }
114
173
  return input.enabled === true
115
174
  }
116
175
 
@@ -128,8 +187,20 @@ export interface FeedReopenOutcome {
128
187
  /** True → the handler returns early (legacy: label dropped, feed dark). */
129
188
  dropLabel: boolean
130
189
  /** When dropLabel is false, the new feed-state fields to write on `turn`
131
- * before the normal append/drain proceeds. */
190
+ * before the normal append/drain proceeds. Present on the ACK-reopen path
191
+ * (which reclassifies the interim ack). ABSENT on the post-substantive
192
+ * reopen path: there `finalAnswerDelivered` MUST stay true (clearing it
193
+ * trips the turn-end re-prompt → duplicate answer) and `activityMessageId`
194
+ * is already null (the substantive answer's `clearActivitySummary` nulled
195
+ * it), so the drain opens a fresh card then edits it — no reset needed. */
132
196
  reset?: FeedReopenState
197
+ /** Post-substantive reopen only. True → the caller must lift lever 1 for
198
+ * this drain (`drainActivitySummary(..., { postAnswerMainActivity: true })`)
199
+ * so the fresh card may OPEN below the already-delivered substantive reply.
200
+ * The sticky lever-1 latch stays set, so without this lift
201
+ * `mayOpenActivityCard` would refuse the OPEN. Absent/false on the ack path
202
+ * (an ack never sets the sticky latch, so lever 1 is inert there). */
203
+ liftLeverOne?: boolean
133
204
  }
134
205
 
135
206
  /**
@@ -151,6 +222,17 @@ export function decideFeedReopen(input: FeedReopenInput): FeedReopenOutcome {
151
222
  if (!shouldReopenFeedAfterAck(input)) {
152
223
  return { dropLabel: true }
153
224
  }
225
+ // Post-substantive reopen: KEEP finalAnswerDelivered true (clearing it would
226
+ // trip the turn-end silent-end re-prompt → duplicate answer) and do NOT
227
+ // reset activityMessageId (the substantive answer's clearActivitySummary
228
+ // already nulled it; the drain OPENs once then EDITs). Only signal the
229
+ // caller to lift lever 1 so the fresh card may open below the reply.
230
+ if (input.finalAnswerSubstantive) {
231
+ return { dropLabel: false, liftLeverOne: true }
232
+ }
233
+ // ACK reopen (legacy): reclassify the interim ack — finalAnswerDelivered
234
+ // back to false, a FRESH feed message (activityMessageId null), last-sent
235
+ // render cleared so the drain re-sends.
154
236
  return {
155
237
  dropLabel: false,
156
238
  reset: {