switchroom 0.18.26 → 0.18.27

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 (32) hide show
  1. package/README.md +6 -2
  2. package/dist/cli/ms-365-write-pretool.mjs +4953 -14
  3. package/dist/cli/switchroom.js +1 -1
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +1 -1
  6. package/profiles/_base/start.sh.hbs +16 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +494 -36
  8. package/telegram-plugin/flushed-turn-supersede.ts +58 -0
  9. package/telegram-plugin/gateway/derive-turn-id.ts +32 -0
  10. package/telegram-plugin/gateway/gateway.ts +305 -41
  11. package/telegram-plugin/gateway/handback-preturn-signal.ts +442 -0
  12. package/telegram-plugin/gateway/model-command.ts +68 -0
  13. package/telegram-plugin/gateway/ms365-write-approval.test.ts +101 -0
  14. package/telegram-plugin/gateway/ms365-write-approval.ts +65 -3
  15. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +12 -0
  16. package/telegram-plugin/gateway/turn-active-marker.ts +35 -0
  17. package/telegram-plugin/send-gate.test.ts +138 -0
  18. package/telegram-plugin/send-gate.ts +104 -1
  19. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +14 -4
  20. package/telegram-plugin/tests/effort-command.test.ts +47 -0
  21. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +60 -0
  22. package/telegram-plugin/tests/handback-preturn-adoption-roundtrip.test.ts +211 -0
  23. package/telegram-plugin/tests/handback-preturn-signal.test.ts +346 -0
  24. package/telegram-plugin/tests/model-command.test.ts +112 -0
  25. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +14 -2
  26. package/telegram-plugin/tests/outbound-send-chunks.test.ts +57 -0
  27. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +18 -11
  28. package/telegram-plugin/tests/reply-owner-resolve.test.ts +90 -0
  29. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +5 -0
  30. package/telegram-plugin/tests/turn-active-marker.test.ts +29 -0
  31. package/telegram-plugin/tests/worker-activity-feed.test.ts +121 -0
  32. package/telegram-plugin/worker-activity-feed.ts +91 -1
@@ -238,6 +238,21 @@ export interface WorkerActivityFeedOpts {
238
238
  * `finish` edit bypass it.
239
239
  */
240
240
  minEditIntervalMs?: number
241
+ /**
242
+ * Coarse cadence (ms) for elapsed-ONLY refreshes. When the only thing that
243
+ * changed since the last edit is the volatile elapsed clock (no new step, no
244
+ * state/toolCount/token change), the card is NOT re-edited until this much
245
+ * time has passed — so a worker sitting in one long tool call advances its
246
+ * clock only every ~`elapsedRefreshMs`, not every jsonl tick. This is the
247
+ * primary defense against the sustained same-message edit stream that earns a
248
+ * Telegram flood ban (finn incident: 26,460 clock-only edits → ~88min 429).
249
+ * A SUBSTANTIVE change (new narrative step, done/failed, toolCount/token
250
+ * delta) still renders promptly under `minEditIntervalMs`. Default 15000ms.
251
+ * The terminal finish edit and forced edits bypass it. Liveness for a truly
252
+ * silent worker is already carried by the typing loop, so a slow clock is not
253
+ * a liveness regression.
254
+ */
255
+ elapsedRefreshMs?: number
241
256
  /**
242
257
  * A worker must have been running at least this long before its first
243
258
  * message is posted. Sub-second / trivial workers never surface a live
@@ -467,6 +482,14 @@ interface FeedGroup {
467
482
  */
468
483
  messageCreatedAtMs: number
469
484
  lastBody: string | null
485
+ /**
486
+ * Substance signature of the last EDITED body — every rendered field EXCEPT
487
+ * the volatile elapsed clock (and heartbeat live suffix). Used to distinguish
488
+ * an elapsed-only change (paced to `elapsedRefreshMs`) from a real update
489
+ * (new step / state / toolCount / tokens, rendered promptly). Null until the
490
+ * group's first paint. See {@link groupSubstanceKey}.
491
+ */
492
+ lastSubstanceKey: string | null
470
493
  lastEditAt: number
471
494
  cooldownUntil: number
472
495
  /** Single serialization chain for the shared message — ticks can't interleave sends. */
@@ -647,6 +670,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
647
670
  const nowFn = opts.now ?? Date.now
648
671
  const floodWaitRemainingMs = opts.floodWaitRemainingMs ?? (() => 0)
649
672
  const minEditInterval = opts.minEditIntervalMs ?? 2500
673
+ const elapsedRefreshMs = Math.max(minEditInterval, Math.floor(opts.elapsedRefreshMs ?? 15000))
650
674
  const firstPaintMin = opts.firstPaintMinMs ?? 8000
651
675
  const heartbeatTickMs = opts.heartbeatTickMs ?? 6000
652
676
  const maxRows = Math.max(1, Math.floor(opts.maxRows ?? 8))
@@ -820,6 +844,52 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
820
844
  return renderCombinedWorkerFeed(rows, { maxRows })
821
845
  }
