switchroom 0.17.6 → 0.18.3
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/bin/workspace-dynamic-hook.sh +12 -13
- package/dist/agent-scheduler/index.js +65 -5
- package/dist/auth-broker/index.js +6623 -514
- package/dist/cli/notion-write-pretool.mjs +64 -4
- package/dist/cli/switchroom.js +1888 -1162
- package/dist/host-control/main.js +6306 -162
- package/dist/vault/approvals/kernel-server.js +6014 -202
- package/dist/vault/broker/server.js +6741 -940
- package/package.json +1 -1
- package/profiles/_base/settings.json.hbs +2 -2
- package/profiles/_base/start.sh.hbs +218 -25
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md +116 -0
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +162 -0
- package/telegram-plugin/auth-snapshot-format.ts +22 -24
- package/telegram-plugin/bridge/bridge.ts +80 -1
- package/telegram-plugin/bridge/ipc-client.ts +19 -0
- package/telegram-plugin/bridge/permission-ledger.ts +61 -0
- package/telegram-plugin/consolidation-legibility.ts +279 -0
- package/telegram-plugin/context-exhaustion.ts +124 -0
- package/telegram-plugin/dist/bridge/bridge.js +85 -1
- package/telegram-plugin/dist/gateway/gateway.js +25802 -8488
- package/telegram-plugin/dist/server.js +86 -2
- package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
- package/telegram-plugin/gateway/activity-card-store.ts +369 -0
- package/telegram-plugin/gateway/gateway.ts +1861 -172
- package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
- package/telegram-plugin/gateway/inbound-spool.ts +22 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
- package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
- package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
- package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
- package/telegram-plugin/gateway/model-command.ts +70 -10
- package/telegram-plugin/gateway/permission-rearm.ts +115 -0
- package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
- package/telegram-plugin/memory-legibility.ts +217 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +6 -0
- package/telegram-plugin/quota-watch.ts +4 -6
- package/telegram-plugin/registry/turns-schema.test.ts +97 -0
- package/telegram-plugin/registry/turns-schema.ts +78 -0
- package/telegram-plugin/render/ir.ts +209 -0
- package/telegram-plugin/render/parse.ts +363 -0
- package/telegram-plugin/render/render.ts +440 -0
- package/telegram-plugin/render/rich-render.ts +72 -0
- package/telegram-plugin/scoped-approval.ts +59 -0
- package/telegram-plugin/silent-end.ts +78 -0
- package/telegram-plugin/stream-controller.ts +14 -3
- package/telegram-plugin/subagent-watcher.ts +60 -6
- package/telegram-plugin/tests/activity-card-store.test.ts +530 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
- package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
- package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
- package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +44 -9
- package/telegram-plugin/tests/feed-survival.test.ts +39 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
- package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
- package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
- package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
- package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
- package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
- package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
- package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
- package/telegram-plugin/tests/model-command.test.ts +193 -16
- package/telegram-plugin/tests/narrative-render.test.ts +125 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
- package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
- package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
- package/telegram-plugin/tests/quota-watch.test.ts +1 -4
- package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
- package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
- package/telegram-plugin/tests/render/parse.test.ts +393 -0
- package/telegram-plugin/tests/render/render.test.ts +436 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
- package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
- package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
- package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +198 -0
- package/telegram-plugin/turn-liveness-floor.ts +35 -1
- package/telegram-plugin/uat/assertions.ts +88 -4
- package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
- package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
- package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
- package/telegram-plugin/worktree-watch-cwds.ts +135 -0
- package/vendor/hindsight-memory/hooks/hooks.json +9 -0
- 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/directive_verify.py +445 -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/lib/client.py +11 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +46 -2
- package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
- package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
- package/vendor/hindsight-memory/scripts/recall.py +217 -10
- package/vendor/hindsight-memory/scripts/retain.py +17 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -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/scripts/tests/test_directive_capture_nudge.py +185 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
- package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -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
- package/vendor/hindsight-memory/tests/test_client.py +43 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
- package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { Bot, GrammyError, InlineKeyboard, InputFile, type Context, type Api } from 'grammy'
|
|
13
13
|
import { run, type RunnerHandle } from '@grammyjs/runner'
|
|
14
14
|
import type { ReactionTypeEmoji } from 'grammy/types'
|
|
15
|
-
import { randomBytes } from 'crypto'
|
|
15
|
+
import { randomBytes, createHash } from 'crypto'
|
|
16
16
|
import { execFileSync, execSync, spawn } from 'child_process'
|
|
17
17
|
import {
|
|
18
18
|
readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync,
|
|
@@ -93,6 +93,20 @@ import {
|
|
|
93
93
|
import { StatusReactionController } from '../status-reactions.js'
|
|
94
94
|
import { DeferredDoneReactions } from '../reaction-defer.js'
|
|
95
95
|
import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
|
|
96
|
+
import {
|
|
97
|
+
detectMemoryLegibilityEvent,
|
|
98
|
+
isMemoryLegibilityEnabled,
|
|
99
|
+
renderMemoryLegibilityLine,
|
|
100
|
+
MemoryLegibilityStager,
|
|
101
|
+
} from '../memory-legibility.js'
|
|
102
|
+
import {
|
|
103
|
+
ConsolidationRateLimiter,
|
|
104
|
+
consolidationSignature,
|
|
105
|
+
detectConsolidationEvent,
|
|
106
|
+
isConsolidationLegibilityEnabled,
|
|
107
|
+
renderConsolidationLine,
|
|
108
|
+
} from '../consolidation-legibility.js'
|
|
109
|
+
import type { WebhookGatewayRecord } from '../../src/web/webhook-gateway-record.js'
|
|
96
110
|
import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
|
|
97
111
|
import type { PinState, DesiredPin } from '../status-pin.js'
|
|
98
112
|
import { decidePinAction } from '../status-pin.js'
|
|
@@ -111,10 +125,25 @@ import {
|
|
|
111
125
|
type PermissionCardRef,
|
|
112
126
|
} from './permission-timeout.js'
|
|
113
127
|
import { renderVaultRequestAccessCard } from './vault-request-access-card.js'
|
|
114
|
-
import { createPermissionCardStore } from './permission-card-store.js'
|
|
128
|
+
import { createPermissionCardStore, type PersistedPermCard } from './permission-card-store.js'
|
|
129
|
+
import {
|
|
130
|
+
isPermissionRearmEnabled,
|
|
131
|
+
permissionRearmGraceMs,
|
|
132
|
+
classifyPermissionRequest,
|
|
133
|
+
computeBootSweepStripTargets,
|
|
134
|
+
distinctRequestIds,
|
|
135
|
+
} from './permission-rearm.js'
|
|
136
|
+
import { createMissedApprovalsStore, type MissedApproval } from './missed-approvals-store.js'
|
|
137
|
+
import {
|
|
138
|
+
renderMissedApprovalsDigest,
|
|
139
|
+
missedApprovalsKeyboard,
|
|
140
|
+
parseMissedApprovalCallback,
|
|
141
|
+
buildMissedApprovalRetryInbound,
|
|
142
|
+
} from './missed-approvals-card.js'
|
|
115
143
|
import { pickRecoveredPermissionOrigin } from './permission-card-origin.js'
|
|
116
144
|
import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
|
|
117
145
|
import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, formatStepSuffix, type SessionActivityHeader } from '../tool-activity-summary.js'
|
|
146
|
+
import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
|
|
118
147
|
import { REPLY_TOOLS, isDraftOfReply } from '../narrative-dedup.js'
|
|
119
148
|
import { toolLabel } from '../tool-labels.js'
|
|
120
149
|
import { createTypingWrapper } from '../typing-wrap.js'
|
|
@@ -144,7 +173,7 @@ import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
|
|
|
144
173
|
import { classifyInbound } from '../inbound-classifier.js'
|
|
145
174
|
import * as silencePoke from '../silence-poke.js'
|
|
146
175
|
import * as pendingProgress from '../pending-work-progress.js'
|
|
147
|
-
import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd } from '../silent-end.js'
|
|
176
|
+
import { writeSilentEndState, clearSilentEndState, recordUndeliveredTurnEnd, silentEndFallbackText, type SilentEndDeps } from '../silent-end.js'
|
|
148
177
|
import { isFinalAnswerReply, isSubstantiveFinalReply, FINAL_ANSWER_MIN_CHARS } from '../final-answer-detect.js'
|
|
149
178
|
import { deriveTurnRole, decideTerminalReason, parsePostAnswerLivenessMs, evaluatePostAnswerLiveness, type LoopRole } from '../turn-liveness-floor.js'
|
|
150
179
|
import { createAnswerStream, type AnswerStreamHandle } from '../answer-stream.js'
|
|
@@ -224,15 +253,9 @@ import { validateStringArray } from './access-validator.js'
|
|
|
224
253
|
*/
|
|
225
254
|
const REPLY_TO_TEXT_MAX = 200
|
|
226
255
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
* has already been exhausted. Without this the user only sees the
|
|
231
|
-
* progress card vanish; silence must never be the failure mode.
|
|
232
|
-
*/
|
|
233
|
-
const SILENT_END_FALLBACK_TEXT =
|
|
234
|
-
'⚠️ The agent finished working but didn’t send a reply — your last ' +
|
|
235
|
-
'message may not have been answered. Please try asking again.'
|
|
256
|
+
// #1161 silent-end fallback text now lives in ../silent-end.ts
|
|
257
|
+
// (`silentEndFallbackText`, imported above) so the transport-boundary
|
|
258
|
+
// tests exercise the real string — see PR #2892.
|
|
236
259
|
import { splitMarkdownChunks, hardSliceToCap, repairEscapedWhitespace, normalizeParagraphBreaks, addParagraphSpacers, normalizePunctuation, stripExcessBold, escapeMarkdown, hardenCardBreaks, RICH_MESSAGE_MAX_CHARS } from '../format.js'
|
|
237
260
|
import { richMessage } from '../rich-send.js'
|
|
238
261
|
import { scrubVoice } from '../text-voice-scrub.js'
|
|
@@ -271,6 +294,7 @@ import {
|
|
|
271
294
|
shouldArmOrphanedReplyTimeout,
|
|
272
295
|
ORPHANED_REPLY_TIMEOUT_MS,
|
|
273
296
|
ORPHANED_REPLY_MAX_REARMS,
|
|
297
|
+
LivenessTracker,
|
|
274
298
|
} from '../context-exhaustion.js'
|
|
275
299
|
import {
|
|
276
300
|
decideTurnFlush,
|
|
@@ -323,6 +347,7 @@ import {
|
|
|
323
347
|
buildModelMenu,
|
|
324
348
|
handleModelMenuCallback,
|
|
325
349
|
isSrToClaudeTransition,
|
|
350
|
+
isValidModelArg,
|
|
326
351
|
MODEL_CALLBACK_PREFIX,
|
|
327
352
|
MODEL_CALLBACK_HEADER,
|
|
328
353
|
MODEL_CALLBACK_SR,
|
|
@@ -334,6 +359,7 @@ import {
|
|
|
334
359
|
type ModelMenuReply,
|
|
335
360
|
} from './model-command.js'
|
|
336
361
|
import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
|
|
362
|
+
import { resolveMainModel } from '../../src/agents/scaffold.js'
|
|
337
363
|
import {
|
|
338
364
|
parseEffortCommand,
|
|
339
365
|
handleEffortCommand,
|
|
@@ -392,12 +418,20 @@ import {
|
|
|
392
418
|
type StatusPinPersistOp,
|
|
393
419
|
type TrackedStatusPin,
|
|
394
420
|
} from './status-pin-store.js'
|
|
421
|
+
import {
|
|
422
|
+
writeActivityCardRecord,
|
|
423
|
+
clearActivityCardRecord,
|
|
424
|
+
runActivityCardBootReaper,
|
|
425
|
+
runActivityCardMidSessionReaper,
|
|
426
|
+
restartOrphanCardFinalizeText,
|
|
427
|
+
type ActivityCardStoreFsSeam,
|
|
428
|
+
} from './activity-card-store.js'
|
|
395
429
|
import { driveEscalation } from './escalation-drive.js'
|
|
396
430
|
import { shouldSuppressRepresent } from './represent-guard.js'
|
|
397
431
|
import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
|
|
398
432
|
import { createInboundSpool } from './inbound-spool.js'
|
|
399
433
|
import { purgeStaleTurnsForChat } from './turn-state-purge.js'
|
|
400
|
-
import { decideInboundDelivery } from './inbound-delivery-gate.js'
|
|
434
|
+
import { decideInboundDelivery, reserveInboundDelivery } from './inbound-delivery-gate.js'
|
|
401
435
|
import { mayDrainBufferedInbound, shouldArmNoReplyDrain } from './serialize-drain-gate.js'
|
|
402
436
|
import { decideFeedReopen } from './feed-reopen-gate.js'
|
|
403
437
|
import {
|
|
@@ -448,6 +482,12 @@ import {
|
|
|
448
482
|
buildVaultSaveFailedInbound,
|
|
449
483
|
buildVaultSaveDiscardedInbound,
|
|
450
484
|
} from './vault-grant-inbound-builders.js'
|
|
485
|
+
import { renderMentalModelProposeCard } from './mental-model-propose-card.js'
|
|
486
|
+
import {
|
|
487
|
+
resolveMentalModelProposal,
|
|
488
|
+
type MentalModelPendingProposal,
|
|
489
|
+
} from './mental-model-propose-resolve.js'
|
|
490
|
+
import { readDeclaredMentalModelNames } from './mental-model-propose-diff.js'
|
|
451
491
|
import {
|
|
452
492
|
parseSkillProposalCallback,
|
|
453
493
|
buildSkillProposalApplyInbound,
|
|
@@ -531,6 +571,8 @@ import {
|
|
|
531
571
|
startSubagentWatcher,
|
|
532
572
|
type SubagentWatcherHandle,
|
|
533
573
|
} from '../subagent-watcher.js'
|
|
574
|
+
import { listRecords as listWorktreeRecords } from '../../src/worktree/registry.js'
|
|
575
|
+
import { ownedWorktreeCwds } from '../worktree-watch-cwds.js'
|
|
534
576
|
import {
|
|
535
577
|
startBootCard,
|
|
536
578
|
resolvePersonaName,
|
|
@@ -551,7 +593,9 @@ import {
|
|
|
551
593
|
recordScopedGrant,
|
|
552
594
|
lookupScopedGrant,
|
|
553
595
|
sweepScopedGrants,
|
|
596
|
+
countScopedGrants,
|
|
554
597
|
} from '../scoped-approval.js'
|
|
598
|
+
import { createScopedGrantStore } from './scoped-grant-store.js'
|
|
555
599
|
import { grantRestartDecision, type GrantRestartDecision } from './grant-restart.js'
|
|
556
600
|
import { synthesizeAllowRuleDiff, extractAddedAllowRule } from '../permission-diff.js'
|
|
557
601
|
import {
|
|
@@ -622,6 +666,7 @@ import {
|
|
|
622
666
|
findRecentTurnsForChat,
|
|
623
667
|
getTurnByKey,
|
|
624
668
|
markTurnResumed,
|
|
669
|
+
reapStaleOpenTurns,
|
|
625
670
|
} from '../registry/turns-schema.js'
|
|
626
671
|
import {
|
|
627
672
|
buildResumeInterruptedInbound,
|
|
@@ -666,6 +711,12 @@ process.on('beforeExit', () => {
|
|
|
666
711
|
// ─── Env + state dir ──────────────────────────────────────────────────────
|
|
667
712
|
const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
|
|
668
713
|
const permCardStore = createPermissionCardStore(STATE_DIR)
|
|
714
|
+
// #2862 — missed-approvals re-offer. Persisted list of approvals that
|
|
715
|
+
// TTL-expired while the operator was away; a digest card is posted on the
|
|
716
|
+
// operator's next activity. Kill switch: SWITCHROOM_MISSED_APPROVAL_REOFFER=0.
|
|
717
|
+
const missedApprovalsStore = createMissedApprovalsStore(STATE_DIR)
|
|
718
|
+
const MISSED_APPROVAL_REOFFER_ENABLED =
|
|
719
|
+
process.env.SWITCHROOM_MISSED_APPROVAL_REOFFER !== '0'
|
|
669
720
|
const ACCESS_FILE = join(STATE_DIR, 'access.json')
|
|
670
721
|
const APPROVED_DIR = join(STATE_DIR, 'approved')
|
|
671
722
|
const ENV_FILE = join(STATE_DIR, '.env')
|
|
@@ -1903,6 +1954,25 @@ const obligationEscalateInFlight = new Set<string>()
|
|
|
1903
1954
|
// drain on the bare turn-end signal.
|
|
1904
1955
|
const SERIALIZE_UNTIL_REPLIED_ENABLED =
|
|
1905
1956
|
process.env.SWITCHROOM_SERIALIZE_UNTIL_REPLIED !== '0'
|
|
1957
|
+
// #2917 — rapid-fire per-chat FIFO. The #1556 delivery gate documents its
|
|
1958
|
+
// `turnInFlight` input as a LIVE read ("evaluated at delivery time — not a
|
|
1959
|
+
// receipt-time snapshot"), but handleInbound passes a receipt-time snapshot
|
|
1960
|
+
// (`turnInFlightAtReceipt`) taken at function entry. Under rapid-fire two
|
|
1961
|
+
// same-chat inbounds each snapshot "idle" during the other's async lead-in
|
|
1962
|
+
// (attachment download, composer-clear), both pass the gate, and both are
|
|
1963
|
+
// delivered — so replies come back reordered (observed 1,2,4,5,3,7,8,6). The
|
|
1964
|
+
// fix reads the gate LIVE off `claudeBusyKeys` (which does NOT yet contain
|
|
1965
|
+
// THIS inbound's own key — that is only marked at delivery — so it can't
|
|
1966
|
+
// self-block) AND reserves the key synchronously BEFORE the composer-clear
|
|
1967
|
+
// await, so a concurrent same-chat inbound observes the reservation and
|
|
1968
|
+
// buffers behind it, preserving FIFO. Nothing is dropped: a buffered inbound
|
|
1969
|
+
// drains on turn-complete / idle exactly as before. Kill switch (=0) restores
|
|
1970
|
+
// the receipt-snapshot behaviour. Only the non-cutover path is affected — the
|
|
1971
|
+
// delivery-machine cutover keeps its own at-receipt machine snapshot (reading
|
|
1972
|
+
// the machine live WOULD self-block, since its inbound event already advanced
|
|
1973
|
+
// it for this key).
|
|
1974
|
+
const SERIALIZE_INBOUND_DELIVERY_ENABLED =
|
|
1975
|
+
process.env.SWITCHROOM_SERIALIZE_INBOUND_DELIVERY !== '0'
|
|
1906
1976
|
// Component 2 (bounded no-reply escape hatch). A turn that legitimately
|
|
1907
1977
|
// ends with NO reply (handback ack, NO_REPLY marker, silent-end) sets
|
|
1908
1978
|
// finalAnswerDelivered=false and would block the serialize gate forever.
|
|
@@ -2302,13 +2372,15 @@ type CurrentTurn = {
|
|
|
2302
2372
|
silentAnchorText: string
|
|
2303
2373
|
capturedText: string[]
|
|
2304
2374
|
orphanedReplyTimeoutId: ReturnType<typeof setTimeout> | null
|
|
2305
|
-
//
|
|
2306
|
-
//
|
|
2307
|
-
//
|
|
2308
|
-
//
|
|
2309
|
-
//
|
|
2310
|
-
//
|
|
2311
|
-
|
|
2375
|
+
// Per-turn liveness tracker for the orphaned-reply backstop. Owns
|
|
2376
|
+
// `lastStreamEventAt` (stamped on ANY genuine stream event so a model
|
|
2377
|
+
// reasoning pause keeps the turn "recently streaming" and re-arms the fuse
|
|
2378
|
+
// instead of firing) and the rearm counter (bounded by
|
|
2379
|
+
// ORPHANED_REPLY_MAX_REARMS so a genuinely wedged single tool that never
|
|
2380
|
+
// streams still surfaces after the cap). The counter is zeroed on every
|
|
2381
|
+
// genuine stream event, so the cap only bites CONSECUTIVE silent expiries.
|
|
2382
|
+
// Fresh instance per enqueue (below). See context-exhaustion.ts.
|
|
2383
|
+
liveness: LivenessTracker
|
|
2312
2384
|
// Component 3 (turn-origin reply routing). A stable per-turn identity,
|
|
2313
2385
|
// `${registryKey-or-chatKey}#${startedAt}`, assigned when the turn
|
|
2314
2386
|
// starts and stamped into the inbound meta (`origin_turn_id`) so a reply
|
|
@@ -4579,6 +4651,131 @@ function clearPermissionTimeoutSuppression(reason: string): void {
|
|
|
4579
4651
|
`telegram gateway: permission no-repeat suppression cleared (${n} sig(s)) — ${reason}\n`,
|
|
4580
4652
|
)
|
|
4581
4653
|
}
|
|
4654
|
+
|
|
4655
|
+
// #2862 — missed-approvals re-offer digest. Called from every operator-activity
|
|
4656
|
+
// path that clears no-repeat suppression (inbound, /approve|/deny, card verdict):
|
|
4657
|
+
// on the FIRST activity after ≥1 approval TTL-expired while the operator was
|
|
4658
|
+
// away, post ONE compact digest card per origin surface listing the misses,
|
|
4659
|
+
// then promote those pending entries into a delivered-digest record (the posted
|
|
4660
|
+
// card is now the durable record in chat). Best-effort + fire-and-forget: never
|
|
4661
|
+
// blocks or throws into the hot inbound/callback path. Kill switch (=0) makes
|
|
4662
|
+
// this a no-op AND the auto-deny append below a no-op, so the whole feature is off.
|
|
4663
|
+
function maybePostMissedApprovalDigest(reason: string): void {
|
|
4664
|
+
if (!MISSED_APPROVAL_REOFFER_ENABLED) return
|
|
4665
|
+
const pending = missedApprovalsStore.listPending()
|
|
4666
|
+
if (pending.length === 0) return
|
|
4667
|
+
// Group by origin surface so each card lands where its cards were posted.
|
|
4668
|
+
const groups = new Map<string, MissedApproval[]>()
|
|
4669
|
+
for (const e of pending) {
|
|
4670
|
+
const key = `${e.chatId}::${e.threadId ?? ''}`
|
|
4671
|
+
const g = groups.get(key)
|
|
4672
|
+
if (g) g.push(e)
|
|
4673
|
+
else groups.set(key, [e])
|
|
4674
|
+
}
|
|
4675
|
+
for (const entries of groups.values()) {
|
|
4676
|
+
const first = entries[0]
|
|
4677
|
+
const chatId = first.chatId
|
|
4678
|
+
const threadId = first.threadId ?? undefined
|
|
4679
|
+
const digestId = randomBytes(4).toString('hex')
|
|
4680
|
+
const text = renderMissedApprovalsDigest(entries, {
|
|
4681
|
+
agentName: process.env.SWITCHROOM_AGENT_NAME ?? null,
|
|
4682
|
+
})
|
|
4683
|
+
void swallowingApiCall(
|
|
4684
|
+
() =>
|
|
4685
|
+
// allow-raw-bot-api: routed through swallowingApiCall retry policy; thread-aware digest card
|
|
4686
|
+
bot.api.sendMessage(chatId, text, {
|
|
4687
|
+
parse_mode: 'HTML',
|
|
4688
|
+
reply_markup: missedApprovalsKeyboard(digestId),
|
|
4689
|
+
...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
|
|
4690
|
+
}),
|
|
4691
|
+
{ chat_id: chatId, verb: 'missed-approval-digest', ...(threadId != null ? { threadId } : {}) },
|
|
4692
|
+
)
|
|
4693
|
+
// Promote synchronously (before the async send resolves) so a second
|
|
4694
|
+
// operator-activity event racing in sees an empty pending list and can't
|
|
4695
|
+
// double-post — "one card per accumulated batch" holds.
|
|
4696
|
+
missedApprovalsStore.promoteToDigest({
|
|
4697
|
+
digestId,
|
|
4698
|
+
chatId,
|
|
4699
|
+
threadId: first.threadId ?? null,
|
|
4700
|
+
entries,
|
|
4701
|
+
deliveredAt: Date.now(),
|
|
4702
|
+
})
|
|
4703
|
+
}
|
|
4704
|
+
process.stderr.write(
|
|
4705
|
+
`telegram gateway: missed-approval digest posted (${pending.length} entr(y/ies), ` +
|
|
4706
|
+
`${groups.size} surface(s)) — ${reason}\n`,
|
|
4707
|
+
)
|
|
4708
|
+
}
|
|
4709
|
+
|
|
4710
|
+
// #2862 — handle a Retry / Dismiss tap on a missed-approvals digest card.
|
|
4711
|
+
// missre:retry:<id> — inject a synthetic inbound asking the agent to
|
|
4712
|
+
// re-attempt the actions (claude-native; re-raises a
|
|
4713
|
+
// fresh approval card). NEVER a verdict / re-execution.
|
|
4714
|
+
// missre:dismiss:<id> — clear the record + edit the card closed.
|
|
4715
|
+
async function handleMissedApprovalCallback(ctx: Context, data: string): Promise<void> {
|
|
4716
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
4717
|
+
const access = loadAccess()
|
|
4718
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
4719
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
4720
|
+
return
|
|
4721
|
+
}
|
|
4722
|
+
const parsed = parseMissedApprovalCallback(data)
|
|
4723
|
+
if (parsed == null) {
|
|
4724
|
+
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
4725
|
+
return
|
|
4726
|
+
}
|
|
4727
|
+
const digest = missedApprovalsStore.getDigest(parsed.digestId)
|
|
4728
|
+
if (digest == null) {
|
|
4729
|
+
await ctx.answerCallbackQuery({ text: 'This digest already actioned or expired.' }).catch(() => {})
|
|
4730
|
+
if (ctx.callbackQuery?.message) {
|
|
4731
|
+
await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {})
|
|
4732
|
+
}
|
|
4733
|
+
return
|
|
4734
|
+
}
|
|
4735
|
+
|
|
4736
|
+
if (parsed.action === 'dismiss') {
|
|
4737
|
+
missedApprovalsStore.removeDigest(parsed.digestId)
|
|
4738
|
+
await ctx.answerCallbackQuery({ text: '🚫 Dismissed — cleared.' }).catch(() => {})
|
|
4739
|
+
if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
|
|
4740
|
+
await ctx
|
|
4741
|
+
.editMessageText(
|
|
4742
|
+
`${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n🚫 <i>Dismissed.</i>`,
|
|
4743
|
+
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
|
4744
|
+
)
|
|
4745
|
+
.catch(() => {})
|
|
4746
|
+
}
|
|
4747
|
+
return
|
|
4748
|
+
}
|
|
4749
|
+
|
|
4750
|
+
// Retry — consume the record, then inject a synthetic inbound to the ORIGIN
|
|
4751
|
+
// surface asking the agent to re-attempt. This rides the same synthesized-
|
|
4752
|
+
// inbound path cron uses; the agent naturally re-raises a fresh approval card.
|
|
4753
|
+
missedApprovalsStore.removeDigest(parsed.digestId)
|
|
4754
|
+
const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
4755
|
+
const synthetic = buildMissedApprovalRetryInbound({
|
|
4756
|
+
ctx: {
|
|
4757
|
+
agent,
|
|
4758
|
+
chat_id: digest.chatId,
|
|
4759
|
+
...(digest.threadId != null ? { threadId: digest.threadId } : {}),
|
|
4760
|
+
},
|
|
4761
|
+
actions: digest.entries.map(e => e.action),
|
|
4762
|
+
operatorId: senderId,
|
|
4763
|
+
})
|
|
4764
|
+
const delivered = deliverResumeSyntheticOrBuffer(agent, synthetic)
|
|
4765
|
+
await ctx.answerCallbackQuery({ text: '🔁 Asking the agent to retry…' }).catch(() => {})
|
|
4766
|
+
if (ctx.callbackQuery?.message && 'text' in ctx.callbackQuery.message) {
|
|
4767
|
+
await ctx
|
|
4768
|
+
.editMessageText(
|
|
4769
|
+
`${escapeHtmlForTg(ctx.callbackQuery.message.text ?? '')}\n\n🔁 <i>Retrying — asked the agent.</i>`,
|
|
4770
|
+
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
|
4771
|
+
)
|
|
4772
|
+
.catch(() => {})
|
|
4773
|
+
}
|
|
4774
|
+
process.stderr.write(
|
|
4775
|
+
`telegram gateway: missed-approval retry injection agent=${agent} ` +
|
|
4776
|
+
`digest=${parsed.digestId} actions=${digest.entries.length} delivered=${delivered}\n`,
|
|
4777
|
+
)
|
|
4778
|
+
}
|
|
4582
4779
|
// Permission/approval-card origin recovery (marko Rentals-budget, 2026-06-17).
|
|
4583
4780
|
// When `currentTurn` was force-closed by the orphaned-reply backstop but the
|
|
4584
4781
|
// claude session kept running into a permission-gated tool, recover the card's
|
|
@@ -4612,13 +4809,54 @@ function sweepStaleAlwaysAllowCorrelations(now = Date.now()): void {
|
|
|
4612
4809
|
}
|
|
4613
4810
|
}
|
|
4614
4811
|
|
|
4812
|
+
// Sibling of pendingAlwaysAllowCorrelations for the agent-proposes →
|
|
4813
|
+
// human-approves MENTAL MODEL flow (hindsight Phase 5). When the operator
|
|
4814
|
+
// taps Approve on a mental-model PROPOSAL card, the gateway dispatches a
|
|
4815
|
+
// `config_propose_edit` appending the model to memory.mental_models[]; hostd
|
|
4816
|
+
// then calls back for operator approval. We pre-register the exact diff here
|
|
4817
|
+
// so that callback auto-approves WITHOUT a second card (the operator already
|
|
4818
|
+
// approved on the proposal card). Forge-resistance: the auto-resolve match is
|
|
4819
|
+
// an EXACT byte-match of the inbound diff against the diff the gateway itself
|
|
4820
|
+
// synthesized and queued — an agent-forged edit finds no entry and falls
|
|
4821
|
+
// through to a real operator card. Single-shot + a dedicated TTL sweep.
|
|
4822
|
+
//
|
|
4823
|
+
// TTL: this correlation must outlive the WHOLE config-edit approval budget,
|
|
4824
|
+
// NOT the 30s "always allow" window. The mental-model resolve dispatches
|
|
4825
|
+
// `config_propose_edit` to hostd with a 720s timeout (see the 720_000 dispatch
|
|
4826
|
+
// budgets below); hostd's approval callback can legitimately arrive any time
|
|
4827
|
+
// within that window if the operator taps slowly. Reusing the 30s
|
|
4828
|
+
// ALWAYS_ALLOW_CORRELATION_TTL_MS would sweep the correlation out from under a
|
|
4829
|
+
// slow-but-valid tap, dropping the auto-approve and surfacing a SECOND card
|
|
4830
|
+
// for an edit the operator already approved. Size it to the hostd budget.
|
|
4831
|
+
const MENTAL_MODEL_CORRELATION_TTL_MS = 720_000
|
|
4832
|
+
const pendingMentalModelCorrelations = new Map<string, { agentName: string; unifiedDiff: string; createdAt: number }>()
|
|
4833
|
+
function mentalModelCorrelationKey(agentName: string, unifiedDiff: string): string {
|
|
4834
|
+
return `${agentName}::${createHash('sha256').update(unifiedDiff).digest('hex')}`
|
|
4835
|
+
}
|
|
4836
|
+
function sweepStaleMentalModelCorrelations(now = Date.now()): void {
|
|
4837
|
+
for (const [key, entry] of pendingMentalModelCorrelations) {
|
|
4838
|
+
if (now - entry.createdAt > MENTAL_MODEL_CORRELATION_TTL_MS) {
|
|
4839
|
+
pendingMentalModelCorrelations.delete(key)
|
|
4840
|
+
}
|
|
4841
|
+
}
|
|
4842
|
+
}
|
|
4843
|
+
|
|
4615
4844
|
// Scoped-approval store: the 30-min window that backs the "✅ Allow" tap for
|
|
4616
4845
|
// narrow non-destructive scopes (not a separate button — it IS what Allow
|
|
4617
4846
|
// means for those). Operator-tapped, gateway-side ONLY (never pushed to the
|
|
4618
4847
|
// bridge's untimed sessionAllowRules), fixed-window, fail-closed. Keyed by
|
|
4619
4848
|
// agent name for per-agent isolation. All policy lives in
|
|
4620
4849
|
// ../scoped-approval.ts (pure + unit-tested); this gateway only wires it.
|
|
4621
|
-
|
|
4850
|
+
//
|
|
4851
|
+
// Persisted across gateway restarts (#2863): a bounce (fleet roll / config
|
|
4852
|
+
// apply / failover probe) used to wipe every window, re-carding an action the
|
|
4853
|
+
// operator allowed minutes ago. The store mirrors to a tiny STATE_DIR JSON
|
|
4854
|
+
// file; reload drops entries already past their ABSOLUTE expiry (a restart
|
|
4855
|
+
// never extends a window). Kill switch SWITCHROOM_SCOPED_GRANT_PERSIST=0 boots
|
|
4856
|
+
// empty and never writes. Fail-closed lookup semantics are unchanged — a
|
|
4857
|
+
// reloaded grant runs the same lookupScopedGrant gate as an in-memory one.
|
|
4858
|
+
const scopedGrantStore = createScopedGrantStore(STATE_DIR)
|
|
4859
|
+
const scopedGrants: ScopedGrantStore = scopedGrantStore.load(Date.now())
|
|
4622
4860
|
const selfAgentName = (): string => process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
4623
4861
|
|
|
4624
4862
|
// `ask_user` MCP tool — open prompts awaiting a user button-tap.
|
|
@@ -4944,6 +5182,81 @@ function sweepPendingVaultRequestAccesses(): void {
|
|
|
4944
5182
|
}
|
|
4945
5183
|
}
|
|
4946
5184
|
|
|
5185
|
+
/**
|
|
5186
|
+
* Staged agent-initiated MENTAL MODEL proposal (hindsight Phase 5). The agent
|
|
5187
|
+
* calls `mental_model_propose`; the operator taps Approve/Deny on the card.
|
|
5188
|
+
* Mirrors PendingVaultRequestAccess — no memory content is staged here, only
|
|
5189
|
+
* the proposed DECLARATION (name + source_query + optional knobs). On Approve
|
|
5190
|
+
* the model becomes a first-class declared model in memory.mental_models[] via
|
|
5191
|
+
* the operator-approved config-edit path; on Deny nothing is written.
|
|
5192
|
+
*/
|
|
5193
|
+
interface PendingMentalModelPropose {
|
|
5194
|
+
agent: string
|
|
5195
|
+
chat_id: string
|
|
5196
|
+
card_message_id?: number
|
|
5197
|
+
threadId?: number
|
|
5198
|
+
/** Proposed declaration, snake_case (matches memory.mental_models[] schema). */
|
|
5199
|
+
spec: {
|
|
5200
|
+
name: string
|
|
5201
|
+
source_query: string
|
|
5202
|
+
refresh_after_consolidation?: boolean
|
|
5203
|
+
max_tokens?: number
|
|
5204
|
+
}
|
|
5205
|
+
reason?: string
|
|
5206
|
+
staged_at: number
|
|
5207
|
+
}
|
|
5208
|
+
const pendingMentalModelProposes = new Map<string, PendingMentalModelPropose>()
|
|
5209
|
+
const MENTAL_MODEL_PROPOSE_TTL_MS = approvalTtlMs()
|
|
5210
|
+
// Sweep expired pending proposals. For any entry past its TTL we ALSO edit the
|
|
5211
|
+
// posted card's keyboard away, so a stale card left in the chat can't be tapped
|
|
5212
|
+
// into a "Card expired" answer — the operator sees the ⌛ expiry inline instead.
|
|
5213
|
+
// Best-effort: card edits are fire-and-forget (the entry is removed regardless).
|
|
5214
|
+
function sweepPendingMentalModelProposes(): void {
|
|
5215
|
+
const cutoff = Date.now() - MENTAL_MODEL_PROPOSE_TTL_MS
|
|
5216
|
+
for (const [k, v] of pendingMentalModelProposes) {
|
|
5217
|
+
if (v.staged_at < cutoff) {
|
|
5218
|
+
pendingMentalModelProposes.delete(k)
|
|
5219
|
+
if (v.card_message_id != null) {
|
|
5220
|
+
void lockedBot.api
|
|
5221
|
+
.editMessageText(
|
|
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
|
+
}
|
|
5231
|
+
}
|
|
5232
|
+
|
|
5233
|
+
// Sliding-window rate limit for mental-model proposals: at most
|
|
5234
|
+
// MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW cards per MENTAL_MODEL_PROPOSE_WINDOW_MS.
|
|
5235
|
+
// The window is per-gateway-process, and since each agent runs its own gateway
|
|
5236
|
+
// process (keyed by $SWITCHROOM_AGENT_NAME), one process == one agent — so this
|
|
5237
|
+
// throttle is effectively per-agent. A candidate model is a deliberate, rare
|
|
5238
|
+
// curation act — an agent that re-proposes in a loop should be throttled so the
|
|
5239
|
+
// operator is never spammed.
|
|
5240
|
+
const mentalModelProposeTimes: number[] = []
|
|
5241
|
+
const MENTAL_MODEL_PROPOSE_WINDOW_MS = 60 * 60 * 1000
|
|
5242
|
+
const MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW = 5
|
|
5243
|
+
// Mirror the memory.mental_models[] schema caps (src/config/schema.ts): a
|
|
5244
|
+
// source_query capped at 2000 chars and max_tokens capped at 8192. Enforced
|
|
5245
|
+
// up-front in executeMentalModelPropose so a proposal that would fail hostd
|
|
5246
|
+
// config validation never reaches an approval card.
|
|
5247
|
+
const MENTAL_MODEL_SOURCE_QUERY_MAX = 2000
|
|
5248
|
+
const MENTAL_MODEL_MAX_TOKENS_CAP = 8192
|
|
5249
|
+
function checkMentalModelProposeRate(now = Date.now()): { ok: true } | { ok: false; retryAtMs: number } {
|
|
5250
|
+
const cutoff = now - MENTAL_MODEL_PROPOSE_WINDOW_MS
|
|
5251
|
+
while (mentalModelProposeTimes.length > 0 && mentalModelProposeTimes[0]! < cutoff) {
|
|
5252
|
+
mentalModelProposeTimes.shift()
|
|
5253
|
+
}
|
|
5254
|
+
if (mentalModelProposeTimes.length >= MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW) {
|
|
5255
|
+
return { ok: false, retryAtMs: mentalModelProposeTimes[0]! + MENTAL_MODEL_PROPOSE_WINDOW_MS }
|
|
5256
|
+
}
|
|
5257
|
+
return { ok: true }
|
|
5258
|
+
}
|
|
5259
|
+
|
|
4947
5260
|
/**
|
|
4948
5261
|
* Mint an approval-kernel decision row for a deferred-secret card
|
|
4949
5262
|
* (MIGRATION.md §1). Best-effort: if the kernel/broker is unreachable, we
|
|
@@ -5238,6 +5551,23 @@ const pendingStateReaper = setInterval(() => {
|
|
|
5238
5551
|
now,
|
|
5239
5552
|
)
|
|
5240
5553
|
}
|
|
5554
|
+
// #2862 — record the miss so it can be re-offered when the operator
|
|
5555
|
+
// returns. Anchor to the card's own origin surface (where the operator
|
|
5556
|
+
// would have tapped), so the digest lands in the same topic — not a
|
|
5557
|
+
// fanned-out DM. Skip if the card was never posted anywhere.
|
|
5558
|
+
if (MISSED_APPROVAL_REOFFER_ENABLED) {
|
|
5559
|
+
const origin = v.cards[0]
|
|
5560
|
+
if (origin != null) {
|
|
5561
|
+
missedApprovalsStore.add({
|
|
5562
|
+
requestId: k,
|
|
5563
|
+
toolName: v.tool_name,
|
|
5564
|
+
action: naturalAction(v.tool_name, v.input_preview),
|
|
5565
|
+
chatId: origin.chatId,
|
|
5566
|
+
threadId: origin.threadId ?? null,
|
|
5567
|
+
timedOutAt: now,
|
|
5568
|
+
})
|
|
5569
|
+
}
|
|
5570
|
+
}
|
|
5241
5571
|
process.stderr.write(
|
|
5242
5572
|
`telegram gateway: permission TTL expired — auto-deny request=${k} ` +
|
|
5243
5573
|
`tool=${v.tool_name} (no operator response in ` +
|
|
@@ -5256,8 +5586,14 @@ const pendingStateReaper = setInterval(() => {
|
|
|
5256
5586
|
if (now > v.expiresAt) vaultPassphraseCache.delete(k)
|
|
5257
5587
|
}
|
|
5258
5588
|
// Drop expired "⏱ 30 min" scoped grants. (Lookup already fails closed on
|
|
5259
|
-
// expiry; this just keeps the map from accumulating dead entries.)
|
|
5589
|
+
// expiry; this just keeps the map from accumulating dead entries.) Persist
|
|
5590
|
+
// the removal so a restart between sweeps can't resurrect a swept grant —
|
|
5591
|
+
// only write when the sweep actually changed something (sweeps only remove).
|
|
5592
|
+
const scopedGrantsBefore = countScopedGrants(scopedGrants)
|
|
5260
5593
|
sweepScopedGrants(scopedGrants, now)
|
|
5594
|
+
if (countScopedGrants(scopedGrants) !== scopedGrantsBefore) {
|
|
5595
|
+
scopedGrantStore.save(scopedGrants)
|
|
5596
|
+
}
|
|
5261
5597
|
for (const [k, v] of deferredSecrets) {
|
|
5262
5598
|
if (now - v.staged_at > DEFERRED_SECRET_TTL_MS) deferredSecrets.delete(k)
|
|
5263
5599
|
}
|
|
@@ -5754,6 +6090,22 @@ const statusPinStoreFs = {
|
|
|
5754
6090
|
}
|
|
5755
6091
|
const statusPinPersistEnabled = !STATIC && PIN_STATUS_WHILE_WORKING
|
|
5756
6092
|
|
|
6093
|
+
// Durable card-handle snapshot for the mid-turn activity card (Known Gap 1,
|
|
6094
|
+
// `reference/rfcs/deterministic-turn-liveness.md`). Persisted on card OPEN,
|
|
6095
|
+
// cleared on normal close/finalize; a boot-time reaper (wired alongside
|
|
6096
|
+
// `statusPinBootCleanup`, same startup-mutex ordering constraint) finalizes
|
|
6097
|
+
// any leftover record with one honest edit so a gateway restart mid-turn can
|
|
6098
|
+
// never leave the card frozen forever. STATIC mode skips disk (dry-run, no
|
|
6099
|
+
// durable volume) — same gate as the status-pin store.
|
|
6100
|
+
const ACTIVITY_CARD_STORE_PATH = join(STATE_DIR, 'activity-cards-pending.json')
|
|
6101
|
+
const activityCardStoreFs: ActivityCardStoreFsSeam = {
|
|
6102
|
+
readFileSync: (p: string) => readFileSync(p, 'utf8'),
|
|
6103
|
+
writeFileSync: (p: string, d: string) => writeFileSync(p, d),
|
|
6104
|
+
renameSync: (a: string, b: string) => renameSync(a, b),
|
|
6105
|
+
existsSync: (p: string) => existsSync(p),
|
|
6106
|
+
}
|
|
6107
|
+
const activityCardPersistEnabled = !STATIC
|
|
6108
|
+
|
|
5757
6109
|
// Slot-banner pin persistence (#421 crash-recovery). The slot banner is pinned
|
|
5758
6110
|
// in the owner chat when the agent is on a non-default OAuth slot. Rather than a
|
|
5759
6111
|
// parallel store + second boot hook, its pin is persisted in the SAME
|
|
@@ -5835,6 +6187,196 @@ async function statusPinBootCleanup(): Promise<void> {
|
|
|
5835
6187
|
)
|
|
5836
6188
|
}
|
|
5837
6189
|
}
|
|
6190
|
+
/**
|
|
6191
|
+
* Boot-time orphan-card reaper (Known Gap 1,
|
|
6192
|
+
* `reference/rfcs/deterministic-turn-liveness.md`). Thin gateway wrapper over
|
|
6193
|
+
* the pure `runActivityCardBootReaper` — binds the live fs seam, a real
|
|
6194
|
+
* Telegram edit, and the gateway logger. Finalizes ANY card left open by a
|
|
6195
|
+
* prior (crashed/restarted) session with ONE honest edit — never a new
|
|
6196
|
+
* message, so no ping. Guarantee is AT-MOST-ONCE: the pure routine deletes each
|
|
6197
|
+
* record before attempting its edit and does not retry, so a failed edit
|
|
6198
|
+
* forfeits that orphan rather than risk a double-finalize on a later boot (see
|
|
6199
|
+
* the module doc in activity-card-store.ts for the full tradeoff). A benign-400
|
|
6200
|
+
* (card already gone) is reported as `vanished`, not `finalized`.
|
|
6201
|
+
*
|
|
6202
|
+
* MUST run ONLY after this gateway wins the startup mutex (the store is a
|
|
6203
|
+
* shared per-agent file; a losing double-boot would finalize a card the
|
|
6204
|
+
* still-alive holder's in-flight turn still legitimately owns) — identical
|
|
6205
|
+
* ordering constraint to `statusPinBootCleanup`.
|
|
6206
|
+
*
|
|
6207
|
+
* Deliberately does NOT attempt to resume climbing the old card: the resumed
|
|
6208
|
+
* turn (if any) opens its own fresh card. Honest finalization, not
|
|
6209
|
+
* resumption, is the goal (see the module doc in activity-card-store.ts).
|
|
6210
|
+
*
|
|
6211
|
+
* After the finalizing edit, also unpins the card, but ONLY for a record whose
|
|
6212
|
+
* persisted `pinned` flag is true — i.e. the card was actually pin-eligible on
|
|
6213
|
+
* open (`PIN_STATUS_WHILE_WORKING`). This unpin is DEFENSE-IN-DEPTH, not the
|
|
6214
|
+
* primary unpin path: `statusPinBootCleanup` already unpins orphaned status
|
|
6215
|
+
* pins from its own durable store on boot. The reaper's unpin is belt-and-
|
|
6216
|
+
* braces for the case where the two stores disagree; the record's `pinned`
|
|
6217
|
+
* flag must therefore reflect the ACTUAL pin outcome (see the open path), not
|
|
6218
|
+
* an unconditional `true`, or the reaper would attempt an unpin on a card that
|
|
6219
|
+
* was never pinned.
|
|
6220
|
+
*/
|
|
6221
|
+
async function activityCardBootReaper(): Promise<void> {
|
|
6222
|
+
if (!activityCardPersistEnabled) return
|
|
6223
|
+
const { finalized, vanished, unpinned, total } = await runActivityCardBootReaper({
|
|
6224
|
+
path: ACTIVITY_CARD_STORE_PATH,
|
|
6225
|
+
fs: activityCardStoreFs,
|
|
6226
|
+
finalizeCard: (record) =>
|
|
6227
|
+
robustApiCall(
|
|
6228
|
+
() =>
|
|
6229
|
+
lockedBot.api.editMessageText(
|
|
6230
|
+
record.chatId,
|
|
6231
|
+
record.activityMessageId,
|
|
6232
|
+
richMessage(restartOrphanCardFinalizeText(record.startedAt)),
|
|
6233
|
+
{},
|
|
6234
|
+
),
|
|
6235
|
+
{
|
|
6236
|
+
chat_id: record.chatId,
|
|
6237
|
+
...(record.threadId != null ? { threadId: record.threadId } : {}),
|
|
6238
|
+
verb: 'activity-card.boot-reap-finalize',
|
|
6239
|
+
},
|
|
6240
|
+
),
|
|
6241
|
+
unpinCard: (record) =>
|
|
6242
|
+
robustApiCall(
|
|
6243
|
+
() => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId),
|
|
6244
|
+
{
|
|
6245
|
+
chat_id: record.chatId,
|
|
6246
|
+
...(record.threadId != null ? { threadId: record.threadId } : {}),
|
|
6247
|
+
verb: 'activity-card.boot-reap-unpin',
|
|
6248
|
+
},
|
|
6249
|
+
),
|
|
6250
|
+
})
|
|
6251
|
+
if (total > 0) {
|
|
6252
|
+
process.stderr.write(
|
|
6253
|
+
`telegram gateway: activity-card: finalized ${finalized}/${total} ` +
|
|
6254
|
+
`(vanished ${vanished}/${total}), unpinned ${unpinned}/${total} ` +
|
|
6255
|
+
`orphaned card(s) from a prior session (at-most-once)\n`,
|
|
6256
|
+
)
|
|
6257
|
+
}
|
|
6258
|
+
}
|
|
6259
|
+
// ─── Mid-session stale-card reaper (#2918) ──────────────────────────────────
|
|
6260
|
+
// The boot reapers (markOrphanedWithTimeoutClassification + activityCardBoot-
|
|
6261
|
+
// Reaper) run ONCE at startup. A turn whose owning SDK subprocess dies
|
|
6262
|
+
// mid-session (SIGKILL / OOM / crash) without a clean end leaves its turns-DB
|
|
6263
|
+
// row `ended_at IS NULL` and its activity card frozen on "working…" until the
|
|
6264
|
+
// NEXT gateway boot — often many hours. This periodic sweep, running inside the
|
|
6265
|
+
// live gateway, finalizes those orphans without waiting for a restart.
|
|
6266
|
+
//
|
|
6267
|
+
// LIVENESS (the critical correctness guard): a turn/card is reaped ONLY when no
|
|
6268
|
+
// live in-flight turn owns it. The live set is derived from `currentTurnMap`
|
|
6269
|
+
// (per-topic byKey) PLUS the singleton `currentTurn` mirror, so it is correct
|
|
6270
|
+
// whether or not the per-topic-isolation flag is on. A healthy long-running
|
|
6271
|
+
// turn is always in that set and is never swept. A TTL gate is a secondary
|
|
6272
|
+
// guard against the just-started-not-yet-tracked race.
|
|
6273
|
+
//
|
|
6274
|
+
// Kill switch: set SWITCHROOM_MID_SESSION_CARD_REAPER=0 to disable (clean
|
|
6275
|
+
// revert, mirrors #2919). TTL/interval overridable via env for tuning.
|
|
6276
|
+
const MID_SESSION_CARD_REAPER_ENABLED =
|
|
6277
|
+
process.env.SWITCHROOM_MID_SESSION_CARD_REAPER !== '0'
|
|
6278
|
+
const MID_SESSION_CARD_REAPER_TTL_MS = (() => {
|
|
6279
|
+
const v = Number(process.env.SWITCHROOM_MID_SESSION_CARD_REAPER_TTL_MS)
|
|
6280
|
+
return Number.isFinite(v) && v > 0 ? v : 15 * 60_000 // 15 min
|
|
6281
|
+
})()
|
|
6282
|
+
const MID_SESSION_CARD_REAPER_INTERVAL_MS = (() => {
|
|
6283
|
+
const v = Number(process.env.SWITCHROOM_MID_SESSION_CARD_REAPER_INTERVAL_MS)
|
|
6284
|
+
return Number.isFinite(v) && v > 0 ? v : 5 * 60_000 // 5 min
|
|
6285
|
+
})()
|
|
6286
|
+
|
|
6287
|
+
// Snapshot the turn_keys / topic keys owned by a live in-flight turn right now.
|
|
6288
|
+
function liveTurnKeySets(): { registryKeys: Set<string>; topicKeys: Set<string> } {
|
|
6289
|
+
const registryKeys = new Set<string>()
|
|
6290
|
+
const topicKeys = new Set<string>()
|
|
6291
|
+
const add = (t: CurrentTurn | null | undefined): void => {
|
|
6292
|
+
if (t == null) return
|
|
6293
|
+
if (t.registryKey != null && t.registryKey.length > 0) registryKeys.add(t.registryKey)
|
|
6294
|
+
topicKeys.add(statusKey(t.sessionChatId, t.sessionThreadId))
|
|
6295
|
+
}
|
|
6296
|
+
for (const t of currentTurnMap.byKey.values()) add(t)
|
|
6297
|
+
add(currentTurn) // flag-OFF store + most-recent mirror
|
|
6298
|
+
return { registryKeys, topicKeys }
|
|
6299
|
+
}
|
|
6300
|
+
|
|
6301
|
+
async function runMidSessionCardReaper(): Promise<void> {
|
|
6302
|
+
if (!MID_SESSION_CARD_REAPER_ENABLED) return
|
|
6303
|
+
const now = Date.now()
|
|
6304
|
+
const { registryKeys, topicKeys } = liveTurnKeySets()
|
|
6305
|
+
|
|
6306
|
+
// 1) Stamp ownerless open turns-DB rows (the durable spinner signal).
|
|
6307
|
+
if (turnsDb != null) {
|
|
6308
|
+
try {
|
|
6309
|
+
const { reaped, reapedTurnKeys } = reapStaleOpenTurns(turnsDb, {
|
|
6310
|
+
activeTurnKeys: registryKeys,
|
|
6311
|
+
ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
|
|
6312
|
+
now,
|
|
6313
|
+
})
|
|
6314
|
+
if (reaped > 0) {
|
|
6315
|
+
process.stderr.write(
|
|
6316
|
+
`telegram gateway: mid-session reaper stamped ${reaped} orphaned turn(s) ` +
|
|
6317
|
+
`as 'restart' (${reapedTurnKeys.join(',')})\n`,
|
|
6318
|
+
)
|
|
6319
|
+
}
|
|
6320
|
+
} catch (err) {
|
|
6321
|
+
process.stderr.write(
|
|
6322
|
+
`telegram gateway: mid-session turn reaper error: ${(err as Error).message}\n`,
|
|
6323
|
+
)
|
|
6324
|
+
}
|
|
6325
|
+
}
|
|
6326
|
+
|
|
6327
|
+
// 2) Finalize the leftover visible activity card(s) for those dead turns.
|
|
6328
|
+
if (activityCardPersistEnabled) {
|
|
6329
|
+
try {
|
|
6330
|
+
const { finalized, vanished, total } = await runActivityCardMidSessionReaper({
|
|
6331
|
+
path: ACTIVITY_CARD_STORE_PATH,
|
|
6332
|
+
fs: activityCardStoreFs,
|
|
6333
|
+
isLive: (record) => topicKeys.has(record.turnKey),
|
|
6334
|
+
ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
|
|
6335
|
+
now,
|
|
6336
|
+
finalizeCard: (record) =>
|
|
6337
|
+
robustApiCall(
|
|
6338
|
+
() =>
|
|
6339
|
+
lockedBot.api.editMessageText(
|
|
6340
|
+
record.chatId,
|
|
6341
|
+
record.activityMessageId,
|
|
6342
|
+
richMessage(restartOrphanCardFinalizeText(record.startedAt)),
|
|
6343
|
+
{},
|
|
6344
|
+
),
|
|
6345
|
+
{
|
|
6346
|
+
chat_id: record.chatId,
|
|
6347
|
+
...(record.threadId != null ? { threadId: record.threadId } : {}),
|
|
6348
|
+
verb: 'activity-card.mid-session-reap-finalize',
|
|
6349
|
+
},
|
|
6350
|
+
),
|
|
6351
|
+
unpinCard: (record) =>
|
|
6352
|
+
robustApiCall(
|
|
6353
|
+
() => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId),
|
|
6354
|
+
{
|
|
6355
|
+
chat_id: record.chatId,
|
|
6356
|
+
...(record.threadId != null ? { threadId: record.threadId } : {}),
|
|
6357
|
+
verb: 'activity-card.mid-session-reap-unpin',
|
|
6358
|
+
},
|
|
6359
|
+
),
|
|
6360
|
+
})
|
|
6361
|
+
if (total > 0) {
|
|
6362
|
+
process.stderr.write(
|
|
6363
|
+
`telegram gateway: activity-card: mid-session finalized ${finalized}/${total} ` +
|
|
6364
|
+
`(vanished ${vanished}/${total}) orphaned card(s) (at-most-once)\n`,
|
|
6365
|
+
)
|
|
6366
|
+
}
|
|
6367
|
+
} catch (err) {
|
|
6368
|
+
process.stderr.write(
|
|
6369
|
+
`telegram gateway: mid-session card reaper error: ${(err as Error).message}\n`,
|
|
6370
|
+
)
|
|
6371
|
+
}
|
|
6372
|
+
}
|
|
6373
|
+
}
|
|
6374
|
+
|
|
6375
|
+
const midSessionCardReaper = setInterval(() => {
|
|
6376
|
+
void runMidSessionCardReaper()
|
|
6377
|
+
}, MID_SESSION_CARD_REAPER_INTERVAL_MS)
|
|
6378
|
+
midSessionCardReaper.unref()
|
|
6379
|
+
|
|
5838
6380
|
// NOTE: statusPinBootCleanup() is deliberately NOT invoked here at import time.
|
|
5839
6381
|
// The status-pin store is a SHARED per-agent file, and cleanup issues real
|
|
5840
6382
|
// unpinChatMessage calls. On a double-boot the losing gateway must NOT touch
|
|
@@ -6140,6 +6682,7 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
|
|
|
6140
6682
|
// LOSING double-boot never unpins the live holder's legitimate pins.
|
|
6141
6683
|
// Fire-and-forget: cleanup is best-effort and must not block boot.
|
|
6142
6684
|
void statusPinBootCleanup()
|
|
6685
|
+
void activityCardBootReaper()
|
|
6143
6686
|
} catch (err) {
|
|
6144
6687
|
process.stderr.write(
|
|
6145
6688
|
`telegram gateway: boot.lock_acquire_failed err=${(err as Error).message} agent=${SWITCHROOM_AGENT_NAME}\n`,
|
|
@@ -6156,6 +6699,7 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
|
|
|
6156
6699
|
// successful writePidFile here means no live holder was detected, so
|
|
6157
6700
|
// running orphan cleanup is consistent with the pre-mutex behaviour.
|
|
6158
6701
|
void statusPinBootCleanup()
|
|
6702
|
+
void activityCardBootReaper()
|
|
6159
6703
|
} catch (writeErr) {
|
|
6160
6704
|
process.stderr.write(`telegram gateway: writePidFile failed: ${writeErr}\n`)
|
|
6161
6705
|
}
|
|
@@ -6199,13 +6743,22 @@ function parsePositiveMsEnv(name: string, fallbackMs: number): number {
|
|
|
6199
6743
|
const n = Number(raw)
|
|
6200
6744
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallbackMs
|
|
6201
6745
|
}
|
|
6746
|
+
// Orphaned-reply "recently streaming" window (thinking-pause fix). If a genuine
|
|
6747
|
+
// stream event landed within this window, the fuse re-arms instead of firing —
|
|
6748
|
+
// so a long model reasoning pause (which emits no text/tool events) is
|
|
6749
|
+
// survivable while a genuine multi-minute hang still surfaces. Default 120 s.
|
|
6750
|
+
const ORPHANED_REPLY_STREAM_WINDOW_MS = parsePositiveMsEnv('SWITCHROOM_ORPHANED_REPLY_STREAM_WINDOW_MS', 120_000)
|
|
6202
6751
|
const SILENCE_FALLBACK_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FALLBACK_MS', 300_000)
|
|
6203
6752
|
const SILENCE_FALLBACK_HARD_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FALLBACK_HARD_MS', 900_000)
|
|
6204
|
-
// #2527 — mid-turn liveness floor threshold (default 45s).
|
|
6205
|
-
//
|
|
6206
|
-
//
|
|
6207
|
-
//
|
|
6208
|
-
//
|
|
6753
|
+
// #2527 — mid-turn liveness floor threshold (default 45s). Still gates the
|
|
6754
|
+
// decision (`decideMidTurnFloor`, role + delivery + fire-once + timing) for a
|
|
6755
|
+
// `user` turn working silently this long without a substantive answer.
|
|
6756
|
+
// Phase 3 (deterministic-turn-liveness.md): the busy-but-silent case no
|
|
6757
|
+
// longer sends TEXT at this threshold — the climbing card (Phase 1) covers
|
|
6758
|
+
// it. The ONE surviving delivery gated by this threshold is the
|
|
6759
|
+
// approval-blocked re-ping (see `onMidTurnFloor` below). Strictly below
|
|
6760
|
+
// SILENCE_FALLBACK_MS (the loud 300s unwedge). Whole floor is
|
|
6761
|
+
// kill-switchable via SWITCHROOM_TG_LIVENESS_FLOOR=0.
|
|
6209
6762
|
const SILENCE_FLOOR_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FLOOR_MS', 45_000)
|
|
6210
6763
|
// #2527 — role-aware terminal reaction honesty (the "thumbs-up false done"
|
|
6211
6764
|
// fix). Default ON; SWITCHROOM_TG_TERMINAL_HONESTY=0 reverts to always-👍.
|
|
@@ -6222,6 +6775,25 @@ const SILENCE_DEFER_INFLIGHT_TOOLS = process.env.SWITCHROOM_SILENCE_DEFER_INFLIG
|
|
|
6222
6775
|
// and null currentTurn mid-work. Default ON; SWITCHROOM_SILENCE_LIVENESS_PRODUCTION=0
|
|
6223
6776
|
// restores the legacy "only a real reply resets the clock" behaviour.
|
|
6224
6777
|
const SILENCE_LIVENESS_PRODUCTION = process.env.SWITCHROOM_SILENCE_LIVENESS_PRODUCTION !== '0'
|
|
6778
|
+
// Sparse chat-legible memory (#2849, hindsight Phase 4). Surface ONE terse
|
|
6779
|
+
// line in the originating chat/topic when the interactive session materially
|
|
6780
|
+
// changes what it remembers (create_directive / invalidate / demote) — never
|
|
6781
|
+
// on ordinary recall or routine consolidation. Default ON;
|
|
6782
|
+
// SWITCHROOM_MEMORY_LEGIBILITY=0 disables it.
|
|
6783
|
+
const MEMORY_LEGIBILITY_ENABLED = isMemoryLegibilityEnabled(process.env.SWITCHROOM_MEMORY_LEGIBILITY)
|
|
6784
|
+
// Consolidation-driven legibility (#2849 follow-up, hindsight Phase 4). The
|
|
6785
|
+
// "updated what I know about Y" side, driven by the background
|
|
6786
|
+
// `consolidation.completed` webhook rather than a foreground tool call.
|
|
6787
|
+
// OPT-IN (default OFF), unlike MEMORY_LEGIBILITY_ENABLED above: this path is
|
|
6788
|
+
// fed by an unbounded background engine + a webhook the pinned hindsight image
|
|
6789
|
+
// does not yet emit, so the RFC keeps it "sparse … clearly gated".
|
|
6790
|
+
const CONSOLIDATION_LEGIBILITY_ENABLED = isConsolidationLegibilityEnabled(
|
|
6791
|
+
process.env.SWITCHROOM_CONSOLIDATION_LEGIBILITY,
|
|
6792
|
+
)
|
|
6793
|
+
// One long-lived limiter across all consolidation events — collapses a burst
|
|
6794
|
+
// of material consolidations to at most one line per interval, and suppresses
|
|
6795
|
+
// an identical "updated about X" line inside the dedup window.
|
|
6796
|
+
const consolidationRateLimiter = new ConsolidationRateLimiter()
|
|
6225
6797
|
|
|
6226
6798
|
/**
|
|
6227
6799
|
* Feed-survival predicate — the single source of truth for "is this turn
|
|
@@ -6290,10 +6862,19 @@ silencePoke.startTimer({
|
|
|
6290
6862
|
if (statusKey(turn.sessionChatId, turn.sessionThreadId) !== key) return null
|
|
6291
6863
|
return { role: turn.role, finalAnswerDelivered: turn.finalAnswerDelivered }
|
|
6292
6864
|
},
|
|
6293
|
-
//
|
|
6294
|
-
//
|
|
6295
|
-
//
|
|
6296
|
-
//
|
|
6865
|
+
// Phase 3 (reference/rfcs/deterministic-turn-liveness.md): the busy-but-
|
|
6866
|
+
// silent TEXT floor is retired. The climbing card (Phase 1 — the 0-label
|
|
6867
|
+
// branch of `feedHeartbeatTick` / `runSilentTurnHeartbeatTick` above) IS
|
|
6868
|
+
// the mid-turn floor now: it edits the already-open activity card's
|
|
6869
|
+
// elapsed clock every `FEED_HEARTBEAT_MIN_STALE_MS`, model-independent, no
|
|
6870
|
+
// ping. This handler still decides/fires via `decideMidTurnFloor` (the
|
|
6871
|
+
// timing + role + fire-once machinery in `silence-poke.ts` /
|
|
6872
|
+
// `turn-liveness-floor.ts` is unchanged and still load-bearing — see
|
|
6873
|
+
// below), but its ONLY surviving delivery is the approval-blocked re-ping.
|
|
6874
|
+
// Do not re-add a generic "still working…" text send here believing this
|
|
6875
|
+
// path covers the silent-tool case; it does not, and re-adding one would
|
|
6876
|
+
// reintroduce the exact banned cadence ping `conversational-pacing.md`
|
|
6877
|
+
// retired in #2667.
|
|
6297
6878
|
onMidTurnFloor: async (ctx) => {
|
|
6298
6879
|
// Late-fire guard, mirroring the fallback: a clean turn-end can race the
|
|
6299
6880
|
// tick. If the turn is gone, stay silent.
|
|
@@ -6301,19 +6882,19 @@ silencePoke.startTimer({
|
|
|
6301
6882
|
const blockedOnApproval = activeStatusReactions
|
|
6302
6883
|
.get(statusKey(ctx.chatId, ctx.threadId))
|
|
6303
6884
|
?.isAwaiting() ?? false
|
|
6885
|
+
// The one surviving text case: the turn is parked on an approval card
|
|
6886
|
+
// waiting for YOUR tap. That's not a stall — it names the real blocker —
|
|
6887
|
+
// so it keeps this quiet re-ping (`conversational-pacing.md` § Silence-
|
|
6888
|
+
// poke fallback names this one of the two surviving honest cases). The
|
|
6889
|
+
// busy-but-silent (non-approval) case sends nothing here; the card climb
|
|
6890
|
+
// (Phase 1) is its only signal now.
|
|
6891
|
+
if (!blockedOnApproval) return
|
|
6304
6892
|
const text = silencePoke.formatFrameworkFallbackText(
|
|
6305
6893
|
'working',
|
|
6306
6894
|
ctx.silenceMs,
|
|
6307
6895
|
ctx.inFlightTools,
|
|
6308
6896
|
blockedOnApproval,
|
|
6309
6897
|
)
|
|
6310
|
-
// The stall-notice stop-gap is retired: `formatFrameworkFallbackText`
|
|
6311
|
-
// returns null for the pure-liveness "still working / running <Tool>"
|
|
6312
|
-
// beat (the only string it still emits is the approval-blocked re-ping).
|
|
6313
|
-
// The early quiet floor beat existed ONLY to surface that stall notice, so
|
|
6314
|
-
// with the notice gone there is nothing to send here unless the turn is
|
|
6315
|
-
// parked on an approval card. Skip silently otherwise; the live draft +
|
|
6316
|
-
// the model's own pacing beats carry progress.
|
|
6317
6898
|
if (text == null) return
|
|
6318
6899
|
try {
|
|
6319
6900
|
await robustApiCall(
|
|
@@ -7283,6 +7864,81 @@ function dispatchPermissionVerdict(ev: PermissionEvent): void {
|
|
|
7283
7864
|
}
|
|
7284
7865
|
}
|
|
7285
7866
|
|
|
7867
|
+
/**
|
|
7868
|
+
* #2861 re-arm: a `permission_request` re-sent by the bridge after a gateway
|
|
7869
|
+
* restart, for a request_id that's NO LONGER in `pendingPermissions` but is
|
|
7870
|
+
* still in the persisted card store. Restore the in-memory pending entry with
|
|
7871
|
+
* the ORIGINAL `startedAt` (so the existing TTL clock keeps running from the
|
|
7872
|
+
* original ask — an already-expired re-arm drops straight into the TTL
|
|
7873
|
+
* auto-deny sweep, which correctly unwedges claude) and re-attach the inline
|
|
7874
|
+
* keyboard on the EXISTING message(s) via editMessageText — never a new card.
|
|
7875
|
+
*
|
|
7876
|
+
* NEVER answers the card: only the question is restored (no-self-escalation).
|
|
7877
|
+
*/
|
|
7878
|
+
function rearmPermissionFromStore(
|
|
7879
|
+
msg: PermissionRequestForward,
|
|
7880
|
+
persisted: PersistedPermCard[],
|
|
7881
|
+
): void {
|
|
7882
|
+
const { requestId, toolName, description, inputPreview } = msg
|
|
7883
|
+
// Original clock: take the earliest persisted startedAt (all entries for one
|
|
7884
|
+
// request share it, but be defensive).
|
|
7885
|
+
const startedAt = persisted.reduce(
|
|
7886
|
+
(min, c) => (c.startedAt < min ? c.startedAt : min),
|
|
7887
|
+
persisted[0].startedAt,
|
|
7888
|
+
)
|
|
7889
|
+
const cardText = persisted[0].cardText
|
|
7890
|
+
const cards = persisted.map(c => ({ chatId: c.chatId, messageId: c.messageId }))
|
|
7891
|
+
pendingPermissions.set(requestId, {
|
|
7892
|
+
tool_name: toolName,
|
|
7893
|
+
description,
|
|
7894
|
+
input_preview: inputPreview,
|
|
7895
|
+
startedAt,
|
|
7896
|
+
card_text: cardText,
|
|
7897
|
+
cards,
|
|
7898
|
+
})
|
|
7899
|
+
process.stderr.write(
|
|
7900
|
+
`telegram gateway: re-arming permission card(s) for request=${requestId} ` +
|
|
7901
|
+
`tool=${toolName} (${cards.length} card(s), original startedAt preserved)\n`,
|
|
7902
|
+
)
|
|
7903
|
+
// Re-attach the keyboard on each surviving card message. Message-id-targeted
|
|
7904
|
+
// edit — no thread needed, so THREAD_NOT_FOUND is not in the blast radius.
|
|
7905
|
+
const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null
|
|
7906
|
+
const keyboard = buildPermissionActionRow(requestId, showAlways)
|
|
7907
|
+
for (const c of cards) {
|
|
7908
|
+
void swallowingApiCall(
|
|
7909
|
+
// allow-raw-bot-api: routed through swallowingApiCall (retry policy); message-id-targeted edit (no thread to lose). Re-attaches the Allow/Deny keyboard on the persisted card after a gateway restart.
|
|
7910
|
+
() => bot.api.editMessageText(c.chatId, c.messageId, richMessage(cardText), { reply_markup: keyboard }),
|
|
7911
|
+
{ chat_id: c.chatId, verb: 'permission_request.rearm' },
|
|
7912
|
+
)
|
|
7913
|
+
}
|
|
7914
|
+
}
|
|
7915
|
+
|
|
7916
|
+
/**
|
|
7917
|
+
* Strip a single stale permission card: drop its keyboard and show the
|
|
7918
|
+
* "gateway restarted" notice. Shared by the boot-sweep (immediate legacy path
|
|
7919
|
+
* and the #2861 grace-period path). Best-effort — a deleted/inaccessible card
|
|
7920
|
+
* is swallowed, never fatal.
|
|
7921
|
+
*/
|
|
7922
|
+
async function stripStalePermissionCard(card: PersistedPermCard): Promise<void> {
|
|
7923
|
+
const toolLabel = card.toolName ?? 'unknown tool'
|
|
7924
|
+
const notice = `🔒 **${toolLabel}**\n\n⚠️ *Gateway restarted — this request is no longer active. Ask your agent to try again if needed.*`
|
|
7925
|
+
try {
|
|
7926
|
+
// allow-raw-bot-api: targeted by message_id; no thread needed; fire-and-forget boot sweep
|
|
7927
|
+
await bot.api.editMessageText(
|
|
7928
|
+
card.chatId,
|
|
7929
|
+
card.messageId,
|
|
7930
|
+
richMessage(notice),
|
|
7931
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
7932
|
+
)
|
|
7933
|
+
} catch (err) {
|
|
7934
|
+
// Card may already be deleted, edited, or in an inaccessible chat — benign
|
|
7935
|
+
process.stderr.write(
|
|
7936
|
+
`telegram gateway: boot-sweep: stale-card strip failed ` +
|
|
7937
|
+
`${card.chatId}:${card.messageId}: ${(err as Error).message}\n`,
|
|
7938
|
+
)
|
|
7939
|
+
}
|
|
7940
|
+
}
|
|
7941
|
+
|
|
7286
7942
|
const ipcServer: IpcServer = createIpcServer({
|
|
7287
7943
|
socketPath: SOCKET_PATH,
|
|
7288
7944
|
|
|
@@ -7652,6 +8308,38 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
7652
8308
|
|
|
7653
8309
|
onPermissionRequest(_client: IpcClient, msg: PermissionRequestForward) {
|
|
7654
8310
|
const { requestId, toolName, description, inputPreview } = msg
|
|
8311
|
+
// #2861 idempotent re-send handling. The bridge re-sends every outstanding
|
|
8312
|
+
// permission_request on each IPC (re)connect, so the same request_id can
|
|
8313
|
+
// arrive more than once. Disposition:
|
|
8314
|
+
// duplicate → already live in pendingPermissions; ignore (no 2nd card).
|
|
8315
|
+
// rearm → gone from memory (gateway restarted) but the persisted card
|
|
8316
|
+
// store still has it; restore + re-attach keyboard in place.
|
|
8317
|
+
// fresh → new ask; fall through to the normal card-posting path.
|
|
8318
|
+
// Either re-claim marks the id so the grace-period boot-sweep won't strip
|
|
8319
|
+
// it. Kill switch SWITCHROOM_PERMISSION_REARM=0 skips this entirely (legacy
|
|
8320
|
+
// behavior: a re-send would post a duplicate card, as before this fix).
|
|
8321
|
+
if (isPermissionRearmEnabled()) {
|
|
8322
|
+
const persisted = pendingPermissions.has(requestId)
|
|
8323
|
+
? []
|
|
8324
|
+
: permCardStore.loadAll().filter(e => e.requestId === requestId)
|
|
8325
|
+
const disposition = classifyPermissionRequest({
|
|
8326
|
+
hasPending: pendingPermissions.has(requestId),
|
|
8327
|
+
persistedCount: persisted.length,
|
|
8328
|
+
})
|
|
8329
|
+
if (disposition === 'duplicate') {
|
|
8330
|
+
process.stderr.write(
|
|
8331
|
+
`telegram gateway: permission_request duplicate re-send ignored ` +
|
|
8332
|
+
`request=${requestId} (already pending)\n`,
|
|
8333
|
+
)
|
|
8334
|
+
return
|
|
8335
|
+
}
|
|
8336
|
+
if (disposition === 'rearm') {
|
|
8337
|
+
// Re-arm restores the pending entry; the grace-period boot-sweep
|
|
8338
|
+
// preserves anything live in pendingPermissions, so it won't strip it.
|
|
8339
|
+
rearmPermissionFromStore(msg, persisted)
|
|
8340
|
+
return
|
|
8341
|
+
}
|
|
8342
|
+
}
|
|
7655
8343
|
// "⏱ 30 min" short-circuit: if the operator tapped a live scoped grant
|
|
7656
8344
|
// covering this exact request, auto-allow without posting a card. CRITICAL:
|
|
7657
8345
|
// dispatch WITHOUT a `rule` so the bridge does NOT cache it untimed
|
|
@@ -8180,11 +8868,24 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
8180
8868
|
// field, or any other byte difference) won't match → falls
|
|
8181
8869
|
// through to a real operator approval card.
|
|
8182
8870
|
const added = extractAddedAllowRule(msg.unifiedDiff)
|
|
8183
|
-
if (
|
|
8184
|
-
|
|
8185
|
-
|
|
8186
|
-
|
|
8187
|
-
|
|
8871
|
+
if (added) {
|
|
8872
|
+
const key = `${msg.agentName}::${added}`
|
|
8873
|
+
const entry = pendingAlwaysAllowCorrelations.get(key)
|
|
8874
|
+
if (entry && entry.unifiedDiff === msg.unifiedDiff) {
|
|
8875
|
+
pendingAlwaysAllowCorrelations.delete(key)
|
|
8876
|
+
return 'approve'
|
|
8877
|
+
}
|
|
8878
|
+
}
|
|
8879
|
+
// hindsight Phase 5: a mental-model PROPOSAL the operator already
|
|
8880
|
+
// approved on the proposal card. The gateway pre-registered the EXACT
|
|
8881
|
+
// diff; auto-approve on a byte-exact match (the security gate), so no
|
|
8882
|
+
// second card is posted. Any forged/other edit finds no entry and
|
|
8883
|
+
// falls through to a real operator card.
|
|
8884
|
+
sweepStaleMentalModelCorrelations()
|
|
8885
|
+
const mmKey = mentalModelCorrelationKey(msg.agentName, msg.unifiedDiff)
|
|
8886
|
+
const mmEntry = pendingMentalModelCorrelations.get(mmKey)
|
|
8887
|
+
if (mmEntry && mmEntry.unifiedDiff === msg.unifiedDiff) {
|
|
8888
|
+
pendingMentalModelCorrelations.delete(mmKey)
|
|
8188
8889
|
return 'approve'
|
|
8189
8890
|
}
|
|
8190
8891
|
return null
|
|
@@ -8317,11 +9018,45 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
8317
9018
|
// frequent cron, or a crashed cron session) falls back to the MAIN agent
|
|
8318
9019
|
// bridge so the fire lands now; it routes cheap again once the session is
|
|
8319
9020
|
// up. See deliverInjectWithFallback.
|
|
9021
|
+
// #2793 part B — durable cron boot-replay. A fire the scheduler is
|
|
9022
|
+
// REPLAYING across a restart carries `meta.replay_fire_ms`; route it
|
|
9023
|
+
// through the durable inbound spool so accept and consume are ledgered
|
|
9024
|
+
// separately (mirrors the real-inbound path). The scheduler's own
|
|
9025
|
+
// scheduler.jsonl records the fire at socket-accept, which stops
|
|
9026
|
+
// findMissedFires from re-firing it — but a socket accept is NOT proof
|
|
9027
|
+
// the session consumed it. Spooling here means: if the session isn't up
|
|
9028
|
+
// (accepted-but-not-consumed), the entry stays un-acked and the spool's
|
|
9029
|
+
// boot-replay re-delivers it on the next gateway boot (closes the
|
|
9030
|
+
// silent-loss window); and the stable `spoolId` (keyed on the replayed
|
|
9031
|
+
// fire) dedups a re-replay so it lands at most once (closes the
|
|
9032
|
+
// double-fire window). Live cron ticks never set replay_fire_ms, so they
|
|
9033
|
+
// keep the fire-and-forget path unchanged. STATIC mode has no spool —
|
|
9034
|
+
// fall through to the legacy path.
|
|
9035
|
+
const isDurableReplay =
|
|
9036
|
+
inboundSpool != null &&
|
|
9037
|
+
typeof msg.inbound.meta?.replay_fire_ms === 'string' &&
|
|
9038
|
+
msg.inbound.meta.replay_fire_ms.length > 0
|
|
9039
|
+
if (isDurableReplay && inboundSpool != null) {
|
|
9040
|
+
// Durable ACCEPT: put before delivery so a crash between accept and
|
|
9041
|
+
// consume leaves the entry recoverable (boot-replay re-delivers).
|
|
9042
|
+
// Idempotent by stable spoolId — a re-replay of the same missed fire
|
|
9043
|
+
// is a no-op while the prior entry is still un-acked.
|
|
9044
|
+
inboundSpool.put(msg.agentName, msg.inbound as unknown as InboundMessage)
|
|
9045
|
+
}
|
|
8320
9046
|
const { target, delivered, fellBackToMain } = deliverInjectWithFallback(
|
|
8321
9047
|
msg.agentName,
|
|
8322
9048
|
msg.inbound.meta,
|
|
8323
9049
|
(t) => ipcServer.sendToAgent(t, msg.inbound),
|
|
8324
9050
|
)
|
|
9051
|
+
if (isDurableReplay && inboundSpool != null && delivered) {
|
|
9052
|
+
// Durable CONSUME: the fire reached a live bridge, so tombstone the
|
|
9053
|
+
// spool entry (same "delivered to a live registered bridge" ack
|
|
9054
|
+
// semantics the inbound spool uses — see its v1 scope note). On a
|
|
9055
|
+
// miss we leave it un-acked: the pendingInboundBuffer.push below
|
|
9056
|
+
// buffers it for the reconnect drain, and the durable spool copy is
|
|
9057
|
+
// re-delivered on the next boot if this process dies first.
|
|
9058
|
+
inboundSpool.ack(msg.inbound as unknown as InboundMessage)
|
|
9059
|
+
}
|
|
8325
9060
|
if (fellBackToMain) {
|
|
8326
9061
|
process.stderr.write(
|
|
8327
9062
|
`telegram gateway: cron fire fell back to main session (no cron bridge) agent=${msg.agentName} prompt_key=${promptKey}\n`,
|
|
@@ -8560,6 +9295,7 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
8560
9295
|
{
|
|
8561
9296
|
inject: webhookInject,
|
|
8562
9297
|
log: (s) => process.stderr.write(`telegram gateway: ${s}`),
|
|
9298
|
+
onConsolidation: surfaceConsolidationLegibility,
|
|
8563
9299
|
},
|
|
8564
9300
|
),
|
|
8565
9301
|
})
|
|
@@ -8681,6 +9417,7 @@ const ALLOWED_TOOLS = new Set([
|
|
|
8681
9417
|
'vault_request_save',
|
|
8682
9418
|
'vault_request_access',
|
|
8683
9419
|
'request_secret',
|
|
9420
|
+
'mental_model_propose',
|
|
8684
9421
|
'linear_agent_activity',
|
|
8685
9422
|
'linear_create_issue',
|
|
8686
9423
|
'linear_agent_setup',
|
|
@@ -8727,6 +9464,8 @@ async function executeToolCall(tool: string, args: Record<string, unknown>): Pro
|
|
|
8727
9464
|
return executeVaultRequestAccess(args)
|
|
8728
9465
|
case 'request_secret':
|
|
8729
9466
|
return executeRequestSecret(args)
|
|
9467
|
+
case 'mental_model_propose':
|
|
9468
|
+
return executeMentalModelPropose(args)
|
|
8730
9469
|
case 'linear_agent_activity':
|
|
8731
9470
|
return executeLinearAgentActivity(args)
|
|
8732
9471
|
case 'linear_create_issue':
|
|
@@ -11355,6 +12094,182 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
|
|
|
11355
12094
|
}
|
|
11356
12095
|
}
|
|
11357
12096
|
|
|
12097
|
+
/** Read the live switchroom.yaml bytes for diff/dup-check. Mirrors the
|
|
12098
|
+
* always-allow persistence path's config read. */
|
|
12099
|
+
function readLiveSwitchroomConfigText(): string {
|
|
12100
|
+
const cfgPath = process.env.SWITCHROOM_CONFIG ?? findSwitchroomConfigFile()
|
|
12101
|
+
return readFileSync(cfgPath, 'utf8')
|
|
12102
|
+
}
|
|
12103
|
+
|
|
12104
|
+
const MENTAL_MODEL_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/
|
|
12105
|
+
|
|
12106
|
+
function buildMentalModelProposeKeyboard(stageId: string): { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> } {
|
|
12107
|
+
return {
|
|
12108
|
+
inline_keyboard: [
|
|
12109
|
+
[
|
|
12110
|
+
{ text: '✅ Approve', callback_data: `mmp:approve:${stageId}` },
|
|
12111
|
+
{ text: '🚫 Deny', callback_data: `mmp:deny:${stageId}` },
|
|
12112
|
+
],
|
|
12113
|
+
],
|
|
12114
|
+
}
|
|
12115
|
+
}
|
|
12116
|
+
|
|
12117
|
+
/**
|
|
12118
|
+
* `mental_model_propose` tool (hindsight Phase 5) — the agent surfaces a
|
|
12119
|
+
* candidate mental model for the operator to approve. Mirrors the
|
|
12120
|
+
* `vault_request_access` shape: the agent can only PROPOSE; the [Approve]/[Deny]
|
|
12121
|
+
* tap is operator-gated (handleMentalModelProposeCallback), so an agent can
|
|
12122
|
+
* never self-approve. On Approve the proposal is DECLARED — appended to the
|
|
12123
|
+
* agent's memory.mental_models[] via the operator-approved config-edit path
|
|
12124
|
+
* (reusing config_propose_edit apply+reconcile) — and ensured in the bank. On
|
|
12125
|
+
* Deny nothing is written. Guardrails enforced here BEFORE any card:
|
|
12126
|
+
* duplicate-name rejection against the agent's already-declared models, and a
|
|
12127
|
+
* per-agent rate limit so proposals stay non-spammy.
|
|
12128
|
+
*/
|
|
12129
|
+
async function executeMentalModelPropose(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
|
|
12130
|
+
const chat_id = String(args.chat_id ?? '')
|
|
12131
|
+
if (!chat_id) throw new Error('mental_model_propose: chat_id is required')
|
|
12132
|
+
const name = typeof args.name === 'string' ? args.name.trim() : ''
|
|
12133
|
+
if (!name) throw new Error('mental_model_propose: name is required')
|
|
12134
|
+
if (!MENTAL_MODEL_NAME_REGEX.test(name)) {
|
|
12135
|
+
throw new Error('mental_model_propose: name must be a slug (letters/digits/_/-, ≤64 chars, e.g. `training-plan-state`)')
|
|
12136
|
+
}
|
|
12137
|
+
const source_query = typeof args.source_query === 'string' ? args.source_query.trim() : ''
|
|
12138
|
+
if (!source_query) throw new Error('mental_model_propose: source_query is required')
|
|
12139
|
+
// Enforce the memory.mental_models[] schema cap (src/config/schema.ts) up-front
|
|
12140
|
+
// so the operator never approves a card that then fails hostd config validation.
|
|
12141
|
+
// The 2000-char ceiling also keeps the rendered card under Telegram's 4096-char
|
|
12142
|
+
// message limit.
|
|
12143
|
+
if (source_query.length > MENTAL_MODEL_SOURCE_QUERY_MAX) {
|
|
12144
|
+
throw new Error(
|
|
12145
|
+
`mental_model_propose: source_query is ${source_query.length} chars; the schema caps it at ${MENTAL_MODEL_SOURCE_QUERY_MAX} (a standing reflection query, not a document). Shorten it.`,
|
|
12146
|
+
)
|
|
12147
|
+
}
|
|
12148
|
+
// Accept `why` as an alias for `reason` (mirrors the vault tools).
|
|
12149
|
+
const reason =
|
|
12150
|
+
typeof args.reason === 'string' ? args.reason : typeof args.why === 'string' ? args.why : undefined
|
|
12151
|
+
let refresh_after_consolidation: boolean | undefined
|
|
12152
|
+
if (args.refresh_after_consolidation !== undefined) {
|
|
12153
|
+
if (typeof args.refresh_after_consolidation !== 'boolean') {
|
|
12154
|
+
throw new Error('mental_model_propose: refresh_after_consolidation must be a boolean')
|
|
12155
|
+
}
|
|
12156
|
+
refresh_after_consolidation = args.refresh_after_consolidation
|
|
12157
|
+
}
|
|
12158
|
+
let max_tokens: number | undefined
|
|
12159
|
+
if (args.max_tokens !== undefined) {
|
|
12160
|
+
const n = Number(args.max_tokens)
|
|
12161
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
12162
|
+
throw new Error('mental_model_propose: max_tokens must be a positive integer')
|
|
12163
|
+
}
|
|
12164
|
+
// Enforce the schema ceiling (src/config/schema.ts) here so the card can't be
|
|
12165
|
+
// approved into a config-validation failure downstream.
|
|
12166
|
+
if (n > MENTAL_MODEL_MAX_TOKENS_CAP) {
|
|
12167
|
+
throw new Error(
|
|
12168
|
+
`mental_model_propose: max_tokens ${n} exceeds the schema cap of ${MENTAL_MODEL_MAX_TOKENS_CAP} (a mental model is a standing summary, not a corpus).`,
|
|
12169
|
+
)
|
|
12170
|
+
}
|
|
12171
|
+
max_tokens = n
|
|
12172
|
+
}
|
|
12173
|
+
assertAllowedChat(chat_id)
|
|
12174
|
+
|
|
12175
|
+
const agentSlug = process.env.SWITCHROOM_AGENT_NAME || 'agent'
|
|
12176
|
+
|
|
12177
|
+
// Rate limit: a proposal is a rare, deliberate curation act — throttle so a
|
|
12178
|
+
// looping agent can never spam the operator with cards.
|
|
12179
|
+
const rate = checkMentalModelProposeRate()
|
|
12180
|
+
if (!rate.ok) {
|
|
12181
|
+
const retryAtIso = new Date(rate.retryAtMs).toISOString()
|
|
12182
|
+
return {
|
|
12183
|
+
content: [
|
|
12184
|
+
{
|
|
12185
|
+
type: 'text',
|
|
12186
|
+
text:
|
|
12187
|
+
`mental_model_propose: RATE-LIMITED (max ${MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW} proposals/hour). ` +
|
|
12188
|
+
`No card was posted. Next slot opens at ${retryAtIso}. Proposing mental models is meant to be ` +
|
|
12189
|
+
`rare — batch or wait rather than re-firing.`,
|
|
12190
|
+
},
|
|
12191
|
+
],
|
|
12192
|
+
}
|
|
12193
|
+
}
|
|
12194
|
+
|
|
12195
|
+
// Duplicate-name guard: reject a proposal for a model already DECLARED for
|
|
12196
|
+
// this agent, BEFORE posting a card (the name is the idempotent-ensure key).
|
|
12197
|
+
try {
|
|
12198
|
+
const configText = readLiveSwitchroomConfigText()
|
|
12199
|
+
const declared = readDeclaredMentalModelNames(configText, agentSlug)
|
|
12200
|
+
if (declared.includes(name)) {
|
|
12201
|
+
return {
|
|
12202
|
+
content: [
|
|
12203
|
+
{
|
|
12204
|
+
type: 'text',
|
|
12205
|
+
text:
|
|
12206
|
+
`mental_model_propose: '${name}' is ALREADY a declared mental model for ${agentSlug} ` +
|
|
12207
|
+
`(memory.mental_models[]). No card was posted — it already exists and is ensured in your ` +
|
|
12208
|
+
`bank. Pick a different name if you meant a NEW model, or just use the existing one.`,
|
|
12209
|
+
},
|
|
12210
|
+
],
|
|
12211
|
+
}
|
|
12212
|
+
}
|
|
12213
|
+
} catch (err) {
|
|
12214
|
+
// Config read failed (transient) — fall through to the card. The approve
|
|
12215
|
+
// path re-reads and re-checks (dupe guard is defense-in-depth), so a
|
|
12216
|
+
// redundant card is harmless; suppressing a needed card is not.
|
|
12217
|
+
process.stderr.write(`telegram gateway: mental_model_propose dup pre-check read failed: ${(err as Error).message}\n`)
|
|
12218
|
+
}
|
|
12219
|
+
|
|
12220
|
+
const stageId = randomBytes(4).toString('hex')
|
|
12221
|
+
const pending: PendingMentalModelPropose = {
|
|
12222
|
+
agent: agentSlug,
|
|
12223
|
+
chat_id,
|
|
12224
|
+
spec: {
|
|
12225
|
+
name,
|
|
12226
|
+
source_query,
|
|
12227
|
+
...(refresh_after_consolidation !== undefined ? { refresh_after_consolidation } : {}),
|
|
12228
|
+
...(max_tokens !== undefined ? { max_tokens } : {}),
|
|
12229
|
+
},
|
|
12230
|
+
...(reason ? { reason } : {}),
|
|
12231
|
+
staged_at: Date.now(),
|
|
12232
|
+
}
|
|
12233
|
+
pendingMentalModelProposes.set(stageId, pending)
|
|
12234
|
+
sweepPendingMentalModelProposes()
|
|
12235
|
+
|
|
12236
|
+
const text = renderMentalModelProposeCard({
|
|
12237
|
+
agent: agentSlug,
|
|
12238
|
+
name,
|
|
12239
|
+
source_query,
|
|
12240
|
+
...(reason ? { reason } : {}),
|
|
12241
|
+
...(refresh_after_consolidation !== undefined ? { refresh_after_consolidation } : {}),
|
|
12242
|
+
})
|
|
12243
|
+
const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
12244
|
+
if (threadId != null) pending.threadId = threadId
|
|
12245
|
+
const sent = await retryWithThreadFallback<{ message_id: number }>(
|
|
12246
|
+
robustApiCall,
|
|
12247
|
+
(tid) =>
|
|
12248
|
+
lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
|
|
12249
|
+
reply_markup: buildMentalModelProposeKeyboard(stageId),
|
|
12250
|
+
...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
|
|
12251
|
+
}),
|
|
12252
|
+
{ threadId, chat_id, verb: 'mental_model_propose.card' },
|
|
12253
|
+
)
|
|
12254
|
+
pending.card_message_id = sent.message_id
|
|
12255
|
+
// Only count a proposal against the rate budget once its card actually
|
|
12256
|
+
// posted (validation errors / dupes don't consume the budget).
|
|
12257
|
+
mentalModelProposeTimes.push(Date.now())
|
|
12258
|
+
|
|
12259
|
+
return {
|
|
12260
|
+
content: [
|
|
12261
|
+
{
|
|
12262
|
+
type: 'text',
|
|
12263
|
+
text:
|
|
12264
|
+
`mental_model_propose: card sent (stage_id=${stageId}, name=${name}). Wait for the operator to tap ` +
|
|
12265
|
+
`Approve or Deny — END YOUR TURN cleanly. A fresh inbound arrives with the outcome ` +
|
|
12266
|
+
`(source=mental_model_proposal_applied / mental_model_proposal_denied). Do NOT re-propose this ` +
|
|
12267
|
+
`model while the card is open.`,
|
|
12268
|
+
},
|
|
12269
|
+
],
|
|
12270
|
+
}
|
|
12271
|
+
}
|
|
12272
|
+
|
|
11358
12273
|
async function executeReact(args: Record<string, unknown>): Promise<unknown> {
|
|
11359
12274
|
if (!args.chat_id) throw new Error('react: chat_id is required')
|
|
11360
12275
|
if (!args.message_id) throw new Error('react: message_id is required')
|
|
@@ -11648,20 +12563,32 @@ function resetOrphanedReplyTimeout(): void {
|
|
|
11648
12563
|
}
|
|
11649
12564
|
return false
|
|
11650
12565
|
})()
|
|
11651
|
-
|
|
11652
|
-
|
|
11653
|
-
|
|
11654
|
-
|
|
11655
|
-
|
|
11656
|
-
|
|
11657
|
-
|
|
11658
|
-
|
|
11659
|
-
|
|
11660
|
-
|
|
11661
|
-
|
|
11662
|
-
|
|
11663
|
-
|
|
11664
|
-
|
|
12566
|
+
// Route the rearm decision through the per-turn LivenessTracker. It
|
|
12567
|
+
// rearms when working OR recently-streaming (thinking-pause survival)
|
|
12568
|
+
// OR human-waiting; working/recently-streaming rearms count against
|
|
12569
|
+
// ORPHANED_REPLY_MAX_REARMS while human-wait rearms stay uncapped.
|
|
12570
|
+
const now = Date.now()
|
|
12571
|
+
const recentlyStreaming = t.liveness.recentlyStreaming(now, ORPHANED_REPLY_STREAM_WINDOW_MS)
|
|
12572
|
+
const decision = t.liveness.decideOnExpiry({
|
|
12573
|
+
working,
|
|
12574
|
+
humanWaiting,
|
|
12575
|
+
now,
|
|
12576
|
+
windowMs: ORPHANED_REPLY_STREAM_WINDOW_MS,
|
|
12577
|
+
maxRearms: ORPHANED_REPLY_MAX_REARMS,
|
|
12578
|
+
})
|
|
12579
|
+
if (decision.rearm) {
|
|
12580
|
+
process.stderr.write(
|
|
12581
|
+
`telegram gateway: orphaned-reply fuse expired — re-arming` +
|
|
12582
|
+
` (rearm ${t.liveness.orphanedReplyRearmCount}/${ORPHANED_REPLY_MAX_REARMS},` +
|
|
12583
|
+
` in_flight=${toolFlightTracker.inFlightCount()},` +
|
|
12584
|
+
` human_wait=${humanWaiting},` +
|
|
12585
|
+
` recently_streaming=${recentlyStreaming},` +
|
|
12586
|
+
` bg_work=${pendingProgress.hasPendingAsyncDispatch(turnKey)})\n`,
|
|
12587
|
+
)
|
|
12588
|
+
resetOrphanedReplyTimeout()
|
|
12589
|
+
return
|
|
12590
|
+
}
|
|
12591
|
+
if (decision.countsAgainstCap) {
|
|
11665
12592
|
process.stderr.write(
|
|
11666
12593
|
`telegram gateway: orphaned-reply rearm cap reached (${ORPHANED_REPLY_MAX_REARMS}) — forcing backstop despite working state\n`,
|
|
11667
12594
|
)
|
|
@@ -11924,6 +12851,31 @@ async function drainActivitySummary(
|
|
|
11924
12851
|
)
|
|
11925
12852
|
turn.activityMessageId = sent.message_id
|
|
11926
12853
|
turn.activityEverOpened = true
|
|
12854
|
+
// Known Gap 1 (deterministic-turn-liveness.md) — persist the
|
|
12855
|
+
// minimal card handle the moment it opens, so a gateway restart
|
|
12856
|
+
// mid-turn has something to finalize on next boot instead of
|
|
12857
|
+
// leaving this card frozen forever. Fire-and-forget/best-effort:
|
|
12858
|
+
// a failed persist degrades to the pre-fix (in-memory-only)
|
|
12859
|
+
// behaviour, never blocks the card opening.
|
|
12860
|
+
if (activityCardPersistEnabled) {
|
|
12861
|
+
writeActivityCardRecord(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs, {
|
|
12862
|
+
turnKey: statusKey(chat, thread),
|
|
12863
|
+
chatId: chat,
|
|
12864
|
+
threadId: thread ?? null,
|
|
12865
|
+
activityMessageId: sent.message_id,
|
|
12866
|
+
startedAt: turn.startedAt,
|
|
12867
|
+
// Mirror the ACTUAL pin decision, not an unconditional `true`:
|
|
12868
|
+
// the OPEN below silently-pins the fresh card only when
|
|
12869
|
+
// `PIN_STATUS_WHILE_WORKING` is on (`reconcileStatusPin` no-ops
|
|
12870
|
+
// when it's off, and can also fail on missing supergroup
|
|
12871
|
+
// rights). Persisting `pinned: true` regardless would make the
|
|
12872
|
+
// boot reaper attempt an unpin on a card that was never pinned.
|
|
12873
|
+
// The reaper's unpin is defense-in-depth anyway
|
|
12874
|
+
// (`statusPinBootCleanup` owns the primary unpin), so tracking
|
|
12875
|
+
// the flag honestly is what matters here.
|
|
12876
|
+
pinned: PIN_STATUS_WHILE_WORKING,
|
|
12877
|
+
})
|
|
12878
|
+
}
|
|
11927
12879
|
// Status-pin: the per-turn status message just opened — it's the
|
|
11928
12880
|
// in-flight "what it's doing" surface. Silently pin it so the turn
|
|
11929
12881
|
// stays in view when the feed scrolls past. Keyed to the same
|
|
@@ -11968,11 +12920,15 @@ async function drainActivitySummary(
|
|
|
11968
12920
|
}
|
|
11969
12921
|
|
|
11970
12922
|
/**
|
|
11971
|
-
*
|
|
11972
|
-
*
|
|
11973
|
-
*
|
|
11974
|
-
*
|
|
11975
|
-
*
|
|
12923
|
+
* OPEN the minimal "Working…" liveness card for a 0-label turn once it has been
|
|
12924
|
+
* alive >= FEED_LIVENESS_OPEN_MS. The ONE place the liveness card may OPEN — both
|
|
12925
|
+
* the enqueue-time early-open timer (`scheduleEarlyLivenessOpen`) and the 6 s
|
|
12926
|
+
* heartbeat call through here, so a card opened by one caller is a clean no-op
|
|
12927
|
+
* for the other. This function OPENS only; it does NOT climb an already-open card
|
|
12928
|
+
* (its WHEN-gate `shouldEarlyOpenLiveness` returns false once `activityMessageId`
|
|
12929
|
+
* is set). The 0-label CLIMB of an already-open card lives at the heartbeat call
|
|
12930
|
+
* site via `silentTurnClimbRender` (deterministic-turn-liveness.md Phase 1) — so
|
|
12931
|
+
* BOTH the labelled and the 0-label branches now keep the card visibly climbing.
|
|
11976
12932
|
* - `drainActivitySummary` OPENs when `activityMessageId == null` and EDITs
|
|
11977
12933
|
* once it is set, so a second call after an open just maintains the card;
|
|
11978
12934
|
* - the `mirrorLines.length === 0` guard at the heartbeat call site (and the
|
|
@@ -12108,11 +13064,19 @@ function feedHeartbeatTick(): void {
|
|
|
12108
13064
|
// durable record once it completes.
|
|
12109
13065
|
// - 'emit' → genuine in-flight post-answer activity; render the card below.
|
|
12110
13066
|
const subagentAt = turn.subagentActivityAt
|
|
13067
|
+
// Fix 3 (sub-agent-delegation freeze): a foreground `Task`/`Agent` still
|
|
13068
|
+
// tracked in `turn.foregroundSubAgents` is POSITIVE evidence the worker
|
|
13069
|
+
// has not reported finished — see the doc comment on
|
|
13070
|
+
// `PostAnswerLivenessInput.stillDispatched` in turn-liveness-floor.ts for
|
|
13071
|
+
// why this must bypass the staleness cap rather than let a single long
|
|
13072
|
+
// silent step freeze the card mid-delegation.
|
|
13073
|
+
const stillDispatched = turn.foregroundSubAgents.size > 0
|
|
12111
13074
|
const livenessVerdict = evaluatePostAnswerLiveness({
|
|
12112
13075
|
subagentActivityAt: subagentAt,
|
|
12113
13076
|
finalAnswerDeliveredAt: turn.finalAnswerDeliveredAt,
|
|
12114
13077
|
now: Date.now(),
|
|
12115
13078
|
staleCapMs: POST_ANSWER_LIVENESS_STALE_MS,
|
|
13079
|
+
stillDispatched,
|
|
12116
13080
|
})
|
|
12117
13081
|
if (livenessVerdict !== 'emit' || subagentAt == null) return // idle gap or stale worker → stay silent (the `== null` also narrows subagentAt for the elapsed below)
|
|
12118
13082
|
// A background worker is genuinely active after the answer. Open or maintain
|
|
@@ -12157,13 +13121,45 @@ function feedHeartbeatTick(): void {
|
|
|
12157
13121
|
// sends (opens) when activityMessageId is null and edits (maintains) once set
|
|
12158
13122
|
// — so this one branch handles both the open and the climb.
|
|
12159
13123
|
//
|
|
12160
|
-
// The
|
|
13124
|
+
// The OPEN logic lives in ONE place (`openLivenessFeedIfDue`) so the
|
|
12161
13125
|
// enqueue-time early-open timer (`scheduleEarlyLivenessOpen`) and this 6 s
|
|
12162
13126
|
// heartbeat both reach the same drain — there is exactly one path that can
|
|
12163
13127
|
// OPEN the liveness card, so the two callers can never double-open or race.
|
|
12164
|
-
|
|
12165
|
-
|
|
12166
|
-
|
|
13128
|
+
//
|
|
13129
|
+
// Phase 1 climb (deterministic-turn-liveness.md): once the card IS open, the
|
|
13130
|
+
// OPEN path no-ops (its WHEN-gate `shouldEarlyOpenLiveness` returns false for
|
|
13131
|
+
// an already-open card) — which is exactly how the 0-label card used to FREEZE
|
|
13132
|
+
// during a long silent tool. So split the two cases: OPEN when no card exists,
|
|
13133
|
+
// and otherwise re-render the "Working…" card with a fresh wall-clock elapsed
|
|
13134
|
+
// through the SAME cardDrainGate / mayDrain / liveness EDIT path the labelled
|
|
13135
|
+
// branch below uses. Model-independent (reads only `now - startedAt`), so a
|
|
13136
|
+
// blocked tool call can't starve it; edit-only, so it never push-notifies.
|
|
13137
|
+
//
|
|
13138
|
+
// The tick BODY lives in feed-heartbeat-climb.ts (`runSilentTurnHeartbeatTick`)
|
|
13139
|
+
// so the shipped decision logic is directly under the outcome-based regression
|
|
13140
|
+
// test (tests/silent-turn-climb-transport.test.ts) — this gateway IIFE cannot
|
|
13141
|
+
// be imported in-process. This call site only wires the REAL deps; the wiring
|
|
13142
|
+
// shape is pinned structurally by tests/feed-heartbeat-liveness-open.test.ts.
|
|
13143
|
+
{
|
|
13144
|
+
const ea = emissionAuthorityFor(turn)
|
|
13145
|
+
const handled = runSilentTurnHeartbeatTick(
|
|
13146
|
+
{
|
|
13147
|
+
mirrorLineCount: turn.mirrorLines.length,
|
|
13148
|
+
activityMessageId: turn.activityMessageId,
|
|
13149
|
+
labeledToolCount: turn.labeledToolCount,
|
|
13150
|
+
ageMs: Date.now() - turn.startedAt,
|
|
13151
|
+
minStaleMs: FEED_HEARTBEAT_MIN_STALE_MS,
|
|
13152
|
+
},
|
|
13153
|
+
{
|
|
13154
|
+
openLivenessFeedIfDue: () => openLivenessFeedIfDue(turn),
|
|
13155
|
+
setPendingRender: (rendered) => { turn.activityPendingRender = rendered },
|
|
13156
|
+
cardDrainGate: (run) => cardDrainGate(turn, ea, run),
|
|
13157
|
+
mayDrain: () => ea.mayDrain(turn),
|
|
13158
|
+
openOrEditCard: (apply) => ea.openOrEditCard('liveness', apply),
|
|
13159
|
+
drain: () => { turn.activityInFlight = drainActivitySummary(turn, 'liveness') },
|
|
13160
|
+
},
|
|
13161
|
+
)
|
|
13162
|
+
if (handled) return
|
|
12167
13163
|
}
|
|
12168
13164
|
|
|
12169
13165
|
// Labelled-feed heartbeat: keep a stale in-progress step visibly advancing.
|
|
@@ -12228,6 +13224,21 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
|
|
|
12228
13224
|
if (turn.activityMessageId == null) return
|
|
12229
13225
|
const id = turn.activityMessageId
|
|
12230
13226
|
turn.activityMessageId = null
|
|
13227
|
+
// Known Gap 1 (deterministic-turn-liveness.md) — the card is closing
|
|
13228
|
+
// normally (about to be deleted or finalized below), so drop its durable
|
|
13229
|
+
// handle too: a normal close must never leave a stale record for the
|
|
13230
|
+
// boot reaper to "finalize" a message that's already been handled.
|
|
13231
|
+
if (activityCardPersistEnabled) {
|
|
13232
|
+
clearActivityCardRecord(
|
|
13233
|
+
ACTIVITY_CARD_STORE_PATH,
|
|
13234
|
+
activityCardStoreFs,
|
|
13235
|
+
statusKey(chat, thread),
|
|
13236
|
+
// Scope to this card's exact id (reap-race guard): only drop the row
|
|
13237
|
+
// for the card THIS turn is closing, never a fresher card another
|
|
13238
|
+
// turn may have already upserted under the same topic key.
|
|
13239
|
+
id,
|
|
13240
|
+
)
|
|
13241
|
+
}
|
|
12231
13242
|
if (CLEAR_STATUS_ON_COMPLETION) {
|
|
12232
13243
|
try {
|
|
12233
13244
|
await robustApiCall(
|
|
@@ -12260,16 +13271,201 @@ function clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | nu
|
|
|
12260
13271
|
() => bot.api.editMessageText(chat, id, richMessage(finalHtml), {}),
|
|
12261
13272
|
{ chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.finalize' },
|
|
12262
13273
|
)
|
|
12263
|
-
} catch (err) {
|
|
12264
|
-
const msg = err instanceof Error ? err.message : String(err)
|
|
12265
|
-
if (!msg.includes('message is not modified')) {
|
|
12266
|
-
process.stderr.write(`telegram gateway: activity-summary finalize failed: ${msg}\n`)
|
|
12267
|
-
}
|
|
13274
|
+
} catch (err) {
|
|
13275
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
13276
|
+
if (!msg.includes('message is not modified')) {
|
|
13277
|
+
process.stderr.write(`telegram gateway: activity-summary finalize failed: ${msg}\n`)
|
|
13278
|
+
}
|
|
13279
|
+
}
|
|
13280
|
+
})
|
|
13281
|
+
}
|
|
13282
|
+
|
|
13283
|
+
/**
|
|
13284
|
+
* #2849 hindsight Phase 4 — sparse chat-legible memory.
|
|
13285
|
+
*
|
|
13286
|
+
* Fire-and-forget: if this main-agent tool call is a material memory
|
|
13287
|
+
* operation (create_directive → 📌 remembered; invalidate/demote → ✂️
|
|
13288
|
+
* forgot), send ONE terse real message into the turn's originating
|
|
13289
|
+
* chat/topic. Routes on `turn.sessionChatId` / `turn.sessionThreadId`
|
|
13290
|
+
* (the originating topic, never the operator DM), through
|
|
13291
|
+
* `retryWithThreadFallback` so a deleted forum topic can't crash the
|
|
13292
|
+
* gateway. Non-material tool calls return silently — no line on ordinary
|
|
13293
|
+
* recall or routine consolidation. Kill-switch: SWITCHROOM_MEMORY_LEGIBILITY=0.
|
|
13294
|
+
*/
|
|
13295
|
+
/**
|
|
13296
|
+
* Fix 1.3 (#2903): a memory-legibility line must render only AFTER the write
|
|
13297
|
+
* is confirmed. We stage the detected event on `tool_use` (keyed by
|
|
13298
|
+
* `toolUseId`) and flush the 📌/✂️ line on the matching SUCCESSFUL
|
|
13299
|
+
* `tool_result` — a failed write (engine down / isError envelope) drops the
|
|
13300
|
+
* staged line, so chat never claims "remembered" for a write that errored.
|
|
13301
|
+
*
|
|
13302
|
+
* A hindsight `tools/call` returns HTTP 200 + `is_error:true` on failure, and
|
|
13303
|
+
* Claude Code surfaces that on the transcript `tool_result` block, which
|
|
13304
|
+
* `session-tail` projects onto `ev.isError`. That is the confirmed-success
|
|
13305
|
+
* signal we gate on.
|
|
13306
|
+
*/
|
|
13307
|
+
interface MemoryLegibilityRoute {
|
|
13308
|
+
chatId: string
|
|
13309
|
+
threadId: number | undefined
|
|
13310
|
+
toolName: string
|
|
13311
|
+
}
|
|
13312
|
+
const memoryLegibilityStager = new MemoryLegibilityStager<MemoryLegibilityRoute>()
|
|
13313
|
+
|
|
13314
|
+
function sendMemoryLegibilityLine(
|
|
13315
|
+
event: NonNullable<ReturnType<typeof detectMemoryLegibilityEvent>>,
|
|
13316
|
+
chatId: string,
|
|
13317
|
+
threadId: number | undefined,
|
|
13318
|
+
): void {
|
|
13319
|
+
const line = renderMemoryLegibilityLine(event)
|
|
13320
|
+
void retryWithThreadFallback(
|
|
13321
|
+
robustApiCall,
|
|
13322
|
+
(tid) =>
|
|
13323
|
+
bot.api.sendMessage(chatId, line, {
|
|
13324
|
+
parse_mode: 'HTML',
|
|
13325
|
+
// Status surface, not the user's answer — never ping the device.
|
|
13326
|
+
disable_notification: true,
|
|
13327
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
13328
|
+
}),
|
|
13329
|
+
{ threadId, chat_id: chatId, verb: 'memory-legibility.sendMessage' },
|
|
13330
|
+
).catch((err) => {
|
|
13331
|
+
process.stderr.write(
|
|
13332
|
+
`telegram gateway: memory-legibility send failed: ${
|
|
13333
|
+
err instanceof Error ? err.message : String(err)
|
|
13334
|
+
}\n`,
|
|
13335
|
+
)
|
|
13336
|
+
})
|
|
13337
|
+
}
|
|
13338
|
+
|
|
13339
|
+
/** Stage a material memory event observed on `tool_use`. Nothing is sent yet;
|
|
13340
|
+
* the line is flushed by `confirmMemoryLegibility` on a successful result. */
|
|
13341
|
+
function surfaceMemoryLegibility(
|
|
13342
|
+
turn: CurrentTurn,
|
|
13343
|
+
toolName: string,
|
|
13344
|
+
toolUseId: string | null | undefined,
|
|
13345
|
+
input: Record<string, unknown> | undefined,
|
|
13346
|
+
): void {
|
|
13347
|
+
if (!MEMORY_LEGIBILITY_ENABLED) return
|
|
13348
|
+
const event = detectMemoryLegibilityEvent(toolName, input)
|
|
13349
|
+
if (event == null) return
|
|
13350
|
+
const chatId = turn.sessionChatId
|
|
13351
|
+
const threadId = turn.sessionThreadId
|
|
13352
|
+
// Without a toolUseId we cannot correlate a result to confirm success. This
|
|
13353
|
+
// effectively never happens (Claude Code always stamps a tool_use id), so
|
|
13354
|
+
// rather than claim an unconfirmed "remembered", we skip and log.
|
|
13355
|
+
if (toolUseId == null || toolUseId.length === 0) {
|
|
13356
|
+
process.stderr.write(
|
|
13357
|
+
`telegram gateway: memory-legibility ${event.kind} SKIPPED (no toolUseId) ` +
|
|
13358
|
+
`chat=${chatId} tool=${toolName}\n`,
|
|
13359
|
+
)
|
|
13360
|
+
return
|
|
13361
|
+
}
|
|
13362
|
+
memoryLegibilityStager.stage(toolUseId, event, { chatId, threadId, toolName })
|
|
13363
|
+
process.stderr.write(
|
|
13364
|
+
`telegram gateway: memory-legibility ${event.kind} STAGED chat=${chatId} ` +
|
|
13365
|
+
`thread=${threadId ?? '-'} tool=${toolName} id=${toolUseId}\n`,
|
|
13366
|
+
)
|
|
13367
|
+
}
|
|
13368
|
+
|
|
13369
|
+
/** Flush (or drop) a staged memory-legibility line on its matching
|
|
13370
|
+
* `tool_result`. Sends only on confirmed success; a failed write is dropped
|
|
13371
|
+
* so chat never claims "remembered" for a write that errored. */
|
|
13372
|
+
function confirmMemoryLegibility(
|
|
13373
|
+
toolUseId: string | null | undefined,
|
|
13374
|
+
isError: boolean | undefined,
|
|
13375
|
+
): void {
|
|
13376
|
+
const resolved = memoryLegibilityStager.confirm(toolUseId, isError)
|
|
13377
|
+
if (resolved == null) {
|
|
13378
|
+
if (isError === true) {
|
|
13379
|
+
process.stderr.write(
|
|
13380
|
+
`telegram gateway: memory-legibility DROPPED (write errored) id=${toolUseId}\n`,
|
|
13381
|
+
)
|
|
12268
13382
|
}
|
|
13383
|
+
return
|
|
13384
|
+
}
|
|
13385
|
+
const { event, meta } = resolved
|
|
13386
|
+
process.stderr.write(
|
|
13387
|
+
`telegram gateway: memory-legibility ${event.kind} CONFIRMED chat=${meta.chatId} ` +
|
|
13388
|
+
`thread=${meta.threadId ?? '-'} tool=${meta.toolName} id=${toolUseId}\n`,
|
|
13389
|
+
)
|
|
13390
|
+
sendMemoryLegibilityLine(event, meta.chatId, meta.threadId)
|
|
13391
|
+
}
|
|
13392
|
+
|
|
13393
|
+
/**
|
|
13394
|
+
* #2849 hindsight Phase 4 (follow-up) — consolidation-driven legibility.
|
|
13395
|
+
*
|
|
13396
|
+
* The gateway-side sink for a verified `hindsight` / `consolidation.completed`
|
|
13397
|
+
* webhook. `recordWebhookEvent` has already logged the event for audit and
|
|
13398
|
+
* resolved the agent's channel target; this decides whether it is MATERIAL
|
|
13399
|
+
* (a genuine store/correct — else nothing), rate-limits it hard, and — only
|
|
13400
|
+
* if it clears both gates — sends ONE terse "🧠 updated what I know about Y"
|
|
13401
|
+
* real message with notifications suppressed. Never injects a model turn.
|
|
13402
|
+
* OPT-IN via SWITCHROOM_CONSOLIDATION_LEGIBILITY.
|
|
13403
|
+
*/
|
|
13404
|
+
function surfaceConsolidationLegibility(
|
|
13405
|
+
rec: WebhookGatewayRecord,
|
|
13406
|
+
target: { chatId: string; threadId?: number },
|
|
13407
|
+
): void {
|
|
13408
|
+
if (!CONSOLIDATION_LEGIBILITY_ENABLED) return
|
|
13409
|
+
const event = detectConsolidationEvent(rec.payload)
|
|
13410
|
+
if (event == null) return // routine no-op consolidation — surface nothing
|
|
13411
|
+
const agent = rec.agent
|
|
13412
|
+
// Rate-limiter clock MUST be wall-clock (Date.now()), never rec.ts: rec.ts
|
|
13413
|
+
// can be derived from the (attacker-influenceable) webhook payload, and a
|
|
13414
|
+
// spoofed far-past / far-future timestamp would poison the per-agent
|
|
13415
|
+
// min-interval gate — either permanently opening it (stale ts always older
|
|
13416
|
+
// than the window) or jamming it shut. The limiter only cares about "how
|
|
13417
|
+
// long since the last surface on THIS gateway", which is a local wall-clock
|
|
13418
|
+
// question.
|
|
13419
|
+
if (!consolidationRateLimiter.allow(agent, consolidationSignature(event), Date.now())) {
|
|
13420
|
+
process.stderr.write(
|
|
13421
|
+
`telegram gateway: consolidation-legibility ${event.kind} rate-limited ` +
|
|
13422
|
+
`agent=${agent} topic='${event.topic}'\n`,
|
|
13423
|
+
)
|
|
13424
|
+
return
|
|
13425
|
+
}
|
|
13426
|
+
const chatId = target.chatId
|
|
13427
|
+
const threadId = target.threadId
|
|
13428
|
+
const line = renderConsolidationLine(event)
|
|
13429
|
+
process.stderr.write(
|
|
13430
|
+
`telegram gateway: consolidation-legibility ${event.kind} chat=${chatId} ` +
|
|
13431
|
+
`thread=${threadId ?? '-'} agent=${agent}\n`,
|
|
13432
|
+
)
|
|
13433
|
+
void retryWithThreadFallback(
|
|
13434
|
+
robustApiCall,
|
|
13435
|
+
(tid) =>
|
|
13436
|
+
bot.api.sendMessage(chatId, line, {
|
|
13437
|
+
parse_mode: 'HTML',
|
|
13438
|
+
// Status surface, not the user's answer — never ping the device.
|
|
13439
|
+
disable_notification: true,
|
|
13440
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
13441
|
+
}),
|
|
13442
|
+
{ threadId, chat_id: chatId, verb: 'consolidation-legibility.sendMessage' },
|
|
13443
|
+
).catch((err) => {
|
|
13444
|
+
process.stderr.write(
|
|
13445
|
+
`telegram gateway: consolidation-legibility send failed: ${
|
|
13446
|
+
err instanceof Error ? err.message : String(err)
|
|
13447
|
+
}\n`,
|
|
13448
|
+
)
|
|
12269
13449
|
})
|
|
12270
13450
|
}
|
|
12271
13451
|
|
|
12272
13452
|
function handleSessionEvent(ev: SessionEvent): void {
|
|
13453
|
+
// Per-turn liveness stamp (orphaned-reply thinking-pause fix). Stamp
|
|
13454
|
+
// lastStreamEventAt AND reset the rearm counter on ANY genuine stream event,
|
|
13455
|
+
// under ONE shared predicate: a live turn is present and this is not the
|
|
13456
|
+
// synthetic durationMs===-1 turn_end (the fire callback's own re-dispatch).
|
|
13457
|
+
// The counter reset MUST live here at the dispatcher — NOT inside
|
|
13458
|
+
// resetOrphanedReplyTimeout() (which is called from the fire callback one
|
|
13459
|
+
// line after the counter increments) and NOT tied to a single case (e.g.
|
|
13460
|
+
// tool_result does not call resetOrphanedReplyTimeout). onStreamEvent applies
|
|
13461
|
+
// the `!(turn_end && -1)` half of the predicate internally.
|
|
13462
|
+
{
|
|
13463
|
+
const liveTurn = currentTurn
|
|
13464
|
+
if (liveTurn != null) {
|
|
13465
|
+
const durationMs = ev.kind === 'turn_end' ? ev.durationMs : undefined
|
|
13466
|
+
liveTurn.liveness.onStreamEvent(ev.kind, durationMs, Date.now())
|
|
13467
|
+
}
|
|
13468
|
+
}
|
|
12273
13469
|
switch (ev.kind) {
|
|
12274
13470
|
case 'enqueue': {
|
|
12275
13471
|
// Drain any orphaned typing-wrap entries left over from a crashed
|
|
@@ -12306,6 +13502,14 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
12306
13502
|
prior.answerStream.stop()
|
|
12307
13503
|
prior.answerStream = null
|
|
12308
13504
|
}
|
|
13505
|
+
// Bounded-leak hardening (A5): clear the prior turn's orphaned-reply
|
|
13506
|
+
// fuse before it is superseded. The fire callback re-reads currentTurn
|
|
13507
|
+
// and no-ops on a stale turn, but proactively clearing the timer avoids
|
|
13508
|
+
// a bounded pile-up of dangling timers across rapid steer/queue turns.
|
|
13509
|
+
if (prior?.orphanedReplyTimeoutId != null) {
|
|
13510
|
+
clearTimeout(prior.orphanedReplyTimeoutId)
|
|
13511
|
+
prior.orphanedReplyTimeoutId = null
|
|
13512
|
+
}
|
|
12309
13513
|
// #1067: swap the entire turn atom in one assignment. Every
|
|
12310
13514
|
// handler captures `const turn = currentTurn` at entry, so a
|
|
12311
13515
|
// captured-then-awaited read can't reattribute to the new turn.
|
|
@@ -12365,7 +13569,9 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
12365
13569
|
silentAnchorText: '',
|
|
12366
13570
|
capturedText: [],
|
|
12367
13571
|
orphanedReplyTimeoutId: null,
|
|
12368
|
-
|
|
13572
|
+
// Fresh liveness tracker: lastStreamEventAt seeded to the turn start
|
|
13573
|
+
// so a turn that never streams still trips the fuse after windowMs.
|
|
13574
|
+
liveness: new LivenessTracker(startedAt),
|
|
12369
13575
|
turnId,
|
|
12370
13576
|
registryKey: null,
|
|
12371
13577
|
noReplyDrainTimer: null,
|
|
@@ -12567,6 +13773,14 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
12567
13773
|
// of dropping. The answer-stream's own dedup handles overlap
|
|
12568
13774
|
// with the reply tool's payload.
|
|
12569
13775
|
preambleSuppressor.onTool({ isReplyTool: isTelegramSurfaceTool(ev.toolName) })
|
|
13776
|
+
// #2849 Phase 4 — sparse chat-legible memory. Surface ONE terse line in
|
|
13777
|
+
// the originating chat/topic when this tool call materially changes what
|
|
13778
|
+
// the agent remembers (create_directive / invalidate / demote). Fires
|
|
13779
|
+
// BEFORE the `if (!ctrl) return` status-reaction gate below so it works
|
|
13780
|
+
// on turns with no active status-reaction controller. Deterministic
|
|
13781
|
+
// tool-call observation — no model call, no polling; ordinary recall and
|
|
13782
|
+
// routine consolidation never reach here (they aren't material tools).
|
|
13783
|
+
surfaceMemoryLegibility(turn, ev.toolName, ev.toolUseId, ev.input)
|
|
12570
13784
|
const ctrl = activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId))
|
|
12571
13785
|
const name = ev.toolName
|
|
12572
13786
|
// Phase tracking removed in #553 PR 5 — phases only fed the
|
|
@@ -12997,6 +14211,9 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
12997
14211
|
}
|
|
12998
14212
|
case 'tool_result': {
|
|
12999
14213
|
if (ev.toolUseId) typingWrapper.onToolResult(ev.toolUseId)
|
|
14214
|
+
// Fix 1.3 (#2903): flush a staged 📌/✂️ memory-legibility line only on a
|
|
14215
|
+
// CONFIRMED-successful write; a failed write (ev.isError) drops it.
|
|
14216
|
+
confirmMemoryLegibility(ev.toolUseId, ev.isError)
|
|
13000
14217
|
return
|
|
13001
14218
|
}
|
|
13002
14219
|
case 'sub_agent_tool_use': {
|
|
@@ -13024,10 +14241,25 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
13024
14241
|
if (ev.durationMs === -1) {
|
|
13025
14242
|
const turn = currentTurn
|
|
13026
14243
|
const key = turn != null ? statusKey(turn.sessionChatId, turn.sessionThreadId) : ''
|
|
13027
|
-
|
|
14244
|
+
// Widened to also suppress while the turn is RECENTLY STREAMING — a
|
|
14245
|
+
// model reasoning pause emits no tool/text events (so
|
|
14246
|
+
// isLegitimatelyWorking is false) but a genuine stream landed within
|
|
14247
|
+
// the window, so the turn is alive and must not be torn down.
|
|
14248
|
+
// ACCEPTED TRADE-OFF (F3): the context-exhaustion recovery latency via
|
|
14249
|
+
// THIS backstop path grows from ~30 s to ~120-150 s, because the
|
|
14250
|
+
// "Prompt is too long" marker is itself a genuine `text` event that
|
|
14251
|
+
// stamps recentlyStreaming. This is acceptable — the primary
|
|
14252
|
+
// context-exhaustion teardown is the immediate `endCurrentTurnAtomic`
|
|
14253
|
+
// in `case 'text'` (isContextExhaustionText) above; this backstop only
|
|
14254
|
+
// matters if that path is missed, and it still COMPLETES once the
|
|
14255
|
+
// window lapses. Recovery is delayed, never suppressed forever.
|
|
14256
|
+
const recentlyStreaming =
|
|
14257
|
+
turn != null && turn.liveness.recentlyStreaming(Date.now(), ORPHANED_REPLY_STREAM_WINDOW_MS)
|
|
14258
|
+
if (isLegitimatelyWorking(key) || recentlyStreaming) {
|
|
13028
14259
|
process.stderr.write(
|
|
13029
14260
|
`telegram gateway: synthetic turn_end suppressed — legitimately working` +
|
|
13030
14261
|
` (in_flight=${toolFlightTracker.inFlightCount()},` +
|
|
14262
|
+
` recently_streaming=${recentlyStreaming},` +
|
|
13031
14263
|
` bg_work=${turn != null ? pendingProgress.hasPendingAsyncDispatch(key) : false})\n`,
|
|
13032
14264
|
)
|
|
13033
14265
|
return
|
|
@@ -13753,11 +14985,28 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
13753
14985
|
// this path. The turn-flush 'flush' branch also returns earlier
|
|
13754
14986
|
// (and sets finalAnswerDelivered=true defensively).
|
|
13755
14987
|
if (turn.finalAnswerDelivered === false) {
|
|
13756
|
-
|
|
13757
|
-
|
|
13758
|
-
|
|
13759
|
-
|
|
13760
|
-
|
|
14988
|
+
// PR #2892 (deterministic-turn-liveness RFC Phase 2) hardening:
|
|
14989
|
+
// wire the represent-guard-style staleness
|
|
14990
|
+
// check (`recordSilentTurnEnd`'s `hasOutboundDeliveredSince` dep) so
|
|
14991
|
+
// an exhausted-looking record left over from a PRIOR, already-
|
|
14992
|
+
// answered turn on this same chat/thread (statusKey is not a
|
|
14993
|
+
// per-turn nonce) can never be misread as this turn's spent
|
|
14994
|
+
// re-prompt budget. Falls back to the pre-existing turnKey/
|
|
14995
|
+
// retryCount-only check when history is unavailable.
|
|
14996
|
+
const silentEndDeps: SilentEndDeps | undefined = HISTORY_ENABLED
|
|
14997
|
+
? {
|
|
14998
|
+
hasOutboundDeliveredSince: (cid, sinceMs, tid) =>
|
|
14999
|
+
hasOutboundDeliveredSince(cid, sinceMs, tid, 1),
|
|
15000
|
+
}
|
|
15001
|
+
: undefined
|
|
15002
|
+
const silentEnd = recordUndeliveredTurnEnd(
|
|
15003
|
+
{
|
|
15004
|
+
chatId,
|
|
15005
|
+
threadId: threadId ?? null,
|
|
15006
|
+
turnKey: tKey,
|
|
15007
|
+
},
|
|
15008
|
+
silentEndDeps,
|
|
15009
|
+
)
|
|
13761
15010
|
if (silentEnd.exhausted) {
|
|
13762
15011
|
process.stderr.write(
|
|
13763
15012
|
`telegram gateway: WARN silent-end fallback — agent stayed ` +
|
|
@@ -13769,7 +15018,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
13769
15018
|
(tid) =>
|
|
13770
15019
|
bot.api.sendMessage(
|
|
13771
15020
|
chatId,
|
|
13772
|
-
|
|
15021
|
+
silentEndFallbackText(turnDurationMs),
|
|
13773
15022
|
tid != null ? { message_thread_id: tid } : {},
|
|
13774
15023
|
),
|
|
13775
15024
|
{ threadId, chat_id: chatId, verb: 'silent-end-fallback.sendMessage' },
|
|
@@ -14289,6 +15538,8 @@ async function handleInbound(
|
|
|
14289
15538
|
// present, so reset any no-repeat suppression: the next time the agent asks
|
|
14290
15539
|
// for something that timed out earlier, they should see a fresh card.
|
|
14291
15540
|
clearPermissionTimeoutSuppression('operator inbound')
|
|
15541
|
+
// #2862 — operator is back; re-offer any approvals that timed out meanwhile.
|
|
15542
|
+
maybePostMissedApprovalDigest('operator inbound')
|
|
14292
15543
|
|
|
14293
15544
|
// Capture wall-clock receive time for inbound_ack metric (#203).
|
|
14294
15545
|
// Must be after gate() so early-exit paths (drop/pair) don't skew the delta.
|
|
@@ -14302,30 +15553,32 @@ async function handleInbound(
|
|
|
14302
15553
|
// network RTT) but not a user-perceived end-to-end measurement.
|
|
14303
15554
|
const inboundReceivedAt = Date.now()
|
|
14304
15555
|
|
|
14305
|
-
//
|
|
14306
|
-
//
|
|
14307
|
-
//
|
|
14308
|
-
//
|
|
14309
|
-
//
|
|
14310
|
-
|
|
14311
|
-
//
|
|
14312
|
-
// inbound event advances it. A fresh-turn inbound transitions the
|
|
14313
|
-
// machine idle→in_turn; reading after the emit would see THIS
|
|
14314
|
-
// message's own just-started turn and self-block it (the same
|
|
14315
|
-
// self-block hazard the claudeBusyKeys snapshot below guards). When
|
|
14316
|
-
// the kill-switch is off this is null and the gate uses the legacy
|
|
15556
|
+
// PR3b-cutover: snapshot the machine's in-turn state AT RECEIPT, before
|
|
15557
|
+
// this handler emits the `inbound` event (that emit is DEFERRED below to
|
|
15558
|
+
// the delivery-commit point — search DEFERRED_INBOUND_EMIT). A fresh-turn
|
|
15559
|
+
// inbound transitions the machine idle→in_turn; reading after the emit
|
|
15560
|
+
// would see THIS message's own just-started turn and self-block it (the
|
|
15561
|
+
// same self-block hazard the claudeBusyKeys snapshot below guards). Null
|
|
15562
|
+
// when the kill-switch is off, in which case the gate uses the legacy
|
|
14317
15563
|
// claudeBusyKeys read.
|
|
15564
|
+
//
|
|
15565
|
+
// WHY THE INBOUND EMIT IS DEFERRED (overlord /usage wedge, 2026-07-08):
|
|
15566
|
+
// the `inbound` event drives the now-AUTHORITATIVE turn-in-flight gate
|
|
15567
|
+
// (turnInFlightForGate → isMachineInTurn). Emitting it HERE — at handler
|
|
15568
|
+
// entry, before the intercept gauntlet below (permission-reply, /auth
|
|
15569
|
+
// paste-back, interrupt-empty, secret-detect drop, …) — drove the machine
|
|
15570
|
+
// into `bridge_alive_in_turn` for messages that then EARLY-RETURN as an
|
|
15571
|
+
// intercept and never become a turn. No delivery, no claudeBusyKeys mark,
|
|
15572
|
+
// and critically no `turnEnd` ever fires, so the machine held the gate
|
|
15573
|
+
// closed until the 5-min TTL tick force-cleared it — buffering every
|
|
15574
|
+
// subsequent inbound (including /usage) the whole time. That is exactly
|
|
15575
|
+
// the dangerous `machine_over_holds` divergence gate-parity-probe.ts
|
|
15576
|
+
// flags. The imperative claudeBusyKeys tracker got it right (never marked
|
|
15577
|
+
// busy for the intercepted message); the machine was mis-fed. Fix: emit
|
|
15578
|
+
// `inbound` only once the message clears every intercept and reaches the
|
|
15579
|
+
// deliver-or-buffer decision, keeping the machine in lockstep with the
|
|
15580
|
+
// imperative delivery lifecycle it models.
|
|
14318
15581
|
const machineInTurnAtReceipt = isDeliveryCutoverEnabled() ? isMachineInTurn() : null
|
|
14319
|
-
shadowEmit({
|
|
14320
|
-
kind: 'inbound',
|
|
14321
|
-
key: _shadowKey,
|
|
14322
|
-
msg: {
|
|
14323
|
-
msgId: ctx.message?.message_id ?? 0,
|
|
14324
|
-
isSteering: false, // refined in PR 3 — for now shadow conservatively classifies as non-steering
|
|
14325
|
-
payload: null,
|
|
14326
|
-
},
|
|
14327
|
-
at: Date.now(),
|
|
14328
|
-
})
|
|
14329
15582
|
|
|
14330
15583
|
// #1556 self-blocking fix (v0.12.22): snapshot the live turn-state
|
|
14331
15584
|
// BEFORE the fresh-turn branch (line ~7357) sets activeTurnStartedAt
|
|
@@ -15522,6 +16775,29 @@ async function handleInbound(
|
|
|
15522
16775
|
return
|
|
15523
16776
|
}
|
|
15524
16777
|
|
|
16778
|
+
// DEFERRED_INBOUND_EMIT — drive the delivery state machine's `inbound`
|
|
16779
|
+
// event HERE, not at handler entry. Every intercept/early-return above
|
|
16780
|
+
// (permission-reply, /auth paste-back, interrupt-empty, secret-detect
|
|
16781
|
+
// drop, drop/pair) has been passed, so any message reaching this point is
|
|
16782
|
+
// a genuine turn the imperative code is about to deliver or buffer. That
|
|
16783
|
+
// keeps the machine's authoritative turn-in-flight state in lockstep with
|
|
16784
|
+
// the imperative delivery it models and can never be advanced into
|
|
16785
|
+
// `bridge_alive_in_turn` by a message that never becomes a turn — the
|
|
16786
|
+
// overlord /usage wedge of 2026-07-08 (see machineInTurnAtReceipt above).
|
|
16787
|
+
// isSteering is now the real classification (computed at ~line 16289), so
|
|
16788
|
+
// the machine correctly distinguishes a mid-turn steer (delivered, no new
|
|
16789
|
+
// turn) from a fresh turn.
|
|
16790
|
+
shadowEmit({
|
|
16791
|
+
kind: 'inbound',
|
|
16792
|
+
key: statusKey(chat_id, messageThreadId) as _ChatKey,
|
|
16793
|
+
msg: {
|
|
16794
|
+
msgId: msgId ?? 0,
|
|
16795
|
+
isSteering,
|
|
16796
|
+
payload: null,
|
|
16797
|
+
},
|
|
16798
|
+
at: Date.now(),
|
|
16799
|
+
})
|
|
16800
|
+
|
|
15525
16801
|
// PR2 obligation-ledger OPEN — BEFORE the buffer-until-idle / deliver split so
|
|
15526
16802
|
// a mid-turn cross-topic inbound (the 715 case) is tracked whether it is
|
|
15527
16803
|
// buffered or delivered now. Idempotent + gated; no-op when the flag is off.
|
|
@@ -15531,21 +16807,32 @@ async function handleInbound(
|
|
|
15531
16807
|
effectiveText,
|
|
15532
16808
|
})
|
|
15533
16809
|
|
|
15534
|
-
|
|
15535
|
-
|
|
15536
|
-
|
|
15537
|
-
|
|
15538
|
-
|
|
15539
|
-
|
|
15540
|
-
|
|
15541
|
-
|
|
15542
|
-
|
|
15543
|
-
|
|
15544
|
-
|
|
15545
|
-
|
|
15546
|
-
|
|
15547
|
-
|
|
15548
|
-
|
|
16810
|
+
// #2917: read the gate LIVE (not the receipt snapshot) on the default path
|
|
16811
|
+
// so a sibling same-chat inbound delivered during THIS handler's async
|
|
16812
|
+
// lead-in is observed here. Reading `claudeBusyKeys` live is self-block-safe:
|
|
16813
|
+
// THIS inbound's own key is only added at delivery (below), never by the
|
|
16814
|
+
// fresh-turn init bundle — so the live read never sees its own key. The
|
|
16815
|
+
// delivery-machine cutover keeps its at-receipt machine snapshot (a live
|
|
16816
|
+
// machine read WOULD self-block, since the inbound event already advanced
|
|
16817
|
+
// the machine for this key). Kill switch (=0) restores the pure snapshot.
|
|
16818
|
+
const gateTurnInFlight =
|
|
16819
|
+
SERIALIZE_INBOUND_DELIVERY_ENABLED && machineInTurnAtReceipt == null
|
|
16820
|
+
? claudeBusyKeys.size > 0
|
|
16821
|
+
: turnInFlightAtReceipt
|
|
16822
|
+
const deliveryGate = reserveInboundDelivery({
|
|
16823
|
+
turnInFlight: gateTurnInFlight,
|
|
16824
|
+
isSteering,
|
|
16825
|
+
// Interrupt-marker carve-out (2026-05-24): the `!`-prefixed body
|
|
16826
|
+
// must bypass the "buffer-until-turn-complete" gate because the
|
|
16827
|
+
// SIGINT'd turn often doesn't emit turn_complete, leaving the
|
|
16828
|
+
// body stranded in pendingInboundBuffer indefinitely. The
|
|
16829
|
+
// `interrupt` const is computed at the start of handleInbound
|
|
16830
|
+
// (line ~7606) and remains in scope here. When the user fires
|
|
16831
|
+
// `!`-with-body, this delivers the body as a fresh inbound to
|
|
16832
|
+
// the freshly-killed bridge.
|
|
16833
|
+
isInterrupt: interrupt.isInterrupt,
|
|
16834
|
+
})
|
|
16835
|
+
if (deliveryGate.decision === 'buffer-until-idle') {
|
|
15549
16836
|
pendingInboundBuffer.push(selfAgent, inboundMsg)
|
|
15550
16837
|
process.stderr.write(
|
|
15551
16838
|
`telegram gateway: inbound held mid-turn agent=${selfAgent} ` +
|
|
@@ -15569,6 +16856,18 @@ async function handleInbound(
|
|
|
15569
16856
|
return
|
|
15570
16857
|
}
|
|
15571
16858
|
|
|
16859
|
+
// #2917: reserve the chat's busy key SYNCHRONOUSLY here — before the
|
|
16860
|
+
// composer-clear await below — so a concurrent same-chat inbound reaching
|
|
16861
|
+
// the LIVE gate above observes this in-flight delivery and buffers behind it
|
|
16862
|
+
// (per-chat FIFO). Without this, both handlers pass the gate during each
|
|
16863
|
+
// other's async lead-in and race to the bridge, reordering the replies.
|
|
16864
|
+
// Only fresh-turn deliveries reserve (steering/interrupt amend a running
|
|
16865
|
+
// turn and must not). Released below if the send misses (bridge offline).
|
|
16866
|
+
let reservedBusyKey: string | null = null
|
|
16867
|
+
if (deliveryGate.reserve && SERIALIZE_INBOUND_DELIVERY_ENABLED && machineInTurnAtReceipt == null) {
|
|
16868
|
+
reservedBusyKey = markClaudeBusyForInbound(inboundMsg)
|
|
16869
|
+
}
|
|
16870
|
+
|
|
15572
16871
|
// Pre-send composer clear (the marko wedge). The inbound is about to be
|
|
15573
16872
|
// delivered as an MCP `notifications/claude/channel` notification, which
|
|
15574
16873
|
// the unmodified CLI appends into its composer and auto-submits ONLY when
|
|
@@ -15598,7 +16897,10 @@ async function handleInbound(
|
|
|
15598
16897
|
|
|
15599
16898
|
const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg)
|
|
15600
16899
|
if (delivered) {
|
|
15601
|
-
|
|
16900
|
+
// Reuse the key reserved synchronously above (#2917) when present, else
|
|
16901
|
+
// mark now — markClaudeBusyForInbound is idempotent (lockstep re-stamp),
|
|
16902
|
+
// so a re-mark is safe and returns the same chat key.
|
|
16903
|
+
const busyKey = reservedBusyKey ?? markClaudeBusyForInbound(inboundMsg)
|
|
15602
16904
|
// Track until claude acks via `enqueue` (the marko drop-wedge): if no ack
|
|
15603
16905
|
// lands, the message stranded in the composer and the sweep re-delivers
|
|
15604
16906
|
// it. Track ONLY messages that produce an `enqueue` to ack against —
|
|
@@ -15620,6 +16922,15 @@ async function handleInbound(
|
|
|
15620
16922
|
}
|
|
15621
16923
|
}
|
|
15622
16924
|
if (!delivered) {
|
|
16925
|
+
// #2917: the synchronous reservation assumed delivery; the send missed
|
|
16926
|
+
// (bridge offline), so release it in lockstep — otherwise the orphaned
|
|
16927
|
+
// busy key would gate every subsequent inbound into the buffer until the
|
|
16928
|
+
// orphan reaper clears it. The message itself is buffered below, so FIFO
|
|
16929
|
+
// is still preserved (it drains in order on the next bridge register).
|
|
16930
|
+
if (reservedBusyKey != null) {
|
|
16931
|
+
claudeBusyKeys.delete(reservedBusyKey)
|
|
16932
|
+
claudeBusyKeySince.delete(reservedBusyKey)
|
|
16933
|
+
}
|
|
15623
16934
|
// Only persist fresh user turns to the durable spool. Steering / `!`
|
|
15624
16935
|
// interrupt / empty bodies are mid-turn amendments or no-ops that would
|
|
15625
16936
|
// arrive orphaned if replayed as a fresh turn after a restart — drop them
|
|
@@ -17185,7 +18496,7 @@ interface ModelDepsRestartContext {
|
|
|
17185
18496
|
}
|
|
17186
18497
|
|
|
17187
18498
|
function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & ModelCommandDeps {
|
|
17188
|
-
|
|
18499
|
+
const deps: ModelMenuDeps & ModelCommandDeps = {
|
|
17189
18500
|
discover: (a) => discoverModels(a),
|
|
17190
18501
|
discoverSrModels: async () => {
|
|
17191
18502
|
// /model/info lives on the ROOT proxy (model-mapped surface), NOT the
|
|
@@ -17291,7 +18602,25 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
|
|
|
17291
18602
|
)
|
|
17292
18603
|
}
|
|
17293
18604
|
},
|
|
18605
|
+
/**
|
|
18606
|
+
* Session-only switch TO an sr-* (LiteLLM/OpenRouter) model. claude's
|
|
18607
|
+
* native `/model` picker rejects unknown sr-* ids, so we can't inject.
|
|
18608
|
+
* Write the token to the `.session-model-override` carrier file (start.sh
|
|
18609
|
+
* consumes it on the next boot and launches `claude --model <token>`), set
|
|
18610
|
+
* the in-memory session-model so /status stays honest across the restart
|
|
18611
|
+
* window, then run the SAME restart dispatch as scheduleRestart above.
|
|
18612
|
+
*/
|
|
18613
|
+
scheduleModelRelaunch: async (model: string, reason: string) => {
|
|
18614
|
+
const agentDir = resolveAgentDirFromEnv()
|
|
18615
|
+
if (!agentDir) throw new Error('agent dir unresolvable — cannot write session-model carrier')
|
|
18616
|
+
// Carrier: single line, token + newline, no quoting (start.sh strips
|
|
18617
|
+
// whitespace and shape-gates). One-shot — consumed on the next boot.
|
|
18618
|
+
writeFileSync(join(agentDir, '.session-model-override'), `${model}\n`, 'utf8')
|
|
18619
|
+
activeSessionModelOverride = model
|
|
18620
|
+
await deps.scheduleRestart(reason)
|
|
18621
|
+
},
|
|
17294
18622
|
}
|
|
18623
|
+
return deps
|
|
17295
18624
|
}
|
|
17296
18625
|
|
|
17297
18626
|
function modelMenuReplyMarkup(reply: ModelMenuReply): InlineKeyboard | undefined {
|
|
@@ -18041,6 +19370,8 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
|
|
|
18041
19370
|
}
|
|
18042
19371
|
// Operator answered via slash ⇒ present; reset no-repeat suppression.
|
|
18043
19372
|
clearPermissionTimeoutSuppression('operator answered via /approve|/deny')
|
|
19373
|
+
// #2862 — operator is present; re-offer any approvals that timed out meanwhile.
|
|
19374
|
+
maybePostMissedApprovalDigest('operator answered via /approve|/deny')
|
|
18044
19375
|
// Forward to connected bridges — same IPC the button handler uses.
|
|
18045
19376
|
dispatchPermissionVerdict({ type: 'permission', requestId: request_id, behavior })
|
|
18046
19377
|
resumeReactionAfterVerdict()
|
|
@@ -18764,25 +20095,21 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
|
|
|
18764
20095
|
}
|
|
18765
20096
|
} catch (err) {
|
|
18766
20097
|
process.stderr.write(`telegram gateway: quota-watch: probe for crossing accounts failed: ${err}\n`)
|
|
18767
|
-
|
|
18768
|
-
|
|
18769
|
-
|
|
18770
|
-
|
|
18771
|
-
|
|
18772
|
-
|
|
18773
|
-
|
|
18774
|
-
|
|
18775
|
-
|
|
18776
|
-
process.stderr.write(`telegram gateway: quota-watch state persist failed: ${saveErr}\n`)
|
|
18777
|
-
}
|
|
20098
|
+
// A quota notification must never carry numbers we could not verify
|
|
20099
|
+
// live. Leave the crossing accounts' state untouched — the
|
|
20100
|
+
// transition re-evaluates (and re-probes) on the next 15-min tick.
|
|
20101
|
+
// Persist any reconciles already applied, then bail.
|
|
20102
|
+
if (reconciledCount > 0) {
|
|
20103
|
+
try {
|
|
20104
|
+
saveQuotaWatchState(stateDir, mutatedState)
|
|
20105
|
+
} catch (saveErr) {
|
|
20106
|
+
process.stderr.write(`telegram gateway: quota-watch state persist failed: ${saveErr}\n`)
|
|
18778
20107
|
}
|
|
18779
|
-
process.stderr.write(
|
|
18780
|
-
`telegram gateway: quota-watch: deferring ${pendingTransitions.length} notification(s) until probe succeeds\n`,
|
|
18781
|
-
)
|
|
18782
|
-
return
|
|
18783
20108
|
}
|
|
18784
|
-
|
|
18785
|
-
|
|
20109
|
+
process.stderr.write(
|
|
20110
|
+
`telegram gateway: quota-watch: deferring ${pendingTransitions.length} notification(s) until probe succeeds\n`,
|
|
20111
|
+
)
|
|
20112
|
+
return
|
|
18786
20113
|
}
|
|
18787
20114
|
|
|
18788
20115
|
// Build final notifications, enriching the snapshot with fresh probe
|
|
@@ -18832,7 +20159,7 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
|
|
|
18832
20159
|
// State normalised by the time of the probe — don't notify.
|
|
18833
20160
|
continue
|
|
18834
20161
|
}
|
|
18835
|
-
} else
|
|
20162
|
+
} else {
|
|
18836
20163
|
// No verified fresh data for this account (per-account probe failure
|
|
18837
20164
|
// or label missing from the batch result). Same rule as the batch
|
|
18838
20165
|
// throw above: never send unverified numbers. State untouched —
|
|
@@ -19898,6 +21225,185 @@ async function handleSkillProposalCallback(ctx: Context, data: string): Promise<
|
|
|
19898
21225
|
)
|
|
19899
21226
|
}
|
|
19900
21227
|
|
|
21228
|
+
/**
|
|
21229
|
+
* hindsight Phase 5 — handle a tap on the mental-model PROPOSAL card.
|
|
21230
|
+
* mmp:approve:<stageId> — declare the model: append it to the agent's
|
|
21231
|
+
* memory.mental_models[] via the operator-approved
|
|
21232
|
+
* config-edit path (reused config_propose_edit
|
|
21233
|
+
* apply+reconcile; reconcile ensures it), then wake
|
|
21234
|
+
* the agent with an "applied" inbound.
|
|
21235
|
+
* mmp:deny:<stageId> — drop the proposal; NOTHING is written; wake the
|
|
21236
|
+
* agent with a "denied" inbound.
|
|
21237
|
+
*
|
|
21238
|
+
* Authorization: the tapper MUST be on the gateway's allowFrom list — an agent
|
|
21239
|
+
* can PROPOSE but can never self-approve (identical gate to the vault flow).
|
|
21240
|
+
*/
|
|
21241
|
+
async function handleMentalModelProposeCallback(ctx: Context, data: string): Promise<void> {
|
|
21242
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
21243
|
+
const access = loadAccess()
|
|
21244
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
21245
|
+
// Self-approve is impossible: only an allow-listed operator can resolve
|
|
21246
|
+
// the card. A tap from anyone else (incl. a compromised agent identity) is
|
|
21247
|
+
// refused here.
|
|
21248
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
|
|
21249
|
+
return
|
|
21250
|
+
}
|
|
21251
|
+
const parts = data.split(':')
|
|
21252
|
+
if (parts.length < 3) {
|
|
21253
|
+
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
21254
|
+
return
|
|
21255
|
+
}
|
|
21256
|
+
const action = parts[1]
|
|
21257
|
+
const stageId = parts.slice(2).join(':')
|
|
21258
|
+
const pending = pendingMentalModelProposes.get(stageId)
|
|
21259
|
+
if (!pending) {
|
|
21260
|
+
await ctx.answerCallbackQuery({ text: 'Card expired — ask the agent to re-propose.' }).catch(() => {})
|
|
21261
|
+
if (ctx.callbackQuery?.message) {
|
|
21262
|
+
await ctx.api
|
|
21263
|
+
.editMessageText(
|
|
21264
|
+
ctx.callbackQuery.message.chat.id,
|
|
21265
|
+
ctx.callbackQuery.message.message_id,
|
|
21266
|
+
richMessage('⌛ _This mental-model proposal card expired before you tapped. Ask the agent to re-propose if it still stands._'),
|
|
21267
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
21268
|
+
)
|
|
21269
|
+
.catch(() => {})
|
|
21270
|
+
}
|
|
21271
|
+
return
|
|
21272
|
+
}
|
|
21273
|
+
if (action !== 'approve' && action !== 'deny') {
|
|
21274
|
+
await ctx.answerCallbackQuery({ text: 'Bad request' }).catch(() => {})
|
|
21275
|
+
return
|
|
21276
|
+
}
|
|
21277
|
+
// Enforce the TTL at TAP time, not just on the next propose's sweep. Without
|
|
21278
|
+
// this, a card left untapped past its TTL is still resolvable if no fresh
|
|
21279
|
+
// proposal has run the sweep — an operator could approve a stale proposal.
|
|
21280
|
+
if (Date.now() - pending.staged_at > MENTAL_MODEL_PROPOSE_TTL_MS) {
|
|
21281
|
+
pendingMentalModelProposes.delete(stageId)
|
|
21282
|
+
await ctx.answerCallbackQuery({ text: 'Card expired — ask the agent to re-propose.' }).catch(() => {})
|
|
21283
|
+
if (pending.card_message_id != null) {
|
|
21284
|
+
await ctx.api
|
|
21285
|
+
.editMessageText(
|
|
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
|
+
}
|
|
21293
|
+
return
|
|
21294
|
+
}
|
|
21295
|
+
// Single-shot: remove the pending entry immediately so a double-tap can't
|
|
21296
|
+
// resolve twice.
|
|
21297
|
+
pendingMentalModelProposes.delete(stageId)
|
|
21298
|
+
|
|
21299
|
+
const proposal: MentalModelPendingProposal = {
|
|
21300
|
+
agent: pending.agent,
|
|
21301
|
+
chat_id: pending.chat_id,
|
|
21302
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
21303
|
+
spec: pending.spec,
|
|
21304
|
+
...(pending.reason ? { reason: pending.reason } : {}),
|
|
21305
|
+
}
|
|
21306
|
+
|
|
21307
|
+
const resolveDeps = {
|
|
21308
|
+
readConfigText: () => readLiveSwitchroomConfigText(),
|
|
21309
|
+
registerPreApproval: (agent: string, diff: string) => {
|
|
21310
|
+
pendingMentalModelCorrelations.set(mentalModelCorrelationKey(agent, diff), {
|
|
21311
|
+
agentName: agent,
|
|
21312
|
+
unifiedDiff: diff,
|
|
21313
|
+
createdAt: Date.now(),
|
|
21314
|
+
})
|
|
21315
|
+
},
|
|
21316
|
+
clearPreApproval: (agent: string, diff: string) => {
|
|
21317
|
+
pendingMentalModelCorrelations.delete(mentalModelCorrelationKey(agent, diff))
|
|
21318
|
+
},
|
|
21319
|
+
dispatchConfigEdit: async (a: { agent: string; diff: string; reason: string }) => {
|
|
21320
|
+
const req: HostdRequest = {
|
|
21321
|
+
v: 1,
|
|
21322
|
+
op: 'config_propose_edit',
|
|
21323
|
+
request_id: hostdRequestId('gw-mental-model'),
|
|
21324
|
+
args: {
|
|
21325
|
+
unified_diff: a.diff,
|
|
21326
|
+
reason: a.reason,
|
|
21327
|
+
target_path: '/state/config/switchroom.yaml',
|
|
21328
|
+
},
|
|
21329
|
+
}
|
|
21330
|
+
// config_propose_edit blocks on validate→approve→apply→reconcile
|
|
21331
|
+
// (5-10 min on a busy host) — allow 12 min. The operator already
|
|
21332
|
+
// approved on the proposal card, so hostd's config-approval callback
|
|
21333
|
+
// auto-resolves via the pre-registered correlation (no second card).
|
|
21334
|
+
const resp = await tryHostdDispatch(a.agent, req, 720_000)
|
|
21335
|
+
if (resp === 'not-configured') {
|
|
21336
|
+
return { state: 'error' as const, reason: 'hostd config-edit is not configured (host_control disabled or socket absent)' }
|
|
21337
|
+
}
|
|
21338
|
+
if (resp.result === 'completed') return { state: 'applied' as const }
|
|
21339
|
+
if (resp.result === 'denied') return { state: 'denied' as const, reason: resp.error ?? 'operator/host denied the edit' }
|
|
21340
|
+
return { state: 'error' as const, reason: resp.error ?? `hostd returned '${resp.result}'` }
|
|
21341
|
+
},
|
|
21342
|
+
// Ensure is delegated to reconcile: config_propose_edit's apply triggers a
|
|
21343
|
+
// reconcile which runs ensureDeclaredMentalModels (#2874) for the newly
|
|
21344
|
+
// declared model — the authoritative, correctly-scoped ensure. We
|
|
21345
|
+
// deliberately do NOT add a redundant gateway-side ensure (it would need
|
|
21346
|
+
// the agent's bank id + a reachable Hindsight endpoint from the gateway).
|
|
21347
|
+
injectInbound: (inbound: InboundMessage) => {
|
|
21348
|
+
deliverResumeSyntheticOrBuffer(pending.agent, inbound)
|
|
21349
|
+
},
|
|
21350
|
+
log: (m: string) => process.stderr.write(`telegram gateway: ${m}\n`),
|
|
21351
|
+
}
|
|
21352
|
+
|
|
21353
|
+
if (action === 'deny') {
|
|
21354
|
+
await ctx.answerCallbackQuery({ text: '🚫 Denied' }).catch(() => {})
|
|
21355
|
+
await resolveMentalModelProposal('deny', proposal, stageId, senderId, resolveDeps)
|
|
21356
|
+
if (pending.card_message_id != null) {
|
|
21357
|
+
await ctx.api
|
|
21358
|
+
.editMessageText(
|
|
21359
|
+
pending.chat_id,
|
|
21360
|
+
pending.card_message_id,
|
|
21361
|
+
richMessage(`🚫 _Denied. **${escapeHtmlForTg(pending.agent)}**'s mental model \`${pending.spec.name}\` was not declared._`),
|
|
21362
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
21363
|
+
)
|
|
21364
|
+
.catch(() => {})
|
|
21365
|
+
}
|
|
21366
|
+
return
|
|
21367
|
+
}
|
|
21368
|
+
|
|
21369
|
+
// Approve. Ack immediately + show an interim state, then persist in the
|
|
21370
|
+
// background (config_propose_edit can take minutes), then edit the card with
|
|
21371
|
+
// the real outcome. The turn resumes via the synthetic inbound injected by
|
|
21372
|
+
// resolveMentalModelProposal — not by this card edit.
|
|
21373
|
+
await ctx.answerCallbackQuery({ text: '✅ Declaring the model…' }).catch(() => {})
|
|
21374
|
+
if (pending.card_message_id != null) {
|
|
21375
|
+
await ctx.api
|
|
21376
|
+
.editMessageText(
|
|
21377
|
+
pending.chat_id,
|
|
21378
|
+
pending.card_message_id,
|
|
21379
|
+
richMessage(`⏳ _Declaring **${escapeHtmlForTg(pending.agent)}**'s mental model \`${pending.spec.name}\` — appending to config + ensuring…_`),
|
|
21380
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
21381
|
+
)
|
|
21382
|
+
.catch(() => {})
|
|
21383
|
+
}
|
|
21384
|
+
void (async () => {
|
|
21385
|
+
let result
|
|
21386
|
+
try {
|
|
21387
|
+
result = await resolveMentalModelProposal('approve', proposal, stageId, senderId, resolveDeps)
|
|
21388
|
+
} catch (err) {
|
|
21389
|
+
process.stderr.write(`telegram gateway: mental_model_propose approve threw: ${(err as Error).message}\n`)
|
|
21390
|
+
result = { outcome: 'failed' as const, reason: (err as Error).message }
|
|
21391
|
+
}
|
|
21392
|
+
if (pending.card_message_id != null) {
|
|
21393
|
+
const label =
|
|
21394
|
+
result.outcome === 'applied'
|
|
21395
|
+
? `✅ **Declared** ${escapeHtmlForTg(pending.agent)}'s mental model \`${pending.spec.name}\` — appended to \`memory.mental_models[]\` and ensured. Restart the agent to load it if it isn't picked up automatically.`
|
|
21396
|
+
: `⚠️ **Did NOT declare** \`${pending.spec.name}\`${'reason' in result && result.reason ? ` — ${escapeHtmlForTg(result.reason)}` : ''}. Nothing was written.`
|
|
21397
|
+
await ctx.api
|
|
21398
|
+
.editMessageText(pending.chat_id, pending.card_message_id, richMessage(label), {
|
|
21399
|
+
reply_markup: { inline_keyboard: [] },
|
|
21400
|
+
link_preview_options: { is_disabled: true },
|
|
21401
|
+
})
|
|
21402
|
+
.catch(() => {})
|
|
21403
|
+
}
|
|
21404
|
+
})()
|
|
21405
|
+
}
|
|
21406
|
+
|
|
19901
21407
|
async function handleVaultRequestAccessCallback(ctx: Context, data: string): Promise<void> {
|
|
19902
21408
|
const senderId = String(ctx.from?.id ?? '')
|
|
19903
21409
|
const access = loadAccess()
|
|
@@ -22166,14 +23672,41 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22166
23672
|
// We track whether we applied the interim edit so we can skip the
|
|
22167
23673
|
// toastOnly short-circuit if we did — a toastOnly return after the interim
|
|
22168
23674
|
// edit would leave the menu stuck button-less.
|
|
22169
|
-
|
|
23675
|
+
// sr-* TARGET tap: switch TO a non-Claude (LiteLLM/OpenRouter) model.
|
|
23676
|
+
// Parity with the text `/model sr-*` path — claude's native picker rejects
|
|
23677
|
+
// unknown sr-* ids, so an in-place inject can't set them. Carry the token
|
|
23678
|
+
// across a graceful restart (the `.session-model-override` carrier) and
|
|
23679
|
+
// relaunch `claude --model sr-*`. Session-only; reverts to the configured
|
|
23680
|
+
// default on the next restart. The sr-* → Claude direction is handled below
|
|
23681
|
+
// via the SELECT/alias outcome + isSrToClaudeTransition.
|
|
22170
23682
|
if (data.startsWith(MODEL_CALLBACK_SR)) {
|
|
22171
|
-
const
|
|
23683
|
+
const srName = data.slice(MODEL_CALLBACK_SR.length)
|
|
23684
|
+
const srLabel = escapeHtmlForTg(srFriendlyLabel(srName))
|
|
23685
|
+
if (!isValidModelArg(srName)) {
|
|
23686
|
+
await ctx
|
|
23687
|
+
.editMessageText(richMessage('❌ Invalid model name'), { reply_markup: { inline_keyboard: [] } })
|
|
23688
|
+
.catch(() => {})
|
|
23689
|
+
return
|
|
23690
|
+
}
|
|
22172
23691
|
await ctx
|
|
22173
|
-
.editMessageText(
|
|
23692
|
+
.editMessageText(
|
|
23693
|
+
richMessage(`🔄 Switching session to **${srLabel}** — restarting (~30s). _Session-only; reverts to the configured default on the next restart._`),
|
|
23694
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
23695
|
+
)
|
|
22174
23696
|
.catch(() => {})
|
|
22175
|
-
|
|
23697
|
+
try {
|
|
23698
|
+
await modelDeps.scheduleModelRelaunch(srName, `user: /model ${srName} (session-only relaunch, menu)`)
|
|
23699
|
+
} catch (err) {
|
|
23700
|
+
await ctx
|
|
23701
|
+
.editMessageText(
|
|
23702
|
+
richMessage(`❌ Could not switch to **${srLabel}**: ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`),
|
|
23703
|
+
{ reply_markup: { inline_keyboard: [] } },
|
|
23704
|
+
)
|
|
23705
|
+
.catch(() => {})
|
|
23706
|
+
}
|
|
23707
|
+
return
|
|
22176
23708
|
}
|
|
23709
|
+
const didInterimSrEdit = false
|
|
22177
23710
|
try {
|
|
22178
23711
|
const prevSessionModel = activeSessionModelOverride
|
|
22179
23712
|
const outcome = await handleModelMenuCallback(data, modelDeps)
|
|
@@ -22439,6 +23972,15 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22439
23972
|
return
|
|
22440
23973
|
}
|
|
22441
23974
|
|
|
23975
|
+
// hindsight Phase 5: agent-proposes → human-approves mental-model card.
|
|
23976
|
+
// mmp:approve:<stageId> — declare the model (append memory.mental_models[]
|
|
23977
|
+
// via operator-approved config edit) + ensure it
|
|
23978
|
+
// mmp:deny:<stageId> — drop the proposal; NOTHING is written
|
|
23979
|
+
if (data.startsWith('mmp:')) {
|
|
23980
|
+
await handleMentalModelProposeCallback(ctx, data)
|
|
23981
|
+
return
|
|
23982
|
+
}
|
|
23983
|
+
|
|
22442
23984
|
// #2670: one-tap skill-improvement proposal card.
|
|
22443
23985
|
// skprop:approve:<id> — apply the stored draft via the personal-skill
|
|
22444
23986
|
// write pipeline (inject a turn; agent writes it)
|
|
@@ -22449,6 +23991,15 @@ bot.on('callback_query:data', async ctx => {
|
|
|
22449
23991
|
return
|
|
22450
23992
|
}
|
|
22451
23993
|
|
|
23994
|
+
// #2862: missed-approvals re-offer digest.
|
|
23995
|
+
// missre:retry:<id> — inject a synthetic inbound asking the agent to
|
|
23996
|
+
// re-attempt (re-raises a fresh approval card)
|
|
23997
|
+
// missre:dismiss:<id> — clear the record + edit the card closed
|
|
23998
|
+
if (data.startsWith('missre:')) {
|
|
23999
|
+
await handleMissedApprovalCallback(ctx, data)
|
|
24000
|
+
return
|
|
24001
|
+
}
|
|
24002
|
+
|
|
22452
24003
|
// Issue #969 P2b: vault recent-denial one-tap approval.
|
|
22453
24004
|
// vrd:<agent>:<key> — mint a 30-day read-grant for the agent + key
|
|
22454
24005
|
// Posted by /vault audit <agent> in the "Recent denials" section.
|
|
@@ -23095,6 +24646,8 @@ bot.on('callback_query:data', async ctx => {
|
|
|
23095
24646
|
// Operator tapped a verdict ⇒ they are present; reset no-repeat suppression
|
|
23096
24647
|
// so a later identical ask is shown fresh rather than silently short-circuited.
|
|
23097
24648
|
clearPermissionTimeoutSuppression('operator answered a permission card')
|
|
24649
|
+
// #2862 — operator is present; re-offer any approvals that timed out meanwhile.
|
|
24650
|
+
maybePostMissedApprovalDigest('operator answered a permission card')
|
|
23098
24651
|
const pd = pendingPermissions.get(request_id)
|
|
23099
24652
|
const resumeAction = pd ? naturalAction(pd.tool_name, pd.input_preview) : ''
|
|
23100
24653
|
const scopedTtl = scopedApprovalTtlMs()
|
|
@@ -23106,6 +24659,9 @@ bot.on('callback_query:data', async ctx => {
|
|
|
23106
24659
|
permCardStore.remove(request_id)
|
|
23107
24660
|
if (timeBox && grantAgent) {
|
|
23108
24661
|
recordScopedGrant(scopedGrants, grantAgent, timeBox.rule, Date.now(), scopedTtl)
|
|
24662
|
+
// Write-through so the window survives a gateway restart (#2863). Absolute
|
|
24663
|
+
// expiry is baked into the entry, so a reload can't extend it.
|
|
24664
|
+
scopedGrantStore.save(scopedGrants)
|
|
23109
24665
|
process.stderr.write(
|
|
23110
24666
|
`telegram gateway: scoped-approval granted via Allow rule="${timeBox.rule}" ` +
|
|
23111
24667
|
`agent=${grantAgent} ttl_ms=${scopedTtl} (request_id=${request_id})\n`,
|
|
@@ -24874,39 +26430,68 @@ void (async () => {
|
|
|
24874
26430
|
// tracks the live turn from there.
|
|
24875
26431
|
try { removeTurnActiveMarker(STATE_DIR) } catch { /* best-effort */ }
|
|
24876
26432
|
|
|
24877
|
-
//
|
|
24878
|
-
// still in the store was never resolved (gateway died before the
|
|
24879
|
-
// operator tapped or the reaper ran).
|
|
24880
|
-
//
|
|
24881
|
-
//
|
|
24882
|
-
//
|
|
24883
|
-
|
|
24884
|
-
|
|
24885
|
-
|
|
24886
|
-
|
|
24887
|
-
|
|
24888
|
-
|
|
24889
|
-
|
|
24890
|
-
|
|
24891
|
-
|
|
24892
|
-
|
|
24893
|
-
|
|
24894
|
-
|
|
24895
|
-
|
|
24896
|
-
|
|
24897
|
-
|
|
24898
|
-
|
|
24899
|
-
|
|
24900
|
-
|
|
24901
|
-
|
|
24902
|
-
|
|
24903
|
-
|
|
24904
|
-
|
|
24905
|
-
|
|
24906
|
-
|
|
26433
|
+
// Boot-sweep for permission cards from a prior gateway session. Any
|
|
26434
|
+
// entry still in the store was never resolved (gateway died before the
|
|
26435
|
+
// operator tapped or the reaper ran).
|
|
26436
|
+
//
|
|
26437
|
+
// #2861: DON'T strip immediately. A still-live claude session's bridge
|
|
26438
|
+
// re-sends its outstanding permission_requests on IPC reconnect within
|
|
26439
|
+
// a few seconds — re-arming the persisted card instead of losing the
|
|
26440
|
+
// suspended turn. So we wait a grace window (~90 s) and only strip
|
|
26441
|
+
// entries that were NOT re-claimed by a bridge re-send in the meantime.
|
|
26442
|
+
// A genuinely dead session (container recreate → new bridge, empty
|
|
26443
|
+
// ledger) never re-sends, so those still get the "restarted" notice.
|
|
26444
|
+
//
|
|
26445
|
+
// Kill switch SWITCHROOM_PERMISSION_REARM=0 reverts to the legacy
|
|
26446
|
+
// immediate strip-everything sweep.
|
|
26447
|
+
if (!isPermissionRearmEnabled()) {
|
|
26448
|
+
void (async () => {
|
|
26449
|
+
const stale = permCardStore.loadAll()
|
|
26450
|
+
if (stale.length === 0) return
|
|
26451
|
+
process.stderr.write(
|
|
26452
|
+
`telegram gateway: boot-sweep: stripping ${stale.length} stale permission card(s) from prior gateway session\n`,
|
|
26453
|
+
)
|
|
26454
|
+
for (const card of stale) await stripStalePermissionCard(card)
|
|
26455
|
+
permCardStore.clear()
|
|
26456
|
+
})()
|
|
26457
|
+
} else {
|
|
26458
|
+
const bootStale = permCardStore.loadAll()
|
|
26459
|
+
if (bootStale.length > 0) {
|
|
26460
|
+
const graceMs = permissionRearmGraceMs()
|
|
26461
|
+
process.stderr.write(
|
|
26462
|
+
`telegram gateway: boot-sweep: ${bootStale.length} pending permission card(s) from prior ` +
|
|
26463
|
+
`session; deferring strip ${graceMs}ms to allow bridge re-arm (#2861)\n`,
|
|
26464
|
+
)
|
|
26465
|
+
setTimeout(() => {
|
|
26466
|
+
void (async () => {
|
|
26467
|
+
// Reload — some entries may have been resolved (tap/TTL) during
|
|
26468
|
+
// the grace window, which removes them from the store.
|
|
26469
|
+
const remaining = permCardStore.loadAll()
|
|
26470
|
+
// Preserve anything currently LIVE in pendingPermissions: that
|
|
26471
|
+
// covers both re-armed cards (a bridge re-send restored them)
|
|
26472
|
+
// AND fresh cards born during the grace window (a new approval
|
|
26473
|
+
// posted after boot). Only cards with no live pending entry —
|
|
26474
|
+
// a genuinely dead session that never reconnected — are stripped.
|
|
26475
|
+
const liveRequestIds = new Set(pendingPermissions.keys())
|
|
26476
|
+
const toStrip = computeBootSweepStripTargets(remaining, liveRequestIds)
|
|
26477
|
+
if (toStrip.length === 0) {
|
|
26478
|
+
process.stderr.write(
|
|
26479
|
+
`telegram gateway: boot-sweep: all pending permission cards re-armed or still live — nothing to strip\n`,
|
|
26480
|
+
)
|
|
26481
|
+
return
|
|
26482
|
+
}
|
|
26483
|
+
process.stderr.write(
|
|
26484
|
+
`telegram gateway: boot-sweep: stripping ${toStrip.length} dead-session permission card(s) ` +
|
|
26485
|
+
`after ${graceMs}ms grace (of ${remaining.length} still persisted, ${liveRequestIds.size} live)\n`,
|
|
26486
|
+
)
|
|
26487
|
+
for (const card of toStrip) await stripStalePermissionCard(card)
|
|
26488
|
+
// Remove ONLY the stripped request_ids — re-armed entries stay
|
|
26489
|
+
// persisted so a subsequent restart re-arms them again.
|
|
26490
|
+
for (const id of distinctRequestIds(toStrip)) permCardStore.remove(id)
|
|
26491
|
+
})()
|
|
26492
|
+
}, graceMs).unref?.()
|
|
24907
26493
|
}
|
|
24908
|
-
|
|
24909
|
-
})()
|
|
26494
|
+
}
|
|
24910
26495
|
|
|
24911
26496
|
// Boot-time pin sweep
|
|
24912
26497
|
try {
|
|
@@ -25105,6 +26690,73 @@ void (async () => {
|
|
|
25105
26690
|
}
|
|
25106
26691
|
} catch {}
|
|
25107
26692
|
|
|
26693
|
+
// ─── Session-model re-hydration + LiteLLM-down alert (session relaunch) ───
|
|
26694
|
+
//
|
|
26695
|
+
// start.sh writes the EFFECTIVE launched model to `.active-session-model`
|
|
26696
|
+
// on every boot (the model actually passed to `claude --model`). Re-hydrate
|
|
26697
|
+
// the in-memory session-model override from it so `/status` and the welcome
|
|
26698
|
+
// card stay honest after a session-relaunch restart. Only treat it as an
|
|
26699
|
+
// override when it differs from the configured/default model — a plain boot
|
|
26700
|
+
// on the configured model leaves the override null.
|
|
26701
|
+
//
|
|
26702
|
+
// Also consume the `.session-model-alert` sentinel: start.sh drops it when
|
|
26703
|
+
// it had to DROP an sr-* override because LiteLLM was unreachable at boot
|
|
26704
|
+
// (booting on the configured default instead of 4xx-ing against Anthropic).
|
|
26705
|
+
// We turn it into a loud Telegram message to the operator, then delete it.
|
|
26706
|
+
try {
|
|
26707
|
+
const smAgentDir = resolveAgentDirFromEnv()
|
|
26708
|
+
if (smAgentDir) {
|
|
26709
|
+
const activePath = join(smAgentDir, '.active-session-model')
|
|
26710
|
+
if (existsSync(activePath)) {
|
|
26711
|
+
try {
|
|
26712
|
+
const launched = readFileSync(activePath, 'utf8').trim()
|
|
26713
|
+
const configured = (() => {
|
|
26714
|
+
type AgentListResp = { agents: Array<{ name: string; model?: string | null }> }
|
|
26715
|
+
const d = switchroomExecJson<AgentListResp>(['agent', 'list'])
|
|
26716
|
+
const raw = d?.agents?.find(a => a.name === getMyAgentName())?.model ?? null
|
|
26717
|
+
// Resolve through the SAME resolver start.sh's scaffold uses, so an
|
|
26718
|
+
// unset (`null`) or `model: "default"` config value maps to the
|
|
26719
|
+
// switchroom default model id — matching the EFFECTIVE model start.sh
|
|
26720
|
+
// wrote to `.active-session-model`. Comparing the raw (unresolved)
|
|
26721
|
+
// value would flag every ordinary restart of a default-model agent as
|
|
26722
|
+
// a phantom session override.
|
|
26723
|
+
return resolveMainModel(raw ?? undefined)
|
|
26724
|
+
})()
|
|
26725
|
+
activeSessionModelOverride =
|
|
26726
|
+
launched.length > 0 && launched !== configured ? launched : null
|
|
26727
|
+
} catch { /* leave override as-is on a bad read */ }
|
|
26728
|
+
}
|
|
26729
|
+
|
|
26730
|
+
const alertPath = join(smAgentDir, '.session-model-alert')
|
|
26731
|
+
if (existsSync(alertPath)) {
|
|
26732
|
+
let alertText: string | null = null
|
|
26733
|
+
try {
|
|
26734
|
+
alertText = readFileSync(alertPath, 'utf8').trim()
|
|
26735
|
+
} catch { alertText = null }
|
|
26736
|
+
try { unlinkSync(alertPath) } catch { /* best-effort */ }
|
|
26737
|
+
if (alertText && alertText.length > 0) {
|
|
26738
|
+
// Notify EVERY operator, not just allowFrom[0]. Each send is wrapped
|
|
26739
|
+
// in its own catch so one operator's failure (blocked bot, bad chat
|
|
26740
|
+
// id) never stops the rest, and the outer boot flow never crashes.
|
|
26741
|
+
const operators = loadAccess().allowFrom
|
|
26742
|
+
for (const operator of operators) {
|
|
26743
|
+
if (!operator) continue
|
|
26744
|
+
void lockedBot.api
|
|
26745
|
+
.sendMessage(operator, `⚠️ ${alertText}`)
|
|
26746
|
+
.catch((err: unknown) =>
|
|
26747
|
+
process.stderr.write(
|
|
26748
|
+
`telegram gateway: session-model alert send failed for ${operator}: ${(err as Error)?.message ?? String(err)}\n`,
|
|
26749
|
+
),
|
|
26750
|
+
)
|
|
26751
|
+
}
|
|
26752
|
+
process.stderr.write(`telegram gateway: session-model: LiteLLM-down override drop — ${alertText}\n`)
|
|
26753
|
+
}
|
|
26754
|
+
}
|
|
26755
|
+
}
|
|
26756
|
+
} catch (err) {
|
|
26757
|
+
process.stderr.write(`telegram gateway: session-model re-hydration failed: ${(err as Error)?.message ?? String(err)}\n`)
|
|
26758
|
+
}
|
|
26759
|
+
|
|
25108
26760
|
// Credit-exhaustion watcher (#348). Reads `<agentDir>/.claude/.claude.json`
|
|
25109
26761
|
// for `cachedExtraUsageDisabledReason`. Fires a Telegram notification
|
|
25110
26762
|
// on transition into / out of fatal billing states (out_of_credits,
|
|
@@ -25300,6 +26952,43 @@ void (async () => {
|
|
|
25300
26952
|
// accident no longer pollute the watcher with phantom
|
|
25301
26953
|
// registrations + ENOENT log spam + false stalls.
|
|
25302
26954
|
agentCwd: watcherAgentDir,
|
|
26955
|
+
// Gap 2 (deterministic-turn-liveness.md "Known gaps"): a
|
|
26956
|
+
// sub-agent dispatched into a `switchroom worktree claim`
|
|
26957
|
+
// cwd runs under a different project-dir slug than
|
|
26958
|
+
// `agentCwd` above, so the #1116 foreign-slug filter would
|
|
26959
|
+
// otherwise skip it forever — no activity stamp, no `🛠
|
|
26960
|
+
// Worker` feed for the entire run. Re-derive, fresh on every
|
|
26961
|
+
// rescan tick, the set of worktree paths this agent itself
|
|
26962
|
+
// currently owns (registry records are the deterministic
|
|
26963
|
+
// source of truth for "cwds this agent's sub-agents may run
|
|
26964
|
+
// in") and let the watcher also watch those slugs.
|
|
26965
|
+
// Best-effort: a registry read failure (e.g. no worktree dir
|
|
26966
|
+
// on an agent that never claims one) must not affect the
|
|
26967
|
+
// primary agentCwd watch.
|
|
26968
|
+
extraWatchCwdsProvider: () =>
|
|
26969
|
+
// Fail-CLOSED ownership filter (unset identity ⇒ nothing;
|
|
26970
|
+
// ownerless records excluded; registry throw ⇒ []). Extracted
|
|
26971
|
+
// to telegram-plugin/worktree-watch-cwds.ts so the #1116 /
|
|
26972
|
+
// Gap-2 ownership predicate is under direct unit test — see
|
|
26973
|
+
// telegram-plugin/tests/worktree-watch-cwds.test.ts.
|
|
26974
|
+
ownedWorktreeCwds({
|
|
26975
|
+
self: process.env.SWITCHROOM_AGENT_NAME,
|
|
26976
|
+
listRecords: listWorktreeRecords,
|
|
26977
|
+
// Durable, non-env identity fallback (#1116 / #2893): when
|
|
26978
|
+
// SWITCHROOM_AGENT_NAME is somehow unset, derive this
|
|
26979
|
+
// agent's own identity from its own directory so worktree
|
|
26980
|
+
// ownership still resolves (env is only the fast path).
|
|
26981
|
+
// `watcherAgentDir` is guaranteed non-null in this branch
|
|
26982
|
+
// (the whole watcher is gated on it above). Kill-switch
|
|
26983
|
+
// SWITCHROOM_WORKTREE_IDENTITY_FALLBACK=0 restores the
|
|
26984
|
+
// pre-fix env-only behaviour.
|
|
26985
|
+
agentDir:
|
|
26986
|
+
process.env.SWITCHROOM_WORKTREE_IDENTITY_FALLBACK === '0'
|
|
26987
|
+
? undefined
|
|
26988
|
+
: watcherAgentDir,
|
|
26989
|
+
log: (msg) =>
|
|
26990
|
+
process.stderr.write(`telegram gateway: ${msg}\n`),
|
|
26991
|
+
}),
|
|
25303
26992
|
// Bug 0 fix: previously omitted, leaving the watcher unable to
|
|
25304
26993
|
// write liveness/stall/turn_end updates to the registry DB.
|
|
25305
26994
|
// Liveness writes are now persisted across the gateway lifetime.
|