diff --git a/AGENTS.md b/AGENTS.md
index b381ceb2f..0ef57992e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -7,6 +7,7 @@
- Tests: colocated `*.test.ts`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
+- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `clawdbot` in `devDependencies` or `peerDependencies` instead (runtime resolves `clawdbot/plugin-sdk` via jiti alias).
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
- Core channel docs: `docs/channels/`
@@ -23,13 +24,14 @@
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
## exe.dev VM ops (general)
-- Access: SSH to the VM directly: `ssh vm-name.exe.xyz` (or use exe.dev web terminal).
-- Updates: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`).
-- Config: use `clawdbot config set ...`; set `gateway.mode=local` if unset.
-- Restart: exe.dev often lacks systemd user bus; stop old gateway and run:
+- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set).
+- SSH flaky: use exe.dev web terminal or Shelley (web agent); keep a tmux session for long ops.
+- Update: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`).
+- Config: use `clawdbot config set ...`; ensure `gateway.mode=local` is set.
+- Discord: store raw token only (no `DISCORD_BOT_TOKEN=` prefix).
+- Restart: stop old gateway and run:
`pkill -9 -f clawdbot-gateway || true; nohup clawdbot gateway run --bind loopback --port 18789 --force > /tmp/clawdbot-gateway.log 2>&1 &`
-- Verify: `clawdbot --version`, `clawdbot health`, `ss -ltnp | rg 18789`.
-- SSH flaky: use exe.dev web terminal or Shelley (web agent) instead of CLI SSH.
+- Verify: `clawdbot channels status --probe`, `ss -ltnp | rg 18789`, `tail -n 120 /tmp/clawdbot-gateway.log`.
## Build, Test, and Development Commands
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
@@ -128,6 +130,10 @@
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
+- Lint/format churn:
+ - If staged+unstaged diffs are formatting-only, auto-resolve without asking.
+ - If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
+ - Only ask when changes are semantic (logic/data/behavior).
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 82fe6e5a8..714a3eb54 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,16 +6,27 @@ Docs: https://docs.clawd.bot
### Changes
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
+- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.
### Fixes
+- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
+- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
+- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
+- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
- TUI: include Gateway slash commands in autocomplete and `/help`.
+- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
+- CLI: suppress diagnostic session/run noise during auth probes.
+- CLI: hide auth probe timeout warnings from embedded runs.
+- CLI: render auth probe results as a table in `clawdbot models status`.
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
+- TUI: render Gateway slash-command replies as system output (for example, `/context`).
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
+- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
## 2026.1.22
diff --git a/README.md b/README.md
index a08760ae6..eb34f3e41 100644
--- a/README.md
+++ b/README.md
@@ -478,28 +478,29 @@ Thanks to all clawtributors:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts
index 7a99b672a..a98a29aa0 100644
--- a/apps/android/app/build.gradle.kts
+++ b/apps/android/app/build.gradle.kts
@@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android"
minSdk = 31
targetSdk = 36
- versionCode = 202601210
- versionName = "2026.1.21"
+ versionCode = 202601230
+ versionName = "2026.1.23"
}
buildTypes {
diff --git a/apps/android/app/src/main/java/com/clawdbot/android/WakeWords.kt b/apps/android/app/src/main/java/com/clawdbot/android/WakeWords.kt
index 855a0de7c..d54ed1e08 100644
--- a/apps/android/app/src/main/java/com/clawdbot/android/WakeWords.kt
+++ b/apps/android/app/src/main/java/com/clawdbot/android/WakeWords.kt
@@ -8,10 +8,14 @@ object WakeWords {
return input.split(",").map { it.trim() }.filter { it.isNotEmpty() }
}
+ fun parseIfChanged(input: String, current: List): List? {
+ val parsed = parseCommaSeparated(input)
+ return if (parsed == current) null else parsed
+ }
+
fun sanitize(words: List, defaults: List): List {
val cleaned =
words.map { it.trim() }.filter { it.isNotEmpty() }.take(maxWords).map { it.take(maxWordLength) }
return cleaned.ifEmpty { defaults }
}
}
-
diff --git a/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt b/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt
index aee1059bd..e3a9b3ecb 100644
--- a/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt
+++ b/apps/android/app/src/main/java/com/clawdbot/android/ui/SettingsSheet.kt
@@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
@@ -49,7 +51,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@@ -58,6 +63,7 @@ import com.clawdbot.android.LocationMode
import com.clawdbot.android.MainViewModel
import com.clawdbot.android.NodeForegroundService
import com.clawdbot.android.VoiceWakeMode
+import com.clawdbot.android.WakeWords
@Composable
fun SettingsSheet(viewModel: MainViewModel) {
@@ -86,6 +92,8 @@ fun SettingsSheet(viewModel: MainViewModel) {
val listState = rememberLazyListState()
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
val (advancedExpanded, setAdvancedExpanded) = remember { mutableStateOf(false) }
+ val focusManager = LocalFocusManager.current
+ var wakeWordsHadFocus by remember { mutableStateOf(false) }
val deviceModel =
remember {
listOfNotNull(Build.MANUFACTURER, Build.MODEL)
@@ -104,6 +112,12 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
+ val commitWakeWords = {
+ val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)
+ if (parsed != null) {
+ viewModel.setWakeWords(parsed)
+ }
+ }
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { perms ->
@@ -481,25 +495,27 @@ fun SettingsSheet(viewModel: MainViewModel) {
value = wakeWordsText,
onValueChange = setWakeWordsText,
label = { Text("Wake Words (comma-separated)") },
- modifier = Modifier.fillMaxWidth(),
+ modifier =
+ Modifier.fillMaxWidth().onFocusChanged { focusState ->
+ if (focusState.isFocused) {
+ wakeWordsHadFocus = true
+ } else if (wakeWordsHadFocus) {
+ wakeWordsHadFocus = false
+ commitWakeWords()
+ }
+ },
singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ keyboardActions =
+ KeyboardActions(
+ onDone = {
+ commitWakeWords()
+ focusManager.clearFocus()
+ },
+ ),
)
}
- item {
- Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
- Button(
- onClick = {
- val parsed = com.clawdbot.android.WakeWords.parseCommaSeparated(wakeWordsText)
- viewModel.setWakeWords(parsed)
- },
- enabled = isConnected,
- ) {
- Text("Save + Sync")
- }
-
- Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") }
- }
- }
+ item { Button(onClick = viewModel::resetWakeWordsDefaults) { Text("Reset defaults") } }
item {
Text(
if (isConnected) {
diff --git a/apps/android/app/src/test/java/com/clawdbot/android/WakeWordsTest.kt b/apps/android/app/src/test/java/com/clawdbot/android/WakeWordsTest.kt
index 1d61383e8..9363e810c 100644
--- a/apps/android/app/src/test/java/com/clawdbot/android/WakeWordsTest.kt
+++ b/apps/android/app/src/test/java/com/clawdbot/android/WakeWordsTest.kt
@@ -1,6 +1,7 @@
package com.clawdbot.android
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
import org.junit.Test
class WakeWordsTest {
@@ -32,5 +33,18 @@ class WakeWordsTest {
assertEquals("w1", sanitized.first())
assertEquals("w${WakeWords.maxWords}", sanitized.last())
}
-}
+ @Test
+ fun parseIfChangedSkipsWhenUnchanged() {
+ val current = listOf("clawd", "claude")
+ val parsed = WakeWords.parseIfChanged(" clawd , claude ", current)
+ assertNull(parsed)
+ }
+
+ @Test
+ fun parseIfChangedReturnsUpdatedList() {
+ val current = listOf("clawd")
+ val parsed = WakeWords.parseIfChanged(" clawd , jarvis ", current)
+ assertEquals(listOf("clawd", "jarvis"), parsed)
+ }
+}
diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist
index 1b7b5b3d5..02785e4f0 100644
--- a/apps/ios/Sources/Info.plist
+++ b/apps/ios/Sources/Info.plist
@@ -19,9 +19,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2026.1.21
+ 2026.1.23
CFBundleVersion
- 20260121
+ 20260123
NSAppTransportSecurity
NSAllowsArbitraryLoadsInWebContent
diff --git a/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift b/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift
index d13edafe2..5aef87b0c 100644
--- a/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift
+++ b/apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift
@@ -1,8 +1,10 @@
import SwiftUI
+import Combine
struct VoiceWakeWordsSettingsView: View {
@Environment(NodeAppModel.self) private var appModel
@State private var triggerWords: [String] = VoiceWakePreferences.loadTriggerWords()
+ @FocusState private var focusedTriggerIndex: Int?
@State private var syncTask: Task?
var body: some View {
@@ -12,6 +14,10 @@ struct VoiceWakeWordsSettingsView: View {
TextField("Wake word", text: self.binding(for: index))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
+ .focused(self.$focusedTriggerIndex, equals: index)
+ .onSubmit {
+ self.commitTriggerWords()
+ }
}
.onDelete(perform: self.removeWords)
@@ -39,17 +45,18 @@ struct VoiceWakeWordsSettingsView: View {
.onAppear {
if self.triggerWords.isEmpty {
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
+ self.commitTriggerWords()
}
}
- .onChange(of: self.triggerWords) { _, newValue in
- // Keep local voice wake responsive even if the gateway isn't connected yet.
- VoiceWakePreferences.saveTriggerWords(newValue)
-
- let snapshot = VoiceWakePreferences.sanitizeTriggerWords(newValue)
- self.syncTask?.cancel()
- self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
- try? await Task.sleep(nanoseconds: 650_000_000)
- await appModel?.setGlobalWakeWords(snapshot)
+ .onChange(of: self.focusedTriggerIndex) { oldValue, newValue in
+ guard oldValue != nil, oldValue != newValue else { return }
+ self.commitTriggerWords()
+ }
+ .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
+ guard self.focusedTriggerIndex == nil else { return }
+ let updated = VoiceWakePreferences.loadTriggerWords()
+ if updated != self.triggerWords {
+ self.triggerWords = updated
}
}
}
@@ -63,6 +70,7 @@ struct VoiceWakeWordsSettingsView: View {
if self.triggerWords.isEmpty {
self.triggerWords = VoiceWakePreferences.defaultTriggerWords
}
+ self.commitTriggerWords()
}
private func binding(for index: Int) -> Binding {
@@ -76,4 +84,15 @@ struct VoiceWakeWordsSettingsView: View {
self.triggerWords[index] = newValue
})
}
+
+ private func commitTriggerWords() {
+ VoiceWakePreferences.saveTriggerWords(self.triggerWords)
+
+ let snapshot = VoiceWakePreferences.sanitizeTriggerWords(self.triggerWords)
+ self.syncTask?.cancel()
+ self.syncTask = Task { [snapshot, weak appModel = self.appModel] in
+ try? await Task.sleep(nanoseconds: 650_000_000)
+ await appModel?.setGlobalWakeWords(snapshot)
+ }
+ }
}
diff --git a/apps/ios/Sources/Voice/VoiceWakePreferences.swift b/apps/ios/Sources/Voice/VoiceWakePreferences.swift
index 96f46518e..4c75c22a6 100644
--- a/apps/ios/Sources/Voice/VoiceWakePreferences.swift
+++ b/apps/ios/Sources/Voice/VoiceWakePreferences.swift
@@ -6,6 +6,8 @@ enum VoiceWakePreferences {
// Keep defaults aligned with the mac app.
static let defaultTriggerWords: [String] = ["clawd", "claude"]
+ static let maxWords = 32
+ static let maxWordLength = 64
static func decodeGatewayTriggers(from payloadJSON: String) -> [String]? {
guard let data = payloadJSON.data(using: .utf8) else { return nil }
@@ -30,6 +32,8 @@ enum VoiceWakePreferences {
let cleaned = words
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
+ .prefix(Self.maxWords)
+ .map { String($0.prefix(Self.maxWordLength)) }
return cleaned.isEmpty ? Self.defaultTriggerWords : cleaned
}
diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist
index e0351f399..cbd68e6a3 100644
--- a/apps/ios/Tests/Info.plist
+++ b/apps/ios/Tests/Info.plist
@@ -17,8 +17,8 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 2026.1.21
+ 2026.1.23
CFBundleVersion
- 20260121
+ 20260123
diff --git a/apps/ios/Tests/VoiceWakePreferencesTests.swift b/apps/ios/Tests/VoiceWakePreferencesTests.swift
index acf501654..ec4a63afa 100644
--- a/apps/ios/Tests/VoiceWakePreferencesTests.swift
+++ b/apps/ios/Tests/VoiceWakePreferencesTests.swift
@@ -11,6 +11,18 @@ import Testing
#expect(VoiceWakePreferences.sanitizeTriggerWords(["", " "]) == VoiceWakePreferences.defaultTriggerWords)
}
+ @Test func sanitizeTriggerWordsLimitsWordLength() {
+ let long = String(repeating: "x", count: VoiceWakePreferences.maxWordLength + 5)
+ let cleaned = VoiceWakePreferences.sanitizeTriggerWords(["ok", long])
+ #expect(cleaned[1].count == VoiceWakePreferences.maxWordLength)
+ }
+
+ @Test func sanitizeTriggerWordsLimitsWordCount() {
+ let words = (1...VoiceWakePreferences.maxWords + 3).map { "w\($0)" }
+ let cleaned = VoiceWakePreferences.sanitizeTriggerWords(words)
+ #expect(cleaned.count == VoiceWakePreferences.maxWords)
+ }
+
@Test func displayStringUsesSanitizedWords() {
#expect(VoiceWakePreferences.displayString(for: ["", " "]) == "clawd, claude")
}
diff --git a/apps/ios/project.yml b/apps/ios/project.yml
index ea6519001..a9b58617e 100644
--- a/apps/ios/project.yml
+++ b/apps/ios/project.yml
@@ -81,8 +81,8 @@ targets:
properties:
CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon
- CFBundleShortVersionString: "2026.1.21"
- CFBundleVersion: "20260121"
+ CFBundleShortVersionString: "2026.1.23"
+ CFBundleVersion: "20260123"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: ClawdbotTests
- CFBundleShortVersionString: "2026.1.21"
- CFBundleVersion: "20260121"
+ CFBundleShortVersionString: "2026.1.23"
+ CFBundleVersion: "20260123"
diff --git a/apps/macos/Sources/Clawdbot/Constants.swift b/apps/macos/Sources/Clawdbot/Constants.swift
index 25f2589e3..b55bd6d20 100644
--- a/apps/macos/Sources/Clawdbot/Constants.swift
+++ b/apps/macos/Sources/Clawdbot/Constants.swift
@@ -12,6 +12,8 @@ let voiceWakeTriggerChimeKey = "clawdbot.voiceWakeTriggerChime"
let voiceWakeSendChimeKey = "clawdbot.voiceWakeSendChime"
let showDockIconKey = "clawdbot.showDockIcon"
let defaultVoiceWakeTriggers = ["clawd", "claude"]
+let voiceWakeMaxWords = 32
+let voiceWakeMaxWordLength = 64
let voiceWakeMicKey = "clawdbot.voiceWakeMicID"
let voiceWakeMicNameKey = "clawdbot.voiceWakeMicName"
let voiceWakeLocaleKey = "clawdbot.voiceWakeLocaleID"
diff --git a/apps/macos/Sources/Clawdbot/Resources/Info.plist b/apps/macos/Sources/Clawdbot/Resources/Info.plist
index 283901c0f..d5b40867a 100644
--- a/apps/macos/Sources/Clawdbot/Resources/Info.plist
+++ b/apps/macos/Sources/Clawdbot/Resources/Info.plist
@@ -15,9 +15,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2026.1.21
+ 2026.1.23
CFBundleVersion
- 202601210
+ 202601230
CFBundleIconFile
Clawdbot
CFBundleURLTypes
diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeHelpers.swift b/apps/macos/Sources/Clawdbot/VoiceWakeHelpers.swift
index a60aa7d7c..98cdc0cb5 100644
--- a/apps/macos/Sources/Clawdbot/VoiceWakeHelpers.swift
+++ b/apps/macos/Sources/Clawdbot/VoiceWakeHelpers.swift
@@ -4,6 +4,8 @@ func sanitizeVoiceWakeTriggers(_ words: [String]) -> [String] {
let cleaned = words
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
+ .prefix(voiceWakeMaxWords)
+ .map { String($0.prefix(voiceWakeMaxWordLength)) }
return cleaned.isEmpty ? defaultVoiceWakeTriggers : cleaned
}
diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift b/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift
index 176980cc5..a41e8bb1f 100644
--- a/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift
+++ b/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift
@@ -21,6 +21,7 @@ struct VoiceWakeSettings: View {
@State private var micObserver = AudioInputDeviceObserver()
@State private var micRefreshTask: Task?
@State private var availableLocales: [Locale] = []
+ @State private var triggerEntries: [TriggerEntry] = []
private let fieldLabelWidth: CGFloat = 140
private let controlWidth: CGFloat = 240
private let isPreview = ProcessInfo.processInfo.isPreview
@@ -31,9 +32,9 @@ struct VoiceWakeSettings: View {
var id: String { self.uid }
}
- private struct IndexedWord: Identifiable {
- let id: Int
- let value: String
+ private struct TriggerEntry: Identifiable {
+ let id: UUID
+ var value: String
}
private var voiceWakeBinding: Binding {
@@ -105,6 +106,7 @@ struct VoiceWakeSettings: View {
.onAppear {
guard !self.isPreview else { return }
self.startMicObserver()
+ self.loadTriggerEntries()
}
.onChange(of: self.state.voiceWakeMicID) { _, _ in
guard !self.isPreview else { return }
@@ -122,8 +124,10 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil
Task { await self.meter.stop() }
self.micObserver.stop()
+ self.syncTriggerEntriesToState()
} else {
self.startMicObserver()
+ self.loadTriggerEntries()
}
}
.onDisappear {
@@ -136,11 +140,16 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil
self.micObserver.stop()
Task { await self.meter.stop() }
+ self.syncTriggerEntriesToState()
}
}
- private var indexedWords: [IndexedWord] {
- self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) }
+ private func loadTriggerEntries() {
+ self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) }
+ }
+
+ private func syncTriggerEntriesToState() {
+ self.state.swabbleTriggerWords = self.triggerEntries.map(\.value)
}
private var triggerTable: some View {
@@ -154,29 +163,42 @@ struct VoiceWakeSettings: View {
} label: {
Label("Add word", systemImage: "plus")
}
- .disabled(self.state.swabbleTriggerWords
- .contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
+ .disabled(self.triggerEntries
+ .contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
- Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers }
+ Button("Reset defaults") {
+ self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
+ self.syncTriggerEntriesToState()
+ }
}
- Table(self.indexedWords) {
- TableColumn("Word") { row in
- TextField("Wake word", text: self.binding(for: row.id))
- .textFieldStyle(.roundedBorder)
- }
- TableColumn("") { row in
- Button {
- self.removeWord(at: row.id)
- } label: {
- Image(systemName: "trash")
+ VStack(spacing: 0) {
+ ForEach(self.$triggerEntries) { $entry in
+ HStack(spacing: 8) {
+ TextField("Wake word", text: $entry.value)
+ .textFieldStyle(.roundedBorder)
+ .onSubmit {
+ self.syncTriggerEntriesToState()
+ }
+
+ Button {
+ self.removeWord(id: entry.id)
+ } label: {
+ Image(systemName: "trash")
+ }
+ .buttonStyle(.borderless)
+ .help("Remove trigger word")
+ .frame(width: 24)
+ }
+ .padding(8)
+
+ if entry.id != self.triggerEntries.last?.id {
+ Divider()
}
- .buttonStyle(.borderless)
- .help("Remove trigger word")
}
- .width(36)
}
- .frame(minHeight: 180)
+ .frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
+ .background(Color(nsColor: .textBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
@@ -211,24 +233,12 @@ struct VoiceWakeSettings: View {
}
private func addWord() {
- self.state.swabbleTriggerWords.append("")
+ self.triggerEntries.append(TriggerEntry(id: UUID(), value: ""))
}
- private func removeWord(at index: Int) {
- guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
- self.state.swabbleTriggerWords.remove(at: index)
- }
-
- private func binding(for index: Int) -> Binding {
- Binding(
- get: {
- guard self.state.swabbleTriggerWords.indices.contains(index) else { return "" }
- return self.state.swabbleTriggerWords[index]
- },
- set: { newValue in
- guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
- self.state.swabbleTriggerWords[index] = newValue
- })
+ private func removeWord(id: UUID) {
+ self.triggerEntries.removeAll { $0.id == id }
+ self.syncTriggerEntriesToState()
}
private func toggleTest() {
@@ -638,13 +648,14 @@ extension VoiceWakeSettings {
state.voicePushToTalkEnabled = true
state.swabbleTriggerWords = ["Claude", "Hey"]
- let view = VoiceWakeSettings(state: state, isActive: true)
+ var view = VoiceWakeSettings(state: state, isActive: true)
view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")]
view.availableLocales = [Locale(identifier: "en_US")]
view.meterLevel = 0.42
view.meterError = "No input"
view.testState = .detected("ok")
view.isTesting = true
+ view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")]
_ = view.body
_ = view.localePicker
@@ -654,8 +665,9 @@ extension VoiceWakeSettings {
_ = view.chimeSection
view.addWord()
- _ = view.binding(for: 0).wrappedValue
- view.removeWord(at: 0)
+ if let entryId = view.triggerEntries.first?.id {
+ view.removeWord(id: entryId)
+ }
}
}
#endif
diff --git a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeHelpersTests.swift b/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeHelpersTests.swift
index e7f7e06fc..49ad5a124 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeHelpersTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/VoiceWakeHelpersTests.swift
@@ -12,6 +12,18 @@ struct VoiceWakeHelpersTests {
#expect(cleaned == defaultVoiceWakeTriggers)
}
+ @Test func sanitizeTriggersLimitsWordLength() {
+ let long = String(repeating: "x", count: voiceWakeMaxWordLength + 5)
+ let cleaned = sanitizeVoiceWakeTriggers(["ok", long])
+ #expect(cleaned[1].count == voiceWakeMaxWordLength)
+ }
+
+ @Test func sanitizeTriggersLimitsWordCount() {
+ let words = (1...voiceWakeMaxWords + 3).map { "w\($0)" }
+ let cleaned = sanitizeVoiceWakeTriggers(words)
+ #expect(cleaned.count == voiceWakeMaxWords)
+ }
+
@Test func normalizeLocaleStripsCollation() {
#expect(normalizeLocaleIdentifier("en_US@collation=phonebook") == "en_US")
}
diff --git a/docs/cli/index.md b/docs/cli/index.md
index 46f6d173e..fcc013fdc 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -700,8 +700,15 @@ Options:
- `--json`
- `--plain`
- `--check` (exit 1=expired/missing, 2=expiring)
+- `--probe` (live probe of configured auth profiles)
+- `--probe-provider `
+- `--probe-profile ` (repeat or comma-separated)
+- `--probe-timeout `
+- `--probe-concurrency `
+- `--probe-max-tokens `
Always includes the auth overview and OAuth expiry status for profiles in the auth store.
+`--probe` runs live requests (may consume tokens and trigger rate limits).
### `models set `
Set `agents.defaults.model.primary`.
diff --git a/docs/cli/models.md b/docs/cli/models.md
index f394a44f9..ba4600ce4 100644
--- a/docs/cli/models.md
+++ b/docs/cli/models.md
@@ -25,12 +25,26 @@ clawdbot models scan
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
When provider usage snapshots are available, the OAuth/token status section includes
provider usage headers.
+Add `--probe` to run live auth probes against each configured provider profile.
+Probes are real requests (may consume tokens and trigger rate limits).
Notes:
- `models set ` accepts `provider/model` or an alias.
- Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
+### `models status`
+Options:
+- `--json`
+- `--plain`
+- `--check` (exit 1=expired/missing, 2=expiring)
+- `--probe` (live probe of configured auth profiles)
+- `--probe-provider ` (probe one provider)
+- `--probe-profile ` (repeat or comma-separated profile ids)
+- `--probe-timeout `
+- `--probe-concurrency `
+- `--probe-max-tokens `
+
## Aliases + fallbacks
```bash
diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md
index 1cef34b11..d87eb5f7f 100644
--- a/docs/help/troubleshooting.md
+++ b/docs/help/troubleshooting.md
@@ -43,6 +43,14 @@ Almost always a Node/npm PATH issue. Start here:
- [Gateway troubleshooting](/gateway/troubleshooting)
- [Control UI](/web/control-ui#insecure-http)
+### `docs.clawd.bot` shows an SSL error (Comcast/Xfinity)
+
+Some Comcast/Xfinity connections block `docs.clawd.bot` via Xfinity Advanced Security.
+Disable Advanced Security or add `docs.clawd.bot` to the allowlist, then retry.
+
+- Xfinity Advanced Security help: https://www.xfinity.com/support/articles/using-xfinity-xfi-advanced-security
+- Quick sanity checks: try a mobile hotspot or VPN to confirm it’s ISP-level filtering
+
### Service says running, but RPC probe fails
- [Gateway troubleshooting](/gateway/troubleshooting)
diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md
index 91b129b16..b4c7d5a84 100644
--- a/docs/platforms/mac/release.md
+++ b/docs/platforms/mac/release.md
@@ -30,17 +30,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=com.clawdbot.mac \
-APP_VERSION=2026.1.21 \
+APP_VERSION=2026.1.23 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: ()" \
scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support)
-ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.21.zip
+ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.23.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
-scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.21.dmg
+scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.23.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.21.dmg
# --apple-id "" --team-id "" --password ""
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
BUNDLE_ID=com.clawdbot.mac \
-APP_VERSION=2026.1.21 \
+APP_VERSION=2026.1.23 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: ()" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
-ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.21.dSYM.zip
+ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.23.dSYM.zip
```
## Appcast entry
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
-SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.21.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
+SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.23.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
## Publish & verify
-- Upload `Clawdbot-2026.1.21.zip` (and `Clawdbot-2026.1.21.dSYM.zip`) to the GitHub release for tag `v2026.1.21`.
+- Upload `Clawdbot-2026.1.23.zip` (and `Clawdbot-2026.1.23.dSYM.zip`) to the GitHub release for tag `v2026.1.23`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
diff --git a/docs/tui.md b/docs/tui.md
index e67b22032..4d094dc6b 100644
--- a/docs/tui.md
+++ b/docs/tui.md
@@ -88,6 +88,8 @@ Session lifecycle:
- `/settings`
- `/exit`
+Other Gateway slash commands (for example, `/context`) are forwarded to the Gateway and shown as system output. See [Slash commands](/tools/slash-commands).
+
## Local shell commands
- Prefix a line with `!` to run a local shell command on the TUI host.
- The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session.
diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json
index d511722a9..4385272be 100644
--- a/extensions/bluebubbles/package.json
+++ b/extensions/bluebubbles/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/bluebubbles",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot BlueBubbles channel plugin",
"clawdbot": {
diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts
index fa40e82a7..0f9973de9 100644
--- a/extensions/bluebubbles/src/monitor.test.ts
+++ b/extensions/bluebubbles/src/monitor.test.ts
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IncomingMessage, ServerResponse } from "node:http";
import { EventEmitter } from "node:events";
+import { removeAckReactionAfterReply, shouldAckReaction } from "clawdbot/plugin-sdk";
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
import {
handleBlueBubblesWebhookRequest,
@@ -128,6 +129,7 @@ function createMockRuntime(): PluginRuntime {
session: {
resolveStorePath: mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
readSessionUpdatedAt: mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
+ recordInboundSession: vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
recordSessionMetaFromInbound: vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"],
updateLastRoute: vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"],
},
@@ -135,6 +137,10 @@ function createMockRuntime(): PluginRuntime {
buildMentionRegexes: mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
matchesMentionPatterns: mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
},
+ reactions: {
+ shouldAckReaction,
+ removeAckReactionAfterReply,
+ },
groups: {
resolveGroupPolicy: mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
resolveRequireMention: mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts
index 81a921ca9..7c860d761 100644
--- a/extensions/bluebubbles/src/monitor.ts
+++ b/extensions/bluebubbles/src/monitor.ts
@@ -1,7 +1,13 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
-import { resolveAckReaction } from "clawdbot/plugin-sdk";
+import {
+ logAckFailure,
+ logInboundDrop,
+ logTypingFailure,
+ resolveAckReaction,
+ resolveControlCommandGate,
+} from "clawdbot/plugin-sdk";
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { downloadBlueBubblesAttachment } from "./attachments.js";
@@ -1346,23 +1352,25 @@ async function processMessage(
})
: false;
const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
- const commandAuthorized = isGroup
- ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
- useAccessGroups,
- authorizers: [
- { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
- { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
- ],
- })
- : dmAuthorized;
+ const commandGate = resolveControlCommandGate({
+ useAccessGroups,
+ authorizers: [
+ { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
+ { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
+ ],
+ allowTextCommands: true,
+ hasControlCommand: hasControlCmd,
+ });
+ const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
// Block control commands from unauthorized senders in groups
- if (isGroup && hasControlCmd && !commandAuthorized) {
- logVerbose(
- core,
- runtime,
- `bluebubbles: drop control command from unauthorized sender ${message.senderId}`,
- );
+ if (isGroup && commandGate.shouldBlock) {
+ logInboundDrop({
+ log: (msg) => logVerbose(core, runtime, msg),
+ channel: "bluebubbles",
+ reason: "control command (unauthorized)",
+ target: message.senderId,
+ });
return;
}
@@ -1521,19 +1529,20 @@ async function processMessage(
core,
runtime,
});
- const shouldAckReaction = () => {
- if (!ackReactionValue) return false;
- if (ackReactionScope === "all") return true;
- if (ackReactionScope === "direct") return !isGroup;
- if (ackReactionScope === "group-all") return isGroup;
- if (ackReactionScope === "group-mentions") {
- if (!isGroup) return false;
- if (!requireMention) return false;
- if (!canDetectMention) return false;
- return effectiveWasMentioned;
- }
- return false;
- };
+ const shouldAckReaction = () =>
+ Boolean(
+ ackReactionValue &&
+ core.channel.reactions.shouldAckReaction({
+ scope: ackReactionScope,
+ isDirect: !isGroup,
+ isGroup,
+ isMentionableGroup: isGroup,
+ requireMention: Boolean(requireMention),
+ canDetectMention,
+ effectiveWasMentioned,
+ shouldBypassMention,
+ }),
+ );
const ackMessageId = message.messageId?.trim() || "";
const ackReactionPromise =
shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue
@@ -1749,29 +1758,27 @@ async function processMessage(
},
});
} finally {
- if (
- removeAckAfterReply &&
- sentMessage &&
- ackReactionPromise &&
- ackReactionValue &&
- chatGuidForActions &&
- ackMessageId
- ) {
- void ackReactionPromise.then((didAck) => {
- if (!didAck) return;
- sendBlueBubblesReaction({
- chatGuid: chatGuidForActions,
- messageGuid: ackMessageId,
- emoji: ackReactionValue,
- remove: true,
- opts: { cfg: config, accountId: account.accountId },
- }).catch((err) => {
- logVerbose(
- core,
- runtime,
- `ack reaction removal failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
- );
- });
+ if (sentMessage && chatGuidForActions && ackMessageId) {
+ core.channel.reactions.removeAckReactionAfterReply({
+ removeAfterReply: removeAckAfterReply,
+ ackReactionPromise,
+ ackReactionValue: ackReactionValue ?? null,
+ remove: () =>
+ sendBlueBubblesReaction({
+ chatGuid: chatGuidForActions,
+ messageGuid: ackMessageId,
+ emoji: ackReactionValue ?? "",
+ remove: true,
+ opts: { cfg: config, accountId: account.accountId },
+ }),
+ onError: (err) => {
+ logAckFailure({
+ log: (msg) => logVerbose(core, runtime, msg),
+ channel: "bluebubbles",
+ target: `${chatGuidForActions}/${ackMessageId}`,
+ error: err,
+ });
+ },
});
}
if (chatGuidForActions && baseUrl && password && !sentMessage) {
@@ -1780,7 +1787,13 @@ async function processMessage(
cfg: config,
accountId: account.accountId,
}).catch((err) => {
- logVerbose(core, runtime, `typing stop (no reply) failed: ${String(err)}`);
+ logTypingFailure({
+ log: (msg) => logVerbose(core, runtime, msg),
+ channel: "bluebubbles",
+ action: "stop",
+ target: chatGuidForActions,
+ error: err,
+ });
});
}
}
diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json
index 36b9e1403..02d1cdbdd 100644
--- a/extensions/copilot-proxy/package.json
+++ b/extensions/copilot-proxy/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/copilot-proxy",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot Copilot Proxy provider plugin",
"clawdbot": {
diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json
index fd1b655a0..407ce60d1 100644
--- a/extensions/diagnostics-otel/package.json
+++ b/extensions/diagnostics-otel/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/diagnostics-otel",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot diagnostics OpenTelemetry exporter",
"clawdbot": {
diff --git a/extensions/discord/package.json b/extensions/discord/package.json
index 8f43497a9..0a645718b 100644
--- a/extensions/discord/package.json
+++ b/extensions/discord/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/discord",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot Discord channel plugin",
"clawdbot": {
diff --git a/extensions/google-antigravity-auth/package.json b/extensions/google-antigravity-auth/package.json
index c7626c272..ff3c485f2 100644
--- a/extensions/google-antigravity-auth/package.json
+++ b/extensions/google-antigravity-auth/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-antigravity-auth",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot Google Antigravity OAuth provider plugin",
"clawdbot": {
diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json
index 9e9515f3e..f4b666ab0 100644
--- a/extensions/google-gemini-cli-auth/package.json
+++ b/extensions/google-gemini-cli-auth/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-gemini-cli-auth",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot Gemini CLI OAuth provider plugin",
"clawdbot": {
diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json
index 94b120b26..a3ac1c642 100644
--- a/extensions/imessage/package.json
+++ b/extensions/imessage/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/imessage",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot iMessage channel plugin",
"clawdbot": {
diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json
index 2b4a5b2dd..ea774ecba 100644
--- a/extensions/lobster/package.json
+++ b/extensions/lobster/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/lobster",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"clawdbot": {
diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md
index 2d25c41c4..9f959843d 100644
--- a/extensions/matrix/CHANGELOG.md
+++ b/extensions/matrix/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 2026.1.23
+
+### Changes
+- Version alignment with core Clawdbot release numbers.
+
## 2026.1.22
### Changes
diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json
index e5dc66a8c..1ba43d57e 100644
--- a/extensions/matrix/package.json
+++ b/extensions/matrix/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/matrix",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot Matrix channel plugin",
"clawdbot": {
diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts
index 49deabbf8..2ba7cbef0 100644
--- a/extensions/matrix/src/matrix/monitor/handler.ts
+++ b/extensions/matrix/src/matrix/monitor/handler.ts
@@ -1,7 +1,12 @@
import type { LocationMessageEventContent, MatrixClient } from "matrix-bot-sdk";
import {
+ createReplyPrefixContext,
+ createTypingCallbacks,
formatAllowlistMatchMeta,
+ logInboundDrop,
+ logTypingFailure,
+ resolveControlCommandGate,
type RuntimeEnv,
} from "clawdbot/plugin-sdk";
import type { CoreConfig, ReplyToMode } from "../../types.js";
@@ -376,21 +381,25 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
userName: senderName,
})
: false;
- const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
+ const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg);
+ const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
{ configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers },
{ configured: groupAllowConfigured, allowed: senderAllowedForGroup },
],
+ allowTextCommands,
+ hasControlCommand: hasControlCommandInMessage,
});
- if (
- isRoom &&
- allowTextCommands &&
- core.channel.text.hasControlCommand(bodyText, cfg) &&
- !commandAuthorized
- ) {
- logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`);
+ const commandAuthorized = commandGate.commandAuthorized;
+ if (isRoom && commandGate.shouldBlock) {
+ logInboundDrop({
+ log: logVerboseMessage,
+ channel: "matrix",
+ reason: "control command (unauthorized)",
+ target: senderId,
+ });
return;
}
const shouldRequireMention = isRoom
@@ -409,7 +418,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
!wasMentioned &&
!hasExplicitMention &&
commandAuthorized &&
- core.channel.text.hasControlCommand(bodyText);
+ hasControlCommandInMessage;
+ const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention;
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
logger.info({ roomId, reason: "no-mention" }, "skipping room message");
return;
@@ -486,47 +496,45 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
OriginatingTo: `room:${roomId}`,
});
- void core.channel.session
- .recordSessionMetaFromInbound({
- storePath,
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
- ctx: ctxPayload,
- })
- .catch((err) => {
+ await core.channel.session.recordInboundSession({
+ storePath,
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
+ ctx: ctxPayload,
+ updateLastRoute: isDirectMessage
+ ? {
+ sessionKey: route.mainSessionKey,
+ channel: "matrix",
+ to: `room:${roomId}`,
+ accountId: route.accountId,
+ }
+ : undefined,
+ onRecordError: (err) => {
logger.warn(
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
"failed updating session meta",
);
- });
-
- if (isDirectMessage) {
- await core.channel.session.updateLastRoute({
- storePath,
- sessionKey: route.mainSessionKey,
- channel: "matrix",
- to: `room:${roomId}`,
- accountId: route.accountId,
- ctx: ctxPayload,
- });
- }
+ },
+ });
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
- const shouldAckReaction = () => {
- if (!ackReaction) return false;
- if (ackScope === "all") return true;
- if (ackScope === "direct") return isDirectMessage;
- if (ackScope === "group-all") return isRoom;
- if (ackScope === "group-mentions") {
- if (!isRoom) return false;
- if (!shouldRequireMention) return false;
- return wasMentioned || shouldBypassMention;
- }
- return false;
- };
+ const shouldAckReaction = () =>
+ Boolean(
+ ackReaction &&
+ core.channel.reactions.shouldAckReaction({
+ scope: ackScope,
+ isDirect: isDirectMessage,
+ isGroup: isRoom,
+ isMentionableGroup: isRoom,
+ requireMention: Boolean(shouldRequireMention),
+ canDetectMention,
+ effectiveWasMentioned: wasMentioned || shouldBypassMention,
+ shouldBypassMention,
+ }),
+ );
if (shouldAckReaction() && messageId) {
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
@@ -553,10 +561,33 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
channel: "matrix",
accountId: route.accountId,
});
+ const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
+ const typingCallbacks = createTypingCallbacks({
+ start: () => sendTypingMatrix(roomId, true, undefined, client),
+ stop: () => sendTypingMatrix(roomId, false, undefined, client),
+ onStartError: (err) => {
+ logTypingFailure({
+ log: logVerboseMessage,
+ channel: "matrix",
+ action: "start",
+ target: roomId,
+ error: err,
+ });
+ },
+ onStopError: (err) => {
+ logTypingFailure({
+ log: logVerboseMessage,
+ channel: "matrix",
+ action: "stop",
+ target: roomId,
+ error: err,
+ });
+ },
+ });
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
- responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
- .responsePrefix,
+ responsePrefix: prefixContext.responsePrefix,
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => {
await deliverMatrixReplies({
@@ -575,10 +606,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
onError: (err, info) => {
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
},
- onReplyStart: () =>
- sendTypingMatrix(roomId, true, undefined, client).catch(() => {}),
- onIdle: () =>
- sendTypingMatrix(roomId, false, undefined, client).catch(() => {}),
+ onReplyStart: typingCallbacks.onReplyStart,
+ onIdle: typingCallbacks.onIdle,
});
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
@@ -588,6 +617,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
replyOptions: {
...replyOptions,
skillFilter: roomConfig?.skills,
+ onModelSelected: prefixContext.onModelSelected,
},
});
markDispatchIdle();
diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json
index e704cedc5..251fe7b0b 100644
--- a/extensions/mattermost/package.json
+++ b/extensions/mattermost/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/mattermost",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot Mattermost channel plugin",
"clawdbot": {
diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts
index cce05f0cb..659ca83aa 100644
--- a/extensions/mattermost/src/mattermost/monitor.ts
+++ b/extensions/mattermost/src/mattermost/monitor.ts
@@ -7,10 +7,15 @@ import type {
RuntimeEnv,
} from "clawdbot/plugin-sdk";
import {
+ createReplyPrefixContext,
+ createTypingCallbacks,
+ logInboundDrop,
+ logTypingFailure,
buildPendingHistoryContextFromMap,
- clearHistoryEntries,
+ clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT,
- recordPendingHistoryEntry,
+ recordPendingHistoryEntryIfEnabled,
+ resolveControlCommandGate,
resolveChannelMediaMaxBytes,
type HistoryEntry,
} from "clawdbot/plugin-sdk";
@@ -30,12 +35,9 @@ import {
} from "./client.js";
import {
createDedupeCache,
- extractShortModelName,
formatInboundFromLabel,
rawDataToString,
- resolveIdentityName,
resolveThreadSessionKeys,
- type ResponsePrefixContext,
} from "./monitor-helpers.js";
import { sendMessageMattermost } from "./send.js";
@@ -307,11 +309,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
};
const sendTypingIndicator = async (channelId: string, parentId?: string) => {
- try {
- await sendMattermostTyping(client, { channelId, parentId });
- } catch (err) {
- logger.debug?.(`mattermost typing cue failed for channel ${channelId}: ${String(err)}`);
- }
+ await sendMattermostTyping(client, { channelId, parentId });
};
const resolveChannelInfo = async (channelId: string): Promise => {
@@ -403,7 +401,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
cfg,
surface: "mattermost",
});
- const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg);
+ const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg);
+ const isControlCommand = allowTextCommands && hasControlCommand;
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed({
senderId,
@@ -415,19 +414,20 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
senderName,
allowFrom: effectiveGroupAllowFrom,
});
+ const commandGate = resolveControlCommandGate({
+ useAccessGroups,
+ authorizers: [
+ { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
+ {
+ configured: effectiveGroupAllowFrom.length > 0,
+ allowed: groupAllowedForCommands,
+ },
+ ],
+ allowTextCommands,
+ hasControlCommand,
+ });
const commandAuthorized =
- kind === "dm"
- ? dmPolicy === "open" || senderAllowedForCommands
- : core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
- useAccessGroups,
- authorizers: [
- { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
- {
- configured: effectiveGroupAllowFrom.length > 0,
- allowed: groupAllowedForCommands,
- },
- ],
- });
+ kind === "dm" ? dmPolicy === "open" || senderAllowedForCommands : commandGate.commandAuthorized;
if (kind === "dm") {
if (dmPolicy === "disabled") {
@@ -488,10 +488,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
}
}
- if (kind !== "dm" && isControlCommand && !commandAuthorized) {
- logVerboseMessage(
- `mattermost: drop control command from unauthorized sender ${senderId}`,
- );
+ if (kind !== "dm" && commandGate.shouldBlock) {
+ logInboundDrop({
+ log: logVerboseMessage,
+ channel: "mattermost",
+ reason: "control command (unauthorized)",
+ target: senderId,
+ });
return;
}
@@ -534,19 +537,19 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
: "");
const pendingSender = senderName;
const recordPendingHistory = () => {
- if (!historyKey || historyLimit <= 0) return;
const trimmed = pendingBody.trim();
- if (!trimmed) return;
- recordPendingHistoryEntry({
+ recordPendingHistoryEntryIfEnabled({
historyMap: channelHistories,
- historyKey,
limit: historyLimit,
- entry: {
- sender: pendingSender,
- body: trimmed,
- timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
- messageId: post.id ?? undefined,
- },
+ historyKey: historyKey ?? "",
+ entry: historyKey && trimmed
+ ? {
+ sender: pendingSender,
+ body: trimmed,
+ timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
+ messageId: post.id ?? undefined,
+ }
+ : null,
});
};
@@ -623,7 +626,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
sender: { name: senderName, id: senderId },
});
let combinedBody = body;
- if (historyKey && historyLimit > 0) {
+ if (historyKey) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: channelHistories,
historyKey,
@@ -713,15 +716,23 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
accountId: account.accountId,
});
- let prefixContext: ResponsePrefixContext = {
- identityName: resolveIdentityName(cfg, route.agentId),
- };
+ const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
+ const typingCallbacks = createTypingCallbacks({
+ start: () => sendTypingIndicator(channelId, threadRootId),
+ onStartError: (err) => {
+ logTypingFailure({
+ log: (message) => logger.debug?.(message),
+ channel: "mattermost",
+ target: channelId,
+ error: err,
+ });
+ },
+ });
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
- responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
- .responsePrefix,
- responsePrefixContextProvider: () => prefixContext,
+ responsePrefix: prefixContext.responsePrefix,
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => {
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
@@ -752,7 +763,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
onError: (err, info) => {
runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`);
},
- onReplyStart: () => sendTypingIndicator(channelId, threadRootId),
+ onReplyStart: typingCallbacks.onReplyStart,
});
await core.channel.reply.dispatchReplyFromConfig({
@@ -763,17 +774,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
...replyOptions,
disableBlockStreaming:
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
- onModelSelected: (ctx) => {
- prefixContext.provider = ctx.provider;
- prefixContext.model = extractShortModelName(ctx.model);
- prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
- prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
- },
+ onModelSelected: prefixContext.onModelSelected,
},
});
markDispatchIdle();
- if (historyKey && historyLimit > 0) {
- clearHistoryEntries({ historyMap: channelHistories, historyKey });
+ if (historyKey) {
+ clearHistoryEntriesIfEnabled({ historyMap: channelHistories, historyKey, limit: historyLimit });
}
};
diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json
index 57dfc36ef..48a089aaa 100644
--- a/extensions/memory-core/package.json
+++ b/extensions/memory-core/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-core",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot core memory search plugin",
"clawdbot": {
diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json
index bdaa21f35..4f0e97377 100644
--- a/extensions/memory-lancedb/package.json
+++ b/extensions/memory-lancedb/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-lancedb",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
"dependencies": {
diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md
index aa132c382..63f54e309 100644
--- a/extensions/msteams/CHANGELOG.md
+++ b/extensions/msteams/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 2026.1.23
+
+### Changes
+- Version alignment with core Clawdbot release numbers.
+
## 2026.1.22
### Changes
diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json
index 5f7843f8a..80d566e7c 100644
--- a/extensions/msteams/package.json
+++ b/extensions/msteams/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/msteams",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": {
diff --git a/extensions/msteams/src/attachments/download.ts b/extensions/msteams/src/attachments/download.ts
index 0a44c50d6..cadb00dca 100644
--- a/extensions/msteams/src/attachments/download.ts
+++ b/extensions/msteams/src/attachments/download.ts
@@ -68,10 +68,10 @@ function scopeCandidatesForUrl(url: string): string[] {
host.endsWith("1drv.ms") ||
host.includes("sharepoint");
return looksLikeGraph
- ? ["https://graph.microsoft.com/.default", "https://api.botframework.com/.default"]
- : ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"];
+ ? ["https://graph.microsoft.com", "https://api.botframework.com"]
+ : ["https://api.botframework.com", "https://graph.microsoft.com"];
} catch {
- return ["https://api.botframework.com/.default", "https://graph.microsoft.com/.default"];
+ return ["https://api.botframework.com", "https://graph.microsoft.com"];
}
}
diff --git a/extensions/msteams/src/attachments/graph.ts b/extensions/msteams/src/attachments/graph.ts
index bb47d413f..6cad32e46 100644
--- a/extensions/msteams/src/attachments/graph.ts
+++ b/extensions/msteams/src/attachments/graph.ts
@@ -198,7 +198,7 @@ export async function downloadMSTeamsGraphMedia(params: {
const messageUrl = params.messageUrl;
let accessToken: string;
try {
- accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
+ accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com");
} catch {
return { media: [], messageUrl, tokenError: true };
}
diff --git a/extensions/msteams/src/directory-live.ts b/extensions/msteams/src/directory-live.ts
index 35715acb4..bbc5c79eb 100644
--- a/extensions/msteams/src/directory-live.ts
+++ b/extensions/msteams/src/directory-live.ts
@@ -64,7 +64,7 @@ async function resolveGraphToken(cfg: unknown): Promise {
if (!creds) throw new Error("MS Teams credentials missing");
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
- const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
+ const token = await tokenProvider.getAccessToken("https://graph.microsoft.com");
const accessToken = readAccessToken(token);
if (!accessToken) throw new Error("MS Teams graph token unavailable");
return accessToken;
diff --git a/extensions/msteams/src/graph-upload.ts b/extensions/msteams/src/graph-upload.ts
index dd4e28683..3bd9ea5a6 100644
--- a/extensions/msteams/src/graph-upload.ts
+++ b/extensions/msteams/src/graph-upload.ts
@@ -13,7 +13,7 @@ import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
const GRAPH_ROOT = "https://graph.microsoft.com/v1.0";
const GRAPH_BETA = "https://graph.microsoft.com/beta";
-const GRAPH_SCOPE = "https://graph.microsoft.com/.default";
+const GRAPH_SCOPE = "https://graph.microsoft.com";
export interface OneDriveUploadResult {
id: string;
diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts
index 1a0129180..16eb8fc0a 100644
--- a/extensions/msteams/src/monitor-handler/message-handler.ts
+++ b/extensions/msteams/src/monitor-handler/message-handler.ts
@@ -1,8 +1,10 @@
import {
buildPendingHistoryContextFromMap,
- clearHistoryEntries,
+ clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT,
- recordPendingHistoryEntry,
+ logInboundDrop,
+ recordPendingHistoryEntryIfEnabled,
+ resolveControlCommandGate,
resolveMentionGating,
formatAllowlistMatchMeta,
type HistoryEntry,
@@ -251,15 +253,24 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
senderId,
senderName,
});
- const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
+ const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg);
+ const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
],
+ allowTextCommands: true,
+ hasControlCommand: hasControlCommandInMessage,
});
- if (core.channel.text.hasControlCommand(text, cfg) && !commandAuthorized) {
- logVerboseMessage(`msteams: drop control command from unauthorized sender ${senderId}`);
+ const commandAuthorized = commandGate.commandAuthorized;
+ if (commandGate.shouldBlock) {
+ logInboundDrop({
+ log: logVerboseMessage,
+ channel: "msteams",
+ reason: "control command (unauthorized)",
+ target: senderId,
+ });
return;
}
@@ -371,19 +382,17 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
requireMention,
mentioned,
});
- if (historyLimit > 0) {
- recordPendingHistoryEntry({
- historyMap: conversationHistories,
- historyKey: conversationId,
- limit: historyLimit,
- entry: {
- sender: senderName,
- body: rawBody,
- timestamp: timestamp?.getTime(),
- messageId: activity.id ?? undefined,
- },
- });
- }
+ recordPendingHistoryEntryIfEnabled({
+ historyMap: conversationHistories,
+ historyKey: conversationId,
+ limit: historyLimit,
+ entry: {
+ sender: senderName,
+ body: rawBody,
+ timestamp: timestamp?.getTime(),
+ messageId: activity.id ?? undefined,
+ },
+ });
return;
}
}
@@ -426,7 +435,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
let combinedBody = body;
const isRoomish = !isDirectMessage;
const historyKey = isRoomish ? conversationId : undefined;
- if (isRoomish && historyKey && historyLimit > 0) {
+ if (isRoomish && historyKey) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: conversationHistories,
historyKey,
@@ -467,12 +476,13 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
...mediaPayload,
});
- void core.channel.session.recordSessionMetaFromInbound({
- storePath,
+ await core.channel.session.recordInboundSession({
+ storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
- }).catch((err) => {
- logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
+ onRecordError: (err) => {
+ logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`);
+ },
});
logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
@@ -512,10 +522,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const didSendReply = counts.final + counts.tool + counts.block > 0;
if (!queuedFinal) {
- if (isRoomish && historyKey && historyLimit > 0) {
- clearHistoryEntries({
+ if (isRoomish && historyKey) {
+ clearHistoryEntriesIfEnabled({
historyMap: conversationHistories,
historyKey,
+ limit: historyLimit,
});
}
return;
@@ -524,8 +535,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
logVerboseMessage(
`msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`,
);
- if (isRoomish && historyKey && historyLimit > 0) {
- clearHistoryEntries({ historyMap: conversationHistories, historyKey });
+ if (isRoomish && historyKey) {
+ clearHistoryEntriesIfEnabled({
+ historyMap: conversationHistories,
+ historyKey,
+ limit: historyLimit,
+ });
}
} catch (err) {
log.error("dispatch failed", { error: String(err) });
diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts
index f711c8240..449a14fe2 100644
--- a/extensions/msteams/src/reply-dispatcher.ts
+++ b/extensions/msteams/src/reply-dispatcher.ts
@@ -1,4 +1,7 @@
import {
+ createReplyPrefixContext,
+ createTypingCallbacks,
+ logTypingFailure,
resolveChannelMediaMaxBytes,
type ClawdbotConfig,
type MSTeamsReplyStyle,
@@ -39,23 +42,33 @@ export function createMSTeamsReplyDispatcher(params: {
}) {
const core = getMSTeamsRuntime();
const sendTypingIndicator = async () => {
- try {
- await params.context.sendActivities([{ type: "typing" }]);
- } catch {
- // Typing indicator is best-effort.
- }
+ await params.context.sendActivities([{ type: "typing" }]);
};
-
- return core.channel.reply.createReplyDispatcherWithTyping({
- responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(
- params.cfg,
- params.agentId,
- ).responsePrefix,
- humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
- deliver: async (payload) => {
- const tableMode = core.channel.text.resolveMarkdownTableMode({
- cfg: params.cfg,
+ const typingCallbacks = createTypingCallbacks({
+ start: sendTypingIndicator,
+ onStartError: (err) => {
+ logTypingFailure({
+ log: (message) => params.log.debug(message),
channel: "msteams",
+ action: "start",
+ error: err,
+ });
+ },
+ });
+ const prefixContext = createReplyPrefixContext({
+ cfg: params.cfg,
+ agentId: params.agentId,
+ });
+
+ const { dispatcher, replyOptions, markDispatchIdle } =
+ core.channel.reply.createReplyDispatcherWithTyping({
+ responsePrefix: prefixContext.responsePrefix,
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
+ deliver: async (payload) => {
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
+ cfg: params.cfg,
+ channel: "msteams",
});
const messages = renderReplyPayloadsToMessages([payload], {
textChunkLimit: params.textLimit,
@@ -87,21 +100,27 @@ export function createMSTeamsReplyDispatcher(params: {
mediaMaxBytes,
});
if (ids.length > 0) params.onSentMessageIds?.(ids);
- },
- onError: (err, info) => {
- const errMsg = formatUnknownError(err);
- const classification = classifyMSTeamsSendError(err);
- const hint = formatMSTeamsSendErrorHint(classification);
- params.runtime.error?.(
- `msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`,
- );
- params.log.error("reply failed", {
- kind: info.kind,
- error: errMsg,
- classification,
- hint,
- });
- },
- onReplyStart: sendTypingIndicator,
- });
+ },
+ onError: (err, info) => {
+ const errMsg = formatUnknownError(err);
+ const classification = classifyMSTeamsSendError(err);
+ const hint = formatMSTeamsSendErrorHint(classification);
+ params.runtime.error?.(
+ `msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`,
+ );
+ params.log.error("reply failed", {
+ kind: info.kind,
+ error: errMsg,
+ classification,
+ hint,
+ });
+ },
+ onReplyStart: typingCallbacks.onReplyStart,
+ });
+
+ return {
+ dispatcher,
+ replyOptions: { ...replyOptions, onModelSelected: prefixContext.onModelSelected },
+ markDispatchIdle,
+ };
}
diff --git a/extensions/msteams/src/resolve-allowlist.ts b/extensions/msteams/src/resolve-allowlist.ts
index a74c42f61..a5e7a0c74 100644
--- a/extensions/msteams/src/resolve-allowlist.ts
+++ b/extensions/msteams/src/resolve-allowlist.ts
@@ -143,7 +143,7 @@ async function resolveGraphToken(cfg: unknown): Promise {
if (!creds) throw new Error("MS Teams credentials missing");
const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds);
const tokenProvider = new sdk.MsalTokenProvider(authConfig);
- const token = await tokenProvider.getAccessToken("https://graph.microsoft.com/.default");
+ const token = await tokenProvider.getAccessToken("https://graph.microsoft.com");
const accessToken = readAccessToken(token);
if (!accessToken) throw new Error("MS Teams graph token unavailable");
return accessToken;
diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json
index c5ee0b8c6..5c6f5e243 100644
--- a/extensions/nextcloud-talk/package.json
+++ b/extensions/nextcloud-talk/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/nextcloud-talk",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot Nextcloud Talk channel plugin",
"clawdbot": {
diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts
index 1c6984848..77db9f338 100644
--- a/extensions/nextcloud-talk/src/inbound.ts
+++ b/extensions/nextcloud-talk/src/inbound.ts
@@ -1,4 +1,9 @@
-import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
+import {
+ logInboundDrop,
+ resolveControlCommandGate,
+ type ClawdbotConfig,
+ type RuntimeEnv,
+} from "clawdbot/plugin-sdk";
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
import {
@@ -118,7 +123,11 @@ export async function handleNextcloudTalkInbound(params: {
senderId,
senderName,
}).allowed;
- const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
+ const hasControlCommand = core.channel.text.hasControlCommand(
+ rawBody,
+ config as ClawdbotConfig,
+ );
+ const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{
@@ -127,7 +136,10 @@ export async function handleNextcloudTalkInbound(params: {
allowed: senderAllowedForCommands,
},
],
+ allowTextCommands,
+ hasControlCommand,
});
+ const commandAuthorized = commandGate.commandAuthorized;
if (isGroup) {
const groupAllow = resolveNextcloudTalkGroupAllow({
@@ -188,15 +200,13 @@ export async function handleNextcloudTalkInbound(params: {
}
}
- if (
- isGroup &&
- allowTextCommands &&
- core.channel.text.hasControlCommand(rawBody, config as ClawdbotConfig) &&
- commandAuthorized !== true
- ) {
- runtime.log?.(
- `nextcloud-talk: drop control command from unauthorized sender ${senderId}`,
- );
+ if (isGroup && commandGate.shouldBlock) {
+ logInboundDrop({
+ log: (message) => runtime.log?.(message),
+ channel: CHANNEL_ID,
+ reason: "control command (unauthorized)",
+ target: senderId,
+ });
return;
}
@@ -212,10 +222,6 @@ export async function handleNextcloudTalkInbound(params: {
wildcardConfig: roomMatch.wildcardConfig,
})
: false;
- const hasControlCommand = core.channel.text.hasControlCommand(
- rawBody,
- config as ClawdbotConfig,
- );
const mentionGate = resolveNextcloudTalkMentionGate({
isGroup,
requireMention: shouldRequireMention,
@@ -287,15 +293,14 @@ export async function handleNextcloudTalkInbound(params: {
CommandAuthorized: commandAuthorized,
});
- void core.channel.session
- .recordSessionMetaFromInbound({
- storePath,
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
- ctx: ctxPayload,
- })
- .catch((err) => {
+ await core.channel.session.recordInboundSession({
+ storePath,
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
+ ctx: ctxPayload,
+ onRecordError: (err) => {
runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
- });
+ },
+ });
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md
index 2005c22b3..610f34e81 100644
--- a/extensions/nostr/CHANGELOG.md
+++ b/extensions/nostr/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 2026.1.23
+
+### Changes
+- Version alignment with core Clawdbot release numbers.
+
## 2026.1.22
### Changes
diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json
index dc5a9e002..0efec2efa 100644
--- a/extensions/nostr/package.json
+++ b/extensions/nostr/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/nostr",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
"clawdbot": {
diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json
index 73282f117..3fa6e8b17 100644
--- a/extensions/open-prose/package.json
+++ b/extensions/open-prose/package.json
@@ -1,9 +1,11 @@
{
"name": "@clawdbot/open-prose",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"clawdbot": {
- "extensions": ["./index.ts"]
+ "extensions": [
+ "./index.ts"
+ ]
}
}
diff --git a/extensions/signal/package.json b/extensions/signal/package.json
index 6c26e7774..89de33544 100644
--- a/extensions/signal/package.json
+++ b/extensions/signal/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/signal",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot Signal channel plugin",
"clawdbot": {
diff --git a/extensions/slack/package.json b/extensions/slack/package.json
index 93470c49d..f129515f5 100644
--- a/extensions/slack/package.json
+++ b/extensions/slack/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/slack",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot Slack channel plugin",
"clawdbot": {
diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json
index 2dc95db2e..e4005c739 100644
--- a/extensions/telegram/package.json
+++ b/extensions/telegram/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/telegram",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot Telegram channel plugin",
"clawdbot": {
diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md
index 08d0f5006..0edc0dcb8 100644
--- a/extensions/voice-call/CHANGELOG.md
+++ b/extensions/voice-call/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 2026.1.23
+
+### Changes
+- Version alignment with core Clawdbot release numbers.
+
## 2026.1.22
### Changes
diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json
index ef0323ee4..88a0326b3 100644
--- a/extensions/voice-call/package.json
+++ b/extensions/voice-call/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/voice-call",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot voice-call plugin",
"dependencies": {
diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json
index 49f0fb541..3dcc4cf6b 100644
--- a/extensions/whatsapp/package.json
+++ b/extensions/whatsapp/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/whatsapp",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot WhatsApp channel plugin",
"clawdbot": {
diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md
index a5e56e1da..ab6d394fa 100644
--- a/extensions/zalo/CHANGELOG.md
+++ b/extensions/zalo/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 2026.1.23
+
+### Changes
+- Version alignment with core Clawdbot release numbers.
+
## 2026.1.22
### Changes
diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json
index 0f59602e7..7ced3106a 100644
--- a/extensions/zalo/package.json
+++ b/extensions/zalo/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalo",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot Zalo channel plugin",
"clawdbot": {
diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts
index 939dcdbde..44a279354 100644
--- a/extensions/zalo/src/monitor.ts
+++ b/extensions/zalo/src/monitor.ts
@@ -570,12 +570,13 @@ async function processMessageWithPipeline(params: {
OriginatingTo: `zalo:${chatId}`,
});
- void core.channel.session.recordSessionMetaFromInbound({
+ await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
- }).catch((err) => {
- runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
+ onRecordError: (err) => {
+ runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
+ },
});
const tableMode = core.channel.text.resolveMarkdownTableMode({
diff --git a/extensions/zalouser/CHANGELOG.md b/extensions/zalouser/CHANGELOG.md
index f5b7a3071..ec3c9e340 100644
--- a/extensions/zalouser/CHANGELOG.md
+++ b/extensions/zalouser/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 2026.1.23
+
+### Changes
+- Version alignment with core Clawdbot release numbers.
+
## 2026.1.22
### Changes
diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json
index 71e6da3cb..9f406c56c 100644
--- a/extensions/zalouser/package.json
+++ b/extensions/zalouser/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalouser",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"type": "module",
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
"dependencies": {
diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts
index 4015fcc8d..97e5a4be3 100644
--- a/extensions/zalouser/src/monitor.ts
+++ b/extensions/zalouser/src/monitor.ts
@@ -311,12 +311,13 @@ async function processMessage(
OriginatingTo: `zalouser:${chatId}`,
});
- void core.channel.session.recordSessionMetaFromInbound({
+ await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
- }).catch((err) => {
- runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
+ onRecordError: (err) => {
+ runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
+ },
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
diff --git a/package.json b/package.json
index ae2aab470..f12d83a74 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "clawdbot",
- "version": "2026.1.22",
+ "version": "2026.1.23",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
diff --git a/src/agents/compaction.test.ts b/src/agents/compaction.test.ts
new file mode 100644
index 000000000..1cfacda9a
--- /dev/null
+++ b/src/agents/compaction.test.ts
@@ -0,0 +1,107 @@
+import type { AgentMessage } from "@mariozechner/pi-agent-core";
+import { describe, expect, it } from "vitest";
+
+import {
+ estimateMessagesTokens,
+ pruneHistoryForContextShare,
+ splitMessagesByTokenShare,
+} from "./compaction.js";
+
+function makeMessage(id: number, size: number): AgentMessage {
+ return {
+ role: "user",
+ content: "x".repeat(size),
+ timestamp: id,
+ };
+}
+
+describe("splitMessagesByTokenShare", () => {
+ it("splits messages into two non-empty parts", () => {
+ const messages: AgentMessage[] = [
+ makeMessage(1, 4000),
+ makeMessage(2, 4000),
+ makeMessage(3, 4000),
+ makeMessage(4, 4000),
+ ];
+
+ const parts = splitMessagesByTokenShare(messages, 2);
+ expect(parts.length).toBeGreaterThanOrEqual(2);
+ expect(parts[0]?.length).toBeGreaterThan(0);
+ expect(parts[1]?.length).toBeGreaterThan(0);
+ expect(parts.flat().length).toBe(messages.length);
+ });
+
+ it("preserves message order across parts", () => {
+ const messages: AgentMessage[] = [
+ makeMessage(1, 4000),
+ makeMessage(2, 4000),
+ makeMessage(3, 4000),
+ makeMessage(4, 4000),
+ makeMessage(5, 4000),
+ makeMessage(6, 4000),
+ ];
+
+ const parts = splitMessagesByTokenShare(messages, 3);
+ expect(parts.flat().map((msg) => msg.timestamp)).toEqual(messages.map((msg) => msg.timestamp));
+ });
+});
+
+describe("pruneHistoryForContextShare", () => {
+ it("drops older chunks until the history budget is met", () => {
+ const messages: AgentMessage[] = [
+ makeMessage(1, 4000),
+ makeMessage(2, 4000),
+ makeMessage(3, 4000),
+ makeMessage(4, 4000),
+ ];
+ const maxContextTokens = 2000; // budget is 1000 tokens (50%)
+ const pruned = pruneHistoryForContextShare({
+ messages,
+ maxContextTokens,
+ maxHistoryShare: 0.5,
+ parts: 2,
+ });
+
+ expect(pruned.droppedChunks).toBeGreaterThan(0);
+ expect(pruned.keptTokens).toBeLessThanOrEqual(Math.floor(maxContextTokens * 0.5));
+ expect(pruned.messages.length).toBeGreaterThan(0);
+ });
+
+ it("keeps the newest messages when pruning", () => {
+ const messages: AgentMessage[] = [
+ makeMessage(1, 4000),
+ makeMessage(2, 4000),
+ makeMessage(3, 4000),
+ makeMessage(4, 4000),
+ makeMessage(5, 4000),
+ makeMessage(6, 4000),
+ ];
+ const totalTokens = estimateMessagesTokens(messages);
+ const maxContextTokens = Math.max(1, Math.floor(totalTokens * 0.5)); // budget = 25%
+ const pruned = pruneHistoryForContextShare({
+ messages,
+ maxContextTokens,
+ maxHistoryShare: 0.5,
+ parts: 2,
+ });
+
+ const keptIds = pruned.messages.map((msg) => msg.timestamp);
+ const expectedSuffix = messages.slice(-keptIds.length).map((msg) => msg.timestamp);
+ expect(keptIds).toEqual(expectedSuffix);
+ });
+
+ it("keeps history when already within budget", () => {
+ const messages: AgentMessage[] = [makeMessage(1, 1000)];
+ const maxContextTokens = 2000;
+ const pruned = pruneHistoryForContextShare({
+ messages,
+ maxContextTokens,
+ maxHistoryShare: 0.5,
+ parts: 2,
+ });
+
+ expect(pruned.droppedChunks).toBe(0);
+ expect(pruned.messages.length).toBe(messages.length);
+ expect(pruned.keptTokens).toBe(estimateMessagesTokens(messages));
+ });
+});
diff --git a/src/agents/compaction.ts b/src/agents/compaction.ts
new file mode 100644
index 000000000..2ab4566fd
--- /dev/null
+++ b/src/agents/compaction.ts
@@ -0,0 +1,341 @@
+import type { AgentMessage } from "@mariozechner/pi-agent-core";
+import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
+import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
+
+import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js";
+
+export const BASE_CHUNK_RATIO = 0.4;
+export const MIN_CHUNK_RATIO = 0.15;
+export const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy
+const DEFAULT_SUMMARY_FALLBACK = "No prior history.";
+const DEFAULT_PARTS = 2;
+const MERGE_SUMMARIES_INSTRUCTIONS =
+ "Merge these partial summaries into a single cohesive summary. Preserve decisions," +
+ " TODOs, open questions, and any constraints.";
+
+export function estimateMessagesTokens(messages: AgentMessage[]): number {
+ return messages.reduce((sum, message) => sum + estimateTokens(message), 0);
+}
+
+function normalizeParts(parts: number, messageCount: number): number {
+ if (!Number.isFinite(parts) || parts <= 1) return 1;
+ return Math.min(Math.max(1, Math.floor(parts)), Math.max(1, messageCount));
+}
+
+export function splitMessagesByTokenShare(
+ messages: AgentMessage[],
+ parts = DEFAULT_PARTS,
+): AgentMessage[][] {
+ if (messages.length === 0) return [];
+ const normalizedParts = normalizeParts(parts, messages.length);
+ if (normalizedParts <= 1) return [messages];
+
+ const totalTokens = estimateMessagesTokens(messages);
+ const targetTokens = totalTokens / normalizedParts;
+ const chunks: AgentMessage[][] = [];
+ let current: AgentMessage[] = [];
+ let currentTokens = 0;
+
+ for (const message of messages) {
+ const messageTokens = estimateTokens(message);
+ if (
+ chunks.length < normalizedParts - 1 &&
+ current.length > 0 &&
+ currentTokens + messageTokens > targetTokens
+ ) {
+ chunks.push(current);
+ current = [];
+ currentTokens = 0;
+ }
+
+ current.push(message);
+ currentTokens += messageTokens;
+ }
+
+ if (current.length > 0) {
+ chunks.push(current);
+ }
+
+ return chunks;
+}
+
+export function chunkMessagesByMaxTokens(
+ messages: AgentMessage[],
+ maxTokens: number,
+): AgentMessage[][] {
+ if (messages.length === 0) return [];
+
+ const chunks: AgentMessage[][] = [];
+ let currentChunk: AgentMessage[] = [];
+ let currentTokens = 0;
+
+ for (const message of messages) {
+ const messageTokens = estimateTokens(message);
+ if (currentChunk.length > 0 && currentTokens + messageTokens > maxTokens) {
+ chunks.push(currentChunk);
+ currentChunk = [];
+ currentTokens = 0;
+ }
+
+ currentChunk.push(message);
+ currentTokens += messageTokens;
+
+ if (messageTokens > maxTokens) {
+ // Split oversized messages to avoid unbounded chunk growth.
+ chunks.push(currentChunk);
+ currentChunk = [];
+ currentTokens = 0;
+ }
+ }
+
+ if (currentChunk.length > 0) {
+ chunks.push(currentChunk);
+ }
+
+ return chunks;
+}
+
+/**
+ * Compute adaptive chunk ratio based on average message size.
+ * When messages are large, we use smaller chunks to avoid exceeding model limits.
+ */
+export function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindow: number): number {
+ if (messages.length === 0) return BASE_CHUNK_RATIO;
+
+ const totalTokens = estimateMessagesTokens(messages);
+ const avgTokens = totalTokens / messages.length;
+
+ // Apply safety margin to account for estimation inaccuracy
+ const safeAvgTokens = avgTokens * SAFETY_MARGIN;
+ const avgRatio = safeAvgTokens / contextWindow;
+
+ // If average message is > 10% of context, reduce chunk ratio
+ if (avgRatio > 0.1) {
+ const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO);
+ return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction);
+ }
+
+ return BASE_CHUNK_RATIO;
+}
+
+/**
+ * Check if a single message is too large to summarize.
+ * If single message > 50% of context, it can't be summarized safely.
+ */
+export function isOversizedForSummary(msg: AgentMessage, contextWindow: number): boolean {
+ const tokens = estimateTokens(msg) * SAFETY_MARGIN;
+ return tokens > contextWindow * 0.5;
+}
+
+async function summarizeChunks(params: {
+ messages: AgentMessage[];
+ model: NonNullable;
+ apiKey: string;
+ signal: AbortSignal;
+ reserveTokens: number;
+ maxChunkTokens: number;
+ customInstructions?: string;
+ previousSummary?: string;
+}): Promise {
+ if (params.messages.length === 0) {
+ return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
+ }
+
+ const chunks = chunkMessagesByMaxTokens(params.messages, params.maxChunkTokens);
+ let summary = params.previousSummary;
+
+ for (const chunk of chunks) {
+ summary = await generateSummary(
+ chunk,
+ params.model,
+ params.reserveTokens,
+ params.apiKey,
+ params.signal,
+ params.customInstructions,
+ summary,
+ );
+ }
+
+ return summary ?? DEFAULT_SUMMARY_FALLBACK;
+}
+
+/**
+ * Summarize with progressive fallback for handling oversized messages.
+ * If full summarization fails, tries partial summarization excluding oversized messages.
+ */
+export async function summarizeWithFallback(params: {
+ messages: AgentMessage[];
+ model: NonNullable;
+ apiKey: string;
+ signal: AbortSignal;
+ reserveTokens: number;
+ maxChunkTokens: number;
+ contextWindow: number;
+ customInstructions?: string;
+ previousSummary?: string;
+}): Promise {
+ const { messages, contextWindow } = params;
+
+ if (messages.length === 0) {
+ return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
+ }
+
+ // Try full summarization first
+ try {
+ return await summarizeChunks(params);
+ } catch (fullError) {
+ console.warn(
+ `Full summarization failed, trying partial: ${
+ fullError instanceof Error ? fullError.message : String(fullError)
+ }`,
+ );
+ }
+
+ // Fallback 1: Summarize only small messages, note oversized ones
+ const smallMessages: AgentMessage[] = [];
+ const oversizedNotes: string[] = [];
+
+ for (const msg of messages) {
+ if (isOversizedForSummary(msg, contextWindow)) {
+ const role = (msg as { role?: string }).role ?? "message";
+ const tokens = estimateTokens(msg);
+ oversizedNotes.push(
+ `[Large ${role} (~${Math.round(tokens / 1000)}K tokens) omitted from summary]`,
+ );
+ } else {
+ smallMessages.push(msg);
+ }
+ }
+
+ if (smallMessages.length > 0) {
+ try {
+ const partialSummary = await summarizeChunks({
+ ...params,
+ messages: smallMessages,
+ });
+ const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : "";
+ return partialSummary + notes;
+ } catch (partialError) {
+ console.warn(
+ `Partial summarization also failed: ${
+ partialError instanceof Error ? partialError.message : String(partialError)
+ }`,
+ );
+ }
+ }
+
+ // Final fallback: Just note what was there
+ return (
+ `Context contained ${messages.length} messages (${oversizedNotes.length} oversized). ` +
+ `Summary unavailable due to size limits.`
+ );
+}
+
+export async function summarizeInStages(params: {
+ messages: AgentMessage[];
+ model: NonNullable;
+ apiKey: string;
+ signal: AbortSignal;
+ reserveTokens: number;
+ maxChunkTokens: number;
+ contextWindow: number;
+ customInstructions?: string;
+ previousSummary?: string;
+ parts?: number;
+ minMessagesForSplit?: number;
+}): Promise {
+ const { messages } = params;
+ if (messages.length === 0) {
+ return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK;
+ }
+
+ const minMessagesForSplit = Math.max(2, params.minMessagesForSplit ?? 4);
+ const parts = normalizeParts(params.parts ?? DEFAULT_PARTS, messages.length);
+ const totalTokens = estimateMessagesTokens(messages);
+
+ if (parts <= 1 || messages.length < minMessagesForSplit || totalTokens <= params.maxChunkTokens) {
+ return summarizeWithFallback(params);
+ }
+
+ const splits = splitMessagesByTokenShare(messages, parts).filter((chunk) => chunk.length > 0);
+ if (splits.length <= 1) {
+ return summarizeWithFallback(params);
+ }
+
+ const partialSummaries: string[] = [];
+ for (const chunk of splits) {
+ partialSummaries.push(
+ await summarizeWithFallback({
+ ...params,
+ messages: chunk,
+ previousSummary: undefined,
+ }),
+ );
+ }
+
+ if (partialSummaries.length === 1) {
+ return partialSummaries[0];
+ }
+
+ const summaryMessages: AgentMessage[] = partialSummaries.map((summary) => ({
+ role: "user",
+ content: summary,
+ timestamp: Date.now(),
+ }));
+
+ const mergeInstructions = params.customInstructions
+ ? `${MERGE_SUMMARIES_INSTRUCTIONS}\n\nAdditional focus:\n${params.customInstructions}`
+ : MERGE_SUMMARIES_INSTRUCTIONS;
+
+ return summarizeWithFallback({
+ ...params,
+ messages: summaryMessages,
+ customInstructions: mergeInstructions,
+ });
+}
+
+export function pruneHistoryForContextShare(params: {
+ messages: AgentMessage[];
+ maxContextTokens: number;
+ maxHistoryShare?: number;
+ parts?: number;
+}): {
+ messages: AgentMessage[];
+ droppedChunks: number;
+ droppedMessages: number;
+ droppedTokens: number;
+ keptTokens: number;
+ budgetTokens: number;
+} {
+ const maxHistoryShare = params.maxHistoryShare ?? 0.5;
+ const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * maxHistoryShare));
+ let keptMessages = params.messages;
+ let droppedChunks = 0;
+ let droppedMessages = 0;
+ let droppedTokens = 0;
+
+ const parts = normalizeParts(params.parts ?? DEFAULT_PARTS, keptMessages.length);
+
+ while (keptMessages.length > 0 && estimateMessagesTokens(keptMessages) > budgetTokens) {
+ const chunks = splitMessagesByTokenShare(keptMessages, parts);
+ if (chunks.length <= 1) break;
+ const [dropped, ...rest] = chunks;
+ droppedChunks += 1;
+ droppedMessages += dropped.length;
+ droppedTokens += estimateMessagesTokens(dropped);
+ keptMessages = rest.flat();
+ }
+
+ return {
+ messages: keptMessages,
+ droppedChunks,
+ droppedMessages,
+ droppedTokens,
+ keptTokens: estimateMessagesTokens(keptMessages),
+ budgetTokens,
+ };
+}
+
+export function resolveContextWindowTokens(model?: ExtensionContext["model"]): number {
+ return Math.max(1, Math.floor(model?.contextWindow ?? DEFAULT_CONTEXT_TOKENS));
+}
diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts
index 0e3388b84..da33315f8 100644
--- a/src/agents/pi-embedded-runner/run.ts
+++ b/src/agents/pi-embedded-runner/run.ts
@@ -79,6 +79,7 @@ export async function runEmbeddedPiAgent(
? "markdown"
: "plain"
: "markdown");
+ const isProbeSession = params.sessionId?.startsWith("probe-") ?? false;
return enqueueCommandInLane(sessionLane, () =>
enqueueGlobal(async () => {
@@ -455,7 +456,7 @@ export async function runEmbeddedPiAgent(
cfg: params.config,
agentDir: params.agentDir,
});
- if (timedOut) {
+ if (timedOut && !isProbeSession) {
log.warn(
`Profile ${lastProfileId} timed out (possible rate limit). Trying next account...`,
);
diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts
index 093588cb3..74c405981 100644
--- a/src/agents/pi-embedded-runner/run/attempt.ts
+++ b/src/agents/pi-embedded-runner/run/attempt.ts
@@ -595,18 +595,23 @@ export async function runEmbeddedAttempt(
setActiveEmbeddedRun(params.sessionId, queueHandle);
let abortWarnTimer: NodeJS.Timeout | undefined;
+ const isProbeSession = params.sessionId?.startsWith("probe-") ?? false;
const abortTimer = setTimeout(
() => {
- log.warn(
- `embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`,
- );
+ if (!isProbeSession) {
+ log.warn(
+ `embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`,
+ );
+ }
abortRun(true);
if (!abortWarnTimer) {
abortWarnTimer = setTimeout(() => {
if (!activeSession.isStreaming) return;
- log.warn(
- `embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`,
- );
+ if (!isProbeSession) {
+ log.warn(
+ `embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`,
+ );
+ }
}, 10_000);
}
},
diff --git a/src/agents/pi-embedded-runner/runs.ts b/src/agents/pi-embedded-runner/runs.ts
index 4fcefca12..dcbe56244 100644
--- a/src/agents/pi-embedded-runner/runs.ts
+++ b/src/agents/pi-embedded-runner/runs.ts
@@ -109,14 +109,18 @@ export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueH
state: "processing",
reason: wasActive ? "run_replaced" : "run_started",
});
- diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
+ if (!sessionId.startsWith("probe-")) {
+ diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
+ }
}
export function clearActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) {
if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) {
ACTIVE_EMBEDDED_RUNS.delete(sessionId);
logSessionStateChange({ sessionId, state: "idle", reason: "run_completed" });
- diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
+ if (!sessionId.startsWith("probe-")) {
+ diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
+ }
notifyEmbeddedRunEnded(sessionId);
} else {
diag.debug(`run clear skipped: sessionId=${sessionId} reason=handle_mismatch`);
diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts
index a6a66637a..82ad19f2a 100644
--- a/src/agents/pi-extensions/compaction-safeguard.ts
+++ b/src/agents/pi-extensions/compaction-safeguard.ts
@@ -1,12 +1,16 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
-import type { ExtensionAPI, ExtensionContext, FileOperations } from "@mariozechner/pi-coding-agent";
-import { estimateTokens, generateSummary } from "@mariozechner/pi-coding-agent";
-
-import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
-
-const BASE_CHUNK_RATIO = 0.4;
-const MIN_CHUNK_RATIO = 0.15;
-const SAFETY_MARGIN = 1.2; // 20% buffer for estimateTokens() inaccuracy
+import type { ExtensionAPI, FileOperations } from "@mariozechner/pi-coding-agent";
+import {
+ BASE_CHUNK_RATIO,
+ MIN_CHUNK_RATIO,
+ SAFETY_MARGIN,
+ computeAdaptiveChunkRatio,
+ estimateMessagesTokens,
+ isOversizedForSummary,
+ pruneHistoryForContextShare,
+ resolveContextWindowTokens,
+ summarizeInStages,
+} from "../compaction.js";
const FALLBACK_SUMMARY =
"Summary unavailable due to context limits. Older messages were truncated.";
const TURN_PREFIX_INSTRUCTIONS =
@@ -129,175 +133,6 @@ function formatFileOperations(readFiles: string[], modifiedFiles: string[]): str
return `\n\n${sections.join("\n\n")}`;
}
-function chunkMessages(messages: AgentMessage[], maxTokens: number): AgentMessage[][] {
- if (messages.length === 0) return [];
-
- const chunks: AgentMessage[][] = [];
- let currentChunk: AgentMessage[] = [];
- let currentTokens = 0;
-
- for (const message of messages) {
- const messageTokens = estimateTokens(message);
- if (currentChunk.length > 0 && currentTokens + messageTokens > maxTokens) {
- chunks.push(currentChunk);
- currentChunk = [];
- currentTokens = 0;
- }
-
- currentChunk.push(message);
- currentTokens += messageTokens;
-
- if (messageTokens > maxTokens) {
- // Split oversized messages to avoid unbounded chunk growth.
- chunks.push(currentChunk);
- currentChunk = [];
- currentTokens = 0;
- }
- }
-
- if (currentChunk.length > 0) {
- chunks.push(currentChunk);
- }
-
- return chunks;
-}
-
-/**
- * Compute adaptive chunk ratio based on average message size.
- * When messages are large, we use smaller chunks to avoid exceeding model limits.
- */
-function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindow: number): number {
- if (messages.length === 0) return BASE_CHUNK_RATIO;
-
- const totalTokens = messages.reduce((sum, m) => sum + estimateTokens(m), 0);
- const avgTokens = totalTokens / messages.length;
-
- // Apply safety margin to account for estimation inaccuracy
- const safeAvgTokens = avgTokens * SAFETY_MARGIN;
- const avgRatio = safeAvgTokens / contextWindow;
-
- // If average message is > 10% of context, reduce chunk ratio
- if (avgRatio > 0.1) {
- const reduction = Math.min(avgRatio * 2, BASE_CHUNK_RATIO - MIN_CHUNK_RATIO);
- return Math.max(MIN_CHUNK_RATIO, BASE_CHUNK_RATIO - reduction);
- }
-
- return BASE_CHUNK_RATIO;
-}
-
-/**
- * Check if a single message is too large to summarize.
- * If single message > 50% of context, it can't be summarized safely.
- */
-function isOversizedForSummary(msg: AgentMessage, contextWindow: number): boolean {
- const tokens = estimateTokens(msg) * SAFETY_MARGIN;
- return tokens > contextWindow * 0.5;
-}
-
-async function summarizeChunks(params: {
- messages: AgentMessage[];
- model: NonNullable;
- apiKey: string;
- signal: AbortSignal;
- reserveTokens: number;
- maxChunkTokens: number;
- customInstructions?: string;
- previousSummary?: string;
-}): Promise {
- if (params.messages.length === 0) {
- return params.previousSummary ?? "No prior history.";
- }
-
- const chunks = chunkMessages(params.messages, params.maxChunkTokens);
- let summary = params.previousSummary;
-
- for (const chunk of chunks) {
- summary = await generateSummary(
- chunk,
- params.model,
- params.reserveTokens,
- params.apiKey,
- params.signal,
- params.customInstructions,
- summary,
- );
- }
-
- return summary ?? "No prior history.";
-}
-
-/**
- * Summarize with progressive fallback for handling oversized messages.
- * If full summarization fails, tries partial summarization excluding oversized messages.
- */
-async function summarizeWithFallback(params: {
- messages: AgentMessage[];
- model: NonNullable;
- apiKey: string;
- signal: AbortSignal;
- reserveTokens: number;
- maxChunkTokens: number;
- contextWindow: number;
- customInstructions?: string;
- previousSummary?: string;
-}): Promise {
- const { messages, contextWindow } = params;
-
- if (messages.length === 0) {
- return params.previousSummary ?? "No prior history.";
- }
-
- // Try full summarization first
- try {
- return await summarizeChunks(params);
- } catch (fullError) {
- console.warn(
- `Full summarization failed, trying partial: ${
- fullError instanceof Error ? fullError.message : String(fullError)
- }`,
- );
- }
-
- // Fallback 1: Summarize only small messages, note oversized ones
- const smallMessages: AgentMessage[] = [];
- const oversizedNotes: string[] = [];
-
- for (const msg of messages) {
- if (isOversizedForSummary(msg, contextWindow)) {
- const role = (msg as { role?: string }).role ?? "message";
- const tokens = estimateTokens(msg);
- oversizedNotes.push(
- `[Large ${role} (~${Math.round(tokens / 1000)}K tokens) omitted from summary]`,
- );
- } else {
- smallMessages.push(msg);
- }
- }
-
- if (smallMessages.length > 0) {
- try {
- const partialSummary = await summarizeChunks({
- ...params,
- messages: smallMessages,
- });
- const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : "";
- return partialSummary + notes;
- } catch (partialError) {
- console.warn(
- `Partial summarization also failed: ${
- partialError instanceof Error ? partialError.message : String(partialError)
- }`,
- );
- }
- }
-
- // Final fallback: Just note what was there
- return (
- `Context contained ${messages.length} messages (${oversizedNotes.length} oversized). ` +
- `Summary unavailable due to size limits.`
- );
-}
-
export default function compactionSafeguardExtension(api: ExtensionAPI): void {
api.on("session_before_compact", async (event, ctx) => {
const { preparation, customInstructions, signal } = event;
@@ -335,19 +170,48 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
}
try {
- const contextWindowTokens = Math.max(
- 1,
- Math.floor(model.contextWindow ?? DEFAULT_CONTEXT_TOKENS),
- );
+ const contextWindowTokens = resolveContextWindowTokens(model);
+ const turnPrefixMessages = preparation.turnPrefixMessages ?? [];
+ let messagesToSummarize = preparation.messagesToSummarize;
+
+ const tokensBefore =
+ typeof preparation.tokensBefore === "number" && Number.isFinite(preparation.tokensBefore)
+ ? preparation.tokensBefore
+ : undefined;
+ if (tokensBefore !== undefined) {
+ const summarizableTokens =
+ estimateMessagesTokens(messagesToSummarize) + estimateMessagesTokens(turnPrefixMessages);
+ const newContentTokens = Math.max(0, Math.floor(tokensBefore - summarizableTokens));
+ const maxHistoryTokens = Math.floor(contextWindowTokens * 0.5);
+
+ if (newContentTokens > maxHistoryTokens) {
+ const pruned = pruneHistoryForContextShare({
+ messages: messagesToSummarize,
+ maxContextTokens: contextWindowTokens,
+ maxHistoryShare: 0.5,
+ parts: 2,
+ });
+ if (pruned.droppedChunks > 0) {
+ const newContentRatio = (newContentTokens / contextWindowTokens) * 100;
+ console.warn(
+ `Compaction safeguard: new content uses ${newContentRatio.toFixed(
+ 1,
+ )}% of context; dropped ${pruned.droppedChunks} older chunk(s) ` +
+ `(${pruned.droppedMessages} messages) to fit history budget.`,
+ );
+ messagesToSummarize = pruned.messages;
+ }
+ }
+ }
// Use adaptive chunk ratio based on message sizes
- const allMessages = [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages];
+ const allMessages = [...messagesToSummarize, ...turnPrefixMessages];
const adaptiveRatio = computeAdaptiveChunkRatio(allMessages, contextWindowTokens);
const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * adaptiveRatio));
const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens));
- const historySummary = await summarizeWithFallback({
- messages: preparation.messagesToSummarize,
+ const historySummary = await summarizeInStages({
+ messages: messagesToSummarize,
model,
apiKey,
signal,
@@ -359,9 +223,9 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
});
let summary = historySummary;
- if (preparation.isSplitTurn && preparation.turnPrefixMessages.length > 0) {
- const prefixSummary = await summarizeWithFallback({
- messages: preparation.turnPrefixMessages,
+ if (preparation.isSplitTurn && turnPrefixMessages.length > 0) {
+ const prefixSummary = await summarizeInStages({
+ messages: turnPrefixMessages,
model,
apiKey,
signal,
@@ -369,6 +233,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
maxChunkTokens,
contextWindow: contextWindowTokens,
customInstructions: TURN_PREFIX_INSTRUCTIONS,
+ previousSummary: undefined,
});
summary = `${historySummary}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixSummary}`;
}
diff --git a/src/auto-reply/dispatch.ts b/src/auto-reply/dispatch.ts
new file mode 100644
index 000000000..8b1626c3d
--- /dev/null
+++ b/src/auto-reply/dispatch.ts
@@ -0,0 +1,77 @@
+import type { ClawdbotConfig } from "../config/config.js";
+import type { FinalizedMsgContext, MsgContext } from "./templating.js";
+import type { GetReplyOptions } from "./types.js";
+import { finalizeInboundContext } from "./reply/inbound-context.js";
+import type { DispatchFromConfigResult } from "./reply/dispatch-from-config.js";
+import { dispatchReplyFromConfig } from "./reply/dispatch-from-config.js";
+import {
+ createReplyDispatcher,
+ createReplyDispatcherWithTyping,
+ type ReplyDispatcher,
+ type ReplyDispatcherOptions,
+ type ReplyDispatcherWithTypingOptions,
+} from "./reply/reply-dispatcher.js";
+
+export type DispatchInboundResult = DispatchFromConfigResult;
+
+export async function dispatchInboundMessage(params: {
+ ctx: MsgContext | FinalizedMsgContext;
+ cfg: ClawdbotConfig;
+ dispatcher: ReplyDispatcher;
+ replyOptions?: Omit;
+ replyResolver?: typeof import("./reply.js").getReplyFromConfig;
+}): Promise {
+ const finalized = finalizeInboundContext(params.ctx);
+ return await dispatchReplyFromConfig({
+ ctx: finalized,
+ cfg: params.cfg,
+ dispatcher: params.dispatcher,
+ replyOptions: params.replyOptions,
+ replyResolver: params.replyResolver,
+ });
+}
+
+export async function dispatchInboundMessageWithBufferedDispatcher(params: {
+ ctx: MsgContext | FinalizedMsgContext;
+ cfg: ClawdbotConfig;
+ dispatcherOptions: ReplyDispatcherWithTypingOptions;
+ replyOptions?: Omit;
+ replyResolver?: typeof import("./reply.js").getReplyFromConfig;
+}): Promise {
+ const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping(
+ params.dispatcherOptions,
+ );
+
+ const result = await dispatchInboundMessage({
+ ctx: params.ctx,
+ cfg: params.cfg,
+ dispatcher,
+ replyResolver: params.replyResolver,
+ replyOptions: {
+ ...params.replyOptions,
+ ...replyOptions,
+ },
+ });
+
+ markDispatchIdle();
+ return result;
+}
+
+export async function dispatchInboundMessageWithDispatcher(params: {
+ ctx: MsgContext | FinalizedMsgContext;
+ cfg: ClawdbotConfig;
+ dispatcherOptions: ReplyDispatcherOptions;
+ replyOptions?: Omit;
+ replyResolver?: typeof import("./reply.js").getReplyFromConfig;
+}): Promise {
+ const dispatcher = createReplyDispatcher(params.dispatcherOptions);
+ const result = await dispatchInboundMessage({
+ ctx: params.ctx,
+ cfg: params.cfg,
+ dispatcher,
+ replyResolver: params.replyResolver,
+ replyOptions: params.replyOptions,
+ });
+ await dispatcher.waitForIdle();
+ return result;
+}
diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts
index 0e7dfa233..532bac00a 100644
--- a/src/auto-reply/reply/agent-runner-execution.ts
+++ b/src/auto-reply/reply/agent-runner-execution.ts
@@ -82,7 +82,8 @@ export async function runAgentTurnWithFallback(params: {
// Track payloads sent directly (not via pipeline) during tool flush to avoid duplicates.
const directlySentBlockKeys = new Set();
- const runId = crypto.randomUUID();
+ const runId = params.opts?.runId ?? crypto.randomUUID();
+ params.opts?.onAgentRunStart?.(runId);
if (params.sessionKey) {
registerAgentRunContext(runId, {
sessionKey: params.sessionKey,
@@ -174,6 +175,7 @@ export async function runAgentTurnWithFallback(params: {
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
ownerNumbers: params.followupRun.run.ownerNumbers,
cliSessionId,
+ images: params.opts?.images,
})
.then((result) => {
emitAgentEvent({
@@ -248,6 +250,8 @@ export async function runAgentTurnWithFallback(params: {
bashElevated: params.followupRun.run.bashElevated,
timeoutMs: params.followupRun.run.timeoutMs,
runId,
+ images: params.opts?.images,
+ abortSignal: params.opts?.abortSignal,
blockReplyBreak: params.resolvedBlockStreamingBreak,
blockReplyChunking: params.blockReplyChunking,
onPartialReply: allowPartialStream
diff --git a/src/auto-reply/reply/history.test.ts b/src/auto-reply/reply/history.test.ts
index addbf5860..7991731da 100644
--- a/src/auto-reply/reply/history.test.ts
+++ b/src/auto-reply/reply/history.test.ts
@@ -5,7 +5,9 @@ import {
buildHistoryContextFromEntries,
buildHistoryContextFromMap,
buildPendingHistoryContextFromMap,
+ clearHistoryEntriesIfEnabled,
HISTORY_CONTEXT_MARKER,
+ recordPendingHistoryEntryIfEnabled,
} from "./history.js";
import { CURRENT_MESSAGE_MARKER } from "./mentions.js";
@@ -105,4 +107,46 @@ describe("history helpers", () => {
expect(result).toContain(CURRENT_MESSAGE_MARKER);
expect(result).toContain("current");
});
+
+ it("records pending entries only when enabled", () => {
+ const historyMap = new Map();
+
+ recordPendingHistoryEntryIfEnabled({
+ historyMap,
+ historyKey: "group",
+ limit: 0,
+ entry: { sender: "A", body: "one" },
+ });
+ expect(historyMap.get("group")).toEqual(undefined);
+
+ recordPendingHistoryEntryIfEnabled({
+ historyMap,
+ historyKey: "group",
+ limit: 2,
+ entry: null,
+ });
+ expect(historyMap.get("group")).toEqual(undefined);
+
+ recordPendingHistoryEntryIfEnabled({
+ historyMap,
+ historyKey: "group",
+ limit: 2,
+ entry: { sender: "B", body: "two" },
+ });
+ expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["two"]);
+ });
+
+ it("clears history entries only when enabled", () => {
+ const historyMap = new Map();
+ historyMap.set("group", [
+ { sender: "A", body: "one" },
+ { sender: "B", body: "two" },
+ ]);
+
+ clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 0 });
+ expect(historyMap.get("group")?.map((entry) => entry.body)).toEqual(["one", "two"]);
+
+ clearHistoryEntriesIfEnabled({ historyMap, historyKey: "group", limit: 2 });
+ expect(historyMap.get("group")).toEqual([]);
+ });
});
diff --git a/src/auto-reply/reply/history.ts b/src/auto-reply/reply/history.ts
index b51e27da4..bc59b4f2e 100644
--- a/src/auto-reply/reply/history.ts
+++ b/src/auto-reply/reply/history.ts
@@ -47,6 +47,22 @@ export function recordPendingHistoryEntry(params: {
return appendHistoryEntry(params);
}
+export function recordPendingHistoryEntryIfEnabled(params: {
+ historyMap: Map;
+ historyKey: string;
+ entry?: T | null;
+ limit: number;
+}): T[] {
+ if (!params.entry) return [];
+ if (params.limit <= 0) return [];
+ return recordPendingHistoryEntry({
+ historyMap: params.historyMap,
+ historyKey: params.historyKey,
+ entry: params.entry,
+ limit: params.limit,
+ });
+}
+
export function buildPendingHistoryContextFromMap(params: {
historyMap: Map;
historyKey: string;
@@ -101,6 +117,15 @@ export function clearHistoryEntries(params: {
params.historyMap.set(params.historyKey, []);
}
+export function clearHistoryEntriesIfEnabled(params: {
+ historyMap: Map;
+ historyKey: string;
+ limit: number;
+}): void {
+ if (params.limit <= 0) return;
+ clearHistoryEntries({ historyMap: params.historyMap, historyKey: params.historyKey });
+}
+
export function buildHistoryContextFromEntries(params: {
entries: HistoryEntry[];
currentMessage: string;
diff --git a/src/auto-reply/reply/provider-dispatcher.ts b/src/auto-reply/reply/provider-dispatcher.ts
index 68e2431d1..e4766156e 100644
--- a/src/auto-reply/reply/provider-dispatcher.ts
+++ b/src/auto-reply/reply/provider-dispatcher.ts
@@ -1,58 +1,44 @@
import type { ClawdbotConfig } from "../../config/config.js";
-import type { FinalizedMsgContext } from "../templating.js";
+import type { FinalizedMsgContext, MsgContext } from "../templating.js";
import type { GetReplyOptions } from "../types.js";
-import type { DispatchFromConfigResult } from "./dispatch-from-config.js";
-import { dispatchReplyFromConfig } from "./dispatch-from-config.js";
+import type { DispatchInboundResult } from "../dispatch.js";
import {
- createReplyDispatcher,
- createReplyDispatcherWithTyping,
- type ReplyDispatcherOptions,
- type ReplyDispatcherWithTypingOptions,
+ dispatchInboundMessageWithBufferedDispatcher,
+ dispatchInboundMessageWithDispatcher,
+} from "../dispatch.js";
+import type {
+ ReplyDispatcherOptions,
+ ReplyDispatcherWithTypingOptions,
} from "./reply-dispatcher.js";
export async function dispatchReplyWithBufferedBlockDispatcher(params: {
- ctx: FinalizedMsgContext;
+ ctx: MsgContext | FinalizedMsgContext;
cfg: ClawdbotConfig;
dispatcherOptions: ReplyDispatcherWithTypingOptions;
replyOptions?: Omit;
replyResolver?: typeof import("../reply.js").getReplyFromConfig;
-}): Promise {
- const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping(
- params.dispatcherOptions,
- );
-
- const result = await dispatchReplyFromConfig({
+}): Promise {
+ return await dispatchInboundMessageWithBufferedDispatcher({
ctx: params.ctx,
cfg: params.cfg,
- dispatcher,
+ dispatcherOptions: params.dispatcherOptions,
replyResolver: params.replyResolver,
- replyOptions: {
- ...params.replyOptions,
- ...replyOptions,
- },
+ replyOptions: params.replyOptions,
});
-
- markDispatchIdle();
- return result;
}
export async function dispatchReplyWithDispatcher(params: {
- ctx: FinalizedMsgContext;
+ ctx: MsgContext | FinalizedMsgContext;
cfg: ClawdbotConfig;
dispatcherOptions: ReplyDispatcherOptions;
replyOptions?: Omit;
replyResolver?: typeof import("../reply.js").getReplyFromConfig;
-}): Promise {
- const dispatcher = createReplyDispatcher(params.dispatcherOptions);
-
- const result = await dispatchReplyFromConfig({
+}): Promise {
+ return await dispatchInboundMessageWithDispatcher({
ctx: params.ctx,
cfg: params.cfg,
- dispatcher,
+ dispatcherOptions: params.dispatcherOptions,
replyResolver: params.replyResolver,
replyOptions: params.replyOptions,
});
-
- await dispatcher.waitForIdle();
- return result;
}
diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts
index e1bf611db..250c14091 100644
--- a/src/auto-reply/types.ts
+++ b/src/auto-reply/types.ts
@@ -1,3 +1,4 @@
+import type { ImageContent } from "@mariozechner/pi-ai";
import type { TypingController } from "./reply/typing.js";
export type BlockReplyContext = {
@@ -13,6 +14,14 @@ export type ModelSelectedContext = {
};
export type GetReplyOptions = {
+ /** Override run id for agent events (defaults to random UUID). */
+ runId?: string;
+ /** Abort signal for the underlying agent run. */
+ abortSignal?: AbortSignal;
+ /** Optional inbound images (used for webchat attachments). */
+ images?: ImageContent[];
+ /** Notifies when an agent run actually starts (useful for webchat command handling). */
+ onAgentRunStart?: (runId: string) => void;
onReplyStart?: () => Promise | void;
onTypingController?: (typing: TypingController) => void;
isHeartbeat?: boolean;
diff --git a/src/channels/ack-reactions.test.ts b/src/channels/ack-reactions.test.ts
new file mode 100644
index 000000000..ed018ba5a
--- /dev/null
+++ b/src/channels/ack-reactions.test.ts
@@ -0,0 +1,269 @@
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ removeAckReactionAfterReply,
+ shouldAckReaction,
+ shouldAckReactionForWhatsApp,
+} from "./ack-reactions.js";
+
+describe("shouldAckReaction", () => {
+ it("honors direct and group-all scopes", () => {
+ expect(
+ shouldAckReaction({
+ scope: "direct",
+ isDirect: true,
+ isGroup: false,
+ isMentionableGroup: false,
+ requireMention: false,
+ canDetectMention: false,
+ effectiveWasMentioned: false,
+ }),
+ ).toBe(true);
+
+ expect(
+ shouldAckReaction({
+ scope: "group-all",
+ isDirect: false,
+ isGroup: true,
+ isMentionableGroup: true,
+ requireMention: false,
+ canDetectMention: false,
+ effectiveWasMentioned: false,
+ }),
+ ).toBe(true);
+ });
+
+ it("skips when scope is off or none", () => {
+ expect(
+ shouldAckReaction({
+ scope: "off",
+ isDirect: true,
+ isGroup: true,
+ isMentionableGroup: true,
+ requireMention: true,
+ canDetectMention: true,
+ effectiveWasMentioned: true,
+ }),
+ ).toBe(false);
+
+ expect(
+ shouldAckReaction({
+ scope: "none",
+ isDirect: true,
+ isGroup: true,
+ isMentionableGroup: true,
+ requireMention: true,
+ canDetectMention: true,
+ effectiveWasMentioned: true,
+ }),
+ ).toBe(false);
+ });
+
+ it("defaults to group-mentions gating", () => {
+ expect(
+ shouldAckReaction({
+ scope: undefined,
+ isDirect: false,
+ isGroup: true,
+ isMentionableGroup: true,
+ requireMention: true,
+ canDetectMention: true,
+ effectiveWasMentioned: true,
+ }),
+ ).toBe(true);
+ });
+
+ it("requires mention gating for group-mentions", () => {
+ expect(
+ shouldAckReaction({
+ scope: "group-mentions",
+ isDirect: false,
+ isGroup: true,
+ isMentionableGroup: true,
+ requireMention: false,
+ canDetectMention: true,
+ effectiveWasMentioned: true,
+ }),
+ ).toBe(false);
+
+ expect(
+ shouldAckReaction({
+ scope: "group-mentions",
+ isDirect: false,
+ isGroup: true,
+ isMentionableGroup: true,
+ requireMention: true,
+ canDetectMention: false,
+ effectiveWasMentioned: true,
+ }),
+ ).toBe(false);
+
+ expect(
+ shouldAckReaction({
+ scope: "group-mentions",
+ isDirect: false,
+ isGroup: true,
+ isMentionableGroup: false,
+ requireMention: true,
+ canDetectMention: true,
+ effectiveWasMentioned: true,
+ }),
+ ).toBe(false);
+
+ expect(
+ shouldAckReaction({
+ scope: "group-mentions",
+ isDirect: false,
+ isGroup: true,
+ isMentionableGroup: true,
+ requireMention: true,
+ canDetectMention: true,
+ effectiveWasMentioned: true,
+ }),
+ ).toBe(true);
+
+ expect(
+ shouldAckReaction({
+ scope: "group-mentions",
+ isDirect: false,
+ isGroup: true,
+ isMentionableGroup: true,
+ requireMention: true,
+ canDetectMention: true,
+ effectiveWasMentioned: false,
+ shouldBypassMention: true,
+ }),
+ ).toBe(true);
+ });
+});
+
+describe("shouldAckReactionForWhatsApp", () => {
+ it("respects direct and group modes", () => {
+ expect(
+ shouldAckReactionForWhatsApp({
+ emoji: "👀",
+ isDirect: true,
+ isGroup: false,
+ directEnabled: true,
+ groupMode: "mentions",
+ wasMentioned: false,
+ groupActivated: false,
+ }),
+ ).toBe(true);
+
+ expect(
+ shouldAckReactionForWhatsApp({
+ emoji: "👀",
+ isDirect: true,
+ isGroup: false,
+ directEnabled: false,
+ groupMode: "mentions",
+ wasMentioned: false,
+ groupActivated: false,
+ }),
+ ).toBe(false);
+
+ expect(
+ shouldAckReactionForWhatsApp({
+ emoji: "👀",
+ isDirect: false,
+ isGroup: true,
+ directEnabled: true,
+ groupMode: "always",
+ wasMentioned: false,
+ groupActivated: false,
+ }),
+ ).toBe(true);
+
+ expect(
+ shouldAckReactionForWhatsApp({
+ emoji: "👀",
+ isDirect: false,
+ isGroup: true,
+ directEnabled: true,
+ groupMode: "never",
+ wasMentioned: true,
+ groupActivated: true,
+ }),
+ ).toBe(false);
+ });
+
+ it("honors mentions or activation for group-mentions", () => {
+ expect(
+ shouldAckReactionForWhatsApp({
+ emoji: "👀",
+ isDirect: false,
+ isGroup: true,
+ directEnabled: true,
+ groupMode: "mentions",
+ wasMentioned: true,
+ groupActivated: false,
+ }),
+ ).toBe(true);
+
+ expect(
+ shouldAckReactionForWhatsApp({
+ emoji: "👀",
+ isDirect: false,
+ isGroup: true,
+ directEnabled: true,
+ groupMode: "mentions",
+ wasMentioned: false,
+ groupActivated: true,
+ }),
+ ).toBe(true);
+
+ expect(
+ shouldAckReactionForWhatsApp({
+ emoji: "👀",
+ isDirect: false,
+ isGroup: true,
+ directEnabled: true,
+ groupMode: "mentions",
+ wasMentioned: false,
+ groupActivated: false,
+ }),
+ ).toBe(false);
+ });
+});
+
+describe("removeAckReactionAfterReply", () => {
+ it("removes only when ack succeeded", async () => {
+ const remove = vi.fn().mockResolvedValue(undefined);
+ const onError = vi.fn();
+ removeAckReactionAfterReply({
+ removeAfterReply: true,
+ ackReactionPromise: Promise.resolve(true),
+ ackReactionValue: "👀",
+ remove,
+ onError,
+ });
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(remove).toHaveBeenCalledTimes(1);
+ expect(onError).not.toHaveBeenCalled();
+ });
+
+ it("skips removal when ack did not happen", async () => {
+ const remove = vi.fn().mockResolvedValue(undefined);
+ removeAckReactionAfterReply({
+ removeAfterReply: true,
+ ackReactionPromise: Promise.resolve(false),
+ ackReactionValue: "👀",
+ remove,
+ });
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(remove).not.toHaveBeenCalled();
+ });
+
+ it("skips when not configured", async () => {
+ const remove = vi.fn().mockResolvedValue(undefined);
+ removeAckReactionAfterReply({
+ removeAfterReply: false,
+ ackReactionPromise: Promise.resolve(true),
+ ackReactionValue: "👀",
+ remove,
+ });
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(remove).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/channels/ack-reactions.ts b/src/channels/ack-reactions.ts
new file mode 100644
index 000000000..f35ae76d8
--- /dev/null
+++ b/src/channels/ack-reactions.ts
@@ -0,0 +1,71 @@
+export type AckReactionScope = "all" | "direct" | "group-all" | "group-mentions" | "off" | "none";
+
+export type WhatsAppAckReactionMode = "always" | "mentions" | "never";
+
+export type AckReactionGateParams = {
+ scope: AckReactionScope | undefined;
+ isDirect: boolean;
+ isGroup: boolean;
+ isMentionableGroup: boolean;
+ requireMention: boolean;
+ canDetectMention: boolean;
+ effectiveWasMentioned: boolean;
+ shouldBypassMention?: boolean;
+};
+
+export function shouldAckReaction(params: AckReactionGateParams): boolean {
+ const scope = params.scope ?? "group-mentions";
+ if (scope === "off" || scope === "none") return false;
+ if (scope === "all") return true;
+ if (scope === "direct") return params.isDirect;
+ if (scope === "group-all") return params.isGroup;
+ if (scope === "group-mentions") {
+ if (!params.isMentionableGroup) return false;
+ if (!params.requireMention) return false;
+ if (!params.canDetectMention) return false;
+ return params.effectiveWasMentioned || params.shouldBypassMention === true;
+ }
+ return false;
+}
+
+export function shouldAckReactionForWhatsApp(params: {
+ emoji: string;
+ isDirect: boolean;
+ isGroup: boolean;
+ directEnabled: boolean;
+ groupMode: WhatsAppAckReactionMode;
+ wasMentioned: boolean;
+ groupActivated: boolean;
+}): boolean {
+ if (!params.emoji) return false;
+ if (params.isDirect) return params.directEnabled;
+ if (!params.isGroup) return false;
+ if (params.groupMode === "never") return false;
+ if (params.groupMode === "always") return true;
+ return shouldAckReaction({
+ scope: "group-mentions",
+ isDirect: false,
+ isGroup: true,
+ isMentionableGroup: true,
+ requireMention: true,
+ canDetectMention: true,
+ effectiveWasMentioned: params.wasMentioned,
+ shouldBypassMention: params.groupActivated,
+ });
+}
+
+export function removeAckReactionAfterReply(params: {
+ removeAfterReply: boolean;
+ ackReactionPromise: Promise | null;
+ ackReactionValue: string | null;
+ remove: () => Promise;
+ onError?: (err: unknown) => void;
+}) {
+ if (!params.removeAfterReply) return;
+ if (!params.ackReactionPromise) return;
+ if (!params.ackReactionValue) return;
+ void params.ackReactionPromise.then((didAck) => {
+ if (!didAck) return;
+ params.remove().catch((err) => params.onError?.(err));
+ });
+}
diff --git a/src/channels/logging.ts b/src/channels/logging.ts
new file mode 100644
index 000000000..0e124a14d
--- /dev/null
+++ b/src/channels/logging.ts
@@ -0,0 +1,33 @@
+export type LogFn = (message: string) => void;
+
+export function logInboundDrop(params: {
+ log: LogFn;
+ channel: string;
+ reason: string;
+ target?: string;
+}): void {
+ const target = params.target ? ` target=${params.target}` : "";
+ params.log(`${params.channel}: drop ${params.reason}${target}`);
+}
+
+export function logTypingFailure(params: {
+ log: LogFn;
+ channel: string;
+ target?: string;
+ action?: "start" | "stop";
+ error: unknown;
+}): void {
+ const target = params.target ? ` target=${params.target}` : "";
+ const action = params.action ? ` action=${params.action}` : "";
+ params.log(`${params.channel} typing${action} failed${target}: ${String(params.error)}`);
+}
+
+export function logAckFailure(params: {
+ log: LogFn;
+ channel: string;
+ target?: string;
+ error: unknown;
+}): void {
+ const target = params.target ? ` target=${params.target}` : "";
+ params.log(`${params.channel} ack cleanup failed${target}: ${String(params.error)}`);
+}
diff --git a/src/channels/reply-prefix.ts b/src/channels/reply-prefix.ts
new file mode 100644
index 000000000..4897426d0
--- /dev/null
+++ b/src/channels/reply-prefix.ts
@@ -0,0 +1,41 @@
+import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../agents/identity.js";
+import type { ClawdbotConfig } from "../config/config.js";
+import type { GetReplyOptions } from "../auto-reply/types.js";
+import {
+ extractShortModelName,
+ type ResponsePrefixContext,
+} from "../auto-reply/reply/response-prefix-template.js";
+
+type ModelSelectionContext = Parameters>[0];
+
+export type ReplyPrefixContextBundle = {
+ prefixContext: ResponsePrefixContext;
+ responsePrefix?: string;
+ responsePrefixContextProvider: () => ResponsePrefixContext;
+ onModelSelected: (ctx: ModelSelectionContext) => void;
+};
+
+export function createReplyPrefixContext(params: {
+ cfg: ClawdbotConfig;
+ agentId: string;
+}): ReplyPrefixContextBundle {
+ const { cfg, agentId } = params;
+ const prefixContext: ResponsePrefixContext = {
+ identityName: resolveIdentityName(cfg, agentId),
+ };
+
+ const onModelSelected = (ctx: ModelSelectionContext) => {
+ // Mutate the object directly instead of reassigning to ensure closures see updates.
+ prefixContext.provider = ctx.provider;
+ prefixContext.model = extractShortModelName(ctx.model);
+ prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
+ prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
+ };
+
+ return {
+ prefixContext,
+ responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix,
+ responsePrefixContextProvider: () => prefixContext,
+ onModelSelected,
+ };
+}
diff --git a/src/channels/session.ts b/src/channels/session.ts
new file mode 100644
index 000000000..2d34d7f74
--- /dev/null
+++ b/src/channels/session.ts
@@ -0,0 +1,49 @@
+import type { MsgContext } from "../auto-reply/templating.js";
+import {
+ recordSessionMetaFromInbound,
+ type GroupKeyResolution,
+ type SessionEntry,
+ updateLastRoute,
+} from "../config/sessions.js";
+
+export type InboundLastRouteUpdate = {
+ sessionKey: string;
+ channel: SessionEntry["lastChannel"];
+ to: string;
+ accountId?: string;
+ threadId?: string | number;
+};
+
+export async function recordInboundSession(params: {
+ storePath: string;
+ sessionKey: string;
+ ctx: MsgContext;
+ groupResolution?: GroupKeyResolution | null;
+ createIfMissing?: boolean;
+ updateLastRoute?: InboundLastRouteUpdate;
+ onRecordError: (err: unknown) => void;
+}): Promise {
+ const { storePath, sessionKey, ctx, groupResolution, createIfMissing } = params;
+ void recordSessionMetaFromInbound({
+ storePath,
+ sessionKey,
+ ctx,
+ groupResolution,
+ createIfMissing,
+ }).catch(params.onRecordError);
+
+ const update = params.updateLastRoute;
+ if (!update) return;
+ await updateLastRoute({
+ storePath,
+ sessionKey: update.sessionKey,
+ deliveryContext: {
+ channel: update.channel,
+ to: update.to,
+ accountId: update.accountId,
+ threadId: update.threadId,
+ },
+ ctx,
+ groupResolution,
+ });
+}
diff --git a/src/channels/typing.test.ts b/src/channels/typing.test.ts
new file mode 100644
index 000000000..42080b3c1
--- /dev/null
+++ b/src/channels/typing.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { createTypingCallbacks } from "./typing.js";
+
+const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+describe("createTypingCallbacks", () => {
+ it("invokes start on reply start", async () => {
+ const start = vi.fn().mockResolvedValue(undefined);
+ const onStartError = vi.fn();
+ const callbacks = createTypingCallbacks({ start, onStartError });
+
+ await callbacks.onReplyStart();
+
+ expect(start).toHaveBeenCalledTimes(1);
+ expect(onStartError).not.toHaveBeenCalled();
+ });
+
+ it("reports start errors", async () => {
+ const start = vi.fn().mockRejectedValue(new Error("fail"));
+ const onStartError = vi.fn();
+ const callbacks = createTypingCallbacks({ start, onStartError });
+
+ await callbacks.onReplyStart();
+
+ expect(onStartError).toHaveBeenCalledTimes(1);
+ });
+
+ it("invokes stop on idle and reports stop errors", async () => {
+ const start = vi.fn().mockResolvedValue(undefined);
+ const stop = vi.fn().mockRejectedValue(new Error("stop"));
+ const onStartError = vi.fn();
+ const onStopError = vi.fn();
+ const callbacks = createTypingCallbacks({ start, stop, onStartError, onStopError });
+
+ callbacks.onIdle?.();
+ await flush();
+
+ expect(stop).toHaveBeenCalledTimes(1);
+ expect(onStopError).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/channels/typing.ts b/src/channels/typing.ts
new file mode 100644
index 000000000..f24c7f188
--- /dev/null
+++ b/src/channels/typing.ts
@@ -0,0 +1,28 @@
+export type TypingCallbacks = {
+ onReplyStart: () => Promise;
+ onIdle?: () => void;
+};
+
+export function createTypingCallbacks(params: {
+ start: () => Promise;
+ stop?: () => Promise;
+ onStartError: (err: unknown) => void;
+ onStopError?: (err: unknown) => void;
+}): TypingCallbacks {
+ const stop = params.stop;
+ const onReplyStart = async () => {
+ try {
+ await params.start();
+ } catch (err) {
+ params.onStartError(err);
+ }
+ };
+
+ const onIdle = stop
+ ? () => {
+ void stop().catch((err) => (params.onStopError ?? params.onStartError)(err));
+ }
+ : undefined;
+
+ return { onReplyStart, onIdle };
+}
diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts
index a2674d94a..20a476f81 100644
--- a/src/cli/models-cli.ts
+++ b/src/cli/models-cli.ts
@@ -71,9 +71,36 @@ export function registerModelsCli(program: Command) {
"Exit non-zero if auth is expiring/expired (1=expired/missing, 2=expiring)",
false,
)
+ .option("--probe", "Probe configured provider auth (live)", false)
+ .option("--probe-provider ", "Only probe a single provider")
+ .option(
+ "--probe-profile ",
+ "Only probe specific auth profile ids (repeat or comma-separated)",
+ (value, previous) => {
+ const next = Array.isArray(previous) ? previous : previous ? [previous] : [];
+ next.push(value);
+ return next;
+ },
+ )
+ .option("--probe-timeout ", "Per-probe timeout in ms")
+ .option("--probe-concurrency ", "Concurrent probes")
+ .option("--probe-max-tokens ", "Probe max tokens (best-effort)")
.action(async (opts) => {
await runModelsCommand(async () => {
- await modelsStatusCommand(opts, defaultRuntime);
+ await modelsStatusCommand(
+ {
+ json: Boolean(opts.json),
+ plain: Boolean(opts.plain),
+ check: Boolean(opts.check),
+ probe: Boolean(opts.probe),
+ probeProvider: opts.probeProvider as string | undefined,
+ probeProfile: opts.probeProfile as string | string[] | undefined,
+ probeTimeout: opts.probeTimeout as string | undefined,
+ probeConcurrency: opts.probeConcurrency as string | undefined,
+ probeMaxTokens: opts.probeMaxTokens as string | undefined,
+ },
+ defaultRuntime,
+ );
});
});
diff --git a/src/commands/models.list.test.ts b/src/commands/models.list.test.ts
index 47ebfe2f5..850f27246 100644
--- a/src/commands/models.list.test.ts
+++ b/src/commands/models.list.test.ts
@@ -17,6 +17,7 @@ const discoverModels = vi.fn();
vi.mock("../config/config.js", () => ({
CONFIG_PATH_CLAWDBOT: "/tmp/clawdbot.json",
+ STATE_DIR_CLAWDBOT: "/tmp/clawdbot-state",
loadConfig,
}));
diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts
new file mode 100644
index 000000000..fbd172b57
--- /dev/null
+++ b/src/commands/models/list.probe.ts
@@ -0,0 +1,414 @@
+import crypto from "node:crypto";
+import fs from "node:fs/promises";
+
+import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
+import {
+ ensureAuthProfileStore,
+ listProfilesForProvider,
+ resolveAuthProfileDisplayLabel,
+} from "../../agents/auth-profiles.js";
+import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
+import { describeFailoverError } from "../../agents/failover-error.js";
+import { loadModelCatalog } from "../../agents/model-catalog.js";
+import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
+import { normalizeProviderId, parseModelRef } from "../../agents/model-selection.js";
+import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
+import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
+import type { ClawdbotConfig } from "../../config/config.js";
+import {
+ resolveSessionTranscriptPath,
+ resolveSessionTranscriptsDirForAgent,
+} from "../../config/sessions/paths.js";
+import { redactSecrets } from "../status-all/format.js";
+import { DEFAULT_PROVIDER, formatMs } from "./shared.js";
+
+const PROBE_PROMPT = "Reply with OK. Do not use tools.";
+
+export type AuthProbeStatus =
+ | "ok"
+ | "auth"
+ | "rate_limit"
+ | "billing"
+ | "timeout"
+ | "format"
+ | "unknown"
+ | "no_model";
+
+export type AuthProbeResult = {
+ provider: string;
+ model?: string;
+ profileId?: string;
+ label: string;
+ source: "profile" | "env" | "models.json";
+ mode?: string;
+ status: AuthProbeStatus;
+ error?: string;
+ latencyMs?: number;
+};
+
+type AuthProbeTarget = {
+ provider: string;
+ model?: { provider: string; model: string } | null;
+ profileId?: string;
+ label: string;
+ source: "profile" | "env" | "models.json";
+ mode?: string;
+};
+
+export type AuthProbeSummary = {
+ startedAt: number;
+ finishedAt: number;
+ durationMs: number;
+ totalTargets: number;
+ options: {
+ provider?: string;
+ profileIds?: string[];
+ timeoutMs: number;
+ concurrency: number;
+ maxTokens: number;
+ };
+ results: AuthProbeResult[];
+};
+
+export type AuthProbeOptions = {
+ provider?: string;
+ profileIds?: string[];
+ timeoutMs: number;
+ concurrency: number;
+ maxTokens: number;
+};
+
+const toStatus = (reason?: string | null): AuthProbeStatus => {
+ if (!reason) return "unknown";
+ if (reason === "auth") return "auth";
+ if (reason === "rate_limit") return "rate_limit";
+ if (reason === "billing") return "billing";
+ if (reason === "timeout") return "timeout";
+ if (reason === "format") return "format";
+ return "unknown";
+};
+
+function buildCandidateMap(modelCandidates: string[]): Map {
+ const map = new Map();
+ for (const raw of modelCandidates) {
+ const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER);
+ if (!parsed) continue;
+ const list = map.get(parsed.provider) ?? [];
+ if (!list.includes(parsed.model)) list.push(parsed.model);
+ map.set(parsed.provider, list);
+ }
+ return map;
+}
+
+function selectProbeModel(params: {
+ provider: string;
+ candidates: Map;
+ catalog: Array<{ provider: string; id: string }>;
+}): { provider: string; model: string } | null {
+ const { provider, candidates, catalog } = params;
+ const direct = candidates.get(provider);
+ if (direct && direct.length > 0) {
+ return { provider, model: direct[0] };
+ }
+ const fromCatalog = catalog.find((entry) => entry.provider === provider);
+ if (fromCatalog) return { provider: fromCatalog.provider, model: fromCatalog.id };
+ return null;
+}
+
+function buildProbeTargets(params: {
+ cfg: ClawdbotConfig;
+ providers: string[];
+ modelCandidates: string[];
+ options: AuthProbeOptions;
+}): Promise<{ targets: AuthProbeTarget[]; results: AuthProbeResult[] }> {
+ const { cfg, providers, modelCandidates, options } = params;
+ const store = ensureAuthProfileStore();
+ const providerFilter = options.provider?.trim();
+ const providerFilterKey = providerFilter ? normalizeProviderId(providerFilter) : null;
+ const profileFilter = new Set((options.profileIds ?? []).map((id) => id.trim()).filter(Boolean));
+
+ return loadModelCatalog({ config: cfg }).then((catalog) => {
+ const candidates = buildCandidateMap(modelCandidates);
+ const targets: AuthProbeTarget[] = [];
+ const results: AuthProbeResult[] = [];
+
+ for (const provider of providers) {
+ const providerKey = normalizeProviderId(provider);
+ if (providerFilterKey && providerKey !== providerFilterKey) continue;
+
+ const model = selectProbeModel({
+ provider: providerKey,
+ candidates,
+ catalog,
+ });
+
+ const profileIds = listProfilesForProvider(store, providerKey);
+ const filteredProfiles = profileFilter.size
+ ? profileIds.filter((id) => profileFilter.has(id))
+ : profileIds;
+
+ if (filteredProfiles.length > 0) {
+ for (const profileId of filteredProfiles) {
+ const profile = store.profiles[profileId];
+ const mode = profile?.type;
+ const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
+ if (!model) {
+ results.push({
+ provider: providerKey,
+ model: undefined,
+ profileId,
+ label,
+ source: "profile",
+ mode,
+ status: "no_model",
+ error: "No model available for probe",
+ });
+ continue;
+ }
+ targets.push({
+ provider: providerKey,
+ model,
+ profileId,
+ label,
+ source: "profile",
+ mode,
+ });
+ }
+ continue;
+ }
+
+ if (profileFilter.size > 0) continue;
+
+ const envKey = resolveEnvApiKey(providerKey);
+ const customKey = getCustomProviderApiKey(cfg, providerKey);
+ if (!envKey && !customKey) continue;
+
+ const label = envKey ? "env" : "models.json";
+ const source = envKey ? "env" : "models.json";
+ const mode = envKey?.source.includes("OAUTH_TOKEN") ? "oauth" : "api_key";
+
+ if (!model) {
+ results.push({
+ provider: providerKey,
+ model: undefined,
+ label,
+ source,
+ mode,
+ status: "no_model",
+ error: "No model available for probe",
+ });
+ continue;
+ }
+
+ targets.push({
+ provider: providerKey,
+ model,
+ label,
+ source,
+ mode,
+ });
+ }
+
+ return { targets, results };
+ });
+}
+
+async function probeTarget(params: {
+ cfg: ClawdbotConfig;
+ agentId: string;
+ agentDir: string;
+ workspaceDir: string;
+ sessionDir: string;
+ target: AuthProbeTarget;
+ timeoutMs: number;
+ maxTokens: number;
+}): Promise {
+ const { cfg, agentId, agentDir, workspaceDir, sessionDir, target, timeoutMs, maxTokens } = params;
+ if (!target.model) {
+ return {
+ provider: target.provider,
+ model: undefined,
+ profileId: target.profileId,
+ label: target.label,
+ source: target.source,
+ mode: target.mode,
+ status: "no_model",
+ error: "No model available for probe",
+ };
+ }
+
+ const sessionId = `probe-${target.provider}-${crypto.randomUUID()}`;
+ const sessionFile = resolveSessionTranscriptPath(sessionId, agentId);
+ await fs.mkdir(sessionDir, { recursive: true });
+
+ const start = Date.now();
+ try {
+ await runEmbeddedPiAgent({
+ sessionId,
+ sessionFile,
+ workspaceDir,
+ agentDir,
+ config: cfg,
+ prompt: PROBE_PROMPT,
+ provider: target.model.provider,
+ model: target.model.model,
+ authProfileId: target.profileId,
+ authProfileIdSource: target.profileId ? "user" : undefined,
+ timeoutMs,
+ runId: `probe-${crypto.randomUUID()}`,
+ lane: `auth-probe:${target.provider}:${target.profileId ?? target.source}`,
+ thinkLevel: "off",
+ reasoningLevel: "off",
+ verboseLevel: "off",
+ streamParams: { maxTokens },
+ });
+ return {
+ provider: target.provider,
+ model: `${target.model.provider}/${target.model.model}`,
+ profileId: target.profileId,
+ label: target.label,
+ source: target.source,
+ mode: target.mode,
+ status: "ok",
+ latencyMs: Date.now() - start,
+ };
+ } catch (err) {
+ const described = describeFailoverError(err);
+ return {
+ provider: target.provider,
+ model: `${target.model.provider}/${target.model.model}`,
+ profileId: target.profileId,
+ label: target.label,
+ source: target.source,
+ mode: target.mode,
+ status: toStatus(described.reason),
+ error: redactSecrets(described.message),
+ latencyMs: Date.now() - start,
+ };
+ }
+}
+
+async function runTargetsWithConcurrency(params: {
+ cfg: ClawdbotConfig;
+ targets: AuthProbeTarget[];
+ timeoutMs: number;
+ maxTokens: number;
+ concurrency: number;
+ onProgress?: (update: { completed: number; total: number; label?: string }) => void;
+}): Promise {
+ const { cfg, targets, timeoutMs, maxTokens, onProgress } = params;
+ const concurrency = Math.max(1, Math.min(targets.length || 1, params.concurrency));
+
+ const agentId = resolveDefaultAgentId(cfg);
+ const agentDir = resolveClawdbotAgentDir();
+ const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId) ?? resolveDefaultAgentWorkspaceDir();
+ const sessionDir = resolveSessionTranscriptsDirForAgent(agentId);
+
+ await fs.mkdir(workspaceDir, { recursive: true });
+
+ let completed = 0;
+ const results: Array = Array.from({ length: targets.length });
+ let cursor = 0;
+
+ const worker = async () => {
+ while (true) {
+ const index = cursor;
+ cursor += 1;
+ if (index >= targets.length) return;
+ const target = targets[index];
+ onProgress?.({
+ completed,
+ total: targets.length,
+ label: `Probing ${target.provider}${target.profileId ? ` (${target.label})` : ""}`,
+ });
+ const result = await probeTarget({
+ cfg,
+ agentId,
+ agentDir,
+ workspaceDir,
+ sessionDir,
+ target,
+ timeoutMs,
+ maxTokens,
+ });
+ results[index] = result;
+ completed += 1;
+ onProgress?.({ completed, total: targets.length });
+ }
+ };
+
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
+
+ return results.filter((entry): entry is AuthProbeResult => Boolean(entry));
+}
+
+export async function runAuthProbes(params: {
+ cfg: ClawdbotConfig;
+ providers: string[];
+ modelCandidates: string[];
+ options: AuthProbeOptions;
+ onProgress?: (update: { completed: number; total: number; label?: string }) => void;
+}): Promise {
+ const startedAt = Date.now();
+ const plan = await buildProbeTargets({
+ cfg: params.cfg,
+ providers: params.providers,
+ modelCandidates: params.modelCandidates,
+ options: params.options,
+ });
+
+ const totalTargets = plan.targets.length;
+ params.onProgress?.({ completed: 0, total: totalTargets });
+
+ const results = totalTargets
+ ? await runTargetsWithConcurrency({
+ cfg: params.cfg,
+ targets: plan.targets,
+ timeoutMs: params.options.timeoutMs,
+ maxTokens: params.options.maxTokens,
+ concurrency: params.options.concurrency,
+ onProgress: params.onProgress,
+ })
+ : [];
+
+ const finishedAt = Date.now();
+
+ return {
+ startedAt,
+ finishedAt,
+ durationMs: finishedAt - startedAt,
+ totalTargets,
+ options: params.options,
+ results: [...plan.results, ...results],
+ };
+}
+
+export function formatProbeLatency(latencyMs?: number | null) {
+ if (!latencyMs && latencyMs !== 0) return "-";
+ return formatMs(latencyMs);
+}
+
+export function groupProbeResults(results: AuthProbeResult[]): Map {
+ const map = new Map();
+ for (const result of results) {
+ const list = map.get(result.provider) ?? [];
+ list.push(result);
+ map.set(result.provider, list);
+ }
+ return map;
+}
+
+export function sortProbeResults(results: AuthProbeResult[]): AuthProbeResult[] {
+ return results.slice().sort((a, b) => {
+ const provider = a.provider.localeCompare(b.provider);
+ if (provider !== 0) return provider;
+ const aLabel = a.label || a.profileId || "";
+ const bLabel = b.label || b.profileId || "";
+ return aLabel.localeCompare(bLabel);
+ });
+}
+
+export function describeProbeSummary(summary: AuthProbeSummary): string {
+ if (summary.totalTargets === 0) return "No probe targets.";
+ return `Probed ${summary.totalTargets} target${summary.totalTargets === 1 ? "" : "s"} in ${formatMs(summary.durationMs)}`;
+}
diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts
index 0bd8f16e9..6b8c8c36d 100644
--- a/src/commands/models/list.status-command.ts
+++ b/src/commands/models/list.status-command.ts
@@ -11,9 +11,15 @@ import {
resolveProfileUnusableUntilForDisplay,
} from "../../agents/auth-profiles.js";
import { resolveEnvApiKey } from "../../agents/model-auth.js";
-import { parseModelRef, resolveConfiguredModelRef } from "../../agents/model-selection.js";
+import {
+ buildModelAliasIndex,
+ parseModelRef,
+ resolveConfiguredModelRef,
+ resolveModelRefFromString,
+} from "../../agents/model-selection.js";
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js";
+import { withProgressTotals } from "../../cli/progress.js";
import {
formatUsageWindowSummary,
loadProviderUsageSummary,
@@ -22,17 +28,38 @@ import {
} from "../../infra/provider-usage.js";
import type { RuntimeEnv } from "../../runtime.js";
import { colorize, theme } from "../../terminal/theme.js";
+import { renderTable } from "../../terminal/table.js";
import { formatCliCommand } from "../../cli/command-format.js";
import { shortenHomePath } from "../../utils.js";
import { resolveProviderAuthOverview } from "./list.auth-overview.js";
import { isRich } from "./list.format.js";
+import {
+ describeProbeSummary,
+ formatProbeLatency,
+ runAuthProbes,
+ sortProbeResults,
+ type AuthProbeSummary,
+} from "./list.probe.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js";
export async function modelsStatusCommand(
- opts: { json?: boolean; plain?: boolean; check?: boolean },
+ opts: {
+ json?: boolean;
+ plain?: boolean;
+ check?: boolean;
+ probe?: boolean;
+ probeProvider?: string;
+ probeProfile?: string | string[];
+ probeTimeout?: string;
+ probeConcurrency?: string;
+ probeMaxTokens?: string;
+ },
runtime: RuntimeEnv,
) {
ensureFlagCompatibility(opts);
+ if (opts.plain && opts.probe) {
+ throw new Error("--probe cannot be used with --plain output.");
+ }
const cfg = loadConfig();
const resolved = resolveConfiguredModelRef({
cfg,
@@ -139,6 +166,69 @@ export async function modelsStatusCommand(
.filter((provider) => !providerAuthMap.has(provider))
.sort((a, b) => a.localeCompare(b));
+ const probeProfileIds = (() => {
+ if (!opts.probeProfile) return [];
+ const raw = Array.isArray(opts.probeProfile) ? opts.probeProfile : [opts.probeProfile];
+ return raw
+ .flatMap((value) => String(value ?? "").split(","))
+ .map((value) => value.trim())
+ .filter(Boolean);
+ })();
+ const probeTimeoutMs = opts.probeTimeout ? Number(opts.probeTimeout) : 8000;
+ if (!Number.isFinite(probeTimeoutMs) || probeTimeoutMs <= 0) {
+ throw new Error("--probe-timeout must be a positive number (ms).");
+ }
+ const probeConcurrency = opts.probeConcurrency ? Number(opts.probeConcurrency) : 2;
+ if (!Number.isFinite(probeConcurrency) || probeConcurrency <= 0) {
+ throw new Error("--probe-concurrency must be > 0.");
+ }
+ const probeMaxTokens = opts.probeMaxTokens ? Number(opts.probeMaxTokens) : 8;
+ if (!Number.isFinite(probeMaxTokens) || probeMaxTokens <= 0) {
+ throw new Error("--probe-max-tokens must be > 0.");
+ }
+
+ const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: DEFAULT_PROVIDER });
+ const rawCandidates = [
+ rawModel || resolvedLabel,
+ ...fallbacks,
+ imageModel,
+ ...imageFallbacks,
+ ...allowed,
+ ].filter(Boolean);
+ const resolvedCandidates = rawCandidates
+ .map(
+ (raw) =>
+ resolveModelRefFromString({
+ raw: String(raw ?? ""),
+ defaultProvider: DEFAULT_PROVIDER,
+ aliasIndex,
+ })?.ref,
+ )
+ .filter((ref): ref is { provider: string; model: string } => Boolean(ref));
+ const modelCandidates = resolvedCandidates.map((ref) => `${ref.provider}/${ref.model}`);
+
+ let probeSummary: AuthProbeSummary | undefined;
+ if (opts.probe) {
+ probeSummary = await withProgressTotals(
+ { label: "Probing auth profiles…", total: 1 },
+ async (update) => {
+ return await runAuthProbes({
+ cfg,
+ providers,
+ modelCandidates,
+ options: {
+ provider: opts.probeProvider,
+ profileIds: probeProfileIds,
+ timeoutMs: probeTimeoutMs,
+ concurrency: probeConcurrency,
+ maxTokens: probeMaxTokens,
+ },
+ onProgress: update,
+ });
+ },
+ );
+ }
+
const providersWithOauth = providerAuth
.filter(
(entry) =>
@@ -228,6 +318,7 @@ export async function modelsStatusCommand(
profiles: authHealth.profiles,
providers: authHealth.providers,
},
+ probes: probeSummary,
},
},
null,
@@ -406,72 +497,118 @@ export async function modelsStatusCommand(
runtime.log(colorize(rich, theme.heading, "OAuth/token status"));
if (oauthProfiles.length === 0) {
runtime.log(colorize(rich, theme.muted, "- none"));
- return;
- }
-
- const usageByProvider = new Map();
- const usageProviders = Array.from(
- new Set(
- oauthProfiles
- .map((profile) => resolveUsageProviderId(profile.provider))
- .filter((provider): provider is UsageProviderId => Boolean(provider)),
- ),
- );
- if (usageProviders.length > 0) {
- try {
- const usageSummary = await loadProviderUsageSummary({
- providers: usageProviders,
- agentDir,
- timeoutMs: 3500,
- });
- for (const snapshot of usageSummary.providers) {
- const formatted = formatUsageWindowSummary(snapshot, {
- now: Date.now(),
- maxWindows: 2,
- includeResets: true,
+ } else {
+ const usageByProvider = new Map();
+ const usageProviders = Array.from(
+ new Set(
+ oauthProfiles
+ .map((profile) => resolveUsageProviderId(profile.provider))
+ .filter((provider): provider is UsageProviderId => Boolean(provider)),
+ ),
+ );
+ if (usageProviders.length > 0) {
+ try {
+ const usageSummary = await loadProviderUsageSummary({
+ providers: usageProviders,
+ agentDir,
+ timeoutMs: 3500,
});
- if (formatted) {
- usageByProvider.set(snapshot.provider, formatted);
+ for (const snapshot of usageSummary.providers) {
+ const formatted = formatUsageWindowSummary(snapshot, {
+ now: Date.now(),
+ maxWindows: 2,
+ includeResets: true,
+ });
+ if (formatted) {
+ usageByProvider.set(snapshot.provider, formatted);
+ }
}
+ } catch {
+ // ignore usage failures
+ }
+ }
+
+ const formatStatus = (status: string) => {
+ if (status === "ok") return colorize(rich, theme.success, "ok");
+ if (status === "static") return colorize(rich, theme.muted, "static");
+ if (status === "expiring") return colorize(rich, theme.warn, "expiring");
+ if (status === "missing") return colorize(rich, theme.warn, "unknown");
+ return colorize(rich, theme.error, "expired");
+ };
+
+ const profilesByProvider = new Map();
+ for (const profile of oauthProfiles) {
+ const current = profilesByProvider.get(profile.provider);
+ if (current) current.push(profile);
+ else profilesByProvider.set(profile.provider, [profile]);
+ }
+
+ for (const [provider, profiles] of profilesByProvider) {
+ const usageKey = resolveUsageProviderId(provider);
+ const usage = usageKey ? usageByProvider.get(usageKey) : undefined;
+ const usageSuffix = usage ? colorize(rich, theme.muted, ` usage: ${usage}`) : "";
+ runtime.log(`- ${colorize(rich, theme.heading, provider)}${usageSuffix}`);
+ for (const profile of profiles) {
+ const labelText = profile.label || profile.profileId;
+ const label = colorize(rich, theme.accent, labelText);
+ const status = formatStatus(profile.status);
+ const expiry =
+ profile.status === "static"
+ ? ""
+ : profile.expiresAt
+ ? ` expires in ${formatRemainingShort(profile.remainingMs)}`
+ : " expires unknown";
+ const source =
+ profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : "";
+ runtime.log(` - ${label} ${status}${expiry}${source}`);
}
- } catch {
- // ignore usage failures
}
}
- const formatStatus = (status: string) => {
- if (status === "ok") return colorize(rich, theme.success, "ok");
- if (status === "static") return colorize(rich, theme.muted, "static");
- if (status === "expiring") return colorize(rich, theme.warn, "expiring");
- if (status === "missing") return colorize(rich, theme.warn, "unknown");
- return colorize(rich, theme.error, "expired");
- };
-
- const profilesByProvider = new Map();
- for (const profile of oauthProfiles) {
- const current = profilesByProvider.get(profile.provider);
- if (current) current.push(profile);
- else profilesByProvider.set(profile.provider, [profile]);
- }
-
- for (const [provider, profiles] of profilesByProvider) {
- const usageKey = resolveUsageProviderId(provider);
- const usage = usageKey ? usageByProvider.get(usageKey) : undefined;
- const usageSuffix = usage ? colorize(rich, theme.muted, ` usage: ${usage}`) : "";
- runtime.log(`- ${colorize(rich, theme.heading, provider)}${usageSuffix}`);
- for (const profile of profiles) {
- const labelText = profile.label || profile.profileId;
- const label = colorize(rich, theme.accent, labelText);
- const status = formatStatus(profile.status);
- const expiry =
- profile.status === "static"
- ? ""
- : profile.expiresAt
- ? ` expires in ${formatRemainingShort(profile.remainingMs)}`
- : " expires unknown";
- const source =
- profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : "";
- runtime.log(` - ${label} ${status}${expiry}${source}`);
+ if (probeSummary) {
+ runtime.log("");
+ runtime.log(colorize(rich, theme.heading, "Auth probes"));
+ if (probeSummary.results.length === 0) {
+ runtime.log(colorize(rich, theme.muted, "- none"));
+ } else {
+ const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
+ const sorted = sortProbeResults(probeSummary.results);
+ const statusColor = (status: string) => {
+ if (status === "ok") return theme.success;
+ if (status === "rate_limit") return theme.warn;
+ if (status === "timeout" || status === "billing") return theme.warn;
+ if (status === "auth" || status === "format") return theme.error;
+ if (status === "no_model") return theme.muted;
+ return theme.muted;
+ };
+ const rows = sorted.map((result) => {
+ const status = colorize(rich, statusColor(result.status), result.status);
+ const latency = formatProbeLatency(result.latencyMs);
+ const detail = result.error ? colorize(rich, theme.muted, result.error) : "";
+ const modelLabel = result.model ?? `${result.provider}/-`;
+ const modeLabel = result.mode ? ` ${colorize(rich, theme.muted, `(${result.mode})`)}` : "";
+ const profile = `${colorize(rich, theme.accent, result.label)}${modeLabel}`;
+ const statusLabel = `${status}${colorize(rich, theme.muted, ` · ${latency}`)}`;
+ return {
+ Model: colorize(rich, theme.heading, modelLabel),
+ Profile: profile,
+ Status: statusLabel,
+ Detail: detail,
+ };
+ });
+ runtime.log(
+ renderTable({
+ width: tableWidth,
+ columns: [
+ { key: "Model", header: "Model", minWidth: 18 },
+ { key: "Profile", header: "Profile", minWidth: 24 },
+ { key: "Status", header: "Status", minWidth: 12 },
+ { key: "Detail", header: "Detail", minWidth: 16, flex: true },
+ ],
+ rows,
+ }).trimEnd(),
+ );
+ runtime.log(colorize(rich, theme.muted, describeProbeSummary(probeSummary)));
}
}
diff --git a/src/discord/monitor.slash.test.ts b/src/discord/monitor.slash.test.ts
index 018f93ed0..af098eb96 100644
--- a/src/discord/monitor.slash.test.ts
+++ b/src/discord/monitor.slash.test.ts
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
const dispatchMock = vi.fn();
@@ -20,15 +21,34 @@ vi.mock("@buape/carbon", () => ({
},
}));
-vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
- dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args),
-}));
+vi.mock("../auto-reply/dispatch.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args),
+ dispatchInboundMessageWithDispatcher: (...args: unknown[]) => dispatchMock(...args),
+ dispatchInboundMessageWithBufferedDispatcher: (...args: unknown[]) => dispatchMock(...args),
+ };
+});
beforeEach(() => {
- dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => {
- dispatcher.sendToolResult({ text: "tool update" });
- dispatcher.sendFinalReply({ text: "final reply" });
- return { queuedFinal: true, counts: { tool: 1, block: 0, final: 1 } };
+ dispatchMock.mockReset().mockImplementation(async (params) => {
+ if ("dispatcher" in params && params.dispatcher) {
+ params.dispatcher.sendToolResult({ text: "tool update" });
+ params.dispatcher.sendFinalReply({ text: "final reply" });
+ return { queuedFinal: true, counts: { tool: 1, block: 0, final: 1 } };
+ }
+ if ("dispatcherOptions" in params && params.dispatcherOptions) {
+ const { dispatcher, markDispatchIdle } = createReplyDispatcherWithTyping(
+ params.dispatcherOptions,
+ );
+ dispatcher.sendToolResult({ text: "tool update" });
+ dispatcher.sendFinalReply({ text: "final reply" });
+ await dispatcher.waitForIdle();
+ markDispatchIdle();
+ return { queuedFinal: true, counts: dispatcher.getQueuedCounts() };
+ }
+ return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
});
});
diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts
index be0c8aa65..bc85e5764 100644
--- a/src/discord/monitor.test.ts
+++ b/src/discord/monitor.test.ts
@@ -377,12 +377,63 @@ describe("discord mention gating", () => {
resolveDiscordShouldRequireMention({
isGuildMessage: true,
isThread: true,
+ botId: "bot123",
+ threadOwnerId: "bot123",
channelConfig,
guildInfo,
}),
).toBe(false);
});
+ it("requires mention inside user-created threads with autoThread enabled", () => {
+ const guildInfo: DiscordGuildEntryResolved = {
+ requireMention: true,
+ channels: {
+ general: { allow: true, autoThread: true },
+ },
+ };
+ const channelConfig = resolveDiscordChannelConfig({
+ guildInfo,
+ channelId: "1",
+ channelName: "General",
+ channelSlug: "general",
+ });
+ expect(
+ resolveDiscordShouldRequireMention({
+ isGuildMessage: true,
+ isThread: true,
+ botId: "bot123",
+ threadOwnerId: "user456",
+ channelConfig,
+ guildInfo,
+ }),
+ ).toBe(true);
+ });
+
+ it("requires mention when thread owner is unknown", () => {
+ const guildInfo: DiscordGuildEntryResolved = {
+ requireMention: true,
+ channels: {
+ general: { allow: true, autoThread: true },
+ },
+ };
+ const channelConfig = resolveDiscordChannelConfig({
+ guildInfo,
+ channelId: "1",
+ channelName: "General",
+ channelSlug: "general",
+ });
+ expect(
+ resolveDiscordShouldRequireMention({
+ isGuildMessage: true,
+ isThread: true,
+ botId: "bot123",
+ channelConfig,
+ guildInfo,
+ }),
+ ).toBe(true);
+ });
+
it("inherits parent channel mention rules for threads", () => {
const guildInfo: DiscordGuildEntryResolved = {
requireMention: true,
diff --git a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts
index b31387b45..d91c7b3d3 100644
--- a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts
+++ b/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.test.ts
@@ -18,9 +18,15 @@ vi.mock("./send.js", () => ({
reactMock(...args);
},
}));
-vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
- dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args),
-}));
+vi.mock("../auto-reply/dispatch.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args),
+ dispatchInboundMessageWithDispatcher: (...args: unknown[]) => dispatchMock(...args),
+ dispatchInboundMessageWithBufferedDispatcher: (...args: unknown[]) => dispatchMock(...args),
+ };
+});
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
@@ -41,7 +47,7 @@ beforeEach(() => {
updateLastRouteMock.mockReset();
dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => {
dispatcher.sendFinalReply({ text: "hi" });
- return { queuedFinal: true, counts: { final: 1 } };
+ return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } };
});
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
diff --git a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts b/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts
index 9da41c577..88fd6e212 100644
--- a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts
+++ b/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts
@@ -18,9 +18,15 @@ vi.mock("./send.js", () => ({
reactMock(...args);
},
}));
-vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
- dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args),
-}));
+vi.mock("../auto-reply/dispatch.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args),
+ dispatchInboundMessageWithDispatcher: (...args: unknown[]) => dispatchMock(...args),
+ dispatchInboundMessageWithBufferedDispatcher: (...args: unknown[]) => dispatchMock(...args),
+ };
+});
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
@@ -40,7 +46,7 @@ beforeEach(() => {
updateLastRouteMock.mockReset();
dispatchMock.mockReset().mockImplementation(async ({ dispatcher }) => {
dispatcher.sendFinalReply({ text: "hi" });
- return { queuedFinal: true, counts: { final: 1 } };
+ return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } };
});
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts
index 7d495af66..12c2d1d39 100644
--- a/src/discord/monitor/allow-list.ts
+++ b/src/discord/monitor/allow-list.ts
@@ -282,14 +282,33 @@ export function resolveDiscordChannelConfigWithFallback(params: {
export function resolveDiscordShouldRequireMention(params: {
isGuildMessage: boolean;
isThread: boolean;
+ botId?: string | null;
+ threadOwnerId?: string | null;
channelConfig?: DiscordChannelConfigResolved | null;
guildInfo?: DiscordGuildEntryResolved | null;
+ /** Pass pre-computed value to avoid redundant checks. */
+ isAutoThreadOwnedByBot?: boolean;
}): boolean {
if (!params.isGuildMessage) return false;
- if (params.isThread && params.channelConfig?.autoThread) return false;
+ // Only skip mention requirement in threads created by the bot (when autoThread is enabled).
+ const isBotThread = params.isAutoThreadOwnedByBot ?? isDiscordAutoThreadOwnedByBot(params);
+ if (isBotThread) return false;
return params.channelConfig?.requireMention ?? params.guildInfo?.requireMention ?? true;
}
+export function isDiscordAutoThreadOwnedByBot(params: {
+ isThread: boolean;
+ channelConfig?: DiscordChannelConfigResolved | null;
+ botId?: string | null;
+ threadOwnerId?: string | null;
+}): boolean {
+ if (!params.isThread) return false;
+ if (!params.channelConfig?.autoThread) return false;
+ const botId = params.botId?.trim();
+ const threadOwnerId = params.threadOwnerId?.trim();
+ return Boolean(botId && threadOwnerId && botId === threadOwnerId);
+}
+
export function isDiscordGroupAllowedByPolicy(params: {
groupPolicy: "open" | "disabled" | "allowlist";
guildAllowlisted: boolean;
diff --git a/src/discord/monitor/message-handler.inbound-contract.test.ts b/src/discord/monitor/message-handler.inbound-contract.test.ts
index 1ffecb293..708c69993 100644
--- a/src/discord/monitor/message-handler.inbound-contract.test.ts
+++ b/src/discord/monitor/message-handler.inbound-contract.test.ts
@@ -9,17 +9,24 @@ import { expectInboundContextContract } from "../../../test/helpers/inbound-cont
let capturedCtx: MsgContext | undefined;
-vi.mock("../../auto-reply/reply/dispatch-from-config.js", () => ({
- dispatchReplyFromConfig: vi.fn(async (params: { ctx: MsgContext }) => {
+vi.mock("../../auto-reply/dispatch.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ const dispatchInboundMessage = vi.fn(async (params: { ctx: MsgContext }) => {
capturedCtx = params.ctx;
- return { queuedFinal: false, counts: { tool: 0, block: 0 } };
- }),
-}));
+ return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
+ });
+ return {
+ ...actual,
+ dispatchInboundMessage,
+ dispatchInboundMessageWithDispatcher: dispatchInboundMessage,
+ dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessage,
+ };
+});
import { processDiscordMessage } from "./message-handler.process.js";
describe("discord processDiscordMessage inbound contract", () => {
- it("passes a finalized MsgContext to dispatchReplyFromConfig", async () => {
+ it("passes a finalized MsgContext to dispatchInboundMessage", async () => {
capturedCtx = undefined;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-discord-"));
diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts
index 6df141e35..5245fe253 100644
--- a/src/discord/monitor/message-handler.preflight.ts
+++ b/src/discord/monitor/message-handler.preflight.ts
@@ -2,7 +2,10 @@ import { ChannelType, MessageType, type User } from "@buape/carbon";
import { hasControlCommand } from "../../auto-reply/command-detection.js";
import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js";
-import { recordPendingHistoryEntry, type HistoryEntry } from "../../auto-reply/reply/history.js";
+import {
+ recordPendingHistoryEntryIfEnabled,
+ type HistoryEntry,
+} from "../../auto-reply/reply/history.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
import { logVerbose, shouldLogVerbose } from "../../globals.js";
import { recordChannelActivity } from "../../infra/channel-activity.js";
@@ -18,6 +21,7 @@ import { resolveMentionGatingWithBypass } from "../../channels/mention-gating.js
import { formatAllowlistMatchMeta } from "../../channels/allowlist-match.js";
import { sendMessageDiscord } from "../send.js";
import { resolveControlCommandGate } from "../../channels/command-gating.js";
+import { logInboundDrop } from "../../channels/logging.js";
import {
allowListMatches,
isDiscordGroupAllowedByPolicy,
@@ -328,9 +332,12 @@ export async function preflightDiscordMessage(
} satisfies HistoryEntry)
: undefined;
+ const threadOwnerId = threadChannel ? (threadChannel.ownerId ?? channelInfo?.ownerId) : undefined;
const shouldRequireMention = resolveDiscordShouldRequireMention({
isGuildMessage,
isThread: Boolean(threadChannel),
+ botId,
+ threadOwnerId,
channelConfig,
guildInfo,
});
@@ -379,7 +386,12 @@ export async function preflightDiscordMessage(
commandAuthorized = commandGate.commandAuthorized;
if (commandGate.shouldBlock) {
- logVerbose(`Blocked discord control command from unauthorized sender ${author.id}`);
+ logInboundDrop({
+ log: logVerbose,
+ channel: "discord",
+ reason: "control command (unauthorized)",
+ target: author.id,
+ });
return null;
}
}
@@ -407,14 +419,12 @@ export async function preflightDiscordMessage(
},
"discord: skipping guild message",
);
- if (historyEntry && params.historyLimit > 0) {
- recordPendingHistoryEntry({
- historyMap: params.guildHistories,
- historyKey: message.channelId,
- limit: params.historyLimit,
- entry: historyEntry,
- });
- }
+ recordPendingHistoryEntryIfEnabled({
+ historyMap: params.guildHistories,
+ historyKey: message.channelId,
+ limit: params.historyLimit,
+ entry: historyEntry ?? null,
+ });
return null;
}
}
diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts
new file mode 100644
index 000000000..351f46f74
--- /dev/null
+++ b/src/discord/monitor/message-handler.process.test.ts
@@ -0,0 +1,123 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const reactMessageDiscord = vi.fn(async () => {});
+const removeReactionDiscord = vi.fn(async () => {});
+
+vi.mock("../send.js", () => ({
+ reactMessageDiscord: (...args: unknown[]) => reactMessageDiscord(...args),
+ removeReactionDiscord: (...args: unknown[]) => removeReactionDiscord(...args),
+}));
+
+vi.mock("../../auto-reply/reply/dispatch-from-config.js", () => ({
+ dispatchReplyFromConfig: vi.fn(async () => ({
+ queuedFinal: false,
+ counts: { final: 0, tool: 0, block: 0 },
+ })),
+}));
+
+vi.mock("../../auto-reply/reply/reply-dispatcher.js", () => ({
+ createReplyDispatcherWithTyping: vi.fn(() => ({
+ dispatcher: {},
+ replyOptions: {},
+ markDispatchIdle: vi.fn(),
+ })),
+}));
+
+import { processDiscordMessage } from "./message-handler.process.js";
+
+async function createBaseContext(overrides: Record = {}) {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-discord-"));
+ const storePath = path.join(dir, "sessions.json");
+ return {
+ cfg: { messages: { ackReaction: "👀" }, session: { store: storePath } },
+ discordConfig: {},
+ accountId: "default",
+ token: "token",
+ runtime: { log: () => {}, error: () => {} },
+ guildHistories: new Map(),
+ historyLimit: 0,
+ mediaMaxBytes: 1024,
+ textLimit: 4000,
+ replyToMode: "off",
+ ackReactionScope: "group-mentions",
+ groupPolicy: "open",
+ data: { guild: { id: "g1", name: "Guild" } },
+ client: { rest: {} },
+ message: {
+ id: "m1",
+ channelId: "c1",
+ timestamp: new Date().toISOString(),
+ attachments: [],
+ },
+ author: {
+ id: "U1",
+ username: "alice",
+ discriminator: "0",
+ globalName: "Alice",
+ },
+ channelInfo: { name: "general" },
+ channelName: "general",
+ isGuildMessage: true,
+ isDirectMessage: false,
+ isGroupDm: false,
+ commandAuthorized: true,
+ baseText: "hi",
+ messageText: "hi",
+ wasMentioned: false,
+ shouldRequireMention: true,
+ canDetectMention: true,
+ effectiveWasMentioned: true,
+ shouldBypassMention: false,
+ threadChannel: null,
+ threadParentId: undefined,
+ threadParentName: undefined,
+ threadParentType: undefined,
+ threadName: undefined,
+ displayChannelSlug: "general",
+ guildInfo: null,
+ guildSlug: "guild",
+ channelConfig: null,
+ baseSessionKey: "agent:main:discord:guild:g1",
+ route: {
+ agentId: "main",
+ channel: "discord",
+ accountId: "default",
+ sessionKey: "agent:main:discord:guild:g1",
+ mainSessionKey: "agent:main:main",
+ },
+ ...overrides,
+ };
+}
+
+beforeEach(() => {
+ reactMessageDiscord.mockClear();
+ removeReactionDiscord.mockClear();
+});
+
+describe("processDiscordMessage ack reactions", () => {
+ it("skips ack reactions for group-mentions when mentions are not required", async () => {
+ const ctx = await createBaseContext({
+ shouldRequireMention: false,
+ effectiveWasMentioned: false,
+ });
+
+ await processDiscordMessage(ctx as any);
+
+ expect(reactMessageDiscord).not.toHaveBeenCalled();
+ });
+
+ it("sends ack reactions for mention-gated guild messages when mentioned", async () => {
+ const ctx = await createBaseContext({
+ shouldRequireMention: true,
+ effectiveWasMentioned: true,
+ });
+
+ await processDiscordMessage(ctx as any);
+
+ expect(reactMessageDiscord).toHaveBeenCalledWith("c1", "m1", "👀", { rest: {} });
+ });
+});
diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts
index ad1e4baea..f575e6e55 100644
--- a/src/discord/monitor/message-handler.process.ts
+++ b/src/discord/monitor/message-handler.process.ts
@@ -1,32 +1,26 @@
+import { resolveAckReaction, resolveHumanDelayConfig } from "../../agents/identity.js";
import {
- resolveAckReaction,
- resolveEffectiveMessagesConfig,
- resolveHumanDelayConfig,
- resolveIdentityName,
-} from "../../agents/identity.js";
-import {
- extractShortModelName,
- type ResponsePrefixContext,
-} from "../../auto-reply/reply/response-prefix-template.js";
+ removeAckReactionAfterReply,
+ shouldAckReaction as shouldAckReactionGate,
+} from "../../channels/ack-reactions.js";
+import { logTypingFailure, logAckFailure } from "../../channels/logging.js";
+import { createReplyPrefixContext } from "../../channels/reply-prefix.js";
+import { createTypingCallbacks } from "../../channels/typing.js";
import {
formatInboundEnvelope,
formatThreadStarterEnvelope,
resolveEnvelopeFormatOptions,
} from "../../auto-reply/envelope.js";
-import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
+import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
import {
buildPendingHistoryContextFromMap,
- clearHistoryEntries,
+ clearHistoryEntriesIfEnabled,
} from "../../auto-reply/reply/history.js";
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
-import {
- readSessionUpdatedAt,
- recordSessionMetaFromInbound,
- resolveStorePath,
- updateLastRoute,
-} from "../../config/sessions.js";
+import { recordInboundSession } from "../../channels/session.js";
+import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
@@ -73,6 +67,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
shouldRequireMention,
canDetectMention,
effectiveWasMentioned,
+ shouldBypassMention,
threadChannel,
threadParentId,
threadParentName,
@@ -95,20 +90,20 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
}
const ackReaction = resolveAckReaction(cfg, route.agentId);
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
- const shouldAckReaction = () => {
- if (!ackReaction) return false;
- if (ackReactionScope === "all") return true;
- if (ackReactionScope === "direct") return isDirectMessage;
- const isGroupChat = isGuildMessage || isGroupDm;
- if (ackReactionScope === "group-all") return isGroupChat;
- if (ackReactionScope === "group-mentions") {
- if (!isGuildMessage) return false;
- if (!shouldRequireMention) return false;
- if (!canDetectMention) return false;
- return effectiveWasMentioned;
- }
- return false;
- };
+ const shouldAckReaction = () =>
+ Boolean(
+ ackReaction &&
+ shouldAckReactionGate({
+ scope: ackReactionScope,
+ isDirect: isDirectMessage,
+ isGroup: isGuildMessage || isGroupDm,
+ isMentionableGroup: isGuildMessage,
+ requireMention: Boolean(shouldRequireMention),
+ canDetectMention,
+ effectiveWasMentioned,
+ shouldBypassMention,
+ }),
+ );
const ackReactionPromise = shouldAckReaction()
? reactMessageDiscord(message.channelId, message.id, ackReaction, {
rest: client.rest,
@@ -288,27 +283,23 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
OriginatingTo: autoThreadContext?.OriginatingTo ?? replyTarget,
});
- void recordSessionMetaFromInbound({
+ await recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
- }).catch((err) => {
- logVerbose(`discord: failed updating session meta: ${String(err)}`);
+ updateLastRoute: isDirectMessage
+ ? {
+ sessionKey: route.mainSessionKey,
+ channel: "discord",
+ to: `user:${author.id}`,
+ accountId: route.accountId,
+ }
+ : undefined,
+ onRecordError: (err) => {
+ logVerbose(`discord: failed updating session meta: ${String(err)}`);
+ },
});
- if (isDirectMessage) {
- await updateLastRoute({
- storePath,
- sessionKey: route.mainSessionKey,
- deliveryContext: {
- channel: "discord",
- to: `user:${author.id}`,
- accountId: route.accountId,
- },
- ctx: ctxPayload,
- });
- }
-
if (shouldLogVerbose()) {
const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n");
logVerbose(
@@ -320,10 +311,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
? deliverTarget.slice("channel:".length)
: message.channelId;
- // Create mutable context for response prefix template interpolation
- let prefixContext: ResponsePrefixContext = {
- identityName: resolveIdentityName(cfg, route.agentId),
- };
+ const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "discord",
@@ -331,8 +319,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
});
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
- responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
- responsePrefixContextProvider: () => prefixContext,
+ responsePrefix: prefixContext.responsePrefix,
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => {
const replyToId = replyReference.use();
@@ -353,10 +341,20 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
onError: (err, info) => {
runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`));
},
- onReplyStart: () => sendTyping({ client, channelId: typingChannelId }),
+ onReplyStart: createTypingCallbacks({
+ start: () => sendTyping({ client, channelId: typingChannelId }),
+ onStartError: (err) => {
+ logTypingFailure({
+ log: logVerbose,
+ channel: "discord",
+ target: typingChannelId,
+ error: err,
+ });
+ },
+ }).onReplyStart,
});
- const { queuedFinal, counts } = await dispatchReplyFromConfig({
+ const { queuedFinal, counts } = await dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
@@ -368,20 +366,17 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
? !discordConfig.blockStreaming
: undefined,
onModelSelected: (ctx) => {
- // Mutate the object directly instead of reassigning to ensure the closure sees updates
- prefixContext.provider = ctx.provider;
- prefixContext.model = extractShortModelName(ctx.model);
- prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
- prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
+ prefixContext.onModelSelected(ctx);
},
},
});
markDispatchIdle();
if (!queuedFinal) {
- if (isGuildMessage && historyLimit > 0) {
- clearHistoryEntries({
+ if (isGuildMessage) {
+ clearHistoryEntriesIfEnabled({
historyMap: guildHistories,
historyKey: message.channelId,
+ limit: historyLimit,
});
}
return;
@@ -392,23 +387,29 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
);
}
- if (removeAckAfterReply && ackReactionPromise && ackReaction) {
- const ackReactionValue = ackReaction;
- void ackReactionPromise.then((didAck) => {
- if (!didAck) return;
- removeReactionDiscord(message.channelId, message.id, ackReactionValue, {
+ removeAckReactionAfterReply({
+ removeAfterReply: removeAckAfterReply,
+ ackReactionPromise,
+ ackReactionValue: ackReaction,
+ remove: async () => {
+ await removeReactionDiscord(message.channelId, message.id, ackReaction, {
rest: client.rest,
- }).catch((err) => {
- logVerbose(
- `discord: failed to remove ack reaction from ${message.channelId}/${message.id}: ${String(err)}`,
- );
});
- });
- }
- if (isGuildMessage && historyLimit > 0) {
- clearHistoryEntries({
+ },
+ onError: (err) => {
+ logAckFailure({
+ log: logVerbose,
+ channel: "discord",
+ target: `${message.channelId}/${message.id}`,
+ error: err,
+ });
+ },
+ });
+ if (isGuildMessage) {
+ clearHistoryEntriesIfEnabled({
historyMap: guildHistories,
historyKey: message.channelId,
+ limit: historyLimit,
});
}
}
diff --git a/src/discord/monitor/message-utils.ts b/src/discord/monitor/message-utils.ts
index a681afa16..2647e5113 100644
--- a/src/discord/monitor/message-utils.ts
+++ b/src/discord/monitor/message-utils.ts
@@ -16,6 +16,7 @@ export type DiscordChannelInfo = {
name?: string;
topic?: string;
parentId?: string;
+ ownerId?: string;
};
type DiscordSnapshotAuthor = {
@@ -69,11 +70,13 @@ export async function resolveDiscordChannelInfo(
const name = "name" in channel ? (channel.name ?? undefined) : undefined;
const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined;
const parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined;
+ const ownerId = "ownerId" in channel ? (channel.ownerId ?? undefined) : undefined;
const payload: DiscordChannelInfo = {
type: channel.type,
name,
topic,
parentId,
+ ownerId,
};
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: payload,
diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts
index bae4ef1c5..71af6408f 100644
--- a/src/discord/monitor/threading.ts
+++ b/src/discord/monitor/threading.ts
@@ -14,6 +14,7 @@ export type DiscordThreadChannel = {
name?: string | null;
parentId?: string | null;
parent?: { id?: string; name?: string };
+ ownerId?: string | null;
};
export type DiscordThreadStarter = {
@@ -63,6 +64,7 @@ export function resolveDiscordThreadChannel(params: {
name: channelInfo?.name ?? undefined,
parentId: channelInfo?.parentId ?? undefined,
parent: undefined,
+ ownerId: channelInfo?.ownerId ?? undefined,
};
}
diff --git a/src/discord/monitor/typing.ts b/src/discord/monitor/typing.ts
index f1ae61fdc..e9ce734d4 100644
--- a/src/discord/monitor/typing.ts
+++ b/src/discord/monitor/typing.ts
@@ -1,15 +1,9 @@
import type { Client } from "@buape/carbon";
-import { logVerbose } from "../../globals.js";
-
export async function sendTyping(params: { client: Client; channelId: string }) {
- try {
- const channel = await params.client.fetchChannel(params.channelId);
- if (!channel) return;
- if ("triggerTyping" in channel && typeof channel.triggerTyping === "function") {
- await channel.triggerTyping();
- }
- } catch (err) {
- logVerbose(`discord typing cue failed for channel ${params.channelId}: ${String(err)}`);
+ const channel = await params.client.fetchChannel(params.channelId);
+ if (!channel) return;
+ if ("triggerTyping" in channel && typeof channel.triggerTyping === "function") {
+ await channel.triggerTyping();
}
}
diff --git a/src/gateway/protocol/schema/exec-approvals.ts b/src/gateway/protocol/schema/exec-approvals.ts
index d58e74ab2..e6f7ce906 100644
--- a/src/gateway/protocol/schema/exec-approvals.ts
+++ b/src/gateway/protocol/schema/exec-approvals.ts
@@ -92,13 +92,13 @@ export const ExecApprovalRequestParamsSchema = Type.Object(
{
id: Type.Optional(NonEmptyString),
command: NonEmptyString,
- cwd: Type.Optional(Type.String()),
- host: Type.Optional(Type.String()),
- security: Type.Optional(Type.String()),
- ask: Type.Optional(Type.String()),
- agentId: Type.Optional(Type.String()),
- resolvedPath: Type.Optional(Type.String()),
- sessionKey: Type.Optional(Type.String()),
+ cwd: Type.Optional(Type.Union([Type.String(), Type.Null()])),
+ host: Type.Optional(Type.Union([Type.String(), Type.Null()])),
+ security: Type.Optional(Type.Union([Type.String(), Type.Null()])),
+ ask: Type.Optional(Type.Union([Type.String(), Type.Null()])),
+ agentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
+ resolvedPath: Type.Optional(Type.Union([Type.String(), Type.Null()])),
+ sessionKey: Type.Optional(Type.Union([Type.String(), Type.Null()])),
timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
},
{ additionalProperties: false },
diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts
index 8c71dca75..50f441779 100644
--- a/src/gateway/server-methods/chat.ts
+++ b/src/gateway/server-methods/chat.ts
@@ -2,14 +2,18 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
+import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent";
+import { resolveSessionAgentId } from "../../agents/agent-scope.js";
+import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../../agents/identity.js";
import { resolveThinkingDefault } from "../../agents/model-selection.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
-import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js";
-import { agentCommand } from "../../commands/agent.js";
-import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js";
-import { registerAgentRunContext } from "../../infra/agent-events.js";
-import { isAcpSessionKey } from "../../routing/session-key.js";
-import { defaultRuntime } from "../../runtime.js";
+import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
+import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
+import {
+ extractShortModelName,
+ type ResponsePrefixContext,
+} from "../../auto-reply/reply/response-prefix-template.js";
+import type { MsgContext } from "../../auto-reply/templating.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import {
@@ -37,7 +41,144 @@ import {
} from "../session-utils.js";
import { stripEnvelopeFromMessages } from "../chat-sanitize.js";
import { formatForLog } from "../ws-log.js";
-import type { GatewayRequestHandlers } from "./types.js";
+import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
+
+type TranscriptAppendResult = {
+ ok: boolean;
+ messageId?: string;
+ message?: Record;
+ error?: string;
+};
+
+function resolveTranscriptPath(params: {
+ sessionId: string;
+ storePath: string | undefined;
+ sessionFile?: string;
+}): string | null {
+ const { sessionId, storePath, sessionFile } = params;
+ if (sessionFile) return sessionFile;
+ if (!storePath) return null;
+ return path.join(path.dirname(storePath), `${sessionId}.jsonl`);
+}
+
+function ensureTranscriptFile(params: { transcriptPath: string; sessionId: string }): {
+ ok: boolean;
+ error?: string;
+} {
+ if (fs.existsSync(params.transcriptPath)) return { ok: true };
+ try {
+ fs.mkdirSync(path.dirname(params.transcriptPath), { recursive: true });
+ const header = {
+ type: "session",
+ version: CURRENT_SESSION_VERSION,
+ id: params.sessionId,
+ timestamp: new Date().toISOString(),
+ cwd: process.cwd(),
+ };
+ fs.writeFileSync(params.transcriptPath, `${JSON.stringify(header)}\n`, "utf-8");
+ return { ok: true };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
+ }
+}
+
+function appendAssistantTranscriptMessage(params: {
+ message: string;
+ label?: string;
+ sessionId: string;
+ storePath: string | undefined;
+ sessionFile?: string;
+ createIfMissing?: boolean;
+}): TranscriptAppendResult {
+ const transcriptPath = resolveTranscriptPath({
+ sessionId: params.sessionId,
+ storePath: params.storePath,
+ sessionFile: params.sessionFile,
+ });
+ if (!transcriptPath) {
+ return { ok: false, error: "transcript path not resolved" };
+ }
+
+ if (!fs.existsSync(transcriptPath)) {
+ if (!params.createIfMissing) {
+ return { ok: false, error: "transcript file not found" };
+ }
+ const ensured = ensureTranscriptFile({
+ transcriptPath,
+ sessionId: params.sessionId,
+ });
+ if (!ensured.ok) {
+ return { ok: false, error: ensured.error ?? "failed to create transcript file" };
+ }
+ }
+
+ const now = Date.now();
+ const messageId = randomUUID().slice(0, 8);
+ const labelPrefix = params.label ? `[${params.label}]\n\n` : "";
+ const messageBody: Record = {
+ role: "assistant",
+ content: [{ type: "text", text: `${labelPrefix}${params.message}` }],
+ timestamp: now,
+ stopReason: "injected",
+ usage: { input: 0, output: 0, totalTokens: 0 },
+ };
+ const transcriptEntry = {
+ type: "message",
+ id: messageId,
+ timestamp: new Date(now).toISOString(),
+ message: messageBody,
+ };
+
+ try {
+ fs.appendFileSync(transcriptPath, `${JSON.stringify(transcriptEntry)}\n`, "utf-8");
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
+ }
+
+ return { ok: true, messageId, message: transcriptEntry.message };
+}
+
+function nextChatSeq(context: { agentRunSeq: Map }, runId: string) {
+ const next = (context.agentRunSeq.get(runId) ?? 0) + 1;
+ context.agentRunSeq.set(runId, next);
+ return next;
+}
+
+function broadcastChatFinal(params: {
+ context: Pick;
+ runId: string;
+ sessionKey: string;
+ message?: Record;
+}) {
+ const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.runId);
+ const payload = {
+ runId: params.runId,
+ sessionKey: params.sessionKey,
+ seq,
+ state: "final" as const,
+ message: params.message,
+ };
+ params.context.broadcast("chat", payload);
+ params.context.nodeSendToSession(params.sessionKey, "chat", payload);
+}
+
+function broadcastChatError(params: {
+ context: Pick;
+ runId: string;
+ sessionKey: string;
+ errorMessage?: string;
+}) {
+ const seq = nextChatSeq({ agentRunSeq: params.context.agentRunSeq }, params.runId);
+ const payload = {
+ runId: params.runId,
+ sessionKey: params.sessionKey,
+ seq,
+ state: "error" as const,
+ errorMessage: params.errorMessage,
+ };
+ params.context.broadcast("chat", payload);
+ params.context.nodeSendToSession(params.sessionKey, "chat", payload);
+}
export const chatHandlers: GatewayRequestHandlers = {
"chat.history": async ({ params, respond, context }) => {
@@ -152,7 +293,7 @@ export const chatHandlers: GatewayRequestHandlers = {
runIds: res.aborted ? [runId] : [],
});
},
- "chat.send": async ({ params, respond, context }) => {
+ "chat.send": async ({ params, respond, context, client }) => {
if (!validateChatSendParams(params)) {
respond(
false,
@@ -212,19 +353,13 @@ export const chatHandlers: GatewayRequestHandlers = {
return;
}
}
- const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
+ const { cfg, entry } = loadSessionEntry(p.sessionKey);
const timeoutMs = resolveAgentTimeoutMs({
cfg,
overrideMs: p.timeoutMs,
});
const now = Date.now();
- const sessionId = entry?.sessionId ?? randomUUID();
- const sessionEntry = mergeSessionEntry(entry, {
- sessionId,
- updatedAt: now,
- });
const clientRunId = p.idempotencyKey;
- registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey });
const sendPolicy = resolveSendPolicy({
cfg,
@@ -281,21 +416,11 @@ export const chatHandlers: GatewayRequestHandlers = {
const abortController = new AbortController();
context.chatAbortControllers.set(clientRunId, {
controller: abortController,
- sessionId,
+ sessionId: entry?.sessionId ?? clientRunId,
sessionKey: p.sessionKey,
startedAtMs: now,
expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }),
});
- context.addChatRun(clientRunId, {
- sessionKey: p.sessionKey,
- clientRunId,
- });
-
- if (storePath) {
- await updateSessionStore(storePath, (store) => {
- store[canonicalKey] = sessionEntry;
- });
- }
const ackPayload = {
runId: clientRunId,
@@ -303,35 +428,116 @@ export const chatHandlers: GatewayRequestHandlers = {
};
respond(true, ackPayload, undefined, { runId: clientRunId });
- const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
- const envelopedMessage = formatInboundEnvelope({
- channel: "WebChat",
- from: p.sessionKey,
- timestamp: now,
- body: parsedMessage,
- chatType: "direct",
- previousTimestamp: entry?.updatedAt,
- envelope: envelopeOptions,
+ const trimmedMessage = parsedMessage.trim();
+ const injectThinking = Boolean(
+ p.thinking && trimmedMessage && !trimmedMessage.startsWith("/"),
+ );
+ const commandBody = injectThinking ? `/think ${p.thinking} ${parsedMessage}` : parsedMessage;
+ const clientInfo = client?.connect?.client;
+ const ctx: MsgContext = {
+ Body: parsedMessage,
+ BodyForAgent: parsedMessage,
+ BodyForCommands: commandBody,
+ RawBody: parsedMessage,
+ CommandBody: commandBody,
+ SessionKey: p.sessionKey,
+ Provider: INTERNAL_MESSAGE_CHANNEL,
+ Surface: INTERNAL_MESSAGE_CHANNEL,
+ OriginatingChannel: INTERNAL_MESSAGE_CHANNEL,
+ ChatType: "direct",
+ CommandAuthorized: true,
+ MessageSid: clientRunId,
+ SenderId: clientInfo?.id,
+ SenderName: clientInfo?.displayName,
+ SenderUsername: clientInfo?.displayName,
+ };
+
+ const agentId = resolveSessionAgentId({
+ sessionKey: p.sessionKey,
+ config: cfg,
});
- const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
- void agentCommand(
- {
- message: envelopedMessage,
- images: parsedImages.length > 0 ? parsedImages : undefined,
- sessionId,
- sessionKey: p.sessionKey,
- runId: clientRunId,
- thinking: p.thinking,
- deliver: p.deliver,
- timeout: Math.ceil(timeoutMs / 1000).toString(),
- messageChannel: INTERNAL_MESSAGE_CHANNEL,
- abortSignal: abortController.signal,
- lane,
+ let prefixContext: ResponsePrefixContext = {
+ identityName: resolveIdentityName(cfg, agentId),
+ };
+ const finalReplyParts: string[] = [];
+ const dispatcher = createReplyDispatcher({
+ responsePrefix: resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix,
+ responsePrefixContextProvider: () => prefixContext,
+ onError: (err) => {
+ context.logGateway.warn(`webchat dispatch failed: ${formatForLog(err)}`);
},
- defaultRuntime,
- context.deps,
- )
+ deliver: async (payload, info) => {
+ if (info.kind !== "final") return;
+ const text = payload.text?.trim() ?? "";
+ if (!text) return;
+ finalReplyParts.push(text);
+ },
+ });
+
+ let agentRunStarted = false;
+ void dispatchInboundMessage({
+ ctx,
+ cfg,
+ dispatcher,
+ replyOptions: {
+ runId: clientRunId,
+ abortSignal: abortController.signal,
+ images: parsedImages.length > 0 ? parsedImages : undefined,
+ disableBlockStreaming: true,
+ onAgentRunStart: () => {
+ agentRunStarted = true;
+ },
+ onModelSelected: (ctx) => {
+ prefixContext.provider = ctx.provider;
+ prefixContext.model = extractShortModelName(ctx.model);
+ prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
+ prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
+ },
+ },
+ })
.then(() => {
+ if (!agentRunStarted) {
+ const combinedReply = finalReplyParts
+ .map((part) => part.trim())
+ .filter(Boolean)
+ .join("\n\n")
+ .trim();
+ let message: Record | undefined;
+ if (combinedReply) {
+ const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(
+ p.sessionKey,
+ );
+ const sessionId = latestEntry?.sessionId ?? entry?.sessionId ?? clientRunId;
+ const appended = appendAssistantTranscriptMessage({
+ message: combinedReply,
+ sessionId,
+ storePath: latestStorePath,
+ sessionFile: latestEntry?.sessionFile,
+ createIfMissing: true,
+ });
+ if (appended.ok) {
+ message = appended.message;
+ } else {
+ context.logGateway.warn(
+ `webchat transcript append failed: ${appended.error ?? "unknown error"}`,
+ );
+ const now = Date.now();
+ message = {
+ role: "assistant",
+ content: [{ type: "text", text: combinedReply }],
+ timestamp: now,
+ stopReason: "injected",
+ usage: { input: 0, output: 0, totalTokens: 0 },
+ };
+ }
+ }
+ broadcastChatFinal({
+ context,
+ runId: clientRunId,
+ sessionKey: p.sessionKey,
+ message,
+ });
+ }
context.dedupe.set(`chat:${clientRunId}`, {
ts: Date.now(),
ok: true,
@@ -350,6 +556,12 @@ export const chatHandlers: GatewayRequestHandlers = {
},
error,
});
+ broadcastChatError({
+ context,
+ runId: clientRunId,
+ sessionKey: p.sessionKey,
+ errorMessage: String(err),
+ });
})
.finally(() => {
context.chatAbortControllers.delete(clientRunId);
diff --git a/src/gateway/server-methods/exec-approval.test.ts b/src/gateway/server-methods/exec-approval.test.ts
index 0b1da93f3..71a63e5a3 100644
--- a/src/gateway/server-methods/exec-approval.test.ts
+++ b/src/gateway/server-methods/exec-approval.test.ts
@@ -36,16 +36,16 @@ describe("exec approval handlers", () => {
expect(validateExecApprovalRequestParams(params)).toBe(true);
});
- // This documents the TypeBox/AJV behavior that caused the Discord exec bug:
- // Type.Optional(Type.String()) does NOT accept null, only string or undefined.
- it("rejects request with resolvedPath as null", () => {
+ // Fixed: null is now accepted (Type.Union([Type.String(), Type.Null()]))
+ // This matches the calling code in bash-tools.exec.ts which passes null.
+ it("accepts request with resolvedPath as null", () => {
const params = {
command: "echo hi",
cwd: "/tmp",
host: "node",
resolvedPath: null,
};
- expect(validateExecApprovalRequestParams(params)).toBe(false);
+ expect(validateExecApprovalRequestParams(params)).toBe(true);
});
});
diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts
index f31c726bb..df59a3f31 100644
--- a/src/gateway/server-methods/sessions.ts
+++ b/src/gateway/server-methods/sessions.ts
@@ -251,6 +251,10 @@ export const sessionsHandlers: GatewayRequestHandlers = {
lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo,
skillsSnapshot: entry?.skillsSnapshot,
+ // Reset token counts to 0 on session reset (#1523)
+ inputTokens: 0,
+ outputTokens: 0,
+ totalTokens: 0,
};
store[primaryKey] = nextEntry;
return nextEntry;
diff --git a/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts b/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts
index 2b55b0c2e..e5c6c37aa 100644
--- a/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts
+++ b/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts
@@ -4,8 +4,8 @@ import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import { emitAgentEvent } from "../infra/agent-events.js";
import {
- agentCommand,
connectOk,
+ getReplyFromConfig,
installGatewayTestHooks,
onceMessage,
rpcReq,
@@ -47,7 +47,7 @@ describe("gateway server chat", () => {
async () => {
const tempDirs: string[] = [];
const { server, ws } = await startServerWithClient();
- const spy = vi.mocked(agentCommand);
+ const spy = vi.mocked(getReplyFromConfig);
const resetSpy = () => {
spy.mockReset();
spy.mockResolvedValue(undefined);
@@ -122,8 +122,9 @@ describe("gateway server chat", () => {
let abortInFlight: Promise | undefined;
try {
const callsBefore = spy.mock.calls.length;
- spy.mockImplementationOnce(async (opts) => {
- const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
+ spy.mockImplementationOnce(async (_ctx, opts) => {
+ opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-1");
+ const signal = opts?.abortSignal;
await new Promise((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
@@ -155,7 +156,7 @@ describe("gateway server chat", () => {
const tick = () => {
if (spy.mock.calls.length > callsBefore) return resolve();
if (Date.now() > deadline)
- return reject(new Error("timeout waiting for agentCommand"));
+ return reject(new Error("timeout waiting for getReplyFromConfig"));
setTimeout(tick, 5);
};
tick();
@@ -177,8 +178,9 @@ describe("gateway server chat", () => {
sessionStoreSaveDelayMs.value = 120;
resetSpy();
try {
- spy.mockImplementationOnce(async (opts) => {
- const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
+ spy.mockImplementationOnce(async (_ctx, opts) => {
+ opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-save-1");
+ const signal = opts?.abortSignal;
await new Promise((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
@@ -215,8 +217,9 @@ describe("gateway server chat", () => {
await writeStore({ main: { sessionId: "sess-main", updatedAt: Date.now() } });
resetSpy();
const callsBeforeStop = spy.mock.calls.length;
- spy.mockImplementationOnce(async (opts) => {
- const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
+ spy.mockImplementationOnce(async (_ctx, opts) => {
+ opts?.onAgentRunStart?.(opts.runId ?? "idem-stop-1");
+ const signal = opts?.abortSignal;
await new Promise((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
@@ -261,7 +264,8 @@ describe("gateway server chat", () => {
const runDone = new Promise((resolve) => {
resolveRun = resolve;
});
- spy.mockImplementationOnce(async () => {
+ spy.mockImplementationOnce(async (_ctx, opts) => {
+ opts?.onAgentRunStart?.(opts.runId ?? "idem-status-1");
await runDone;
});
const started = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
@@ -294,8 +298,9 @@ describe("gateway server chat", () => {
}
expect(completed).toBe(true);
resetSpy();
- spy.mockImplementationOnce(async (opts) => {
- const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
+ spy.mockImplementationOnce(async (_ctx, opts) => {
+ opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-all-1");
+ const signal = opts?.abortSignal;
await new Promise((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
@@ -359,9 +364,9 @@ describe("gateway server chat", () => {
const agentStartedP = new Promise((resolve) => {
agentStartedResolve = resolve;
});
- spy.mockImplementationOnce(async (opts) => {
+ spy.mockImplementationOnce(async (_ctx, opts) => {
agentStartedResolve?.();
- const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
+ const signal = opts?.abortSignal;
await new Promise((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
diff --git a/src/gateway/server.chat.gateway-server-chat.e2e.test.ts b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts
index 75f541f39..54f772580 100644
--- a/src/gateway/server.chat.gateway-server-chat.e2e.test.ts
+++ b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts
@@ -6,8 +6,8 @@ import { WebSocket } from "ws";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js";
import {
- agentCommand,
connectOk,
+ getReplyFromConfig,
installGatewayTestHooks,
onceMessage,
rpcReq,
@@ -71,7 +71,7 @@ describe("gateway server chat", () => {
webchatWs.close();
webchatWs = undefined;
- const spy = vi.mocked(agentCommand);
+ const spy = vi.mocked(getReplyFromConfig);
spy.mockClear();
testState.agentConfig = { timeoutSeconds: 123 };
const callsBeforeTimeout = spy.mock.calls.length;
@@ -83,8 +83,8 @@ describe("gateway server chat", () => {
expect(timeoutRes.ok).toBe(true);
await waitFor(() => spy.mock.calls.length > callsBeforeTimeout);
- const timeoutCall = spy.mock.calls.at(-1)?.[0] as { timeout?: string } | undefined;
- expect(timeoutCall?.timeout).toBe("123");
+ const timeoutCall = spy.mock.calls.at(-1)?.[1] as { runId?: string } | undefined;
+ expect(timeoutCall?.runId).toBe("idem-timeout-1");
testState.agentConfig = undefined;
spy.mockClear();
@@ -97,8 +97,8 @@ describe("gateway server chat", () => {
expect(sessionRes.ok).toBe(true);
await waitFor(() => spy.mock.calls.length > callsBeforeSession);
- const sessionCall = spy.mock.calls.at(-1)?.[0] as { sessionKey?: string } | undefined;
- expect(sessionCall?.sessionKey).toBe("agent:main:subagent:abc");
+ const sessionCall = spy.mock.calls.at(-1)?.[0] as { SessionKey?: string } | undefined;
+ expect(sessionCall?.SessionKey).toBe("agent:main:subagent:abc");
const sendPolicyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
tempDirs.push(sendPolicyDir);
@@ -203,10 +203,10 @@ describe("gateway server chat", () => {
expect(imgRes.payload?.runId).toBeDefined();
await waitFor(() => spy.mock.calls.length > callsBeforeImage, 8000);
- const imgCall = spy.mock.calls.at(-1)?.[0] as
+ const imgOpts = spy.mock.calls.at(-1)?.[1] as
| { images?: Array<{ type: string; data: string; mimeType: string }> }
| undefined;
- expect(imgCall?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
+ expect(imgOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
tempDirs.push(historyDir);
@@ -259,6 +259,45 @@ describe("gateway server chat", () => {
}
});
+ test("routes chat.send slash commands without agent runs", async () => {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
+ try {
+ testState.sessionStorePath = path.join(dir, "sessions.json");
+ await writeSessionStore({
+ entries: {
+ main: {
+ sessionId: "sess-main",
+ updatedAt: Date.now(),
+ },
+ },
+ });
+
+ const spy = vi.mocked(agentCommand);
+ const callsBefore = spy.mock.calls.length;
+ const eventPromise = onceMessage(
+ ws,
+ (o) =>
+ o.type === "event" &&
+ o.event === "chat" &&
+ o.payload?.state === "final" &&
+ o.payload?.runId === "idem-command-1",
+ 8000,
+ );
+ const res = await rpcReq(ws, "chat.send", {
+ sessionKey: "main",
+ message: "/context list",
+ idempotencyKey: "idem-command-1",
+ });
+ expect(res.ok).toBe(true);
+ const evt = await eventPromise;
+ expect(evt.payload?.message?.command).toBe(true);
+ expect(spy.mock.calls.length).toBe(callsBefore);
+ } finally {
+ testState.sessionStorePath = undefined;
+ await fs.rm(dir, { recursive: true, force: true });
+ }
+ });
+
test("agent events include sessionKey and agent.wait covers lifecycle flows", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts
index 46631ba09..5268bd459 100644
--- a/src/gateway/test-helpers.mocks.ts
+++ b/src/gateway/test-helpers.mocks.ts
@@ -166,6 +166,7 @@ const hoisted = vi.hoisted(() => ({
waitCalls: [] as string[],
waitResults: new Map(),
},
+ getReplyFromConfig: vi.fn().mockResolvedValue(undefined),
sendWhatsAppMock: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
}));
@@ -197,6 +198,7 @@ export const testTailnetIPv4 = hoisted.testTailnetIPv4;
export const piSdkMock = hoisted.piSdkMock;
export const cronIsolatedRun = hoisted.cronIsolatedRun;
export const agentCommand = hoisted.agentCommand;
+export const getReplyFromConfig = hoisted.getReplyFromConfig;
export const testState = {
agentConfig: undefined as Record | undefined,
@@ -540,6 +542,9 @@ vi.mock("../channels/web/index.js", async () => {
vi.mock("../commands/agent.js", () => ({
agentCommand,
}));
+vi.mock("../auto-reply/reply.js", () => ({
+ getReplyFromConfig,
+}));
vi.mock("../cli/deps.js", async () => {
const actual = await vi.importActual("../cli/deps.js");
const base = actual.createDefaultDeps();
diff --git a/src/imessage/monitor/monitor-provider.ts b/src/imessage/monitor/monitor-provider.ts
index 65185087b..fa0ce2195 100644
--- a/src/imessage/monitor/monitor-provider.ts
+++ b/src/imessage/monitor/monitor-provider.ts
@@ -1,14 +1,6 @@
import fs from "node:fs/promises";
-import {
- resolveEffectiveMessagesConfig,
- resolveHumanDelayConfig,
- resolveIdentityName,
-} from "../../agents/identity.js";
-import {
- extractShortModelName,
- type ResponsePrefixContext,
-} from "../../auto-reply/reply/response-prefix-template.js";
+import { resolveHumanDelayConfig } from "../../agents/identity.js";
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import { hasControlCommand } from "../../auto-reply/command-detection.js";
import {
@@ -20,28 +12,26 @@ import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../auto-reply/inbound-debounce.js";
-import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
+import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import {
buildPendingHistoryContextFromMap,
- clearHistoryEntries,
+ clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT,
- recordPendingHistoryEntry,
+ recordPendingHistoryEntryIfEnabled,
type HistoryEntry,
} from "../../auto-reply/reply/history.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
+import { logInboundDrop } from "../../channels/logging.js";
+import { createReplyPrefixContext } from "../../channels/reply-prefix.js";
+import { recordInboundSession } from "../../channels/session.js";
import { loadConfig } from "../../config/config.js";
import {
resolveChannelGroupPolicy,
resolveChannelGroupRequireMention,
} from "../../config/group-policy.js";
-import {
- readSessionUpdatedAt,
- recordSessionMetaFromInbound,
- resolveStorePath,
- updateLastRoute,
-} from "../../config/sessions.js";
+import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { waitForTransportReady } from "../../infra/transport-ready.js";
import { mediaKindFromMime } from "../../media/constants.js";
@@ -52,7 +42,7 @@ import {
} from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { truncateUtf16Safe } from "../../utils.js";
-import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
+import { resolveControlCommandGate } from "../../channels/command-gating.js";
import { resolveIMessageAccount } from "../accounts.js";
import { createIMessageRpcClient } from "../client.js";
import { probeIMessage } from "../probe.js";
@@ -383,41 +373,44 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
chatIdentifier,
})
: false;
- const commandAuthorized = isGroup
- ? resolveCommandAuthorizedFromAuthorizers({
- useAccessGroups,
- authorizers: [
- { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
- { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
- ],
- })
- : dmAuthorized;
- if (isGroup && hasControlCommand(messageText, cfg) && !commandAuthorized) {
- logVerbose(`imessage: drop control command from unauthorized sender ${sender}`);
+ const hasControlCommandInMessage = hasControlCommand(messageText, cfg);
+ const commandGate = resolveControlCommandGate({
+ useAccessGroups,
+ authorizers: [
+ { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
+ { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
+ ],
+ allowTextCommands: true,
+ hasControlCommand: hasControlCommandInMessage,
+ });
+ const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
+ if (isGroup && commandGate.shouldBlock) {
+ logInboundDrop({
+ log: logVerbose,
+ channel: "imessage",
+ reason: "control command (unauthorized)",
+ target: sender,
+ });
return;
}
const shouldBypassMention =
- isGroup &&
- requireMention &&
- !mentioned &&
- commandAuthorized &&
- hasControlCommand(messageText);
+ isGroup && requireMention && !mentioned && commandAuthorized && hasControlCommandInMessage;
const effectiveWasMentioned = mentioned || shouldBypassMention;
if (isGroup && requireMention && canDetectMention && !mentioned && !shouldBypassMention) {
logVerbose(`imessage: skipping group message (no mention)`);
- if (historyKey && historyLimit > 0) {
- recordPendingHistoryEntry({
- historyMap: groupHistories,
- historyKey,
- limit: historyLimit,
- entry: {
- sender: senderNormalized,
- body: bodyText,
- timestamp: createdAt,
- messageId: message.id ? String(message.id) : undefined,
- },
- });
- }
+ recordPendingHistoryEntryIfEnabled({
+ historyMap: groupHistories,
+ historyKey: historyKey ?? "",
+ limit: historyLimit,
+ entry: historyKey
+ ? {
+ sender: senderNormalized,
+ body: bodyText,
+ timestamp: createdAt,
+ messageId: message.id ? String(message.id) : undefined,
+ }
+ : null,
+ });
return;
}
@@ -454,7 +447,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
envelope: envelopeOptions,
});
let combinedBody = body;
- if (isGroup && historyKey && historyLimit > 0) {
+ if (isGroup && historyKey) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: groupHistories,
historyKey,
@@ -509,30 +502,25 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
OriginatingTo: imessageTo,
});
- void recordSessionMetaFromInbound({
+ const updateTarget = (isGroup ? chatTarget : undefined) || sender;
+ await recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
- }).catch((err) => {
- logVerbose(`imessage: failed updating session meta: ${String(err)}`);
+ updateLastRoute:
+ !isGroup && updateTarget
+ ? {
+ sessionKey: route.mainSessionKey,
+ channel: "imessage",
+ to: updateTarget,
+ accountId: route.accountId,
+ }
+ : undefined,
+ onRecordError: (err) => {
+ logVerbose(`imessage: failed updating session meta: ${String(err)}`);
+ },
});
- if (!isGroup) {
- const to = (isGroup ? chatTarget : undefined) || sender;
- if (to) {
- await updateLastRoute({
- storePath,
- sessionKey: route.mainSessionKey,
- deliveryContext: {
- channel: "imessage",
- to,
- accountId: route.accountId,
- },
- ctx: ctxPayload,
- });
- }
- }
-
if (shouldLogVerbose()) {
const preview = truncateUtf16Safe(body, 200).replace(/\n/g, "\\n");
logVerbose(
@@ -540,14 +528,11 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
);
}
- // Create mutable context for response prefix template interpolation
- let prefixContext: ResponsePrefixContext = {
- identityName: resolveIdentityName(cfg, route.agentId),
- };
+ const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
const dispatcher = createReplyDispatcher({
- responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
- responsePrefixContextProvider: () => prefixContext,
+ responsePrefix: prefixContext.responsePrefix,
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => {
await deliverReplies({
@@ -565,7 +550,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
},
});
- const { queuedFinal } = await dispatchReplyFromConfig({
+ const { queuedFinal } = await dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
@@ -574,23 +559,21 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
typeof accountInfo.config.blockStreaming === "boolean"
? !accountInfo.config.blockStreaming
: undefined,
- onModelSelected: (ctx) => {
- // Mutate the object directly instead of reassigning to ensure the closure sees updates
- prefixContext.provider = ctx.provider;
- prefixContext.model = extractShortModelName(ctx.model);
- prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
- prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
- },
+ onModelSelected: prefixContext.onModelSelected,
},
});
if (!queuedFinal) {
- if (isGroup && historyKey && historyLimit > 0) {
- clearHistoryEntries({ historyMap: groupHistories, historyKey });
+ if (isGroup && historyKey) {
+ clearHistoryEntriesIfEnabled({
+ historyMap: groupHistories,
+ historyKey,
+ limit: historyLimit,
+ });
}
return;
}
- if (isGroup && historyKey && historyLimit > 0) {
- clearHistoryEntries({ historyMap: groupHistories, historyKey });
+ if (isGroup && historyKey) {
+ clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit });
}
}
diff --git a/src/infra/provider-usage.format.ts b/src/infra/provider-usage.format.ts
index f5a1b6995..d10879008 100644
--- a/src/infra/provider-usage.format.ts
+++ b/src/infra/provider-usage.format.ts
@@ -39,7 +39,7 @@ export function formatUsageWindowSummary(
snapshot: ProviderUsageSnapshot,
opts?: { now?: number; maxWindows?: number; includeResets?: boolean },
): string | null {
- if (snapshot.error) return `error: ${snapshot.error}`;
+ if (snapshot.error) return null;
if (snapshot.windows.length === 0) return null;
const now = opts?.now ?? Date.now();
const maxWindows =
diff --git a/src/infra/tailscale.test.ts b/src/infra/tailscale.test.ts
index cb8d0be4e..410c7befd 100644
--- a/src/infra/tailscale.test.ts
+++ b/src/infra/tailscale.test.ts
@@ -1,8 +1,21 @@
-import { describe, expect, it, vi } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
-import { ensureGoInstalled, ensureTailscaledInstalled, getTailnetHostname } from "./tailscale.js";
+import * as tailscale from "./tailscale.js";
+
+const {
+ ensureGoInstalled,
+ ensureTailscaledInstalled,
+ getTailnetHostname,
+ enableTailscaleServe,
+ disableTailscaleServe,
+ ensureFunnel,
+} = tailscale;
describe("tailscale helpers", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
it("parses DNS name from tailscale status", async () => {
const exec = vi.fn().mockResolvedValue({
stdout: JSON.stringify({
@@ -48,4 +61,131 @@ describe("tailscale helpers", () => {
await ensureTailscaledInstalled(exec as never, prompt, runtime);
expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]);
});
+
+ it("enableTailscaleServe attempts normal first, then sudo", async () => {
+ // 1. First attempt fails
+ // 2. Second attempt (sudo) succeeds
+ vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale");
+ const exec = vi
+ .fn()
+ .mockRejectedValueOnce(new Error("permission denied"))
+ .mockResolvedValueOnce({ stdout: "" });
+
+ await enableTailscaleServe(3000, exec as never);
+
+ expect(exec).toHaveBeenNthCalledWith(
+ 1,
+ "tailscale",
+ expect.arrayContaining(["serve", "--bg", "--yes", "3000"]),
+ expect.any(Object),
+ );
+
+ expect(exec).toHaveBeenNthCalledWith(
+ 2,
+ "sudo",
+ expect.arrayContaining(["-n", "tailscale", "serve", "--bg", "--yes", "3000"]),
+ expect.any(Object),
+ );
+ });
+
+ it("enableTailscaleServe does NOT use sudo if first attempt succeeds", async () => {
+ vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale");
+ const exec = vi.fn().mockResolvedValue({ stdout: "" });
+
+ await enableTailscaleServe(3000, exec as never);
+
+ expect(exec).toHaveBeenCalledTimes(1);
+ expect(exec).toHaveBeenCalledWith(
+ "tailscale",
+ expect.arrayContaining(["serve", "--bg", "--yes", "3000"]),
+ expect.any(Object),
+ );
+ });
+
+ it("disableTailscaleServe uses fallback", async () => {
+ vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale");
+ const exec = vi
+ .fn()
+ .mockRejectedValueOnce(new Error("permission denied"))
+ .mockResolvedValueOnce({ stdout: "" });
+
+ await disableTailscaleServe(exec as never);
+
+ expect(exec).toHaveBeenCalledTimes(2);
+ expect(exec).toHaveBeenNthCalledWith(
+ 2,
+ "sudo",
+ expect.arrayContaining(["-n", "tailscale", "serve", "reset"]),
+ expect.any(Object),
+ );
+ });
+
+ it("ensureFunnel uses fallback for enabling", async () => {
+ // Mock exec:
+ // 1. status (success)
+ // 2. enable (fails)
+ // 3. enable sudo (success)
+ vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale");
+ const exec = vi
+ .fn()
+ .mockResolvedValueOnce({ stdout: JSON.stringify({ BackendState: "Running" }) }) // status
+ .mockRejectedValueOnce(new Error("permission denied")) // enable normal
+ .mockResolvedValueOnce({ stdout: "" }); // enable sudo
+
+ const runtime = {
+ error: vi.fn(),
+ log: vi.fn(),
+ exit: vi.fn() as unknown as (code: number) => never,
+ };
+ const prompt = vi.fn();
+
+ await ensureFunnel(8080, exec as never, runtime, prompt);
+
+ // 1. status
+ expect(exec).toHaveBeenNthCalledWith(
+ 1,
+ "tailscale",
+ expect.arrayContaining(["funnel", "status", "--json"]),
+ );
+
+ // 2. enable normal
+ expect(exec).toHaveBeenNthCalledWith(
+ 2,
+ "tailscale",
+ expect.arrayContaining(["funnel", "--yes", "--bg", "8080"]),
+ expect.any(Object),
+ );
+
+ // 3. enable sudo
+ expect(exec).toHaveBeenNthCalledWith(
+ 3,
+ "sudo",
+ expect.arrayContaining(["-n", "tailscale", "funnel", "--yes", "--bg", "8080"]),
+ expect.any(Object),
+ );
+ });
+
+ it("enableTailscaleServe skips sudo on non-permission errors", async () => {
+ vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale");
+ const exec = vi.fn().mockRejectedValueOnce(new Error("boom"));
+
+ await expect(enableTailscaleServe(3000, exec as never)).rejects.toThrow("boom");
+
+ expect(exec).toHaveBeenCalledTimes(1);
+ });
+
+ it("enableTailscaleServe rethrows original error if sudo fails", async () => {
+ vi.spyOn(tailscale, "getTailscaleBinary").mockResolvedValue("tailscale");
+ const originalError = Object.assign(new Error("permission denied"), {
+ stderr: "permission denied",
+ });
+ const exec = vi
+ .fn()
+ .mockRejectedValueOnce(originalError)
+ .mockRejectedValueOnce(new Error("sudo: a password is required"));
+
+ await expect(enableTailscaleServe(3000, exec as never)).rejects.toBe(originalError);
+
+ expect(exec).toHaveBeenCalledTimes(2);
+ });
});
diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts
index 58d7b3f93..8ff340184 100644
--- a/src/infra/tailscale.ts
+++ b/src/infra/tailscale.ts
@@ -206,6 +206,64 @@ export async function ensureTailscaledInstalled(
await exec("brew", ["install", "tailscale"]);
}
+type ExecErrorDetails = {
+ stdout?: unknown;
+ stderr?: unknown;
+ message?: unknown;
+ code?: unknown;
+};
+
+function extractExecErrorText(err: unknown) {
+ const errOutput = err as ExecErrorDetails;
+ const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : "";
+ const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : "";
+ const message = typeof errOutput.message === "string" ? errOutput.message : "";
+ const code = typeof errOutput.code === "string" ? errOutput.code : "";
+ return { stdout, stderr, message, code };
+}
+
+function isPermissionDeniedError(err: unknown): boolean {
+ const { stdout, stderr, message, code } = extractExecErrorText(err);
+ if (code.toUpperCase() === "EACCES") return true;
+ const combined = `${stdout}\n${stderr}\n${message}`.toLowerCase();
+ return (
+ combined.includes("permission denied") ||
+ combined.includes("access denied") ||
+ combined.includes("operation not permitted") ||
+ combined.includes("not permitted") ||
+ combined.includes("requires root") ||
+ combined.includes("must be run as root") ||
+ combined.includes("must be run with sudo") ||
+ combined.includes("requires sudo") ||
+ combined.includes("need sudo")
+ );
+}
+
+// Helper to attempt a command, and retry with sudo if it fails.
+async function execWithSudoFallback(
+ exec: typeof runExec,
+ bin: string,
+ args: string[],
+ opts: { maxBuffer?: number; timeoutMs?: number },
+): Promise<{ stdout: string; stderr: string }> {
+ try {
+ return await exec(bin, args, opts);
+ } catch (err) {
+ if (!isPermissionDeniedError(err)) {
+ throw err;
+ }
+ logVerbose(`Command failed, retrying with sudo: ${bin} ${args.join(" ")}`);
+ try {
+ return await exec("sudo", ["-n", bin, ...args], opts);
+ } catch (sudoErr) {
+ const { stderr, message } = extractExecErrorText(sudoErr);
+ const detail = (stderr || message).trim();
+ if (detail) logVerbose(`Sudo retry failed: ${detail}`);
+ throw err;
+ }
+ }
+}
+
export async function ensureFunnel(
port: number,
exec: typeof runExec = runExec,
@@ -237,10 +295,16 @@ export async function ensureFunnel(
}
logVerbose(`Enabling funnel on port ${port}…`);
- const { stdout } = await exec(tailscaleBin, ["funnel", "--yes", "--bg", `${port}`], {
- maxBuffer: 200_000,
- timeoutMs: 15_000,
- });
+ // Attempt with fallback
+ const { stdout } = await execWithSudoFallback(
+ exec,
+ tailscaleBin,
+ ["funnel", "--yes", "--bg", `${port}`],
+ {
+ maxBuffer: 200_000,
+ timeoutMs: 15_000,
+ },
+ );
if (stdout.trim()) console.log(stdout.trim());
} catch (err) {
const errOutput = err as { stdout?: unknown; stderr?: unknown };
@@ -288,7 +352,7 @@ export async function ensureFunnel(
export async function enableTailscaleServe(port: number, exec: typeof runExec = runExec) {
const tailscaleBin = await getTailscaleBinary();
- await exec(tailscaleBin, ["serve", "--bg", "--yes", `${port}`], {
+ await execWithSudoFallback(exec, tailscaleBin, ["serve", "--bg", "--yes", `${port}`], {
maxBuffer: 200_000,
timeoutMs: 15_000,
});
@@ -296,7 +360,7 @@ export async function enableTailscaleServe(port: number, exec: typeof runExec =
export async function disableTailscaleServe(exec: typeof runExec = runExec) {
const tailscaleBin = await getTailscaleBinary();
- await exec(tailscaleBin, ["serve", "reset"], {
+ await execWithSudoFallback(exec, tailscaleBin, ["serve", "reset"], {
maxBuffer: 200_000,
timeoutMs: 15_000,
});
@@ -304,7 +368,7 @@ export async function disableTailscaleServe(exec: typeof runExec = runExec) {
export async function enableTailscaleFunnel(port: number, exec: typeof runExec = runExec) {
const tailscaleBin = await getTailscaleBinary();
- await exec(tailscaleBin, ["funnel", "--bg", "--yes", `${port}`], {
+ await execWithSudoFallback(exec, tailscaleBin, ["funnel", "--bg", "--yes", `${port}`], {
maxBuffer: 200_000,
timeoutMs: 15_000,
});
@@ -312,7 +376,7 @@ export async function enableTailscaleFunnel(port: number, exec: typeof runExec =
export async function disableTailscaleFunnel(exec: typeof runExec = runExec) {
const tailscaleBin = await getTailscaleBinary();
- await exec(tailscaleBin, ["funnel", "reset"], {
+ await execWithSudoFallback(exec, tailscaleBin, ["funnel", "reset"], {
maxBuffer: 200_000,
timeoutMs: 15_000,
});
diff --git a/src/logging/diagnostic.ts b/src/logging/diagnostic.ts
index ba6239184..adcb93eca 100644
--- a/src/logging/diagnostic.ts
+++ b/src/logging/diagnostic.ts
@@ -197,17 +197,20 @@ export function logSessionStateChange(
},
) {
const state = getSessionState(params);
+ const isProbeSession = state.sessionId?.startsWith("probe-") ?? false;
const prevState = state.state;
state.state = params.state;
state.lastActivity = Date.now();
if (params.state === "idle") state.queueDepth = Math.max(0, state.queueDepth - 1);
- diag.info(
- `session state: sessionId=${state.sessionId ?? "unknown"} sessionKey=${
- state.sessionKey ?? "unknown"
- } prev=${prevState} new=${params.state} reason="${params.reason ?? ""}" queueDepth=${
- state.queueDepth
- }`,
- );
+ if (!isProbeSession) {
+ diag.info(
+ `session state: sessionId=${state.sessionId ?? "unknown"} sessionKey=${
+ state.sessionKey ?? "unknown"
+ } prev=${prevState} new=${params.state} reason="${params.reason ?? ""}" queueDepth=${
+ state.queueDepth
+ }`,
+ );
+ }
emitDiagnosticEvent({
type: "session.state",
sessionId: state.sessionId,
diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts
index 72bb72422..167838b52 100644
--- a/src/plugin-sdk/index.ts
+++ b/src/plugin-sdk/index.ts
@@ -108,8 +108,10 @@ export { SILENT_REPLY_TOKEN, isSilentReplyText } from "../auto-reply/tokens.js";
export {
buildPendingHistoryContextFromMap,
clearHistoryEntries,
+ clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntry,
+ recordPendingHistoryEntryIfEnabled,
} from "../auto-reply/reply/history.js";
export type { HistoryEntry } from "../auto-reply/reply/history.js";
export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js";
@@ -117,9 +119,23 @@ export {
resolveMentionGating,
resolveMentionGatingWithBypass,
} from "../channels/mention-gating.js";
+export type {
+ AckReactionGateParams,
+ AckReactionScope,
+ WhatsAppAckReactionMode,
+} from "../channels/ack-reactions.js";
+export {
+ removeAckReactionAfterReply,
+ shouldAckReaction,
+ shouldAckReactionForWhatsApp,
+} from "../channels/ack-reactions.js";
+export { createTypingCallbacks } from "../channels/typing.js";
+export { createReplyPrefixContext } from "../channels/reply-prefix.js";
+export { logAckFailure, logInboundDrop, logTypingFailure } from "../channels/logging.js";
export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
export type { NormalizedLocation } from "../channels/location.js";
export { formatLocationText, toLocationContext } from "../channels/location.js";
+export { resolveControlCommandGate } from "../channels/command-gating.js";
export {
resolveBlueBubblesGroupRequireMention,
resolveDiscordGroupRequireMention,
@@ -128,6 +144,7 @@ export {
resolveTelegramGroupRequireMention,
resolveWhatsAppGroupRequireMention,
} from "../channels/plugins/group-mentions.js";
+export { recordInboundSession } from "../channels/session.js";
export {
buildChannelKeyCandidates,
normalizeChannelSlug,
diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts
index 6bb10984b..534d3361b 100644
--- a/src/plugins/runtime/index.ts
+++ b/src/plugins/runtime/index.ts
@@ -25,7 +25,9 @@ import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../a
import { createMemoryGetTool, createMemorySearchTool } from "../../agents/tools/memory-tool.js";
import { handleSlackAction } from "../../agents/tools/slack-actions.js";
import { handleWhatsAppAction } from "../../agents/tools/whatsapp-actions.js";
+import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/ack-reactions.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
+import { recordInboundSession } from "../../channels/session.js";
import { discordMessageActions } from "../../channels/plugins/actions/discord.js";
import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js";
import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js";
@@ -192,12 +194,17 @@ export function createPluginRuntime(): PluginRuntime {
resolveStorePath,
readSessionUpdatedAt,
recordSessionMetaFromInbound,
+ recordInboundSession,
updateLastRoute,
},
mentions: {
buildMentionRegexes,
matchesMentionPatterns,
},
+ reactions: {
+ shouldAckReaction,
+ removeAckReactionAfterReply,
+ },
groups: {
resolveGroupPolicy: resolveChannelGroupPolicy,
resolveRequireMention: resolveChannelGroupRequireMention,
diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts
index 1f321d04b..3cda8ee51 100644
--- a/src/plugins/runtime/types.ts
+++ b/src/plugins/runtime/types.ts
@@ -19,6 +19,9 @@ type SaveMediaBuffer = typeof import("../../media/store.js").saveMediaBuffer;
type BuildMentionRegexes = typeof import("../../auto-reply/reply/mentions.js").buildMentionRegexes;
type MatchesMentionPatterns =
typeof import("../../auto-reply/reply/mentions.js").matchesMentionPatterns;
+type ShouldAckReaction = typeof import("../../channels/ack-reactions.js").shouldAckReaction;
+type RemoveAckReactionAfterReply =
+ typeof import("../../channels/ack-reactions.js").removeAckReactionAfterReply;
type ResolveChannelGroupPolicy =
typeof import("../../config/group-policy.js").resolveChannelGroupPolicy;
type ResolveChannelGroupRequireMention =
@@ -51,6 +54,7 @@ type FormatInboundEnvelope = typeof import("../../auto-reply/envelope.js").forma
type ResolveEnvelopeFormatOptions =
typeof import("../../auto-reply/envelope.js").resolveEnvelopeFormatOptions;
type ResolveStateDir = typeof import("../../config/paths.js").resolveStateDir;
+type RecordInboundSession = typeof import("../../channels/session.js").recordInboundSession;
type RecordSessionMetaFromInbound =
typeof import("../../config/sessions.js").recordSessionMetaFromInbound;
type ResolveStorePath = typeof import("../../config/sessions.js").resolveStorePath;
@@ -205,12 +209,17 @@ export type PluginRuntime = {
resolveStorePath: ResolveStorePath;
readSessionUpdatedAt: ReadSessionUpdatedAt;
recordSessionMetaFromInbound: RecordSessionMetaFromInbound;
+ recordInboundSession: RecordInboundSession;
updateLastRoute: UpdateLastRoute;
};
mentions: {
buildMentionRegexes: BuildMentionRegexes;
matchesMentionPatterns: MatchesMentionPatterns;
};
+ reactions: {
+ shouldAckReaction: ShouldAckReaction;
+ removeAckReactionAfterReply: RemoveAckReactionAfterReply;
+ };
groups: {
resolveGroupPolicy: ResolveChannelGroupPolicy;
resolveRequireMention: ResolveChannelGroupRequireMention;
diff --git a/src/process/command-queue.ts b/src/process/command-queue.ts
index 9b203c938..2f2857130 100644
--- a/src/process/command-queue.ts
+++ b/src/process/command-queue.ts
@@ -68,9 +68,12 @@ function drainLane(lane: string) {
entry.resolve(result);
} catch (err) {
state.active -= 1;
- diag.error(
- `lane task error: lane=${lane} durationMs=${Date.now() - startTime} error="${String(err)}"`,
- );
+ const isProbeLane = lane.startsWith("auth-probe:") || lane.startsWith("session:probe-");
+ if (!isProbeLane) {
+ diag.error(
+ `lane task error: lane=${lane} durationMs=${Date.now() - startTime} error="${String(err)}"`,
+ );
+ }
pump();
entry.reject(err);
}
diff --git a/src/signal/monitor.event-handler.sender-prefix.test.ts b/src/signal/monitor.event-handler.sender-prefix.test.ts
index 3c5569940..bf8ee7136 100644
--- a/src/signal/monitor.event-handler.sender-prefix.test.ts
+++ b/src/signal/monitor.event-handler.sender-prefix.test.ts
@@ -12,21 +12,21 @@ describe("signal event handler sender prefix", () => {
beforeEach(() => {
dispatchMock.mockReset().mockImplementation(async ({ dispatcher, ctx }) => {
dispatcher.sendFinalReply({ text: "ok" });
- return { queuedFinal: true, counts: { final: 1 }, ctx };
+ return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 }, ctx };
});
readAllowFromMock.mockReset().mockResolvedValue([]);
});
it("prefixes group bodies with sender label", async () => {
let capturedBody = "";
- const dispatchModule = await import("../auto-reply/reply/dispatch-from-config.js");
- vi.spyOn(dispatchModule, "dispatchReplyFromConfig").mockImplementation(
+ const dispatchModule = await import("../auto-reply/dispatch.js");
+ vi.spyOn(dispatchModule, "dispatchInboundMessage").mockImplementation(
async (...args: unknown[]) => dispatchMock(...args),
);
dispatchMock.mockImplementationOnce(async ({ dispatcher, ctx }) => {
capturedBody = ctx.Body ?? "";
dispatcher.sendFinalReply({ text: "ok" });
- return { queuedFinal: true, counts: { final: 1 } };
+ return { queuedFinal: true, counts: { tool: 0, block: 0, final: 1 } };
});
const { createSignalEventHandler } = await import("./monitor/event-handler.js");
diff --git a/src/signal/monitor.event-handler.typing-read-receipts.test.ts b/src/signal/monitor.event-handler.typing-read-receipts.test.ts
index c4ba6f9ce..7aee1e24d 100644
--- a/src/signal/monitor.event-handler.typing-read-receipts.test.ts
+++ b/src/signal/monitor.event-handler.typing-read-receipts.test.ts
@@ -9,14 +9,21 @@ vi.mock("./send.js", () => ({
sendReadReceiptSignal: (...args: unknown[]) => sendReadReceiptMock(...args),
}));
-vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
- dispatchReplyFromConfig: vi.fn(
+vi.mock("../auto-reply/dispatch.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ const dispatchInboundMessage = vi.fn(
async (params: { replyOptions?: { onReplyStart?: () => void } }) => {
await Promise.resolve(params.replyOptions?.onReplyStart?.());
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
},
- ),
-}));
+ );
+ return {
+ ...actual,
+ dispatchInboundMessage,
+ dispatchInboundMessageWithDispatcher: dispatchInboundMessage,
+ dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessage,
+ };
+});
vi.mock("../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: vi.fn().mockResolvedValue([]),
@@ -25,11 +32,13 @@ vi.mock("../pairing/pairing-store.js", () => ({
describe("signal event handler typing + read receipts", () => {
beforeEach(() => {
+ vi.useRealTimers();
sendTypingMock.mockReset().mockResolvedValue(true);
sendReadReceiptMock.mockReset().mockResolvedValue(true);
});
it("sends typing + read receipt for allowed DMs", async () => {
+ vi.resetModules();
const { createSignalEventHandler } = await import("./monitor/event-handler.js");
const handler = createSignalEventHandler({
runtime: { log: () => {}, error: () => {} } as any,
diff --git a/src/signal/monitor/event-handler.inbound-contract.test.ts b/src/signal/monitor/event-handler.inbound-contract.test.ts
index 9277eb990..d073357ff 100644
--- a/src/signal/monitor/event-handler.inbound-contract.test.ts
+++ b/src/signal/monitor/event-handler.inbound-contract.test.ts
@@ -5,17 +5,24 @@ import { expectInboundContextContract } from "../../../test/helpers/inbound-cont
let capturedCtx: MsgContext | undefined;
-vi.mock("../../auto-reply/reply/dispatch-from-config.js", () => ({
- dispatchReplyFromConfig: vi.fn(async (params: { ctx: MsgContext }) => {
+vi.mock("../../auto-reply/dispatch.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ const dispatchInboundMessage = vi.fn(async (params: { ctx: MsgContext }) => {
capturedCtx = params.ctx;
- return { queuedFinal: false, counts: { tool: 0, block: 0 } };
- }),
-}));
+ return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
+ });
+ return {
+ ...actual,
+ dispatchInboundMessage,
+ dispatchInboundMessageWithDispatcher: dispatchInboundMessage,
+ dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessage,
+ };
+});
import { createSignalEventHandler } from "./event-handler.js";
describe("signal createSignalEventHandler inbound contract", () => {
- it("passes a finalized MsgContext to dispatchReplyFromConfig", async () => {
+ it("passes a finalized MsgContext to dispatchInboundMessage", async () => {
capturedCtx = undefined;
const handler = createSignalEventHandler({
diff --git a/src/signal/monitor/event-handler.ts b/src/signal/monitor/event-handler.ts
index 30fabedfb..72195ff78 100644
--- a/src/signal/monitor/event-handler.ts
+++ b/src/signal/monitor/event-handler.ts
@@ -1,12 +1,4 @@
-import {
- resolveEffectiveMessagesConfig,
- resolveHumanDelayConfig,
- resolveIdentityName,
-} from "../../agents/identity.js";
-import {
- extractShortModelName,
- type ResponsePrefixContext,
-} from "../../auto-reply/reply/response-prefix-template.js";
+import { resolveHumanDelayConfig } from "../../agents/identity.js";
import { hasControlCommand } from "../../auto-reply/command-detection.js";
import {
formatInboundEnvelope,
@@ -17,19 +9,18 @@ import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../auto-reply/inbound-debounce.js";
-import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js";
+import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
import {
buildPendingHistoryContextFromMap,
- clearHistoryEntries,
+ clearHistoryEntriesIfEnabled,
} from "../../auto-reply/reply/history.js";
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
-import {
- readSessionUpdatedAt,
- recordSessionMetaFromInbound,
- resolveStorePath,
- updateLastRoute,
-} from "../../config/sessions.js";
+import { logInboundDrop, logTypingFailure } from "../../channels/logging.js";
+import { createReplyPrefixContext } from "../../channels/reply-prefix.js";
+import { recordInboundSession } from "../../channels/session.js";
+import { createTypingCallbacks } from "../../channels/typing.js";
+import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../../globals.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { mediaKindFromMime } from "../../media/constants.js";
@@ -40,7 +31,7 @@ import {
} from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { normalizeE164 } from "../../utils.js";
-import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
+import { resolveControlCommandGate } from "../../channels/command-gating.js";
import {
formatSignalPairingIdLine,
formatSignalSenderDisplay,
@@ -111,7 +102,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
});
let combinedBody = body;
const historyKey = entry.isGroup ? String(entry.groupId ?? "unknown") : undefined;
- if (entry.isGroup && historyKey && deps.historyLimit > 0) {
+ if (entry.isGroup && historyKey) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: deps.groupHistories,
historyKey,
@@ -159,53 +150,52 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
OriginatingTo: signalTo,
});
- void recordSessionMetaFromInbound({
+ await recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
- }).catch((err) => {
- logVerbose(`signal: failed updating session meta: ${String(err)}`);
+ updateLastRoute: !entry.isGroup
+ ? {
+ sessionKey: route.mainSessionKey,
+ channel: "signal",
+ to: entry.senderRecipient,
+ accountId: route.accountId,
+ }
+ : undefined,
+ onRecordError: (err) => {
+ logVerbose(`signal: failed updating session meta: ${String(err)}`);
+ },
});
- if (!entry.isGroup) {
- await updateLastRoute({
- storePath,
- sessionKey: route.mainSessionKey,
- deliveryContext: {
- channel: "signal",
- to: entry.senderRecipient,
- accountId: route.accountId,
- },
- ctx: ctxPayload,
- });
- }
-
if (shouldLogVerbose()) {
const preview = body.slice(0, 200).replace(/\\n/g, "\\\\n");
logVerbose(`signal inbound: from=${ctxPayload.From} len=${body.length} preview="${preview}"`);
}
- // Create mutable context for response prefix template interpolation
- let prefixContext: ResponsePrefixContext = {
- identityName: resolveIdentityName(deps.cfg, route.agentId),
- };
+ const prefixContext = createReplyPrefixContext({ cfg: deps.cfg, agentId: route.agentId });
- const onReplyStart = async () => {
- try {
+ const typingCallbacks = createTypingCallbacks({
+ start: async () => {
if (!ctxPayload.To) return;
await sendTypingSignal(ctxPayload.To, {
baseUrl: deps.baseUrl,
account: deps.account,
accountId: deps.accountId,
});
- } catch (err) {
- logVerbose(`signal typing cue failed for ${ctxPayload.To}: ${String(err)}`);
- }
- };
+ },
+ onStartError: (err) => {
+ logTypingFailure({
+ log: logVerbose,
+ channel: "signal",
+ target: ctxPayload.To ?? undefined,
+ error: err,
+ });
+ },
+ });
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
- responsePrefix: resolveEffectiveMessagesConfig(deps.cfg, route.agentId).responsePrefix,
- responsePrefixContextProvider: () => prefixContext,
+ responsePrefix: prefixContext.responsePrefix,
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: resolveHumanDelayConfig(deps.cfg, route.agentId),
deliver: async (payload) => {
await deps.deliverReplies({
@@ -222,10 +212,10 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
onError: (err, info) => {
deps.runtime.error?.(danger(`signal ${info.kind} reply failed: ${String(err)}`));
},
- onReplyStart,
+ onReplyStart: typingCallbacks.onReplyStart,
});
- const { queuedFinal } = await dispatchReplyFromConfig({
+ const { queuedFinal } = await dispatchInboundMessage({
ctx: ctxPayload,
cfg: deps.cfg,
dispatcher,
@@ -234,23 +224,27 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
disableBlockStreaming:
typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined,
onModelSelected: (ctx) => {
- // Mutate the object directly instead of reassigning to ensure the closure sees updates
- prefixContext.provider = ctx.provider;
- prefixContext.model = extractShortModelName(ctx.model);
- prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
- prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
+ prefixContext.onModelSelected(ctx);
},
},
});
markDispatchIdle();
if (!queuedFinal) {
- if (entry.isGroup && historyKey && deps.historyLimit > 0) {
- clearHistoryEntries({ historyMap: deps.groupHistories, historyKey });
+ if (entry.isGroup && historyKey) {
+ clearHistoryEntriesIfEnabled({
+ historyMap: deps.groupHistories,
+ historyKey,
+ limit: deps.historyLimit,
+ });
}
return;
}
- if (entry.isGroup && historyKey && deps.historyLimit > 0) {
- clearHistoryEntries({ historyMap: deps.groupHistories, historyKey });
+ if (entry.isGroup && historyKey) {
+ clearHistoryEntriesIfEnabled({
+ historyMap: deps.groupHistories,
+ historyKey,
+ limit: deps.historyLimit,
+ });
}
}
@@ -451,17 +445,24 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
const useAccessGroups = deps.cfg.commands?.useAccessGroups !== false;
const ownerAllowedForCommands = isSignalSenderAllowed(sender, effectiveDmAllow);
const groupAllowedForCommands = isSignalSenderAllowed(sender, effectiveGroupAllow);
- const commandAuthorized = isGroup
- ? resolveCommandAuthorizedFromAuthorizers({
- useAccessGroups,
- authorizers: [
- { configured: effectiveDmAllow.length > 0, allowed: ownerAllowedForCommands },
- { configured: effectiveGroupAllow.length > 0, allowed: groupAllowedForCommands },
- ],
- })
- : dmAllowed;
- if (isGroup && hasControlCommand(messageText, deps.cfg) && !commandAuthorized) {
- logVerbose(`signal: drop control command from unauthorized sender ${senderDisplay}`);
+ const hasControlCommandInMessage = hasControlCommand(messageText, deps.cfg);
+ const commandGate = resolveControlCommandGate({
+ useAccessGroups,
+ authorizers: [
+ { configured: effectiveDmAllow.length > 0, allowed: ownerAllowedForCommands },
+ { configured: effectiveGroupAllow.length > 0, allowed: groupAllowedForCommands },
+ ],
+ allowTextCommands: true,
+ hasControlCommand: hasControlCommandInMessage,
+ });
+ const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAllowed;
+ if (isGroup && commandGate.shouldBlock) {
+ logInboundDrop({
+ log: logVerbose,
+ channel: "signal",
+ reason: "control command (unauthorized)",
+ target: senderDisplay,
+ });
return;
}
diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts
index de1b1f267..d31885cfa 100644
--- a/src/slack/monitor/message-handler/dispatch.ts
+++ b/src/slack/monitor/message-handler/dispatch.ts
@@ -1,14 +1,10 @@
-import {
- resolveEffectiveMessagesConfig,
- resolveHumanDelayConfig,
- resolveIdentityName,
-} from "../../../agents/identity.js";
-import {
- extractShortModelName,
- type ResponsePrefixContext,
-} from "../../../auto-reply/reply/response-prefix-template.js";
-import { dispatchReplyFromConfig } from "../../../auto-reply/reply/dispatch-from-config.js";
-import { clearHistoryEntries } from "../../../auto-reply/reply/history.js";
+import { resolveHumanDelayConfig } from "../../../agents/identity.js";
+import { dispatchInboundMessage } from "../../../auto-reply/dispatch.js";
+import { clearHistoryEntriesIfEnabled } from "../../../auto-reply/reply/history.js";
+import { removeAckReactionAfterReply } from "../../../channels/ack-reactions.js";
+import { logAckFailure, logTypingFailure } from "../../../channels/logging.js";
+import { createReplyPrefixContext } from "../../../channels/reply-prefix.js";
+import { createTypingCallbacks } from "../../../channels/typing.js";
import { createReplyDispatcherWithTyping } from "../../../auto-reply/reply/reply-dispatcher.js";
import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js";
@@ -60,23 +56,50 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
hasRepliedRef,
});
- const onReplyStart = async () => {
- didSetStatus = true;
- await ctx.setSlackThreadStatus({
- channelId: message.channel,
- threadTs: statusThreadTs,
- status: "is typing...",
- });
- };
+ const typingTarget = statusThreadTs ? `${message.channel}/${statusThreadTs}` : message.channel;
+ const typingCallbacks = createTypingCallbacks({
+ start: async () => {
+ didSetStatus = true;
+ await ctx.setSlackThreadStatus({
+ channelId: message.channel,
+ threadTs: statusThreadTs,
+ status: "is typing...",
+ });
+ },
+ stop: async () => {
+ if (!didSetStatus) return;
+ didSetStatus = false;
+ await ctx.setSlackThreadStatus({
+ channelId: message.channel,
+ threadTs: statusThreadTs,
+ status: "",
+ });
+ },
+ onStartError: (err) => {
+ logTypingFailure({
+ log: (message) => runtime.error?.(danger(message)),
+ channel: "slack",
+ action: "start",
+ target: typingTarget,
+ error: err,
+ });
+ },
+ onStopError: (err) => {
+ logTypingFailure({
+ log: (message) => runtime.error?.(danger(message)),
+ channel: "slack",
+ action: "stop",
+ target: typingTarget,
+ error: err,
+ });
+ },
+ });
- // Create mutable context for response prefix template interpolation
- let prefixContext: ResponsePrefixContext = {
- identityName: resolveIdentityName(cfg, route.agentId),
- };
+ const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
- responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
- responsePrefixContextProvider: () => prefixContext,
+ responsePrefix: prefixContext.responsePrefix,
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => {
const replyThreadTs = replyPlan.nextThreadTs();
@@ -93,18 +116,13 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
},
onError: (err, info) => {
runtime.error?.(danger(`slack ${info.kind} reply failed: ${String(err)}`));
- if (didSetStatus) {
- void ctx.setSlackThreadStatus({
- channelId: message.channel,
- threadTs: statusThreadTs,
- status: "",
- });
- }
+ typingCallbacks.onIdle?.();
},
- onReplyStart,
+ onReplyStart: typingCallbacks.onReplyStart,
+ onIdle: typingCallbacks.onIdle,
});
- const { queuedFinal, counts } = await dispatchReplyFromConfig({
+ const { queuedFinal, counts } = await dispatchInboundMessage({
ctx: prepared.ctxPayload,
cfg,
dispatcher,
@@ -117,29 +135,18 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
? !account.config.blockStreaming
: undefined,
onModelSelected: (ctx) => {
- // Mutate the object directly instead of reassigning to ensure the closure sees updates
- prefixContext.provider = ctx.provider;
- prefixContext.model = extractShortModelName(ctx.model);
- prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
- prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
+ prefixContext.onModelSelected(ctx);
},
},
});
markDispatchIdle();
- if (didSetStatus) {
- await ctx.setSlackThreadStatus({
- channelId: message.channel,
- threadTs: statusThreadTs,
- status: "",
- });
- }
-
if (!queuedFinal) {
- if (prepared.isRoomish && ctx.historyLimit > 0) {
- clearHistoryEntries({
+ if (prepared.isRoomish) {
+ clearHistoryEntriesIfEnabled({
historyMap: ctx.channelHistories,
historyKey: prepared.historyKey,
+ limit: ctx.historyLimit,
});
}
return;
@@ -152,26 +159,35 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
);
}
- if (ctx.removeAckAfterReply && prepared.ackReactionPromise && prepared.ackReactionMessageTs) {
- const messageTs = prepared.ackReactionMessageTs;
- const ackValue = prepared.ackReactionValue;
- void prepared.ackReactionPromise.then((didAck) => {
- if (!didAck) return;
- removeSlackReaction(message.channel, messageTs, ackValue, {
- token: ctx.botToken,
- client: ctx.app.client,
- }).catch((err) => {
- logVerbose(
- `slack: failed to remove ack reaction from ${message.channel}/${message.ts}: ${String(err)}`,
- );
+ removeAckReactionAfterReply({
+ removeAfterReply: ctx.removeAckAfterReply,
+ ackReactionPromise: prepared.ackReactionPromise,
+ ackReactionValue: prepared.ackReactionValue,
+ remove: () =>
+ removeSlackReaction(
+ message.channel,
+ prepared.ackReactionMessageTs ?? "",
+ prepared.ackReactionValue,
+ {
+ token: ctx.botToken,
+ client: ctx.app.client,
+ },
+ ),
+ onError: (err) => {
+ logAckFailure({
+ log: logVerbose,
+ channel: "slack",
+ target: `${message.channel}/${message.ts}`,
+ error: err,
});
- });
- }
+ },
+ });
- if (prepared.isRoomish && ctx.historyLimit > 0) {
- clearHistoryEntries({
+ if (prepared.isRoomish) {
+ clearHistoryEntriesIfEnabled({
historyMap: ctx.channelHistories,
historyKey: prepared.historyKey,
+ limit: ctx.historyLimit,
});
}
}
diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts
index 767ecea89..24c251d2a 100644
--- a/src/slack/monitor/message-handler/prepare.ts
+++ b/src/slack/monitor/message-handler/prepare.ts
@@ -9,7 +9,7 @@ import {
} from "../../../auto-reply/envelope.js";
import {
buildPendingHistoryContextFromMap,
- recordPendingHistoryEntry,
+ recordPendingHistoryEntryIfEnabled,
} from "../../../auto-reply/reply/history.js";
import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../../../auto-reply/reply/mentions.js";
@@ -19,15 +19,17 @@ import { buildPairingReply } from "../../../pairing/pairing-messages.js";
import { upsertChannelPairingRequest } from "../../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../../../routing/session-key.js";
+import {
+ shouldAckReaction as shouldAckReactionGate,
+ type AckReactionScope,
+} from "../../../channels/ack-reactions.js";
import { resolveMentionGatingWithBypass } from "../../../channels/mention-gating.js";
import { resolveConversationLabel } from "../../../channels/conversation-label.js";
import { resolveControlCommandGate } from "../../../channels/command-gating.js";
+import { logInboundDrop } from "../../../channels/logging.js";
import { formatAllowlistMatchMeta } from "../../../channels/allowlist-match.js";
-import {
- readSessionUpdatedAt,
- recordSessionMetaFromInbound,
- resolveStorePath,
-} from "../../../config/sessions.js";
+import { recordInboundSession } from "../../../channels/session.js";
+import { readSessionUpdatedAt, resolveStorePath } from "../../../config/sessions.js";
import type { ResolvedSlackAccount } from "../../accounts.js";
import { reactSlackMessage } from "../../actions.js";
@@ -264,7 +266,12 @@ export async function prepareSlackMessage(params: {
const commandAuthorized = commandGate.commandAuthorized;
if (isRoomish && commandGate.shouldBlock) {
- logVerbose(`Blocked slack control command from unauthorized sender ${senderId}`);
+ logInboundDrop({
+ log: logVerbose,
+ channel: "slack",
+ reason: "control command (unauthorized)",
+ target: senderId,
+ });
return null;
}
@@ -288,28 +295,26 @@ export async function prepareSlackMessage(params: {
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
if (isRoom && shouldRequireMention && mentionGate.shouldSkip) {
ctx.logger.info({ channel: message.channel, reason: "no-mention" }, "skipping channel message");
- if (ctx.historyLimit > 0) {
- const pendingText = (message.text ?? "").trim();
- const fallbackFile = message.files?.[0]?.name
- ? `[Slack file: ${message.files[0].name}]`
- : message.files?.length
- ? "[Slack file]"
- : "";
- const pendingBody = pendingText || fallbackFile;
- if (pendingBody) {
- recordPendingHistoryEntry({
- historyMap: ctx.channelHistories,
- historyKey,
- limit: ctx.historyLimit,
- entry: {
+ const pendingText = (message.text ?? "").trim();
+ const fallbackFile = message.files?.[0]?.name
+ ? `[Slack file: ${message.files[0].name}]`
+ : message.files?.length
+ ? "[Slack file]"
+ : "";
+ const pendingBody = pendingText || fallbackFile;
+ recordPendingHistoryEntryIfEnabled({
+ historyMap: ctx.channelHistories,
+ historyKey,
+ limit: ctx.historyLimit,
+ entry: pendingBody
+ ? {
sender: senderName,
body: pendingBody,
timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
messageId: message.ts,
- },
- });
- }
- }
+ }
+ : null,
+ });
return null;
}
@@ -324,19 +329,20 @@ export async function prepareSlackMessage(params: {
const ackReaction = resolveAckReaction(cfg, route.agentId);
const ackReactionValue = ackReaction ?? "";
- const shouldAckReaction = () => {
- if (!ackReaction) return false;
- if (ctx.ackReactionScope === "all") return true;
- if (ctx.ackReactionScope === "direct") return isDirectMessage;
- if (ctx.ackReactionScope === "group-all") return isRoomish;
- if (ctx.ackReactionScope === "group-mentions") {
- if (!isRoom) return false;
- if (!shouldRequireMention) return false;
- if (!canDetectMention) return false;
- return effectiveWasMentioned;
- }
- return false;
- };
+ const shouldAckReaction = () =>
+ Boolean(
+ ackReaction &&
+ shouldAckReactionGate({
+ scope: ctx.ackReactionScope as AckReactionScope | undefined,
+ isDirect: isDirectMessage,
+ isGroup: isRoomish,
+ isMentionableGroup: isRoom,
+ requireMention: Boolean(shouldRequireMention),
+ canDetectMention,
+ effectiveWasMentioned,
+ shouldBypassMention: mentionGate.shouldBypassMention,
+ }),
+ );
const ackReactionMessageTs = message.ts;
const ackReactionPromise =
@@ -508,19 +514,28 @@ export async function prepareSlackMessage(params: {
OriginatingTo: slackTo,
}) satisfies FinalizedMsgContext;
- void recordSessionMetaFromInbound({
+ await recordInboundSession({
storePath,
- sessionKey: sessionKey,
+ sessionKey,
ctx: ctxPayload,
- }).catch((err) => {
- ctx.logger.warn(
- {
- error: String(err),
- storePath,
- sessionKey,
- },
- "failed updating session meta",
- );
+ updateLastRoute: isDirectMessage
+ ? {
+ sessionKey: route.mainSessionKey,
+ channel: "slack",
+ to: `user:${message.user}`,
+ accountId: route.accountId,
+ }
+ : undefined,
+ onRecordError: (err) => {
+ ctx.logger.warn(
+ {
+ error: String(err),
+ storePath,
+ sessionKey,
+ },
+ "failed updating session meta",
+ );
+ },
});
const replyTarget = ctxPayload.To ?? undefined;
diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts
index dc8039a18..e4a7d6780 100644
--- a/src/telegram/bot-message-context.ts
+++ b/src/telegram/bot-message-context.ts
@@ -6,26 +6,24 @@ import { normalizeCommandBody } from "../auto-reply/commands-registry.js";
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js";
import {
buildPendingHistoryContextFromMap,
- recordPendingHistoryEntry,
+ recordPendingHistoryEntryIfEnabled,
type HistoryEntry,
} from "../auto-reply/reply/history.js";
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js";
import { formatLocationText, toLocationContext } from "../channels/location.js";
+import { recordInboundSession } from "../channels/session.js";
import { formatCliCommand } from "../cli/command-format.js";
-import {
- readSessionUpdatedAt,
- recordSessionMetaFromInbound,
- resolveStorePath,
- updateLastRoute,
-} from "../config/sessions.js";
+import { readSessionUpdatedAt, resolveStorePath } from "../config/sessions.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { DmPolicy, TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { recordChannelActivity } from "../infra/channel-activity.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
+import { shouldAckReaction as shouldAckReactionGate } from "../channels/ack-reactions.js";
import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
import { resolveControlCommandGate } from "../channels/command-gating.js";
+import { logInboundDrop } from "../channels/logging.js";
import {
buildGroupLabel,
buildSenderLabel,
@@ -159,11 +157,7 @@ export const buildTelegramMessageContext = async ({
}
const sendTyping = async () => {
- try {
- await bot.api.sendChatAction(chatId, "typing", buildTypingThreadParams(resolvedThreadId));
- } catch (err) {
- logVerbose(`telegram typing cue failed for chat ${chatId}: ${String(err)}`);
- }
+ await bot.api.sendChatAction(chatId, "typing", buildTypingThreadParams(resolvedThreadId));
};
const sendRecordVoice = async () => {
@@ -313,7 +307,12 @@ export const buildTelegramMessageContext = async ({
(ent) => ent.type === "mention",
);
if (isGroup && commandGate.shouldBlock) {
- logVerbose(`telegram: drop control command from unauthorized sender ${senderId ?? "unknown"}`);
+ logInboundDrop({
+ log: logVerbose,
+ channel: "telegram",
+ reason: "control command (unauthorized)",
+ target: senderId ?? "unknown",
+ });
return null;
}
const activationOverride = resolveGroupActivation({
@@ -349,19 +348,19 @@ export const buildTelegramMessageContext = async ({
if (isGroup && requireMention && canDetectMention) {
if (mentionGate.shouldSkip) {
logger.info({ chatId, reason: "no-mention" }, "skipping group message");
- if (historyKey && historyLimit > 0) {
- recordPendingHistoryEntry({
- historyMap: groupHistories,
- historyKey,
- limit: historyLimit,
- entry: {
- sender: buildSenderLabel(msg, senderId || chatId),
- body: rawBody,
- timestamp: msg.date ? msg.date * 1000 : undefined,
- messageId: typeof msg.message_id === "number" ? String(msg.message_id) : undefined,
- },
- });
- }
+ recordPendingHistoryEntryIfEnabled({
+ historyMap: groupHistories,
+ historyKey: historyKey ?? "",
+ limit: historyLimit,
+ entry: historyKey
+ ? {
+ sender: buildSenderLabel(msg, senderId || chatId),
+ body: rawBody,
+ timestamp: msg.date ? msg.date * 1000 : undefined,
+ messageId: typeof msg.message_id === "number" ? String(msg.message_id) : undefined,
+ }
+ : null,
+ });
return null;
}
}
@@ -369,19 +368,20 @@ export const buildTelegramMessageContext = async ({
// ACK reactions
const ackReaction = resolveAckReaction(cfg, route.agentId);
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
- const shouldAckReaction = () => {
- if (!ackReaction) return false;
- if (ackReactionScope === "all") return true;
- if (ackReactionScope === "direct") return !isGroup;
- if (ackReactionScope === "group-all") return isGroup;
- if (ackReactionScope === "group-mentions") {
- if (!isGroup) return false;
- if (!requireMention) return false;
- if (!canDetectMention) return false;
- return effectiveWasMentioned;
- }
- return false;
- };
+ const shouldAckReaction = () =>
+ Boolean(
+ ackReaction &&
+ shouldAckReactionGate({
+ scope: ackReactionScope,
+ isDirect: !isGroup,
+ isGroup,
+ isMentionableGroup: isGroup,
+ requireMention: Boolean(requireMention),
+ canDetectMention,
+ effectiveWasMentioned,
+ shouldBypassMention: mentionGate.shouldBypassMention,
+ }),
+ );
const api = bot.api as unknown as {
setMessageReaction?: (
chatId: number | string,
@@ -517,12 +517,21 @@ export const buildTelegramMessageContext = async ({
OriginatingTo: `telegram:${chatId}`,
});
- void recordSessionMetaFromInbound({
+ await recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
- }).catch((err) => {
- logVerbose(`telegram: failed updating session meta: ${String(err)}`);
+ updateLastRoute: !isGroup
+ ? {
+ sessionKey: route.mainSessionKey,
+ channel: "telegram",
+ to: String(chatId),
+ accountId: route.accountId,
+ }
+ : undefined,
+ onRecordError: (err) => {
+ logVerbose(`telegram: failed updating session meta: ${String(err)}`);
+ },
});
if (replyTarget && shouldLogVerbose()) {
@@ -538,19 +547,6 @@ export const buildTelegramMessageContext = async ({
);
}
- if (!isGroup) {
- await updateLastRoute({
- storePath,
- sessionKey: route.mainSessionKey,
- deliveryContext: {
- channel: "telegram",
- to: String(chatId),
- accountId: route.accountId,
- },
- ctx: ctxPayload,
- });
- }
-
if (shouldLogVerbose()) {
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
const mediaInfo = allMedia.length > 1 ? ` mediaCount=${allMedia.length}` : "";
diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts
index 4afbaa653..98c5a6d40 100644
--- a/src/telegram/bot-message-dispatch.ts
+++ b/src/telegram/bot-message-dispatch.ts
@@ -1,12 +1,11 @@
// @ts-nocheck
-import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../agents/identity.js";
-import {
- extractShortModelName,
- type ResponsePrefixContext,
-} from "../auto-reply/reply/response-prefix-template.js";
import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js";
-import { clearHistoryEntries } from "../auto-reply/reply/history.js";
+import { clearHistoryEntriesIfEnabled } from "../auto-reply/reply/history.js";
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
+import { removeAckReactionAfterReply } from "../channels/ack-reactions.js";
+import { logAckFailure, logTypingFailure } from "../channels/logging.js";
+import { createReplyPrefixContext } from "../channels/reply-prefix.js";
+import { createTypingCallbacks } from "../channels/typing.js";
import { danger, logVerbose } from "../globals.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
import { deliverReplies } from "./bot/delivery.js";
@@ -120,10 +119,7 @@ export const dispatchTelegramMessage = async ({
Boolean(draftStream) ||
(typeof telegramCfg.blockStreaming === "boolean" ? !telegramCfg.blockStreaming : undefined);
- // Create mutable context for response prefix template interpolation
- let prefixContext: ResponsePrefixContext = {
- identityName: resolveIdentityName(cfg, route.agentId),
- };
+ const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId });
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "telegram",
@@ -134,8 +130,8 @@ export const dispatchTelegramMessage = async ({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
- responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
- responsePrefixContextProvider: () => prefixContext,
+ responsePrefix: prefixContext.responsePrefix,
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
deliver: async (payload, info) => {
if (info.kind === "final") {
await flushDraft();
@@ -157,7 +153,17 @@ export const dispatchTelegramMessage = async ({
onError: (err, info) => {
runtime.error?.(danger(`telegram ${info.kind} reply failed: ${String(err)}`));
},
- onReplyStart: sendTyping,
+ onReplyStart: createTypingCallbacks({
+ start: sendTyping,
+ onStartError: (err) => {
+ logTypingFailure({
+ log: logVerbose,
+ channel: "telegram",
+ target: String(chatId),
+ error: err,
+ });
+ },
+ }).onReplyStart,
},
replyOptions: {
skillFilter,
@@ -169,32 +175,33 @@ export const dispatchTelegramMessage = async ({
: undefined,
disableBlockStreaming,
onModelSelected: (ctx) => {
- // Mutate the object directly instead of reassigning to ensure the closure sees updates
- prefixContext.provider = ctx.provider;
- prefixContext.model = extractShortModelName(ctx.model);
- prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
- prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
+ prefixContext.onModelSelected(ctx);
},
},
});
draftStream?.stop();
if (!queuedFinal) {
- if (isGroup && historyKey && historyLimit > 0) {
- clearHistoryEntries({ historyMap: groupHistories, historyKey });
+ if (isGroup && historyKey) {
+ clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit });
}
return;
}
- if (removeAckAfterReply && ackReactionPromise && msg.message_id && reactionApi) {
- void ackReactionPromise.then((didAck) => {
- if (!didAck) return;
- reactionApi(chatId, msg.message_id, []).catch((err) => {
- logVerbose(
- `telegram: failed to remove ack reaction from ${chatId}/${msg.message_id}: ${String(err)}`,
- );
+ removeAckReactionAfterReply({
+ removeAfterReply: removeAckAfterReply,
+ ackReactionPromise,
+ ackReactionValue: ackReactionPromise ? "ack" : null,
+ remove: () => reactionApi?.(chatId, msg.message_id ?? 0, []) ?? Promise.resolve(),
+ onError: (err) => {
+ if (!msg.message_id) return;
+ logAckFailure({
+ log: logVerbose,
+ channel: "telegram",
+ target: `${chatId}/${msg.message_id}`,
+ error: err,
});
- });
- }
- if (isGroup && historyKey && historyLimit > 0) {
- clearHistoryEntries({ historyMap: groupHistories, historyKey });
+ },
+ });
+ if (isGroup && historyKey) {
+ clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit });
}
};
diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts
index 4fea3521a..1a6afa519 100644
--- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts
+++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts
@@ -1,7 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
-import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
-import { createTelegramBot } from "./bot.js";
+
+let createTelegramBot: typeof import("./bot.js").createTelegramBot;
+let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
+
+const { sessionStorePath } = vi.hoisted(() => ({
+ sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`,
+}));
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
@@ -22,6 +27,14 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
+vi.mock("../config/sessions.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath),
+ };
+});
+
const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({
readTelegramAllowFromStore: vi.fn(async () => [] as string[]),
upsertTelegramPairingRequest: vi.fn(async () => ({
@@ -111,7 +124,7 @@ vi.mock("../auto-reply/reply.js", () => {
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
});
-const replyModule = await import("../auto-reply/reply.js");
+let replyModule: typeof import("../auto-reply/reply.js");
const getOnHandler = (event: string) => {
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
@@ -121,7 +134,11 @@ const getOnHandler = (event: string) => {
const ORIGINAL_TZ = process.env.TZ;
describe("createTelegramBot", () => {
- beforeEach(() => {
+ beforeEach(async () => {
+ vi.resetModules();
+ ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
+ ({ createTelegramBot } = await import("./bot.js"));
+ replyModule = await import("../auto-reply/reply.js");
process.env.TZ = "UTC";
resetInboundDedupe();
loadConfig.mockReturnValue({
diff --git a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts b/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts
index 2afe8cd1c..0cda853be 100644
--- a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts
+++ b/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts
@@ -1,6 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
-import { createTelegramBot } from "./bot.js";
+
+let createTelegramBot: typeof import("./bot.js").createTelegramBot;
+let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
+
+const { sessionStorePath } = vi.hoisted(() => ({
+ sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`,
+}));
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
@@ -21,6 +26,14 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
+vi.mock("../config/sessions.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath),
+ };
+});
+
const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({
readTelegramAllowFromStore: vi.fn(async () => [] as string[]),
upsertTelegramPairingRequest: vi.fn(async () => ({
@@ -110,7 +123,7 @@ vi.mock("../auto-reply/reply.js", () => {
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
});
-const replyModule = await import("../auto-reply/reply.js");
+let replyModule: typeof import("../auto-reply/reply.js");
const getOnHandler = (event: string) => {
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
@@ -119,7 +132,11 @@ const getOnHandler = (event: string) => {
};
describe("createTelegramBot", () => {
- beforeEach(() => {
+ beforeEach(async () => {
+ vi.resetModules();
+ ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
+ ({ createTelegramBot } = await import("./bot.js"));
+ replyModule = await import("../auto-reply/reply.js");
resetInboundDedupe();
loadConfig.mockReturnValue({
channels: {
diff --git a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts b/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts
index 6c712ca1d..0aa431d1b 100644
--- a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts
+++ b/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts
@@ -1,6 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
-import { createTelegramBot } from "./bot.js";
+
+let createTelegramBot: typeof import("./bot.js").createTelegramBot;
+let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
+
+const { sessionStorePath } = vi.hoisted(() => ({
+ sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`,
+}));
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
@@ -21,6 +26,14 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
+vi.mock("../config/sessions.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath),
+ };
+});
+
const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({
readTelegramAllowFromStore: vi.fn(async () => [] as string[]),
upsertTelegramPairingRequest: vi.fn(async () => ({
@@ -110,7 +123,7 @@ vi.mock("../auto-reply/reply.js", () => {
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
});
-const replyModule = await import("../auto-reply/reply.js");
+let replyModule: typeof import("../auto-reply/reply.js");
const getOnHandler = (event: string) => {
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
@@ -119,7 +132,11 @@ const getOnHandler = (event: string) => {
};
describe("createTelegramBot", () => {
- beforeEach(() => {
+ beforeEach(async () => {
+ vi.resetModules();
+ ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
+ ({ createTelegramBot } = await import("./bot.js"));
+ replyModule = await import("../auto-reply/reply.js");
resetInboundDedupe();
loadConfig.mockReturnValue({
channels: {
diff --git a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts b/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts
index 9ed0ed677..8ed8e189f 100644
--- a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts
+++ b/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts
@@ -1,6 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
-import { createTelegramBot } from "./bot.js";
+
+let createTelegramBot: typeof import("./bot.js").createTelegramBot;
+let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
+
+const { sessionStorePath } = vi.hoisted(() => ({
+ sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`,
+}));
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
@@ -21,6 +26,14 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
+vi.mock("../config/sessions.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath),
+ };
+});
+
const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({
readTelegramAllowFromStore: vi.fn(async () => [] as string[]),
upsertTelegramPairingRequest: vi.fn(async () => ({
@@ -110,7 +123,7 @@ vi.mock("../auto-reply/reply.js", () => {
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
});
-const replyModule = await import("../auto-reply/reply.js");
+let replyModule: typeof import("../auto-reply/reply.js");
const getOnHandler = (event: string) => {
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
@@ -119,7 +132,11 @@ const getOnHandler = (event: string) => {
};
describe("createTelegramBot", () => {
- beforeEach(() => {
+ beforeEach(async () => {
+ vi.resetModules();
+ ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
+ ({ createTelegramBot } = await import("./bot.js"));
+ replyModule = await import("../auto-reply/reply.js");
resetInboundDedupe();
loadConfig.mockReturnValue({
channels: {
diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts
index ab43c4269..ebbd3b092 100644
--- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts
+++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts
@@ -1,9 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
-import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
-import { createTelegramBot, getTelegramSequentialKey } from "./bot.js";
import { resolveTelegramFetch } from "./fetch.js";
+let createTelegramBot: typeof import("./bot.js").createTelegramBot;
+let getTelegramSequentialKey: typeof import("./bot.js").getTelegramSequentialKey;
+let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
+
+const { sessionStorePath } = vi.hoisted(() => ({
+ sessionStorePath: `/tmp/clawdbot-telegram-throttler-${Math.random().toString(16).slice(2)}.json`,
+}));
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
}));
@@ -23,6 +28,14 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
+vi.mock("../config/sessions.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath),
+ };
+});
+
const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({
readTelegramAllowFromStore: vi.fn(async () => [] as string[]),
upsertTelegramPairingRequest: vi.fn(async () => ({
@@ -114,7 +127,7 @@ vi.mock("../auto-reply/reply.js", () => {
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
});
-const replyModule = await import("../auto-reply/reply.js");
+let replyModule: typeof import("../auto-reply/reply.js");
const getOnHandler = (event: string) => {
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
@@ -125,7 +138,11 @@ const getOnHandler = (event: string) => {
const ORIGINAL_TZ = process.env.TZ;
describe("createTelegramBot", () => {
- beforeEach(() => {
+ beforeEach(async () => {
+ vi.resetModules();
+ ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
+ ({ createTelegramBot, getTelegramSequentialKey } = await import("./bot.js"));
+ replyModule = await import("../auto-reply/reply.js");
process.env.TZ = "UTC";
resetInboundDedupe();
loadConfig.mockReturnValue({
diff --git a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts
index dfdcf43e3..805aa34da 100644
--- a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts
+++ b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts
@@ -1,6 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
-import { createTelegramBot } from "./bot.js";
+
+let createTelegramBot: typeof import("./bot.js").createTelegramBot;
+let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
+
+const { sessionStorePath } = vi.hoisted(() => ({
+ sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`,
+}));
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
@@ -21,6 +26,14 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
+vi.mock("../config/sessions.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath),
+ };
+});
+
const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({
readTelegramAllowFromStore: vi.fn(async () => [] as string[]),
upsertTelegramPairingRequest: vi.fn(async () => ({
@@ -110,7 +123,7 @@ vi.mock("../auto-reply/reply.js", () => {
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
});
-const replyModule = await import("../auto-reply/reply.js");
+let replyModule: typeof import("../auto-reply/reply.js");
const getOnHandler = (event: string) => {
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
@@ -119,7 +132,11 @@ const getOnHandler = (event: string) => {
};
describe("createTelegramBot", () => {
- beforeEach(() => {
+ beforeEach(async () => {
+ vi.resetModules();
+ ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
+ ({ createTelegramBot } = await import("./bot.js"));
+ replyModule = await import("../auto-reply/reply.js");
resetInboundDedupe();
loadConfig.mockReturnValue({
channels: {
diff --git a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts b/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts
index 1e1174fbf..ec81283bb 100644
--- a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts
+++ b/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts
@@ -1,6 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
-import { createTelegramBot } from "./bot.js";
+
+let createTelegramBot: typeof import("./bot.js").createTelegramBot;
+let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
+
+const { sessionStorePath } = vi.hoisted(() => ({
+ sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`,
+}));
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
@@ -21,6 +26,14 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
+vi.mock("../config/sessions.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath),
+ };
+});
+
const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({
readTelegramAllowFromStore: vi.fn(async () => [] as string[]),
upsertTelegramPairingRequest: vi.fn(async () => ({
@@ -110,7 +123,7 @@ vi.mock("../auto-reply/reply.js", () => {
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
});
-const replyModule = await import("../auto-reply/reply.js");
+let replyModule: typeof import("../auto-reply/reply.js");
const getOnHandler = (event: string) => {
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
@@ -119,7 +132,11 @@ const getOnHandler = (event: string) => {
};
describe("createTelegramBot", () => {
- beforeEach(() => {
+ beforeEach(async () => {
+ vi.resetModules();
+ ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
+ ({ createTelegramBot } = await import("./bot.js"));
+ replyModule = await import("../auto-reply/reply.js");
resetInboundDedupe();
loadConfig.mockReturnValue({
channels: {
diff --git a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts
index 6e83c61c3..fd9401dac 100644
--- a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts
+++ b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts
@@ -1,6 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
-import { createTelegramBot } from "./bot.js";
+
+let createTelegramBot: typeof import("./bot.js").createTelegramBot;
+let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
+
+const { sessionStorePath } = vi.hoisted(() => ({
+ sessionStorePath: `/tmp/clawdbot-telegram-${Math.random().toString(16).slice(2)}.json`,
+}));
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
@@ -21,6 +26,14 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
+vi.mock("../config/sessions.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath),
+ };
+});
+
const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({
readTelegramAllowFromStore: vi.fn(async () => [] as string[]),
upsertTelegramPairingRequest: vi.fn(async () => ({
@@ -110,7 +123,7 @@ vi.mock("../auto-reply/reply.js", () => {
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
});
-const replyModule = await import("../auto-reply/reply.js");
+let replyModule: typeof import("../auto-reply/reply.js");
const getOnHandler = (event: string) => {
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
@@ -119,7 +132,11 @@ const getOnHandler = (event: string) => {
};
describe("createTelegramBot", () => {
- beforeEach(() => {
+ beforeEach(async () => {
+ vi.resetModules();
+ ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
+ ({ createTelegramBot } = await import("./bot.js"));
+ replyModule = await import("../auto-reply/reply.js");
resetInboundDedupe();
loadConfig.mockReturnValue({
channels: {
diff --git a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts b/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts
index 74f87d63b..80c880b79 100644
--- a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts
+++ b/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts
@@ -2,8 +2,15 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
-import { createTelegramBot } from "./bot.js";
+
+let createTelegramBot: typeof import("./bot.js").createTelegramBot;
+let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
+
+const { sessionStorePath } = vi.hoisted(() => ({
+ sessionStorePath: `/tmp/clawdbot-telegram-reply-threading-${Math.random()
+ .toString(16)
+ .slice(2)}.json`,
+}));
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
@@ -24,6 +31,14 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
+vi.mock("../config/sessions.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath),
+ };
+});
+
const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({
readTelegramAllowFromStore: vi.fn(async () => [] as string[]),
upsertTelegramPairingRequest: vi.fn(async () => ({
@@ -113,7 +128,7 @@ vi.mock("../auto-reply/reply.js", () => {
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
});
-const replyModule = await import("../auto-reply/reply.js");
+let replyModule: typeof import("../auto-reply/reply.js");
const getOnHandler = (event: string) => {
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
@@ -122,7 +137,11 @@ const getOnHandler = (event: string) => {
};
describe("createTelegramBot", () => {
- beforeEach(() => {
+ beforeEach(async () => {
+ vi.resetModules();
+ ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
+ ({ createTelegramBot } = await import("./bot.js"));
+ replyModule = await import("../auto-reply/reply.js");
resetInboundDedupe();
loadConfig.mockReturnValue({
channels: {
diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts
index 51beb4f4b..d4cdfaf4b 100644
--- a/src/telegram/bot.test.ts
+++ b/src/telegram/bot.test.ts
@@ -6,18 +6,24 @@ import {
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
} from "../auto-reply/commands-registry.js";
+import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
+import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js";
+import { resolveTelegramFetch } from "./fetch.js";
+
+let createTelegramBot: typeof import("./bot.js").createTelegramBot;
+let getTelegramSequentialKey: typeof import("./bot.js").getTelegramSequentialKey;
+let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
+let replyModule: typeof import("../auto-reply/reply.js");
const { listSkillCommandsForAgents } = vi.hoisted(() => ({
listSkillCommandsForAgents: vi.fn(() => []),
}));
vi.mock("../auto-reply/skill-commands.js", () => ({
listSkillCommandsForAgents,
}));
-import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
-import * as replyModule from "../auto-reply/reply.js";
-import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js";
-import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
-import { createTelegramBot, getTelegramSequentialKey } from "./bot.js";
-import { resolveTelegramFetch } from "./fetch.js";
+
+const { sessionStorePath } = vi.hoisted(() => ({
+ sessionStorePath: `/tmp/clawdbot-telegram-bot-${Math.random().toString(16).slice(2)}.json`,
+}));
function resolveSkillCommands(config: Parameters[0]) {
return listSkillCommandsForAgents({ cfg: config });
@@ -42,6 +48,14 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
+vi.mock("../config/sessions.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath),
+ };
+});
+
const { readTelegramAllowFromStore, upsertTelegramPairingRequest } = vi.hoisted(() => ({
readTelegramAllowFromStore: vi.fn(async () => [] as string[]),
upsertTelegramPairingRequest: vi.fn(async () => ({
@@ -155,7 +169,11 @@ const getOnHandler = (event: string) => {
const ORIGINAL_TZ = process.env.TZ;
describe("createTelegramBot", () => {
- beforeEach(() => {
+ beforeEach(async () => {
+ vi.resetModules();
+ ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js"));
+ ({ createTelegramBot, getTelegramSequentialKey } = await import("./bot.js"));
+ replyModule = await import("../auto-reply/reply.js");
process.env.TZ = "UTC";
resetInboundDedupe();
loadConfig.mockReturnValue({
diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts
index 7bedb4d62..a14172809 100644
--- a/src/tui/tui-command-handlers.ts
+++ b/src/tui/tui-command-handlers.ts
@@ -408,6 +408,12 @@ export function createCommandHandlers(context: CommandHandlerContext) {
case "new":
case "reset":
try {
+ // Clear token counts immediately to avoid stale display (#1523)
+ state.sessionInfo.inputTokens = null;
+ state.sessionInfo.outputTokens = null;
+ state.sessionInfo.totalTokens = null;
+ tui.requestRender();
+
await client.resetSession(state.currentSessionKey);
chatLog.addSystem(`session ${state.currentSessionKey} reset`);
await loadHistory();
diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts
index 3f8e2befd..148dca67a 100644
--- a/src/tui/tui-event-handlers.ts
+++ b/src/tui/tui-event-handlers.ts
@@ -1,6 +1,6 @@
import type { TUI } from "@mariozechner/pi-tui";
import type { ChatLog } from "./components/chat-log.js";
-import { asString } from "./tui-formatters.js";
+import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js";
import { TuiStreamAssembler } from "./tui-stream-assembler.js";
import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
@@ -49,6 +49,17 @@ export function createEventHandlers(context: EventHandlerContext) {
setActivityStatus("streaming");
}
if (evt.state === "final") {
+ if (isCommandMessage(evt.message)) {
+ const text = extractTextFromMessage(evt.message);
+ if (text) chatLog.addSystem(text);
+ streamAssembler.drop(evt.runId);
+ noteFinalizedRun(evt.runId);
+ state.activeChatRunId = null;
+ setActivityStatus("idle");
+ void refreshSessionInfo?.();
+ tui.requestRender();
+ return;
+ }
const stopReason =
evt.message && typeof evt.message === "object" && !Array.isArray(evt.message)
? typeof (evt.message as Record).stopReason === "string"
diff --git a/src/tui/tui-formatters.test.ts b/src/tui/tui-formatters.test.ts
index 541c58727..3200b237a 100644
--- a/src/tui/tui-formatters.test.ts
+++ b/src/tui/tui-formatters.test.ts
@@ -4,6 +4,7 @@ import {
extractContentFromMessage,
extractTextFromMessage,
extractThinkingFromMessage,
+ isCommandMessage,
} from "./tui-formatters.js";
describe("extractTextFromMessage", () => {
@@ -98,3 +99,11 @@ describe("extractContentFromMessage", () => {
expect(text).toContain("HTTP 429");
});
});
+
+describe("isCommandMessage", () => {
+ it("detects command-marked messages", () => {
+ expect(isCommandMessage({ command: true })).toBe(true);
+ expect(isCommandMessage({ command: false })).toBe(false);
+ expect(isCommandMessage({})).toBe(false);
+ });
+});
diff --git a/src/tui/tui-formatters.ts b/src/tui/tui-formatters.ts
index 11e8e68c9..f77eb9ff1 100644
--- a/src/tui/tui-formatters.ts
+++ b/src/tui/tui-formatters.ts
@@ -140,6 +140,11 @@ export function extractTextFromMessage(
return formatRawAssistantErrorForUi(errorMessage);
}
+export function isCommandMessage(message: unknown): boolean {
+ if (!message || typeof message !== "object") return false;
+ return (message as Record).command === true;
+}
+
export function formatTokens(total?: number | null, context?: number | null) {
if (total == null && context == null) return "tokens ?";
const totalLabel = total == null ? "?" : formatTokenCount(total);
diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts
index 327363653..5dc6696ad 100644
--- a/src/tui/tui-session-actions.ts
+++ b/src/tui/tui-session-actions.ts
@@ -6,7 +6,7 @@ import {
} from "../routing/session-key.js";
import type { ChatLog } from "./components/chat-log.js";
import type { GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js";
-import { asString, extractTextFromMessage } from "./tui-formatters.js";
+import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js";
import type { TuiOptions, TuiStateAccess } from "./tui-types.js";
type SessionActionContext = {
@@ -161,6 +161,11 @@ export function createSessionActions(context: SessionActionContext) {
for (const entry of record.messages ?? []) {
if (!entry || typeof entry !== "object") continue;
const message = entry as Record;
+ if (isCommandMessage(message)) {
+ const text = extractTextFromMessage(message);
+ if (text) chatLog.addSystem(text);
+ continue;
+ }
if (message.role === "user") {
const text = extractTextFromMessage(message);
if (text) chatLog.addUser(text);
diff --git a/src/web/auto-reply/monitor/ack-reaction.ts b/src/web/auto-reply/monitor/ack-reaction.ts
index 58a2504cd..6a99da312 100644
--- a/src/web/auto-reply/monitor/ack-reaction.ts
+++ b/src/web/auto-reply/monitor/ack-reaction.ts
@@ -1,5 +1,6 @@
import type { loadConfig } from "../../../config/config.js";
import { logVerbose } from "../../../globals.js";
+import { shouldAckReactionForWhatsApp } from "../../../channels/ack-reactions.js";
import { sendReactionWhatsApp } from "../../outbound.js";
import { formatError } from "../../session.js";
import type { WebInboundMsg } from "../types.js";
@@ -24,30 +25,25 @@ export function maybeSendAckReaction(params: {
const groupMode = ackConfig?.group ?? "mentions";
const conversationIdForCheck = params.msg.conversationId ?? params.msg.from;
- const shouldSendReaction = () => {
- if (!emoji) return false;
-
- if (params.msg.chatType === "direct") {
- return directEnabled;
- }
-
- if (params.msg.chatType === "group") {
- if (groupMode === "never") return false;
- if (groupMode === "always") return true;
- if (groupMode === "mentions") {
- const activation = resolveGroupActivationFor({
+ const activation =
+ params.msg.chatType === "group"
+ ? resolveGroupActivationFor({
cfg: params.cfg,
agentId: params.agentId,
sessionKey: params.sessionKey,
conversationId: conversationIdForCheck,
- });
- if (activation === "always") return true;
- return params.msg.wasMentioned === true;
- }
- }
-
- return false;
- };
+ })
+ : null;
+ const shouldSendReaction = () =>
+ shouldAckReactionForWhatsApp({
+ emoji,
+ isDirect: params.msg.chatType === "direct",
+ isGroup: params.msg.chatType === "group",
+ directEnabled,
+ groupMode,
+ wasMentioned: params.msg.wasMentioned === true,
+ groupActivated: activation === "always",
+ });
if (!shouldSendReaction()) return;
diff --git a/src/web/auto-reply/monitor/group-gating.ts b/src/web/auto-reply/monitor/group-gating.ts
index a4e46d41e..8d1a33645 100644
--- a/src/web/auto-reply/monitor/group-gating.ts
+++ b/src/web/auto-reply/monitor/group-gating.ts
@@ -6,7 +6,7 @@ import { resolveMentionGating } from "../../../channels/mention-gating.js";
import type { MentionConfig } from "../mentions.js";
import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js";
import type { WebInboundMsg } from "../types.js";
-import { recordPendingHistoryEntry } from "../../../auto-reply/reply/history.js";
+import { recordPendingHistoryEntryIfEnabled } from "../../../auto-reply/reply/history.js";
import { stripMentionsForCommand } from "./commands.js";
import { resolveGroupActivationFor, resolveGroupPolicyFor } from "./group-activation.js";
import { noteGroupMember } from "./group-members.js";
@@ -66,24 +66,22 @@ export function applyGroupGating(params: {
if (activationCommand.hasCommand && !owner) {
params.logVerbose(`Ignoring /activation from non-owner in group ${params.conversationId}`);
- if (params.groupHistoryLimit > 0) {
- const sender =
- params.msg.senderName && params.msg.senderE164
- ? `${params.msg.senderName} (${params.msg.senderE164})`
- : (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown");
- recordPendingHistoryEntry({
- historyMap: params.groupHistories,
- historyKey: params.groupHistoryKey,
- limit: params.groupHistoryLimit,
- entry: {
- sender,
- body: params.msg.body,
- timestamp: params.msg.timestamp,
- id: params.msg.id,
- senderJid: params.msg.senderJid,
- },
- });
- }
+ const sender =
+ params.msg.senderName && params.msg.senderE164
+ ? `${params.msg.senderName} (${params.msg.senderE164})`
+ : (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown");
+ recordPendingHistoryEntryIfEnabled({
+ historyMap: params.groupHistories,
+ historyKey: params.groupHistoryKey,
+ limit: params.groupHistoryLimit,
+ entry: {
+ sender,
+ body: params.msg.body,
+ timestamp: params.msg.timestamp,
+ id: params.msg.id,
+ senderJid: params.msg.senderJid,
+ },
+ });
return { shouldProcess: false };
}
@@ -126,24 +124,22 @@ export function applyGroupGating(params: {
params.logVerbose(
`Group message stored for context (no mention detected) in ${params.conversationId}: ${params.msg.body}`,
);
- if (params.groupHistoryLimit > 0) {
- const sender =
- params.msg.senderName && params.msg.senderE164
- ? `${params.msg.senderName} (${params.msg.senderE164})`
- : (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown");
- recordPendingHistoryEntry({
- historyMap: params.groupHistories,
- historyKey: params.groupHistoryKey,
- limit: params.groupHistoryLimit,
- entry: {
- sender,
- body: params.msg.body,
- timestamp: params.msg.timestamp,
- id: params.msg.id,
- senderJid: params.msg.senderJid,
- },
- });
- }
+ const sender =
+ params.msg.senderName && params.msg.senderE164
+ ? `${params.msg.senderName} (${params.msg.senderE164})`
+ : (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown");
+ recordPendingHistoryEntryIfEnabled({
+ historyMap: params.groupHistories,
+ historyKey: params.groupHistoryKey,
+ limit: params.groupHistoryLimit,
+ entry: {
+ sender,
+ body: params.msg.body,
+ timestamp: params.msg.timestamp,
+ id: params.msg.id,
+ senderJid: params.msg.senderJid,
+ },
+ });
return { shouldProcess: false };
}
diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts
index c1d280a65..57ad5448f 100644
--- a/src/web/auto-reply/monitor/process-message.ts
+++ b/src/web/auto-reply/monitor/process-message.ts
@@ -1,12 +1,4 @@
-import {
- resolveEffectiveMessagesConfig,
- resolveIdentityName,
- resolveIdentityNamePrefix,
-} from "../../../agents/identity.js";
-import {
- extractShortModelName,
- type ResponsePrefixContext,
-} from "../../../auto-reply/reply/response-prefix-template.js";
+import { resolveIdentityNamePrefix } from "../../../agents/identity.js";
import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js";
import {
formatInboundEnvelope,
@@ -22,6 +14,7 @@ import type { ReplyPayload } from "../../../auto-reply/types.js";
import { shouldComputeCommandAuthorized } from "../../../auto-reply/command-detection.js";
import { finalizeInboundContext } from "../../../auto-reply/reply/inbound-context.js";
import { toLocationContext } from "../../../channels/location.js";
+import { createReplyPrefixContext } from "../../../channels/reply-prefix.js";
import type { loadConfig } from "../../../config/config.js";
import {
readSessionUpdatedAt,
@@ -247,22 +240,20 @@ export async function processMessage(params: {
? await resolveWhatsAppCommandAuthorized({ cfg: params.cfg, msg: params.msg })
: undefined;
const configuredResponsePrefix = params.cfg.messages?.responsePrefix;
- const resolvedMessages = resolveEffectiveMessagesConfig(params.cfg, params.route.agentId);
+ const prefixContext = createReplyPrefixContext({
+ cfg: params.cfg,
+ agentId: params.route.agentId,
+ });
const isSelfChat =
params.msg.chatType !== "group" &&
Boolean(params.msg.selfE164) &&
normalizeE164(params.msg.from) === normalizeE164(params.msg.selfE164 ?? "");
const responsePrefix =
- resolvedMessages.responsePrefix ??
+ prefixContext.responsePrefix ??
(configuredResponsePrefix === undefined && isSelfChat
? (resolveIdentityNamePrefix(params.cfg, params.route.agentId) ?? "[clawdbot]")
: undefined);
- // Create mutable context for response prefix template interpolation
- let prefixContext: ResponsePrefixContext = {
- identityName: resolveIdentityName(params.cfg, params.route.agentId),
- };
-
const ctxPayload = finalizeInboundContext({
Body: combinedBody,
RawBody: params.msg.body,
@@ -334,7 +325,7 @@ export async function processMessage(params: {
replyResolver: params.replyResolver,
dispatcherOptions: {
responsePrefix,
- responsePrefixContextProvider: () => prefixContext,
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
onHeartbeatStrip: () => {
if (!didLogHeartbeatStrip) {
didLogHeartbeatStrip = true;
@@ -393,13 +384,7 @@ export async function processMessage(params: {
typeof params.cfg.channels?.whatsapp?.blockStreaming === "boolean"
? !params.cfg.channels.whatsapp.blockStreaming
: undefined,
- onModelSelected: (ctx) => {
- // Mutate the object directly instead of reassigning to ensure the closure sees updates
- prefixContext.provider = ctx.provider;
- prefixContext.model = extractShortModelName(ctx.model);
- prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
- prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
- },
+ onModelSelected: prefixContext.onModelSelected,
},
});