822
846
 
847
+ /**
848
+ * Substance signature of a group's render at `now` — a stable string of every
849
+ * field that renders EXCEPT the volatile elapsed clock (and the heartbeat
850
+ * live suffix, which is elapsed-derived). Two renders with the same substance
851
+ * key differ only in their clock; an edit between them advances nothing the
852
+ * user cares about and is exactly the churn that accumulates into a flood ban.
853
+ *
854
+ * Built from the group data (not by string-stripping the rendered body) so it
855
+ * is deterministic and robust to render-format changes: description, state,
856
+ * toolCount, totalTokens, and the full narrative trail per running row (or the
857
+ * terminal recap's state/result/narrative when finalizing).
858
+ */
859
+ function groupSubstanceKey(g: FeedGroup, terminalRecap: WorkerActivityView | null): string {
860
+ // Unambiguous delimiters so adjacent fields can never blur into a boundary
861
+ // collision (e.g. toolCount `1`+desc `"23"` vs toolCount `12`+desc `"3"`).
862
+ // FS separates fields within a row; RS separates rows in the combined body.
863
+ const FS = '\x00'
864
+ const RS = '\x1e'
865
+ if (terminalRecap != null) {
866
+ return [
867
+ 'T',
868
+ terminalRecap.state,
869
+ terminalRecap.description,
870
+ terminalRecap.toolCount,
871
+ terminalRecap.totalTokens ?? '',
872
+ terminalRecap.latestSummary,
873
+ ...(terminalRecap.narrativeLines ?? []),
874
+ ].join(FS)
875
+ }
876
+ const running = runningRows(g)
877
+ if (running.length === 0) return 'EMPTY'
878
+ return running
879
+ .map((r) => {
880
+ const v = r.lastView as WorkerActivityView
881
+ return [
882
+ r.agentId,
883
+ v.state,
884
+ v.description,
885
+ v.toolCount,
886
+ v.totalTokens ?? '',
887
+ ...r.narrative,
888
+ ].join(FS)
889
+ })
890
+ .join(RS)
891
+ }
892
+
823
893
  /** Remove a worker's row + index entry; delete the group if it is now empty. */
824
894
  function removeWorker(g: FeedGroup, agentId: string): void {
825
895
  g.workers.delete(agentId)
@@ -926,6 +996,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
926
996
  }
927
997
 
928
998
  const body = renderGroupBody(g, now, opts2.terminalRecap ?? null, opts2.heartbeat ?? false)
