switchroom 0.15.45 → 0.16.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.
- package/dist/agent-scheduler/index.js +56 -15
- package/dist/auth-broker/index.js +383 -97
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +7 -4
- package/dist/cli/notion-write-pretool.mjs +35 -4
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/switchroom.js +2894 -841
- package/dist/host-control/main.js +2685 -207
- package/dist/vault/approvals/kernel-server.js +7453 -7413
- package/dist/vault/broker/server.js +11428 -11388
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +97 -1
- package/profiles/_shared/execution-discipline.md.hbs +18 -0
- package/profiles/default/CLAUDE.md.hbs +0 -19
- package/telegram-plugin/.claude-plugin/plugin.json +2 -2
- package/telegram-plugin/answer-stream-flag.ts +12 -49
- package/telegram-plugin/answer-stream.ts +5 -150
- package/telegram-plugin/auth-snapshot-format.ts +280 -48
- package/telegram-plugin/auto-fallback-fleet.ts +44 -1
- package/telegram-plugin/context-exhaustion.ts +12 -0
- package/telegram-plugin/demo-mask.ts +154 -0
- package/telegram-plugin/dist/bridge/bridge.js +55 -12
- package/telegram-plugin/dist/gateway/gateway.js +2938 -977
- package/telegram-plugin/dist/server.js +55 -12
- package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
- package/telegram-plugin/draft-stream.ts +47 -410
- package/telegram-plugin/final-answer-detect.ts +17 -12
- package/telegram-plugin/fleet-fallback-resume.ts +131 -0
- package/telegram-plugin/format.ts +56 -19
- package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
- package/telegram-plugin/gateway/auth-command.ts +70 -14
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
- package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
- package/telegram-plugin/gateway/current-turn-map.ts +188 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
- package/telegram-plugin/gateway/effort-command.ts +8 -3
- package/telegram-plugin/gateway/emission-authority.ts +369 -0
- package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
- package/telegram-plugin/gateway/gateway.ts +1857 -292
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- package/telegram-plugin/gateway/model-command.ts +115 -4
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
- package/telegram-plugin/gateway/represent-guard.ts +72 -0
- package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
- package/telegram-plugin/gateway/status-surface-log.ts +14 -3
- package/telegram-plugin/history.ts +33 -11
- package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
- package/telegram-plugin/issues-card.ts +4 -0
- package/telegram-plugin/model-unavailable.ts +124 -0
- package/telegram-plugin/narrative-dedup.ts +69 -0
- package/telegram-plugin/over-ping-safety-net.ts +70 -4
- package/telegram-plugin/package.json +3 -3
- package/telegram-plugin/pending-work-progress.ts +12 -0
- package/telegram-plugin/permission-rule.ts +32 -5
- package/telegram-plugin/permission-title.ts +152 -9
- package/telegram-plugin/quota-check.ts +13 -0
- package/telegram-plugin/quota-watch.ts +135 -7
- package/telegram-plugin/registry/turns-schema.test.ts +24 -0
- package/telegram-plugin/registry/turns-schema.ts +9 -0
- package/telegram-plugin/runtime-metrics.ts +13 -0
- package/telegram-plugin/session-tail.ts +96 -11
- package/telegram-plugin/silence-poke.ts +170 -24
- package/telegram-plugin/slot-banner-driver.ts +3 -0
- package/telegram-plugin/status-no-truncate.ts +44 -0
- package/telegram-plugin/status-reactions.ts +20 -3
- package/telegram-plugin/stream-controller.ts +4 -23
- package/telegram-plugin/stream-reply-handler.ts +6 -24
- package/telegram-plugin/streaming-metrics.ts +91 -0
- package/telegram-plugin/subagent-watcher.ts +212 -66
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
- package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
- package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
- package/telegram-plugin/tests/answer-stream.test.ts +2 -411
- package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
- package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
- package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
- package/telegram-plugin/tests/demo-mask.test.ts +127 -0
- package/telegram-plugin/tests/draft-stream.test.ts +0 -827
- package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
- package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
- package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
- package/telegram-plugin/tests/feed-survival.test.ts +526 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
- package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
- package/telegram-plugin/tests/history.test.ts +60 -0
- package/telegram-plugin/tests/model-command.test.ts +134 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
- package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
- package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
- package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
- package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
- package/telegram-plugin/tests/permission-rule.test.ts +17 -0
- package/telegram-plugin/tests/permission-title.test.ts +206 -17
- package/telegram-plugin/tests/quota-watch.test.ts +252 -9
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
- package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
- package/telegram-plugin/tests/represent-guard.test.ts +162 -0
- package/telegram-plugin/tests/session-tail.test.ts +147 -3
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
- package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
- package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
- package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
- package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
- package/telegram-plugin/tests/telegram-format.test.ts +101 -6
- package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
- package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
- package/telegram-plugin/tests/tool-labels.test.ts +67 -0
- package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
- package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
- package/telegram-plugin/tests/welcome-text.test.ts +32 -3
- package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
- package/telegram-plugin/tool-activity-summary.ts +375 -58
- package/telegram-plugin/turn-liveness-floor.ts +240 -0
- package/telegram-plugin/uat/assertions.ts +115 -0
- package/telegram-plugin/uat/driver.ts +68 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
- package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
- package/telegram-plugin/welcome-text.ts +13 -1
- package/telegram-plugin/worker-activity-feed.ts +157 -82
- package/telegram-plugin/draft-transport.ts +0 -122
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
- package/telegram-plugin/tests/draft-transport.test.ts +0 -211
|
@@ -48,6 +48,12 @@
|
|
|
48
48
|
* pacing prompt + draft still apply; only the framework safety net is off.
|
|
49
49
|
*/
|
|
50
50
|
|
|
51
|
+
import {
|
|
52
|
+
decideMidTurnFloor,
|
|
53
|
+
midTurnFloorEnabled,
|
|
54
|
+
type LoopRole,
|
|
55
|
+
} from './turn-liveness-floor.js'
|
|
56
|
+
|
|
51
57
|
/** #1292: snapshot of an in-flight tool call, surfaced in the 300s
|
|
52
58
|
* framework-fallback message so the user sees the actual observable
|
|
53
59
|
* ("running Grep \"foo\" for 4m") instead of the dishonest generic
|
|
@@ -73,6 +79,10 @@ export interface SilencePokeState {
|
|
|
73
79
|
lastThinkingAt: number | null
|
|
74
80
|
/** True once the 300s framework fallback has fired this turn. */
|
|
75
81
|
fallbackFired: boolean
|
|
82
|
+
/** #2527: true once the mid-turn liveness floor has fired this turn.
|
|
83
|
+
* Independent of `fallbackFired` — the floor is the early (45s) quiet
|
|
84
|
+
* beat, the fallback the late (300s) loud unwedge. Fire-once each. */
|
|
85
|
+
floorFired: boolean
|
|
76
86
|
/** #1292: in-flight tool calls keyed by toolUseId. Populated by
|
|
77
87
|
* `noteToolStart` on every parent-agent `tool_use` event the gateway
|
|
78
88
|
* sees and drained by `noteToolEnd` on the matching `tool_result`.
|
|
@@ -99,6 +109,14 @@ export interface ThresholdsMs {
|
|
|
99
109
|
* defer is on; defaults to no ceiling (Infinity) when omitted.
|
|
100
110
|
*/
|
|
101
111
|
fallbackHardCeiling?: number
|
|
112
|
+
/**
|
|
113
|
+
* #2527 — mid-turn liveness floor threshold. After this much busy-silence
|
|
114
|
+
* on a `user` turn that hasn't delivered a substantive answer, the floor
|
|
115
|
+
* fires ONE quiet (no-ping) interim so the user isn't left staring at the
|
|
116
|
+
* ambient 👀. Strictly below `fallback` (which owns the beat above it).
|
|
117
|
+
* Omitted (undefined) disables the floor entirely.
|
|
118
|
+
*/
|
|
119
|
+
floor?: number
|
|
102
120
|
}
|
|
103
121
|
|
|
104
122
|
export const DEFAULT_THRESHOLDS: ThresholdsMs = {
|
|
@@ -127,8 +145,26 @@ export interface FrameworkFallbackContext {
|
|
|
127
145
|
inFlightTools: ToolSnapshot[]
|
|
128
146
|
}
|
|
129
147
|
|
|
148
|
+
/**
|
|
149
|
+
* #2527 — context handed to the gateway when the mid-turn floor fires. The
|
|
150
|
+
* gateway formats the honest text (from `inFlightTools`) and sends it through
|
|
151
|
+
* the SAME path a model reply takes — no parallel send. Mirrors
|
|
152
|
+
* `FrameworkFallbackContext` minus the wedge semantics: the floor never
|
|
153
|
+
* unwedges the turn, it just speaks.
|
|
154
|
+
*/
|
|
155
|
+
export interface MidTurnFloorContext {
|
|
156
|
+
key: string
|
|
157
|
+
chatId: string
|
|
158
|
+
threadId: number | null
|
|
159
|
+
silenceMs: number
|
|
160
|
+
inFlightTools: ToolSnapshot[]
|
|
161
|
+
/** True when fired by a user "Status?" mid-turn inbound rather than the timer. */
|
|
162
|
+
forced: boolean
|
|
163
|
+
}
|
|
164
|
+
|
|
130
165
|
export type SilencePokeMetric =
|
|
131
166
|
| { kind: 'silence_fallback_sent'; key: string; fallback_kind: 'working' | 'thinking'; silence_ms: number }
|
|
167
|
+
| { kind: 'mid_turn_floor'; key: string; silence_ms: number; forced: boolean; decision: 'fire' | string }
|
|
132
168
|
|
|
133
169
|
export interface SilencePokeDeps {
|
|
134
170
|
/** Called when the 300s fallback fires. Caller sends the user-visible
|
|
@@ -141,20 +177,40 @@ export interface SilencePokeDeps {
|
|
|
141
177
|
/** Poll interval (tests). */
|
|
142
178
|
pollIntervalMs?: number
|
|
143
179
|
/**
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
* demonstrably working
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* Default false (legacy behaviour) — enable per-agent to canary.
|
|
180
|
+
* Feed-survival predicate callback. When provided, the 300s framework
|
|
181
|
+
* fallback is DEFERRED while this function returns true: the agent is
|
|
182
|
+
* demonstrably working (in-flight tool, detached background process, or a
|
|
183
|
+
* human-wait tool like ask_user), and since #2162 the live activity feed
|
|
184
|
+
* shows that work, so nulling `currentTurn` would darken a feed the user is
|
|
185
|
+
* actively watching. The defer is bounded by `thresholdsMs.fallbackHardCeiling`
|
|
186
|
+
* so a hung-or-missing-work-signal turn still unwedges eventually.
|
|
152
187
|
*
|
|
153
|
-
*
|
|
154
|
-
* (
|
|
155
|
-
*
|
|
188
|
+
* This supersedes `deferFallbackWhileToolInFlight`. When present it is ALWAYS
|
|
189
|
+
* consulted (no extra env flag required). Set SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS=0
|
|
190
|
+
* in the environment to force-disable the defer even when this callback is wired.
|
|
191
|
+
*/
|
|
192
|
+
isLegitimatelyWorking?: (key: string) => boolean
|
|
193
|
+
/**
|
|
194
|
+
* Legacy boolean flag — honoured when `isLegitimatelyWorking` is absent.
|
|
195
|
+
* When true, the 300s fallback is deferred while `inFlightTools` is non-empty,
|
|
196
|
+
* bounded by `thresholdsMs.fallbackHardCeiling`.
|
|
197
|
+
* @deprecated Prefer `isLegitimatelyWorking` which covers detached work and
|
|
198
|
+
* human-wait tools in addition to foreground in-flight tool calls.
|
|
156
199
|
*/
|
|
157
200
|
deferFallbackWhileToolInFlight?: boolean
|
|
201
|
+
/**
|
|
202
|
+
* #2527 — called when the mid-turn liveness floor fires. The gateway sends
|
|
203
|
+
* the honest "still on it" interim through the shared reply path. Optional:
|
|
204
|
+
* when absent the floor never fires (back-compat for test harnesses).
|
|
205
|
+
*/
|
|
206
|
+
onMidTurnFloor?: (ctx: MidTurnFloorContext) => Promise<void> | void
|
|
207
|
+
/**
|
|
208
|
+
* #2527 — the gateway-owned half of the floor decision: the turn's loop
|
|
209
|
+
* role and whether a substantive answer has already landed. silence-poke
|
|
210
|
+
* owns the timing/working/fire-once half; the pure `decideMidTurnFloor`
|
|
211
|
+
* combines both. Returns null when there is no live turn for `key`.
|
|
212
|
+
*/
|
|
213
|
+
floorState?: (key: string) => { role: LoopRole; finalAnswerDelivered: boolean } | null
|
|
158
214
|
}
|
|
159
215
|
|
|
160
216
|
const state = new Map<string, SilencePokeState>()
|
|
@@ -180,6 +236,7 @@ export function startTurn(key: string, now: number): void {
|
|
|
180
236
|
lastOutboundAt: null,
|
|
181
237
|
lastThinkingAt: null,
|
|
182
238
|
fallbackFired: false,
|
|
239
|
+
floorFired: false,
|
|
183
240
|
inFlightTools: new Map(),
|
|
184
241
|
})
|
|
185
242
|
}
|
|
@@ -409,6 +466,76 @@ function truncateLabel(label: string): string {
|
|
|
409
466
|
return label.slice(0, MAX - 1) + '…'
|
|
410
467
|
}
|
|
411
468
|
|
|
469
|
+
/** Snapshot in-flight tools sorted longest-running first — for the honest
|
|
470
|
+
* floor/fallback message body. */
|
|
471
|
+
function snapshotInFlight(s: SilencePokeState, now: number): ToolSnapshot[] {
|
|
472
|
+
return Array.from(s.inFlightTools.values())
|
|
473
|
+
.sort((a, b) => a.startedAt - b.startedAt)
|
|
474
|
+
.map((t) => ({ name: t.name, label: t.label, durationMs: now - t.startedAt }))
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* #2527 — evaluate and (if eligible) fire the mid-turn liveness floor for one
|
|
479
|
+
* turn. silence-poke owns the timing/working/fire-once half; the gateway
|
|
480
|
+
* provides the role + delivery half via `floorState`; the pure
|
|
481
|
+
* `decideMidTurnFloor` combines them so the policy lives in one tested place.
|
|
482
|
+
* `forced=true` is a user "Status?" poke (bypasses timing + working).
|
|
483
|
+
*/
|
|
484
|
+
function tryMidTurnFloor(key: string, s: SilencePokeState, now: number, forced: boolean): void {
|
|
485
|
+
if (activeDeps == null) return
|
|
486
|
+
const { onMidTurnFloor, floorState, isLegitimatelyWorking } = activeDeps
|
|
487
|
+
if (onMidTurnFloor == null || floorState == null) return
|
|
488
|
+
const thresholds = activeDeps.thresholdsMs ?? DEFAULT_THRESHOLDS
|
|
489
|
+
if (thresholds.floor == null) return
|
|
490
|
+
const fs = floorState(key)
|
|
491
|
+
if (fs == null) return
|
|
492
|
+
const silence = now - (s.lastOutboundAt ?? s.turnStartedAt)
|
|
493
|
+
if (silence < 0) return
|
|
494
|
+
const decision = decideMidTurnFloor({
|
|
495
|
+
enabled: midTurnFloorEnabled(),
|
|
496
|
+
role: fs.role,
|
|
497
|
+
finalAnswerDelivered: fs.finalAnswerDelivered,
|
|
498
|
+
silenceMs: silence,
|
|
499
|
+
floorThresholdMs: thresholds.floor,
|
|
500
|
+
fallbackThresholdMs: thresholds.fallback,
|
|
501
|
+
legitimatelyWorking: isLegitimatelyWorking?.(key) ?? false,
|
|
502
|
+
alreadyFired: s.floorFired,
|
|
503
|
+
force: forced,
|
|
504
|
+
})
|
|
505
|
+
if (decision.kind !== 'fire') {
|
|
506
|
+
// Per-tick skips are noise; only surface a declined FORCED poke (the
|
|
507
|
+
// user asked "Status?" and we chose not to speak — worth seeing).
|
|
508
|
+
if (forced) {
|
|
509
|
+
activeDeps.emitMetric({ kind: 'mid_turn_floor', key, silence_ms: silence, forced, decision: decision.reason })
|
|
510
|
+
}
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
s.floorFired = true
|
|
514
|
+
activeDeps.emitMetric({ kind: 'mid_turn_floor', key, silence_ms: silence, forced, decision: 'fire' })
|
|
515
|
+
const { chatId, threadId } = parseKey(key)
|
|
516
|
+
try {
|
|
517
|
+
const r = onMidTurnFloor({ key, chatId, threadId, silenceMs: silence, inFlightTools: snapshotInFlight(s, now), forced })
|
|
518
|
+
if (r != null && typeof (r as Promise<void>).catch === 'function') {
|
|
519
|
+
;(r as Promise<void>).catch((err) => {
|
|
520
|
+
process.stderr.write(`silence-poke: mid-turn floor handler rejected: ${err}\n`)
|
|
521
|
+
})
|
|
522
|
+
}
|
|
523
|
+
} catch (err) {
|
|
524
|
+
process.stderr.write(`silence-poke: mid-turn floor handler threw: ${err}\n`)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* #2527 — fire the mid-turn floor immediately for `key` (a user "Status?"
|
|
530
|
+
* mid-turn inbound landed during a silent stretch). No-op if there is no
|
|
531
|
+
* live turn for the key or the floor is ineligible/already-fired.
|
|
532
|
+
*/
|
|
533
|
+
export function pokeFloorNow(key: string, now: number): void {
|
|
534
|
+
const s = state.get(key)
|
|
535
|
+
if (s == null) return
|
|
536
|
+
tryMidTurnFloor(key, s, now, true)
|
|
537
|
+
}
|
|
538
|
+
|
|
412
539
|
/**
|
|
413
540
|
* Internal tick — iterates active states and fires the 300s framework
|
|
414
541
|
* fallback (which the gateway turns into a user-visible message + an
|
|
@@ -423,20 +550,39 @@ function tick(now: number): void {
|
|
|
423
550
|
const silence = now - zeroAt
|
|
424
551
|
if (silence < 0) continue
|
|
425
552
|
|
|
553
|
+
// #2527 — the early, quiet mid-turn liveness beat (below the fallback
|
|
554
|
+
// window). Evaluated every tick; fires at most once per turn.
|
|
555
|
+
tryMidTurnFloor(key, s, now, false)
|
|
556
|
+
|
|
426
557
|
if (!s.fallbackFired && silence >= thresholds.fallback) {
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
558
|
+
// Feed-survival defer: hold back the unwedge while the agent is
|
|
559
|
+
// demonstrably working — an in-flight tool, a detached background process,
|
|
560
|
+
// or a human-wait tool (ask_user). Since #2162 the live activity feed
|
|
561
|
+
// renders that work, so nulling currentTurn would darken a feed the user
|
|
562
|
+
// is actively watching. Bounded by fallbackHardCeiling so a
|
|
563
|
+
// hung-or-leaked-signal turn still unwedges eventually.
|
|
564
|
+
//
|
|
565
|
+
// Two defer paths (tried in priority order):
|
|
566
|
+
// 1. `isLegitimatelyWorking(key)` — new single source of truth covering
|
|
567
|
+
// foreground in-flight tools, detached background work, and human-wait
|
|
568
|
+
// tools. Active by default when the callback is wired; force-disabled
|
|
569
|
+
// by SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS=0.
|
|
570
|
+
// 2. Legacy `deferFallbackWhileToolInFlight` boolean — covers only
|
|
571
|
+
// `inFlightTools.size > 0`; kept for test fixtures that set it
|
|
572
|
+
// directly without wiring the callback.
|
|
573
|
+
//
|
|
574
|
+
// In both cases: `continue` WITHOUT setting fallbackFired so the next
|
|
575
|
+
// tick re-checks. Once the work signal clears and the turn stays silent
|
|
576
|
+
// past the base threshold, or the ceiling is crossed, the fallback fires.
|
|
577
|
+
const ceiling = thresholds.fallbackHardCeiling ?? Number.POSITIVE_INFINITY
|
|
578
|
+
const underCeiling = silence < ceiling
|
|
579
|
+
if (underCeiling) {
|
|
580
|
+
const forceDisable = process.env.SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS === '0'
|
|
581
|
+
if (!forceDisable && activeDeps.isLegitimatelyWorking != null) {
|
|
582
|
+
if (activeDeps.isLegitimatelyWorking(key)) continue
|
|
583
|
+
} else if (!forceDisable && activeDeps.deferFallbackWhileToolInFlight === true && s.inFlightTools.size > 0) {
|
|
584
|
+
continue
|
|
585
|
+
}
|
|
440
586
|
}
|
|
441
587
|
s.fallbackFired = true
|
|
442
588
|
const { chatId, threadId } = parseKey(key)
|
|
@@ -110,6 +110,9 @@ export async function refreshBanner(
|
|
|
110
110
|
sent = await args.bot.api.sendMessage(args.ownerChatId, action.text, {
|
|
111
111
|
parse_mode: 'HTML',
|
|
112
112
|
link_preview_options: { is_disabled: true },
|
|
113
|
+
// OAuth slot banner is a status notice — silence the open ping.
|
|
114
|
+
// (the pin below is already silent; the edit path doesn't ping.)
|
|
115
|
+
disable_notification: true,
|
|
113
116
|
});
|
|
114
117
|
} catch (err) {
|
|
115
118
|
args.onError?.('pin', err);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status-card shared constants.
|
|
3
|
+
*
|
|
4
|
+
* Both status surfaces — the main-session agent activity card
|
|
5
|
+
* (`tool-activity-summary.ts`) and the background-worker activity feed
|
|
6
|
+
* (`worker-activity-feed.ts`) — render through the single
|
|
7
|
+
* `renderStatusCard` primitive in `tool-activity-summary.ts`. This module
|
|
8
|
+
* holds the tuning constants that primitive (and its internal helpers)
|
|
9
|
+
* read, so a forked renderer never re-derives them.
|
|
10
|
+
*
|
|
11
|
+
* The former `SWITCHROOM_STATUS_NO_TRUNCATE` feature flag was retired:
|
|
12
|
+
* rolling-window-with-char-budget is now the only behaviour. The per-line
|
|
13
|
+
* cap (`STATUS_LINE_MAX`) and rolling window (`STATUS_ROLLING_LINES`) apply
|
|
14
|
+
* universally on BOTH surfaces; the total char budget
|
|
15
|
+
* (`STATUS_CARD_CHAR_BUDGET`) is the wire-limit backstop.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Number of trailing narrative/step lines shown in the rolling window.
|
|
20
|
+
* The feed is a fixed-height rolling window: oldest drops off as new arrive.
|
|
21
|
+
* Overflow surfaces a `+N earlier…` header on BOTH surfaces.
|
|
22
|
+
*/
|
|
23
|
+
export const STATUS_ROLLING_LINES = 5
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Per-line character cap, applied to every step + child step on BOTH
|
|
27
|
+
* surfaces before HTML-escaping (clip raw → escape last). A line longer
|
|
28
|
+
* than this is truncated with a trailing `…`.
|
|
29
|
+
*/
|
|
30
|
+
export const STATUS_LINE_MAX = 200
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The safe char budget for a rendered Telegram status card. Telegram's hard
|
|
34
|
+
* cap is 4096; we use 4000 to leave 96 chars of headroom for HTML framing,
|
|
35
|
+
* emoji, and escape expansion — matching the convention in
|
|
36
|
+
* pending-work-progress.ts (TELEGRAM_MSG_CAP = 4000).
|
|
37
|
+
*
|
|
38
|
+
* With STATUS_ROLLING_LINES=5 lines each ≤ STATUS_LINE_MAX this backstop
|
|
39
|
+
* effectively never fires in practice, but is kept as a wire-limit safety net.
|
|
40
|
+
*/
|
|
41
|
+
export const STATUS_CARD_CHAR_BUDGET = 4000
|
|
42
|
+
|
|
43
|
+
/** Indent marker for a nested (foreground sub-agent) step line. */
|
|
44
|
+
export const NESTED_PREFIX = ' ↳ '
|
|
@@ -55,6 +55,7 @@ export type ReactionState =
|
|
|
55
55
|
| 'compacting'
|
|
56
56
|
| 'awaiting'
|
|
57
57
|
| 'done'
|
|
58
|
+
| 'undelivered'
|
|
58
59
|
| 'error'
|
|
59
60
|
| 'stallSoft'
|
|
60
61
|
| 'stallHard'
|
|
@@ -80,7 +81,11 @@ export const REACTION_VARIANTS: Record<ReactionState, string[]> = {
|
|
|
80
81
|
web: ['⚡', '🤔', '👌'], // WORKING: lookup in motion
|
|
81
82
|
compacting:['✍', '🤔', '👀'],
|
|
82
83
|
awaiting: ['🙏', '🤔', '👀'], // BLOCKED ON HUMAN: parked on a permission card
|
|
83
|
-
done: ['👍', '💯', '🎉'], // FINISHED: turn_end
|
|
84
|
+
done: ['👍', '💯', '🎉'], // FINISHED: turn_end delivered an answer
|
|
85
|
+
// #2527 — FINISHED but the user turn produced NO answer. A gentle,
|
|
86
|
+
// non-celebratory terminal so the ambient signal never reads as "done"
|
|
87
|
+
// over an undelivered turn. The silent-end fallback text carries the why.
|
|
88
|
+
undelivered:['😐', '🤷', '🤔'],
|
|
84
89
|
error: ['😱', '😨', '🤯'], // NON-TERMINAL — recovery allowed
|
|
85
90
|
stallSoft: ['🥱', '😴', '🤔'],
|
|
86
91
|
stallHard: ['😨', '🤯', '😱'],
|
|
@@ -103,7 +108,7 @@ export function resolveToolReactionState(toolName: string): ReactionState {
|
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
/** Reason passed to `finalize()` — selects the terminal emoji. */
|
|
106
|
-
export type FinalizeReason = 'done' | 'error'
|
|
111
|
+
export type FinalizeReason = 'done' | 'undelivered' | 'error'
|
|
107
112
|
|
|
108
113
|
/** Configuration knobs the controller respects. */
|
|
109
114
|
export interface StatusReactionConfig {
|
|
@@ -115,6 +120,14 @@ export interface StatusReactionConfig {
|
|
|
115
120
|
stallHardMs?: number
|
|
116
121
|
/** Optional logger for debugging — receives a single string per event. */
|
|
117
122
|
log?: (msg: string) => void
|
|
123
|
+
/**
|
|
124
|
+
* Optional structured callback fired on every emoji transition (after the
|
|
125
|
+
* API call succeeds). Used by the gateway to emit `status_reaction_transition`
|
|
126
|
+
* streaming-metrics events for #2527 observability. Kept out of the main
|
|
127
|
+
* `log` callback so callers that only want the human-readable log string
|
|
128
|
+
* don't need to parse it.
|
|
129
|
+
*/
|
|
130
|
+
onTransition?: (emoji: string) => void
|
|
118
131
|
}
|
|
119
132
|
|
|
120
133
|
/**
|
|
@@ -152,6 +165,7 @@ export class StatusReactionController {
|
|
|
152
165
|
private readonly stallSoftMs: number
|
|
153
166
|
private readonly stallHardMs: number
|
|
154
167
|
private readonly log?: (msg: string) => void
|
|
168
|
+
private readonly onTransition?: (emoji: string) => void
|
|
155
169
|
|
|
156
170
|
constructor(
|
|
157
171
|
private readonly emit: ReactionEmitter,
|
|
@@ -163,6 +177,7 @@ export class StatusReactionController {
|
|
|
163
177
|
this.stallSoftMs = config.stallSoftMs ?? 30000
|
|
164
178
|
this.stallHardMs = config.stallHardMs ?? 90000
|
|
165
179
|
this.log = config.log
|
|
180
|
+
this.onTransition = config.onTransition
|
|
166
181
|
}
|
|
167
182
|
|
|
168
183
|
/** 👀 — message received and queued for processing. Bypasses debounce. */
|
|
@@ -222,7 +237,8 @@ export class StatusReactionController {
|
|
|
222
237
|
* lands promptly. Subsequent calls are no-ops.
|
|
223
238
|
*/
|
|
224
239
|
finalize(reason: FinalizeReason = 'done'): void {
|
|
225
|
-
const state: ReactionState =
|
|
240
|
+
const state: ReactionState =
|
|
241
|
+
reason === 'error' ? 'error' : reason === 'undelivered' ? 'undelivered' : 'done'
|
|
226
242
|
this.finishWithState(state)
|
|
227
243
|
}
|
|
228
244
|
|
|
@@ -348,6 +364,7 @@ export class StatusReactionController {
|
|
|
348
364
|
this.currentEmoji = emoji
|
|
349
365
|
if (this.pendingEmoji === emoji) this.pendingEmoji = null
|
|
350
366
|
this.log?.(`reaction → ${emoji}`)
|
|
367
|
+
this.onTransition?.(emoji)
|
|
351
368
|
} catch (err) {
|
|
352
369
|
this.log?.(`reaction emit failed (${emoji}): ${(err as Error).message}`)
|
|
353
370
|
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* entire server.ts top-level initialization.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { createDraftStream, type DraftStreamHandle
|
|
19
|
+
import { createDraftStream, type DraftStreamHandle } from './draft-stream.js'
|
|
20
20
|
import { htmlToPlainText } from './html-sanitize.js'
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -152,30 +152,15 @@ export interface StreamControllerConfig {
|
|
|
152
152
|
*/
|
|
153
153
|
log?: (msg: string) => void
|
|
154
154
|
/**
|
|
155
|
-
* Optional warning logger. Used for
|
|
155
|
+
* Optional warning logger. Used for fallback notices.
|
|
156
156
|
*/
|
|
157
157
|
warn?: (msg: string) => void
|
|
158
|
-
/**
|
|
159
|
-
* Transport selector passed to createDraftStream.
|
|
160
|
-
* - "auto" (default): use draft transport for DMs only
|
|
161
|
-
* - "draft": always prefer draft (if sendMessageDraft is available)
|
|
162
|
-
* - "message": always use sendMessage/editMessageText
|
|
163
|
-
*
|
|
164
|
-
* The gateway forces "message" for forum topics (threads), since
|
|
165
|
-
* sendMessageDraft does not support threaded chats.
|
|
166
|
-
*/
|
|
167
|
-
previewTransport?: 'auto' | 'message' | 'draft'
|
|
168
158
|
/**
|
|
169
159
|
* True when the chat is a private DM. Passed to createDraftStream so
|
|
170
|
-
*
|
|
160
|
+
* the throttle default (400 ms for DMs vs 1000 ms for groups) is applied
|
|
161
|
+
* correctly when no explicit throttleMs is set.
|
|
171
162
|
*/
|
|
172
163
|
isPrivateChat?: boolean
|
|
173
|
-
/**
|
|
174
|
-
* sendMessageDraft callback. When provided (and transport allows it),
|
|
175
|
-
* intermediate stream updates use the draft API. On finalize(), a real
|
|
176
|
-
* sendMessage is posted for push notification and the draft is cleared.
|
|
177
|
-
*/
|
|
178
|
-
sendMessageDraft?: StreamDraftFn
|
|
179
164
|
/**
|
|
180
165
|
* If set, the controller is initialized as if a previous send had
|
|
181
166
|
* landed with this `message_id`. The first `update()` invokes
|
|
@@ -214,9 +199,7 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
|
|
|
214
199
|
quoteText,
|
|
215
200
|
protectContent,
|
|
216
201
|
replyMarkup,
|
|
217
|
-
previewTransport,
|
|
218
202
|
isPrivateChat,
|
|
219
|
-
sendMessageDraft,
|
|
220
203
|
initialMessageId,
|
|
221
204
|
} = cfg
|
|
222
205
|
|
|
@@ -314,9 +297,7 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
|
|
|
314
297
|
...(idleMs != null ? { idleMs } : {}),
|
|
315
298
|
...(log != null ? { log } : {}),
|
|
316
299
|
...(warn != null ? { warn } : {}),
|
|
317
|
-
...(previewTransport != null ? { previewTransport } : {}),
|
|
318
300
|
...(isPrivateChat != null ? { isPrivateChat } : {}),
|
|
319
|
-
...(sendMessageDraft != null ? { sendMessageDraft } : {}),
|
|
320
301
|
...(initialMessageId != null ? { initialMessageId } : {}),
|
|
321
302
|
chatId,
|
|
322
303
|
},
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* wraps into an MCP content response.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import type { DraftStreamHandle
|
|
19
|
+
import type { DraftStreamHandle } from './draft-stream.js'
|
|
20
20
|
import {
|
|
21
21
|
createStreamController,
|
|
22
22
|
type StreamBotApi,
|
|
@@ -240,13 +240,6 @@ export interface StreamReplyDeps {
|
|
|
240
240
|
/** Error-path stderr. */
|
|
241
241
|
writeError: (line: string) => void
|
|
242
242
|
throttleMs?: number
|
|
243
|
-
/**
|
|
244
|
-
* sendMessageDraft callback. When provided, stream_reply uses the draft
|
|
245
|
-
* API for intermediate updates (DM transport). On done=true, a real
|
|
246
|
-
* sendMessage fires for push notification, then the draft is cleared.
|
|
247
|
-
* Optional — omit to keep the existing sendMessage/editMessageText path.
|
|
248
|
-
*/
|
|
249
|
-
sendMessageDraft?: StreamDraftFn
|
|
250
243
|
/**
|
|
251
244
|
* Idempotency hook for the duplicate-message class (issue #626).
|
|
252
245
|
*
|
|
@@ -275,12 +268,12 @@ export interface StreamReplyDeps {
|
|
|
275
268
|
}) => number | null | undefined
|
|
276
269
|
/**
|
|
277
270
|
* True when the current chat is a private DM. Passed to the stream
|
|
278
|
-
* controller so
|
|
271
|
+
* controller so the DM throttle default (400 ms) is applied instead of
|
|
272
|
+
* the group default (1000 ms) when no explicit throttleMs is set.
|
|
279
273
|
*/
|
|
280
274
|
isPrivateChat?: boolean
|
|
281
275
|
/**
|
|
282
|
-
* True when the current chat is a forum topic.
|
|
283
|
-
* support sendMessageDraft — this forces message transport.
|
|
276
|
+
* True when the current chat is a forum topic.
|
|
284
277
|
*/
|
|
285
278
|
isForumTopic?: boolean
|
|
286
279
|
/**
|
|
@@ -464,14 +457,6 @@ export async function handleStreamReply(
|
|
|
464
457
|
}
|
|
465
458
|
}
|
|
466
459
|
|
|
467
|
-
// Resolve draft-transport options. Forum topics force message transport
|
|
468
|
-
// because sendMessageDraft does not support threads.
|
|
469
|
-
const isForumTopic = deps.isForumTopic === true
|
|
470
|
-
const resolvedTransport: 'auto' | 'message' | 'draft' =
|
|
471
|
-
isForumTopic || deps.sendMessageDraft == null
|
|
472
|
-
? 'message'
|
|
473
|
-
: 'auto'
|
|
474
|
-
|
|
475
460
|
// Idempotency hook (#626): if an external authority (e.g. the
|
|
476
461
|
// gateway's pin manager) already knows the anchor message id for
|
|
477
462
|
// this lane+turn, initialize the stream with it so the next update
|
|
@@ -504,8 +489,8 @@ export async function handleStreamReply(
|
|
|
504
489
|
threadId,
|
|
505
490
|
parseMode,
|
|
506
491
|
disableLinkPreview: deps.disableLinkPreview,
|
|
507
|
-
//
|
|
508
|
-
//
|
|
492
|
+
// Pass undefined when caller didn't override, so draft-stream's
|
|
493
|
+
// DM/group throttle defaults apply (400 ms DMs, 1000 ms groups).
|
|
509
494
|
...(deps.throttleMs != null ? { throttleMs: deps.throttleMs } : {}),
|
|
510
495
|
retry: deps.retry,
|
|
511
496
|
...(replyToMessageId != null ? { replyToMessageId } : {}),
|
|
@@ -513,9 +498,7 @@ export async function handleStreamReply(
|
|
|
513
498
|
...(args.protect_content === true ? { protectContent: true } : {}),
|
|
514
499
|
...(args.disable_notification === true ? { disableNotification: true } : {}),
|
|
515
500
|
...(args.reply_markup != null ? { replyMarkup: args.reply_markup } : {}),
|
|
516
|
-
previewTransport: resolvedTransport,
|
|
517
501
|
isPrivateChat: deps.isPrivateChat === true,
|
|
518
|
-
...(deps.sendMessageDraft != null ? { sendMessageDraft: deps.sendMessageDraft } : {}),
|
|
519
502
|
...(initialMessageId != null ? { initialMessageId } : {}),
|
|
520
503
|
onSend: (messageId, charCount) =>
|
|
521
504
|
deps.logStreamingEvent({ kind: 'draft_send', chatId: chat_id, messageId, charCount }),
|
|
@@ -539,7 +522,6 @@ export async function handleStreamReply(
|
|
|
539
522
|
|| msg.startsWith('stream → edited')
|
|
540
523
|
|| msg.startsWith('stream → not modified')
|
|
541
524
|
|| msg.startsWith('stream finalized')
|
|
542
|
-
|| msg.startsWith('stream → draft')
|
|
543
525
|
|| msg.startsWith('stream → materialized')
|
|
544
526
|
) return
|
|
545
527
|
deps.writeError(`telegram channel: stream_reply ${msg}\n`)
|
|
@@ -93,6 +93,97 @@ export type StreamingEvent =
|
|
|
93
93
|
chatId: string
|
|
94
94
|
messageId: number | undefined
|
|
95
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Emitted when maybeEarlyAckReaction fires the 👀 pre-coalesce reaction
|
|
98
|
+
* for a private-chat inbound. Lets operators see how often the fast-ack
|
|
99
|
+
* path triggers vs. the regular StatusReactionController path (#553 F2).
|
|
100
|
+
*/
|
|
101
|
+
| {
|
|
102
|
+
kind: 'early_ack_reaction'
|
|
103
|
+
chatId: string
|
|
104
|
+
messageId: number
|
|
105
|
+
emoji: string
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Emitted when a fresh StatusReactionController is installed for a new turn
|
|
109
|
+
* (group / non-DM path where the controller manages the whole reaction lifecycle).
|
|
110
|
+
*/
|
|
111
|
+
| {
|
|
112
|
+
kind: 'status_reaction_install'
|
|
113
|
+
chatId: string
|
|
114
|
+
turnId: string
|
|
115
|
+
messageId: number
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Emitted on every emoji transition inside a StatusReactionController.
|
|
119
|
+
* Lets operators trace the full queued→thinking→tool→done lifecycle and
|
|
120
|
+
* see how many state changes occur in a silent turn.
|
|
121
|
+
*/
|
|
122
|
+
| {
|
|
123
|
+
kind: 'status_reaction_transition'
|
|
124
|
+
chatId: string
|
|
125
|
+
turnId: string
|
|
126
|
+
emoji: string
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Emitted when StatusReactionController.finalize() / setDone() runs
|
|
130
|
+
* (controller disposed at turn_end or disconnect-flush). Terminal event.
|
|
131
|
+
*/
|
|
132
|
+
| {
|
|
133
|
+
kind: 'status_reaction_dispose'
|
|
134
|
+
chatId: string
|
|
135
|
+
turnId: string
|
|
136
|
+
reason: 'done' | 'error' | 'disconnect' | 'undelivered'
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Emitted when the FIRST text reply (reply or stream_reply) of a turn is
|
|
140
|
+
* sent to the user. `timeToFirstTextReplyMs` is the wall-clock delta from
|
|
141
|
+
* the inbound-received timestamp to the moment this reply tool fires.
|
|
142
|
+
* Issue #2527 instrumentation: reveals when a turn is reaction-only.
|
|
143
|
+
*/
|
|
144
|
+
| {
|
|
145
|
+
kind: 'turn_reply_timing'
|
|
146
|
+
chatId: string
|
|
147
|
+
threadId: number | undefined
|
|
148
|
+
turnId: string
|
|
149
|
+
timeToFirstTextReplyMs: number
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Emitted at turn_end when the turn produced ZERO text replies (only
|
|
153
|
+
* reaction-emoji transitions). This is the primary observable for the
|
|
154
|
+
* #2527 failure mode — the user sees only an emoji and the turn is done.
|
|
155
|
+
*/
|
|
156
|
+
| {
|
|
157
|
+
kind: 'turn_no_reply_warn'
|
|
158
|
+
chatId: string
|
|
159
|
+
threadId: number | undefined
|
|
160
|
+
turnId: string
|
|
161
|
+
turnDurationMs: number
|
|
162
|
+
reactionCount: number
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Emitted when the silence-poke framework-fallback fires and sends its
|
|
166
|
+
* "still working…" ping. Records the silence duration so operators can
|
|
167
|
+
* correlate with reaction-only turns.
|
|
168
|
+
*/
|
|
169
|
+
| {
|
|
170
|
+
kind: 'silence_poke_fire'
|
|
171
|
+
chatId: string
|
|
172
|
+
threadId: number | undefined
|
|
173
|
+
silenceMs: number
|
|
174
|
+
fallbackKind: string
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Emitted when the silence-poke handler short-circuits because the turn
|
|
178
|
+
* already ended cleanly during the silence window (the late-fire race).
|
|
179
|
+
*/
|
|
180
|
+
| {
|
|
181
|
+
kind: 'silence_poke_skip'
|
|
182
|
+
chatId: string
|
|
183
|
+
threadId: number | undefined
|
|
184
|
+
silenceMs: number
|
|
185
|
+
skipReason: string
|
|
186
|
+
}
|
|
96
187
|
|
|
97
188
|
/**
|
|
98
189
|
* True iff the env gate is on. Re-read on every call so tests can toggle
|