switchroom 0.18.6 β 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/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +1 -0
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +1172 -812
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +1 -0
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +146 -50
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2965 -862
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1618 -198
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +227 -54
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- 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/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/operator-events.ts +24 -0
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/subagent-watcher.ts +222 -37
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -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/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +203 -43
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/operator-events.test.ts +1 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/permission-diff.test.ts +111 -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/resume-inbound-builder.test.ts +286 -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/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- 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/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- 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
- package/telegram-plugin/worker-activity-feed.ts +9 -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,
|
|
@@ -126,6 +128,14 @@ import {
|
|
|
126
128
|
} from './permission-timeout.js'
|
|
127
129
|
import { renderVaultRequestAccessCard } from './vault-request-access-card.js'
|
|
128
130
|
import { createPermissionCardStore, type PersistedPermCard } from './permission-card-store.js'
|
|
131
|
+
import { createPendingCardStore, type PersistedApprovalCard } from './pending-card-store.js'
|
|
132
|
+
import {
|
|
133
|
+
buildVaultAccessTimeoutInbound,
|
|
134
|
+
buildVaultSaveTimeoutInbound,
|
|
135
|
+
buildSecretRequestTimeoutInbound,
|
|
136
|
+
buildMentalModelProposeTimeoutInbound,
|
|
137
|
+
} from './approval-timeout-inbound-builders.js'
|
|
138
|
+
import { expirePendingCard, sweepExpiredEntries } from './pending-card-expiry.js'
|
|
129
139
|
import {
|
|
130
140
|
isPermissionRearmEnabled,
|
|
131
141
|
permissionRearmGraceMs,
|
|
@@ -134,6 +144,11 @@ import {
|
|
|
134
144
|
distinctRequestIds,
|
|
135
145
|
} from './permission-rearm.js'
|
|
136
146
|
import { createMissedApprovalsStore, type MissedApproval } from './missed-approvals-store.js'
|
|
147
|
+
import {
|
|
148
|
+
createAlwaysAllowPersistQueue,
|
|
149
|
+
drainAlwaysAllowPersistQueue,
|
|
150
|
+
type AlwaysAllowDrainDeps,
|
|
151
|
+
} from './always-allow-persist-queue.js'
|
|
137
152
|
import {
|
|
138
153
|
renderMissedApprovalsDigest,
|
|
139
154
|
missedApprovalsKeyboard,
|
|
@@ -143,6 +158,8 @@ import {
|
|
|
143
158
|
import { pickRecoveredPermissionOrigin } from './permission-card-origin.js'
|
|
144
159
|
import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
|
|
145
160
|
import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, formatStepSuffix, type SessionActivityHeader } from '../tool-activity-summary.js'
|
|
161
|
+
import { formatModelLabel } from '../model-label.js'
|
|
162
|
+
import { createSessionModelSource } from './session-model-source.js'
|
|
146
163
|
import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
|
|
147
164
|
import { REPLY_TOOLS, isDraftOfReply } from '../narrative-dedup.js'
|
|
148
165
|
import { toolLabel } from '../tool-labels.js'
|
|
@@ -160,6 +177,7 @@ import {
|
|
|
160
177
|
isMessageTooLongError,
|
|
161
178
|
} from '../retry-api-call.js'
|
|
162
179
|
import { installTgPostLogger, withTgPostTags } from '../shared/bot-runtime.js'
|
|
180
|
+
import { floodStatePath, makeFloodWaitRecorder } from '../flood-circuit-breaker.js'
|
|
163
181
|
import { buildAttachmentPath, assertInsideInbox } from '../attachment-path.js'
|
|
164
182
|
import { logStreamingEvent } from '../streaming-metrics.js'
|
|
165
183
|
import * as signalTracker from '../turn-signal-tracker.js'
|
|
@@ -360,10 +378,22 @@ import {
|
|
|
360
378
|
MODEL_CALLBACK_PAGE_EXTERNAL,
|
|
361
379
|
MODEL_CALLBACK_PAGE_MAIN,
|
|
362
380
|
srFriendlyLabel,
|
|
381
|
+
expandSrAlias,
|
|
382
|
+
isSrModel,
|
|
363
383
|
type ModelMenuDeps,
|
|
364
384
|
type ModelCommandDeps,
|
|
365
385
|
type ModelMenuReply,
|
|
366
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'
|
|
367
397
|
import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
|
|
368
398
|
import { resolveMainModel } from '../../src/agents/scaffold.js'
|
|
369
399
|
import {
|
|
@@ -396,7 +426,7 @@ import {
|
|
|
396
426
|
_resetHostdEnabledCache,
|
|
397
427
|
} from './hostd-dispatch.js'
|
|
398
428
|
import { formatUpdateStatusLine } from './update-status-line.js'
|
|
399
|
-
import type { HostdRequest } from '../../src/host-control/protocol.js'
|
|
429
|
+
import type { HostdRequest, HostdResponse } from '../../src/host-control/protocol.js'
|
|
400
430
|
import type { AgentAudit } from '../welcome-text.js'
|
|
401
431
|
import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
|
|
402
432
|
import { startWebhookIngestServer } from './webhook-ingest-server.js'
|
|
@@ -432,6 +462,10 @@ import {
|
|
|
432
462
|
restartOrphanCardFinalizeText,
|
|
433
463
|
type ActivityCardStoreFsSeam,
|
|
434
464
|
} from './activity-card-store.js'
|
|
465
|
+
import {
|
|
466
|
+
decideWorkerPinReaps,
|
|
467
|
+
WORKER_PIN_TTL_MS_DEFAULT,
|
|
468
|
+
} from './worker-pin-reaper.js'
|
|
435
469
|
import { driveEscalation } from './escalation-drive.js'
|
|
436
470
|
import { shouldSuppressRepresent } from './represent-guard.js'
|
|
437
471
|
import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
|
|
@@ -530,6 +564,7 @@ import type {
|
|
|
530
564
|
InjectInboundMessage,
|
|
531
565
|
SendOutboundMessage,
|
|
532
566
|
QuotaWallDetectedMessage,
|
|
567
|
+
QueryPendingPermissionMessage,
|
|
533
568
|
PostSkillProposalMessage,
|
|
534
569
|
PermissionEvent,
|
|
535
570
|
RolloutStatusPostMessage,
|
|
@@ -561,6 +596,7 @@ import {
|
|
|
561
596
|
clearCleanShutdownMarker,
|
|
562
597
|
shouldSuppressRecoveryBanner,
|
|
563
598
|
shouldSuppressBootResume,
|
|
599
|
+
parseBootResumeMode,
|
|
564
600
|
resolveShutdownMarker,
|
|
565
601
|
DEFAULT_MAX_AGE_MS as CLEAN_SHUTDOWN_MAX_AGE_MS,
|
|
566
602
|
} from './clean-shutdown-marker.js'
|
|
@@ -677,9 +713,11 @@ import {
|
|
|
677
713
|
import {
|
|
678
714
|
buildResumeInterruptedInbound,
|
|
679
715
|
buildResumeWatchdogReportInbound,
|
|
680
|
-
|
|
716
|
+
buildResumeDeferredReportInbound,
|
|
717
|
+
decideBootResumeKind,
|
|
681
718
|
} from './resume-inbound-builder.js'
|
|
682
|
-
import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey } from '../registry/subagents-schema.js'
|
|
719
|
+
import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey, listNonTerminalSubagentsForTurn } from '../registry/subagents-schema.js'
|
|
720
|
+
import type { InterruptedSubagent } from './resume-inbound-builder.js'
|
|
683
721
|
import { resolveWorkerFeedDispatch, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
|
|
684
722
|
import {
|
|
685
723
|
resolveSubagentStatusSurface,
|
|
@@ -717,12 +755,127 @@ process.on('beforeExit', () => {
|
|
|
717
755
|
// βββ Env + state dir ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
718
756
|
const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
|
|
719
757
|
const permCardStore = createPermissionCardStore(STATE_DIR)
|
|
758
|
+
// Durable store for the four AGENT-INITIATED approval-card families
|
|
759
|
+
// (vault_request_access / vault_request_save / request_secret /
|
|
760
|
+
// mental_model_propose). Persists card METADATA only β never any secret value
|
|
761
|
+
// (see pending-card-store.ts secrets-hygiene note). Restored at boot so a
|
|
762
|
+
// post-restart tap on a still-valid card works like a pre-restart tap, and
|
|
763
|
+
// swept in the pendingStateReaper so an unanswered card wakes the parked agent
|
|
764
|
+
// on TTL instead of leaving it parked forever.
|
|
765
|
+
const pendingCardStore = createPendingCardStore(STATE_DIR)
|
|
720
766
|
// #2862 β missed-approvals re-offer. Persisted list of approvals that
|
|
721
767
|
// TTL-expired while the operator was away; a digest card is posted on the
|
|
722
768
|
// operator's next activity. Kill switch: SWITCHROOM_MISSED_APPROVAL_REOFFER=0.
|
|
723
769
|
const missedApprovalsStore = createMissedApprovalsStore(STATE_DIR)
|
|
724
770
|
const MISSED_APPROVAL_REOFFER_ENABLED =
|
|
725
771
|
process.env.SWITCHROOM_MISSED_APPROVAL_REOFFER !== '0'
|
|
772
|
+
// #2973 pt.2 β durable retry queue for "π Always allow" persists that
|
|
773
|
+
// fail for a retryable reason (stale config view, rate limit, transient
|
|
774
|
+
// hostd error). Drained at boot (below) and on a periodic timer so a
|
|
775
|
+
// gateway restart mid-persist never loses the retry.
|
|
776
|
+
const alwaysAllowPersistQueue = createAlwaysAllowPersistQueue(STATE_DIR)
|
|
777
|
+
|
|
778
|
+
/** Read a hostd `error_envelope`'s structured `retry_after` fix (if
|
|
779
|
+
* present) as a millisecond delay from now. Returns undefined for any
|
|
780
|
+
* other fix kind / missing envelope / unparsable timestamp β callers
|
|
781
|
+
* fall back to plain exponential backoff in that case. */
|
|
782
|
+
function extractRetryAfterMs(resp: HostdResponse): number | undefined {
|
|
783
|
+
const fix = resp.error_envelope?.fix
|
|
784
|
+
if (fix == null || fix.kind !== 'retry_after') return undefined
|
|
785
|
+
const at = Date.parse(fix.retry_at)
|
|
786
|
+
if (Number.isNaN(at)) return undefined
|
|
787
|
+
return Math.max(0, at - Date.now())
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Build a FRESH dependency set for `drainAlwaysAllowPersistQueue` β a new
|
|
792
|
+
* factory call per drain pass, not a cached singleton, so every attempt
|
|
793
|
+
* re-reads config from disk (the #2973 stale-container-side-view failure
|
|
794
|
+
* class is fixed by never reusing a snapshot across retries).
|
|
795
|
+
*/
|
|
796
|
+
function alwaysAllowDrainDeps(): AlwaysAllowDrainDeps {
|
|
797
|
+
return {
|
|
798
|
+
readConfigText: () => {
|
|
799
|
+
const cfgPath = process.env.SWITCHROOM_CONFIG ?? SWITCHROOM_CONFIG ?? findSwitchroomConfigFile()
|
|
800
|
+
return readFileSync(cfgPath, 'utf8')
|
|
801
|
+
},
|
|
802
|
+
resolveAllowList: (_configText, agentName) => {
|
|
803
|
+
const cfg = loadSwitchroomConfig()
|
|
804
|
+
const rawAgent = cfg.agents?.[agentName]
|
|
805
|
+
if (!rawAgent) return []
|
|
806
|
+
const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
|
|
807
|
+
return (resolved as { tools?: { allow?: string[] } }).tools?.allow ?? []
|
|
808
|
+
},
|
|
809
|
+
isRulePersisted,
|
|
810
|
+
synthesizeDiff: (agentName, rule, configText) =>
|
|
811
|
+
synthesizeAllowRuleDiff({ agentName, rule, configText }),
|
|
812
|
+
dispatchConfigEdit: async (entry, unifiedDiff) => {
|
|
813
|
+
const req: HostdRequest = {
|
|
814
|
+
v: 1,
|
|
815
|
+
op: 'config_propose_edit',
|
|
816
|
+
request_id: hostdRequestId('gw-always-allow-retry'),
|
|
817
|
+
args: {
|
|
818
|
+
unified_diff: unifiedDiff,
|
|
819
|
+
reason: `Operator 'always allow' retry: ${entry.agentName} can ${entry.grantPhrase}`,
|
|
820
|
+
target_path: '/state/config/switchroom.yaml',
|
|
821
|
+
},
|
|
822
|
+
}
|
|
823
|
+
const resp = await tryHostdDispatch(entry.agentName, req, 720_000)
|
|
824
|
+
if (resp === 'not-configured') {
|
|
825
|
+
return { ok: false as const, error: 'hostd not-configured (retry queue requires host_control.enabled)' }
|
|
826
|
+
}
|
|
827
|
+
if (resp.result === 'completed') return { ok: true as const }
|
|
828
|
+
return {
|
|
829
|
+
ok: false as const,
|
|
830
|
+
error: resp.error ?? `hostd ${resp.result}`,
|
|
831
|
+
retryAfterMs: extractRetryAfterMs(resp),
|
|
832
|
+
}
|
|
833
|
+
},
|
|
834
|
+
// #2973 pt.3 β loud terminal failure. A card edit doesn't ping the
|
|
835
|
+
// operator (the original permission card was already edited to the
|
|
836
|
+
// "saving durably in backgroundβ¦" interim state); this posts a NEW
|
|
837
|
+
// message instead, via the same operator-event broadcast every other
|
|
838
|
+
// fleet alert uses.
|
|
839
|
+
notifyTerminalFailure: (entry, reason) => {
|
|
840
|
+
emitGatewayOperatorEvent({
|
|
841
|
+
kind: 'always-allow-persist-failed',
|
|
842
|
+
agent: entry.agentName,
|
|
843
|
+
detail: `"${entry.grantPhrase}" (rule \`${entry.rule}\`) β ${reason}`,
|
|
844
|
+
suggestedActions: [],
|
|
845
|
+
firstSeenAt: new Date(),
|
|
846
|
+
})
|
|
847
|
+
},
|
|
848
|
+
log: (line) => process.stderr.write(line),
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/** ~10 min between periodic drain passes β frequent enough that a
|
|
853
|
+
* retryable failure (rate limit, transient hostd hiccup) resolves within
|
|
854
|
+
* a reasonable window, infrequent enough to never look like polling. */
|
|
855
|
+
const ALWAYS_ALLOW_DRAIN_INTERVAL_MS = 10 * 60_000
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Boot drain (picks up anything a prior gateway process queued right
|
|
859
|
+
* before a restart β success criterion: "restarting the gateway mid-
|
|
860
|
+
* persist does not lose the queued entry") + a periodic timer thereafter.
|
|
861
|
+
* Called once at gateway startup. Every pass is independently
|
|
862
|
+
* fault-tolerant β `drainAlwaysAllowPersistQueue` never throws.
|
|
863
|
+
*/
|
|
864
|
+
function scheduleAlwaysAllowPersistDrain(): void {
|
|
865
|
+
void drainAlwaysAllowPersistQueue(alwaysAllowPersistQueue, alwaysAllowDrainDeps()).catch((err) => {
|
|
866
|
+
process.stderr.write(
|
|
867
|
+
`telegram gateway: always-allow-persist-queue boot drain failed: ${(err as Error).message}\n`,
|
|
868
|
+
)
|
|
869
|
+
})
|
|
870
|
+
const timer = setInterval(() => {
|
|
871
|
+
void drainAlwaysAllowPersistQueue(alwaysAllowPersistQueue, alwaysAllowDrainDeps()).catch((err) => {
|
|
872
|
+
process.stderr.write(
|
|
873
|
+
`telegram gateway: always-allow-persist-queue periodic drain failed: ${(err as Error).message}\n`,
|
|
874
|
+
)
|
|
875
|
+
})
|
|
876
|
+
}, ALWAYS_ALLOW_DRAIN_INTERVAL_MS)
|
|
877
|
+
timer.unref?.()
|
|
878
|
+
}
|
|
726
879
|
const ACCESS_FILE = join(STATE_DIR, 'access.json')
|
|
727
880
|
const APPROVED_DIR = join(STATE_DIR, 'approved')
|
|
728
881
|
const ENV_FILE = join(STATE_DIR, '.env')
|
|
@@ -774,6 +927,16 @@ function triggerSelfRestart(
|
|
|
774
927
|
)
|
|
775
928
|
return false
|
|
776
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
|
+
}
|
|
777
940
|
process.stderr.write(
|
|
778
941
|
`telegram gateway: restart-via-SIGTERM-PID1 agent=${targetAgent} reason=${reason} (docker)\n`,
|
|
779
942
|
)
|
|
@@ -785,6 +948,10 @@ function triggerSelfRestart(
|
|
|
785
948
|
return true
|
|
786
949
|
}
|
|
787
950
|
// Legacy systemd path.
|
|
951
|
+
if (targetAgent === selfAgent) {
|
|
952
|
+
const smDir = resolveAgentDirFromEnv()
|
|
953
|
+
if (smDir) writeRelaunchModelIntent(smDir, intentForRestartReason(reason), reason)
|
|
954
|
+
}
|
|
788
955
|
process.stderr.write(
|
|
789
956
|
`telegram gateway: restart-via-systemctl agent=${targetAgent} reason=${reason}\n`,
|
|
790
957
|
)
|
|
@@ -1383,70 +1550,123 @@ try {
|
|
|
1383
1550
|
const pending = findLatestTurnIfInterrupted(turnsDb)
|
|
1384
1551
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
1385
1552
|
if (pending != null && selfAgent) {
|
|
1386
|
-
//
|
|
1387
|
-
//
|
|
1388
|
-
//
|
|
1389
|
-
//
|
|
1390
|
-
//
|
|
1391
|
-
//
|
|
1553
|
+
// Boot-resume policy (2026-07, superseding #2585). The block only runs
|
|
1554
|
+
// when `pending` exists β i.e. there IS genuinely in-flight work. The
|
|
1555
|
+
// product decision is that a DELIBERATE restart must not silently drop
|
|
1556
|
+
// that work: `session_continuity.boot_resume` (SWITCHROOM_BOOT_RESUME,
|
|
1557
|
+
// default 'in-flight') resumes it even after a clean shutdown. #2585's
|
|
1558
|
+
// quota-saving posture survives only as the opt-in 'never' mode β and
|
|
1559
|
+
// even then we deliver a passive REPORT, never silence, because the user
|
|
1560
|
+
// must always be told when in-flight work stopped.
|
|
1392
1561
|
//
|
|
1393
1562
|
// NOTE: GATEWAY_CLEAN_SHUTDOWN_MARKER_PATH is defined lower in this file
|
|
1394
1563
|
// (module-init order); we compute the path inline here using the same
|
|
1395
1564
|
// formula so we can read it at boot-resume time.
|
|
1396
|
-
// SWITCHROOM_BOOT_RESUME_ALWAYS=1
|
|
1397
|
-
// unconditional resume
|
|
1565
|
+
// SWITCHROOM_BOOT_RESUME_ALWAYS=1 remains a back-compat escape hatch that
|
|
1566
|
+
// forces unconditional resume regardless of mode.
|
|
1398
1567
|
const bootResumeMarkerPath =
|
|
1399
1568
|
process.env.SWITCHROOM_GATEWAY_CLEAN_SHUTDOWN_MARKER ?? join(STATE_DIR, 'clean-shutdown.json')
|
|
1400
1569
|
const bootResumeCleanMarker = readCleanShutdownMarker(bootResumeMarkerPath)
|
|
1401
1570
|
const bootResumeForceAlways = process.env.SWITCHROOM_BOOT_RESUME_ALWAYS === '1'
|
|
1571
|
+
const bootResumeMode = parseBootResumeMode(process.env.SWITCHROOM_BOOT_RESUME)
|
|
1402
1572
|
const bootResumeSuppressed = shouldSuppressBootResume(bootResumeCleanMarker, Date.now(), {
|
|
1403
1573
|
forceAlways: bootResumeForceAlways,
|
|
1574
|
+
mode: bootResumeMode,
|
|
1404
1575
|
})
|
|
1405
|
-
|
|
1576
|
+
|
|
1577
|
+
// 3h staleness failsafe (operator spec, 2026-06-03): never AUTO-resume
|
|
1578
|
+
// interrupted work older than RESUME_MAX_AGE_MS β selectResumeBuilder
|
|
1579
|
+
// downgrades a stale 'resume' to the passive 'report'. Env override
|
|
1580
|
+
// SWITCHROOM_RESUME_MAX_AGE_MS (ms); set very high to disable.
|
|
1581
|
+
const RESUME_MAX_AGE_MS = (() => {
|
|
1582
|
+
const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS)
|
|
1583
|
+
return Number.isFinite(v) && v > 0 ? v : 10_800_000 // 3h
|
|
1584
|
+
})()
|
|
1585
|
+
|
|
1586
|
+
// Decide the inbound kind (pure β see decideBootResumeKind). Precedence:
|
|
1587
|
+
// 1. loop-guard β 'defer-loop' (never a second resume of a resume)
|
|
1588
|
+
// 2. suppressed β 'defer-suppressed' (boot_resume: never; notice, not silence)
|
|
1589
|
+
// 3. otherwise β selectResumeBuilder (resume | report | none)
|
|
1590
|
+
const bootResumeKind = decideBootResumeKind({
|
|
1591
|
+
pending,
|
|
1592
|
+
suppressed: bootResumeSuppressed,
|
|
1593
|
+
ageMs: Math.max(0, Date.now() - pending.started_at),
|
|
1594
|
+
maxAgeMs: RESUME_MAX_AGE_MS,
|
|
1595
|
+
})
|
|
1596
|
+
|
|
1597
|
+
// Sub-agents that were still in flight (running / stalled β non-terminal)
|
|
1598
|
+
// when the turn was killed. Read HERE, at module top, BEFORE the
|
|
1599
|
+
// subagent-watcher's boot scan + reaper run: the watcher never deletes
|
|
1600
|
+
// these rows (it only flips runningβstalled and marks files historical
|
|
1601
|
+
// in-memory), and this accessor already includes 'stalled', so the data
|
|
1602
|
+
// survives either ordering β but reading pre-watcher keeps it simplest.
|
|
1603
|
+
// Threaded into ALL the builders below (resume, watchdog report, and the
|
|
1604
|
+
// deferred report) so the session gets an explicit killed-workers block β
|
|
1605
|
+
// it must not declare the task done on ghost workers, and even a
|
|
1606
|
+
// suppressed/loop-guarded resume must still NAME the deaths.
|
|
1607
|
+
let interruptedSubagents: InterruptedSubagent[] = []
|
|
1608
|
+
try {
|
|
1609
|
+
interruptedSubagents = listNonTerminalSubagentsForTurn(turnsDb, pending.turn_key).map(
|
|
1610
|
+
(s) => ({ agentType: s.agent_type, description: s.description, status: s.status }),
|
|
1611
|
+
)
|
|
1612
|
+
} catch (err) {
|
|
1406
1613
|
process.stderr.write(
|
|
1407
|
-
`telegram gateway: boot-resume
|
|
1408
|
-
`${bootResumeCleanMarker?.reason ? ` reason=${JSON.stringify(bootResumeCleanMarker.reason)}` : ''}` +
|
|
1409
|
-
`) β unclean exits still resume turnKey=${pending.turn_key}\n`,
|
|
1614
|
+
`telegram gateway: boot-resume subagent lookup failed (${(err as Error).message}) β continuing without worker list\n`,
|
|
1410
1615
|
)
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
const RESUME_MAX_AGE_MS = (() => {
|
|
1418
|
-
const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS)
|
|
1419
|
-
return Number.isFinite(v) && v > 0 ? v : 10_800_000 // 3h
|
|
1420
|
-
})()
|
|
1421
|
-
const kind = selectResumeBuilder(pending.ended_via, {
|
|
1422
|
-
ageMs: Math.max(0, Date.now() - pending.started_at),
|
|
1423
|
-
maxAgeMs: RESUME_MAX_AGE_MS,
|
|
1424
|
-
})
|
|
1425
|
-
if (kind === 'resume') {
|
|
1426
|
-
bootResumeInbound = { agent: selfAgent, msg: buildResumeInterruptedInbound({ turn: pending }) }
|
|
1427
|
-
} else if (kind === 'report') {
|
|
1428
|
-
// idleMs: this boot's measured marker age if it just classified this
|
|
1429
|
-
// turn; otherwise recover it from the persisted interrupt_reason (a
|
|
1430
|
-
// later boot, marker already swept); else fall back to total runtime.
|
|
1431
|
-
let idleMs = pending.turn_key === timeoutTurnKey && markerAgeMs != null ? markerAgeMs : null
|
|
1432
|
-
if (idleMs == null && pending.interrupt_reason) {
|
|
1433
|
-
try {
|
|
1434
|
-
const parsed = JSON.parse(pending.interrupt_reason) as { idleMs?: unknown }
|
|
1435
|
-
if (typeof parsed.idleMs === 'number' && Number.isFinite(parsed.idleMs)) idleMs = parsed.idleMs
|
|
1436
|
-
} catch { /* malformed snapshot β fall through */ }
|
|
1437
|
-
}
|
|
1438
|
-
if (idleMs == null) idleMs = Math.max(0, Date.now() - pending.started_at)
|
|
1439
|
-
bootResumeInbound = {
|
|
1440
|
-
agent: selfAgent,
|
|
1441
|
-
msg: buildResumeWatchdogReportInbound({ turn: pending, idleMs }),
|
|
1442
|
-
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
if (bootResumeKind === 'resume') {
|
|
1619
|
+
bootResumeInbound = {
|
|
1620
|
+
agent: selfAgent,
|
|
1621
|
+
msg: buildResumeInterruptedInbound({ turn: pending, subagents: interruptedSubagents }),
|
|
1443
1622
|
}
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1623
|
+
} else if (bootResumeKind === 'report') {
|
|
1624
|
+
// idleMs: this boot's measured marker age if it just classified this
|
|
1625
|
+
// turn; otherwise recover it from the persisted interrupt_reason (a
|
|
1626
|
+
// later boot, marker already swept); else fall back to total runtime.
|
|
1627
|
+
let idleMs = pending.turn_key === timeoutTurnKey && markerAgeMs != null ? markerAgeMs : null
|
|
1628
|
+
if (idleMs == null && pending.interrupt_reason) {
|
|
1629
|
+
try {
|
|
1630
|
+
const parsed = JSON.parse(pending.interrupt_reason) as { idleMs?: unknown }
|
|
1631
|
+
if (typeof parsed.idleMs === 'number' && Number.isFinite(parsed.idleMs)) idleMs = parsed.idleMs
|
|
1632
|
+
} catch { /* malformed snapshot β fall through */ }
|
|
1449
1633
|
}
|
|
1634
|
+
if (idleMs == null) idleMs = Math.max(0, Date.now() - pending.started_at)
|
|
1635
|
+
bootResumeInbound = {
|
|
1636
|
+
agent: selfAgent,
|
|
1637
|
+
msg: buildResumeWatchdogReportInbound({ turn: pending, idleMs, subagents: interruptedSubagents }),
|
|
1638
|
+
}
|
|
1639
|
+
} else if (bootResumeKind === 'defer-loop' || bootResumeKind === 'defer-suppressed') {
|
|
1640
|
+
// Passive deferred-report: work was in flight but we decline to
|
|
1641
|
+
// auto-resume (loop-guard, or boot_resume:never). Silence is never
|
|
1642
|
+
// acceptable here β tell the user what was in flight and ask.
|
|
1643
|
+
bootResumeInbound = {
|
|
1644
|
+
agent: selfAgent,
|
|
1645
|
+
msg: buildResumeDeferredReportInbound({
|
|
1646
|
+
turn: pending,
|
|
1647
|
+
reason: bootResumeKind === 'defer-loop' ? 'loop-guard' : 'clean-restart-suppressed',
|
|
1648
|
+
subagents: interruptedSubagents,
|
|
1649
|
+
}),
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
if (bootResumeKind === 'defer-suppressed') {
|
|
1654
|
+
process.stderr.write(
|
|
1655
|
+
`telegram gateway: boot-resume suppressed (clean shutdown` +
|
|
1656
|
+
`${bootResumeCleanMarker?.reason ? ` reason=${JSON.stringify(bootResumeCleanMarker.reason)}` : ''}` +
|
|
1657
|
+
`, mode=${bootResumeMode}) β passive report delivered for turnKey=${pending.turn_key}\n`,
|
|
1658
|
+
)
|
|
1659
|
+
} else if (bootResumeKind === 'defer-loop') {
|
|
1660
|
+
process.stderr.write(
|
|
1661
|
+
`telegram gateway: boot-resume loop-guard tripped (interrupted turn was itself a resume) ` +
|
|
1662
|
+
`β passive report delivered instead of re-resuming turnKey=${pending.turn_key}\n`,
|
|
1663
|
+
)
|
|
1664
|
+
}
|
|
1665
|
+
if (bootResumeInbound != null) {
|
|
1666
|
+
process.stderr.write(
|
|
1667
|
+
`telegram gateway: boot-resume queued kind=${bootResumeKind} mode=${bootResumeMode} ` +
|
|
1668
|
+
`turnKey=${pending.turn_key} endedVia=${pending.ended_via ?? 'open'} chat=${pending.chat_id}\n`,
|
|
1669
|
+
)
|
|
1450
1670
|
}
|
|
1451
1671
|
}
|
|
1452
1672
|
|
|
@@ -1687,7 +1907,22 @@ const activeReactionMsgIds = new Map<string, { chatId: string; messageId: number
|
|
|
1687
1907
|
// by chatKey(chat_id, bufferedThread). Delete-on-answer: never a dangling
|
|
1688
1908
|
// placeholder. Reaped on answer (executeReply/stream), on turn-flush, and
|
|
1689
1909
|
// in purgeReactionTracking cleanup so an abnormal turn-end can't strand it.
|
|
1690
|
-
|
|
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>>()
|
|
1691
1926
|
// Reactions whose terminal π is deferred because a background sub-agent
|
|
1692
1927
|
// worker was still running when the parent's `turn_end` fired. Painting π
|
|
1693
1928
|
// then would read as "done / nothing happening" while the worker keeps
|
|
@@ -2123,6 +2358,16 @@ const TOPIC_FRAMING_ENABLED =
|
|
|
2123
2358
|
// β no placeholder (the π ack reaction still fires). Delete-on-answer.
|
|
2124
2359
|
const QUEUED_STATUS_UX_ENABLED =
|
|
2125
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'
|
|
2126
2371
|
// Feed-reopen-after-ack. When a tool label arrives for a turn already
|
|
2127
2372
|
// marked finalAnswerDelivered, the model is still WORKING β so the earlier
|
|
2128
2373
|
// "final" reply was an interim ACK (an ack-first reply pings or runs β₯200
|
|
@@ -2273,6 +2518,110 @@ function deliverResumeSyntheticOrBuffer(agent: string, inbound: InboundMessage):
|
|
|
2273
2518
|
return delivered
|
|
2274
2519
|
}
|
|
2275
2520
|
|
|
2521
|
+
/** Outcome of routing an agent-authored button tap through the turn-safe
|
|
2522
|
+
* delivery machinery. `buffered-mid-turn` and `delivered` both mean "the tap
|
|
2523
|
+
* will be actioned" (the mid-turn case flushes on turn-complete); only
|
|
2524
|
+
* `buffered-bridge-offline` needs the user-facing "agent is restarting" notice. */
|
|
2525
|
+
type ButtonTapDeliveryOutcome = 'delivered' | 'buffered-mid-turn' | 'buffered-bridge-offline'
|
|
2526
|
+
|
|
2527
|
+
/**
|
|
2528
|
+
* Deliver an agent-authored inline-keyboard button tap (`agent:` callback_data)
|
|
2529
|
+
* through the SAME turn-safe machinery as a normal Telegram inbound, instead of
|
|
2530
|
+
* the old raw `sendToAgent` + buffer-only-on-bridge-miss.
|
|
2531
|
+
*
|
|
2532
|
+
* THE BUG (#271 button path, verified 2026-07): the `agent:` callback handler
|
|
2533
|
+
* delivered the synthesized tap inbound with a bare `ipcServer.sendToAgent`,
|
|
2534
|
+
* marked busy, and buffered ONLY when the bridge was offline. It never ran the
|
|
2535
|
+
* #1556 turn gate β so a tap landing WHILE a turn is in flight fired the MCP
|
|
2536
|
+
* channel notification mid-turn, typed into the CLI composer, and stranded there
|
|
2537
|
+
* (the lawgpt/marko wedge). It also skipped the pre-send composer clear and the
|
|
2538
|
+
* deliver-until-acked tracking, so a stranded tap was never sweep-redelivered.
|
|
2539
|
+
*
|
|
2540
|
+
* Fix: reuse the resume-synthetic turn gate (mid-turn β `buffer-until-idle`, the
|
|
2541
|
+
* turn-complete hook + idle-drain flush it the instant claude goes idle), the
|
|
2542
|
+
* pre-send composer clear, and the delivery-confirm tracking β exactly like the
|
|
2543
|
+
* `handleInbound` fresh-turn path. A button tap carries no `meta.source` and a
|
|
2544
|
+
* non-empty body, so `shouldTrackDelivery` enrols it; we additionally require a
|
|
2545
|
+
* real `meta.message_id` so the `enqueue` ack has something to match (else the
|
|
2546
|
+
* never-drop sweep would storm). The tap UX is unchanged β the ack toast, the
|
|
2547
|
+
* single-use keyboard strip, and the bridge-offline spool + restart notice all
|
|
2548
|
+
* stay at the call site; only delivery timing/safety changes.
|
|
2549
|
+
*/
|
|
2550
|
+
async function deliverButtonTapInbound(
|
|
2551
|
+
agent: string,
|
|
2552
|
+
inbound: InboundMessage,
|
|
2553
|
+
): Promise<ButtonTapDeliveryOutcome> {
|
|
2554
|
+
// #1556 turn gate β same authoritative "is a turn in flight?" read the
|
|
2555
|
+
// resume-synthetic path uses. Mid-turn β hold in the pending-inbound buffer;
|
|
2556
|
+
// the turn-complete hook + idle-drain timer flush it when claude goes idle,
|
|
2557
|
+
// where it lands cleanly as a fresh turn instead of stranding in the composer.
|
|
2558
|
+
const { decision, reserve } = reserveInboundDelivery({
|
|
2559
|
+
turnInFlight: turnInFlightForGate(),
|
|
2560
|
+
isSteering: false,
|
|
2561
|
+
isInterrupt: false,
|
|
2562
|
+
})
|
|
2563
|
+
if (decision === 'buffer-until-idle') {
|
|
2564
|
+
pendingInboundBuffer.push(agent, inbound)
|
|
2565
|
+
return 'buffered-mid-turn'
|
|
2566
|
+
}
|
|
2567
|
+
// #2917 per-chat FIFO: reserve the chat's busy key SYNCHRONOUSLY β before the
|
|
2568
|
+
// composer-clear await below β so a concurrent same-chat inbound reaching the
|
|
2569
|
+
// live gate observes this in-flight delivery and buffers behind it. Released
|
|
2570
|
+
// in lockstep below if the send misses (bridge offline).
|
|
2571
|
+
let reservedBusyKey: string | null = null
|
|
2572
|
+
if (reserve && SERIALIZE_INBOUND_DELIVERY_ENABLED) {
|
|
2573
|
+
reservedBusyKey = markClaudeBusyForInbound(inbound)
|
|
2574
|
+
}
|
|
2575
|
+
// Pre-send composer clear (the marko wedge) β wipe stale typed-ahead / ghost
|
|
2576
|
+
// text so the channel notification lands at a clean line and auto-submits.
|
|
2577
|
+
// Soft-fail by contract: a clear failure must NEVER block delivery.
|
|
2578
|
+
if (agent) {
|
|
2579
|
+
try {
|
|
2580
|
+
const { clearAgentComposer } = await import('../../src/agents/tmux.js')
|
|
2581
|
+
const cleared = clearAgentComposer({ agentName: agent })
|
|
2582
|
+
if ('error' in cleared) {
|
|
2583
|
+
process.stderr.write(
|
|
2584
|
+
`telegram gateway: button-tap pre-send composer-clear soft-failed agent=${agent}: ${cleared.error} β delivering anyway\n`,
|
|
2585
|
+
)
|
|
2586
|
+
}
|
|
2587
|
+
} catch (err) {
|
|
2588
|
+
process.stderr.write(
|
|
2589
|
+
`telegram gateway: button-tap pre-send composer-clear threw agent=${agent}: ${(err as Error).message} β delivering anyway\n`,
|
|
2590
|
+
)
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
const delivered = ipcServer.sendToAgent(agent, inbound)
|
|
2594
|
+
if (delivered) {
|
|
2595
|
+
const busyKey = reservedBusyKey ?? markClaudeBusyForInbound(inbound)
|
|
2596
|
+
// Track until claude acks via `enqueue` so the deliver-until-acked sweep
|
|
2597
|
+
// re-delivers a tap stranded in the composer. Only when we have a real
|
|
2598
|
+
// message_id to match the ack against β otherwise the never-drop loop storms.
|
|
2599
|
+
if (
|
|
2600
|
+
DELIVERY_CONFIRM_ENABLED &&
|
|
2601
|
+
inbound.meta?.message_id != null &&
|
|
2602
|
+
inbound.meta.message_id !== '' &&
|
|
2603
|
+
shouldTrackDelivery({
|
|
2604
|
+
isSteering: false,
|
|
2605
|
+
isInterrupt: false,
|
|
2606
|
+
hasSource: inbound.meta?.source != null,
|
|
2607
|
+
effectiveText: inbound.text,
|
|
2608
|
+
})
|
|
2609
|
+
) {
|
|
2610
|
+
trackDelivery(deliveryQueue, busyKey, inbound, Date.now(), String(inbound.messageId))
|
|
2611
|
+
}
|
|
2612
|
+
return 'delivered'
|
|
2613
|
+
}
|
|
2614
|
+
// Bridge offline: release the synchronous reservation in lockstep (else the
|
|
2615
|
+
// orphaned busy key gates every later inbound into the buffer), then spool the
|
|
2616
|
+
// tap so it replays on reconnect β same behaviour as before this fix.
|
|
2617
|
+
if (reservedBusyKey != null) {
|
|
2618
|
+
claudeBusyKeys.delete(reservedBusyKey)
|
|
2619
|
+
claudeBusyKeySince.delete(reservedBusyKey)
|
|
2620
|
+
}
|
|
2621
|
+
pendingInboundBuffer.push(agent, inbound)
|
|
2622
|
+
return 'buffered-bridge-offline'
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2276
2625
|
const pendingRestarts = new Map<string, number>() // agentName -> timestamp when restart was requested
|
|
2277
2626
|
|
|
2278
2627
|
// βββ Proactive context compaction (session.max_context_tokens) ββββββββββ
|
|
@@ -2502,6 +2851,13 @@ type CurrentTurn = {
|
|
|
2502
2851
|
// resume protocol uses this to decide "did the previous turn actually
|
|
2503
2852
|
// finish a reply, or was it interrupted before commit?".
|
|
2504
2853
|
lastAssistantDone: boolean
|
|
2854
|
+
// Live model in use for THIS turn, sourced from the main transcript's
|
|
2855
|
+
// `message.model` (the exact resolved model per API call) via the session-tail
|
|
2856
|
+
// `model` event β never from config or launch-time state. Updated on change;
|
|
2857
|
+
// undefined until the turn's first assistant line lands. Rendered onto the
|
|
2858
|
+
// activity/liveness card header's metrics line (e.g. "2m Β· 14 tools Β· opus 4.8")
|
|
2859
|
+
// and preferred by /status's buildAgentMetadata over the in-memory override.
|
|
2860
|
+
currentModel?: string
|
|
2505
2861
|
// Phase 1 of #332: count of tool_use events in the current turn, for
|
|
2506
2862
|
// the tool_call_count column in the turns registry.
|
|
2507
2863
|
toolCallCount: number
|
|
@@ -2627,6 +2983,16 @@ type CurrentTurn = {
|
|
|
2627
2983
|
// is never written and this is exactly the old singleton.
|
|
2628
2984
|
let currentTurn: CurrentTurn | null = null
|
|
2629
2985
|
const currentTurnMap = new CurrentTurnMap<CurrentTurn>()
|
|
2986
|
+
// Freshness-aware /status session-model source. Two writers: the session-tail
|
|
2987
|
+
// `model` event (each assistant line's `message.model` β ground truth for the
|
|
2988
|
+
// last API call, survives between turns) and the #2982 /model override (set
|
|
2989
|
+
// the instant a switch is confirmed β the ONLY truthful source in the
|
|
2990
|
+
// idle-after-switch window, before the next assistant line lands). Every write
|
|
2991
|
+
// is seq-stamped and `resolve()` prefers the NEWER observation, so neither
|
|
2992
|
+
// source can go stale behind the other (session-model-source.ts, pinned by
|
|
2993
|
+
// tests/session-model-source.test.ts). buildAgentMetadata reads resolve();
|
|
2994
|
+
// the /model command paths write via setOverride.
|
|
2995
|
+
const sessionModelSource = createSessionModelSource()
|
|
2630
2996
|
// Captures the most-recently-started turn's sessionChatId. Unlike currentTurn,
|
|
2631
2997
|
// this is NOT cleared by the silence poke (firePoke/clearTurnStarted). It lets
|
|
2632
2998
|
// the Bug B fallback in executeReply route to the correct chat even when the
|
|
@@ -3164,7 +3530,9 @@ function postQueuedStatus(chatId: string, bufferedThread: number, inFlightThread
|
|
|
3164
3530
|
*/
|
|
3165
3531
|
function promoteQueuedStatus(chatId: string, thread: number | undefined): void {
|
|
3166
3532
|
if (!QUEUED_STATUS_UX_ENABLED) return
|
|
3167
|
-
|
|
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).
|
|
3168
3536
|
const key = statusKey(chatId, thread)
|
|
3169
3537
|
const entry = queuedStatusMsgIds.get(key)
|
|
3170
3538
|
if (entry == null) return
|
|
@@ -3175,7 +3543,7 @@ function promoteQueuedStatus(chatId: string, thread: number | undefined): void {
|
|
|
3175
3543
|
void swallowingApiCall(
|
|
3176
3544
|
() =>
|
|
3177
3545
|
bot.api.editMessageText(chatId, entry.messageId, 'βοΈ On it β replying now.', {}),
|
|
3178
|
-
{ chat_id: chatId, verb: 'queued-status.promote', threadId: thread },
|
|
3546
|
+
{ chat_id: chatId, verb: 'queued-status.promote', ...(thread != null ? { threadId: thread } : {}) },
|
|
3179
3547
|
)
|
|
3180
3548
|
}
|
|
3181
3549
|
|
|
@@ -3195,6 +3563,115 @@ function reapQueuedStatus(chatId: string, thread: number | undefined): void {
|
|
|
3195
3563
|
)
|
|
3196
3564
|
}
|
|
3197
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
|
+
|
|
3198
3675
|
// Problem B β deferred safe-boundary interrupt.
|
|
3199
3676
|
//
|
|
3200
3677
|
// `toolFlightTracker` mirrors the session-event stream to know whether a
|
|
@@ -3528,6 +4005,17 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
|
|
|
3528
4005
|
const pqThread = pqThreadPart === '_' || pqThreadPart === '' ? null : Number(pqThreadPart)
|
|
3529
4006
|
reapQueuedStatus(pqChatId, Number.isFinite(pqThread) ? (pqThread as number) : undefined)
|
|
3530
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
|
+
}
|
|
3531
4019
|
// PR3b: clear the parallel-turns fleet-gate entry. Symmetric with
|
|
3532
4020
|
// the markClaudeBusyForInbound on the delivery path. Safe no-op
|
|
3533
4021
|
// when the key was never marked (synthetic purge from a sweep).
|
|
@@ -4579,8 +5067,16 @@ const typingWrapper = createTypingWrapper({
|
|
|
4579
5067
|
// βββ Robust API call wrapper ββββββββββββββββββββββββββββββββββββββββββββββ
|
|
4580
5068
|
// Extracted to telegram-plugin/retry-api-call.ts so it's unit-testable in
|
|
4581
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)
|
|
4582
5077
|
const robustApiCall = createRetryApiCall({
|
|
4583
5078
|
log: (line) => process.stderr.write(line),
|
|
5079
|
+
onFloodWait: makeFloodWaitRecorder(FLOOD_STATE_PATH),
|
|
4584
5080
|
})
|
|
4585
5081
|
|
|
4586
5082
|
// Fire-and-forget wrapper for outbound surfaces that previously had
|
|
@@ -5236,17 +5732,26 @@ interface PendingVaultRequestSave {
|
|
|
5236
5732
|
why?: string
|
|
5237
5733
|
/** Unix-ms timestamp; entries are reaped after VAULT_REQUEST_SAVE_TTL_MS. */
|
|
5238
5734
|
staged_at: number
|
|
5735
|
+
/** Set on entries RESTORED from disk after a gateway restart. The staged
|
|
5736
|
+
* secret `value` is held in memory only (never persisted β secrets
|
|
5737
|
+
* hygiene), so a restored entry has an empty value and cannot complete the
|
|
5738
|
+
* write. A Save tap on such a card degrades gracefully: it tells the agent
|
|
5739
|
+
* the value was lost to a restart instead of writing an empty secret. */
|
|
5740
|
+
restoredWithoutValue?: boolean
|
|
5239
5741
|
}
|
|
5240
5742
|
const pendingVaultRequestSaves = new Map<string, PendingVaultRequestSave>()
|
|
5241
5743
|
// Gateway-side reap window for a staged vault-save card. Tracks the operator
|
|
5242
5744
|
// approval-card lifetime (config-driven, 60-min default) so the reap never
|
|
5243
5745
|
// races ahead of the card the operator is still looking at.
|
|
5244
5746
|
const VAULT_REQUEST_SAVE_TTL_MS = approvalTtlMs()
|
|
5245
|
-
function sweepPendingVaultRequestSaves(): void {
|
|
5246
|
-
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
|
|
5747
|
+
function sweepPendingVaultRequestSaves(now = Date.now()): void {
|
|
5748
|
+
sweepExpiredEntries(
|
|
5749
|
+
pendingVaultRequestSaves,
|
|
5750
|
+
(v, n) => v.staged_at < n - VAULT_REQUEST_SAVE_TTL_MS,
|
|
5751
|
+
expireVaultSaveCard,
|
|
5752
|
+
now,
|
|
5753
|
+
cardExpiryLog,
|
|
5754
|
+
)
|
|
5250
5755
|
}
|
|
5251
5756
|
|
|
5252
5757
|
/**
|
|
@@ -5289,11 +5794,14 @@ const pendingVaultRequestAccesses = new Map<string, PendingVaultRequestAccess>()
|
|
|
5289
5794
|
// Gateway-side reap window for a staged vault-access card. Tracks the operator
|
|
5290
5795
|
// approval-card lifetime (config-driven, 60-min default) β see approvalTtlMs.
|
|
5291
5796
|
const VAULT_REQUEST_ACCESS_TTL_MS = approvalTtlMs()
|
|
5292
|
-
function sweepPendingVaultRequestAccesses(): void {
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5797
|
+
function sweepPendingVaultRequestAccesses(now = Date.now()): void {
|
|
5798
|
+
sweepExpiredEntries(
|
|
5799
|
+
pendingVaultRequestAccesses,
|
|
5800
|
+
(v, n) => v.staged_at < n - VAULT_REQUEST_ACCESS_TTL_MS,
|
|
5801
|
+
expireVaultAccessCard,
|
|
5802
|
+
now,
|
|
5803
|
+
cardExpiryLog,
|
|
5804
|
+
)
|
|
5297
5805
|
}
|
|
5298
5806
|
|
|
5299
5807
|
/**
|
|
@@ -5325,23 +5833,14 @@ const MENTAL_MODEL_PROPOSE_TTL_MS = approvalTtlMs()
|
|
|
5325
5833
|
// posted card's keyboard away, so a stale card left in the chat can't be tapped
|
|
5326
5834
|
// into a "Card expired" answer β the operator sees the β expiry inline instead.
|
|
5327
5835
|
// Best-effort: card edits are fire-and-forget (the entry is removed regardless).
|
|
5328
|
-
function sweepPendingMentalModelProposes(): void {
|
|
5329
|
-
|
|
5330
|
-
|
|
5331
|
-
|
|
5332
|
-
|
|
5333
|
-
|
|
5334
|
-
|
|
5335
|
-
|
|
5336
|
-
v.chat_id,
|
|
5337
|
-
v.card_message_id,
|
|
5338
|
-
richMessage('β _This mental-model proposal card expired. Ask the agent to re-propose if it still stands._'),
|
|
5339
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
5340
|
-
)
|
|
5341
|
-
.catch(() => {})
|
|
5342
|
-
}
|
|
5343
|
-
}
|
|
5344
|
-
}
|
|
5836
|
+
function sweepPendingMentalModelProposes(now = Date.now()): void {
|
|
5837
|
+
sweepExpiredEntries(
|
|
5838
|
+
pendingMentalModelProposes,
|
|
5839
|
+
(v, n) => v.staged_at < n - MENTAL_MODEL_PROPOSE_TTL_MS,
|
|
5840
|
+
expireMentalModelProposeCard,
|
|
5841
|
+
now,
|
|
5842
|
+
cardExpiryLog,
|
|
5843
|
+
)
|
|
5345
5844
|
}
|
|
5346
5845
|
|
|
5347
5846
|
// Sliding-window rate limit for mental-model proposals: at most
|
|
@@ -5578,6 +6077,296 @@ function isAutoFallbackCooldownActive(_agentName: string, now: number): boolean
|
|
|
5578
6077
|
}
|
|
5579
6078
|
}
|
|
5580
6079
|
|
|
6080
|
+
// ββ Agent-initiated approval-card TTL expiry β wake the parked agent ββββββββ
|
|
6081
|
+
//
|
|
6082
|
+
// The four agent-initiated approval-card families (vault_request_access /
|
|
6083
|
+
// vault_request_save / request_secret / mental_model_propose) each park the
|
|
6084
|
+
// requesting agent (it ends its turn to wait for the operator's tap). Before
|
|
6085
|
+
// this, an unanswered card that TTL-expired left the agent parked FOREVER: the
|
|
6086
|
+
// lazy sweep just deleted the in-memory entry and nothing woke the agent. Each
|
|
6087
|
+
// expire* helper mirrors the permission-card timeout path (#2411 / #2862) by
|
|
6088
|
+
// routing through the pure `expirePendingCard` core (pending-card-expiry.ts),
|
|
6089
|
+
// whose ordering + fault-isolation contract is behaviorally pinned by
|
|
6090
|
+
// pending-card-expiry.test.ts:
|
|
6091
|
+
// 1. drop the in-memory entry AND its durable store record FIRST (single-
|
|
6092
|
+
// shot β a second tick can never double-fire the wake),
|
|
6093
|
+
// 2. edit the card to a β expired state + strip its keyboard (best-effort),
|
|
6094
|
+
// 3. record it in missedApprovalsStore BEFORE delivering, so a throwing
|
|
6095
|
+
// deliver can't lose the re-offer for the operator's return,
|
|
6096
|
+
// 4. inject a TIMEOUT-outcome synthetic inbound (turn-gated via
|
|
6097
|
+
// deliverResumeSyntheticOrBuffer, guarded β a half-dead IPC socket that
|
|
6098
|
+
// throws on write is contained, never escaping the reaper's setInterval
|
|
6099
|
+
// callback into an uncaughtException gateway shutdown).
|
|
6100
|
+
// Called from BOTH the lazy sweeps (on next stage) and the pendingStateReaper
|
|
6101
|
+
// (every 60s β the authoritative timer so an idle gateway still wakes agents).
|
|
6102
|
+
|
|
6103
|
+
async function editCardExpired(chatId: string, messageId: number | undefined, body: string): Promise<void> {
|
|
6104
|
+
if (messageId == null) return
|
|
6105
|
+
await lockedBot.api
|
|
6106
|
+
// allow-raw-bot-api: message-id-targeted edit (no thread to lose); best-effort card-expiry strip from the reaper (no grammy ctx). Dropping reply_markup strips the stale keyboard atomically with the text edit.
|
|
6107
|
+
.editMessageText(chatId, messageId, richMessage(body), { reply_markup: { inline_keyboard: [] } })
|
|
6108
|
+
.catch(() => {})
|
|
6109
|
+
}
|
|
6110
|
+
|
|
6111
|
+
function recordMissedApproval(opts: {
|
|
6112
|
+
stageId: string
|
|
6113
|
+
toolName: string
|
|
6114
|
+
action: string
|
|
6115
|
+
chatId: string
|
|
6116
|
+
threadId?: number
|
|
6117
|
+
now: number
|
|
6118
|
+
}): void {
|
|
6119
|
+
if (!MISSED_APPROVAL_REOFFER_ENABLED) return
|
|
6120
|
+
missedApprovalsStore.add({
|
|
6121
|
+
requestId: opts.stageId,
|
|
6122
|
+
toolName: opts.toolName,
|
|
6123
|
+
action: opts.action,
|
|
6124
|
+
chatId: opts.chatId,
|
|
6125
|
+
threadId: opts.threadId ?? null,
|
|
6126
|
+
timedOutAt: opts.now,
|
|
6127
|
+
})
|
|
6128
|
+
}
|
|
6129
|
+
|
|
6130
|
+
const cardExpiryLog = (msg: string): void => {
|
|
6131
|
+
process.stderr.write(`telegram gateway: ${msg}\n`)
|
|
6132
|
+
}
|
|
6133
|
+
|
|
6134
|
+
function expireVaultAccessCard(stageId: string, v: PendingVaultRequestAccess, now: number): void {
|
|
6135
|
+
const timeoutMinutes = Math.round(VAULT_REQUEST_ACCESS_TTL_MS / 60000)
|
|
6136
|
+
const { delivered } = expirePendingCard({
|
|
6137
|
+
remove: () => {
|
|
6138
|
+
pendingVaultRequestAccesses.delete(stageId)
|
|
6139
|
+
pendingCardStore.remove(stageId)
|
|
6140
|
+
},
|
|
6141
|
+
editCard: () => void editCardExpired(
|
|
6142
|
+
v.chat_id,
|
|
6143
|
+
v.card_message_id,
|
|
6144
|
+
`β _This vault access request for \`${escapeHtmlForTg(v.key)}\` timed out before you tapped. Ask **${escapeHtmlForTg(v.agent)}** to re-request if it still stands._`,
|
|
6145
|
+
),
|
|
6146
|
+
buildInbound: () => buildVaultAccessTimeoutInbound({
|
|
6147
|
+
agent: v.agent,
|
|
6148
|
+
chatId: v.chat_id,
|
|
6149
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
6150
|
+
stageId,
|
|
6151
|
+
timeoutMinutes,
|
|
6152
|
+
key: v.key,
|
|
6153
|
+
scope: v.scope,
|
|
6154
|
+
}),
|
|
6155
|
+
deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
|
|
6156
|
+
recordMiss: () => recordMissedApproval({
|
|
6157
|
+
stageId,
|
|
6158
|
+
toolName: 'vault_request_access',
|
|
6159
|
+
action: `grant ${v.agent} ${v.scope} access to \`${v.key}\``,
|
|
6160
|
+
chatId: v.chat_id,
|
|
6161
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
6162
|
+
now,
|
|
6163
|
+
}),
|
|
6164
|
+
log: cardExpiryLog,
|
|
6165
|
+
})
|
|
6166
|
+
process.stderr.write(
|
|
6167
|
+
`telegram gateway: vault_request_access TTL expired β wake agent=${v.agent} ` +
|
|
6168
|
+
`key=${v.key} stage=${stageId} delivered=${delivered}\n`,
|
|
6169
|
+
)
|
|
6170
|
+
}
|
|
6171
|
+
|
|
6172
|
+
function expireVaultSaveCard(stageId: string, v: PendingVaultRequestSave, now: number): void {
|
|
6173
|
+
const timeoutMinutes = Math.round(VAULT_REQUEST_SAVE_TTL_MS / 60000)
|
|
6174
|
+
const { delivered } = expirePendingCard({
|
|
6175
|
+
remove: () => {
|
|
6176
|
+
pendingVaultRequestSaves.delete(stageId)
|
|
6177
|
+
pendingCardStore.remove(stageId)
|
|
6178
|
+
},
|
|
6179
|
+
editCard: () => void editCardExpired(
|
|
6180
|
+
v.chat_id,
|
|
6181
|
+
v.card_message_id,
|
|
6182
|
+
`β _This vault-save card for \`${escapeHtmlForTg(v.key)}\` timed out before you tapped. The secret was NOT stored. Ask **${escapeHtmlForTg(v.agent)}** to re-issue if you still want to save._`,
|
|
6183
|
+
),
|
|
6184
|
+
buildInbound: () => buildVaultSaveTimeoutInbound({
|
|
6185
|
+
agent: v.agent,
|
|
6186
|
+
chatId: v.chat_id,
|
|
6187
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
6188
|
+
stageId,
|
|
6189
|
+
timeoutMinutes,
|
|
6190
|
+
key: v.key,
|
|
6191
|
+
}),
|
|
6192
|
+
deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
|
|
6193
|
+
recordMiss: () => recordMissedApproval({
|
|
6194
|
+
stageId,
|
|
6195
|
+
toolName: 'vault_request_save',
|
|
6196
|
+
action: `save the secret \`${v.key}\` for ${v.agent}`,
|
|
6197
|
+
chatId: v.chat_id,
|
|
6198
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
6199
|
+
now,
|
|
6200
|
+
}),
|
|
6201
|
+
log: cardExpiryLog,
|
|
6202
|
+
})
|
|
6203
|
+
process.stderr.write(
|
|
6204
|
+
`telegram gateway: vault_request_save TTL expired β wake agent=${v.agent} ` +
|
|
6205
|
+
`key=${v.key} stage=${stageId} delivered=${delivered}\n`,
|
|
6206
|
+
)
|
|
6207
|
+
}
|
|
6208
|
+
|
|
6209
|
+
function expireSecretRequestCard(stageId: string, v: PendingSecretRequest, now: number): void {
|
|
6210
|
+
const timeoutMinutes = Math.round(PENDING_SECRET_REQUEST_TTL_MS / 60000)
|
|
6211
|
+
const { delivered } = expirePendingCard({
|
|
6212
|
+
remove: () => {
|
|
6213
|
+
pendingSecretRequests.delete(stageId)
|
|
6214
|
+
pendingCardStore.remove(stageId)
|
|
6215
|
+
},
|
|
6216
|
+
editCard: () => void editCardExpired(
|
|
6217
|
+
v.chat_id,
|
|
6218
|
+
v.card_message_id,
|
|
6219
|
+
`β _This secret-request card for \`${escapeHtmlForTg(v.key)}\` timed out before you provided it. Ask **${escapeHtmlForTg(v.agent)}** to re-request if it still needs the value._`,
|
|
6220
|
+
),
|
|
6221
|
+
buildInbound: () => buildSecretRequestTimeoutInbound({
|
|
6222
|
+
agent: v.agent,
|
|
6223
|
+
chatId: v.chat_id,
|
|
6224
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
6225
|
+
stageId,
|
|
6226
|
+
timeoutMinutes,
|
|
6227
|
+
key: v.key,
|
|
6228
|
+
}),
|
|
6229
|
+
deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
|
|
6230
|
+
recordMiss: () => recordMissedApproval({
|
|
6231
|
+
stageId,
|
|
6232
|
+
toolName: 'request_secret',
|
|
6233
|
+
action: `provide the secret \`${v.key}\` for ${v.agent}`,
|
|
6234
|
+
chatId: v.chat_id,
|
|
6235
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
6236
|
+
now,
|
|
6237
|
+
}),
|
|
6238
|
+
log: cardExpiryLog,
|
|
6239
|
+
})
|
|
6240
|
+
process.stderr.write(
|
|
6241
|
+
`telegram gateway: request_secret TTL expired β wake agent=${v.agent} ` +
|
|
6242
|
+
`key=${v.key} stage=${stageId} delivered=${delivered}\n`,
|
|
6243
|
+
)
|
|
6244
|
+
}
|
|
6245
|
+
|
|
6246
|
+
function expireMentalModelProposeCard(stageId: string, v: PendingMentalModelPropose, now: number): void {
|
|
6247
|
+
const timeoutMinutes = Math.round(MENTAL_MODEL_PROPOSE_TTL_MS / 60000)
|
|
6248
|
+
const { delivered } = expirePendingCard({
|
|
6249
|
+
remove: () => {
|
|
6250
|
+
pendingMentalModelProposes.delete(stageId)
|
|
6251
|
+
pendingCardStore.remove(stageId)
|
|
6252
|
+
},
|
|
6253
|
+
editCard: () => void editCardExpired(
|
|
6254
|
+
v.chat_id,
|
|
6255
|
+
v.card_message_id,
|
|
6256
|
+
`β _This mental-model proposal card for \`${escapeHtmlForTg(v.spec.name)}\` timed out before you tapped. Ask **${escapeHtmlForTg(v.agent)}** to re-propose if it still stands._`,
|
|
6257
|
+
),
|
|
6258
|
+
buildInbound: () => buildMentalModelProposeTimeoutInbound({
|
|
6259
|
+
agent: v.agent,
|
|
6260
|
+
chatId: v.chat_id,
|
|
6261
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
6262
|
+
stageId,
|
|
6263
|
+
timeoutMinutes,
|
|
6264
|
+
name: v.spec.name,
|
|
6265
|
+
}),
|
|
6266
|
+
deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
|
|
6267
|
+
recordMiss: () => recordMissedApproval({
|
|
6268
|
+
stageId,
|
|
6269
|
+
toolName: 'mental_model_propose',
|
|
6270
|
+
action: `declare the mental model \`${v.spec.name}\` for ${v.agent}`,
|
|
6271
|
+
chatId: v.chat_id,
|
|
6272
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
6273
|
+
now,
|
|
6274
|
+
}),
|
|
6275
|
+
log: cardExpiryLog,
|
|
6276
|
+
})
|
|
6277
|
+
process.stderr.write(
|
|
6278
|
+
`telegram gateway: mental_model_propose TTL expired β wake agent=${v.agent} ` +
|
|
6279
|
+
`name=${v.spec.name} stage=${stageId} delivered=${delivered}\n`,
|
|
6280
|
+
)
|
|
6281
|
+
}
|
|
6282
|
+
|
|
6283
|
+
// Run all four agent-initiated approval-card expiry sweeps. Called from the
|
|
6284
|
+
// pendingStateReaper (the authoritative 60s timer). Each family sweep is
|
|
6285
|
+
// per-entry guarded via sweepExpiredEntries, so one throwing expiry (dead IPC
|
|
6286
|
+
// socket, store IO error) can't skip the remaining entries or families.
|
|
6287
|
+
function sweepExpiredApprovalCards(now: number): void {
|
|
6288
|
+
sweepPendingVaultRequestAccesses(now)
|
|
6289
|
+
sweepPendingVaultRequestSaves(now)
|
|
6290
|
+
sweepPendingMentalModelProposes(now)
|
|
6291
|
+
sweepSecretRequests(now)
|
|
6292
|
+
}
|
|
6293
|
+
|
|
6294
|
+
// Boot restore: repopulate the four in-memory approval-card maps from the
|
|
6295
|
+
// durable store so a post-restart tap on a still-valid card resolves normally
|
|
6296
|
+
// (approve β grant + synthetic; deny β denial synthetic) instead of hitting
|
|
6297
|
+
// the "Card expired" tombstone. Entries already past their TTL are left for
|
|
6298
|
+
// the reaper's next tick, which wakes the parked agent via the timeout path.
|
|
6299
|
+
// vault_request_save entries restore WITHOUT their staged value (never
|
|
6300
|
+
// persisted) and are flagged `restoredWithoutValue` so a Save tap degrades
|
|
6301
|
+
// gracefully rather than writing an empty secret.
|
|
6302
|
+
function restorePendingApprovalCards(): number {
|
|
6303
|
+
let restored = 0
|
|
6304
|
+
for (const e of pendingCardStore.loadAll()) {
|
|
6305
|
+
try {
|
|
6306
|
+
if (e.family === 'vault_request_access') {
|
|
6307
|
+
pendingVaultRequestAccesses.set(e.stageId, {
|
|
6308
|
+
agent: e.agent,
|
|
6309
|
+
chat_id: e.chatId,
|
|
6310
|
+
...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
|
|
6311
|
+
...(e.threadId != null ? { threadId: e.threadId } : {}),
|
|
6312
|
+
key: e.key,
|
|
6313
|
+
scope: e.scope,
|
|
6314
|
+
...(e.reason != null ? { reason: e.reason } : {}),
|
|
6315
|
+
ttl_seconds: e.ttlSeconds,
|
|
6316
|
+
staged_at: e.stagedAt,
|
|
6317
|
+
})
|
|
6318
|
+
restored++
|
|
6319
|
+
} else if (e.family === 'vault_request_save') {
|
|
6320
|
+
pendingVaultRequestSaves.set(e.stageId, {
|
|
6321
|
+
agent: e.agent,
|
|
6322
|
+
chat_id: e.chatId,
|
|
6323
|
+
...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
|
|
6324
|
+
...(e.threadId != null ? { threadId: e.threadId } : {}),
|
|
6325
|
+
key: e.key,
|
|
6326
|
+
kind: e.kind,
|
|
6327
|
+
value: '', // never persisted β secrets hygiene
|
|
6328
|
+
...(e.why != null ? { why: e.why } : {}),
|
|
6329
|
+
staged_at: e.stagedAt,
|
|
6330
|
+
restoredWithoutValue: true,
|
|
6331
|
+
})
|
|
6332
|
+
restored++
|
|
6333
|
+
} else if (e.family === 'request_secret') {
|
|
6334
|
+
pendingSecretRequests.set(e.stageId, {
|
|
6335
|
+
agent: e.agent,
|
|
6336
|
+
chat_id: e.chatId,
|
|
6337
|
+
...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
|
|
6338
|
+
...(e.threadId != null ? { threadId: e.threadId } : {}),
|
|
6339
|
+
key: e.key,
|
|
6340
|
+
...(e.reason != null ? { reason: e.reason } : {}),
|
|
6341
|
+
staged_at: e.stagedAt,
|
|
6342
|
+
})
|
|
6343
|
+
restored++
|
|
6344
|
+
} else if (e.family === 'mental_model_propose') {
|
|
6345
|
+
pendingMentalModelProposes.set(e.stageId, {
|
|
6346
|
+
agent: e.agent,
|
|
6347
|
+
chat_id: e.chatId,
|
|
6348
|
+
...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
|
|
6349
|
+
...(e.threadId != null ? { threadId: e.threadId } : {}),
|
|
6350
|
+
spec: e.spec,
|
|
6351
|
+
...(e.reason != null ? { reason: e.reason } : {}),
|
|
6352
|
+
staged_at: e.stagedAt,
|
|
6353
|
+
})
|
|
6354
|
+
restored++
|
|
6355
|
+
}
|
|
6356
|
+
} catch (err) {
|
|
6357
|
+
process.stderr.write(
|
|
6358
|
+
`telegram gateway: pending-card restore skipped a malformed entry: ${(err as Error).message}\n`,
|
|
6359
|
+
)
|
|
6360
|
+
}
|
|
6361
|
+
}
|
|
6362
|
+
if (restored > 0) {
|
|
6363
|
+
process.stderr.write(
|
|
6364
|
+
`telegram gateway: restored ${restored} pending approval card(s) from prior gateway session\n`,
|
|
6365
|
+
)
|
|
6366
|
+
}
|
|
6367
|
+
return restored
|
|
6368
|
+
}
|
|
6369
|
+
|
|
5581
6370
|
// 60-second sweep drops anything past its documented TTL.
|
|
5582
6371
|
const pendingStateReaper = setInterval(() => {
|
|
5583
6372
|
const now = Date.now()
|
|
@@ -5711,6 +6500,23 @@ const pendingStateReaper = setInterval(() => {
|
|
|
5711
6500
|
for (const [k, v] of deferredSecrets) {
|
|
5712
6501
|
if (now - v.staged_at > DEFERRED_SECRET_TTL_MS) deferredSecrets.delete(k)
|
|
5713
6502
|
}
|
|
6503
|
+
// Agent-initiated approval cards (vault_request_access / vault_request_save /
|
|
6504
|
+
// request_secret / mental_model_propose): expire past-TTL entries and WAKE
|
|
6505
|
+
// the parked agent (timeout synthetic + missed-approvals re-offer). This is
|
|
6506
|
+
// the authoritative timer β before this the only expiry path was a lazy
|
|
6507
|
+
// sweep on the NEXT stage, so an agent that ended its turn to wait on one of
|
|
6508
|
+
// these cards could sit parked forever if no further request ever staged.
|
|
6509
|
+
// try/catch matches the sibling sweepStaleTurnActiveMarker guard: an escaped
|
|
6510
|
+
// throw inside this setInterval callback would reach uncaughtException and
|
|
6511
|
+
// take the WHOLE gateway down (per-entry faults are already contained inside
|
|
6512
|
+
// sweepExpiredEntries/expirePendingCard; this is the outer belt).
|
|
6513
|
+
try {
|
|
6514
|
+
sweepExpiredApprovalCards(now)
|
|
6515
|
+
} catch (err) {
|
|
6516
|
+
process.stderr.write(
|
|
6517
|
+
`telegram gateway: approval-card expiry sweep failed: ${(err as Error).message}\n`,
|
|
6518
|
+
)
|
|
6519
|
+
}
|
|
5714
6520
|
// #550: sweep a stale turn-active marker. Defence-in-depth for the
|
|
5715
6521
|
// case where neither the turn_end arm nor onTurnComplete fired (SDK
|
|
5716
6522
|
// killed before the JSONL turn_duration record, compaction window,
|
|
@@ -6186,6 +6992,10 @@ const statusPinState = new Map<string, PinState>()
|
|
|
6186
6992
|
// owned pins without threading the chat id through every call site. Written on
|
|
6187
6993
|
// every desired-pinned reconcile, cleared alongside the state on unpin.
|
|
6188
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>()
|
|
6189
6999
|
|
|
6190
7000
|
// Durable snapshot of the pin claim set on the persistent per-agent volume
|
|
6191
7001
|
// (STATE_DIR = /state/agent/telegram in prod). Closes the crash hole: the
|
|
@@ -6237,6 +7047,22 @@ const BANNER_PIN_KEY = 'banner:owner'
|
|
|
6237
7047
|
// run β no-op). The boot-cleanup gate below is widened to cover this.
|
|
6238
7048
|
const bannerPinPersistEnabled = !STATIC
|
|
6239
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
|
+
|
|
6240
7066
|
// Persist (or drop) the slot-banner's pin row into the shared store. Routes
|
|
6241
7067
|
// through mutateStatusPinRow: a read-modify-write for ONLY the banner:owner key,
|
|
6242
7068
|
// serialised on the store's per-path lock, so the live fg:/wk: status-pin rows
|
|
@@ -6287,9 +7113,9 @@ async function statusPinBootCleanup(): Promise<void> {
|
|
|
6287
7113
|
// map-backed status pins (statusPinPersistEnabled) or the slot banner
|
|
6288
7114
|
// (bannerPinPersistEnabled). A single cleanup drains ALL orphaned rows β
|
|
6289
7115
|
// status pins AND banner alike β since they share status-pins.json.
|
|
6290
|
-
if (!statusPinPersistEnabled && !bannerPinPersistEnabled) return
|
|
7116
|
+
if (!statusPinPersistEnabled && !bannerPinPersistEnabled && !toolPinPersistEnabled) return
|
|
6291
7117
|
const api = statusPinApi()
|
|
6292
|
-
const { cleared, total } = await runStatusPinBootCleanup({
|
|
7118
|
+
const { cleared, retained, kept, total } = await runStatusPinBootCleanup({
|
|
6293
7119
|
path: STATUS_PIN_STORE_PATH,
|
|
6294
7120
|
fs: statusPinStoreFs,
|
|
6295
7121
|
unpin: (chatId, messageId) => api.unpinChatMessage(chatId, messageId),
|
|
@@ -6297,7 +7123,8 @@ async function statusPinBootCleanup(): Promise<void> {
|
|
|
6297
7123
|
if (total > 0) {
|
|
6298
7124
|
process.stderr.write(
|
|
6299
7125
|
`telegram gateway: status-pin: cleared ${cleared}/${total} ` +
|
|
6300
|
-
`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`,
|
|
6301
7128
|
)
|
|
6302
7129
|
}
|
|
6303
7130
|
}
|
|
@@ -6398,6 +7225,25 @@ const MID_SESSION_CARD_REAPER_INTERVAL_MS = (() => {
|
|
|
6398
7225
|
return Number.isFinite(v) && v > 0 ? v : 5 * 60_000 // 5 min
|
|
6399
7226
|
})()
|
|
6400
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
|
+
|
|
6401
7247
|
// Snapshot the turn_keys / topic keys owned by a live in-flight turn right now.
|
|
6402
7248
|
function liveTurnKeySets(): { registryKeys: Set<string>; topicKeys: Set<string> } {
|
|
6403
7249
|
const registryKeys = new Set<string>()
|
|
@@ -6462,15 +7308,27 @@ async function runMidSessionCardReaper(): Promise<void> {
|
|
|
6462
7308
|
verb: 'activity-card.mid-session-reap-finalize',
|
|
6463
7309
|
},
|
|
6464
7310
|
),
|
|
6465
|
-
|
|
6466
|
-
|
|
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(
|
|
6467
7324
|
() => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId),
|
|
6468
7325
|
{
|
|
6469
7326
|
chat_id: record.chatId,
|
|
6470
7327
|
...(record.threadId != null ? { threadId: record.threadId } : {}),
|
|
6471
7328
|
verb: 'activity-card.mid-session-reap-unpin',
|
|
6472
7329
|
},
|
|
6473
|
-
)
|
|
7330
|
+
)
|
|
7331
|
+
},
|
|
6474
7332
|
})
|
|
6475
7333
|
if (total > 0) {
|
|
6476
7334
|
process.stderr.write(
|
|
@@ -6484,6 +7342,55 @@ async function runMidSessionCardReaper(): Promise<void> {
|
|
|
6484
7342
|
)
|
|
6485
7343
|
}
|
|
6486
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
|
+
}
|
|
6487
7394
|
}
|
|
6488
7395
|
|
|
6489
7396
|
const midSessionCardReaper = setInterval(() => {
|
|
@@ -6571,9 +7478,11 @@ async function reconcileStatusPinInner(
|
|
|
6571
7478
|
if (next == null) {
|
|
6572
7479
|
statusPinState.delete(pinKey)
|
|
6573
7480
|
statusPinChatIds.delete(pinKey)
|
|
7481
|
+
statusPinPinnedAt.delete(pinKey)
|
|
6574
7482
|
} else {
|
|
6575
7483
|
statusPinState.set(pinKey, next)
|
|
6576
7484
|
statusPinChatIds.set(pinKey, chatId)
|
|
7485
|
+
if (!statusPinPinnedAt.has(pinKey)) statusPinPinnedAt.set(pinKey, Date.now())
|
|
6577
7486
|
}
|
|
6578
7487
|
return
|
|
6579
7488
|
}
|
|
@@ -6593,9 +7502,11 @@ async function reconcileStatusPinInner(
|
|
|
6593
7502
|
if (next == null) {
|
|
6594
7503
|
statusPinState.delete(pinKey)
|
|
6595
7504
|
statusPinChatIds.delete(pinKey)
|
|
7505
|
+
statusPinPinnedAt.delete(pinKey)
|
|
6596
7506
|
} else {
|
|
6597
7507
|
statusPinState.set(pinKey, next)
|
|
6598
7508
|
statusPinChatIds.set(pinKey, chatId)
|
|
7509
|
+
if (!statusPinPinnedAt.has(pinKey)) statusPinPinnedAt.set(pinKey, Date.now())
|
|
6599
7510
|
}
|
|
6600
7511
|
}
|
|
6601
7512
|
|
|
@@ -6638,7 +7549,7 @@ async function unpinAllStatusPins(): Promise<void> {
|
|
|
6638
7549
|
if (st == null) continue
|
|
6639
7550
|
// Recover the chat id from the state map's companion key registry.
|
|
6640
7551
|
const chatId = statusPinChatIds.get(key)
|
|
6641
|
-
if (chatId == null) { statusPinState.delete(key); continue }
|
|
7552
|
+
if (chatId == null) { statusPinState.delete(key); statusPinPinnedAt.delete(key); continue }
|
|
6642
7553
|
await reconcileStatusPin(key, chatId, { pinned: false })
|
|
6643
7554
|
}
|
|
6644
7555
|
}
|
|
@@ -7396,6 +8307,19 @@ function trackRedeliveredInbound(merged: InboundMessage): void {
|
|
|
7396
8307
|
) {
|
|
7397
8308
|
return
|
|
7398
8309
|
}
|
|
8310
|
+
// Button-tap anti-storm guard β mirrors the immediate-delivery path in
|
|
8311
|
+
// deliverButtonTapInbound. A tap synthesized without a source message
|
|
8312
|
+
// (`cbMessageId == null` β `messageId: 0`, no meta.message_id) has no id
|
|
8313
|
+
// the `enqueue` ack can ever match, so enrolling it would make the
|
|
8314
|
+
// never-drop sweep re-deliver it until TTL. The immediate path skips
|
|
8315
|
+
// tracking for such taps; a tap that buffered mid-turn and flushed through
|
|
8316
|
+
// here must be skipped identically (asymmetry = a storm on one path only).
|
|
8317
|
+
if (
|
|
8318
|
+
merged.meta?.button_callback === 'true' &&
|
|
8319
|
+
(merged.meta.message_id == null || merged.meta.message_id === '')
|
|
8320
|
+
) {
|
|
8321
|
+
return
|
|
8322
|
+
}
|
|
7399
8323
|
const key = chatKey(merged.chatId, merged.threadId != null ? Number(merged.threadId) : null)
|
|
7400
8324
|
trackDelivery(
|
|
7401
8325
|
deliveryQueue,
|
|
@@ -8230,6 +9154,7 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
8230
9154
|
dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
|
|
8231
9155
|
configSnapshotPath: join(resolvedAgentDirForCard, '.config-snapshot.json'),
|
|
8232
9156
|
bootCardStatePath: join(resolvedAgentDirForCard, '.boot-card-msgid.json'),
|
|
9157
|
+
floodStatePath: FLOOD_STATE_PATH,
|
|
8233
9158
|
...(updateOutcomeLine ? { updateOutcomeLine } : {}),
|
|
8234
9159
|
}, ackMsgId).then(handle => {
|
|
8235
9160
|
activeBootCard = handle
|
|
@@ -9261,6 +10186,42 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
9261
10186
|
void fireFleetAutoFallback(msg.agentName, untilMs)
|
|
9262
10187
|
},
|
|
9263
10188
|
|
|
10189
|
+
// Issue #2971 β read-only wedge-watchdog probe: is there a live pending
|
|
10190
|
+
// permission request (Telegram approval card) for this agent right now?
|
|
10191
|
+
// Sourced directly from `pendingPermissions` β no mutation, no card
|
|
10192
|
+
// posting, just a snapshot read. The watchdog uses this to decide whether
|
|
10193
|
+
// to Esc a shape-persistent permission-prompt TUI or defer to the card /
|
|
10194
|
+
// the #2724 TTL reaper. Always answered synchronously and on the SAME
|
|
10195
|
+
// connection so the watchdog's short (~2s) budget can resolve promptly.
|
|
10196
|
+
onQueryPendingPermission(client: IpcClient, msg: QueryPendingPermissionMessage) {
|
|
10197
|
+
const self = process.env.SWITCHROOM_AGENT_NAME
|
|
10198
|
+
if (self && msg.agentName !== self) {
|
|
10199
|
+
process.stderr.write(
|
|
10200
|
+
`telegram gateway: query_pending_permission rejected β agent mismatch (${msg.agentName} != ${self})\n`,
|
|
10201
|
+
)
|
|
10202
|
+
try {
|
|
10203
|
+
client.send({ type: 'pending_permission_status', correlationId: msg.correlationId, pending: false })
|
|
10204
|
+
} catch { /* best effort */ }
|
|
10205
|
+
return
|
|
10206
|
+
}
|
|
10207
|
+
// Any LIVE entry answers the question β this gateway serves exactly one
|
|
10208
|
+
// agent, so `pendingPermissions` is already scoped to `msg.agentName`.
|
|
10209
|
+
const [requestId] = pendingPermissions.keys()
|
|
10210
|
+
const pending = pendingPermissions.size > 0
|
|
10211
|
+
try {
|
|
10212
|
+
client.send({
|
|
10213
|
+
type: 'pending_permission_status',
|
|
10214
|
+
correlationId: msg.correlationId,
|
|
10215
|
+
pending,
|
|
10216
|
+
...(pending && requestId ? { requestId } : {}),
|
|
10217
|
+
})
|
|
10218
|
+
} catch (err) {
|
|
10219
|
+
process.stderr.write(
|
|
10220
|
+
`telegram gateway: query_pending_permission reply failed: ${(err as Error).message}\n`,
|
|
10221
|
+
)
|
|
10222
|
+
}
|
|
10223
|
+
},
|
|
10224
|
+
|
|
9264
10225
|
// #2670 one-tap self-improvement β persist a skill-improvement proposal and
|
|
9265
10226
|
// post its Approve/Dismiss card. The store transition + apply-injection on
|
|
9266
10227
|
// Approve are owned by handleSkillProposalCallback (so a gateway restart
|
|
@@ -9788,6 +10749,15 @@ const voicePreSynthQueue = new PreSynthQueue({
|
|
|
9788
10749
|
}
|
|
9789
10750
|
const filePath = writeVoiceCacheFile(VOICE_CACHE_DIR, job.token, result.audio)
|
|
9790
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.
|
|
9791
10761
|
process.stderr.write(
|
|
9792
10762
|
`telegram gateway: voice-presynth: cached ${result.audio.length} bytes at ${filePath} ` +
|
|
9793
10763
|
`(${result.durationMs}ms, backlog=${voicePreSynthQueue.size})\n`,
|
|
@@ -11748,6 +12718,21 @@ async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{
|
|
|
11748
12718
|
{ threadId, chat_id, verb: 'vault_request_save.card' },
|
|
11749
12719
|
)
|
|
11750
12720
|
pending.card_message_id = sent.message_id
|
|
12721
|
+
// Persist card METADATA (never the staged `value` β secrets hygiene) so a
|
|
12722
|
+
// gateway restart doesn't strand the parked agent. A restored Save tap can't
|
|
12723
|
+
// complete (value is gone) and degrades to a "value lost to restart" wake-up.
|
|
12724
|
+
pendingCardStore.add({
|
|
12725
|
+
family: 'vault_request_save',
|
|
12726
|
+
stageId,
|
|
12727
|
+
agent: pending.agent,
|
|
12728
|
+
chatId: pending.chat_id,
|
|
12729
|
+
...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
|
|
12730
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
12731
|
+
key: pending.key,
|
|
12732
|
+
kind: pending.kind,
|
|
12733
|
+
...(pending.why != null ? { why: pending.why } : {}),
|
|
12734
|
+
stagedAt: pending.staged_at,
|
|
12735
|
+
})
|
|
11751
12736
|
|
|
11752
12737
|
return {
|
|
11753
12738
|
content: [
|
|
@@ -11791,11 +12776,17 @@ const armedSecretCaptures = new Map<string, ArmedSecretCapture>()
|
|
|
11791
12776
|
const PENDING_SECRET_REQUEST_TTL_MS = 30 * 60_000 // card lifetime
|
|
11792
12777
|
const ARMED_SECRET_CAPTURE_TTL_MS = 10 * 60_000 // window to send the value after tapping
|
|
11793
12778
|
|
|
11794
|
-
function sweepSecretRequests(): void {
|
|
11795
|
-
|
|
11796
|
-
|
|
11797
|
-
|
|
11798
|
-
|
|
12779
|
+
function sweepSecretRequests(now = Date.now()): void {
|
|
12780
|
+
sweepExpiredEntries(
|
|
12781
|
+
pendingSecretRequests,
|
|
12782
|
+
(v, n) => n - v.staged_at > PENDING_SECRET_REQUEST_TTL_MS,
|
|
12783
|
+
expireSecretRequestCard,
|
|
12784
|
+
now,
|
|
12785
|
+
cardExpiryLog,
|
|
12786
|
+
)
|
|
12787
|
+
// armedSecretCaptures is a TRANSIENT post-tap window (never persisted): it's
|
|
12788
|
+
// only set after the operator taps [Provide securely], and the request is
|
|
12789
|
+
// no longer parked-on-a-card. Just drop stale ones β no wake needed.
|
|
11799
12790
|
for (const [k, v] of armedSecretCaptures) {
|
|
11800
12791
|
if (now - v.armed_at > ARMED_SECRET_CAPTURE_TTL_MS) armedSecretCaptures.delete(k)
|
|
11801
12792
|
}
|
|
@@ -11845,7 +12836,10 @@ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ co
|
|
|
11845
12836
|
// Dedupe: one open request per (chat, key). Drop any prior stage for
|
|
11846
12837
|
// the same target so the operator never sees stacked cards.
|
|
11847
12838
|
for (const [sid, p] of pendingSecretRequests) {
|
|
11848
|
-
if (p.chat_id === chat_id && p.key === key)
|
|
12839
|
+
if (p.chat_id === chat_id && p.key === key) {
|
|
12840
|
+
pendingSecretRequests.delete(sid)
|
|
12841
|
+
pendingCardStore.remove(sid)
|
|
12842
|
+
}
|
|
11849
12843
|
}
|
|
11850
12844
|
|
|
11851
12845
|
const stageId = randomBytes(4).toString('hex')
|
|
@@ -11867,6 +12861,21 @@ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ co
|
|
|
11867
12861
|
{ threadId, chat_id, verb: 'request_secret.card' },
|
|
11868
12862
|
)
|
|
11869
12863
|
pending.card_message_id = sent.message_id
|
|
12864
|
+
// Persist card metadata so a gateway restart doesn't strand the parked
|
|
12865
|
+
// agent. request_secret holds NO value at staging time (the value arrives
|
|
12866
|
+
// after the operator taps [Provide securely]), so nothing sensitive lands
|
|
12867
|
+
// on disk here.
|
|
12868
|
+
pendingCardStore.add({
|
|
12869
|
+
family: 'request_secret',
|
|
12870
|
+
stageId,
|
|
12871
|
+
agent: pending.agent,
|
|
12872
|
+
chatId: pending.chat_id,
|
|
12873
|
+
...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
|
|
12874
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
12875
|
+
key: pending.key,
|
|
12876
|
+
...(pending.reason != null ? { reason: pending.reason } : {}),
|
|
12877
|
+
stagedAt: pending.staged_at,
|
|
12878
|
+
})
|
|
11870
12879
|
|
|
11871
12880
|
return {
|
|
11872
12881
|
content: [
|
|
@@ -11915,6 +12924,7 @@ async function captureProvidedSecret(
|
|
|
11915
12924
|
armedSecretCaptures.delete(chat_id)
|
|
11916
12925
|
const pending = pendingSecretRequests.get(armed.stageId)
|
|
11917
12926
|
pendingSecretRequests.delete(armed.stageId)
|
|
12927
|
+
pendingCardStore.remove(armed.stageId)
|
|
11918
12928
|
|
|
11919
12929
|
// Delete the raw message FIRST β surfaces a warning if it fails.
|
|
11920
12930
|
if (msgId != null) await deleteSensitiveMessage(chat_id, msgId, 'provided secret value')
|
|
@@ -12030,6 +13040,7 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
|
|
|
12030
13040
|
|
|
12031
13041
|
if (action === 'decline') {
|
|
12032
13042
|
pendingSecretRequests.delete(stageId)
|
|
13043
|
+
pendingCardStore.remove(stageId)
|
|
12033
13044
|
armedSecretCaptures.delete(pending.chat_id)
|
|
12034
13045
|
await ctx.answerCallbackQuery({ text: 'Declined.' }).catch(() => {})
|
|
12035
13046
|
if (pending.card_message_id != null) {
|
|
@@ -12197,12 +13208,27 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
|
|
|
12197
13208
|
{ threadId, chat_id, verb: 'vault_request_access.card' },
|
|
12198
13209
|
)
|
|
12199
13210
|
pending.card_message_id = sent.message_id
|
|
13211
|
+
// Persist card metadata (no secret material β this flow stages only the ACL
|
|
13212
|
+
// request) so a gateway restart doesn't strand the parked agent.
|
|
13213
|
+
pendingCardStore.add({
|
|
13214
|
+
family: 'vault_request_access',
|
|
13215
|
+
stageId,
|
|
13216
|
+
agent: pending.agent,
|
|
13217
|
+
chatId: pending.chat_id,
|
|
13218
|
+
...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
|
|
13219
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
13220
|
+
key: pending.key,
|
|
13221
|
+
scope: pending.scope,
|
|
13222
|
+
...(pending.reason != null ? { reason: pending.reason } : {}),
|
|
13223
|
+
ttlSeconds: pending.ttl_seconds,
|
|
13224
|
+
stagedAt: pending.staged_at,
|
|
13225
|
+
})
|
|
12200
13226
|
|
|
12201
13227
|
return {
|
|
12202
13228
|
content: [
|
|
12203
13229
|
{
|
|
12204
13230
|
type: 'text',
|
|
12205
|
-
text: `vault_request_access: card sent (stage_id=${stageId}, key=${key}, scope=${scopeRaw}). Wait for the operator to tap Approve or Deny β do not retry the vault read until you see a confirmation message. If the card times out (
|
|
13231
|
+
text: `vault_request_access: card sent (stage_id=${stageId}, key=${key}, scope=${scopeRaw}). Wait for the operator to tap Approve or Deny β do not retry the vault read until you see a confirmation message. If the card times out (${Math.round(VAULT_REQUEST_ACCESS_TTL_MS / 60000)} min) you can re-request.`,
|
|
12206
13232
|
},
|
|
12207
13233
|
],
|
|
12208
13234
|
}
|
|
@@ -12366,6 +13392,19 @@ async function executeMentalModelPropose(args: Record<string, unknown>): Promise
|
|
|
12366
13392
|
{ threadId, chat_id, verb: 'mental_model_propose.card' },
|
|
12367
13393
|
)
|
|
12368
13394
|
pending.card_message_id = sent.message_id
|
|
13395
|
+
// Persist card metadata (the proposed DECLARATION is not secret material) so
|
|
13396
|
+
// a gateway restart doesn't strand the parked agent.
|
|
13397
|
+
pendingCardStore.add({
|
|
13398
|
+
family: 'mental_model_propose',
|
|
13399
|
+
stageId,
|
|
13400
|
+
agent: pending.agent,
|
|
13401
|
+
chatId: pending.chat_id,
|
|
13402
|
+
...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
|
|
13403
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
13404
|
+
spec: pending.spec,
|
|
13405
|
+
...(pending.reason != null ? { reason: pending.reason } : {}),
|
|
13406
|
+
stagedAt: pending.staged_at,
|
|
13407
|
+
})
|
|
12369
13408
|
// Only count a proposal against the rate budget once its card actually
|
|
12370
13409
|
// posted (validation errors / dupes don't consume the budget).
|
|
12371
13410
|
mentalModelProposeTimes.push(Date.now())
|
|
@@ -12526,10 +13565,26 @@ async function executePinMessage(args: Record<string, unknown>): Promise<unknown
|
|
|
12526
13565
|
// errors are retried. THREAD_NOT_FOUND on a stale topic surfaces to the
|
|
12527
13566
|
// agent as a tool-error β pinning a vanished message is genuinely a
|
|
12528
13567
|
// failure the agent should see.
|
|
13568
|
+
const pinMsgId = Number(args.message_id)
|
|
12529
13569
|
await robustApiCall(
|
|
12530
|
-
() => lockedBot.api.pinChatMessage(pinChatId,
|
|
13570
|
+
() => lockedBot.api.pinChatMessage(pinChatId, pinMsgId),
|
|
12531
13571
|
{ chat_id: pinChatId, verb: 'pin_message' },
|
|
12532
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
|
+
}
|
|
12533
13588
|
return { content: [{ type: 'text', text: `pinned message ${args.message_id}` }] }
|
|
12534
13589
|
}
|
|
12535
13590
|
|
|
@@ -12771,6 +13826,7 @@ function composeTurnActivity(turn: CurrentTurn, final = false, liveSuffix = ''):
|
|
|
12771
13826
|
elapsedMs: turn.startedAt > 0 ? Date.now() - turn.startedAt : 0,
|
|
12772
13827
|
toolCount: turn.labeledToolCount,
|
|
12773
13828
|
state: final ? 'done' : 'running',
|
|
13829
|
+
model: turn.currentModel,
|
|
12774
13830
|
}
|
|
12775
13831
|
return renderActivityFeedWithNested(turn.mirrorLines, childLines, final, liveSuffix, stepCount, header)
|
|
12776
13832
|
}
|
|
@@ -13088,6 +14144,7 @@ function openLivenessFeedIfDue(turn: CurrentTurn): void {
|
|
|
13088
14144
|
const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ['Workingβ¦']
|
|
13089
14145
|
const livenessHeader: SessionActivityHeader = {
|
|
13090
14146
|
label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
|
|
14147
|
+
model: turn.currentModel,
|
|
13091
14148
|
}
|
|
13092
14149
|
// Liveness card is a single "step" whose start is the turn start, so `age`
|
|
13093
14150
|
// IS the step's own elapsed. formatStepSuffix keeps the `β` line timer-free
|
|
@@ -13218,6 +14275,7 @@ function feedHeartbeatTick(): void {
|
|
|
13218
14275
|
const age = Date.now() - turn.startedAt
|
|
13219
14276
|
const livenessHeader: SessionActivityHeader = {
|
|
13220
14277
|
label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
|
|
14278
|
+
model: turn.currentModel,
|
|
13221
14279
|
}
|
|
13222
14280
|
const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ['Working in backgroundβ¦']
|
|
13223
14281
|
// `subagentAt` is the worker's last ADVANCE β the current step's start β
|
|
@@ -13407,6 +14465,7 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
|
|
|
13407
14465
|
const livenessElapsed = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0
|
|
13408
14466
|
const livenessHeader: SessionActivityHeader = {
|
|
13409
14467
|
label: 'Agent', elapsedMs: livenessElapsed, toolCount: turn.labeledToolCount, state: 'done',
|
|
14468
|
+
model: turn.currentModel,
|
|
13410
14469
|
}
|
|
13411
14470
|
finalHtml = renderActivityFeedWithNested(['Workingβ¦'], [], true, '', undefined, livenessHeader)
|
|
13412
14471
|
}
|
|
@@ -13897,6 +14956,22 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
13897
14956
|
return
|
|
13898
14957
|
}
|
|
13899
14958
|
case 'dequeue': return
|
|
14959
|
+
case 'model': {
|
|
14960
|
+
// Live model capture for the main turn. The session-tail projection
|
|
14961
|
+
// already filtered sentinels (`<synthetic>` compaction lines), so any
|
|
14962
|
+
// value reaching here is a real resolved model id. Record it on the turn
|
|
14963
|
+
// (update-on-change) so the activity/liveness card header and /status
|
|
14964
|
+
// render the model actually serving this turn's API calls β transcript-
|
|
14965
|
+
// sourced, never config. Also note it on the freshness-aware session-model
|
|
14966
|
+
// source so a /status query between turns still reflects the last model
|
|
14967
|
+
// (and a fresh assistant line reclaims the source from a /model override).
|
|
14968
|
+
const turn = currentTurn
|
|
14969
|
+
if (turn != null) {
|
|
14970
|
+
turn.currentModel = ev.model
|
|
14971
|
+
}
|
|
14972
|
+
sessionModelSource.noteTranscriptModel(ev.model)
|
|
14973
|
+
return
|
|
14974
|
+
}
|
|
13900
14975
|
case 'thinking': {
|
|
13901
14976
|
// #1067: snapshot the turn atom at handler entry. Even though this
|
|
13902
14977
|
// handler is sync, the principle is uniform across all event arms
|
|
@@ -17022,13 +18097,23 @@ async function handleInbound(
|
|
|
17022
18097
|
// know they're queued. Suppressed for DMs (no topics) and same-topic
|
|
17023
18098
|
// queues (the in-flight turn's own card already covers them).
|
|
17024
18099
|
const inFlightThread = currentTurn?.sessionThreadId
|
|
17025
|
-
|
|
18100
|
+
const crossTopicQueuedCard =
|
|
17026
18101
|
QUEUED_STATUS_UX_ENABLED &&
|
|
17027
18102
|
!isDmChatId(chat_id) &&
|
|
17028
18103
|
messageThreadId != null &&
|
|
17029
18104
|
messageThreadId !== inFlightThread
|
|
17030
|
-
) {
|
|
18105
|
+
if (crossTopicQueuedCard) {
|
|
17031
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)
|
|
17032
18117
|
}
|
|
17033
18118
|
return
|
|
17034
18119
|
}
|
|
@@ -17074,6 +18159,15 @@ async function handleInbound(
|
|
|
17074
18159
|
|
|
17075
18160
|
const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg)
|
|
17076
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
|
+
}
|
|
17077
18171
|
// Reuse the key reserved synchronously above (#2917) when present, else
|
|
17078
18172
|
// mark now β markClaudeBusyForInbound is idempotent (lockstep re-stamp),
|
|
17079
18173
|
// so a re-mark is safe and returns the same chat key.
|
|
@@ -18463,13 +19557,12 @@ function buildAgentAudit(agentName: string): AgentAudit | undefined {
|
|
|
18463
19557
|
// broker's fleet-wide `ListStateData` payload via
|
|
18464
19558
|
// `buildAuthSummaryFromBroker`, with billingType pulled from the
|
|
18465
19559
|
// agent's `.claude.json` (the broker doesn't track plan tier).
|
|
18466
|
-
|
|
18467
|
-
|
|
18468
|
-
|
|
18469
|
-
|
|
18470
|
-
|
|
18471
|
-
|
|
18472
|
-
let activeSessionModelOverride: string | null = null
|
|
19560
|
+
// The live session-model override set by the `/model` picker (session-only)
|
|
19561
|
+
// lives on `sessionModelSource` (setOverride/getOverride, declared beside the
|
|
19562
|
+
// currentTurn globals). Held in gateway memory so it clears on restart, the
|
|
19563
|
+
// same point at which claude's session reverts to the configured model β
|
|
19564
|
+
// keeping `/status` honest without a persisted store. `resolve()` arbitrates
|
|
19565
|
+
// freshness against the transcript-observed model (#2982 idle-switch window).
|
|
18473
19566
|
|
|
18474
19567
|
async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
|
|
18475
19568
|
type AgentListResp = {
|
|
@@ -18499,7 +19592,18 @@ async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
|
|
|
18499
19592
|
return {
|
|
18500
19593
|
agentName,
|
|
18501
19594
|
model: a?.model ?? null,
|
|
18502
|
-
|
|
19595
|
+
// The FRESHEST session-model observation wins (session-model-source.ts):
|
|
19596
|
+
// the transcript's `message.model` (ground truth for the last API call)
|
|
19597
|
+
// vs the #2982 /model override (the only truthful source in the idle-
|
|
19598
|
+
// after-switch window, before the next assistant line). Both rendered
|
|
19599
|
+
// through formatModelLabel for a consistent short form; an override value
|
|
19600
|
+
// that isn't model-shaped (an already-friendly "Opus 4.8" label) passes
|
|
19601
|
+
// through verbatim. Never sourced from config.
|
|
19602
|
+
sessionModel: (() => {
|
|
19603
|
+
const resolved = sessionModelSource.resolve()
|
|
19604
|
+
if (resolved == null) return null
|
|
19605
|
+
return formatModelLabel(resolved.model) ?? resolved.model
|
|
19606
|
+
})(),
|
|
18503
19607
|
extendsProfile: (a?.extends ?? a?.template) ?? null,
|
|
18504
19608
|
topicName: a?.topic_name ?? null,
|
|
18505
19609
|
topicEmoji: a?.topic_emoji ?? null,
|
|
@@ -18730,7 +19834,7 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
|
|
|
18730
19834
|
},
|
|
18731
19835
|
escapeHtml: escapeHtmlForTg,
|
|
18732
19836
|
preBlock,
|
|
18733
|
-
getActiveSessionModel: () =>
|
|
19837
|
+
getActiveSessionModel: () => sessionModelSource.getOverride(),
|
|
18734
19838
|
/**
|
|
18735
19839
|
* Graceful restart for sr-* β Claude model switch. Same mechanism as
|
|
18736
19840
|
* the /restart command: writes a restart marker (so the post-restart
|
|
@@ -18740,9 +19844,18 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
|
|
|
18740
19844
|
scheduleRestart: async (reason: string) => {
|
|
18741
19845
|
const name = getMyAgentName()
|
|
18742
19846
|
// Debounce: mirror the /restart command's 15 s guard to prevent
|
|
18743
|
-
// double-dispatch on a rapid double-tap of /model <claude-alias>.
|
|
19847
|
+
// double-dispatch on a rapid double-tap of /model <claude-alias>. This
|
|
19848
|
+
// path previously `return`ed silently β indistinguishable from a
|
|
19849
|
+
// successful dispatch β so a model switch caught in the window was a
|
|
19850
|
+
// silent no-op while the caller reported success. THROW a tagged error
|
|
19851
|
+
// so scheduleModelRelaunch can react (keep the in-flight restart's carrier,
|
|
19852
|
+
// tell the operator honestly) instead of falsely claiming the switch stuck.
|
|
18744
19853
|
const existing = readRestartMarker()
|
|
18745
|
-
if (existing && Date.now() - existing.ts < 15_000)
|
|
19854
|
+
if (existing && Date.now() - existing.ts < 15_000) {
|
|
19855
|
+
const e = new Error('a restart is already in flight β try again in ~15s')
|
|
19856
|
+
;(e as { code?: string }).code = 'restart_in_flight'
|
|
19857
|
+
throw e
|
|
19858
|
+
}
|
|
18746
19859
|
if (restartCtx) {
|
|
18747
19860
|
writeRestartMarker({
|
|
18748
19861
|
chat_id: restartCtx.chatId,
|
|
@@ -18752,6 +19865,15 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
|
|
|
18752
19865
|
})
|
|
18753
19866
|
}
|
|
18754
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
|
+
}
|
|
18755
19877
|
await sweepBeforeSelfRestart()
|
|
18756
19878
|
const hostdResp = await tryHostdDispatch(name, {
|
|
18757
19879
|
v: 1,
|
|
@@ -18771,30 +19893,61 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
|
|
|
18771
19893
|
)
|
|
18772
19894
|
return
|
|
18773
19895
|
}
|
|
18774
|
-
// 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.
|
|
18775
19899
|
if (hostdResp.result !== 'started' && hostdResp.result !== 'completed') {
|
|
18776
19900
|
clearRestartMarker()
|
|
19901
|
+
{
|
|
19902
|
+
const smDir = resolveAgentDirFromEnv()
|
|
19903
|
+
if (smDir) clearRelaunchModelIntent(smDir)
|
|
19904
|
+
}
|
|
18777
19905
|
throw new Error(
|
|
18778
19906
|
`hostd restart failed (result=${hostdResp.result}): ${hostdResp.error ?? '(no details)'}`,
|
|
18779
19907
|
)
|
|
18780
19908
|
}
|
|
18781
19909
|
},
|
|
18782
19910
|
/**
|
|
18783
|
-
*
|
|
18784
|
-
* native `/model` picker rejects
|
|
18785
|
-
* Write the
|
|
18786
|
-
*
|
|
18787
|
-
* the in-memory session-model so /status stays honest
|
|
18788
|
-
* 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.
|
|
18789
19918
|
*/
|
|
18790
19919
|
scheduleModelRelaunch: async (model: string, reason: string) => {
|
|
18791
19920
|
const agentDir = resolveAgentDirFromEnv()
|
|
18792
|
-
if (!agentDir) throw new Error('agent dir unresolvable β cannot write session-model
|
|
18793
|
-
|
|
18794
|
-
|
|
18795
|
-
|
|
18796
|
-
|
|
18797
|
-
|
|
19921
|
+
if (!agentDir) throw new Error('agent dir unresolvable β cannot write session-model file')
|
|
19922
|
+
const prevOverride = sessionModelSource.getOverride()
|
|
19923
|
+
const prevFileRaw = readSessionModelFileRaw(agentDir)
|
|
19924
|
+
writeSessionModelFile(
|
|
19925
|
+
agentDir,
|
|
19926
|
+
model,
|
|
19927
|
+
readConfiguredDefaultModel(agentDir) ??
|
|
19928
|
+
resolveMainModel(deps.getConfiguredModel() ?? undefined),
|
|
19929
|
+
)
|
|
19930
|
+
sessionModelSource.setOverride(model)
|
|
19931
|
+
try {
|
|
19932
|
+
await deps.scheduleRestart(reason)
|
|
19933
|
+
} catch (err) {
|
|
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.
|
|
19944
|
+
if ((err as { code?: string })?.code !== 'restart_in_flight') {
|
|
19945
|
+
restoreSessionModelFileRaw(agentDir, prevFileRaw)
|
|
19946
|
+
sessionModelSource.setOverride(prevOverride)
|
|
19947
|
+
clearRelaunchModelIntent(agentDir)
|
|
19948
|
+
}
|
|
19949
|
+
throw err
|
|
19950
|
+
}
|
|
18798
19951
|
},
|
|
18799
19952
|
}
|
|
18800
19953
|
return deps
|
|
@@ -18823,7 +19976,50 @@ bot.command('model', async ctx => {
|
|
|
18823
19976
|
return
|
|
18824
19977
|
}
|
|
18825
19978
|
const reply = await handleModelCommand(parsed, deps)
|
|
18826
|
-
|
|
19979
|
+
// Record a POSITIVELY-CONFIRMED typed switch so /status reflects what's
|
|
19980
|
+
// actually running β the SAME in-memory override the menu callback path sets
|
|
19981
|
+
// (buildAgentMetadata resolves it via sessionModelSource). Only
|
|
19982
|
+
// set on the confirmed inject path; the sr-*/relaunch paths already set the
|
|
19983
|
+
// override inside scheduleModelRelaunch, and an unverified switch carries no
|
|
19984
|
+
// selectedModel so /status is never lied to.
|
|
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) {
|
|
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
|
+
}
|
|
20021
|
+
}
|
|
20022
|
+
await switchroomReply(ctx, reply.text + persistWarning, { html: reply.html })
|
|
18827
20023
|
})
|
|
18828
20024
|
|
|
18829
20025
|
// `/effort` β show or switch the reasoning effort for the live session.
|
|
@@ -18934,6 +20130,13 @@ bot.command('restart', async ctx => {
|
|
|
18934
20130
|
// greeting card shows "Restarted user: /restart from chat" instead
|
|
18935
20131
|
// of whatever reason the downstream CLI would default to.
|
|
18936
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
|
+
}
|
|
18937
20140
|
await sweepBeforeSelfRestart()
|
|
18938
20141
|
const hostdResp = await tryHostdDispatch(getMyAgentName(), {
|
|
18939
20142
|
v: 1,
|
|
@@ -19092,6 +20295,12 @@ async function handleNewCommand(ctx: Context): Promise<void> {
|
|
|
19092
20295
|
// Stamp user attribution so the next greeting shows "Restarted user:
|
|
19093
20296
|
// /new" / "user: /reset" rather than the downstream CLI default.
|
|
19094
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
|
+
}
|
|
19095
20304
|
await sweepBeforeSelfRestart()
|
|
19096
20305
|
const hostdResp = await tryHostdDispatch(getMyAgentName(), {
|
|
19097
20306
|
v: 1,
|
|
@@ -21118,6 +22327,7 @@ async function performVaultAccessApproval(
|
|
|
21118
22327
|
const visible = await listViaBroker()
|
|
21119
22328
|
if (visible !== null && visible.includes(pending.key)) {
|
|
21120
22329
|
pendingVaultRequestAccesses.delete(stageId)
|
|
22330
|
+
pendingCardStore.remove(stageId)
|
|
21121
22331
|
if (pending.card_message_id != null) {
|
|
21122
22332
|
await ctx.api
|
|
21123
22333
|
.editMessageText(
|
|
@@ -21216,6 +22426,7 @@ async function performVaultAccessApproval(
|
|
|
21216
22426
|
// the agent to re-issue, or the broker error message will tell
|
|
21217
22427
|
// them the next step.
|
|
21218
22428
|
pendingVaultRequestAccesses.delete(stageId)
|
|
22429
|
+
pendingCardStore.remove(stageId)
|
|
21219
22430
|
if (pending.card_message_id != null) {
|
|
21220
22431
|
await ctx.api
|
|
21221
22432
|
.editMessageText(
|
|
@@ -21247,6 +22458,7 @@ async function performVaultAccessApproval(
|
|
|
21247
22458
|
}
|
|
21248
22459
|
|
|
21249
22460
|
pendingVaultRequestAccesses.delete(stageId)
|
|
22461
|
+
pendingCardStore.remove(stageId)
|
|
21250
22462
|
if (pending.card_message_id != null) {
|
|
21251
22463
|
const days = Math.round(pending.ttl_seconds / 86400)
|
|
21252
22464
|
const footer =
|
|
@@ -21455,23 +22667,17 @@ async function handleMentalModelProposeCallback(ctx: Context, data: string): Pro
|
|
|
21455
22667
|
// this, a card left untapped past its TTL is still resolvable if no fresh
|
|
21456
22668
|
// proposal has run the sweep β an operator could approve a stale proposal.
|
|
21457
22669
|
if (Date.now() - pending.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS) {
|
|
21458
|
-
|
|
21459
|
-
|
|
21460
|
-
|
|
21461
|
-
|
|
21462
|
-
|
|
21463
|
-
pending.chat_id,
|
|
21464
|
-
pending.card_message_id,
|
|
21465
|
-
richMessage('β _This mental-model proposal card expired before you tapped. Ask the agent to re-propose if it still stands._'),
|
|
21466
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
21467
|
-
)
|
|
21468
|
-
.catch(() => {})
|
|
21469
|
-
}
|
|
22670
|
+
// Expired between post and tap: route through the shared expiry path so the
|
|
22671
|
+
// parked agent is WOKEN (timeout synthetic + missed-approvals re-offer) and
|
|
22672
|
+
// the durable store entry is cleared β not just a silent map delete.
|
|
22673
|
+
expireMentalModelProposeCard(stageId, pending, Date.now())
|
|
22674
|
+
await ctx.answerCallbackQuery({ text: 'Card expired β the agent was notified.' }).catch(() => {})
|
|
21470
22675
|
return
|
|
21471
22676
|
}
|
|
21472
22677
|
// Single-shot: remove the pending entry immediately so a double-tap can't
|
|
21473
22678
|
// resolve twice.
|
|
21474
22679
|
pendingMentalModelProposes.delete(stageId)
|
|
22680
|
+
pendingCardStore.remove(stageId)
|
|
21475
22681
|
|
|
21476
22682
|
const proposal: MentalModelPendingProposal = {
|
|
21477
22683
|
agent: pending.agent,
|
|
@@ -21613,6 +22819,7 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
|
|
|
21613
22819
|
|
|
21614
22820
|
if (action === 'deny') {
|
|
21615
22821
|
pendingVaultRequestAccesses.delete(stageId)
|
|
22822
|
+
pendingCardStore.remove(stageId)
|
|
21616
22823
|
await ctx.answerCallbackQuery({ text: 'π« Denied' }).catch(() => {})
|
|
21617
22824
|
if (pending.card_message_id != null) {
|
|
21618
22825
|
await ctx.api
|
|
@@ -21835,6 +23042,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
21835
23042
|
|
|
21836
23043
|
if (action === 'discard') {
|
|
21837
23044
|
pendingVaultRequestSaves.delete(stageId)
|
|
23045
|
+
pendingCardStore.remove(stageId)
|
|
21838
23046
|
await ctx.answerCallbackQuery({ text: 'π« Discarded' }).catch(() => {})
|
|
21839
23047
|
if (pending.card_message_id != null) {
|
|
21840
23048
|
await ctx.api
|
|
@@ -21905,6 +23113,43 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
21905
23113
|
// stale "spinning" state on the button while we run the write.
|
|
21906
23114
|
await ctx.answerCallbackQuery({ text: 'β³ Savingβ¦' }).catch(() => {})
|
|
21907
23115
|
|
|
23116
|
+
// Restored-after-restart guard: the staged secret VALUE is held in gateway
|
|
23117
|
+
// memory only and is never persisted (secrets hygiene). If this card was
|
|
23118
|
+
// restored from disk after a gateway restart, the value is gone β we CANNOT
|
|
23119
|
+
// complete the write. Degrade gracefully: strip the card, wake the agent
|
|
23120
|
+
// with a save-failed (value-lost) synthetic so it re-requests, and stop.
|
|
23121
|
+
if (pending.restoredWithoutValue || pending.value.length === 0) {
|
|
23122
|
+
pendingVaultRequestSaves.delete(stageId)
|
|
23123
|
+
pendingCardStore.remove(stageId)
|
|
23124
|
+
if (pending.card_message_id != null) {
|
|
23125
|
+
await ctx.api
|
|
23126
|
+
.editMessageText(
|
|
23127
|
+
pending.chat_id,
|
|
23128
|
+
pending.card_message_id,
|
|
23129
|
+
richMessage(`β οΈ _The staged value for \`${escapeHtmlForTg(pending.key)}\` was lost to a gateway restart β nothing was saved. Ask **${escapeHtmlForTg(pending.agent)}** to re-issue \`vault_request_save\` if you still want to store it._`),
|
|
23130
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
23131
|
+
)
|
|
23132
|
+
.catch(() => {})
|
|
23133
|
+
}
|
|
23134
|
+
const lostInbound = buildVaultSaveFailedInbound({
|
|
23135
|
+
ctx: {
|
|
23136
|
+
agent: pending.agent,
|
|
23137
|
+
key: pending.key,
|
|
23138
|
+
chat_id: pending.chat_id,
|
|
23139
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
23140
|
+
},
|
|
23141
|
+
stageId,
|
|
23142
|
+
operatorId: senderId,
|
|
23143
|
+
reason: 'staged value lost to a gateway restart β re-request the save',
|
|
23144
|
+
})
|
|
23145
|
+
const lDelivered = deliverResumeSyntheticOrBuffer(pending.agent, lostInbound)
|
|
23146
|
+
process.stderr.write(
|
|
23147
|
+
`telegram gateway: vault_request_save value lost to restart β wake agent=${pending.agent} ` +
|
|
23148
|
+
`key=${pending.key} stage=${stageId} delivered=${lDelivered}\n`,
|
|
23149
|
+
)
|
|
23150
|
+
return
|
|
23151
|
+
}
|
|
23152
|
+
|
|
21908
23153
|
// #1115 follow-up: the save-approve flow now mirrors the access-
|
|
21909
23154
|
// approve flow under telegram-id mode β broker `put` accepts
|
|
21910
23155
|
// `attest_via_posture: true` (server.ts:1448-1500), so the
|
|
@@ -21940,6 +23185,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
21940
23185
|
.catch(() => {})
|
|
21941
23186
|
}
|
|
21942
23187
|
pendingVaultRequestSaves.delete(stageId)
|
|
23188
|
+
pendingCardStore.remove(stageId)
|
|
21943
23189
|
return
|
|
21944
23190
|
}
|
|
21945
23191
|
// defaultVaultWrite spawns `switchroom vault set <key>` with the
|
|
@@ -21971,6 +23217,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
21971
23217
|
// retry by re-invoking the same MCP tool, but the value will be
|
|
21972
23218
|
// re-staged with a new ID. Drop the current stage.
|
|
21973
23219
|
pendingVaultRequestSaves.delete(stageId)
|
|
23220
|
+
pendingCardStore.remove(stageId)
|
|
21974
23221
|
// Wake the waiting agent with the failure (symmetric with the
|
|
21975
23222
|
// success/discard paths) so it doesn't assume vault:<key> exists.
|
|
21976
23223
|
const failReason =
|
|
@@ -21996,6 +23243,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
21996
23243
|
|
|
21997
23244
|
// Success β mask the value in the card for visual confirmation.
|
|
21998
23245
|
pendingVaultRequestSaves.delete(stageId)
|
|
23246
|
+
pendingCardStore.remove(stageId)
|
|
21999
23247
|
if (pending.card_message_id != null) {
|
|
22000
23248
|
await ctx.api
|
|
22001
23249
|
.editMessageText(
|
|
@@ -23888,7 +25136,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
23888
25136
|
// sr-* TARGET tap: switch TO a non-Claude (LiteLLM/OpenRouter) model.
|
|
23889
25137
|
// Parity with the text `/model sr-*` path β claude's native picker rejects
|
|
23890
25138
|
// unknown sr-* ids, so an in-place inject can't set them. Carry the token
|
|
23891
|
-
// across a graceful restart (the `.session-model
|
|
25139
|
+
// across a graceful restart (the durable `.session-model` override) and
|
|
23892
25140
|
// relaunch `claude --model sr-*`. Session-only; reverts to the configured
|
|
23893
25141
|
// default on the next restart. The sr-* β Claude direction is handled below
|
|
23894
25142
|
// via the SELECT/alias outcome + isSrToClaudeTransition.
|
|
@@ -23921,13 +25169,38 @@ bot.on('callback_query:data', async ctx => {
|
|
|
23921
25169
|
}
|
|
23922
25170
|
const didInterimSrEdit = false
|
|
23923
25171
|
try {
|
|
23924
|
-
const prevSessionModel =
|
|
25172
|
+
const prevSessionModel = sessionModelSource.getOverride()
|
|
23925
25173
|
const outcome = await handleModelMenuCallback(data, modelDeps)
|
|
23926
25174
|
// Record a successful session switch so /status reflects what's
|
|
23927
|
-
// actually running
|
|
23928
|
-
//
|
|
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.
|
|
23929
25179
|
if (outcome.selectedModel) {
|
|
23930
|
-
|
|
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)
|
|
23931
25204
|
}
|
|
23932
25205
|
// toastOnly: leave the menu untouched β but only if we haven't already
|
|
23933
25206
|
// cleared its buttons with the interim sr-* edit. If we have, fall
|
|
@@ -23949,6 +25222,36 @@ bot.on('callback_query:data', async ctx => {
|
|
|
23949
25222
|
{ reply_markup: { inline_keyboard: [] } },
|
|
23950
25223
|
)
|
|
23951
25224
|
.catch(() => {})
|
|
25225
|
+
// Carry the requested Claude model across the restart via the SAME
|
|
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.
|
|
25233
|
+
{
|
|
25234
|
+
const agentDir = resolveAgentDirFromEnv()
|
|
25235
|
+
const token = outcome.selectedModelToken
|
|
25236
|
+
if (agentDir && token) {
|
|
25237
|
+
try {
|
|
25238
|
+
writeSessionModelFile(
|
|
25239
|
+
agentDir,
|
|
25240
|
+
token,
|
|
25241
|
+
readConfiguredDefaultModel(agentDir) ??
|
|
25242
|
+
resolveMainModel(modelDeps.getConfiguredModel() ?? undefined),
|
|
25243
|
+
)
|
|
25244
|
+
sessionModelSource.setOverride(token)
|
|
25245
|
+
} catch (e) {
|
|
25246
|
+
process.stderr.write(`telegram gateway: sr-to-claude session-model write failed: ${(e as Error)?.message ?? String(e)}\n`)
|
|
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)
|
|
25253
|
+
}
|
|
25254
|
+
}
|
|
23952
25255
|
// Write the restart marker so the post-restart boot card edits into this chat.
|
|
23953
25256
|
writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() })
|
|
23954
25257
|
stampUserRestartReason('user: sr-to-claude model switch (menu)')
|
|
@@ -24307,22 +25610,36 @@ bot.on('callback_query:data', async ctx => {
|
|
|
24307
25610
|
}
|
|
24308
25611
|
return undefined
|
|
24309
25612
|
})()
|
|
24310
|
-
|
|
24311
|
-
|
|
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
|
|
24312
25626
|
// immediately β no GPU wait. Missing/unreadable file (expired + swept,
|
|
24313
|
-
// crash, pre-feature entry, kill-switched gateway) falls back
|
|
24314
|
-
//
|
|
24315
|
-
|
|
24316
|
-
|
|
24317
|
-
|
|
24318
|
-
|
|
24319
|
-
|
|
24320
|
-
|
|
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
|
|
24321
25642
|
}
|
|
24322
|
-
}
|
|
24323
|
-
if (audio != null) {
|
|
24324
|
-
await ctx.answerCallbackQuery({ text: 'π' }).catch(() => {})
|
|
24325
|
-
} else {
|
|
24326
25643
|
await ctx.answerCallbackQuery({ text: 'π Synthesizingβ¦' }).catch(() => {})
|
|
24327
25644
|
// Local sidecar (kokoro) synthesis β same helper the immediate voice-out
|
|
24328
25645
|
// path uses. On-demand is a local-engine feature; the cache is only
|
|
@@ -24332,7 +25649,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
24332
25649
|
await ctx
|
|
24333
25650
|
.answerCallbackQuery({ text: 'Voice sidecar unavailable β try again later.' })
|
|
24334
25651
|
.catch(() => {})
|
|
24335
|
-
return
|
|
25652
|
+
return null
|
|
24336
25653
|
}
|
|
24337
25654
|
const result = await synthesizeViaSidecar({
|
|
24338
25655
|
token: sidecarToken,
|
|
@@ -24351,33 +25668,52 @@ bot.on('callback_query:data', async ctx => {
|
|
|
24351
25668
|
await ctx
|
|
24352
25669
|
.answerCallbackQuery({ text: `Voice failed: ${result.reason}` })
|
|
24353
25670
|
.catch(() => {})
|
|
24354
|
-
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
|
+
)
|
|
24355
25712
|
}
|
|
24356
|
-
|
|
25713
|
+
return
|
|
24357
25714
|
}
|
|
24358
|
-
|
|
24359
|
-
// narrow a captured `let` inside an arrow function).
|
|
24360
|
-
const audioOut: Uint8Array = audio
|
|
25715
|
+
|
|
24361
25716
|
try {
|
|
24362
|
-
// Native voice note (NOT a document), quote-replying the button's
|
|
24363
|
-
// message.
|
|
24364
|
-
await robustApiCall(
|
|
24365
|
-
() =>
|
|
24366
|
-
bot.api.sendVoice(
|
|
24367
|
-
cbChatId,
|
|
24368
|
-
// allow-raw-bot-api: single native voice-note send for an on-demand Listen tap.
|
|
24369
|
-
new InputFile(Buffer.from(audioOut)),
|
|
24370
|
-
{
|
|
24371
|
-
...(cbMessageId != null ? { reply_parameters: { message_id: cbMessageId } } : {}),
|
|
24372
|
-
...(cbThreadId != null ? { message_thread_id: cbThreadId } : {}),
|
|
24373
|
-
} as never,
|
|
24374
|
-
),
|
|
24375
|
-
{
|
|
24376
|
-
chat_id: cbChatId,
|
|
24377
|
-
verb: 'voice-ondemand.sendVoice',
|
|
24378
|
-
...(cbThreadId != null ? { threadId: cbThreadId } : {}),
|
|
24379
|
-
},
|
|
24380
|
-
)
|
|
24381
25717
|
// Single-use on SUCCESS: strip the 'π Listen' keyboard so the button
|
|
24382
25718
|
// can't be re-tapped now that the audio has been delivered. Mirrors the
|
|
24383
25719
|
// agent-button single_use strip (keyboardIsSingleUse) house style.
|
|
@@ -24401,7 +25737,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
24401
25737
|
} catch (err) {
|
|
24402
25738
|
const msg = err instanceof Error ? err.message : String(err)
|
|
24403
25739
|
process.stderr.write(
|
|
24404
|
-
`telegram gateway: voice-out on-demand:
|
|
25740
|
+
`telegram gateway: voice-out on-demand: strip-listen-keyboard failed (non-fatal): ${msg}\n`,
|
|
24405
25741
|
)
|
|
24406
25742
|
}
|
|
24407
25743
|
return
|
|
@@ -24490,17 +25826,17 @@ bot.on('callback_query:data', async ctx => {
|
|
|
24490
25826
|
process.stderr.write(
|
|
24491
25827
|
`telegram gateway: button_callback chatId=${cbChatId} user=${ctx.from.id} data=${JSON.stringify(agentCb.raw)} btnText=${JSON.stringify(buttonText ?? null)}\n`,
|
|
24492
25828
|
)
|
|
24493
|
-
//
|
|
24494
|
-
// normal
|
|
24495
|
-
//
|
|
24496
|
-
//
|
|
24497
|
-
//
|
|
24498
|
-
//
|
|
25829
|
+
// #271 turn-safety: route the tap through the SAME turn-safe delivery
|
|
25830
|
+
// machinery as a normal inbound (deliverButtonTapInbound) instead of a raw
|
|
25831
|
+
// sendToAgent. A tap landing mid-turn now buffers until idle (never strands
|
|
25832
|
+
// in the composer, #1556), a delivered tap is composer-cleared first and
|
|
25833
|
+
// tracked so the redelivery sweep rescues a strand, and a bridge-offline tap
|
|
25834
|
+
// still spools + shows the restart notice below (unchanged UX). The old raw
|
|
25835
|
+
// path (sendToAgent β pendingInboundBuffer, drained by onClientRegistered)
|
|
25836
|
+
// fixed only the bridge-mid-reconnect drop; it never gated on turn state.
|
|
24499
25837
|
const selfAgentBtn = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
24500
|
-
const
|
|
24501
|
-
if (
|
|
24502
|
-
if (!btnDelivered) {
|
|
24503
|
-
pendingInboundBuffer.push(selfAgentBtn, inboundMsg)
|
|
25838
|
+
const btnOutcome = await deliverButtonTapInbound(selfAgentBtn, inboundMsg)
|
|
25839
|
+
if (btnOutcome === 'buffered-bridge-offline') {
|
|
24504
25840
|
// No registered bridge β the agent's mid-restart. Tell the user
|
|
24505
25841
|
// so they don't think the button silently swallowed their tap;
|
|
24506
25842
|
// the tap is genuinely buffered now and replays on reconnect.
|
|
@@ -24729,6 +26065,36 @@ bot.on('callback_query:data', async ctx => {
|
|
|
24729
26065
|
process.stderr.write(
|
|
24730
26066
|
`telegram gateway: always-allow hostd FAILED: ${failReason} (request_id=${request_id})\n`,
|
|
24731
26067
|
)
|
|
26068
|
+
// #2973 pt.2 β enqueue for the durable retry queue UNLESS the
|
|
26069
|
+
// failure is non-retryable (config edits locked β retrying
|
|
26070
|
+
// won't help until the operator flips the flag; that case
|
|
26071
|
+
// keeps today's honest "did NOT save" card only). Everything
|
|
26072
|
+
// else (stale config view, transient hostd error, rate limit)
|
|
26073
|
+
// gets picked up by the boot/periodic drain instead of quietly
|
|
26074
|
+
// requiring the operator to notice and re-tap.
|
|
26075
|
+
if (!editLockHint) {
|
|
26076
|
+
try {
|
|
26077
|
+
await alwaysAllowPersistQueue.enqueue({
|
|
26078
|
+
agentName,
|
|
26079
|
+
rule: chosen.rule,
|
|
26080
|
+
grantPhrase,
|
|
26081
|
+
chatId: ctx.chat?.id != null ? String(ctx.chat.id) : undefined,
|
|
26082
|
+
threadId: (ctx.callbackQuery?.message as { message_thread_id?: number } | undefined)?.message_thread_id,
|
|
26083
|
+
error: failReason,
|
|
26084
|
+
})
|
|
26085
|
+
} catch (enqueueErr) {
|
|
26086
|
+
// The retry queue's own write failed (disk full, perms, β¦) β
|
|
26087
|
+
// don't pretend this landed. Fold it into the operator-facing
|
|
26088
|
+
// failReason so the "did NOT save" card is honest about the
|
|
26089
|
+
// retry mechanism ALSO having failed, not just the original
|
|
26090
|
+
// dispatch (#2973 adversarial review pt.2).
|
|
26091
|
+
const enqueueMsg = (enqueueErr as Error).message
|
|
26092
|
+
process.stderr.write(
|
|
26093
|
+
`telegram gateway: always-allow enqueue for retry FAILED: ${enqueueMsg} (request_id=${request_id})\n`,
|
|
26094
|
+
)
|
|
26095
|
+
failReason = `${failReason} (retry queue also failed to persist: ${enqueueMsg})`
|
|
26096
|
+
}
|
|
26097
|
+
}
|
|
24732
26098
|
}
|
|
24733
26099
|
}
|
|
24734
26100
|
|
|
@@ -26619,6 +27985,12 @@ void (async () => {
|
|
|
26619
27985
|
}
|
|
26620
27986
|
}
|
|
26621
27987
|
|
|
27988
|
+
// #2973 pt.2 β drain any always-allow persists left queued by a
|
|
27989
|
+
// prior gateway process (e.g. one that restarted mid-persist),
|
|
27990
|
+
// then keep draining periodically for the rest of this process's
|
|
27991
|
+
// lifetime.
|
|
27992
|
+
scheduleAlwaysAllowPersistDrain()
|
|
27993
|
+
|
|
26622
27994
|
void registerSwitchroomBotCommands().catch(() => {})
|
|
26623
27995
|
|
|
26624
27996
|
// #613 fix: pre-warm the chatAvailableReactions cache for every
|
|
@@ -26732,6 +28104,18 @@ void (async () => {
|
|
|
26732
28104
|
}
|
|
26733
28105
|
}
|
|
26734
28106
|
|
|
28107
|
+
// Restore the four agent-initiated approval-card families from the
|
|
28108
|
+
// durable store so a post-restart tap on a still-valid card resolves
|
|
28109
|
+
// normally instead of hitting the "Card expired" tombstone (and an
|
|
28110
|
+
// already-expired entry gets woken by the reaper's next tick).
|
|
28111
|
+
try {
|
|
28112
|
+
restorePendingApprovalCards()
|
|
28113
|
+
} catch (err) {
|
|
28114
|
+
process.stderr.write(
|
|
28115
|
+
`telegram gateway: pending approval-card restore failed: ${(err as Error).message}\n`,
|
|
28116
|
+
)
|
|
28117
|
+
}
|
|
28118
|
+
|
|
26735
28119
|
// Boot-time pin sweep
|
|
26736
28120
|
try {
|
|
26737
28121
|
const bootAccess = loadAccess()
|
|
@@ -26903,6 +28287,7 @@ void (async () => {
|
|
|
26903
28287
|
dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
|
|
26904
28288
|
configSnapshotPath: join(resolvedAgentDirForBootCard, '.config-snapshot.json'),
|
|
26905
28289
|
bootCardStatePath: join(resolvedAgentDirForBootCard, '.boot-card-msgid.json'),
|
|
28290
|
+
floodStatePath: FLOOD_STATE_PATH,
|
|
26906
28291
|
...(updateOutcomeLine ? { updateOutcomeLine } : {}),
|
|
26907
28292
|
}, ackMsgId)
|
|
26908
28293
|
activeBootCard = handle
|
|
@@ -26961,8 +28346,9 @@ void (async () => {
|
|
|
26961
28346
|
// a phantom session override.
|
|
26962
28347
|
return resolveMainModel(raw ?? undefined)
|
|
26963
28348
|
})()
|
|
26964
|
-
|
|
26965
|
-
launched.length > 0 && launched !== configured ? launched : null
|
|
28349
|
+
sessionModelSource.setOverride(
|
|
28350
|
+
launched.length > 0 && launched !== configured ? launched : null,
|
|
28351
|
+
)
|
|
26966
28352
|
} catch { /* leave override as-is on a bad read */ }
|
|
26967
28353
|
}
|
|
26968
28354
|
|
|
@@ -27276,7 +28662,7 @@ void (async () => {
|
|
|
27276
28662
|
// Gated to background completions: foreground sub-agents
|
|
27277
28663
|
// need nothing here, and 'orphan' is a stale historical-at-
|
|
27278
28664
|
// boot row, not a fresh completion the user is waiting on.
|
|
27279
|
-
onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs }) => {
|
|
28665
|
+
onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs, background: entryBackground }) => {
|
|
27280
28666
|
// Reaction promotion: if the parent turn already ended
|
|
27281
28667
|
// with this (or another) worker still running, its π was
|
|
27282
28668
|
// deferred (held on βοΈ/β‘). Now that a worker finished,
|
|
@@ -27307,13 +28693,32 @@ void (async () => {
|
|
|
27307
28693
|
// (worker-feed-dispatch.ts, pinned by its test). Best-effort:
|
|
27308
28694
|
// a DB hiccup keeps the watcher's generic label rather than
|
|
27309
28695
|
// throwing out of the terminal handler.
|
|
27310
|
-
let dispatch: WorkerFeedDispatch = resolveWorkerFeedDispatch(null, description)
|
|
28696
|
+
let dispatch: WorkerFeedDispatch = resolveWorkerFeedDispatch(null, description, entryBackground)
|
|
27311
28697
|
if (turnsDb != null) {
|
|
27312
28698
|
try {
|
|
27313
|
-
dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description)
|
|
28699
|
+
dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description, entryBackground)
|
|
27314
28700
|
} catch { /* best-effort */ }
|
|
27315
28701
|
}
|
|
27316
|
-
|
|
28702
|
+
let isBackground = dispatch.isBackground
|
|
28703
|
+
// Fix #1(+#2): the registry row never linked AND the watcher
|
|
28704
|
+
// entry's own cached background flag was never observed
|
|
28705
|
+
// either (both `resolveWorkerFeedDispatch` fallbacks came up
|
|
28706
|
+
// empty) β this is the "DB row is unlinked" bug's worst
|
|
28707
|
+
// case. A finished worker with actual narrative result text
|
|
28708
|
+
// is far more likely a dropped background handback than a
|
|
28709
|
+
// legitimate foreground no-op (a foreground sub-agent's
|
|
28710
|
+
// result returns inline as the Task tool result β the
|
|
28711
|
+
// gateway wouldn't otherwise need to route anything here).
|
|
28712
|
+
// Degrade to background so the result is delivered instead
|
|
28713
|
+
// of silently lost. Idempotency: this only flips the
|
|
28714
|
+
// dispatch classification for THIS single onFinish call β
|
|
28715
|
+
// all the existing dedup/idempotency guards below
|
|
28716
|
+
// (decideSubagentHandback's spool key, completionNotified,
|
|
28717
|
+
// etc.) still apply unchanged, so this cannot cause a
|
|
28718
|
+
// double-handback.
|
|
28719
|
+
if (!dispatch.hasRow && entryBackground == null && resultText.trim().length > 0) {
|
|
28720
|
+
isBackground = true
|
|
28721
|
+
}
|
|
27317
28722
|
// NESTED (depth-2+) worker terminal: its live status surfaced
|
|
27318
28723
|
// via the worker feed (see onProgress), so finalize that card
|
|
27319
28724
|
// cleanly β never leave it frozen mid-"β step". But NO user
|
|
@@ -27330,6 +28735,10 @@ void (async () => {
|
|
|
27330
28735
|
latestSummary: resultText,
|
|
27331
28736
|
elapsedMs: durationMs,
|
|
27332
28737
|
state: outcome === 'failed' ? 'failed' : 'done',
|
|
28738
|
+
// Persisted (registry) model β the last one the watcher
|
|
28739
|
+
// recorded from the worker's transcript β so the terminal
|
|
28740
|
+
// card keeps the model tag even with no live entry.
|
|
28741
|
+
model: dispatch.feedModel ?? undefined,
|
|
27333
28742
|
})
|
|
27334
28743
|
reconcileWorkerPin(agentId, null, false)
|
|
27335
28744
|
}
|
|
@@ -27407,6 +28816,7 @@ void (async () => {
|
|
|
27407
28816
|
latestSummary: resultText,
|
|
27408
28817
|
elapsedMs: durationMs,
|
|
27409
28818
|
state: outcome === 'failed' ? 'failed' : 'done',
|
|
28819
|
+
model: dispatch.feedModel ?? undefined,
|
|
27410
28820
|
})
|
|
27411
28821
|
// Status-pin: worker done β drop its pin.
|
|
27412
28822
|
reconcileWorkerPin(agentId, null, false)
|
|
@@ -27426,6 +28836,7 @@ void (async () => {
|
|
|
27426
28836
|
latestSummary: resultText,
|
|
27427
28837
|
elapsedMs: durationMs,
|
|
27428
28838
|
state: outcome === 'failed' ? 'failed' : 'done',
|
|
28839
|
+
model: dispatch.feedModel ?? undefined,
|
|
27429
28840
|
})
|
|
27430
28841
|
// Status-pin: worker done β drop its pin.
|
|
27431
28842
|
reconcileWorkerPin(agentId, null, false)
|
|
@@ -27512,7 +28923,7 @@ void (async () => {
|
|
|
27512
28923
|
// suppresses stale-after-restart delivery (a 4-h-old
|
|
27513
28924
|
// "still working (5m)" would be a lie). Sweep on handback
|
|
27514
28925
|
// lives in the `onFinish` block just above.
|
|
27515
|
-
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine }) => {
|
|
28926
|
+
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model }) => {
|
|
27516
28927
|
let fleetChatId = ''
|
|
27517
28928
|
try {
|
|
27518
28929
|
const fleets = progressDriver?.peekAllFleets() ?? []
|
|
@@ -27546,6 +28957,13 @@ void (async () => {
|
|
|
27546
28957
|
// never grew past "startingβ¦" (the frozen-card symptom). The
|
|
27547
28958
|
// foreground nest path below already used this precedence.
|
|
27548
28959
|
const stepLine = (progressLine != null && progressLine.length > 0) ? progressLine : latestSummary
|
|
28960
|
+
// Live model for the worker card: prefer the transcript-sourced
|
|
28961
|
+
// model on the entry (threaded via onProgress) and fall back to
|
|
28962
|
+
// the dispatch-time model persisted on the registry row
|
|
28963
|
+
// (tool_input.model) until the worker's first assistant line
|
|
28964
|
+
// lands. Undefined when neither is known β the card omits it,
|
|
28965
|
+
// never guessing from config.
|
|
28966
|
+
const feedModel = model ?? dispatch.feedModel ?? undefined
|
|
27549
28967
|
if (!isBackground) {
|
|
27550
28968
|
// Model A β a foreground sub-agent runs inside the parent's
|
|
27551
28969
|
// turn, so its live narrative nests under the parent's
|
|
@@ -27585,6 +29003,7 @@ void (async () => {
|
|
|
27585
29003
|
latestSummary: stepLine,
|
|
27586
29004
|
elapsedMs,
|
|
27587
29005
|
state: 'running',
|
|
29006
|
+
model: feedModel,
|
|
27588
29007
|
},
|
|
27589
29008
|
wk.threadId,
|
|
27590
29009
|
)?.then(() => reconcileWorkerPin(agentId, wkChat, true))
|
|
@@ -27726,6 +29145,7 @@ void (async () => {
|
|
|
27726
29145
|
latestSummary: stepLine,
|
|
27727
29146
|
elapsedMs,
|
|
27728
29147
|
state: 'running',
|
|
29148
|
+
model: feedModel,
|
|
27729
29149
|
},
|
|
27730
29150
|
wk.threadId,
|
|
27731
29151
|
)?.then(() => reconcileWorkerPin(agentId, wkChat, true))
|