999
+ const substanceKey = groupSubstanceKey(g, opts2.terminalRecap ?? null)
929
1000
  if (body == null) {
930
1001
  // Nothing to show. On a terminal finalize the row is already dropped;
931
1002
  // clear the staged repaint (the handback carries the result).
@@ -961,6 +1032,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
961
1032
  // group-message lifetime cap measures the message's true absolute age.
962
1033
  g.messageCreatedAtMs = now
963
1034
  g.lastBody = body
1035
+ g.lastSubstanceKey = substanceKey
964
1036
  g.lastEditAt = now
965
1037
  // A fresh message is a live running paint, never a terminal recap.
966
1038
  g.terminalPainted = false
@@ -982,7 +1054,19 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
982
1054
  if (isTerminal) clearStaged()
983
1055
  return
984
1056
  }
985
- if (!opts2.force && now - g.lastEditAt < minEditInterval) return
1057
+ if (!opts2.force && !isTerminal) {
1058
+ // Spacing floor: never edit the same message faster than the 429 floor.
1059
+ if (now - g.lastEditAt < minEditInterval) return
1060
+ // Elapsed-only pacing (flood-ban defense): if the SUBSTANCE is unchanged
1061
+ // since the last edit — the body differs only because the clock advanced —
1062
+ // hold the edit until the coarse `elapsedRefreshMs` cadence. A real change
1063
+ // (new step / state / toolCount / tokens) shifts `substanceKey` and falls
1064
+ // through immediately (subject only to the spacing floor above). This is
1065
+ // what collapses the sustained ~0.33/s clock-only edit stream that
1066
+ // accumulates into Telegram's per-message flood counter.
1067
+ const substanceChanged = substanceKey !== g.lastSubstanceKey
1068
+ if (!substanceChanged && now - g.lastEditAt < elapsedRefreshMs) return
1069
+ }
986
1070
 
987
1071
  try {
988
1072
  const res = await opts.bot.editMessageText(g.chatId, g.messageId, body, sendOptsFor(g))
@@ -996,6 +1080,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
996
1080
  return
997
1081
  }
998
1082
  g.lastBody = body
1083
+ g.lastSubstanceKey = substanceKey
999
1084
  g.lastEditAt = now
1000
1085
  if (isTerminal) {
1001
1086
  log(
@@ -1029,6 +1114,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1029
1114
  }
1030
1115
  if (outcome === 'not_modified') {
1031
1116
  g.lastBody = body
1117
+ g.lastSubstanceKey = substanceKey
1032
1118
  g.lastEditAt = now
1033
1119
  if (isTerminal) clearStaged()
1034
1120
  return
@@ -1040,6 +1126,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1040
1126
  g.messageId = null
1041
1127
  g.messageCreatedAtMs = 0
1042
1128
  g.lastBody = null
1129
+ g.lastSubstanceKey = null
1043
1130
  // The pinned message no longer exists → release the group pin claim
1044
1131
  // (clearStaged already re-syncs on the terminal path).
1045
1132
  if (isTerminal) clearStaged()
@@ -1242,6 +1329,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1242
1329
  g.messageId = null
1243
1330
  g.messageCreatedAtMs = 0
1244
1331
  g.lastBody = null
1332
+ g.lastSubstanceKey = null
1245
1333
  // Clear the (possibly stale) pin claim for the retired message; the
1246
1334
  // fresh first-paint below re-pins the new one from a null claim.
1247
1335
  syncPin(g)
@@ -1322,6 +1410,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1322
1410
  messageId: null,
1323
1411
  messageCreatedAtMs: 0,
1324
1412
  lastBody: null,
1413
+ lastSubstanceKey: null,
1325
1414
  lastEditAt: 0,
1326
1415
  cooldownUntil: 0,
1327
1416
  chain: Promise.resolve(),
@@ -1342,6 +1431,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1342
1431
  g.messageId = null
1343
1432
  g.messageCreatedAtMs = 0
1344
1433
  g.lastBody = null
1434
+ g.lastSubstanceKey = null
1345
1435
  g.pendingFinalize.clear()
1346
1436
  g.terminalPainted = false
1347
1437
  // The stale terminal message is no longer this group's pinned surface.