switchroom 0.18.7 → 0.18.8
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/cli/switchroom.js +905 -758
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +111 -34
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/gateway/gateway.js +1403 -657
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/gateway.ts +564 -85
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +23 -11
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -0
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- package/telegram-plugin/voice-send.ts +154 -0
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
resolveInterruptMaxWaitMs,
|
|
42
42
|
resolveSafeBoundaryEnabled,
|
|
43
43
|
} from './interrupt-defer.js'
|
|
44
|
+
import { shouldPostBusyAck, formatBusyAckText, BUSY_ACK_STEP_AGE_THRESHOLD_MS } from './busy-ack.js'
|
|
44
45
|
import {
|
|
45
46
|
resolveStickerSendArgs,
|
|
46
47
|
resolveGifSendArgs,
|
|
@@ -62,6 +63,7 @@ import {
|
|
|
62
63
|
buildListenKeyboard,
|
|
63
64
|
mayInjectListenButton,
|
|
64
65
|
} from '../voice-ondemand.js'
|
|
66
|
+
import { sendVoiceReusingFileId } from '../voice-send.js'
|
|
65
67
|
import {
|
|
66
68
|
PreSynthQueue,
|
|
67
69
|
sweepVoiceCacheDir,
|
|
@@ -175,6 +177,7 @@ import {
|
|
|
175
177
|
isMessageTooLongError,
|
|
176
178
|
} from '../retry-api-call.js'
|
|
177
179
|
import { installTgPostLogger, withTgPostTags } from '../shared/bot-runtime.js'
|
|
180
|
+
import { floodStatePath, makeFloodWaitRecorder } from '../flood-circuit-breaker.js'
|
|
178
181
|
import { buildAttachmentPath, assertInsideInbox } from '../attachment-path.js'
|
|
179
182
|
import { logStreamingEvent } from '../streaming-metrics.js'
|
|
180
183
|
import * as signalTracker from '../turn-signal-tracker.js'
|
|
@@ -375,10 +378,22 @@ import {
|
|
|
375
378
|
MODEL_CALLBACK_PAGE_EXTERNAL,
|
|
376
379
|
MODEL_CALLBACK_PAGE_MAIN,
|
|
377
380
|
srFriendlyLabel,
|
|
381
|
+
expandSrAlias,
|
|
382
|
+
isSrModel,
|
|
378
383
|
type ModelMenuDeps,
|
|
379
384
|
type ModelCommandDeps,
|
|
380
385
|
type ModelMenuReply,
|
|
381
386
|
} from './model-command.js'
|
|
387
|
+
import {
|
|
388
|
+
writeSessionModelFile,
|
|
389
|
+
readSessionModelFileRaw,
|
|
390
|
+
restoreSessionModelFileRaw,
|
|
391
|
+
clearSessionModelFile,
|
|
392
|
+
readConfiguredDefaultModel,
|
|
393
|
+
writeRelaunchModelIntent,
|
|
394
|
+
clearRelaunchModelIntent,
|
|
395
|
+
intentForRestartReason,
|
|
396
|
+
} from './session-model-file.js'
|
|
382
397
|
import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
|
|
383
398
|
import { resolveMainModel } from '../../src/agents/scaffold.js'
|
|
384
399
|
import {
|
|
@@ -447,6 +462,10 @@ import {
|
|
|
447
462
|
restartOrphanCardFinalizeText,
|
|
448
463
|
type ActivityCardStoreFsSeam,
|
|
449
464
|
} from './activity-card-store.js'
|
|
465
|
+
import {
|
|
466
|
+
decideWorkerPinReaps,
|
|
467
|
+
WORKER_PIN_TTL_MS_DEFAULT,
|
|
468
|
+
} from './worker-pin-reaper.js'
|
|
450
469
|
import { driveEscalation } from './escalation-drive.js'
|
|
451
470
|
import { shouldSuppressRepresent } from './represent-guard.js'
|
|
452
471
|
import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
|
|
@@ -908,6 +927,16 @@ function triggerSelfRestart(
|
|
|
908
927
|
)
|
|
909
928
|
return false
|
|
910
929
|
}
|
|
930
|
+
// Session-model stickiness (reference/rfcs/session-model-stickiness.md):
|
|
931
|
+
// boot default is REVERT, so every switchroom-managed bounce must stamp
|
|
932
|
+
// its intent BEFORE the SIGTERM is even scheduled (write-before-kill
|
|
933
|
+
// invariant — pinned by gateway-session-model-relaunch.test.ts). The
|
|
934
|
+
// per-reason table classifies recovery/model-switch bounces as "keep"
|
|
935
|
+
// and the deliberate inline restart button as "revert".
|
|
936
|
+
{
|
|
937
|
+
const smDir = resolveAgentDirFromEnv()
|
|
938
|
+
if (smDir) writeRelaunchModelIntent(smDir, intentForRestartReason(reason), reason)
|
|
939
|
+
}
|
|
911
940
|
process.stderr.write(
|
|
912
941
|
`telegram gateway: restart-via-SIGTERM-PID1 agent=${targetAgent} reason=${reason} (docker)\n`,
|
|
913
942
|
)
|
|
@@ -919,6 +948,10 @@ function triggerSelfRestart(
|
|
|
919
948
|
return true
|
|
920
949
|
}
|
|
921
950
|
// Legacy systemd path.
|
|
951
|
+
if (targetAgent === selfAgent) {
|
|
952
|
+
const smDir = resolveAgentDirFromEnv()
|
|
953
|
+
if (smDir) writeRelaunchModelIntent(smDir, intentForRestartReason(reason), reason)
|
|
954
|
+
}
|
|
922
955
|
process.stderr.write(
|
|
923
956
|
`telegram gateway: restart-via-systemctl agent=${targetAgent} reason=${reason}\n`,
|
|
924
957
|
)
|
|
@@ -1874,7 +1907,22 @@ const activeReactionMsgIds = new Map<string, { chatId: string; messageId: number
|
|
|
1874
1907
|
// by chatKey(chat_id, bufferedThread). Delete-on-answer: never a dangling
|
|
1875
1908
|
// placeholder. Reaped on answer (executeReply/stream), on turn-flush, and
|
|
1876
1909
|
// in purgeReactionTracking cleanup so an abnormal turn-end can't strand it.
|
|
1877
|
-
|
|
1910
|
+
// #2995: threadId is null for DM cards — the mid-flight busy-ack extends
|
|
1911
|
+
// this lifecycle to DMs and same-topic surfaces (the original component-5
|
|
1912
|
+
// card was cross-topic only).
|
|
1913
|
+
const queuedStatusMsgIds = new Map<string, { chatId: string; threadId: number | null; messageId: number }>()
|
|
1914
|
+
// #2995 mid-flight busy-ack dedupe: statusKeys that already got a busy-ack
|
|
1915
|
+
// card during the CURRENT turn — at most one card per turn per chat/topic
|
|
1916
|
+
// even after the card itself is reaped. The ending turn's key is deleted in
|
|
1917
|
+
// purgeReactionTracking (per-key, not a global clear — a purge for topic A
|
|
1918
|
+
// must not reset topic B's dedupe).
|
|
1919
|
+
const busyAckPostedKeys = new Set<string>()
|
|
1920
|
+
// #2995 deferred re-check: a ping that arrives while the blocking step is
|
|
1921
|
+
// still YOUNG (< threshold) must not be silently forgotten — the step may
|
|
1922
|
+
// run for minutes more (the original #2995 silence). One timer per key,
|
|
1923
|
+
// armed for (threshold − stepAge); it re-evaluates live state when it
|
|
1924
|
+
// fires and is cancelled by purgeReactionTracking when the turn ends.
|
|
1925
|
+
const busyAckRecheckTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
1878
1926
|
// Reactions whose terminal 👍 is deferred because a background sub-agent
|
|
1879
1927
|
// worker was still running when the parent's `turn_end` fired. Painting 👍
|
|
1880
1928
|
// then would read as "done / nothing happening" while the worker keeps
|
|
@@ -2310,6 +2358,16 @@ const TOPIC_FRAMING_ENABLED =
|
|
|
2310
2358
|
// → no placeholder (the 👀 ack reaction still fires). Delete-on-answer.
|
|
2311
2359
|
const QUEUED_STATUS_UX_ENABLED =
|
|
2312
2360
|
process.env.SWITCHROOM_QUEUED_STATUS_UX !== '0'
|
|
2361
|
+
// #2995 mid-flight busy ack. When a mid-turn inbound is buffered (or lands
|
|
2362
|
+
// as a steer) while the running turn sits inside ONE long tool call (step
|
|
2363
|
+
// age past the threshold in busy-ack.ts), post a silent deterministic
|
|
2364
|
+
// "⏳ Queued — currently inside `<tool>` …" card into the inbound's own
|
|
2365
|
+
// chat/topic — model-free, zero tokens. Reuses the queuedStatusMsgIds
|
|
2366
|
+
// edit/delete lifecycle (delete-on-answer, reap on abnormal turn-end),
|
|
2367
|
+
// extending it to DMs and same-topic surfaces. At most one card per turn
|
|
2368
|
+
// per chat/topic. Kill switch off (=0) → legacy silence (👀 only).
|
|
2369
|
+
const MIDFLIGHT_BUSY_ACK_ENABLED =
|
|
2370
|
+
process.env.SWITCHROOM_MIDFLIGHT_BUSY_ACK !== '0'
|
|
2313
2371
|
// Feed-reopen-after-ack. When a tool label arrives for a turn already
|
|
2314
2372
|
// marked finalAnswerDelivered, the model is still WORKING — so the earlier
|
|
2315
2373
|
// "final" reply was an interim ACK (an ack-first reply pings or runs ≥200
|
|
@@ -3472,7 +3530,9 @@ function postQueuedStatus(chatId: string, bufferedThread: number, inFlightThread
|
|
|
3472
3530
|
*/
|
|
3473
3531
|
function promoteQueuedStatus(chatId: string, thread: number | undefined): void {
|
|
3474
3532
|
if (!QUEUED_STATUS_UX_ENABLED) return
|
|
3475
|
-
|
|
3533
|
+
// #2995: thread == null is a DM (or General-topic) card — the busy-ack
|
|
3534
|
+
// extended this lifecycle to DMs, so promote those too (statusKey keys
|
|
3535
|
+
// a null thread identically at post and promote time).
|
|
3476
3536
|
const key = statusKey(chatId, thread)
|
|
3477
3537
|
const entry = queuedStatusMsgIds.get(key)
|
|
3478
3538
|
if (entry == null) return
|
|
@@ -3483,7 +3543,7 @@ function promoteQueuedStatus(chatId: string, thread: number | undefined): void {
|
|
|
3483
3543
|
void swallowingApiCall(
|
|
3484
3544
|
() =>
|
|
3485
3545
|
bot.api.editMessageText(chatId, entry.messageId, '✍️ On it — replying now.', {}),
|
|
3486
|
-
{ chat_id: chatId, verb: 'queued-status.promote', threadId: thread },
|
|
3546
|
+
{ chat_id: chatId, verb: 'queued-status.promote', ...(thread != null ? { threadId: thread } : {}) },
|
|
3487
3547
|
)
|
|
3488
3548
|
}
|
|
3489
3549
|
|
|
@@ -3503,6 +3563,115 @@ function reapQueuedStatus(chatId: string, thread: number | undefined): void {
|
|
|
3503
3563
|
)
|
|
3504
3564
|
}
|
|
3505
3565
|
|
|
3566
|
+
/**
|
|
3567
|
+
* #2995 — mid-flight busy ack (decision + dispatch). Called when a mid-turn
|
|
3568
|
+
* inbound was buffered (`buffer-until-idle`) or delivered as a steer. If the
|
|
3569
|
+
* running turn is sitting inside one LONG tool step (see busy-ack.ts for the
|
|
3570
|
+
* threshold rationale), post a silent deterministic card into the inbound's
|
|
3571
|
+
* own chat/topic naming the blocking activity — so a quick question asked
|
|
3572
|
+
* behind a `--watch`-style blocking call doesn't read as ignored for
|
|
3573
|
+
* minutes. Model-free, zero tokens. The card is stored in
|
|
3574
|
+
* `queuedStatusMsgIds`, so it inherits the component-5 lifecycle: promoted
|
|
3575
|
+
* ("On it") when the buffered turn starts, deleted when the answer lands or
|
|
3576
|
+
* the turn ends. Dedupe is per turn per chat/topic via `busyAckPostedKeys`.
|
|
3577
|
+
*/
|
|
3578
|
+
function maybePostBusyAck(
|
|
3579
|
+
gateDecision: 'buffer-until-idle' | 'steer',
|
|
3580
|
+
chatId: string,
|
|
3581
|
+
threadId: number | undefined,
|
|
3582
|
+
): void {
|
|
3583
|
+
if (!MIDFLIGHT_BUSY_ACK_ENABLED) return
|
|
3584
|
+
const key = statusKey(chatId, threadId)
|
|
3585
|
+
const inFlight = currentTurn
|
|
3586
|
+
const inFlightKey =
|
|
3587
|
+
inFlight != null ? statusKey(inFlight.sessionChatId, inFlight.sessionThreadId) : key
|
|
3588
|
+
const now = Date.now()
|
|
3589
|
+
const step = silencePoke.longestInFlightTool(inFlightKey, now)
|
|
3590
|
+
const midToolCall = toolFlightTracker.isMidToolCall()
|
|
3591
|
+
const stepAgeMs = step?.durationMs ?? null
|
|
3592
|
+
const alreadyAcked = queuedStatusMsgIds.has(key) || busyAckPostedKeys.has(key)
|
|
3593
|
+
const fire = shouldPostBusyAck({ gateDecision, midToolCall, stepAgeMs, alreadyAcked })
|
|
3594
|
+
if (!fire) {
|
|
3595
|
+
// Deferred re-check: the ONLY miss reason we re-arm for is a step that
|
|
3596
|
+
// is genuinely open but still young — it may run for minutes more, and
|
|
3597
|
+
// a one-shot evaluation would re-create the original #2995 silence. Arm
|
|
3598
|
+
// one timer for (threshold − stepAge); everything is re-read live at
|
|
3599
|
+
// fire time, and the timer is pinned to THIS turn (turnId guard) plus
|
|
3600
|
+
// cancelled outright by purgeReactionTracking when the turn ends.
|
|
3601
|
+
if (
|
|
3602
|
+
midToolCall &&
|
|
3603
|
+
!alreadyAcked &&
|
|
3604
|
+
stepAgeMs != null &&
|
|
3605
|
+
stepAgeMs < BUSY_ACK_STEP_AGE_THRESHOLD_MS &&
|
|
3606
|
+
!busyAckRecheckTimers.has(key)
|
|
3607
|
+
) {
|
|
3608
|
+
const turnIdAtSchedule = inFlight?.turnId ?? null
|
|
3609
|
+
const delayMs = BUSY_ACK_STEP_AGE_THRESHOLD_MS - stepAgeMs + 250
|
|
3610
|
+
busyAckRecheckTimers.set(key, setTimeout(() => {
|
|
3611
|
+
busyAckRecheckTimers.delete(key)
|
|
3612
|
+
// The ping is only still waiting if the SAME turn is still running
|
|
3613
|
+
// (a new/ended turn means the buffered inbound is being handled —
|
|
3614
|
+
// a "Queued" card then would be a lie).
|
|
3615
|
+
if (turnIdAtSchedule == null || currentTurn?.turnId !== turnIdAtSchedule) return
|
|
3616
|
+
maybePostBusyAck(gateDecision, chatId, threadId)
|
|
3617
|
+
}, delayMs))
|
|
3618
|
+
}
|
|
3619
|
+
return
|
|
3620
|
+
}
|
|
3621
|
+
const pendingRecheck = busyAckRecheckTimers.get(key)
|
|
3622
|
+
if (pendingRecheck != null) {
|
|
3623
|
+
clearTimeout(pendingRecheck)
|
|
3624
|
+
busyAckRecheckTimers.delete(key)
|
|
3625
|
+
}
|
|
3626
|
+
busyAckPostedKeys.add(key)
|
|
3627
|
+
const text = formatBusyAckText({
|
|
3628
|
+
gateDecision,
|
|
3629
|
+
toolName: step?.name ?? null,
|
|
3630
|
+
toolLabel: step?.label ?? null,
|
|
3631
|
+
})
|
|
3632
|
+
process.stderr.write(
|
|
3633
|
+
`telegram gateway: mid-flight busy ack chat=${chatId} thread=${threadId ?? '-'} ` +
|
|
3634
|
+
`decision=${gateDecision} step=${step?.name ?? '-'} step_age_ms=${step?.durationMs ?? '-'}\n`,
|
|
3635
|
+
)
|
|
3636
|
+
postBusyAck(chatId, threadId, text)
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3639
|
+
/**
|
|
3640
|
+
* #2995 — send the busy-ack card. Mirrors `postQueuedStatus` (idempotent
|
|
3641
|
+
* key, silent send, post-race orphan cleanup) but is thread-OPTIONAL so it
|
|
3642
|
+
* covers DMs and same-topic queues — the surfaces the cross-topic queued
|
|
3643
|
+
* status deliberately suppressed. Shares `queuedStatusMsgIds`, so
|
|
3644
|
+
* promote/reap clean it up exactly like the queued-status placeholder.
|
|
3645
|
+
*/
|
|
3646
|
+
function postBusyAck(chatId: string, threadId: number | undefined, text: string): void {
|
|
3647
|
+
const key = statusKey(chatId, threadId)
|
|
3648
|
+
if (queuedStatusMsgIds.has(key)) return
|
|
3649
|
+
void (async () => {
|
|
3650
|
+
const sent = await swallowingApiCall(
|
|
3651
|
+
() =>
|
|
3652
|
+
// Deterministic busy-ack placeholder, not the user's answer —
|
|
3653
|
+
// silent by contract (no device ping for a status card).
|
|
3654
|
+
bot.api.sendMessage(chatId, text, {
|
|
3655
|
+
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
3656
|
+
disable_notification: true,
|
|
3657
|
+
}),
|
|
3658
|
+
{ chat_id: chatId, verb: 'busy-ack.post', ...(threadId != null ? { threadId } : {}) },
|
|
3659
|
+
)
|
|
3660
|
+
const messageId = (sent as { message_id?: number } | undefined)?.message_id
|
|
3661
|
+
if (typeof messageId !== 'number') return
|
|
3662
|
+
// Re-check after the await (same race pattern as postQueuedStatus):
|
|
3663
|
+
// if a placeholder landed for this key in the gap, delete this orphan.
|
|
3664
|
+
if (queuedStatusMsgIds.has(key)) {
|
|
3665
|
+
void swallowingApiCall(
|
|
3666
|
+
() => bot.api.deleteMessage(chatId, messageId),
|
|
3667
|
+
{ chat_id: chatId, verb: 'busy-ack.post-race-cleanup', ...(threadId != null ? { threadId } : {}) },
|
|
3668
|
+
)
|
|
3669
|
+
return
|
|
3670
|
+
}
|
|
3671
|
+
queuedStatusMsgIds.set(key, { chatId, threadId: threadId ?? null, messageId })
|
|
3672
|
+
})()
|
|
3673
|
+
}
|
|
3674
|
+
|
|
3506
3675
|
// Problem B — deferred safe-boundary interrupt.
|
|
3507
3676
|
//
|
|
3508
3677
|
// `toolFlightTracker` mirrors the session-event stream to know whether a
|
|
@@ -3836,6 +4005,17 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
|
|
|
3836
4005
|
const pqThread = pqThreadPart === '_' || pqThreadPart === '' ? null : Number(pqThreadPart)
|
|
3837
4006
|
reapQueuedStatus(pqChatId, Number.isFinite(pqThread) ? (pqThread as number) : undefined)
|
|
3838
4007
|
}
|
|
4008
|
+
// #2995 — reset the ending turn's OWN busy-ack dedupe + cancel its pending
|
|
4009
|
+
// deferred re-check. Per-key, not a global clear: a purge for topic A must
|
|
4010
|
+
// not reset topic B's dedupe or kill B's armed re-check. A buffered topic's
|
|
4011
|
+
// entry clears when ITS turn eventually runs and ends (this same path), and
|
|
4012
|
+
// the re-check timer self-guards on turnId anyway (defense-in-depth).
|
|
4013
|
+
busyAckPostedKeys.delete(key)
|
|
4014
|
+
const busyAckRecheck = busyAckRecheckTimers.get(key)
|
|
4015
|
+
if (busyAckRecheck != null) {
|
|
4016
|
+
clearTimeout(busyAckRecheck)
|
|
4017
|
+
busyAckRecheckTimers.delete(key)
|
|
4018
|
+
}
|
|
3839
4019
|
// PR3b: clear the parallel-turns fleet-gate entry. Symmetric with
|
|
3840
4020
|
// the markClaudeBusyForInbound on the delivery path. Safe no-op
|
|
3841
4021
|
// when the key was never marked (synthetic purge from a sweep).
|
|
@@ -4887,8 +5067,16 @@ const typingWrapper = createTypingWrapper({
|
|
|
4887
5067
|
// ─── Robust API call wrapper ──────────────────────────────────────────────
|
|
4888
5068
|
// Extracted to telegram-plugin/retry-api-call.ts so it's unit-testable in
|
|
4889
5069
|
// isolation; the gateway just composes the pure policy with its own logger.
|
|
5070
|
+
// #2923: the shared flood-wait marker. Every observed 429 retry_after window
|
|
5071
|
+
// is persisted here via onFloodWait, and both boot-card callsites consult the
|
|
5072
|
+
// SAME file to suppress a restart card while a per-bot flood ban is open (so a
|
|
5073
|
+
// restart doesn't post into the window and extend the ban). Falls back to a
|
|
5074
|
+
// no-op recorder when TELEGRAM_STATE_DIR is unset (dev/one-shot contexts).
|
|
5075
|
+
// STATE_DIR always resolves (env or a ~/.claude fallback), so this is live.
|
|
5076
|
+
const FLOOD_STATE_PATH = floodStatePath(STATE_DIR)
|
|
4890
5077
|
const robustApiCall = createRetryApiCall({
|
|
4891
5078
|
log: (line) => process.stderr.write(line),
|
|
5079
|
+
onFloodWait: makeFloodWaitRecorder(FLOOD_STATE_PATH),
|
|
4892
5080
|
})
|
|
4893
5081
|
|
|
4894
5082
|
// Fire-and-forget wrapper for outbound surfaces that previously had
|
|
@@ -6804,6 +6992,10 @@ const statusPinState = new Map<string, PinState>()
|
|
|
6804
6992
|
// owned pins without threading the chat id through every call site. Written on
|
|
6805
6993
|
// every desired-pinned reconcile, cleared alongside the state on unpin.
|
|
6806
6994
|
const statusPinChatIds = new Map<string, string>()
|
|
6995
|
+
// Companion registry: pinKey → wall-clock ms the claim was FIRST taken (a
|
|
6996
|
+
// re-pin of the same key keeps the original timestamp). Feeds the TTL gate of
|
|
6997
|
+
// the mid-session `wk:` pin reaper (#3001); cleared alongside the state.
|
|
6998
|
+
const statusPinPinnedAt = new Map<string, number>()
|
|
6807
6999
|
|
|
6808
7000
|
// Durable snapshot of the pin claim set on the persistent per-agent volume
|
|
6809
7001
|
// (STATE_DIR = /state/agent/telegram in prod). Closes the crash hole: the
|
|
@@ -6855,6 +7047,22 @@ const BANNER_PIN_KEY = 'banner:owner'
|
|
|
6855
7047
|
// run → no-op). The boot-cleanup gate below is widened to cover this.
|
|
6856
7048
|
const bannerPinPersistEnabled = !STATIC
|
|
6857
7049
|
|
|
7050
|
+
// `pin_message` MCP-tool pin registration (#3001). Tool pins ride the same
|
|
7051
|
+
// shared status-pins.json store under `tool:<chatId>:<messageId>` keys, but
|
|
7052
|
+
// with a TTL row (`expiresAt`): a tool pin is a deliberate agent action with
|
|
7053
|
+
// no "work finished" event, so a restart does NOT reset it — boot cleanup
|
|
7054
|
+
// keeps unexpired tool rows and only unpins them once the TTL lapses (the
|
|
7055
|
+
// backstop against agent-pinned messages accumulating forever). Independent
|
|
7056
|
+
// of PIN_STATUS_WHILE_WORKING (it gates the auto status pin, not the tool).
|
|
7057
|
+
const toolPinPersistEnabled = !STATIC
|
|
7058
|
+
// 7 days: generous — an agent-pinned message the operator still cares about
|
|
7059
|
+
// after a week has usually been re-pinned or acted on; anything older is the
|
|
7060
|
+
// stale-pin long tail this issue exists to clear. Override for tuning.
|
|
7061
|
+
const TOOL_PIN_TTL_MS = (() => {
|
|
7062
|
+
const v = Number(process.env.SWITCHROOM_TOOL_PIN_TTL_MS)
|
|
7063
|
+
return Number.isFinite(v) && v > 0 ? v : 7 * 24 * 60 * 60_000
|
|
7064
|
+
})()
|
|
7065
|
+
|
|
6858
7066
|
// Persist (or drop) the slot-banner's pin row into the shared store. Routes
|
|
6859
7067
|
// through mutateStatusPinRow: a read-modify-write for ONLY the banner:owner key,
|
|
6860
7068
|
// serialised on the store's per-path lock, so the live fg:/wk: status-pin rows
|
|
@@ -6905,9 +7113,9 @@ async function statusPinBootCleanup(): Promise<void> {
|
|
|
6905
7113
|
// map-backed status pins (statusPinPersistEnabled) or the slot banner
|
|
6906
7114
|
// (bannerPinPersistEnabled). A single cleanup drains ALL orphaned rows —
|
|
6907
7115
|
// status pins AND banner alike — since they share status-pins.json.
|
|
6908
|
-
if (!statusPinPersistEnabled && !bannerPinPersistEnabled) return
|
|
7116
|
+
if (!statusPinPersistEnabled && !bannerPinPersistEnabled && !toolPinPersistEnabled) return
|
|
6909
7117
|
const api = statusPinApi()
|
|
6910
|
-
const { cleared, total } = await runStatusPinBootCleanup({
|
|
7118
|
+
const { cleared, retained, kept, total } = await runStatusPinBootCleanup({
|
|
6911
7119
|
path: STATUS_PIN_STORE_PATH,
|
|
6912
7120
|
fs: statusPinStoreFs,
|
|
6913
7121
|
unpin: (chatId, messageId) => api.unpinChatMessage(chatId, messageId),
|
|
@@ -6915,7 +7123,8 @@ async function statusPinBootCleanup(): Promise<void> {
|
|
|
6915
7123
|
if (total > 0) {
|
|
6916
7124
|
process.stderr.write(
|
|
6917
7125
|
`telegram gateway: status-pin: cleared ${cleared}/${total} ` +
|
|
6918
|
-
`orphaned pin(s) from a prior session
|
|
7126
|
+
`orphaned pin(s) from a prior session ` +
|
|
7127
|
+
`(retained ${retained} for retry, kept ${kept} unexpired tool pin(s))\n`,
|
|
6919
7128
|
)
|
|
6920
7129
|
}
|
|
6921
7130
|
}
|
|
@@ -7016,6 +7225,25 @@ const MID_SESSION_CARD_REAPER_INTERVAL_MS = (() => {
|
|
|
7016
7225
|
return Number.isFinite(v) && v > 0 ? v : 5 * 60_000 // 5 min
|
|
7017
7226
|
})()
|
|
7018
7227
|
|
|
7228
|
+
// ─── Mid-session stale worker-pin reaper (#3001) ─────────────────────────────
|
|
7229
|
+
// The `wk:<agentId>` pin is normally dropped by the worker's completion
|
|
7230
|
+
// handler, but a missed onFinish (watcher crash / SDK SIGKILL / dropped JSONL
|
|
7231
|
+
// tail) used to leave the pin glued to the chat until the NEXT gateway boot.
|
|
7232
|
+
// This sweep (piggybacking on the mid-session reaper interval) unpins a
|
|
7233
|
+
// claimed worker pin when the registry says its worker is TERMINAL, or when
|
|
7234
|
+
// the pin has been held past a TTL. Pure decision in worker-pin-reaper.ts;
|
|
7235
|
+
// each reap executes through reconcileStatusPin so the in-memory claim and
|
|
7236
|
+
// the durable store row clear together.
|
|
7237
|
+
//
|
|
7238
|
+
// Kill switch: SWITCHROOM_WORKER_PIN_REAPER=0 disables (clean revert, mirrors
|
|
7239
|
+
// SWITCHROOM_MID_SESSION_CARD_REAPER). TTL overridable via env for tuning.
|
|
7240
|
+
const WORKER_PIN_REAPER_ENABLED =
|
|
7241
|
+
process.env.SWITCHROOM_WORKER_PIN_REAPER !== '0'
|
|
7242
|
+
const WORKER_PIN_REAPER_TTL_MS = (() => {
|
|
7243
|
+
const v = Number(process.env.SWITCHROOM_WORKER_PIN_REAPER_TTL_MS)
|
|
7244
|
+
return Number.isFinite(v) && v > 0 ? v : WORKER_PIN_TTL_MS_DEFAULT // 6 h
|
|
7245
|
+
})()
|
|
7246
|
+
|
|
7019
7247
|
// Snapshot the turn_keys / topic keys owned by a live in-flight turn right now.
|
|
7020
7248
|
function liveTurnKeySets(): { registryKeys: Set<string>; topicKeys: Set<string> } {
|
|
7021
7249
|
const registryKeys = new Set<string>()
|
|
@@ -7080,15 +7308,27 @@ async function runMidSessionCardReaper(): Promise<void> {
|
|
|
7080
7308
|
verb: 'activity-card.mid-session-reap-finalize',
|
|
7081
7309
|
},
|
|
7082
7310
|
),
|
|
7083
|
-
|
|
7084
|
-
|
|
7311
|
+
// #3001: route through reconcileStatusPin when the pin is a live
|
|
7312
|
+
// in-memory claim, so the claim AND the durable status-pins.json row
|
|
7313
|
+
// clear together (the raw-API unpin left both behind — the boot sweep
|
|
7314
|
+
// then re-unpinned an already-unpinned message and the service-message
|
|
7315
|
+
// handler kept a phantom claim). Falls back to the raw unpin when no
|
|
7316
|
+
// claim is tracked (e.g. claim already dropped out-of-band).
|
|
7317
|
+
unpinCard: async (record) => {
|
|
7318
|
+
const pinKey = `fg:${record.turnKey}`
|
|
7319
|
+
if (statusPinState.has(pinKey)) {
|
|
7320
|
+
await reconcileStatusPin(pinKey, record.chatId, { pinned: false })
|
|
7321
|
+
return true
|
|
7322
|
+
}
|
|
7323
|
+
return robustApiCall(
|
|
7085
7324
|
() => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId),
|
|
7086
7325
|
{
|
|
7087
7326
|
chat_id: record.chatId,
|
|
7088
7327
|
...(record.threadId != null ? { threadId: record.threadId } : {}),
|
|
7089
7328
|
verb: 'activity-card.mid-session-reap-unpin',
|
|
7090
7329
|
},
|
|
7091
|
-
)
|
|
7330
|
+
)
|
|
7331
|
+
},
|
|
7092
7332
|
})
|
|
7093
7333
|
if (total > 0) {
|
|
7094
7334
|
process.stderr.write(
|
|
@@ -7102,6 +7342,55 @@ async function runMidSessionCardReaper(): Promise<void> {
|
|
|
7102
7342
|
)
|
|
7103
7343
|
}
|
|
7104
7344
|
}
|
|
7345
|
+
|
|
7346
|
+
// 3) Reap stale `wk:` worker pins (#3001): a claimed worker pin whose worker
|
|
7347
|
+
// is terminal in the registry (missed onFinish) or whose claim outlived
|
|
7348
|
+
// the TTL. Executes through reconcileStatusPin so the in-memory claim and
|
|
7349
|
+
// the durable store row clear together.
|
|
7350
|
+
if (WORKER_PIN_REAPER_ENABLED && PIN_STATUS_WHILE_WORKING) {
|
|
7351
|
+
try {
|
|
7352
|
+
const candidates = [...statusPinState.keys()]
|
|
7353
|
+
.filter((k) => k.startsWith('wk:'))
|
|
7354
|
+
.map((k) => ({
|
|
7355
|
+
pinKey: k,
|
|
7356
|
+
chatId: statusPinChatIds.get(k) ?? '',
|
|
7357
|
+
// A missing timestamp (should not happen — set on every claim)
|
|
7358
|
+
// degrades to "claimed just now": terminality can still reap it,
|
|
7359
|
+
// the TTL gate never can. Conservative, never a spurious unpin.
|
|
7360
|
+
pinnedAt: statusPinPinnedAt.get(k) ?? now,
|
|
7361
|
+
}))
|
|
7362
|
+
const reaps = decideWorkerPinReaps({
|
|
7363
|
+
pins: candidates,
|
|
7364
|
+
statusOf: (agentId) => {
|
|
7365
|
+
if (turnsDb == null) return 'unknown'
|
|
7366
|
+
try {
|
|
7367
|
+
const row = getSubagentByJsonlId(turnsDb, agentId)
|
|
7368
|
+
if (row == null) return 'unknown'
|
|
7369
|
+
if (row.status === 'completed' || row.status === 'failed') return 'terminal'
|
|
7370
|
+
// A live 'running' row exempts the pin from the TTL (no churn on
|
|
7371
|
+
// healthy long workers); 'stalled' degrades to 'unknown' → TTL.
|
|
7372
|
+
if (row.status === 'running') return 'running'
|
|
7373
|
+
return 'unknown'
|
|
7374
|
+
} catch {
|
|
7375
|
+
return 'unknown' // DB hiccup degrades to "keep until TTL"
|
|
7376
|
+
}
|
|
7377
|
+
},
|
|
7378
|
+
ttlMs: WORKER_PIN_REAPER_TTL_MS,
|
|
7379
|
+
now,
|
|
7380
|
+
})
|
|
7381
|
+
for (const reap of reaps) {
|
|
7382
|
+
process.stderr.write(
|
|
7383
|
+
`telegram gateway: worker-pin reaper unpinning ${reap.pinKey} ` +
|
|
7384
|
+
`(chat=${reap.chatId} reason=${reap.reason})\n`,
|
|
7385
|
+
)
|
|
7386
|
+
await reconcileStatusPin(reap.pinKey, reap.chatId, { pinned: false })
|
|
7387
|
+
}
|
|
7388
|
+
} catch (err) {
|
|
7389
|
+
process.stderr.write(
|
|
7390
|
+
`telegram gateway: worker-pin reaper error: ${(err as Error).message}\n`,
|
|
7391
|
+
)
|
|
7392
|
+
}
|
|
7393
|
+
}
|
|
7105
7394
|
}
|
|
7106
7395
|
|
|
7107
7396
|
const midSessionCardReaper = setInterval(() => {
|
|
@@ -7189,9 +7478,11 @@ async function reconcileStatusPinInner(
|
|
|
7189
7478
|
if (next == null) {
|
|
7190
7479
|
statusPinState.delete(pinKey)
|
|
7191
7480
|
statusPinChatIds.delete(pinKey)
|
|
7481
|
+
statusPinPinnedAt.delete(pinKey)
|
|
7192
7482
|
} else {
|
|
7193
7483
|
statusPinState.set(pinKey, next)
|
|
7194
7484
|
statusPinChatIds.set(pinKey, chatId)
|
|
7485
|
+
if (!statusPinPinnedAt.has(pinKey)) statusPinPinnedAt.set(pinKey, Date.now())
|
|
7195
7486
|
}
|
|
7196
7487
|
return
|
|
7197
7488
|
}
|
|
@@ -7211,9 +7502,11 @@ async function reconcileStatusPinInner(
|
|
|
7211
7502
|
if (next == null) {
|
|
7212
7503
|
statusPinState.delete(pinKey)
|
|
7213
7504
|
statusPinChatIds.delete(pinKey)
|
|
7505
|
+
statusPinPinnedAt.delete(pinKey)
|
|
7214
7506
|
} else {
|
|
7215
7507
|
statusPinState.set(pinKey, next)
|
|
7216
7508
|
statusPinChatIds.set(pinKey, chatId)
|
|
7509
|
+
if (!statusPinPinnedAt.has(pinKey)) statusPinPinnedAt.set(pinKey, Date.now())
|
|
7217
7510
|
}
|
|
7218
7511
|
}
|
|
7219
7512
|
|
|
@@ -7256,7 +7549,7 @@ async function unpinAllStatusPins(): Promise<void> {
|
|
|
7256
7549
|
if (st == null) continue
|
|
7257
7550
|
// Recover the chat id from the state map's companion key registry.
|
|
7258
7551
|
const chatId = statusPinChatIds.get(key)
|
|
7259
|
-
if (chatId == null) { statusPinState.delete(key); continue }
|
|
7552
|
+
if (chatId == null) { statusPinState.delete(key); statusPinPinnedAt.delete(key); continue }
|
|
7260
7553
|
await reconcileStatusPin(key, chatId, { pinned: false })
|
|
7261
7554
|
}
|
|
7262
7555
|
}
|
|
@@ -8861,6 +9154,7 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
8861
9154
|
dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
|
|
8862
9155
|
configSnapshotPath: join(resolvedAgentDirForCard, '.config-snapshot.json'),
|
|
8863
9156
|
bootCardStatePath: join(resolvedAgentDirForCard, '.boot-card-msgid.json'),
|
|
9157
|
+
floodStatePath: FLOOD_STATE_PATH,
|
|
8864
9158
|
...(updateOutcomeLine ? { updateOutcomeLine } : {}),
|
|
8865
9159
|
}, ackMsgId).then(handle => {
|
|
8866
9160
|
activeBootCard = handle
|
|
@@ -10455,6 +10749,15 @@ const voicePreSynthQueue = new PreSynthQueue({
|
|
|
10455
10749
|
}
|
|
10456
10750
|
const filePath = writeVoiceCacheFile(VOICE_CACHE_DIR, job.token, result.audio)
|
|
10457
10751
|
voiceOnDemandCache.setFilePath(job.token, filePath)
|
|
10752
|
+
// TODO(first-tap-instant): pre-mint the Telegram file_id here so even the
|
|
10753
|
+
// FIRST user tap sends by id (no upload). Would require uploading this ogg
|
|
10754
|
+
// once to the bot's OWN hidden storage/log chat and calling
|
|
10755
|
+
// voiceOnDemandCache.setTelegramFileId(job.token, msg.voice.file_id).
|
|
10756
|
+
// Deferred: no storage-chat id exists in config/env today, and the product
|
|
10757
|
+
// invariant forbids sending audio to the USER's chat without a tap — so a
|
|
10758
|
+
// dedicated bot-storage chat id must be added first. Until then, the first
|
|
10759
|
+
// tap uploads (via the disk fast-path) and captures the id for reuse; only
|
|
10760
|
+
// the second+ taps are instant.
|
|
10458
10761
|
process.stderr.write(
|
|
10459
10762
|
`telegram gateway: voice-presynth: cached ${result.audio.length} bytes at ${filePath} ` +
|
|
10460
10763
|
`(${result.durationMs}ms, backlog=${voicePreSynthQueue.size})\n`,
|
|
@@ -13262,10 +13565,26 @@ async function executePinMessage(args: Record<string, unknown>): Promise<unknown
|
|
|
13262
13565
|
// errors are retried. THREAD_NOT_FOUND on a stale topic surfaces to the
|
|
13263
13566
|
// agent as a tool-error — pinning a vanished message is genuinely a
|
|
13264
13567
|
// failure the agent should see.
|
|
13568
|
+
const pinMsgId = Number(args.message_id)
|
|
13265
13569
|
await robustApiCall(
|
|
13266
|
-
() => lockedBot.api.pinChatMessage(pinChatId,
|
|
13570
|
+
() => lockedBot.api.pinChatMessage(pinChatId, pinMsgId),
|
|
13267
13571
|
{ chat_id: pinChatId, verb: 'pin_message' },
|
|
13268
13572
|
)
|
|
13573
|
+
// #3001: register the tool pin in the shared status-pin store under a
|
|
13574
|
+
// `tool:` key so it is no longer fire-and-forget. Unlike work-scoped
|
|
13575
|
+
// fg:/wk: rows a tool pin has no "work finished" event, so a restart does
|
|
13576
|
+
// NOT reset it — boot cleanup keeps the row until its TTL, then unpins the
|
|
13577
|
+
// (likely long-forgotten) message. Best-effort fire-and-forget: a store
|
|
13578
|
+
// failure must never fail the tool call the pin already landed for.
|
|
13579
|
+
if (toolPinPersistEnabled) {
|
|
13580
|
+
const toolPinKey = `tool:${pinChatId}:${pinMsgId}`
|
|
13581
|
+
void mutateStatusPinRow(STATUS_PIN_STORE_PATH, statusPinStoreFs, toolPinKey, {
|
|
13582
|
+
pinKey: toolPinKey,
|
|
13583
|
+
chatId: pinChatId,
|
|
13584
|
+
messageId: pinMsgId,
|
|
13585
|
+
expiresAt: Date.now() + TOOL_PIN_TTL_MS,
|
|
13586
|
+
})
|
|
13587
|
+
}
|
|
13269
13588
|
return { content: [{ type: 'text', text: `pinned message ${args.message_id}` }] }
|
|
13270
13589
|
}
|
|
13271
13590
|
|
|
@@ -17778,13 +18097,23 @@ async function handleInbound(
|
|
|
17778
18097
|
// know they're queued. Suppressed for DMs (no topics) and same-topic
|
|
17779
18098
|
// queues (the in-flight turn's own card already covers them).
|
|
17780
18099
|
const inFlightThread = currentTurn?.sessionThreadId
|
|
17781
|
-
|
|
18100
|
+
const crossTopicQueuedCard =
|
|
17782
18101
|
QUEUED_STATUS_UX_ENABLED &&
|
|
17783
18102
|
!isDmChatId(chat_id) &&
|
|
17784
18103
|
messageThreadId != null &&
|
|
17785
18104
|
messageThreadId !== inFlightThread
|
|
17786
|
-
) {
|
|
18105
|
+
if (crossTopicQueuedCard) {
|
|
17787
18106
|
postQueuedStatus(chat_id, messageThreadId, inFlightThread)
|
|
18107
|
+
} else {
|
|
18108
|
+
// #2995 mid-flight busy ack — ONLY the surfaces the cross-topic card
|
|
18109
|
+
// suppresses (DMs, same-topic). Mutually exclusive with
|
|
18110
|
+
// postQueuedStatus above: both record into queuedStatusMsgIds only
|
|
18111
|
+
// AFTER their sendMessage awaits resolve, so calling both here would
|
|
18112
|
+
// race past each other's has(key) check and double-card the topic.
|
|
18113
|
+
// When the running turn is inside one LONG tool step, a buffered
|
|
18114
|
+
// quick question would otherwise wait minutes with only a 👀; post a
|
|
18115
|
+
// silent deterministic "Queued — currently inside <tool>" card.
|
|
18116
|
+
maybePostBusyAck('buffer-until-idle', chat_id, messageThreadId ?? undefined)
|
|
17788
18117
|
}
|
|
17789
18118
|
return
|
|
17790
18119
|
}
|
|
@@ -17830,6 +18159,15 @@ async function handleInbound(
|
|
|
17830
18159
|
|
|
17831
18160
|
const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg)
|
|
17832
18161
|
if (delivered) {
|
|
18162
|
+
// #2995 — a steer delivered mid-turn while the turn is stuck inside one
|
|
18163
|
+
// long tool step gets a deterministic ack (worded as a steer, not
|
|
18164
|
+
// "Queued" — classification visibility): the model can't narrate the
|
|
18165
|
+
// steer until the blocking call returns. Posted only AFTER the bridge
|
|
18166
|
+
// dispatch succeeded — a "Steer noted" card for a send that missed
|
|
18167
|
+
// (bridge offline → buffered/dropped below) would be untrue.
|
|
18168
|
+
if (isSteering) {
|
|
18169
|
+
maybePostBusyAck('steer', chat_id, messageThreadId ?? undefined)
|
|
18170
|
+
}
|
|
17833
18171
|
// Reuse the key reserved synchronously above (#2917) when present, else
|
|
17834
18172
|
// mark now — markClaudeBusyForInbound is idempotent (lockstep re-stamp),
|
|
17835
18173
|
// so a re-mark is safe and returns the same chat key.
|
|
@@ -19527,6 +19865,15 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
|
|
|
19527
19865
|
})
|
|
19528
19866
|
}
|
|
19529
19867
|
stampUserRestartReason(reason)
|
|
19868
|
+
// Model-switch restarts are switchroom-managed relaunches: the session
|
|
19869
|
+
// override (written by the caller before this dispatch) must survive
|
|
19870
|
+
// the bounce, so stamp keep-intent BEFORE dispatch (boot default is
|
|
19871
|
+
// revert). hostd shells through `switchroom agent restart`, which
|
|
19872
|
+
// deliberately writes no intent of its own.
|
|
19873
|
+
{
|
|
19874
|
+
const smDir = resolveAgentDirFromEnv()
|
|
19875
|
+
if (smDir) writeRelaunchModelIntent(smDir, 'keep', reason)
|
|
19876
|
+
}
|
|
19530
19877
|
await sweepBeforeSelfRestart()
|
|
19531
19878
|
const hostdResp = await tryHostdDispatch(name, {
|
|
19532
19879
|
v: 1,
|
|
@@ -19546,43 +19893,58 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
|
|
|
19546
19893
|
)
|
|
19547
19894
|
return
|
|
19548
19895
|
}
|
|
19549
|
-
// hostd is configured but returned an error/denied result.
|
|
19896
|
+
// hostd is configured but returned an error/denied result. No restart
|
|
19897
|
+
// is coming, so the keep-intent stamped above must not linger — a
|
|
19898
|
+
// crash within its 10-min freshness window would wrongly KEEP.
|
|
19550
19899
|
if (hostdResp.result !== 'started' && hostdResp.result !== 'completed') {
|
|
19551
19900
|
clearRestartMarker()
|
|
19901
|
+
{
|
|
19902
|
+
const smDir = resolveAgentDirFromEnv()
|
|
19903
|
+
if (smDir) clearRelaunchModelIntent(smDir)
|
|
19904
|
+
}
|
|
19552
19905
|
throw new Error(
|
|
19553
19906
|
`hostd restart failed (result=${hostdResp.result}): ${hostdResp.error ?? '(no details)'}`,
|
|
19554
19907
|
)
|
|
19555
19908
|
}
|
|
19556
19909
|
},
|
|
19557
19910
|
/**
|
|
19558
|
-
*
|
|
19559
|
-
* native `/model` picker rejects
|
|
19560
|
-
* Write the
|
|
19561
|
-
*
|
|
19562
|
-
* the in-memory session-model so /status stays honest
|
|
19563
|
-
* window, then run the SAME restart dispatch as
|
|
19911
|
+
* Switch TO a model that needs a relaunch (sr-* LiteLLM/OpenRouter ids,
|
|
19912
|
+
* which claude's native `/model` picker rejects, and the sr-to-claude
|
|
19913
|
+
* direction). Write the DURABLE `.session-model` override (start.sh
|
|
19914
|
+
* applies it on every keep-relaunch boot and launches `claude --model
|
|
19915
|
+
* <token>`), set the in-memory session-model so /status stays honest
|
|
19916
|
+
* across the restart window, then run the SAME restart dispatch as
|
|
19917
|
+
* scheduleRestart above — which stamps the keep-intent this boot needs.
|
|
19564
19918
|
*/
|
|
19565
19919
|
scheduleModelRelaunch: async (model: string, reason: string) => {
|
|
19566
19920
|
const agentDir = resolveAgentDirFromEnv()
|
|
19567
|
-
if (!agentDir) throw new Error('agent dir unresolvable — cannot write session-model
|
|
19568
|
-
// Carrier: single line, token + newline, no quoting (start.sh strips
|
|
19569
|
-
// whitespace and shape-gates). One-shot — consumed on the next boot.
|
|
19921
|
+
if (!agentDir) throw new Error('agent dir unresolvable — cannot write session-model file')
|
|
19570
19922
|
const prevOverride = sessionModelSource.getOverride()
|
|
19571
|
-
|
|
19923
|
+
const prevFileRaw = readSessionModelFileRaw(agentDir)
|
|
19924
|
+
writeSessionModelFile(
|
|
19925
|
+
agentDir,
|
|
19926
|
+
model,
|
|
19927
|
+
readConfiguredDefaultModel(agentDir) ??
|
|
19928
|
+
resolveMainModel(deps.getConfiguredModel() ?? undefined),
|
|
19929
|
+
)
|
|
19572
19930
|
sessionModelSource.setOverride(model)
|
|
19573
19931
|
try {
|
|
19574
19932
|
await deps.scheduleRestart(reason)
|
|
19575
19933
|
} catch (err) {
|
|
19576
|
-
|
|
19577
|
-
//
|
|
19578
|
-
//
|
|
19579
|
-
//
|
|
19580
|
-
// OTHER dispatch failure means no restart is coming, so roll BOTH
|
|
19581
|
-
// a lingering
|
|
19582
|
-
// NEXT
|
|
19934
|
+
// A restart already in flight OWNS the override we just wrote — its
|
|
19935
|
+
// boot (stamped keep by the in-flight path's own intent, last-writer-
|
|
19936
|
+
// wins) will apply our token, so the switch is queued, not lost: keep
|
|
19937
|
+
// the file + override and let the caller tell the operator "~15s".
|
|
19938
|
+
// Any OTHER dispatch failure means no restart is coming, so roll BOTH
|
|
19939
|
+
// back — a lingering file/override would lie to /status and
|
|
19940
|
+
// mis-launch the NEXT relaunch. The keep-intent goes with them
|
|
19941
|
+
// (belt-and-braces: scheduleRestart's failure branch clears it too):
|
|
19942
|
+
// a fresh keep on disk with no restart coming would wrongly KEEP
|
|
19943
|
+
// across a crash inside its 10-min window.
|
|
19583
19944
|
if ((err as { code?: string })?.code !== 'restart_in_flight') {
|
|
19584
|
-
|
|
19945
|
+
restoreSessionModelFileRaw(agentDir, prevFileRaw)
|
|
19585
19946
|
sessionModelSource.setOverride(prevOverride)
|
|
19947
|
+
clearRelaunchModelIntent(agentDir)
|
|
19586
19948
|
}
|
|
19587
19949
|
throw err
|
|
19588
19950
|
}
|
|
@@ -19620,10 +19982,44 @@ bot.command('model', async ctx => {
|
|
|
19620
19982
|
// set on the confirmed inject path; the sr-*/relaunch paths already set the
|
|
19621
19983
|
// override inside scheduleModelRelaunch, and an unverified switch carries no
|
|
19622
19984
|
// selectedModel so /status is never lied to.
|
|
19623
|
-
|
|
19985
|
+
const requested = parsed.kind === 'set' ? expandSrAlias(parsed.model) : null
|
|
19986
|
+
let persistWarning = ''
|
|
19987
|
+
if (requested?.toLowerCase() === 'default') {
|
|
19988
|
+
// `/model default` clears the sticky file even WITHOUT a positive
|
|
19989
|
+
// confirmation: claude's arg-form switch can be silent, and a surviving
|
|
19990
|
+
// sticky file would resurrect the old model on the next keep-relaunch.
|
|
19991
|
+
// Clearing is idempotent; the in-memory override change stays gated on a
|
|
19992
|
+
// confirmed switch so an unverified inject never lies to /status.
|
|
19993
|
+
const smDir = resolveAgentDirFromEnv()
|
|
19994
|
+
if (smDir) clearSessionModelFile(smDir)
|
|
19995
|
+
if (reply.selectedModel) sessionModelSource.setOverride(null)
|
|
19996
|
+
} else if (reply.selectedModel) {
|
|
19624
19997
|
sessionModelSource.setOverride(reply.selectedModel)
|
|
19998
|
+
// Durable stickiness: persist the REQUESTED canonical token — never
|
|
19999
|
+
// the confirmation's display label ("Opus 4.8"), which `claude
|
|
20000
|
+
// --model` would reject on the next boot. sr-* switches never reach
|
|
20001
|
+
// here (they go through scheduleModelRelaunch, which persists).
|
|
20002
|
+
const smDir = resolveAgentDirFromEnv()
|
|
20003
|
+
if (smDir && requested && isValidModelArg(requested) && !isSrModel(requested)) {
|
|
20004
|
+
try {
|
|
20005
|
+
writeSessionModelFile(
|
|
20006
|
+
smDir,
|
|
20007
|
+
requested,
|
|
20008
|
+
readConfiguredDefaultModel(smDir) ??
|
|
20009
|
+
resolveMainModel(deps.getConfiguredModel() ?? undefined),
|
|
20010
|
+
)
|
|
20011
|
+
} catch (err) {
|
|
20012
|
+
// The reply body already promises stickiness — never let the promise
|
|
20013
|
+
// and the disk disagree silently.
|
|
20014
|
+
persistWarning =
|
|
20015
|
+
'\n⚠️ Couldn’t persist the sticky override — the switch is live now but won’t survive a relaunch.'
|
|
20016
|
+
process.stderr.write(
|
|
20017
|
+
`telegram gateway: session-model persist failed (typed /model): ${(err as Error)?.message ?? String(err)}\n`,
|
|
20018
|
+
)
|
|
20019
|
+
}
|
|
20020
|
+
}
|
|
19625
20021
|
}
|
|
19626
|
-
await switchroomReply(ctx, reply.text, { html: reply.html })
|
|
20022
|
+
await switchroomReply(ctx, reply.text + persistWarning, { html: reply.html })
|
|
19627
20023
|
})
|
|
19628
20024
|
|
|
19629
20025
|
// `/effort` — show or switch the reasoning effort for the live session.
|
|
@@ -19734,6 +20130,13 @@ bot.command('restart', async ctx => {
|
|
|
19734
20130
|
// greeting card shows "Restarted user: /restart from chat" instead
|
|
19735
20131
|
// of whatever reason the downstream CLI would default to.
|
|
19736
20132
|
stampUserRestartReason('user: /restart from chat')
|
|
20133
|
+
// /restart is a DELIBERATE restart: the session model reverts to the
|
|
20134
|
+
// configured default. Absence of intent would revert anyway (boot
|
|
20135
|
+
// default) — the explicit stamp is reason-honesty for the boot notice.
|
|
20136
|
+
{
|
|
20137
|
+
const smDir = resolveAgentDirFromEnv()
|
|
20138
|
+
if (smDir) writeRelaunchModelIntent(smDir, 'revert', 'user: /restart from chat')
|
|
20139
|
+
}
|
|
19737
20140
|
await sweepBeforeSelfRestart()
|
|
19738
20141
|
const hostdResp = await tryHostdDispatch(getMyAgentName(), {
|
|
19739
20142
|
v: 1,
|
|
@@ -19892,6 +20295,12 @@ async function handleNewCommand(ctx: Context): Promise<void> {
|
|
|
19892
20295
|
// Stamp user attribution so the next greeting shows "Restarted user:
|
|
19893
20296
|
// /new" / "user: /reset" rather than the downstream CLI default.
|
|
19894
20297
|
stampUserRestartReason(`user: /${kind} from chat`)
|
|
20298
|
+
// /new and /reset start a fresh CONVERSATION, not a fresh model choice:
|
|
20299
|
+
// the sticky session-model override KEEPS across them (contract row 7).
|
|
20300
|
+
// Boot default is revert, so the keep-intent must land before dispatch.
|
|
20301
|
+
if (agentDir != null) {
|
|
20302
|
+
writeRelaunchModelIntent(agentDir, 'keep', `user: /${kind} from chat`)
|
|
20303
|
+
}
|
|
19895
20304
|
await sweepBeforeSelfRestart()
|
|
19896
20305
|
const hostdResp = await tryHostdDispatch(getMyAgentName(), {
|
|
19897
20306
|
v: 1,
|
|
@@ -24727,7 +25136,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
24727
25136
|
// sr-* TARGET tap: switch TO a non-Claude (LiteLLM/OpenRouter) model.
|
|
24728
25137
|
// Parity with the text `/model sr-*` path — claude's native picker rejects
|
|
24729
25138
|
// unknown sr-* ids, so an in-place inject can't set them. Carry the token
|
|
24730
|
-
// across a graceful restart (the `.session-model
|
|
25139
|
+
// across a graceful restart (the durable `.session-model` override) and
|
|
24731
25140
|
// relaunch `claude --model sr-*`. Session-only; reverts to the configured
|
|
24732
25141
|
// default on the next restart. The sr-* → Claude direction is handled below
|
|
24733
25142
|
// via the SELECT/alias outcome + isSrToClaudeTransition.
|
|
@@ -24763,10 +25172,35 @@ bot.on('callback_query:data', async ctx => {
|
|
|
24763
25172
|
const prevSessionModel = sessionModelSource.getOverride()
|
|
24764
25173
|
const outcome = await handleModelMenuCallback(data, modelDeps)
|
|
24765
25174
|
// Record a successful session switch so /status reflects what's
|
|
24766
|
-
// actually running
|
|
24767
|
-
//
|
|
25175
|
+
// actually running, and persist the STICKY override
|
|
25176
|
+
// (reference/rfcs/session-model-stickiness.md): the canonical token
|
|
25177
|
+
// (never the display label) goes to the durable `.session-model`; a
|
|
25178
|
+
// confirmed "Default (recommended)" selection clears it instead.
|
|
24768
25179
|
if (outcome.selectedModel) {
|
|
24769
25180
|
sessionModelSource.setOverride(outcome.selectedModel)
|
|
25181
|
+
const smDir = resolveAgentDirFromEnv()
|
|
25182
|
+
if (smDir && outcome.selectedModelToken) {
|
|
25183
|
+
try {
|
|
25184
|
+
writeSessionModelFile(
|
|
25185
|
+
smDir,
|
|
25186
|
+
outcome.selectedModelToken,
|
|
25187
|
+
readConfiguredDefaultModel(smDir) ??
|
|
25188
|
+
resolveMainModel(modelDeps.getConfiguredModel() ?? undefined),
|
|
25189
|
+
)
|
|
25190
|
+
} catch (err) {
|
|
25191
|
+
// The banner already promises stickiness — surface the failure on
|
|
25192
|
+
// the same card instead of only stderr.
|
|
25193
|
+
outcome.reply.text +=
|
|
25194
|
+
'\n⚠️ Couldn’t persist the sticky override — the switch is live now but won’t survive a relaunch.'
|
|
25195
|
+
process.stderr.write(
|
|
25196
|
+
`telegram gateway: session-model persist failed (menu): ${(err as Error)?.message ?? String(err)}\n`,
|
|
25197
|
+
)
|
|
25198
|
+
}
|
|
25199
|
+
}
|
|
25200
|
+
}
|
|
25201
|
+
if (outcome.clearedDefault) {
|
|
25202
|
+
const smDir = resolveAgentDirFromEnv()
|
|
25203
|
+
if (smDir) clearSessionModelFile(smDir)
|
|
24770
25204
|
}
|
|
24771
25205
|
// toastOnly: leave the menu untouched — but only if we haven't already
|
|
24772
25206
|
// cleared its buttons with the interim sr-* edit. If we have, fall
|
|
@@ -24789,22 +25223,33 @@ bot.on('callback_query:data', async ctx => {
|
|
|
24789
25223
|
)
|
|
24790
25224
|
.catch(() => {})
|
|
24791
25225
|
// Carry the requested Claude model across the restart via the SAME
|
|
24792
|
-
// `.session-model
|
|
24793
|
-
// boot launches the CONFIGURED default and the tapped model
|
|
24794
|
-
// dropped. `selectedModelToken` is a real `claude --model`
|
|
24795
|
-
// full claude-* id); a "Default"-row tap yields no
|
|
24796
|
-
//
|
|
24797
|
-
//
|
|
25226
|
+
// durable `.session-model` override a Claude → sr-* switch uses —
|
|
25227
|
+
// otherwise boot launches the CONFIGURED default and the tapped model
|
|
25228
|
+
// is silently dropped. `selectedModelToken` is a real `claude --model`
|
|
25229
|
+
// token (alias or full claude-* id); a "Default"-row tap yields no
|
|
25230
|
+
// token → clear the override and boot the configured default
|
|
25231
|
+
// (correct). start.sh's LiteLLM-down guard only skips sr-* overrides,
|
|
25232
|
+
// so a Claude token is never dropped.
|
|
24798
25233
|
{
|
|
24799
25234
|
const agentDir = resolveAgentDirFromEnv()
|
|
24800
25235
|
const token = outcome.selectedModelToken
|
|
24801
25236
|
if (agentDir && token) {
|
|
24802
25237
|
try {
|
|
24803
|
-
|
|
25238
|
+
writeSessionModelFile(
|
|
25239
|
+
agentDir,
|
|
25240
|
+
token,
|
|
25241
|
+
readConfiguredDefaultModel(agentDir) ??
|
|
25242
|
+
resolveMainModel(modelDeps.getConfiguredModel() ?? undefined),
|
|
25243
|
+
)
|
|
24804
25244
|
sessionModelSource.setOverride(token)
|
|
24805
25245
|
} catch (e) {
|
|
24806
|
-
process.stderr.write(`telegram gateway: sr-to-claude
|
|
25246
|
+
process.stderr.write(`telegram gateway: sr-to-claude session-model write failed: ${(e as Error)?.message ?? String(e)}\n`)
|
|
24807
25247
|
}
|
|
25248
|
+
} else if (agentDir) {
|
|
25249
|
+
// Default-row tap while on sr-*: the restart must land on the
|
|
25250
|
+
// configured default — a stale sticky override would resurrect
|
|
25251
|
+
// the old model on the next keep-relaunch.
|
|
25252
|
+
clearSessionModelFile(agentDir)
|
|
24808
25253
|
}
|
|
24809
25254
|
}
|
|
24810
25255
|
// Write the restart marker so the post-restart boot card edits into this chat.
|
|
@@ -25165,22 +25610,36 @@ bot.on('callback_query:data', async ctx => {
|
|
|
25165
25610
|
}
|
|
25166
25611
|
return undefined
|
|
25167
25612
|
})()
|
|
25168
|
-
|
|
25169
|
-
|
|
25613
|
+
const tok = token as string
|
|
25614
|
+
const sendOpts = {
|
|
25615
|
+
...(cbMessageId != null ? { reply_parameters: { message_id: cbMessageId } } : {}),
|
|
25616
|
+
...(cbThreadId != null ? { message_thread_id: cbThreadId } : {}),
|
|
25617
|
+
} as never
|
|
25618
|
+
const sendVerbOpts = {
|
|
25619
|
+
chat_id: cbChatId,
|
|
25620
|
+
verb: 'voice-ondemand.sendVoice',
|
|
25621
|
+
...(cbThreadId != null ? { threadId: cbThreadId } : {}),
|
|
25622
|
+
}
|
|
25623
|
+
|
|
25624
|
+
// #2763 attach-on-tap: prefer the eagerly pre-synthesized file (written by
|
|
25625
|
+
// the pre-synth queue at reply time). If it's on disk, attach it
|
|
25170
25626
|
// immediately — no GPU wait. Missing/unreadable file (expired + swept,
|
|
25171
|
-
// crash, pre-feature entry, kill-switched gateway) falls back
|
|
25172
|
-
//
|
|
25173
|
-
|
|
25174
|
-
|
|
25175
|
-
|
|
25176
|
-
|
|
25177
|
-
|
|
25178
|
-
|
|
25627
|
+
// crash, pre-feature entry, kill-switched gateway) falls back transparently
|
|
25628
|
+
// to the lazy synth path. Only invoked when there's no reusable file_id (or
|
|
25629
|
+
// a stored id was rejected as stale) — see sendVoiceReusingFileId.
|
|
25630
|
+
const loadAudio = async (): Promise<Uint8Array | null> => {
|
|
25631
|
+
let audio: Uint8Array | null = null
|
|
25632
|
+
if (entry.filePath != null) {
|
|
25633
|
+
try {
|
|
25634
|
+
audio = readFileSync(entry.filePath)
|
|
25635
|
+
} catch {
|
|
25636
|
+
audio = null // swept/missing — lazy fallback
|
|
25637
|
+
}
|
|
25638
|
+
}
|
|
25639
|
+
if (audio != null) {
|
|
25640
|
+
await ctx.answerCallbackQuery({ text: '🔊' }).catch(() => {})
|
|
25641
|
+
return audio
|
|
25179
25642
|
}
|
|
25180
|
-
}
|
|
25181
|
-
if (audio != null) {
|
|
25182
|
-
await ctx.answerCallbackQuery({ text: '🔊' }).catch(() => {})
|
|
25183
|
-
} else {
|
|
25184
25643
|
await ctx.answerCallbackQuery({ text: '🔊 Synthesizing…' }).catch(() => {})
|
|
25185
25644
|
// Local sidecar (kokoro) synthesis — same helper the immediate voice-out
|
|
25186
25645
|
// path uses. On-demand is a local-engine feature; the cache is only
|
|
@@ -25190,7 +25649,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
25190
25649
|
await ctx
|
|
25191
25650
|
.answerCallbackQuery({ text: 'Voice sidecar unavailable — try again later.' })
|
|
25192
25651
|
.catch(() => {})
|
|
25193
|
-
return
|
|
25652
|
+
return null
|
|
25194
25653
|
}
|
|
25195
25654
|
const result = await synthesizeViaSidecar({
|
|
25196
25655
|
token: sidecarToken,
|
|
@@ -25209,33 +25668,52 @@ bot.on('callback_query:data', async ctx => {
|
|
|
25209
25668
|
await ctx
|
|
25210
25669
|
.answerCallbackQuery({ text: `Voice failed: ${result.reason}` })
|
|
25211
25670
|
.catch(() => {})
|
|
25212
|
-
return
|
|
25671
|
+
return null
|
|
25672
|
+
}
|
|
25673
|
+
return result.audio
|
|
25674
|
+
}
|
|
25675
|
+
|
|
25676
|
+
// Fast path: if we already captured a reusable file_id, ack instantly and
|
|
25677
|
+
// send BY the id — no disk read, no re-upload. First tap (no id yet) and a
|
|
25678
|
+
// stale-id fallback both go through loadAudio + InputFile below and refresh
|
|
25679
|
+
// the stored id from the returned message.
|
|
25680
|
+
if (entry.telegramFileId != null) {
|
|
25681
|
+
await ctx.answerCallbackQuery({ text: '🔊' }).catch(() => {})
|
|
25682
|
+
}
|
|
25683
|
+
const sendResult = await sendVoiceReusingFileId({
|
|
25684
|
+
fileId: entry.telegramFileId ?? null,
|
|
25685
|
+
sendByFileId: (fid) =>
|
|
25686
|
+
robustApiCall(
|
|
25687
|
+
// allow-raw-bot-api: reuse Telegram's file_id — no re-upload.
|
|
25688
|
+
() => bot.api.sendVoice(cbChatId, fid, sendOpts),
|
|
25689
|
+
sendVerbOpts,
|
|
25690
|
+
),
|
|
25691
|
+
loadAudio,
|
|
25692
|
+
sendByUpload: (audioOut) =>
|
|
25693
|
+
robustApiCall(
|
|
25694
|
+
// allow-raw-bot-api: single native voice-note send for an on-demand Listen tap.
|
|
25695
|
+
() => bot.api.sendVoice(cbChatId, new InputFile(Buffer.from(audioOut)), sendOpts),
|
|
25696
|
+
sendVerbOpts,
|
|
25697
|
+
),
|
|
25698
|
+
onFileId: (fid) => voiceOnDemandCache.setTelegramFileId(tok, fid),
|
|
25699
|
+
log: (line) => process.stderr.write(line),
|
|
25700
|
+
})
|
|
25701
|
+
|
|
25702
|
+
if (!sendResult.ok) {
|
|
25703
|
+
// no-audio already surfaced a toast inside loadAudio; send-failed is
|
|
25704
|
+
// logged non-fatally — the '🔊 Listen' keyboard is left intact so the
|
|
25705
|
+
// user can retry (only a successful send strips it below).
|
|
25706
|
+
if (sendResult.reason === 'send-failed') {
|
|
25707
|
+
const err = sendResult.error
|
|
25708
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
25709
|
+
process.stderr.write(
|
|
25710
|
+
`telegram gateway: voice-out on-demand: sendVoice failed (non-fatal): ${msg}\n`,
|
|
25711
|
+
)
|
|
25213
25712
|
}
|
|
25214
|
-
|
|
25713
|
+
return
|
|
25215
25714
|
}
|
|
25216
|
-
|
|
25217
|
-
// narrow a captured `let` inside an arrow function).
|
|
25218
|
-
const audioOut: Uint8Array = audio
|
|
25715
|
+
|
|
25219
25716
|
try {
|
|
25220
|
-
// Native voice note (NOT a document), quote-replying the button's
|
|
25221
|
-
// message.
|
|
25222
|
-
await robustApiCall(
|
|
25223
|
-
() =>
|
|
25224
|
-
bot.api.sendVoice(
|
|
25225
|
-
cbChatId,
|
|
25226
|
-
// allow-raw-bot-api: single native voice-note send for an on-demand Listen tap.
|
|
25227
|
-
new InputFile(Buffer.from(audioOut)),
|
|
25228
|
-
{
|
|
25229
|
-
...(cbMessageId != null ? { reply_parameters: { message_id: cbMessageId } } : {}),
|
|
25230
|
-
...(cbThreadId != null ? { message_thread_id: cbThreadId } : {}),
|
|
25231
|
-
} as never,
|
|
25232
|
-
),
|
|
25233
|
-
{
|
|
25234
|
-
chat_id: cbChatId,
|
|
25235
|
-
verb: 'voice-ondemand.sendVoice',
|
|
25236
|
-
...(cbThreadId != null ? { threadId: cbThreadId } : {}),
|
|
25237
|
-
},
|
|
25238
|
-
)
|
|
25239
25717
|
// Single-use on SUCCESS: strip the '🔊 Listen' keyboard so the button
|
|
25240
25718
|
// can't be re-tapped now that the audio has been delivered. Mirrors the
|
|
25241
25719
|
// agent-button single_use strip (keyboardIsSingleUse) house style.
|
|
@@ -25259,7 +25737,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
25259
25737
|
} catch (err) {
|
|
25260
25738
|
const msg = err instanceof Error ? err.message : String(err)
|
|
25261
25739
|
process.stderr.write(
|
|
25262
|
-
`telegram gateway: voice-out on-demand:
|
|
25740
|
+
`telegram gateway: voice-out on-demand: strip-listen-keyboard failed (non-fatal): ${msg}\n`,
|
|
25263
25741
|
)
|
|
25264
25742
|
}
|
|
25265
25743
|
return
|
|
@@ -27809,6 +28287,7 @@ void (async () => {
|
|
|
27809
28287
|
dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
|
|
27810
28288
|
configSnapshotPath: join(resolvedAgentDirForBootCard, '.config-snapshot.json'),
|
|
27811
28289
|
bootCardStatePath: join(resolvedAgentDirForBootCard, '.boot-card-msgid.json'),
|
|
28290
|
+
floodStatePath: FLOOD_STATE_PATH,
|
|
27812
28291
|
...(updateOutcomeLine ? { updateOutcomeLine } : {}),
|
|
27813
28292
|
}, ackMsgId)
|
|
27814
28293
|
activeBootCard = handle
|