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.
- package/README.md +6 -2
- package/dist/cli/ms-365-write-pretool.mjs +4953 -14
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +16 -0
- package/telegram-plugin/dist/gateway/gateway.js +494 -36
- package/telegram-plugin/flushed-turn-supersede.ts +58 -0
- package/telegram-plugin/gateway/derive-turn-id.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +305 -41
- package/telegram-plugin/gateway/handback-preturn-signal.ts +442 -0
- package/telegram-plugin/gateway/model-command.ts +68 -0
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +101 -0
- package/telegram-plugin/gateway/ms365-write-approval.ts +65 -3
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +12 -0
- package/telegram-plugin/gateway/turn-active-marker.ts +35 -0
- package/telegram-plugin/send-gate.test.ts +138 -0
- package/telegram-plugin/send-gate.ts +104 -1
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +14 -4
- package/telegram-plugin/tests/effort-command.test.ts +47 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +60 -0
- package/telegram-plugin/tests/handback-preturn-adoption-roundtrip.test.ts +211 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +346 -0
- package/telegram-plugin/tests/model-command.test.ts +112 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +14 -2
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +57 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +18 -11
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +90 -0
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +5 -0
- package/telegram-plugin/tests/turn-active-marker.test.ts +29 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +121 -0
- 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 &&
|
|
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.
|