switchroom 0.18.3 β 0.18.7
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 +3 -1
- package/dist/auth-broker/index.js +3 -1
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +3 -1
- package/dist/cli/switchroom.js +386 -128
- package/dist/host-control/main.js +4 -2
- package/dist/vault/approvals/kernel-server.js +3 -1
- package/dist/vault/broker/server.js +38 -8
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +35 -16
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +15 -1
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
- package/telegram-plugin/dist/server.js +24 -0
- 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/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1331 -151
- 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/model-command.ts +212 -51
- 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/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/operator-events.ts +45 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/subagent-watcher.ts +249 -46
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +202 -42
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/operator-events.test.ts +17 -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/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -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-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- 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 +115 -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/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/worker-activity-feed.ts +229 -15
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
|
@@ -126,6 +126,14 @@ import {
|
|
|
126
126
|
} from './permission-timeout.js'
|
|
127
127
|
import { renderVaultRequestAccessCard } from './vault-request-access-card.js'
|
|
128
128
|
import { createPermissionCardStore, type PersistedPermCard } from './permission-card-store.js'
|
|
129
|
+
import { createPendingCardStore, type PersistedApprovalCard } from './pending-card-store.js'
|
|
130
|
+
import {
|
|
131
|
+
buildVaultAccessTimeoutInbound,
|
|
132
|
+
buildVaultSaveTimeoutInbound,
|
|
133
|
+
buildSecretRequestTimeoutInbound,
|
|
134
|
+
buildMentalModelProposeTimeoutInbound,
|
|
135
|
+
} from './approval-timeout-inbound-builders.js'
|
|
136
|
+
import { expirePendingCard, sweepExpiredEntries } from './pending-card-expiry.js'
|
|
129
137
|
import {
|
|
130
138
|
isPermissionRearmEnabled,
|
|
131
139
|
permissionRearmGraceMs,
|
|
@@ -134,6 +142,11 @@ import {
|
|
|
134
142
|
distinctRequestIds,
|
|
135
143
|
} from './permission-rearm.js'
|
|
136
144
|
import { createMissedApprovalsStore, type MissedApproval } from './missed-approvals-store.js'
|
|
145
|
+
import {
|
|
146
|
+
createAlwaysAllowPersistQueue,
|
|
147
|
+
drainAlwaysAllowPersistQueue,
|
|
148
|
+
type AlwaysAllowDrainDeps,
|
|
149
|
+
} from './always-allow-persist-queue.js'
|
|
137
150
|
import {
|
|
138
151
|
renderMissedApprovalsDigest,
|
|
139
152
|
missedApprovalsKeyboard,
|
|
@@ -143,6 +156,8 @@ import {
|
|
|
143
156
|
import { pickRecoveredPermissionOrigin } from './permission-card-origin.js'
|
|
144
157
|
import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
|
|
145
158
|
import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, formatStepSuffix, type SessionActivityHeader } from '../tool-activity-summary.js'
|
|
159
|
+
import { formatModelLabel } from '../model-label.js'
|
|
160
|
+
import { createSessionModelSource } from './session-model-source.js'
|
|
146
161
|
import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
|
|
147
162
|
import { REPLY_TOOLS, isDraftOfReply } from '../narrative-dedup.js'
|
|
148
163
|
import { toolLabel } from '../tool-labels.js'
|
|
@@ -185,6 +200,12 @@ import {
|
|
|
185
200
|
import { clearStaleTelegramPollingState } from '../startup-reset.js'
|
|
186
201
|
import { gatewayStartupRetry } from './startup-network-retry.js'
|
|
187
202
|
import { writeQuarantineMarker } from './quarantine.js'
|
|
203
|
+
import {
|
|
204
|
+
runPersonDirectoryBootCheck,
|
|
205
|
+
safeResolvePersonName,
|
|
206
|
+
type PersonDirectory,
|
|
207
|
+
type RawPersonEntry,
|
|
208
|
+
} from './resolve-person.js'
|
|
188
209
|
// RFC H Β§7.3: auth-dashboard + auth-slot-parser deleted. Three chat
|
|
189
210
|
// verbs (/auth show | use | rotate) talk to switchroom-auth-broker
|
|
190
211
|
// via the thin client in src/auth/broker/client.ts.
|
|
@@ -390,7 +411,7 @@ import {
|
|
|
390
411
|
_resetHostdEnabledCache,
|
|
391
412
|
} from './hostd-dispatch.js'
|
|
392
413
|
import { formatUpdateStatusLine } from './update-status-line.js'
|
|
393
|
-
import type { HostdRequest } from '../../src/host-control/protocol.js'
|
|
414
|
+
import type { HostdRequest, HostdResponse } from '../../src/host-control/protocol.js'
|
|
394
415
|
import type { AgentAudit } from '../welcome-text.js'
|
|
395
416
|
import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
|
|
396
417
|
import { startWebhookIngestServer } from './webhook-ingest-server.js'
|
|
@@ -524,6 +545,7 @@ import type {
|
|
|
524
545
|
InjectInboundMessage,
|
|
525
546
|
SendOutboundMessage,
|
|
526
547
|
QuotaWallDetectedMessage,
|
|
548
|
+
QueryPendingPermissionMessage,
|
|
527
549
|
PostSkillProposalMessage,
|
|
528
550
|
PermissionEvent,
|
|
529
551
|
RolloutStatusPostMessage,
|
|
@@ -555,6 +577,7 @@ import {
|
|
|
555
577
|
clearCleanShutdownMarker,
|
|
556
578
|
shouldSuppressRecoveryBanner,
|
|
557
579
|
shouldSuppressBootResume,
|
|
580
|
+
parseBootResumeMode,
|
|
558
581
|
resolveShutdownMarker,
|
|
559
582
|
DEFAULT_MAX_AGE_MS as CLEAN_SHUTDOWN_MAX_AGE_MS,
|
|
560
583
|
} from './clean-shutdown-marker.js'
|
|
@@ -671,9 +694,11 @@ import {
|
|
|
671
694
|
import {
|
|
672
695
|
buildResumeInterruptedInbound,
|
|
673
696
|
buildResumeWatchdogReportInbound,
|
|
674
|
-
|
|
697
|
+
buildResumeDeferredReportInbound,
|
|
698
|
+
decideBootResumeKind,
|
|
675
699
|
} from './resume-inbound-builder.js'
|
|
676
|
-
import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey } from '../registry/subagents-schema.js'
|
|
700
|
+
import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey, listNonTerminalSubagentsForTurn } from '../registry/subagents-schema.js'
|
|
701
|
+
import type { InterruptedSubagent } from './resume-inbound-builder.js'
|
|
677
702
|
import { resolveWorkerFeedDispatch, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
|
|
678
703
|
import {
|
|
679
704
|
resolveSubagentStatusSurface,
|
|
@@ -711,16 +736,137 @@ process.on('beforeExit', () => {
|
|
|
711
736
|
// βββ Env + state dir ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
712
737
|
const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
|
|
713
738
|
const permCardStore = createPermissionCardStore(STATE_DIR)
|
|
739
|
+
// Durable store for the four AGENT-INITIATED approval-card families
|
|
740
|
+
// (vault_request_access / vault_request_save / request_secret /
|
|
741
|
+
// mental_model_propose). Persists card METADATA only β never any secret value
|
|
742
|
+
// (see pending-card-store.ts secrets-hygiene note). Restored at boot so a
|
|
743
|
+
// post-restart tap on a still-valid card works like a pre-restart tap, and
|
|
744
|
+
// swept in the pendingStateReaper so an unanswered card wakes the parked agent
|
|
745
|
+
// on TTL instead of leaving it parked forever.
|
|
746
|
+
const pendingCardStore = createPendingCardStore(STATE_DIR)
|
|
714
747
|
// #2862 β missed-approvals re-offer. Persisted list of approvals that
|
|
715
748
|
// TTL-expired while the operator was away; a digest card is posted on the
|
|
716
749
|
// operator's next activity. Kill switch: SWITCHROOM_MISSED_APPROVAL_REOFFER=0.
|
|
717
750
|
const missedApprovalsStore = createMissedApprovalsStore(STATE_DIR)
|
|
718
751
|
const MISSED_APPROVAL_REOFFER_ENABLED =
|
|
719
752
|
process.env.SWITCHROOM_MISSED_APPROVAL_REOFFER !== '0'
|
|
753
|
+
// #2973 pt.2 β durable retry queue for "π Always allow" persists that
|
|
754
|
+
// fail for a retryable reason (stale config view, rate limit, transient
|
|
755
|
+
// hostd error). Drained at boot (below) and on a periodic timer so a
|
|
756
|
+
// gateway restart mid-persist never loses the retry.
|
|
757
|
+
const alwaysAllowPersistQueue = createAlwaysAllowPersistQueue(STATE_DIR)
|
|
758
|
+
|
|
759
|
+
/** Read a hostd `error_envelope`'s structured `retry_after` fix (if
|
|
760
|
+
* present) as a millisecond delay from now. Returns undefined for any
|
|
761
|
+
* other fix kind / missing envelope / unparsable timestamp β callers
|
|
762
|
+
* fall back to plain exponential backoff in that case. */
|
|
763
|
+
function extractRetryAfterMs(resp: HostdResponse): number | undefined {
|
|
764
|
+
const fix = resp.error_envelope?.fix
|
|
765
|
+
if (fix == null || fix.kind !== 'retry_after') return undefined
|
|
766
|
+
const at = Date.parse(fix.retry_at)
|
|
767
|
+
if (Number.isNaN(at)) return undefined
|
|
768
|
+
return Math.max(0, at - Date.now())
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Build a FRESH dependency set for `drainAlwaysAllowPersistQueue` β a new
|
|
773
|
+
* factory call per drain pass, not a cached singleton, so every attempt
|
|
774
|
+
* re-reads config from disk (the #2973 stale-container-side-view failure
|
|
775
|
+
* class is fixed by never reusing a snapshot across retries).
|
|
776
|
+
*/
|
|
777
|
+
function alwaysAllowDrainDeps(): AlwaysAllowDrainDeps {
|
|
778
|
+
return {
|
|
779
|
+
readConfigText: () => {
|
|
780
|
+
const cfgPath = process.env.SWITCHROOM_CONFIG ?? SWITCHROOM_CONFIG ?? findSwitchroomConfigFile()
|
|
781
|
+
return readFileSync(cfgPath, 'utf8')
|
|
782
|
+
},
|
|
783
|
+
resolveAllowList: (_configText, agentName) => {
|
|
784
|
+
const cfg = loadSwitchroomConfig()
|
|
785
|
+
const rawAgent = cfg.agents?.[agentName]
|
|
786
|
+
if (!rawAgent) return []
|
|
787
|
+
const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
|
|
788
|
+
return (resolved as { tools?: { allow?: string[] } }).tools?.allow ?? []
|
|
789
|
+
},
|
|
790
|
+
isRulePersisted,
|
|
791
|
+
synthesizeDiff: (agentName, rule, configText) =>
|
|
792
|
+
synthesizeAllowRuleDiff({ agentName, rule, configText }),
|
|
793
|
+
dispatchConfigEdit: async (entry, unifiedDiff) => {
|
|
794
|
+
const req: HostdRequest = {
|
|
795
|
+
v: 1,
|
|
796
|
+
op: 'config_propose_edit',
|
|
797
|
+
request_id: hostdRequestId('gw-always-allow-retry'),
|
|
798
|
+
args: {
|
|
799
|
+
unified_diff: unifiedDiff,
|
|
800
|
+
reason: `Operator 'always allow' retry: ${entry.agentName} can ${entry.grantPhrase}`,
|
|
801
|
+
target_path: '/state/config/switchroom.yaml',
|
|
802
|
+
},
|
|
803
|
+
}
|
|
804
|
+
const resp = await tryHostdDispatch(entry.agentName, req, 720_000)
|
|
805
|
+
if (resp === 'not-configured') {
|
|
806
|
+
return { ok: false as const, error: 'hostd not-configured (retry queue requires host_control.enabled)' }
|
|
807
|
+
}
|
|
808
|
+
if (resp.result === 'completed') return { ok: true as const }
|
|
809
|
+
return {
|
|
810
|
+
ok: false as const,
|
|
811
|
+
error: resp.error ?? `hostd ${resp.result}`,
|
|
812
|
+
retryAfterMs: extractRetryAfterMs(resp),
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
// #2973 pt.3 β loud terminal failure. A card edit doesn't ping the
|
|
816
|
+
// operator (the original permission card was already edited to the
|
|
817
|
+
// "saving durably in backgroundβ¦" interim state); this posts a NEW
|
|
818
|
+
// message instead, via the same operator-event broadcast every other
|
|
819
|
+
// fleet alert uses.
|
|
820
|
+
notifyTerminalFailure: (entry, reason) => {
|
|
821
|
+
emitGatewayOperatorEvent({
|
|
822
|
+
kind: 'always-allow-persist-failed',
|
|
823
|
+
agent: entry.agentName,
|
|
824
|
+
detail: `"${entry.grantPhrase}" (rule \`${entry.rule}\`) β ${reason}`,
|
|
825
|
+
suggestedActions: [],
|
|
826
|
+
firstSeenAt: new Date(),
|
|
827
|
+
})
|
|
828
|
+
},
|
|
829
|
+
log: (line) => process.stderr.write(line),
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** ~10 min between periodic drain passes β frequent enough that a
|
|
834
|
+
* retryable failure (rate limit, transient hostd hiccup) resolves within
|
|
835
|
+
* a reasonable window, infrequent enough to never look like polling. */
|
|
836
|
+
const ALWAYS_ALLOW_DRAIN_INTERVAL_MS = 10 * 60_000
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Boot drain (picks up anything a prior gateway process queued right
|
|
840
|
+
* before a restart β success criterion: "restarting the gateway mid-
|
|
841
|
+
* persist does not lose the queued entry") + a periodic timer thereafter.
|
|
842
|
+
* Called once at gateway startup. Every pass is independently
|
|
843
|
+
* fault-tolerant β `drainAlwaysAllowPersistQueue` never throws.
|
|
844
|
+
*/
|
|
845
|
+
function scheduleAlwaysAllowPersistDrain(): void {
|
|
846
|
+
void drainAlwaysAllowPersistQueue(alwaysAllowPersistQueue, alwaysAllowDrainDeps()).catch((err) => {
|
|
847
|
+
process.stderr.write(
|
|
848
|
+
`telegram gateway: always-allow-persist-queue boot drain failed: ${(err as Error).message}\n`,
|
|
849
|
+
)
|
|
850
|
+
})
|
|
851
|
+
const timer = setInterval(() => {
|
|
852
|
+
void drainAlwaysAllowPersistQueue(alwaysAllowPersistQueue, alwaysAllowDrainDeps()).catch((err) => {
|
|
853
|
+
process.stderr.write(
|
|
854
|
+
`telegram gateway: always-allow-persist-queue periodic drain failed: ${(err as Error).message}\n`,
|
|
855
|
+
)
|
|
856
|
+
})
|
|
857
|
+
}, ALWAYS_ALLOW_DRAIN_INTERVAL_MS)
|
|
858
|
+
timer.unref?.()
|
|
859
|
+
}
|
|
720
860
|
const ACCESS_FILE = join(STATE_DIR, 'access.json')
|
|
721
861
|
const APPROVED_DIR = join(STATE_DIR, 'approved')
|
|
722
862
|
const ENV_FILE = join(STATE_DIR, '.env')
|
|
723
863
|
const INBOX_DIR = join(STATE_DIR, 'inbox')
|
|
864
|
+
// person_id name resolution (docs/configuration.md). Sibling to
|
|
865
|
+
// access.json but a COMPLETELY SEPARATE, purely config-derived file β
|
|
866
|
+
// scaffold regenerates it on every reconcile via writeFileSyncIfChanged,
|
|
867
|
+
// the gateway never mutates it, and (unlike access.json) it is read
|
|
868
|
+
// exactly ONCE at boot into PERSON_DIRECTORY below β no hot-reload.
|
|
869
|
+
const PEOPLE_FILE = join(STATE_DIR, 'people.json')
|
|
724
870
|
|
|
725
871
|
/**
|
|
726
872
|
* Trigger a restart of the agent + gateway pair.
|
|
@@ -1224,6 +1370,33 @@ function loadAccess(): Access {
|
|
|
1224
1370
|
return BOOT_ACCESS ?? readAccessFile()
|
|
1225
1371
|
}
|
|
1226
1372
|
|
|
1373
|
+
/**
|
|
1374
|
+
* Read `people.json` (the scaffold's plain projection of `users:` entries
|
|
1375
|
+
* that carry a `person_id`). Fail-open: ENOENT or corrupt/malformed JSON
|
|
1376
|
+
* returns an empty array rather than throwing β this feature must never
|
|
1377
|
+
* block startup. Unlike `access.json` this file is never gateway-mutated,
|
|
1378
|
+
* so there's no "move corrupt file aside" concern; the scaffold owns and
|
|
1379
|
+
* regenerates it on every reconcile.
|
|
1380
|
+
*/
|
|
1381
|
+
function readPeopleFile(): RawPersonEntry[] {
|
|
1382
|
+
try {
|
|
1383
|
+
const raw = readFileSync(PEOPLE_FILE, 'utf8')
|
|
1384
|
+
const parsed = JSON.parse(raw) as { entries?: unknown }
|
|
1385
|
+
if (!Array.isArray(parsed.entries)) return []
|
|
1386
|
+
return parsed.entries as RawPersonEntry[]
|
|
1387
|
+
} catch {
|
|
1388
|
+
return []
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* Boot-time-only person-name directory (see resolve-person.ts module doc).
|
|
1394
|
+
* Populated exactly once by the boot-validation block in the startup IIFE
|
|
1395
|
+
* below, then never reassigned or re-read from disk again for the life of
|
|
1396
|
+
* this process β a config change requires an agent restart.
|
|
1397
|
+
*/
|
|
1398
|
+
let PERSON_DIRECTORY: PersonDirectory = { byTelegramKey: {} }
|
|
1399
|
+
|
|
1227
1400
|
function assertAllowedChat(chat_id: string | number): void {
|
|
1228
1401
|
const id = String(chat_id)
|
|
1229
1402
|
const access = loadAccess()
|
|
@@ -1344,71 +1517,124 @@ try {
|
|
|
1344
1517
|
const pending = findLatestTurnIfInterrupted(turnsDb)
|
|
1345
1518
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
1346
1519
|
if (pending != null && selfAgent) {
|
|
1347
|
-
//
|
|
1348
|
-
//
|
|
1349
|
-
//
|
|
1350
|
-
//
|
|
1351
|
-
//
|
|
1352
|
-
//
|
|
1520
|
+
// Boot-resume policy (2026-07, superseding #2585). The block only runs
|
|
1521
|
+
// when `pending` exists β i.e. there IS genuinely in-flight work. The
|
|
1522
|
+
// product decision is that a DELIBERATE restart must not silently drop
|
|
1523
|
+
// that work: `session_continuity.boot_resume` (SWITCHROOM_BOOT_RESUME,
|
|
1524
|
+
// default 'in-flight') resumes it even after a clean shutdown. #2585's
|
|
1525
|
+
// quota-saving posture survives only as the opt-in 'never' mode β and
|
|
1526
|
+
// even then we deliver a passive REPORT, never silence, because the user
|
|
1527
|
+
// must always be told when in-flight work stopped.
|
|
1353
1528
|
//
|
|
1354
1529
|
// NOTE: GATEWAY_CLEAN_SHUTDOWN_MARKER_PATH is defined lower in this file
|
|
1355
1530
|
// (module-init order); we compute the path inline here using the same
|
|
1356
1531
|
// formula so we can read it at boot-resume time.
|
|
1357
|
-
// SWITCHROOM_BOOT_RESUME_ALWAYS=1
|
|
1358
|
-
// unconditional resume
|
|
1532
|
+
// SWITCHROOM_BOOT_RESUME_ALWAYS=1 remains a back-compat escape hatch that
|
|
1533
|
+
// forces unconditional resume regardless of mode.
|
|
1359
1534
|
const bootResumeMarkerPath =
|
|
1360
1535
|
process.env.SWITCHROOM_GATEWAY_CLEAN_SHUTDOWN_MARKER ?? join(STATE_DIR, 'clean-shutdown.json')
|
|
1361
1536
|
const bootResumeCleanMarker = readCleanShutdownMarker(bootResumeMarkerPath)
|
|
1362
1537
|
const bootResumeForceAlways = process.env.SWITCHROOM_BOOT_RESUME_ALWAYS === '1'
|
|
1538
|
+
const bootResumeMode = parseBootResumeMode(process.env.SWITCHROOM_BOOT_RESUME)
|
|
1363
1539
|
const bootResumeSuppressed = shouldSuppressBootResume(bootResumeCleanMarker, Date.now(), {
|
|
1364
1540
|
forceAlways: bootResumeForceAlways,
|
|
1541
|
+
mode: bootResumeMode,
|
|
1542
|
+
})
|
|
1543
|
+
|
|
1544
|
+
// 3h staleness failsafe (operator spec, 2026-06-03): never AUTO-resume
|
|
1545
|
+
// interrupted work older than RESUME_MAX_AGE_MS β selectResumeBuilder
|
|
1546
|
+
// downgrades a stale 'resume' to the passive 'report'. Env override
|
|
1547
|
+
// SWITCHROOM_RESUME_MAX_AGE_MS (ms); set very high to disable.
|
|
1548
|
+
const RESUME_MAX_AGE_MS = (() => {
|
|
1549
|
+
const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS)
|
|
1550
|
+
return Number.isFinite(v) && v > 0 ? v : 10_800_000 // 3h
|
|
1551
|
+
})()
|
|
1552
|
+
|
|
1553
|
+
// Decide the inbound kind (pure β see decideBootResumeKind). Precedence:
|
|
1554
|
+
// 1. loop-guard β 'defer-loop' (never a second resume of a resume)
|
|
1555
|
+
// 2. suppressed β 'defer-suppressed' (boot_resume: never; notice, not silence)
|
|
1556
|
+
// 3. otherwise β selectResumeBuilder (resume | report | none)
|
|
1557
|
+
const bootResumeKind = decideBootResumeKind({
|
|
1558
|
+
pending,
|
|
1559
|
+
suppressed: bootResumeSuppressed,
|
|
1560
|
+
ageMs: Math.max(0, Date.now() - pending.started_at),
|
|
1561
|
+
maxAgeMs: RESUME_MAX_AGE_MS,
|
|
1365
1562
|
})
|
|
1366
|
-
|
|
1563
|
+
|
|
1564
|
+
// Sub-agents that were still in flight (running / stalled β non-terminal)
|
|
1565
|
+
// when the turn was killed. Read HERE, at module top, BEFORE the
|
|
1566
|
+
// subagent-watcher's boot scan + reaper run: the watcher never deletes
|
|
1567
|
+
// these rows (it only flips runningβstalled and marks files historical
|
|
1568
|
+
// in-memory), and this accessor already includes 'stalled', so the data
|
|
1569
|
+
// survives either ordering β but reading pre-watcher keeps it simplest.
|
|
1570
|
+
// Threaded into ALL the builders below (resume, watchdog report, and the
|
|
1571
|
+
// deferred report) so the session gets an explicit killed-workers block β
|
|
1572
|
+
// it must not declare the task done on ghost workers, and even a
|
|
1573
|
+
// suppressed/loop-guarded resume must still NAME the deaths.
|
|
1574
|
+
let interruptedSubagents: InterruptedSubagent[] = []
|
|
1575
|
+
try {
|
|
1576
|
+
interruptedSubagents = listNonTerminalSubagentsForTurn(turnsDb, pending.turn_key).map(
|
|
1577
|
+
(s) => ({ agentType: s.agent_type, description: s.description, status: s.status }),
|
|
1578
|
+
)
|
|
1579
|
+
} catch (err) {
|
|
1367
1580
|
process.stderr.write(
|
|
1368
|
-
`telegram gateway: boot-resume
|
|
1369
|
-
`${bootResumeCleanMarker?.reason ? ` reason=${JSON.stringify(bootResumeCleanMarker.reason)}` : ''}` +
|
|
1370
|
-
`) β unclean exits still resume turnKey=${pending.turn_key}\n`,
|
|
1581
|
+
`telegram gateway: boot-resume subagent lookup failed (${(err as Error).message}) β continuing without worker list\n`,
|
|
1371
1582
|
)
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
const RESUME_MAX_AGE_MS = (() => {
|
|
1379
|
-
const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS)
|
|
1380
|
-
return Number.isFinite(v) && v > 0 ? v : 10_800_000 // 3h
|
|
1381
|
-
})()
|
|
1382
|
-
const kind = selectResumeBuilder(pending.ended_via, {
|
|
1383
|
-
ageMs: Math.max(0, Date.now() - pending.started_at),
|
|
1384
|
-
maxAgeMs: RESUME_MAX_AGE_MS,
|
|
1385
|
-
})
|
|
1386
|
-
if (kind === 'resume') {
|
|
1387
|
-
bootResumeInbound = { agent: selfAgent, msg: buildResumeInterruptedInbound({ turn: pending }) }
|
|
1388
|
-
} else if (kind === 'report') {
|
|
1389
|
-
// idleMs: this boot's measured marker age if it just classified this
|
|
1390
|
-
// turn; otherwise recover it from the persisted interrupt_reason (a
|
|
1391
|
-
// later boot, marker already swept); else fall back to total runtime.
|
|
1392
|
-
let idleMs = pending.turn_key === timeoutTurnKey && markerAgeMs != null ? markerAgeMs : null
|
|
1393
|
-
if (idleMs == null && pending.interrupt_reason) {
|
|
1394
|
-
try {
|
|
1395
|
-
const parsed = JSON.parse(pending.interrupt_reason) as { idleMs?: unknown }
|
|
1396
|
-
if (typeof parsed.idleMs === 'number' && Number.isFinite(parsed.idleMs)) idleMs = parsed.idleMs
|
|
1397
|
-
} catch { /* malformed snapshot β fall through */ }
|
|
1398
|
-
}
|
|
1399
|
-
if (idleMs == null) idleMs = Math.max(0, Date.now() - pending.started_at)
|
|
1400
|
-
bootResumeInbound = {
|
|
1401
|
-
agent: selfAgent,
|
|
1402
|
-
msg: buildResumeWatchdogReportInbound({ turn: pending, idleMs }),
|
|
1403
|
-
}
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
if (bootResumeKind === 'resume') {
|
|
1586
|
+
bootResumeInbound = {
|
|
1587
|
+
agent: selfAgent,
|
|
1588
|
+
msg: buildResumeInterruptedInbound({ turn: pending, subagents: interruptedSubagents }),
|
|
1404
1589
|
}
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1590
|
+
} else if (bootResumeKind === 'report') {
|
|
1591
|
+
// idleMs: this boot's measured marker age if it just classified this
|
|
1592
|
+
// turn; otherwise recover it from the persisted interrupt_reason (a
|
|
1593
|
+
// later boot, marker already swept); else fall back to total runtime.
|
|
1594
|
+
let idleMs = pending.turn_key === timeoutTurnKey && markerAgeMs != null ? markerAgeMs : null
|
|
1595
|
+
if (idleMs == null && pending.interrupt_reason) {
|
|
1596
|
+
try {
|
|
1597
|
+
const parsed = JSON.parse(pending.interrupt_reason) as { idleMs?: unknown }
|
|
1598
|
+
if (typeof parsed.idleMs === 'number' && Number.isFinite(parsed.idleMs)) idleMs = parsed.idleMs
|
|
1599
|
+
} catch { /* malformed snapshot β fall through */ }
|
|
1600
|
+
}
|
|
1601
|
+
if (idleMs == null) idleMs = Math.max(0, Date.now() - pending.started_at)
|
|
1602
|
+
bootResumeInbound = {
|
|
1603
|
+
agent: selfAgent,
|
|
1604
|
+
msg: buildResumeWatchdogReportInbound({ turn: pending, idleMs, subagents: interruptedSubagents }),
|
|
1605
|
+
}
|
|
1606
|
+
} else if (bootResumeKind === 'defer-loop' || bootResumeKind === 'defer-suppressed') {
|
|
1607
|
+
// Passive deferred-report: work was in flight but we decline to
|
|
1608
|
+
// auto-resume (loop-guard, or boot_resume:never). Silence is never
|
|
1609
|
+
// acceptable here β tell the user what was in flight and ask.
|
|
1610
|
+
bootResumeInbound = {
|
|
1611
|
+
agent: selfAgent,
|
|
1612
|
+
msg: buildResumeDeferredReportInbound({
|
|
1613
|
+
turn: pending,
|
|
1614
|
+
reason: bootResumeKind === 'defer-loop' ? 'loop-guard' : 'clean-restart-suppressed',
|
|
1615
|
+
subagents: interruptedSubagents,
|
|
1616
|
+
}),
|
|
1410
1617
|
}
|
|
1411
1618
|
}
|
|
1619
|
+
|
|
1620
|
+
if (bootResumeKind === 'defer-suppressed') {
|
|
1621
|
+
process.stderr.write(
|
|
1622
|
+
`telegram gateway: boot-resume suppressed (clean shutdown` +
|
|
1623
|
+
`${bootResumeCleanMarker?.reason ? ` reason=${JSON.stringify(bootResumeCleanMarker.reason)}` : ''}` +
|
|
1624
|
+
`, mode=${bootResumeMode}) β passive report delivered for turnKey=${pending.turn_key}\n`,
|
|
1625
|
+
)
|
|
1626
|
+
} else if (bootResumeKind === 'defer-loop') {
|
|
1627
|
+
process.stderr.write(
|
|
1628
|
+
`telegram gateway: boot-resume loop-guard tripped (interrupted turn was itself a resume) ` +
|
|
1629
|
+
`β passive report delivered instead of re-resuming turnKey=${pending.turn_key}\n`,
|
|
1630
|
+
)
|
|
1631
|
+
}
|
|
1632
|
+
if (bootResumeInbound != null) {
|
|
1633
|
+
process.stderr.write(
|
|
1634
|
+
`telegram gateway: boot-resume queued kind=${bootResumeKind} mode=${bootResumeMode} ` +
|
|
1635
|
+
`turnKey=${pending.turn_key} endedVia=${pending.ended_via ?? 'open'} chat=${pending.chat_id}\n`,
|
|
1636
|
+
)
|
|
1637
|
+
}
|
|
1412
1638
|
}
|
|
1413
1639
|
|
|
1414
1640
|
// Diagnostic env file (one-shot, sourced by start.sh) β kept for the
|
|
@@ -1488,6 +1714,64 @@ function resolveSubagentOriginChat(
|
|
|
1488
1714
|
}
|
|
1489
1715
|
}
|
|
1490
1716
|
|
|
1717
|
+
/**
|
|
1718
|
+
* Tracks worker-feed agents whose owner-DM fallback has already been
|
|
1719
|
+
* logged, so `resolveWorkerFeedChat` emits the routing-decision line once
|
|
1720
|
+
* per agent instead of every watcher tick (~1/s). Capped FIFO at 256 β a
|
|
1721
|
+
* late duplicate log is harmless (one extra stderr line), and the cap keeps
|
|
1722
|
+
* the set bounded across a long gateway lifetime even if origin resolution
|
|
1723
|
+
* is persistently broken fleet-wide (history disabled β every nested worker
|
|
1724
|
+
* hits the fallback). A gateway restart clears it.
|
|
1725
|
+
*/
|
|
1726
|
+
const WORKER_FEED_FALLBACK_LOG_CAP = 256
|
|
1727
|
+
const workerFeedOwnerDmFallbackLogged = new Set<string>()
|
|
1728
|
+
|
|
1729
|
+
/**
|
|
1730
|
+
* Resolve a worker-feed destination chat with a guaranteed last resort.
|
|
1731
|
+
*
|
|
1732
|
+
* The universal-liveness contract is "any active work has a card, at any
|
|
1733
|
+
* nesting depth, with or without a parent turn open." Origin resolution
|
|
1734
|
+
* (`resolveSubagentOriginChat`) only succeeds when the ancestor turn row is
|
|
1735
|
+
* in `turnsDb` and history is enabled; a depth-2+ worker whose chain can't
|
|
1736
|
+
* be walked (history disabled, row reaped, ancestor never stamped) would
|
|
1737
|
+
* otherwise fall through `fleetChatId || allowFrom[0]` and, if those are
|
|
1738
|
+
* empty too, hit the `chatId.length === 0` skip in `workerActivityFeed.update`
|
|
1739
|
+
* β painting NOTHING, silently. That is the one path most likely to violate
|
|
1740
|
+
* "any and all active work has a card."
|
|
1741
|
+
*
|
|
1742
|
+
* This never returns `''`: the precedence is origin chat β fleet chat β
|
|
1743
|
+
* first allowed chat (the owner DM). The owner DM is the durable floor β
|
|
1744
|
+
* "wrong chat" beats "no card." A one-line routing-decision log flags the
|
|
1745
|
+
* fallback so an operator can see the misroute without it reading as an
|
|
1746
|
+
* error (it isn't one β the card surfaced).
|
|
1747
|
+
*/
|
|
1748
|
+
function resolveWorkerFeedChat(
|
|
1749
|
+
agentId: string,
|
|
1750
|
+
fleetChatId: string,
|
|
1751
|
+
): { chatId: string; threadId?: number } {
|
|
1752
|
+
const origin = resolveSubagentOriginChat(agentId)
|
|
1753
|
+
if (origin != null && origin.chatId.length > 0) return origin
|
|
1754
|
+
if (fleetChatId.length > 0) return { chatId: fleetChatId }
|
|
1755
|
+
const ownerDm = loadAccess().allowFrom[0] ?? ''
|
|
1756
|
+
if (origin == null && fleetChatId.length === 0 && ownerDm.length > 0) {
|
|
1757
|
+
// Routing decision, not a warning: origin resolution failed and no
|
|
1758
|
+
// fleet chat is configured, so the nested worker's card lands in the
|
|
1759
|
+
// owner DM. Logged ONCE per agent so the misroute is auditable
|
|
1760
|
+
// without spamming every tick (the watcher drives onProgress ~1/s).
|
|
1761
|
+
if (!workerFeedOwnerDmFallbackLogged.has(agentId)) {
|
|
1762
|
+
workerFeedOwnerDmFallbackLogged.add(agentId)
|
|
1763
|
+
if (workerFeedOwnerDmFallbackLogged.size > WORKER_FEED_FALLBACK_LOG_CAP) {
|
|
1764
|
+
const oldest = workerFeedOwnerDmFallbackLogged.values().next().value
|
|
1765
|
+
if (oldest != null) workerFeedOwnerDmFallbackLogged.delete(oldest)
|
|
1766
|
+
}
|
|
1767
|
+
process.stderr.write(
|
|
1768
|
+
`telegram gateway: worker-feed origin unresolved agent=${agentId} β routing card to owner DM\n`,
|
|
1769
|
+
)
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
return { chatId: ownerDm, threadId: origin?.threadId }
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1491
1775
|
// βββ Periodic history reaper (#1073) ββββββββββββββββββββββββββββββββββββββ
|
|
1492
1776
|
// The init-time prune in history.ts only touched the `messages` table.
|
|
1493
1777
|
// `subagents` and `turns` in registry.db grew unbounded β every Agent()
|
|
@@ -2176,6 +2460,110 @@ function deliverResumeSyntheticOrBuffer(agent: string, inbound: InboundMessage):
|
|
|
2176
2460
|
return delivered
|
|
2177
2461
|
}
|
|
2178
2462
|
|
|
2463
|
+
/** Outcome of routing an agent-authored button tap through the turn-safe
|
|
2464
|
+
* delivery machinery. `buffered-mid-turn` and `delivered` both mean "the tap
|
|
2465
|
+
* will be actioned" (the mid-turn case flushes on turn-complete); only
|
|
2466
|
+
* `buffered-bridge-offline` needs the user-facing "agent is restarting" notice. */
|
|
2467
|
+
type ButtonTapDeliveryOutcome = 'delivered' | 'buffered-mid-turn' | 'buffered-bridge-offline'
|
|
2468
|
+
|
|
2469
|
+
/**
|
|
2470
|
+
* Deliver an agent-authored inline-keyboard button tap (`agent:` callback_data)
|
|
2471
|
+
* through the SAME turn-safe machinery as a normal Telegram inbound, instead of
|
|
2472
|
+
* the old raw `sendToAgent` + buffer-only-on-bridge-miss.
|
|
2473
|
+
*
|
|
2474
|
+
* THE BUG (#271 button path, verified 2026-07): the `agent:` callback handler
|
|
2475
|
+
* delivered the synthesized tap inbound with a bare `ipcServer.sendToAgent`,
|
|
2476
|
+
* marked busy, and buffered ONLY when the bridge was offline. It never ran the
|
|
2477
|
+
* #1556 turn gate β so a tap landing WHILE a turn is in flight fired the MCP
|
|
2478
|
+
* channel notification mid-turn, typed into the CLI composer, and stranded there
|
|
2479
|
+
* (the lawgpt/marko wedge). It also skipped the pre-send composer clear and the
|
|
2480
|
+
* deliver-until-acked tracking, so a stranded tap was never sweep-redelivered.
|
|
2481
|
+
*
|
|
2482
|
+
* Fix: reuse the resume-synthetic turn gate (mid-turn β `buffer-until-idle`, the
|
|
2483
|
+
* turn-complete hook + idle-drain flush it the instant claude goes idle), the
|
|
2484
|
+
* pre-send composer clear, and the delivery-confirm tracking β exactly like the
|
|
2485
|
+
* `handleInbound` fresh-turn path. A button tap carries no `meta.source` and a
|
|
2486
|
+
* non-empty body, so `shouldTrackDelivery` enrols it; we additionally require a
|
|
2487
|
+
* real `meta.message_id` so the `enqueue` ack has something to match (else the
|
|
2488
|
+
* never-drop sweep would storm). The tap UX is unchanged β the ack toast, the
|
|
2489
|
+
* single-use keyboard strip, and the bridge-offline spool + restart notice all
|
|
2490
|
+
* stay at the call site; only delivery timing/safety changes.
|
|
2491
|
+
*/
|
|
2492
|
+
async function deliverButtonTapInbound(
|
|
2493
|
+
agent: string,
|
|
2494
|
+
inbound: InboundMessage,
|
|
2495
|
+
): Promise<ButtonTapDeliveryOutcome> {
|
|
2496
|
+
// #1556 turn gate β same authoritative "is a turn in flight?" read the
|
|
2497
|
+
// resume-synthetic path uses. Mid-turn β hold in the pending-inbound buffer;
|
|
2498
|
+
// the turn-complete hook + idle-drain timer flush it when claude goes idle,
|
|
2499
|
+
// where it lands cleanly as a fresh turn instead of stranding in the composer.
|
|
2500
|
+
const { decision, reserve } = reserveInboundDelivery({
|
|
2501
|
+
turnInFlight: turnInFlightForGate(),
|
|
2502
|
+
isSteering: false,
|
|
2503
|
+
isInterrupt: false,
|
|
2504
|
+
})
|
|
2505
|
+
if (decision === 'buffer-until-idle') {
|
|
2506
|
+
pendingInboundBuffer.push(agent, inbound)
|
|
2507
|
+
return 'buffered-mid-turn'
|
|
2508
|
+
}
|
|
2509
|
+
// #2917 per-chat FIFO: reserve the chat's busy key SYNCHRONOUSLY β before the
|
|
2510
|
+
// composer-clear await below β so a concurrent same-chat inbound reaching the
|
|
2511
|
+
// live gate observes this in-flight delivery and buffers behind it. Released
|
|
2512
|
+
// in lockstep below if the send misses (bridge offline).
|
|
2513
|
+
let reservedBusyKey: string | null = null
|
|
2514
|
+
if (reserve && SERIALIZE_INBOUND_DELIVERY_ENABLED) {
|
|
2515
|
+
reservedBusyKey = markClaudeBusyForInbound(inbound)
|
|
2516
|
+
}
|
|
2517
|
+
// Pre-send composer clear (the marko wedge) β wipe stale typed-ahead / ghost
|
|
2518
|
+
// text so the channel notification lands at a clean line and auto-submits.
|
|
2519
|
+
// Soft-fail by contract: a clear failure must NEVER block delivery.
|
|
2520
|
+
if (agent) {
|
|
2521
|
+
try {
|
|
2522
|
+
const { clearAgentComposer } = await import('../../src/agents/tmux.js')
|
|
2523
|
+
const cleared = clearAgentComposer({ agentName: agent })
|
|
2524
|
+
if ('error' in cleared) {
|
|
2525
|
+
process.stderr.write(
|
|
2526
|
+
`telegram gateway: button-tap pre-send composer-clear soft-failed agent=${agent}: ${cleared.error} β delivering anyway\n`,
|
|
2527
|
+
)
|
|
2528
|
+
}
|
|
2529
|
+
} catch (err) {
|
|
2530
|
+
process.stderr.write(
|
|
2531
|
+
`telegram gateway: button-tap pre-send composer-clear threw agent=${agent}: ${(err as Error).message} β delivering anyway\n`,
|
|
2532
|
+
)
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
const delivered = ipcServer.sendToAgent(agent, inbound)
|
|
2536
|
+
if (delivered) {
|
|
2537
|
+
const busyKey = reservedBusyKey ?? markClaudeBusyForInbound(inbound)
|
|
2538
|
+
// Track until claude acks via `enqueue` so the deliver-until-acked sweep
|
|
2539
|
+
// re-delivers a tap stranded in the composer. Only when we have a real
|
|
2540
|
+
// message_id to match the ack against β otherwise the never-drop loop storms.
|
|
2541
|
+
if (
|
|
2542
|
+
DELIVERY_CONFIRM_ENABLED &&
|
|
2543
|
+
inbound.meta?.message_id != null &&
|
|
2544
|
+
inbound.meta.message_id !== '' &&
|
|
2545
|
+
shouldTrackDelivery({
|
|
2546
|
+
isSteering: false,
|
|
2547
|
+
isInterrupt: false,
|
|
2548
|
+
hasSource: inbound.meta?.source != null,
|
|
2549
|
+
effectiveText: inbound.text,
|
|
2550
|
+
})
|
|
2551
|
+
) {
|
|
2552
|
+
trackDelivery(deliveryQueue, busyKey, inbound, Date.now(), String(inbound.messageId))
|
|
2553
|
+
}
|
|
2554
|
+
return 'delivered'
|
|
2555
|
+
}
|
|
2556
|
+
// Bridge offline: release the synchronous reservation in lockstep (else the
|
|
2557
|
+
// orphaned busy key gates every later inbound into the buffer), then spool the
|
|
2558
|
+
// tap so it replays on reconnect β same behaviour as before this fix.
|
|
2559
|
+
if (reservedBusyKey != null) {
|
|
2560
|
+
claudeBusyKeys.delete(reservedBusyKey)
|
|
2561
|
+
claudeBusyKeySince.delete(reservedBusyKey)
|
|
2562
|
+
}
|
|
2563
|
+
pendingInboundBuffer.push(agent, inbound)
|
|
2564
|
+
return 'buffered-bridge-offline'
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2179
2567
|
const pendingRestarts = new Map<string, number>() // agentName -> timestamp when restart was requested
|
|
2180
2568
|
|
|
2181
2569
|
// βββ Proactive context compaction (session.max_context_tokens) ββββββββββ
|
|
@@ -2405,6 +2793,13 @@ type CurrentTurn = {
|
|
|
2405
2793
|
// resume protocol uses this to decide "did the previous turn actually
|
|
2406
2794
|
// finish a reply, or was it interrupted before commit?".
|
|
2407
2795
|
lastAssistantDone: boolean
|
|
2796
|
+
// Live model in use for THIS turn, sourced from the main transcript's
|
|
2797
|
+
// `message.model` (the exact resolved model per API call) via the session-tail
|
|
2798
|
+
// `model` event β never from config or launch-time state. Updated on change;
|
|
2799
|
+
// undefined until the turn's first assistant line lands. Rendered onto the
|
|
2800
|
+
// activity/liveness card header's metrics line (e.g. "2m Β· 14 tools Β· opus 4.8")
|
|
2801
|
+
// and preferred by /status's buildAgentMetadata over the in-memory override.
|
|
2802
|
+
currentModel?: string
|
|
2408
2803
|
// Phase 1 of #332: count of tool_use events in the current turn, for
|
|
2409
2804
|
// the tool_call_count column in the turns registry.
|
|
2410
2805
|
toolCallCount: number
|
|
@@ -2530,6 +2925,16 @@ type CurrentTurn = {
|
|
|
2530
2925
|
// is never written and this is exactly the old singleton.
|
|
2531
2926
|
let currentTurn: CurrentTurn | null = null
|
|
2532
2927
|
const currentTurnMap = new CurrentTurnMap<CurrentTurn>()
|
|
2928
|
+
// Freshness-aware /status session-model source. Two writers: the session-tail
|
|
2929
|
+
// `model` event (each assistant line's `message.model` β ground truth for the
|
|
2930
|
+
// last API call, survives between turns) and the #2982 /model override (set
|
|
2931
|
+
// the instant a switch is confirmed β the ONLY truthful source in the
|
|
2932
|
+
// idle-after-switch window, before the next assistant line lands). Every write
|
|
2933
|
+
// is seq-stamped and `resolve()` prefers the NEWER observation, so neither
|
|
2934
|
+
// source can go stale behind the other (session-model-source.ts, pinned by
|
|
2935
|
+
// tests/session-model-source.test.ts). buildAgentMetadata reads resolve();
|
|
2936
|
+
// the /model command paths write via setOverride.
|
|
2937
|
+
const sessionModelSource = createSessionModelSource()
|
|
2533
2938
|
// Captures the most-recently-started turn's sessionChatId. Unlike currentTurn,
|
|
2534
2939
|
// this is NOT cleared by the silence poke (firePoke/clearTurnStarted). It lets
|
|
2535
2940
|
// the Bug B fallback in executeReply route to the correct chat even when the
|
|
@@ -4070,9 +4475,26 @@ async function resolveCompactCard(
|
|
|
4070
4475
|
{ chat_id: card.chatId, verb: `proactiveCompact.${kind}` },
|
|
4071
4476
|
);
|
|
4072
4477
|
} catch (err) {
|
|
4478
|
+
// Best-effort status-card edit β a transport hiccup (429 / "not
|
|
4479
|
+
// modified" / message gone) is not a liveness-logic error and must
|
|
4480
|
+
// not log a "card edit failed" warning (the warning-as-excuse-for-a-
|
|
4481
|
+
// stale-card anti-pattern). Only a genuine unexpected error warrants
|
|
4482
|
+
// a line; transport classes are silent.
|
|
4483
|
+
const desc = err instanceof Error ? err.message : String(err);
|
|
4484
|
+
const low = desc.toLowerCase();
|
|
4485
|
+
if (
|
|
4486
|
+
low.includes('not modified') ||
|
|
4487
|
+
low.includes('not found') ||
|
|
4488
|
+
low.includes("can't be edited") ||
|
|
4489
|
+
low.includes('cannot be edited') ||
|
|
4490
|
+
low.includes('not enough rights') ||
|
|
4491
|
+
low.includes('429') ||
|
|
4492
|
+
low.includes('retry after')
|
|
4493
|
+
) {
|
|
4494
|
+
return;
|
|
4495
|
+
}
|
|
4073
4496
|
process.stderr.write(
|
|
4074
|
-
`telegram gateway: proactive-compact ${kind} card edit failed:
|
|
4075
|
-
`${err instanceof Error ? err.message : String(err)}\n`,
|
|
4497
|
+
`telegram gateway: proactive-compact ${kind} card edit failed: ${desc}\n`,
|
|
4076
4498
|
);
|
|
4077
4499
|
}
|
|
4078
4500
|
}
|
|
@@ -5122,17 +5544,26 @@ interface PendingVaultRequestSave {
|
|
|
5122
5544
|
why?: string
|
|
5123
5545
|
/** Unix-ms timestamp; entries are reaped after VAULT_REQUEST_SAVE_TTL_MS. */
|
|
5124
5546
|
staged_at: number
|
|
5547
|
+
/** Set on entries RESTORED from disk after a gateway restart. The staged
|
|
5548
|
+
* secret `value` is held in memory only (never persisted β secrets
|
|
5549
|
+
* hygiene), so a restored entry has an empty value and cannot complete the
|
|
5550
|
+
* write. A Save tap on such a card degrades gracefully: it tells the agent
|
|
5551
|
+
* the value was lost to a restart instead of writing an empty secret. */
|
|
5552
|
+
restoredWithoutValue?: boolean
|
|
5125
5553
|
}
|
|
5126
5554
|
const pendingVaultRequestSaves = new Map<string, PendingVaultRequestSave>()
|
|
5127
5555
|
// Gateway-side reap window for a staged vault-save card. Tracks the operator
|
|
5128
5556
|
// approval-card lifetime (config-driven, 60-min default) so the reap never
|
|
5129
5557
|
// races ahead of the card the operator is still looking at.
|
|
5130
5558
|
const VAULT_REQUEST_SAVE_TTL_MS = approvalTtlMs()
|
|
5131
|
-
function sweepPendingVaultRequestSaves(): void {
|
|
5132
|
-
|
|
5133
|
-
|
|
5134
|
-
|
|
5135
|
-
|
|
5559
|
+
function sweepPendingVaultRequestSaves(now = Date.now()): void {
|
|
5560
|
+
sweepExpiredEntries(
|
|
5561
|
+
pendingVaultRequestSaves,
|
|
5562
|
+
(v, n) => v.staged_at < n - VAULT_REQUEST_SAVE_TTL_MS,
|
|
5563
|
+
expireVaultSaveCard,
|
|
5564
|
+
now,
|
|
5565
|
+
cardExpiryLog,
|
|
5566
|
+
)
|
|
5136
5567
|
}
|
|
5137
5568
|
|
|
5138
5569
|
/**
|
|
@@ -5175,11 +5606,14 @@ const pendingVaultRequestAccesses = new Map<string, PendingVaultRequestAccess>()
|
|
|
5175
5606
|
// Gateway-side reap window for a staged vault-access card. Tracks the operator
|
|
5176
5607
|
// approval-card lifetime (config-driven, 60-min default) β see approvalTtlMs.
|
|
5177
5608
|
const VAULT_REQUEST_ACCESS_TTL_MS = approvalTtlMs()
|
|
5178
|
-
function sweepPendingVaultRequestAccesses(): void {
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5609
|
+
function sweepPendingVaultRequestAccesses(now = Date.now()): void {
|
|
5610
|
+
sweepExpiredEntries(
|
|
5611
|
+
pendingVaultRequestAccesses,
|
|
5612
|
+
(v, n) => v.staged_at < n - VAULT_REQUEST_ACCESS_TTL_MS,
|
|
5613
|
+
expireVaultAccessCard,
|
|
5614
|
+
now,
|
|
5615
|
+
cardExpiryLog,
|
|
5616
|
+
)
|
|
5183
5617
|
}
|
|
5184
5618
|
|
|
5185
5619
|
/**
|
|
@@ -5211,23 +5645,14 @@ const MENTAL_MODEL_PROPOSE_TTL_MS = approvalTtlMs()
|
|
|
5211
5645
|
// posted card's keyboard away, so a stale card left in the chat can't be tapped
|
|
5212
5646
|
// into a "Card expired" answer β the operator sees the β expiry inline instead.
|
|
5213
5647
|
// Best-effort: card edits are fire-and-forget (the entry is removed regardless).
|
|
5214
|
-
function sweepPendingMentalModelProposes(): void {
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
v.chat_id,
|
|
5223
|
-
v.card_message_id,
|
|
5224
|
-
richMessage('β _This mental-model proposal card expired. Ask the agent to re-propose if it still stands._'),
|
|
5225
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
5226
|
-
)
|
|
5227
|
-
.catch(() => {})
|
|
5228
|
-
}
|
|
5229
|
-
}
|
|
5230
|
-
}
|
|
5648
|
+
function sweepPendingMentalModelProposes(now = Date.now()): void {
|
|
5649
|
+
sweepExpiredEntries(
|
|
5650
|
+
pendingMentalModelProposes,
|
|
5651
|
+
(v, n) => v.staged_at < n - MENTAL_MODEL_PROPOSE_TTL_MS,
|
|
5652
|
+
expireMentalModelProposeCard,
|
|
5653
|
+
now,
|
|
5654
|
+
cardExpiryLog,
|
|
5655
|
+
)
|
|
5231
5656
|
}
|
|
5232
5657
|
|
|
5233
5658
|
// Sliding-window rate limit for mental-model proposals: at most
|
|
@@ -5464,6 +5889,296 @@ function isAutoFallbackCooldownActive(_agentName: string, now: number): boolean
|
|
|
5464
5889
|
}
|
|
5465
5890
|
}
|
|
5466
5891
|
|
|
5892
|
+
// ββ Agent-initiated approval-card TTL expiry β wake the parked agent ββββββββ
|
|
5893
|
+
//
|
|
5894
|
+
// The four agent-initiated approval-card families (vault_request_access /
|
|
5895
|
+
// vault_request_save / request_secret / mental_model_propose) each park the
|
|
5896
|
+
// requesting agent (it ends its turn to wait for the operator's tap). Before
|
|
5897
|
+
// this, an unanswered card that TTL-expired left the agent parked FOREVER: the
|
|
5898
|
+
// lazy sweep just deleted the in-memory entry and nothing woke the agent. Each
|
|
5899
|
+
// expire* helper mirrors the permission-card timeout path (#2411 / #2862) by
|
|
5900
|
+
// routing through the pure `expirePendingCard` core (pending-card-expiry.ts),
|
|
5901
|
+
// whose ordering + fault-isolation contract is behaviorally pinned by
|
|
5902
|
+
// pending-card-expiry.test.ts:
|
|
5903
|
+
// 1. drop the in-memory entry AND its durable store record FIRST (single-
|
|
5904
|
+
// shot β a second tick can never double-fire the wake),
|
|
5905
|
+
// 2. edit the card to a β expired state + strip its keyboard (best-effort),
|
|
5906
|
+
// 3. record it in missedApprovalsStore BEFORE delivering, so a throwing
|
|
5907
|
+
// deliver can't lose the re-offer for the operator's return,
|
|
5908
|
+
// 4. inject a TIMEOUT-outcome synthetic inbound (turn-gated via
|
|
5909
|
+
// deliverResumeSyntheticOrBuffer, guarded β a half-dead IPC socket that
|
|
5910
|
+
// throws on write is contained, never escaping the reaper's setInterval
|
|
5911
|
+
// callback into an uncaughtException gateway shutdown).
|
|
5912
|
+
// Called from BOTH the lazy sweeps (on next stage) and the pendingStateReaper
|
|
5913
|
+
// (every 60s β the authoritative timer so an idle gateway still wakes agents).
|
|
5914
|
+
|
|
5915
|
+
async function editCardExpired(chatId: string, messageId: number | undefined, body: string): Promise<void> {
|
|
5916
|
+
if (messageId == null) return
|
|
5917
|
+
await lockedBot.api
|
|
5918
|
+
// 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.
|
|
5919
|
+
.editMessageText(chatId, messageId, richMessage(body), { reply_markup: { inline_keyboard: [] } })
|
|
5920
|
+
.catch(() => {})
|
|
5921
|
+
}
|
|
5922
|
+
|
|
5923
|
+
function recordMissedApproval(opts: {
|
|
5924
|
+
stageId: string
|
|
5925
|
+
toolName: string
|
|
5926
|
+
action: string
|
|
5927
|
+
chatId: string
|
|
5928
|
+
threadId?: number
|
|
5929
|
+
now: number
|
|
5930
|
+
}): void {
|
|
5931
|
+
if (!MISSED_APPROVAL_REOFFER_ENABLED) return
|
|
5932
|
+
missedApprovalsStore.add({
|
|
5933
|
+
requestId: opts.stageId,
|
|
5934
|
+
toolName: opts.toolName,
|
|
5935
|
+
action: opts.action,
|
|
5936
|
+
chatId: opts.chatId,
|
|
5937
|
+
threadId: opts.threadId ?? null,
|
|
5938
|
+
timedOutAt: opts.now,
|
|
5939
|
+
})
|
|
5940
|
+
}
|
|
5941
|
+
|
|
5942
|
+
const cardExpiryLog = (msg: string): void => {
|
|
5943
|
+
process.stderr.write(`telegram gateway: ${msg}\n`)
|
|
5944
|
+
}
|
|
5945
|
+
|
|
5946
|
+
function expireVaultAccessCard(stageId: string, v: PendingVaultRequestAccess, now: number): void {
|
|
5947
|
+
const timeoutMinutes = Math.round(VAULT_REQUEST_ACCESS_TTL_MS / 60000)
|
|
5948
|
+
const { delivered } = expirePendingCard({
|
|
5949
|
+
remove: () => {
|
|
5950
|
+
pendingVaultRequestAccesses.delete(stageId)
|
|
5951
|
+
pendingCardStore.remove(stageId)
|
|
5952
|
+
},
|
|
5953
|
+
editCard: () => void editCardExpired(
|
|
5954
|
+
v.chat_id,
|
|
5955
|
+
v.card_message_id,
|
|
5956
|
+
`β _This vault access request for \`${escapeHtmlForTg(v.key)}\` timed out before you tapped. Ask **${escapeHtmlForTg(v.agent)}** to re-request if it still stands._`,
|
|
5957
|
+
),
|
|
5958
|
+
buildInbound: () => buildVaultAccessTimeoutInbound({
|
|
5959
|
+
agent: v.agent,
|
|
5960
|
+
chatId: v.chat_id,
|
|
5961
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
5962
|
+
stageId,
|
|
5963
|
+
timeoutMinutes,
|
|
5964
|
+
key: v.key,
|
|
5965
|
+
scope: v.scope,
|
|
5966
|
+
}),
|
|
5967
|
+
deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
|
|
5968
|
+
recordMiss: () => recordMissedApproval({
|
|
5969
|
+
stageId,
|
|
5970
|
+
toolName: 'vault_request_access',
|
|
5971
|
+
action: `grant ${v.agent} ${v.scope} access to \`${v.key}\``,
|
|
5972
|
+
chatId: v.chat_id,
|
|
5973
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
5974
|
+
now,
|
|
5975
|
+
}),
|
|
5976
|
+
log: cardExpiryLog,
|
|
5977
|
+
})
|
|
5978
|
+
process.stderr.write(
|
|
5979
|
+
`telegram gateway: vault_request_access TTL expired β wake agent=${v.agent} ` +
|
|
5980
|
+
`key=${v.key} stage=${stageId} delivered=${delivered}\n`,
|
|
5981
|
+
)
|
|
5982
|
+
}
|
|
5983
|
+
|
|
5984
|
+
function expireVaultSaveCard(stageId: string, v: PendingVaultRequestSave, now: number): void {
|
|
5985
|
+
const timeoutMinutes = Math.round(VAULT_REQUEST_SAVE_TTL_MS / 60000)
|
|
5986
|
+
const { delivered } = expirePendingCard({
|
|
5987
|
+
remove: () => {
|
|
5988
|
+
pendingVaultRequestSaves.delete(stageId)
|
|
5989
|
+
pendingCardStore.remove(stageId)
|
|
5990
|
+
},
|
|
5991
|
+
editCard: () => void editCardExpired(
|
|
5992
|
+
v.chat_id,
|
|
5993
|
+
v.card_message_id,
|
|
5994
|
+
`β _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._`,
|
|
5995
|
+
),
|
|
5996
|
+
buildInbound: () => buildVaultSaveTimeoutInbound({
|
|
5997
|
+
agent: v.agent,
|
|
5998
|
+
chatId: v.chat_id,
|
|
5999
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
6000
|
+
stageId,
|
|
6001
|
+
timeoutMinutes,
|
|
6002
|
+
key: v.key,
|
|
6003
|
+
}),
|
|
6004
|
+
deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
|
|
6005
|
+
recordMiss: () => recordMissedApproval({
|
|
6006
|
+
stageId,
|
|
6007
|
+
toolName: 'vault_request_save',
|
|
6008
|
+
action: `save the secret \`${v.key}\` for ${v.agent}`,
|
|
6009
|
+
chatId: v.chat_id,
|
|
6010
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
6011
|
+
now,
|
|
6012
|
+
}),
|
|
6013
|
+
log: cardExpiryLog,
|
|
6014
|
+
})
|
|
6015
|
+
process.stderr.write(
|
|
6016
|
+
`telegram gateway: vault_request_save TTL expired β wake agent=${v.agent} ` +
|
|
6017
|
+
`key=${v.key} stage=${stageId} delivered=${delivered}\n`,
|
|
6018
|
+
)
|
|
6019
|
+
}
|
|
6020
|
+
|
|
6021
|
+
function expireSecretRequestCard(stageId: string, v: PendingSecretRequest, now: number): void {
|
|
6022
|
+
const timeoutMinutes = Math.round(PENDING_SECRET_REQUEST_TTL_MS / 60000)
|
|
6023
|
+
const { delivered } = expirePendingCard({
|
|
6024
|
+
remove: () => {
|
|
6025
|
+
pendingSecretRequests.delete(stageId)
|
|
6026
|
+
pendingCardStore.remove(stageId)
|
|
6027
|
+
},
|
|
6028
|
+
editCard: () => void editCardExpired(
|
|
6029
|
+
v.chat_id,
|
|
6030
|
+
v.card_message_id,
|
|
6031
|
+
`β _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._`,
|
|
6032
|
+
),
|
|
6033
|
+
buildInbound: () => buildSecretRequestTimeoutInbound({
|
|
6034
|
+
agent: v.agent,
|
|
6035
|
+
chatId: v.chat_id,
|
|
6036
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
6037
|
+
stageId,
|
|
6038
|
+
timeoutMinutes,
|
|
6039
|
+
key: v.key,
|
|
6040
|
+
}),
|
|
6041
|
+
deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
|
|
6042
|
+
recordMiss: () => recordMissedApproval({
|
|
6043
|
+
stageId,
|
|
6044
|
+
toolName: 'request_secret',
|
|
6045
|
+
action: `provide the secret \`${v.key}\` for ${v.agent}`,
|
|
6046
|
+
chatId: v.chat_id,
|
|
6047
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
6048
|
+
now,
|
|
6049
|
+
}),
|
|
6050
|
+
log: cardExpiryLog,
|
|
6051
|
+
})
|
|
6052
|
+
process.stderr.write(
|
|
6053
|
+
`telegram gateway: request_secret TTL expired β wake agent=${v.agent} ` +
|
|
6054
|
+
`key=${v.key} stage=${stageId} delivered=${delivered}\n`,
|
|
6055
|
+
)
|
|
6056
|
+
}
|
|
6057
|
+
|
|
6058
|
+
function expireMentalModelProposeCard(stageId: string, v: PendingMentalModelPropose, now: number): void {
|
|
6059
|
+
const timeoutMinutes = Math.round(MENTAL_MODEL_PROPOSE_TTL_MS / 60000)
|
|
6060
|
+
const { delivered } = expirePendingCard({
|
|
6061
|
+
remove: () => {
|
|
6062
|
+
pendingMentalModelProposes.delete(stageId)
|
|
6063
|
+
pendingCardStore.remove(stageId)
|
|
6064
|
+
},
|
|
6065
|
+
editCard: () => void editCardExpired(
|
|
6066
|
+
v.chat_id,
|
|
6067
|
+
v.card_message_id,
|
|
6068
|
+
`β _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._`,
|
|
6069
|
+
),
|
|
6070
|
+
buildInbound: () => buildMentalModelProposeTimeoutInbound({
|
|
6071
|
+
agent: v.agent,
|
|
6072
|
+
chatId: v.chat_id,
|
|
6073
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
6074
|
+
stageId,
|
|
6075
|
+
timeoutMinutes,
|
|
6076
|
+
name: v.spec.name,
|
|
6077
|
+
}),
|
|
6078
|
+
deliver: (inbound) => deliverResumeSyntheticOrBuffer(v.agent, inbound),
|
|
6079
|
+
recordMiss: () => recordMissedApproval({
|
|
6080
|
+
stageId,
|
|
6081
|
+
toolName: 'mental_model_propose',
|
|
6082
|
+
action: `declare the mental model \`${v.spec.name}\` for ${v.agent}`,
|
|
6083
|
+
chatId: v.chat_id,
|
|
6084
|
+
...(v.threadId != null ? { threadId: v.threadId } : {}),
|
|
6085
|
+
now,
|
|
6086
|
+
}),
|
|
6087
|
+
log: cardExpiryLog,
|
|
6088
|
+
})
|
|
6089
|
+
process.stderr.write(
|
|
6090
|
+
`telegram gateway: mental_model_propose TTL expired β wake agent=${v.agent} ` +
|
|
6091
|
+
`name=${v.spec.name} stage=${stageId} delivered=${delivered}\n`,
|
|
6092
|
+
)
|
|
6093
|
+
}
|
|
6094
|
+
|
|
6095
|
+
// Run all four agent-initiated approval-card expiry sweeps. Called from the
|
|
6096
|
+
// pendingStateReaper (the authoritative 60s timer). Each family sweep is
|
|
6097
|
+
// per-entry guarded via sweepExpiredEntries, so one throwing expiry (dead IPC
|
|
6098
|
+
// socket, store IO error) can't skip the remaining entries or families.
|
|
6099
|
+
function sweepExpiredApprovalCards(now: number): void {
|
|
6100
|
+
sweepPendingVaultRequestAccesses(now)
|
|
6101
|
+
sweepPendingVaultRequestSaves(now)
|
|
6102
|
+
sweepPendingMentalModelProposes(now)
|
|
6103
|
+
sweepSecretRequests(now)
|
|
6104
|
+
}
|
|
6105
|
+
|
|
6106
|
+
// Boot restore: repopulate the four in-memory approval-card maps from the
|
|
6107
|
+
// durable store so a post-restart tap on a still-valid card resolves normally
|
|
6108
|
+
// (approve β grant + synthetic; deny β denial synthetic) instead of hitting
|
|
6109
|
+
// the "Card expired" tombstone. Entries already past their TTL are left for
|
|
6110
|
+
// the reaper's next tick, which wakes the parked agent via the timeout path.
|
|
6111
|
+
// vault_request_save entries restore WITHOUT their staged value (never
|
|
6112
|
+
// persisted) and are flagged `restoredWithoutValue` so a Save tap degrades
|
|
6113
|
+
// gracefully rather than writing an empty secret.
|
|
6114
|
+
function restorePendingApprovalCards(): number {
|
|
6115
|
+
let restored = 0
|
|
6116
|
+
for (const e of pendingCardStore.loadAll()) {
|
|
6117
|
+
try {
|
|
6118
|
+
if (e.family === 'vault_request_access') {
|
|
6119
|
+
pendingVaultRequestAccesses.set(e.stageId, {
|
|
6120
|
+
agent: e.agent,
|
|
6121
|
+
chat_id: e.chatId,
|
|
6122
|
+
...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
|
|
6123
|
+
...(e.threadId != null ? { threadId: e.threadId } : {}),
|
|
6124
|
+
key: e.key,
|
|
6125
|
+
scope: e.scope,
|
|
6126
|
+
...(e.reason != null ? { reason: e.reason } : {}),
|
|
6127
|
+
ttl_seconds: e.ttlSeconds,
|
|
6128
|
+
staged_at: e.stagedAt,
|
|
6129
|
+
})
|
|
6130
|
+
restored++
|
|
6131
|
+
} else if (e.family === 'vault_request_save') {
|
|
6132
|
+
pendingVaultRequestSaves.set(e.stageId, {
|
|
6133
|
+
agent: e.agent,
|
|
6134
|
+
chat_id: e.chatId,
|
|
6135
|
+
...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
|
|
6136
|
+
...(e.threadId != null ? { threadId: e.threadId } : {}),
|
|
6137
|
+
key: e.key,
|
|
6138
|
+
kind: e.kind,
|
|
6139
|
+
value: '', // never persisted β secrets hygiene
|
|
6140
|
+
...(e.why != null ? { why: e.why } : {}),
|
|
6141
|
+
staged_at: e.stagedAt,
|
|
6142
|
+
restoredWithoutValue: true,
|
|
6143
|
+
})
|
|
6144
|
+
restored++
|
|
6145
|
+
} else if (e.family === 'request_secret') {
|
|
6146
|
+
pendingSecretRequests.set(e.stageId, {
|
|
6147
|
+
agent: e.agent,
|
|
6148
|
+
chat_id: e.chatId,
|
|
6149
|
+
...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
|
|
6150
|
+
...(e.threadId != null ? { threadId: e.threadId } : {}),
|
|
6151
|
+
key: e.key,
|
|
6152
|
+
...(e.reason != null ? { reason: e.reason } : {}),
|
|
6153
|
+
staged_at: e.stagedAt,
|
|
6154
|
+
})
|
|
6155
|
+
restored++
|
|
6156
|
+
} else if (e.family === 'mental_model_propose') {
|
|
6157
|
+
pendingMentalModelProposes.set(e.stageId, {
|
|
6158
|
+
agent: e.agent,
|
|
6159
|
+
chat_id: e.chatId,
|
|
6160
|
+
...(e.cardMessageId != null ? { card_message_id: e.cardMessageId } : {}),
|
|
6161
|
+
...(e.threadId != null ? { threadId: e.threadId } : {}),
|
|
6162
|
+
spec: e.spec,
|
|
6163
|
+
...(e.reason != null ? { reason: e.reason } : {}),
|
|
6164
|
+
staged_at: e.stagedAt,
|
|
6165
|
+
})
|
|
6166
|
+
restored++
|
|
6167
|
+
}
|
|
6168
|
+
} catch (err) {
|
|
6169
|
+
process.stderr.write(
|
|
6170
|
+
`telegram gateway: pending-card restore skipped a malformed entry: ${(err as Error).message}\n`,
|
|
6171
|
+
)
|
|
6172
|
+
}
|
|
6173
|
+
}
|
|
6174
|
+
if (restored > 0) {
|
|
6175
|
+
process.stderr.write(
|
|
6176
|
+
`telegram gateway: restored ${restored} pending approval card(s) from prior gateway session\n`,
|
|
6177
|
+
)
|
|
6178
|
+
}
|
|
6179
|
+
return restored
|
|
6180
|
+
}
|
|
6181
|
+
|
|
5467
6182
|
// 60-second sweep drops anything past its documented TTL.
|
|
5468
6183
|
const pendingStateReaper = setInterval(() => {
|
|
5469
6184
|
const now = Date.now()
|
|
@@ -5597,6 +6312,23 @@ const pendingStateReaper = setInterval(() => {
|
|
|
5597
6312
|
for (const [k, v] of deferredSecrets) {
|
|
5598
6313
|
if (now - v.staged_at > DEFERRED_SECRET_TTL_MS) deferredSecrets.delete(k)
|
|
5599
6314
|
}
|
|
6315
|
+
// Agent-initiated approval cards (vault_request_access / vault_request_save /
|
|
6316
|
+
// request_secret / mental_model_propose): expire past-TTL entries and WAKE
|
|
6317
|
+
// the parked agent (timeout synthetic + missed-approvals re-offer). This is
|
|
6318
|
+
// the authoritative timer β before this the only expiry path was a lazy
|
|
6319
|
+
// sweep on the NEXT stage, so an agent that ended its turn to wait on one of
|
|
6320
|
+
// these cards could sit parked forever if no further request ever staged.
|
|
6321
|
+
// try/catch matches the sibling sweepStaleTurnActiveMarker guard: an escaped
|
|
6322
|
+
// throw inside this setInterval callback would reach uncaughtException and
|
|
6323
|
+
// take the WHOLE gateway down (per-entry faults are already contained inside
|
|
6324
|
+
// sweepExpiredEntries/expirePendingCard; this is the outer belt).
|
|
6325
|
+
try {
|
|
6326
|
+
sweepExpiredApprovalCards(now)
|
|
6327
|
+
} catch (err) {
|
|
6328
|
+
process.stderr.write(
|
|
6329
|
+
`telegram gateway: approval-card expiry sweep failed: ${(err as Error).message}\n`,
|
|
6330
|
+
)
|
|
6331
|
+
}
|
|
5600
6332
|
// #550: sweep a stale turn-active marker. Defence-in-depth for the
|
|
5601
6333
|
// case where neither the turn_end arm nor onTurnComplete fired (SDK
|
|
5602
6334
|
// killed before the JSONL turn_duration record, compaction window,
|
|
@@ -7282,6 +8014,19 @@ function trackRedeliveredInbound(merged: InboundMessage): void {
|
|
|
7282
8014
|
) {
|
|
7283
8015
|
return
|
|
7284
8016
|
}
|
|
8017
|
+
// Button-tap anti-storm guard β mirrors the immediate-delivery path in
|
|
8018
|
+
// deliverButtonTapInbound. A tap synthesized without a source message
|
|
8019
|
+
// (`cbMessageId == null` β `messageId: 0`, no meta.message_id) has no id
|
|
8020
|
+
// the `enqueue` ack can ever match, so enrolling it would make the
|
|
8021
|
+
// never-drop sweep re-deliver it until TTL. The immediate path skips
|
|
8022
|
+
// tracking for such taps; a tap that buffered mid-turn and flushed through
|
|
8023
|
+
// here must be skipped identically (asymmetry = a storm on one path only).
|
|
8024
|
+
if (
|
|
8025
|
+
merged.meta?.button_callback === 'true' &&
|
|
8026
|
+
(merged.meta.message_id == null || merged.meta.message_id === '')
|
|
8027
|
+
) {
|
|
8028
|
+
return
|
|
8029
|
+
}
|
|
7285
8030
|
const key = chatKey(merged.chatId, merged.threadId != null ? Number(merged.threadId) : null)
|
|
7286
8031
|
trackDelivery(
|
|
7287
8032
|
deliveryQueue,
|
|
@@ -9147,6 +9892,42 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
9147
9892
|
void fireFleetAutoFallback(msg.agentName, untilMs)
|
|
9148
9893
|
},
|
|
9149
9894
|
|
|
9895
|
+
// Issue #2971 β read-only wedge-watchdog probe: is there a live pending
|
|
9896
|
+
// permission request (Telegram approval card) for this agent right now?
|
|
9897
|
+
// Sourced directly from `pendingPermissions` β no mutation, no card
|
|
9898
|
+
// posting, just a snapshot read. The watchdog uses this to decide whether
|
|
9899
|
+
// to Esc a shape-persistent permission-prompt TUI or defer to the card /
|
|
9900
|
+
// the #2724 TTL reaper. Always answered synchronously and on the SAME
|
|
9901
|
+
// connection so the watchdog's short (~2s) budget can resolve promptly.
|
|
9902
|
+
onQueryPendingPermission(client: IpcClient, msg: QueryPendingPermissionMessage) {
|
|
9903
|
+
const self = process.env.SWITCHROOM_AGENT_NAME
|
|
9904
|
+
if (self && msg.agentName !== self) {
|
|
9905
|
+
process.stderr.write(
|
|
9906
|
+
`telegram gateway: query_pending_permission rejected β agent mismatch (${msg.agentName} != ${self})\n`,
|
|
9907
|
+
)
|
|
9908
|
+
try {
|
|
9909
|
+
client.send({ type: 'pending_permission_status', correlationId: msg.correlationId, pending: false })
|
|
9910
|
+
} catch { /* best effort */ }
|
|
9911
|
+
return
|
|
9912
|
+
}
|
|
9913
|
+
// Any LIVE entry answers the question β this gateway serves exactly one
|
|
9914
|
+
// agent, so `pendingPermissions` is already scoped to `msg.agentName`.
|
|
9915
|
+
const [requestId] = pendingPermissions.keys()
|
|
9916
|
+
const pending = pendingPermissions.size > 0
|
|
9917
|
+
try {
|
|
9918
|
+
client.send({
|
|
9919
|
+
type: 'pending_permission_status',
|
|
9920
|
+
correlationId: msg.correlationId,
|
|
9921
|
+
pending,
|
|
9922
|
+
...(pending && requestId ? { requestId } : {}),
|
|
9923
|
+
})
|
|
9924
|
+
} catch (err) {
|
|
9925
|
+
process.stderr.write(
|
|
9926
|
+
`telegram gateway: query_pending_permission reply failed: ${(err as Error).message}\n`,
|
|
9927
|
+
)
|
|
9928
|
+
}
|
|
9929
|
+
},
|
|
9930
|
+
|
|
9150
9931
|
// #2670 one-tap self-improvement β persist a skill-improvement proposal and
|
|
9151
9932
|
// post its Approve/Dismiss card. The store transition + apply-injection on
|
|
9152
9933
|
// Approve are owned by handleSkillProposalCallback (so a gateway restart
|
|
@@ -11634,6 +12415,21 @@ async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{
|
|
|
11634
12415
|
{ threadId, chat_id, verb: 'vault_request_save.card' },
|
|
11635
12416
|
)
|
|
11636
12417
|
pending.card_message_id = sent.message_id
|
|
12418
|
+
// Persist card METADATA (never the staged `value` β secrets hygiene) so a
|
|
12419
|
+
// gateway restart doesn't strand the parked agent. A restored Save tap can't
|
|
12420
|
+
// complete (value is gone) and degrades to a "value lost to restart" wake-up.
|
|
12421
|
+
pendingCardStore.add({
|
|
12422
|
+
family: 'vault_request_save',
|
|
12423
|
+
stageId,
|
|
12424
|
+
agent: pending.agent,
|
|
12425
|
+
chatId: pending.chat_id,
|
|
12426
|
+
...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
|
|
12427
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
12428
|
+
key: pending.key,
|
|
12429
|
+
kind: pending.kind,
|
|
12430
|
+
...(pending.why != null ? { why: pending.why } : {}),
|
|
12431
|
+
stagedAt: pending.staged_at,
|
|
12432
|
+
})
|
|
11637
12433
|
|
|
11638
12434
|
return {
|
|
11639
12435
|
content: [
|
|
@@ -11677,11 +12473,17 @@ const armedSecretCaptures = new Map<string, ArmedSecretCapture>()
|
|
|
11677
12473
|
const PENDING_SECRET_REQUEST_TTL_MS = 30 * 60_000 // card lifetime
|
|
11678
12474
|
const ARMED_SECRET_CAPTURE_TTL_MS = 10 * 60_000 // window to send the value after tapping
|
|
11679
12475
|
|
|
11680
|
-
function sweepSecretRequests(): void {
|
|
11681
|
-
|
|
11682
|
-
|
|
11683
|
-
|
|
11684
|
-
|
|
12476
|
+
function sweepSecretRequests(now = Date.now()): void {
|
|
12477
|
+
sweepExpiredEntries(
|
|
12478
|
+
pendingSecretRequests,
|
|
12479
|
+
(v, n) => n - v.staged_at > PENDING_SECRET_REQUEST_TTL_MS,
|
|
12480
|
+
expireSecretRequestCard,
|
|
12481
|
+
now,
|
|
12482
|
+
cardExpiryLog,
|
|
12483
|
+
)
|
|
12484
|
+
// armedSecretCaptures is a TRANSIENT post-tap window (never persisted): it's
|
|
12485
|
+
// only set after the operator taps [Provide securely], and the request is
|
|
12486
|
+
// no longer parked-on-a-card. Just drop stale ones β no wake needed.
|
|
11685
12487
|
for (const [k, v] of armedSecretCaptures) {
|
|
11686
12488
|
if (now - v.armed_at > ARMED_SECRET_CAPTURE_TTL_MS) armedSecretCaptures.delete(k)
|
|
11687
12489
|
}
|
|
@@ -11731,7 +12533,10 @@ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ co
|
|
|
11731
12533
|
// Dedupe: one open request per (chat, key). Drop any prior stage for
|
|
11732
12534
|
// the same target so the operator never sees stacked cards.
|
|
11733
12535
|
for (const [sid, p] of pendingSecretRequests) {
|
|
11734
|
-
if (p.chat_id === chat_id && p.key === key)
|
|
12536
|
+
if (p.chat_id === chat_id && p.key === key) {
|
|
12537
|
+
pendingSecretRequests.delete(sid)
|
|
12538
|
+
pendingCardStore.remove(sid)
|
|
12539
|
+
}
|
|
11735
12540
|
}
|
|
11736
12541
|
|
|
11737
12542
|
const stageId = randomBytes(4).toString('hex')
|
|
@@ -11753,6 +12558,21 @@ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ co
|
|
|
11753
12558
|
{ threadId, chat_id, verb: 'request_secret.card' },
|
|
11754
12559
|
)
|
|
11755
12560
|
pending.card_message_id = sent.message_id
|
|
12561
|
+
// Persist card metadata so a gateway restart doesn't strand the parked
|
|
12562
|
+
// agent. request_secret holds NO value at staging time (the value arrives
|
|
12563
|
+
// after the operator taps [Provide securely]), so nothing sensitive lands
|
|
12564
|
+
// on disk here.
|
|
12565
|
+
pendingCardStore.add({
|
|
12566
|
+
family: 'request_secret',
|
|
12567
|
+
stageId,
|
|
12568
|
+
agent: pending.agent,
|
|
12569
|
+
chatId: pending.chat_id,
|
|
12570
|
+
...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
|
|
12571
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
12572
|
+
key: pending.key,
|
|
12573
|
+
...(pending.reason != null ? { reason: pending.reason } : {}),
|
|
12574
|
+
stagedAt: pending.staged_at,
|
|
12575
|
+
})
|
|
11756
12576
|
|
|
11757
12577
|
return {
|
|
11758
12578
|
content: [
|
|
@@ -11801,6 +12621,7 @@ async function captureProvidedSecret(
|
|
|
11801
12621
|
armedSecretCaptures.delete(chat_id)
|
|
11802
12622
|
const pending = pendingSecretRequests.get(armed.stageId)
|
|
11803
12623
|
pendingSecretRequests.delete(armed.stageId)
|
|
12624
|
+
pendingCardStore.remove(armed.stageId)
|
|
11804
12625
|
|
|
11805
12626
|
// Delete the raw message FIRST β surfaces a warning if it fails.
|
|
11806
12627
|
if (msgId != null) await deleteSensitiveMessage(chat_id, msgId, 'provided secret value')
|
|
@@ -11916,6 +12737,7 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
|
|
|
11916
12737
|
|
|
11917
12738
|
if (action === 'decline') {
|
|
11918
12739
|
pendingSecretRequests.delete(stageId)
|
|
12740
|
+
pendingCardStore.remove(stageId)
|
|
11919
12741
|
armedSecretCaptures.delete(pending.chat_id)
|
|
11920
12742
|
await ctx.answerCallbackQuery({ text: 'Declined.' }).catch(() => {})
|
|
11921
12743
|
if (pending.card_message_id != null) {
|
|
@@ -12083,12 +12905,27 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
|
|
|
12083
12905
|
{ threadId, chat_id, verb: 'vault_request_access.card' },
|
|
12084
12906
|
)
|
|
12085
12907
|
pending.card_message_id = sent.message_id
|
|
12908
|
+
// Persist card metadata (no secret material β this flow stages only the ACL
|
|
12909
|
+
// request) so a gateway restart doesn't strand the parked agent.
|
|
12910
|
+
pendingCardStore.add({
|
|
12911
|
+
family: 'vault_request_access',
|
|
12912
|
+
stageId,
|
|
12913
|
+
agent: pending.agent,
|
|
12914
|
+
chatId: pending.chat_id,
|
|
12915
|
+
...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
|
|
12916
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
12917
|
+
key: pending.key,
|
|
12918
|
+
scope: pending.scope,
|
|
12919
|
+
...(pending.reason != null ? { reason: pending.reason } : {}),
|
|
12920
|
+
ttlSeconds: pending.ttl_seconds,
|
|
12921
|
+
stagedAt: pending.staged_at,
|
|
12922
|
+
})
|
|
12086
12923
|
|
|
12087
12924
|
return {
|
|
12088
12925
|
content: [
|
|
12089
12926
|
{
|
|
12090
12927
|
type: 'text',
|
|
12091
|
-
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 (
|
|
12928
|
+
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.`,
|
|
12092
12929
|
},
|
|
12093
12930
|
],
|
|
12094
12931
|
}
|
|
@@ -12252,6 +13089,19 @@ async function executeMentalModelPropose(args: Record<string, unknown>): Promise
|
|
|
12252
13089
|
{ threadId, chat_id, verb: 'mental_model_propose.card' },
|
|
12253
13090
|
)
|
|
12254
13091
|
pending.card_message_id = sent.message_id
|
|
13092
|
+
// Persist card metadata (the proposed DECLARATION is not secret material) so
|
|
13093
|
+
// a gateway restart doesn't strand the parked agent.
|
|
13094
|
+
pendingCardStore.add({
|
|
13095
|
+
family: 'mental_model_propose',
|
|
13096
|
+
stageId,
|
|
13097
|
+
agent: pending.agent,
|
|
13098
|
+
chatId: pending.chat_id,
|
|
13099
|
+
...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
|
|
13100
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
13101
|
+
spec: pending.spec,
|
|
13102
|
+
...(pending.reason != null ? { reason: pending.reason } : {}),
|
|
13103
|
+
stagedAt: pending.staged_at,
|
|
13104
|
+
})
|
|
12255
13105
|
// Only count a proposal against the rate budget once its card actually
|
|
12256
13106
|
// posted (validation errors / dupes don't consume the budget).
|
|
12257
13107
|
mentalModelProposeTimes.push(Date.now())
|
|
@@ -12657,6 +13507,7 @@ function composeTurnActivity(turn: CurrentTurn, final = false, liveSuffix = ''):
|
|
|
12657
13507
|
elapsedMs: turn.startedAt > 0 ? Date.now() - turn.startedAt : 0,
|
|
12658
13508
|
toolCount: turn.labeledToolCount,
|
|
12659
13509
|
state: final ? 'done' : 'running',
|
|
13510
|
+
model: turn.currentModel,
|
|
12660
13511
|
}
|
|
12661
13512
|
return renderActivityFeedWithNested(turn.mirrorLines, childLines, final, liveSuffix, stepCount, header)
|
|
12662
13513
|
}
|
|
@@ -12896,7 +13747,22 @@ async function drainActivitySummary(
|
|
|
12896
13747
|
turn.activityLastSentRender = target
|
|
12897
13748
|
} catch (err) {
|
|
12898
13749
|
const msg = err instanceof Error ? err.message : String(err)
|
|
12899
|
-
|
|
13750
|
+
const low = msg.toLowerCase()
|
|
13751
|
+
// Transport-class failures (429, message gone, "not modified") must
|
|
13752
|
+
// NOT inflate `activityDrainFailures` β that counter flags a turn as
|
|
13753
|
+
// DEGRADED on turn-end, and a transient rate-limit or an
|
|
13754
|
+
// already-deleted message is not a logic defect. Counting them would
|
|
13755
|
+
// false-flag a healthy turn. "not modified" is success; gone/429 are
|
|
13756
|
+
// retried or harmless. Only a genuine send/open failure counts.
|
|
13757
|
+
const isTransport =
|
|
13758
|
+
low.includes('not modified') ||
|
|
13759
|
+
low.includes('not found') ||
|
|
13760
|
+
low.includes("can't be edited") ||
|
|
13761
|
+
low.includes('cannot be edited') ||
|
|
13762
|
+
low.includes('not enough rights') ||
|
|
13763
|
+
low.includes('429') ||
|
|
13764
|
+
low.includes('retry after')
|
|
13765
|
+
if (!isTransport) {
|
|
12900
13766
|
turn.activityDrainFailures += 1
|
|
12901
13767
|
// Surface the failing anchor + topic: the resume-400 bug fed a
|
|
12902
13768
|
// fabricated 13-digit message_id as the reply anchor here, so every
|
|
@@ -12959,6 +13825,7 @@ function openLivenessFeedIfDue(turn: CurrentTurn): void {
|
|
|
12959
13825
|
const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ['Workingβ¦']
|
|
12960
13826
|
const livenessHeader: SessionActivityHeader = {
|
|
12961
13827
|
label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
|
|
13828
|
+
model: turn.currentModel,
|
|
12962
13829
|
}
|
|
12963
13830
|
// Liveness card is a single "step" whose start is the turn start, so `age`
|
|
12964
13831
|
// IS the step's own elapsed. formatStepSuffix keeps the `β` line timer-free
|
|
@@ -13089,6 +13956,7 @@ function feedHeartbeatTick(): void {
|
|
|
13089
13956
|
const age = Date.now() - turn.startedAt
|
|
13090
13957
|
const livenessHeader: SessionActivityHeader = {
|
|
13091
13958
|
label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
|
|
13959
|
+
model: turn.currentModel,
|
|
13092
13960
|
}
|
|
13093
13961
|
const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ['Working in backgroundβ¦']
|
|
13094
13962
|
// `subagentAt` is the worker's last ADVANCE β the current step's start β
|
|
@@ -13246,7 +14114,23 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
|
|
|
13246
14114
|
{ chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.delete' },
|
|
13247
14115
|
)
|
|
13248
14116
|
} catch (err) {
|
|
13249
|
-
|
|
14117
|
+
// Best-effort teardown of a status card. A transport-class failure
|
|
14118
|
+
// (message already deleted, chat gone, 429) is not a liveness-logic
|
|
14119
|
+
// error β "message to delete not found" is the desired end state
|
|
14120
|
+
// here, and a 429 on a teardown delete is retried by robustApiCall.
|
|
14121
|
+
// Stay silent on those; warn only on a genuinely unexpected error.
|
|
14122
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
14123
|
+
const low = msg.toLowerCase()
|
|
14124
|
+
if (
|
|
14125
|
+
low.includes('not modified') ||
|
|
14126
|
+
low.includes('not found') ||
|
|
14127
|
+
low.includes('not enough rights') ||
|
|
14128
|
+
low.includes('429') ||
|
|
14129
|
+
low.includes('retry after')
|
|
14130
|
+
) {
|
|
14131
|
+
return
|
|
14132
|
+
}
|
|
14133
|
+
process.stderr.write(`telegram gateway: activity-summary delete failed: ${msg}\n`)
|
|
13250
14134
|
}
|
|
13251
14135
|
return
|
|
13252
14136
|
}
|
|
@@ -13262,6 +14146,7 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
|
|
|
13262
14146
|
const livenessElapsed = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0
|
|
13263
14147
|
const livenessHeader: SessionActivityHeader = {
|
|
13264
14148
|
label: 'Agent', elapsedMs: livenessElapsed, toolCount: turn.labeledToolCount, state: 'done',
|
|
14149
|
+
model: turn.currentModel,
|
|
13265
14150
|
}
|
|
13266
14151
|
finalHtml = renderActivityFeedWithNested(['Workingβ¦'], [], true, '', undefined, livenessHeader)
|
|
13267
14152
|
}
|
|
@@ -13272,10 +14157,26 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
|
|
|
13272
14157
|
{ chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.finalize' },
|
|
13273
14158
|
)
|
|
13274
14159
|
} catch (err) {
|
|
14160
|
+
// Same transport-class discipline as the delete path: the card
|
|
14161
|
+
// finalize is a best-effort liveness edit. "not modified" = the card
|
|
14162
|
+
// already shows the finalized body (success); "not found" / "not
|
|
14163
|
+
// enough rights" = the message is gone (nothing to finalize); 429 is
|
|
14164
|
+
// retried by robustApiCall. None of those are liveness-logic errors
|
|
14165
|
+
// and none warrant a stderr warning. Warn only on the unexpected.
|
|
13275
14166
|
const msg = err instanceof Error ? err.message : String(err)
|
|
13276
|
-
|
|
13277
|
-
|
|
14167
|
+
const low = msg.toLowerCase()
|
|
14168
|
+
if (
|
|
14169
|
+
low.includes('not modified') ||
|
|
14170
|
+
low.includes('not found') ||
|
|
14171
|
+
low.includes("can't be edited") ||
|
|
14172
|
+
low.includes('cannot be edited') ||
|
|
14173
|
+
low.includes('not enough rights') ||
|
|
14174
|
+
low.includes('429') ||
|
|
14175
|
+
low.includes('retry after')
|
|
14176
|
+
) {
|
|
14177
|
+
return
|
|
13278
14178
|
}
|
|
14179
|
+
process.stderr.write(`telegram gateway: activity-summary finalize failed: ${msg}\n`)
|
|
13279
14180
|
}
|
|
13280
14181
|
})
|
|
13281
14182
|
}
|
|
@@ -13736,6 +14637,22 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
13736
14637
|
return
|
|
13737
14638
|
}
|
|
13738
14639
|
case 'dequeue': return
|
|
14640
|
+
case 'model': {
|
|
14641
|
+
// Live model capture for the main turn. The session-tail projection
|
|
14642
|
+
// already filtered sentinels (`<synthetic>` compaction lines), so any
|
|
14643
|
+
// value reaching here is a real resolved model id. Record it on the turn
|
|
14644
|
+
// (update-on-change) so the activity/liveness card header and /status
|
|
14645
|
+
// render the model actually serving this turn's API calls β transcript-
|
|
14646
|
+
// sourced, never config. Also note it on the freshness-aware session-model
|
|
14647
|
+
// source so a /status query between turns still reflects the last model
|
|
14648
|
+
// (and a fresh assistant line reclaims the source from a /model override).
|
|
14649
|
+
const turn = currentTurn
|
|
14650
|
+
if (turn != null) {
|
|
14651
|
+
turn.currentModel = ev.model
|
|
14652
|
+
}
|
|
14653
|
+
sessionModelSource.noteTranscriptModel(ev.model)
|
|
14654
|
+
return
|
|
14655
|
+
}
|
|
13739
14656
|
case 'thinking': {
|
|
13740
14657
|
// #1067: snapshot the turn atom at handler entry. Even though this
|
|
13741
14658
|
// handler is sync, the principle is uniform across all event arms
|
|
@@ -16650,12 +17567,28 @@ async function handleInbound(
|
|
|
16650
17567
|
TOPIC_FRAMING_ENABLED && messageThreadId != null
|
|
16651
17568
|
? 'This message belongs to the current topic only β answer ONLY this question, in this topic. Do not also answer a pending message from another topic.'
|
|
16652
17569
|
: undefined
|
|
17570
|
+
// person_id name resolution (docs/configuration.md, resolve-person.ts):
|
|
17571
|
+
// chat-scoped, boot-time-static lookup β falls back to today's raw
|
|
17572
|
+
// id/username behavior (fail-open) whenever unresolved or when group
|
|
17573
|
+
// membership can't be positively confirmed from access.json's allowFrom.
|
|
17574
|
+
// Never touches access.json itself and never blocks/denies anything.
|
|
17575
|
+
const rawUser = from.username ?? String(from.id)
|
|
17576
|
+
const displayUser = safeResolvePersonName(
|
|
17577
|
+
PERSON_DIRECTORY,
|
|
17578
|
+
{
|
|
17579
|
+
telegramId: String(from.id),
|
|
17580
|
+
username: from.username,
|
|
17581
|
+
isDm: isDmChatId(chat_id),
|
|
17582
|
+
groupAllowFrom: access.groups[chat_id]?.allowFrom,
|
|
17583
|
+
},
|
|
17584
|
+
rawUser,
|
|
17585
|
+
)
|
|
16653
17586
|
const inboundMsg: InboundMessage = {
|
|
16654
17587
|
type: 'inbound',
|
|
16655
17588
|
chatId: chat_id,
|
|
16656
17589
|
...(messageThreadId != null ? { threadId: messageThreadId } : {}),
|
|
16657
17590
|
messageId: msgId ?? 0,
|
|
16658
|
-
user:
|
|
17591
|
+
user: displayUser,
|
|
16659
17592
|
userId: from.id,
|
|
16660
17593
|
ts: ctx.message?.date ?? Math.floor(Date.now() / 1000),
|
|
16661
17594
|
text: effectiveText,
|
|
@@ -16670,7 +17603,7 @@ async function handleInbound(
|
|
|
16670
17603
|
meta: {
|
|
16671
17604
|
chat_id,
|
|
16672
17605
|
...(msgId != null ? { message_id: String(msgId) } : {}),
|
|
16673
|
-
user:
|
|
17606
|
+
user: displayUser,
|
|
16674
17607
|
user_id: String(from.id),
|
|
16675
17608
|
ts: new Date((ctx.message?.date ?? 0) * 1000).toISOString(),
|
|
16676
17609
|
...(messageThreadId != null ? { message_thread_id: String(messageThreadId) } : {}),
|
|
@@ -18286,13 +19219,12 @@ function buildAgentAudit(agentName: string): AgentAudit | undefined {
|
|
|
18286
19219
|
// broker's fleet-wide `ListStateData` payload via
|
|
18287
19220
|
// `buildAuthSummaryFromBroker`, with billingType pulled from the
|
|
18288
19221
|
// agent's `.claude.json` (the broker doesn't track plan tier).
|
|
18289
|
-
|
|
18290
|
-
|
|
18291
|
-
|
|
18292
|
-
|
|
18293
|
-
|
|
18294
|
-
|
|
18295
|
-
let activeSessionModelOverride: string | null = null
|
|
19222
|
+
// The live session-model override set by the `/model` picker (session-only)
|
|
19223
|
+
// lives on `sessionModelSource` (setOverride/getOverride, declared beside the
|
|
19224
|
+
// currentTurn globals). Held in gateway memory so it clears on restart, the
|
|
19225
|
+
// same point at which claude's session reverts to the configured model β
|
|
19226
|
+
// keeping `/status` honest without a persisted store. `resolve()` arbitrates
|
|
19227
|
+
// freshness against the transcript-observed model (#2982 idle-switch window).
|
|
18296
19228
|
|
|
18297
19229
|
async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
|
|
18298
19230
|
type AgentListResp = {
|
|
@@ -18322,7 +19254,18 @@ async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
|
|
|
18322
19254
|
return {
|
|
18323
19255
|
agentName,
|
|
18324
19256
|
model: a?.model ?? null,
|
|
18325
|
-
|
|
19257
|
+
// The FRESHEST session-model observation wins (session-model-source.ts):
|
|
19258
|
+
// the transcript's `message.model` (ground truth for the last API call)
|
|
19259
|
+
// vs the #2982 /model override (the only truthful source in the idle-
|
|
19260
|
+
// after-switch window, before the next assistant line). Both rendered
|
|
19261
|
+
// through formatModelLabel for a consistent short form; an override value
|
|
19262
|
+
// that isn't model-shaped (an already-friendly "Opus 4.8" label) passes
|
|
19263
|
+
// through verbatim. Never sourced from config.
|
|
19264
|
+
sessionModel: (() => {
|
|
19265
|
+
const resolved = sessionModelSource.resolve()
|
|
19266
|
+
if (resolved == null) return null
|
|
19267
|
+
return formatModelLabel(resolved.model) ?? resolved.model
|
|
19268
|
+
})(),
|
|
18326
19269
|
extendsProfile: (a?.extends ?? a?.template) ?? null,
|
|
18327
19270
|
topicName: a?.topic_name ?? null,
|
|
18328
19271
|
topicEmoji: a?.topic_emoji ?? null,
|
|
@@ -18553,7 +19496,7 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
|
|
|
18553
19496
|
},
|
|
18554
19497
|
escapeHtml: escapeHtmlForTg,
|
|
18555
19498
|
preBlock,
|
|
18556
|
-
getActiveSessionModel: () =>
|
|
19499
|
+
getActiveSessionModel: () => sessionModelSource.getOverride(),
|
|
18557
19500
|
/**
|
|
18558
19501
|
* Graceful restart for sr-* β Claude model switch. Same mechanism as
|
|
18559
19502
|
* the /restart command: writes a restart marker (so the post-restart
|
|
@@ -18563,9 +19506,18 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
|
|
|
18563
19506
|
scheduleRestart: async (reason: string) => {
|
|
18564
19507
|
const name = getMyAgentName()
|
|
18565
19508
|
// Debounce: mirror the /restart command's 15 s guard to prevent
|
|
18566
|
-
// double-dispatch on a rapid double-tap of /model <claude-alias>.
|
|
19509
|
+
// double-dispatch on a rapid double-tap of /model <claude-alias>. This
|
|
19510
|
+
// path previously `return`ed silently β indistinguishable from a
|
|
19511
|
+
// successful dispatch β so a model switch caught in the window was a
|
|
19512
|
+
// silent no-op while the caller reported success. THROW a tagged error
|
|
19513
|
+
// so scheduleModelRelaunch can react (keep the in-flight restart's carrier,
|
|
19514
|
+
// tell the operator honestly) instead of falsely claiming the switch stuck.
|
|
18567
19515
|
const existing = readRestartMarker()
|
|
18568
|
-
if (existing && Date.now() - existing.ts < 15_000)
|
|
19516
|
+
if (existing && Date.now() - existing.ts < 15_000) {
|
|
19517
|
+
const e = new Error('a restart is already in flight β try again in ~15s')
|
|
19518
|
+
;(e as { code?: string }).code = 'restart_in_flight'
|
|
19519
|
+
throw e
|
|
19520
|
+
}
|
|
18569
19521
|
if (restartCtx) {
|
|
18570
19522
|
writeRestartMarker({
|
|
18571
19523
|
chat_id: restartCtx.chatId,
|
|
@@ -18615,9 +19567,25 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
|
|
|
18615
19567
|
if (!agentDir) throw new Error('agent dir unresolvable β cannot write session-model carrier')
|
|
18616
19568
|
// Carrier: single line, token + newline, no quoting (start.sh strips
|
|
18617
19569
|
// whitespace and shape-gates). One-shot β consumed on the next boot.
|
|
19570
|
+
const prevOverride = sessionModelSource.getOverride()
|
|
18618
19571
|
writeFileSync(join(agentDir, '.session-model-override'), `${model}\n`, 'utf8')
|
|
18619
|
-
|
|
18620
|
-
|
|
19572
|
+
sessionModelSource.setOverride(model)
|
|
19573
|
+
try {
|
|
19574
|
+
await deps.scheduleRestart(reason)
|
|
19575
|
+
} catch (err) {
|
|
19576
|
+
const carrierPath = join(agentDir, '.session-model-override')
|
|
19577
|
+
// A restart already in flight OWNS the carrier we just wrote β it will
|
|
19578
|
+
// consume our token at boot, so the switch is queued, not lost: keep the
|
|
19579
|
+
// carrier + override and let the caller tell the operator "~15s". Any
|
|
19580
|
+
// OTHER dispatch failure means no restart is coming, so roll BOTH back β
|
|
19581
|
+
// a lingering carrier/override would lie to /status and mis-launch the
|
|
19582
|
+
// NEXT ordinary restart.
|
|
19583
|
+
if ((err as { code?: string })?.code !== 'restart_in_flight') {
|
|
19584
|
+
try { rmSync(carrierPath, { force: true }) } catch { /* best-effort */ }
|
|
19585
|
+
sessionModelSource.setOverride(prevOverride)
|
|
19586
|
+
}
|
|
19587
|
+
throw err
|
|
19588
|
+
}
|
|
18621
19589
|
},
|
|
18622
19590
|
}
|
|
18623
19591
|
return deps
|
|
@@ -18646,6 +19614,15 @@ bot.command('model', async ctx => {
|
|
|
18646
19614
|
return
|
|
18647
19615
|
}
|
|
18648
19616
|
const reply = await handleModelCommand(parsed, deps)
|
|
19617
|
+
// Record a POSITIVELY-CONFIRMED typed switch so /status reflects what's
|
|
19618
|
+
// actually running β the SAME in-memory override the menu callback path sets
|
|
19619
|
+
// (buildAgentMetadata resolves it via sessionModelSource). Only
|
|
19620
|
+
// set on the confirmed inject path; the sr-*/relaunch paths already set the
|
|
19621
|
+
// override inside scheduleModelRelaunch, and an unverified switch carries no
|
|
19622
|
+
// selectedModel so /status is never lied to.
|
|
19623
|
+
if (reply.selectedModel) {
|
|
19624
|
+
sessionModelSource.setOverride(reply.selectedModel)
|
|
19625
|
+
}
|
|
18649
19626
|
await switchroomReply(ctx, reply.text, { html: reply.html })
|
|
18650
19627
|
})
|
|
18651
19628
|
|
|
@@ -20941,6 +21918,7 @@ async function performVaultAccessApproval(
|
|
|
20941
21918
|
const visible = await listViaBroker()
|
|
20942
21919
|
if (visible !== null && visible.includes(pending.key)) {
|
|
20943
21920
|
pendingVaultRequestAccesses.delete(stageId)
|
|
21921
|
+
pendingCardStore.remove(stageId)
|
|
20944
21922
|
if (pending.card_message_id != null) {
|
|
20945
21923
|
await ctx.api
|
|
20946
21924
|
.editMessageText(
|
|
@@ -21039,6 +22017,7 @@ async function performVaultAccessApproval(
|
|
|
21039
22017
|
// the agent to re-issue, or the broker error message will tell
|
|
21040
22018
|
// them the next step.
|
|
21041
22019
|
pendingVaultRequestAccesses.delete(stageId)
|
|
22020
|
+
pendingCardStore.remove(stageId)
|
|
21042
22021
|
if (pending.card_message_id != null) {
|
|
21043
22022
|
await ctx.api
|
|
21044
22023
|
.editMessageText(
|
|
@@ -21070,6 +22049,7 @@ async function performVaultAccessApproval(
|
|
|
21070
22049
|
}
|
|
21071
22050
|
|
|
21072
22051
|
pendingVaultRequestAccesses.delete(stageId)
|
|
22052
|
+
pendingCardStore.remove(stageId)
|
|
21073
22053
|
if (pending.card_message_id != null) {
|
|
21074
22054
|
const days = Math.round(pending.ttl_seconds / 86400)
|
|
21075
22055
|
const footer =
|
|
@@ -21278,23 +22258,17 @@ async function handleMentalModelProposeCallback(ctx: Context, data: string): Pro
|
|
|
21278
22258
|
// this, a card left untapped past its TTL is still resolvable if no fresh
|
|
21279
22259
|
// proposal has run the sweep β an operator could approve a stale proposal.
|
|
21280
22260
|
if (Date.now() - pending.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS) {
|
|
21281
|
-
|
|
21282
|
-
|
|
21283
|
-
|
|
21284
|
-
|
|
21285
|
-
|
|
21286
|
-
pending.chat_id,
|
|
21287
|
-
pending.card_message_id,
|
|
21288
|
-
richMessage('β _This mental-model proposal card expired before you tapped. Ask the agent to re-propose if it still stands._'),
|
|
21289
|
-
{ reply_markup: { inline_keyboard: [] } },
|
|
21290
|
-
)
|
|
21291
|
-
.catch(() => {})
|
|
21292
|
-
}
|
|
22261
|
+
// Expired between post and tap: route through the shared expiry path so the
|
|
22262
|
+
// parked agent is WOKEN (timeout synthetic + missed-approvals re-offer) and
|
|
22263
|
+
// the durable store entry is cleared β not just a silent map delete.
|
|
22264
|
+
expireMentalModelProposeCard(stageId, pending, Date.now())
|
|
22265
|
+
await ctx.answerCallbackQuery({ text: 'Card expired β the agent was notified.' }).catch(() => {})
|
|
21293
22266
|
return
|
|
21294
22267
|
}
|
|
21295
22268
|
// Single-shot: remove the pending entry immediately so a double-tap can't
|
|
21296
22269
|
// resolve twice.
|
|
21297
22270
|
pendingMentalModelProposes.delete(stageId)
|
|
22271
|
+
pendingCardStore.remove(stageId)
|
|
21298
22272
|
|
|
21299
22273
|
const proposal: MentalModelPendingProposal = {
|
|
21300
22274
|
agent: pending.agent,
|
|
@@ -21436,6 +22410,7 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
|
|
|
21436
22410
|
|
|
21437
22411
|
if (action === 'deny') {
|
|
21438
22412
|
pendingVaultRequestAccesses.delete(stageId)
|
|
22413
|
+
pendingCardStore.remove(stageId)
|
|
21439
22414
|
await ctx.answerCallbackQuery({ text: 'π« Denied' }).catch(() => {})
|
|
21440
22415
|
if (pending.card_message_id != null) {
|
|
21441
22416
|
await ctx.api
|
|
@@ -21658,6 +22633,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
21658
22633
|
|
|
21659
22634
|
if (action === 'discard') {
|
|
21660
22635
|
pendingVaultRequestSaves.delete(stageId)
|
|
22636
|
+
pendingCardStore.remove(stageId)
|
|
21661
22637
|
await ctx.answerCallbackQuery({ text: 'π« Discarded' }).catch(() => {})
|
|
21662
22638
|
if (pending.card_message_id != null) {
|
|
21663
22639
|
await ctx.api
|
|
@@ -21728,6 +22704,43 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
21728
22704
|
// stale "spinning" state on the button while we run the write.
|
|
21729
22705
|
await ctx.answerCallbackQuery({ text: 'β³ Savingβ¦' }).catch(() => {})
|
|
21730
22706
|
|
|
22707
|
+
// Restored-after-restart guard: the staged secret VALUE is held in gateway
|
|
22708
|
+
// memory only and is never persisted (secrets hygiene). If this card was
|
|
22709
|
+
// restored from disk after a gateway restart, the value is gone β we CANNOT
|
|
22710
|
+
// complete the write. Degrade gracefully: strip the card, wake the agent
|
|
22711
|
+
// with a save-failed (value-lost) synthetic so it re-requests, and stop.
|
|
22712
|
+
if (pending.restoredWithoutValue || pending.value.length === 0) {
|
|
22713
|
+
pendingVaultRequestSaves.delete(stageId)
|
|
22714
|
+
pendingCardStore.remove(stageId)
|
|
22715
|
+
if (pending.card_message_id != null) {
|
|
22716
|
+
await ctx.api
|
|
22717
|
+
.editMessageText(
|
|
22718
|
+
pending.chat_id,
|
|
22719
|
+
pending.card_message_id,
|
|
22720
|
+
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._`),
|
|
22721
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
22722
|
+
)
|
|
22723
|
+
.catch(() => {})
|
|
22724
|
+
}
|
|
22725
|
+
const lostInbound = buildVaultSaveFailedInbound({
|
|
22726
|
+
ctx: {
|
|
22727
|
+
agent: pending.agent,
|
|
22728
|
+
key: pending.key,
|
|
22729
|
+
chat_id: pending.chat_id,
|
|
22730
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
22731
|
+
},
|
|
22732
|
+
stageId,
|
|
22733
|
+
operatorId: senderId,
|
|
22734
|
+
reason: 'staged value lost to a gateway restart β re-request the save',
|
|
22735
|
+
})
|
|
22736
|
+
const lDelivered = deliverResumeSyntheticOrBuffer(pending.agent, lostInbound)
|
|
22737
|
+
process.stderr.write(
|
|
22738
|
+
`telegram gateway: vault_request_save value lost to restart β wake agent=${pending.agent} ` +
|
|
22739
|
+
`key=${pending.key} stage=${stageId} delivered=${lDelivered}\n`,
|
|
22740
|
+
)
|
|
22741
|
+
return
|
|
22742
|
+
}
|
|
22743
|
+
|
|
21731
22744
|
// #1115 follow-up: the save-approve flow now mirrors the access-
|
|
21732
22745
|
// approve flow under telegram-id mode β broker `put` accepts
|
|
21733
22746
|
// `attest_via_posture: true` (server.ts:1448-1500), so the
|
|
@@ -21763,6 +22776,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
21763
22776
|
.catch(() => {})
|
|
21764
22777
|
}
|
|
21765
22778
|
pendingVaultRequestSaves.delete(stageId)
|
|
22779
|
+
pendingCardStore.remove(stageId)
|
|
21766
22780
|
return
|
|
21767
22781
|
}
|
|
21768
22782
|
// defaultVaultWrite spawns `switchroom vault set <key>` with the
|
|
@@ -21794,6 +22808,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
21794
22808
|
// retry by re-invoking the same MCP tool, but the value will be
|
|
21795
22809
|
// re-staged with a new ID. Drop the current stage.
|
|
21796
22810
|
pendingVaultRequestSaves.delete(stageId)
|
|
22811
|
+
pendingCardStore.remove(stageId)
|
|
21797
22812
|
// Wake the waiting agent with the failure (symmetric with the
|
|
21798
22813
|
// success/discard paths) so it doesn't assume vault:<key> exists.
|
|
21799
22814
|
const failReason =
|
|
@@ -21819,6 +22834,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
21819
22834
|
|
|
21820
22835
|
// Success β mask the value in the card for visual confirmation.
|
|
21821
22836
|
pendingVaultRequestSaves.delete(stageId)
|
|
22837
|
+
pendingCardStore.remove(stageId)
|
|
21822
22838
|
if (pending.card_message_id != null) {
|
|
21823
22839
|
await ctx.api
|
|
21824
22840
|
.editMessageText(
|
|
@@ -22841,7 +23857,15 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
|
|
|
22841
23857
|
tz,
|
|
22842
23858
|
now: renderNow,
|
|
22843
23859
|
demo: refreshDemo,
|
|
22844
|
-
|
|
23860
|
+
// Honesty backstop (same as /usage): a TOTAL probe failure (zero
|
|
23861
|
+
// result rows, nothing served from cache) renders an explicit
|
|
23862
|
+
// "probe failed" marker instead of a false "Live" footer next to
|
|
23863
|
+
// no-data rows.
|
|
23864
|
+
...(staleCachedAtMs != null
|
|
23865
|
+
? { staleCachedAtMs }
|
|
23866
|
+
: probeResp.results.length > 0
|
|
23867
|
+
? { liveProbedAtMs: renderNow.getTime() }
|
|
23868
|
+
: { probeFailed: true }),
|
|
22845
23869
|
})
|
|
22846
23870
|
const kbRows = buildSnapshotKeyboard(snapshots, { now: renderNow, demo: refreshDemo })
|
|
22847
23871
|
const inline_keyboard = kbRows.map((row) =>
|
|
@@ -23307,21 +24331,49 @@ bot.command('usage', async ctx => {
|
|
|
23307
24331
|
// flight; a TTL-hit or failed-probe fallback is tagged served:"cache",
|
|
23308
24332
|
// which we surface as a "β cached Nm ago" footer instead of a false
|
|
23309
24333
|
// live stamp.
|
|
24334
|
+
const renderNow = new Date()
|
|
23310
24335
|
const probeResp = await client.probeQuota(state.accounts.map((a) => a.label)).catch(() => ({ results: [] }))
|
|
23311
24336
|
const { quotas, staleCachedAtMs } = zipProbeResults(
|
|
23312
24337
|
state.accounts.map((a) => a.label),
|
|
23313
24338
|
probeResp.results,
|
|
23314
24339
|
)
|
|
23315
|
-
const {
|
|
24340
|
+
const { buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
|
|
23316
24341
|
'../auth-snapshot-format.js'
|
|
23317
24342
|
)
|
|
23318
|
-
const
|
|
24343
|
+
const { renderUsageCard } = await import('../quota-bar-format.js')
|
|
23319
24344
|
const snapshots = buildSnapshotsFromState(state, quotas)
|
|
23320
|
-
|
|
23321
|
-
|
|
23322
|
-
|
|
24345
|
+
// /usage renders the compact quota-bar block (at-a-glance headroom +
|
|
24346
|
+
// pace tick per window) plus a two-line footer β the actionable
|
|
24347
|
+
// cross-account recommendation verdict and the freshness marker (`β
|
|
24348
|
+
// cached Nm ago` when the probe was served stale from cache, else
|
|
24349
|
+
// `Live Β· refreshed Nm ago`). The old Format 2 table
|
|
24350
|
+
// (renderAuthSnapshotFormat2) is no longer appended here (operator
|
|
24351
|
+
// call, 2026-07-10): the two views were redundant and the table
|
|
24352
|
+
// doubled the message length. Every per-window reset the table
|
|
24353
|
+
// carried has an equivalent in the bar rows' `pct% / <time-left>`
|
|
24354
|
+
// segment, just relative instead of absolute β no info lost; the
|
|
24355
|
+
// recommendation + cached/live footer the table used to carry are
|
|
24356
|
+
// preserved by renderUsageCard.
|
|
24357
|
+
const exhaustedByLabel = new Map<string, boolean>(
|
|
24358
|
+
state.accounts.map((a) => [a.label, a.exhausted]),
|
|
24359
|
+
)
|
|
24360
|
+
const text = renderUsageCard(snapshots, exhaustedByLabel, {
|
|
24361
|
+
now: renderNow,
|
|
23323
24362
|
demo,
|
|
23324
|
-
|
|
24363
|
+
// #2495 Change 2 β a TTL-hit / failed-probe fallback is tagged
|
|
24364
|
+
// served:"cache"; surface it as `β cached Nm ago` instead of a
|
|
24365
|
+
// false live stamp. Otherwise stamp the live refresh time.
|
|
24366
|
+
// Honesty backstop: a TOTAL probe failure (the .catch above
|
|
24367
|
+
// returned `{results: []}` and nothing was served from cache)
|
|
24368
|
+
// must render an explicit "probe failed" marker, NOT a false
|
|
24369
|
+
// "Live" stamp next to "β οΈ no data" rows. Without this the
|
|
24370
|
+
// footer claimed "Live Β· refreshed 0s ago" while every account
|
|
24371
|
+
// row said "no data β probe failed" (#2959 review finding).
|
|
24372
|
+
...(staleCachedAtMs != null
|
|
24373
|
+
? { staleCachedAtMs }
|
|
24374
|
+
: probeResp.results.length > 0
|
|
24375
|
+
? { liveProbedAtMs: renderNow.getTime() }
|
|
24376
|
+
: { probeFailed: true }),
|
|
23325
24377
|
})
|
|
23326
24378
|
// Preserve the Switch/Refresh/usage/Add inline keyboard on the
|
|
23327
24379
|
// rich-message render β the table card carries the same actions the
|
|
@@ -23708,13 +24760,13 @@ bot.on('callback_query:data', async ctx => {
|
|
|
23708
24760
|
}
|
|
23709
24761
|
const didInterimSrEdit = false
|
|
23710
24762
|
try {
|
|
23711
|
-
const prevSessionModel =
|
|
24763
|
+
const prevSessionModel = sessionModelSource.getOverride()
|
|
23712
24764
|
const outcome = await handleModelMenuCallback(data, modelDeps)
|
|
23713
24765
|
// Record a successful session switch so /status reflects what's
|
|
23714
24766
|
// actually running. In-memory only β clears when the gateway (and thus
|
|
23715
24767
|
// claude's session) restarts, exactly matching the session-only scope.
|
|
23716
24768
|
if (outcome.selectedModel) {
|
|
23717
|
-
|
|
24769
|
+
sessionModelSource.setOverride(outcome.selectedModel)
|
|
23718
24770
|
}
|
|
23719
24771
|
// toastOnly: leave the menu untouched β but only if we haven't already
|
|
23720
24772
|
// cleared its buttons with the interim sr-* edit. If we have, fall
|
|
@@ -23736,6 +24788,25 @@ bot.on('callback_query:data', async ctx => {
|
|
|
23736
24788
|
{ reply_markup: { inline_keyboard: [] } },
|
|
23737
24789
|
)
|
|
23738
24790
|
.catch(() => {})
|
|
24791
|
+
// Carry the requested Claude model across the restart via the SAME
|
|
24792
|
+
// `.session-model-override` carrier a Claude β sr-* switch uses β otherwise
|
|
24793
|
+
// boot launches the CONFIGURED default and the tapped model is silently
|
|
24794
|
+
// dropped. `selectedModelToken` is a real `claude --model` token (alias or
|
|
24795
|
+
// full claude-* id); a "Default"-row tap yields no token β boot the
|
|
24796
|
+
// configured default (correct). start.sh's LiteLLM-down guard only drops
|
|
24797
|
+
// sr-* overrides, so a Claude token is never dropped.
|
|
24798
|
+
{
|
|
24799
|
+
const agentDir = resolveAgentDirFromEnv()
|
|
24800
|
+
const token = outcome.selectedModelToken
|
|
24801
|
+
if (agentDir && token) {
|
|
24802
|
+
try {
|
|
24803
|
+
writeFileSync(join(agentDir, '.session-model-override'), `${token}\n`, 'utf8')
|
|
24804
|
+
sessionModelSource.setOverride(token)
|
|
24805
|
+
} catch (e) {
|
|
24806
|
+
process.stderr.write(`telegram gateway: sr-to-claude carrier write failed: ${(e as Error)?.message ?? String(e)}\n`)
|
|
24807
|
+
}
|
|
24808
|
+
}
|
|
24809
|
+
}
|
|
23739
24810
|
// Write the restart marker so the post-restart boot card edits into this chat.
|
|
23740
24811
|
writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() })
|
|
23741
24812
|
stampUserRestartReason('user: sr-to-claude model switch (menu)')
|
|
@@ -24277,17 +25348,17 @@ bot.on('callback_query:data', async ctx => {
|
|
|
24277
25348
|
process.stderr.write(
|
|
24278
25349
|
`telegram gateway: button_callback chatId=${cbChatId} user=${ctx.from.id} data=${JSON.stringify(agentCb.raw)} btnText=${JSON.stringify(buttonText ?? null)}\n`,
|
|
24279
25350
|
)
|
|
24280
|
-
//
|
|
24281
|
-
// normal
|
|
24282
|
-
//
|
|
24283
|
-
//
|
|
24284
|
-
//
|
|
24285
|
-
//
|
|
25351
|
+
// #271 turn-safety: route the tap through the SAME turn-safe delivery
|
|
25352
|
+
// machinery as a normal inbound (deliverButtonTapInbound) instead of a raw
|
|
25353
|
+
// sendToAgent. A tap landing mid-turn now buffers until idle (never strands
|
|
25354
|
+
// in the composer, #1556), a delivered tap is composer-cleared first and
|
|
25355
|
+
// tracked so the redelivery sweep rescues a strand, and a bridge-offline tap
|
|
25356
|
+
// still spools + shows the restart notice below (unchanged UX). The old raw
|
|
25357
|
+
// path (sendToAgent β pendingInboundBuffer, drained by onClientRegistered)
|
|
25358
|
+
// fixed only the bridge-mid-reconnect drop; it never gated on turn state.
|
|
24286
25359
|
const selfAgentBtn = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
24287
|
-
const
|
|
24288
|
-
if (
|
|
24289
|
-
if (!btnDelivered) {
|
|
24290
|
-
pendingInboundBuffer.push(selfAgentBtn, inboundMsg)
|
|
25360
|
+
const btnOutcome = await deliverButtonTapInbound(selfAgentBtn, inboundMsg)
|
|
25361
|
+
if (btnOutcome === 'buffered-bridge-offline') {
|
|
24291
25362
|
// No registered bridge β the agent's mid-restart. Tell the user
|
|
24292
25363
|
// so they don't think the button silently swallowed their tap;
|
|
24293
25364
|
// the tap is genuinely buffered now and replays on reconnect.
|
|
@@ -24516,6 +25587,36 @@ bot.on('callback_query:data', async ctx => {
|
|
|
24516
25587
|
process.stderr.write(
|
|
24517
25588
|
`telegram gateway: always-allow hostd FAILED: ${failReason} (request_id=${request_id})\n`,
|
|
24518
25589
|
)
|
|
25590
|
+
// #2973 pt.2 β enqueue for the durable retry queue UNLESS the
|
|
25591
|
+
// failure is non-retryable (config edits locked β retrying
|
|
25592
|
+
// won't help until the operator flips the flag; that case
|
|
25593
|
+
// keeps today's honest "did NOT save" card only). Everything
|
|
25594
|
+
// else (stale config view, transient hostd error, rate limit)
|
|
25595
|
+
// gets picked up by the boot/periodic drain instead of quietly
|
|
25596
|
+
// requiring the operator to notice and re-tap.
|
|
25597
|
+
if (!editLockHint) {
|
|
25598
|
+
try {
|
|
25599
|
+
await alwaysAllowPersistQueue.enqueue({
|
|
25600
|
+
agentName,
|
|
25601
|
+
rule: chosen.rule,
|
|
25602
|
+
grantPhrase,
|
|
25603
|
+
chatId: ctx.chat?.id != null ? String(ctx.chat.id) : undefined,
|
|
25604
|
+
threadId: (ctx.callbackQuery?.message as { message_thread_id?: number } | undefined)?.message_thread_id,
|
|
25605
|
+
error: failReason,
|
|
25606
|
+
})
|
|
25607
|
+
} catch (enqueueErr) {
|
|
25608
|
+
// The retry queue's own write failed (disk full, perms, β¦) β
|
|
25609
|
+
// don't pretend this landed. Fold it into the operator-facing
|
|
25610
|
+
// failReason so the "did NOT save" card is honest about the
|
|
25611
|
+
// retry mechanism ALSO having failed, not just the original
|
|
25612
|
+
// dispatch (#2973 adversarial review pt.2).
|
|
25613
|
+
const enqueueMsg = (enqueueErr as Error).message
|
|
25614
|
+
process.stderr.write(
|
|
25615
|
+
`telegram gateway: always-allow enqueue for retry FAILED: ${enqueueMsg} (request_id=${request_id})\n`,
|
|
25616
|
+
)
|
|
25617
|
+
failReason = `${failReason} (retry queue also failed to persist: ${enqueueMsg})`
|
|
25618
|
+
}
|
|
25619
|
+
}
|
|
24519
25620
|
}
|
|
24520
25621
|
}
|
|
24521
25622
|
|
|
@@ -26380,6 +27481,38 @@ void (async () => {
|
|
|
26380
27481
|
|
|
26381
27482
|
if (!didOneTimeSetup) {
|
|
26382
27483
|
didOneTimeSetup = true
|
|
27484
|
+
|
|
27485
|
+
// person_id name resolution (docs/configuration.md, resolve-person.ts):
|
|
27486
|
+
// ONE-TIME boot validation, NOT periodic. `PERSON_DIRECTORY` is
|
|
27487
|
+
// built exactly once, here, from the static in-memory `people.json`
|
|
27488
|
+
// the scaffold projected from `switchroom.yaml`'s `users:` block β
|
|
27489
|
+
// a config change requires an agent restart, this never re-reads.
|
|
27490
|
+
// The orchestration (per-entry validation + dead-man's-switch) is
|
|
27491
|
+
// in the pure, unit-tested `runPersonDirectoryBootCheck` β this
|
|
27492
|
+
// block only performs the resulting I/O (stderr log + the EXISTING
|
|
27493
|
+
// fleet-alert path at low/config severity, kind: 'config-warning',
|
|
27494
|
+
// which must never page like a real outage).
|
|
27495
|
+
{
|
|
27496
|
+
const bootCheck = runPersonDirectoryBootCheck(readPeopleFile)
|
|
27497
|
+
PERSON_DIRECTORY = bootCheck.directory
|
|
27498
|
+
process.stderr.write(bootCheck.logLine + '\n')
|
|
27499
|
+
if (bootCheck.alertDetail != null) {
|
|
27500
|
+
emitGatewayOperatorEvent({
|
|
27501
|
+
kind: 'config-warning',
|
|
27502
|
+
agent: process.env.SWITCHROOM_AGENT_NAME ?? '-',
|
|
27503
|
+
detail: bootCheck.alertDetail,
|
|
27504
|
+
suggestedActions: [],
|
|
27505
|
+
firstSeenAt: new Date(),
|
|
27506
|
+
})
|
|
27507
|
+
}
|
|
27508
|
+
}
|
|
27509
|
+
|
|
27510
|
+
// #2973 pt.2 β drain any always-allow persists left queued by a
|
|
27511
|
+
// prior gateway process (e.g. one that restarted mid-persist),
|
|
27512
|
+
// then keep draining periodically for the rest of this process's
|
|
27513
|
+
// lifetime.
|
|
27514
|
+
scheduleAlwaysAllowPersistDrain()
|
|
27515
|
+
|
|
26383
27516
|
void registerSwitchroomBotCommands().catch(() => {})
|
|
26384
27517
|
|
|
26385
27518
|
// #613 fix: pre-warm the chatAvailableReactions cache for every
|
|
@@ -26493,6 +27626,18 @@ void (async () => {
|
|
|
26493
27626
|
}
|
|
26494
27627
|
}
|
|
26495
27628
|
|
|
27629
|
+
// Restore the four agent-initiated approval-card families from the
|
|
27630
|
+
// durable store so a post-restart tap on a still-valid card resolves
|
|
27631
|
+
// normally instead of hitting the "Card expired" tombstone (and an
|
|
27632
|
+
// already-expired entry gets woken by the reaper's next tick).
|
|
27633
|
+
try {
|
|
27634
|
+
restorePendingApprovalCards()
|
|
27635
|
+
} catch (err) {
|
|
27636
|
+
process.stderr.write(
|
|
27637
|
+
`telegram gateway: pending approval-card restore failed: ${(err as Error).message}\n`,
|
|
27638
|
+
)
|
|
27639
|
+
}
|
|
27640
|
+
|
|
26496
27641
|
// Boot-time pin sweep
|
|
26497
27642
|
try {
|
|
26498
27643
|
const bootAccess = loadAccess()
|
|
@@ -26722,8 +27867,9 @@ void (async () => {
|
|
|
26722
27867
|
// a phantom session override.
|
|
26723
27868
|
return resolveMainModel(raw ?? undefined)
|
|
26724
27869
|
})()
|
|
26725
|
-
|
|
26726
|
-
launched.length > 0 && launched !== configured ? launched : null
|
|
27870
|
+
sessionModelSource.setOverride(
|
|
27871
|
+
launched.length > 0 && launched !== configured ? launched : null,
|
|
27872
|
+
)
|
|
26727
27873
|
} catch { /* leave override as-is on a bad read */ }
|
|
26728
27874
|
}
|
|
26729
27875
|
|
|
@@ -27037,7 +28183,7 @@ void (async () => {
|
|
|
27037
28183
|
// Gated to background completions: foreground sub-agents
|
|
27038
28184
|
// need nothing here, and 'orphan' is a stale historical-at-
|
|
27039
28185
|
// boot row, not a fresh completion the user is waiting on.
|
|
27040
|
-
onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs }) => {
|
|
28186
|
+
onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs, background: entryBackground }) => {
|
|
27041
28187
|
// Reaction promotion: if the parent turn already ended
|
|
27042
28188
|
// with this (or another) worker still running, its π was
|
|
27043
28189
|
// deferred (held on βοΈ/β‘). Now that a worker finished,
|
|
@@ -27068,13 +28214,32 @@ void (async () => {
|
|
|
27068
28214
|
// (worker-feed-dispatch.ts, pinned by its test). Best-effort:
|
|
27069
28215
|
// a DB hiccup keeps the watcher's generic label rather than
|
|
27070
28216
|
// throwing out of the terminal handler.
|
|
27071
|
-
let dispatch: WorkerFeedDispatch = resolveWorkerFeedDispatch(null, description)
|
|
28217
|
+
let dispatch: WorkerFeedDispatch = resolveWorkerFeedDispatch(null, description, entryBackground)
|
|
27072
28218
|
if (turnsDb != null) {
|
|
27073
28219
|
try {
|
|
27074
|
-
dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description)
|
|
28220
|
+
dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description, entryBackground)
|
|
27075
28221
|
} catch { /* best-effort */ }
|
|
27076
28222
|
}
|
|
27077
|
-
|
|
28223
|
+
let isBackground = dispatch.isBackground
|
|
28224
|
+
// Fix #1(+#2): the registry row never linked AND the watcher
|
|
28225
|
+
// entry's own cached background flag was never observed
|
|
28226
|
+
// either (both `resolveWorkerFeedDispatch` fallbacks came up
|
|
28227
|
+
// empty) β this is the "DB row is unlinked" bug's worst
|
|
28228
|
+
// case. A finished worker with actual narrative result text
|
|
28229
|
+
// is far more likely a dropped background handback than a
|
|
28230
|
+
// legitimate foreground no-op (a foreground sub-agent's
|
|
28231
|
+
// result returns inline as the Task tool result β the
|
|
28232
|
+
// gateway wouldn't otherwise need to route anything here).
|
|
28233
|
+
// Degrade to background so the result is delivered instead
|
|
28234
|
+
// of silently lost. Idempotency: this only flips the
|
|
28235
|
+
// dispatch classification for THIS single onFinish call β
|
|
28236
|
+
// all the existing dedup/idempotency guards below
|
|
28237
|
+
// (decideSubagentHandback's spool key, completionNotified,
|
|
28238
|
+
// etc.) still apply unchanged, so this cannot cause a
|
|
28239
|
+
// double-handback.
|
|
28240
|
+
if (!dispatch.hasRow && entryBackground == null && resultText.trim().length > 0) {
|
|
28241
|
+
isBackground = true
|
|
28242
|
+
}
|
|
27078
28243
|
// NESTED (depth-2+) worker terminal: its live status surfaced
|
|
27079
28244
|
// via the worker feed (see onProgress), so finalize that card
|
|
27080
28245
|
// cleanly β never leave it frozen mid-"β step". But NO user
|
|
@@ -27091,6 +28256,10 @@ void (async () => {
|
|
|
27091
28256
|
latestSummary: resultText,
|
|
27092
28257
|
elapsedMs: durationMs,
|
|
27093
28258
|
state: outcome === 'failed' ? 'failed' : 'done',
|
|
28259
|
+
// Persisted (registry) model β the last one the watcher
|
|
28260
|
+
// recorded from the worker's transcript β so the terminal
|
|
28261
|
+
// card keeps the model tag even with no live entry.
|
|
28262
|
+
model: dispatch.feedModel ?? undefined,
|
|
27094
28263
|
})
|
|
27095
28264
|
reconcileWorkerPin(agentId, null, false)
|
|
27096
28265
|
}
|
|
@@ -27168,6 +28337,7 @@ void (async () => {
|
|
|
27168
28337
|
latestSummary: resultText,
|
|
27169
28338
|
elapsedMs: durationMs,
|
|
27170
28339
|
state: outcome === 'failed' ? 'failed' : 'done',
|
|
28340
|
+
model: dispatch.feedModel ?? undefined,
|
|
27171
28341
|
})
|
|
27172
28342
|
// Status-pin: worker done β drop its pin.
|
|
27173
28343
|
reconcileWorkerPin(agentId, null, false)
|
|
@@ -27187,6 +28357,7 @@ void (async () => {
|
|
|
27187
28357
|
latestSummary: resultText,
|
|
27188
28358
|
elapsedMs: durationMs,
|
|
27189
28359
|
state: outcome === 'failed' ? 'failed' : 'done',
|
|
28360
|
+
model: dispatch.feedModel ?? undefined,
|
|
27190
28361
|
})
|
|
27191
28362
|
// Status-pin: worker done β drop its pin.
|
|
27192
28363
|
reconcileWorkerPin(agentId, null, false)
|
|
@@ -27273,7 +28444,7 @@ void (async () => {
|
|
|
27273
28444
|
// suppresses stale-after-restart delivery (a 4-h-old
|
|
27274
28445
|
// "still working (5m)" would be a lie). Sweep on handback
|
|
27275
28446
|
// lives in the `onFinish` block just above.
|
|
27276
|
-
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine }) => {
|
|
28447
|
+
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model }) => {
|
|
27277
28448
|
let fleetChatId = ''
|
|
27278
28449
|
try {
|
|
27279
28450
|
const fleets = progressDriver?.peekAllFleets() ?? []
|
|
@@ -27307,6 +28478,13 @@ void (async () => {
|
|
|
27307
28478
|
// never grew past "startingβ¦" (the frozen-card symptom). The
|
|
27308
28479
|
// foreground nest path below already used this precedence.
|
|
27309
28480
|
const stepLine = (progressLine != null && progressLine.length > 0) ? progressLine : latestSummary
|
|
28481
|
+
// Live model for the worker card: prefer the transcript-sourced
|
|
28482
|
+
// model on the entry (threaded via onProgress) and fall back to
|
|
28483
|
+
// the dispatch-time model persisted on the registry row
|
|
28484
|
+
// (tool_input.model) until the worker's first assistant line
|
|
28485
|
+
// lands. Undefined when neither is known β the card omits it,
|
|
28486
|
+
// never guessing from config.
|
|
28487
|
+
const feedModel = model ?? dispatch.feedModel ?? undefined
|
|
27310
28488
|
if (!isBackground) {
|
|
27311
28489
|
// Model A β a foreground sub-agent runs inside the parent's
|
|
27312
28490
|
// turn, so its live narrative nests under the parent's
|
|
@@ -27334,8 +28512,8 @@ void (async () => {
|
|
|
27334
28512
|
orphanStatusEnabled,
|
|
27335
28513
|
})
|
|
27336
28514
|
if (surface === 'worker-feed') {
|
|
27337
|
-
const
|
|
27338
|
-
const wkChat =
|
|
28515
|
+
const wk = resolveWorkerFeedChat(agentId, fleetChatId)
|
|
28516
|
+
const wkChat = wk.chatId
|
|
27339
28517
|
void workerActivityFeed?.update(
|
|
27340
28518
|
agentId,
|
|
27341
28519
|
wkChat,
|
|
@@ -27346,8 +28524,9 @@ void (async () => {
|
|
|
27346
28524
|
latestSummary: stepLine,
|
|
27347
28525
|
elapsedMs,
|
|
27348
28526
|
state: 'running',
|
|
28527
|
+
model: feedModel,
|
|
27349
28528
|
},
|
|
27350
|
-
|
|
28529
|
+
wk.threadId,
|
|
27351
28530
|
)?.then(() => reconcileWorkerPin(agentId, wkChat, true))
|
|
27352
28531
|
return
|
|
27353
28532
|
}
|
|
@@ -27472,8 +28651,8 @@ void (async () => {
|
|
|
27472
28651
|
// resolved (the pinned-card fleet that used to carry the chat
|
|
27473
28652
|
// is gone β see resolveSubagentOriginChat).
|
|
27474
28653
|
if (workerFeedEnabled) {
|
|
27475
|
-
const
|
|
27476
|
-
const wkChat =
|
|
28654
|
+
const wk = resolveWorkerFeedChat(agentId, fleetChatId)
|
|
28655
|
+
const wkChat = wk.chatId
|
|
27477
28656
|
void workerActivityFeed?.update(
|
|
27478
28657
|
agentId,
|
|
27479
28658
|
wkChat,
|
|
@@ -27487,8 +28666,9 @@ void (async () => {
|
|
|
27487
28666
|
latestSummary: stepLine,
|
|
27488
28667
|
elapsedMs,
|
|
27489
28668
|
state: 'running',
|
|
28669
|
+
model: feedModel,
|
|
27490
28670
|
},
|
|
27491
|
-
|
|
28671
|
+
wk.threadId,
|
|
27492
28672
|
)?.then(() => reconcileWorkerPin(agentId, wkChat, true))
|
|
27493
28673
|
return
|
|
27494
28674
|
}
|