Compare commits

...

7 Commits

Author SHA1 Message Date
Peter Steinberger
ca26e17273 fix: avoid node invoke deadlock in macOS gateway (#1752) (thanks @ngutman) 2026-01-25 11:47:55 +00:00
Nimrod Gutman
34ab1d245c refactor(gateway): share request encoding 2026-01-25 11:35:00 +00:00
Nimrod Gutman
a11b98f801 fix(node): avoid invoke result deadlock 2026-01-25 11:35:00 +00:00
Robby
67db63ba05 fix: enable scrolling in settings page on Windows (#1780)
Fixes #1743

The settings page was unable to scroll because .config-layout has
overflow:hidden which blocks child scrolling. Added min-height:0 and
overflow-y:auto to .config-main to enable scrolling within the grid
layout.
2026-01-25 11:34:01 +00:00
Peter Steinberger
bbefb2e5a5 docs: add GPT 5.2 vs Codex FAQ 2026-01-25 11:26:30 +00:00
Peter Steinberger
50f233d16d chore: stabilize prek hooks runner selection (#1720) (thanks @dguido) 2026-01-25 10:55:28 +00:00
Dan Guido
48aea87028 feat: add prek pre-commit hooks and dependabot (#1720)
* feat: add prek pre-commit hooks and dependabot

Pre-commit hooks (via prek):
- Basic hygiene: trailing-whitespace, end-of-file-fixer, check-yaml, check-added-large-files, check-merge-conflict
- Security: detect-secrets, zizmor (GitHub Actions audit)
- Linting: shellcheck, actionlint, oxlint, swiftlint
- Formatting: oxfmt, swiftformat

Dependabot:
- npm and GitHub Actions ecosystems
- Grouped updates (production/development/actions)
- 7-day cooldown for supply chain protection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: add prek install instruction to AGENTS.md

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 10:53:23 +00:00
76 changed files with 2441 additions and 252 deletions

View File

@@ -7,6 +7,10 @@
[exclude-files]
# pnpm lockfiles contain lots of high-entropy package integrity blobs.
pattern = (^|/)pnpm-lock\.yaml$
# Generated output and vendored assets.
pattern = (^|/)(dist|vendor)/
# Local config file with allowlist patterns.
pattern = (^|/)\.detect-secrets\.cfg$
[exclude-lines]
# Fastlane checks for private key marker; not a real key.

17
.github/actionlint.yaml vendored Normal file
View File

@@ -0,0 +1,17 @@
# actionlint configuration
# https://github.com/rhysd/actionlint/blob/main/docs/config.md
self-hosted-runner:
labels:
# Blacksmith CI runners
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-4vcpu-windows-2025
# Ignore patterns for known issues
paths:
.github/workflows/**/*.yml:
ignore:
# Ignore shellcheck warnings (we run shellcheck separately)
- 'shellcheck reported issue.+'
# Ignore intentional if: false for disabled jobs
- 'constant expression "false" in condition'

113
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,113 @@
# Dependabot configuration
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
registries:
npm-npmjs:
type: npm-registry
url: https://registry.npmjs.org
replaces-base: true
updates:
# npm dependencies (root)
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
production:
dependency-type: production
update-types:
- minor
- patch
development:
dependency-type: development
update-types:
- minor
- patch
open-pull-requests-limit: 10
registries:
- npm-npmjs
# GitHub Actions
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
actions:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Swift Package Manager - macOS app
- package-ecosystem: swift
directory: /apps/macos
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
swift-deps:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Swift Package Manager - shared ClawdbotKit
- package-ecosystem: swift
directory: /apps/shared/ClawdbotKit
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
swift-deps:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Swift Package Manager - Swabble
- package-ecosystem: swift
directory: /Swabble
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
swift-deps:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Gradle - Android app
- package-ecosystem: gradle
directory: /apps/android
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
android-deps:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5

105
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,105 @@
# Pre-commit hooks for clawdbot
# Install: prek install
# Run manually: prek run --all-files
#
# See https://pre-commit.com for more information
repos:
# Basic file hygiene
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
exclude: '^(docs/|dist/|vendor/|.*\.snap$)'
- id: end-of-file-fixer
exclude: '^(docs/|dist/|vendor/|.*\.snap$)'
- id: check-yaml
args: [--allow-multiple-documents]
- id: check-added-large-files
args: [--maxkb=500]
- id: check-merge-conflict
# Secret detection (same as CI)
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args:
- --baseline
- .secrets.baseline
- --exclude-files
- '(^|/)(dist/|vendor/|pnpm-lock\.yaml$|\.detect-secrets\.cfg$)'
- --exclude-lines
- 'key_content\.include\?\("BEGIN PRIVATE KEY"\)'
- --exclude-lines
- 'case \.apiKeyEnv: "API key \(env var\)"'
- --exclude-lines
- 'case apikey = "apiKey"'
- --exclude-lines
- '"gateway\.remote\.password"'
- --exclude-lines
- '"gateway\.auth\.password"'
- --exclude-lines
- '"talk\.apiKey"'
- --exclude-lines
- '=== "string"'
- --exclude-lines
- 'typeof remote\?\.password === "string"'
# Shell script linting
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.11.0
hooks:
- id: shellcheck
args: [--severity=error] # Only fail on errors, not warnings/info
# Exclude vendor and scripts with embedded code or known issues
exclude: '^(vendor/|scripts/e2e/)'
# GitHub Actions linting
- repo: https://github.com/rhysd/actionlint
rev: v1.7.10
hooks:
- id: actionlint
# GitHub Actions security audit
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.22.0
hooks:
- id: zizmor
args: [--persona=regular, --min-severity=medium, --min-confidence=medium]
exclude: '^(vendor/|Swabble/)'
# Project checks (same commands as CI)
- repo: local
hooks:
# oxlint --type-aware src test
- id: oxlint
name: oxlint
entry: scripts/pre-commit/run-node-tool.sh oxlint --type-aware src test
language: system
pass_filenames: false
types_or: [javascript, jsx, ts, tsx]
# oxfmt --check src test
- id: oxfmt
name: oxfmt
entry: scripts/pre-commit/run-node-tool.sh oxfmt --check src test
language: system
pass_filenames: false
types_or: [javascript, jsx, ts, tsx]
# swiftlint (same as CI)
- id: swiftlint
name: swiftlint
entry: swiftlint --config .swiftlint.yml
language: system
pass_filenames: false
types: [swift]
# swiftformat --lint (same as CI)
- id: swiftformat
name: swiftformat
entry: swiftformat --lint apps/macos/Sources --config .swiftformat
language: system
pass_filenames: false
types: [swift]

File diff suppressed because it is too large Load Diff

25
.shellcheckrc Normal file
View File

@@ -0,0 +1,25 @@
# ShellCheck configuration
# https://www.shellcheck.net/wiki/
# Disable common false positives and style suggestions
# SC2034: Variable appears unused (often exported or used indirectly)
disable=SC2034
# SC2155: Declare and assign separately (common idiom, rarely causes issues)
disable=SC2155
# SC2295: Expansions inside ${..} need quoting (info-level, rarely causes issues)
disable=SC2295
# SC1012: \r is literal (tr -d '\r' works as intended on most systems)
disable=SC1012
# SC2026: Word outside quotes (info-level, often intentional)
disable=SC2026
# SC2016: Expressions don't expand in single quotes (often intentional in sed/awk)
disable=SC2016
# SC2129: Consider using { cmd1; cmd2; } >> file (style preference)
disable=SC2129

View File

@@ -23,7 +23,7 @@
# Whitespace
--trimwhitespace always
--emptybraces no-space
--nospaceoperators ...,..<
--nospaceoperators ...,..<
--ranges no-space
--someAny true
--voidtype void

View File

@@ -37,6 +37,7 @@
## Build, Test, and Development Commands
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
- Install deps: `pnpm install`
- Pre-commit hooks: `prek install` (runs same checks as CI)
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.

View File

@@ -12,6 +12,7 @@ Docs: https://docs.clawd.bot
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
- Docs: add verbose installer troubleshooting guidance.
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
@@ -25,6 +26,7 @@ Docs: https://docs.clawd.bot
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
### Fixes
- macOS: rearm gateway receive loop before push handling to avoid node invoke stalls. (#1752) Thanks @ngutman.
- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.
- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.

View File

@@ -459,7 +459,7 @@ Use these when youre past the onboarding flow and want the deeper reference.
## Clawd
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞
by Peter Steinberger and the community.
- [clawd.me](https://clawd.me)
@@ -468,7 +468,7 @@ by Peter Steinberger and the community.
## Community
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
AI/vibe-coded PRs welcome! 🤖
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for

View File

@@ -12,4 +12,3 @@ If you believe youve found a security issue in Clawdbot, please report it pri
For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see:
- `https://docs.clawd.bot/gateway/security`

View File

@@ -212,4 +212,4 @@
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="22284796" type="application/octet-stream" sparkle:edSignature="pXji4NMA/cu35iMxln385d6LnsT4yIZtFtFiR7sIimKeSC2CsyeWzzSD0EhJsN98PdSoy69iEFZt4I2ZtNCECg=="/>
</item>
</channel>
</rss>
</rss>

View File

@@ -12,4 +12,3 @@ data class CameraHudState(
val kind: CameraHudKind,
val message: String,
)

View File

@@ -12,4 +12,3 @@ enum class VoiceWakeMode(val rawValue: String) {
}
}
}

View File

@@ -135,7 +135,7 @@ class SmsManager(private val context: Context) {
/**
* Send an SMS message.
*
*
* @param paramsJson JSON with "to" (phone number) and "message" (text) fields
* @return SendResult indicating success or failure
*/

View File

@@ -1,4 +1,3 @@
<resources>
<color name="ic_launcher_background">#0A0A0A</color>
</resources>

View File

@@ -1,4 +1,3 @@
<resources>
<string name="app_name">Clawdbot Node</string>
</resources>

View File

@@ -23,4 +23,3 @@ class VoiceWakeCommandExtractorTest {
assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude")))
}
}

View File

@@ -16,4 +16,3 @@ dependencyResolutionManagement {
rootProject.name = "ClawdbotNodeAndroid"
include(":app")

View File

@@ -3,4 +3,3 @@ parent_config: ../../.swiftlint.yml
included:
- Sources
- ../shared/ClawdisNodeKit/Sources

View File

@@ -33,4 +33,4 @@
],
"squares" : "shared"
}
}
}

View File

@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.

View File

@@ -173,4 +173,4 @@
"iPod5,1": "iPod touch (5th generation)",
"iPod7,1": "iPod touch (6th generation)",
"iPod9,1": "iPod touch (7th generation)"
}
}

View File

@@ -211,4 +211,4 @@
"Mac Pro (2019)",
"Mac Pro (Rack, 2019)"
]
}
}

View File

@@ -427,8 +427,8 @@ public actor GatewayChannelActor {
Task { await self.handleReceiveFailure(err) }
case let .success(msg):
Task {
await self.handle(msg)
await self.listen()
await self.handle(msg)
}
}
}
@@ -574,46 +574,22 @@ public actor GatewayChannelActor {
params: [String: AnyCodable]?,
timeoutMs: Double? = nil) async throws -> Data
{
do {
try await self.connect()
} catch {
throw self.wrap(error, context: "gateway connect")
}
let id = UUID().uuidString
try await self.connectOrThrow(context: "gateway connect")
let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
let paramsObject: ProtoAnyCodable? = params.map { entries in
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
dict[entry.key] = ProtoAnyCodable(entry.value.value)
}
return ProtoAnyCodable(dict)
}
let frame = RequestFrame(
type: "req",
id: id,
method: method,
params: paramsObject)
let data: Data
do {
data = try self.encoder.encode(frame)
} catch {
self.logger.error(
"gateway request encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
throw error
}
let payload = try self.encodeRequest(method: method, params: params, kind: "request")
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
self.pending[id] = cont
self.pending[payload.id] = cont
Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
await self.timeoutRequest(id: id, timeoutMs: effectiveTimeout)
await self.timeoutRequest(id: payload.id, timeoutMs: effectiveTimeout)
}
Task {
do {
try await self.task?.send(.data(data))
try await self.task?.send(.data(payload.data))
} catch {
let wrapped = self.wrap(error, context: "gateway send \(method)")
let waiter = self.pending.removeValue(forKey: id)
let waiter = self.pending.removeValue(forKey: payload.id)
// Treat send failures as a broken socket: mark disconnected and trigger reconnect.
self.connected = false
self.task?.cancel(with: .goingAway, reason: nil)
@@ -657,6 +633,42 @@ public actor GatewayChannelActor {
return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
}
private func connectOrThrow(context: String) async throws {
do {
try await self.connect()
} catch {
throw self.wrap(error, context: context)
}
}
private func encodeRequest(
method: String,
params: [String: AnyCodable]?,
kind: String) throws -> (id: String, data: Data)
{
let id = UUID().uuidString
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
let paramsObject: ProtoAnyCodable? = params.map { entries in
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
dict[entry.key] = ProtoAnyCodable(entry.value.value)
}
return ProtoAnyCodable(dict)
}
let frame = RequestFrame(
type: "req",
id: id,
method: method,
params: paramsObject)
do {
let data = try self.encoder.encode(frame)
return (id: id, data: data)
} catch {
self.logger.error(
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
throw error
}
}
private func failPending(_ error: Error) async {
let waiters = self.pending
self.pending.removeAll()

View File

@@ -0,0 +1,212 @@
import Foundation
import Testing
@testable import ClawdbotKit
import ClawdbotProtocol
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let lock = NSLock()
private var queue: [URLSessionWebSocketTask.Message] = []
private var pendingHandler: (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
private var pendingContinuation: CheckedContinuation<URLSessionWebSocketTask.Message, Error>?
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
var state: URLSessionTask.State = .running
func resume() {}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
state = .canceling
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
guard case let .data(data) = message else { return }
guard let frame = try? decoder.decode(RequestFrame.self, from: data) else { return }
switch frame.method {
case "connect":
enqueueResponse(id: frame.id, payload: helloOkPayload())
default:
enqueueResponse(id: frame.id, payload: ["ok": true])
}
}
func receive() async throws -> URLSessionWebSocketTask.Message {
try await withCheckedThrowingContinuation { cont in
lock.lock()
if !queue.isEmpty {
let msg = queue.removeFirst()
lock.unlock()
cont.resume(returning: msg)
return
}
pendingContinuation = cont
lock.unlock()
}
}
func receive(
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
lock.lock()
if !queue.isEmpty {
let msg = queue.removeFirst()
lock.unlock()
completionHandler(.success(msg))
return
}
pendingHandler = completionHandler
lock.unlock()
}
func enqueue(_ message: URLSessionWebSocketTask.Message) {
lock.lock()
if let handler = pendingHandler {
pendingHandler = nil
lock.unlock()
handler(.success(message))
return
}
if let continuation = pendingContinuation {
pendingContinuation = nil
lock.unlock()
continuation.resume(returning: message)
return
}
queue.append(message)
lock.unlock()
}
private func enqueueResponse(id: String, payload: [String: Any]) {
let response = ResponseFrame(
type: "res",
id: id,
ok: true,
payload: ClawdbotProtocol.AnyCodable(payload),
error: nil)
guard let data = try? encoder.encode(response) else { return }
enqueue(.data(data))
}
private func helloOkPayload() -> [String: Any] {
[
"type": "hello.ok",
"protocol": 1,
"server": [:],
"features": [:],
"snapshot": [
"presence": [],
"health": [:],
"stateVersion": [
"presence": 0,
"health": 0,
],
"uptimeMs": 0,
],
"policy": [
"tickIntervalMs": 1000,
],
]
}
}
private final class FakeWebSocketSession: WebSocketSessioning {
let task: FakeWebSocketTask
init(task: FakeWebSocketTask) {
self.task = task
}
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
WebSocketTaskBox(task: task)
}
}
private actor AsyncSignal {
private var continuation: CheckedContinuation<Result<Void, Error>, Never>?
private var stored: Result<Void, Error>?
func finish(_ result: Result<Void, Error>) {
if let continuation {
self.continuation = nil
continuation.resume(returning: result)
return
}
stored = result
}
func wait() async throws {
let result = await withCheckedContinuation { cont in
if let stored {
self.stored = nil
cont.resume(returning: stored)
return
}
continuation = cont
}
switch result {
case .success:
return
case let .failure(error):
throw error
}
}
}
private enum TestError: Error {
case timeout
}
struct GatewayChannelTests {
@Test
func listenRearmsBeforePushHandler() async throws {
let task = FakeWebSocketTask()
let session = FakeWebSocketSession(task: task)
let signal = AsyncSignal()
let url = URL(string: "ws://example.invalid")!
final class ChannelBox { var channel: GatewayChannelActor? }
let box = ChannelBox()
let channel = GatewayChannelActor(
url: url,
token: nil,
session: WebSocketSessionBox(session: session),
pushHandler: { push in
guard case let .event(evt) = push, evt.event == "test.event" else { return }
guard let channel = box.channel else { return }
let params: [String: ClawdbotKit.AnyCodable] = [
"event": ClawdbotKit.AnyCodable("test"),
"payloadJSON": ClawdbotKit.AnyCodable(NSNull()),
]
do {
_ = try await channel.request(method: "node.event", params: params, timeoutMs: 50)
await signal.finish(.success(()))
} catch {
await signal.finish(.failure(error))
}
})
box.channel = channel
let challenge = EventFrame(
type: "event",
event: "connect.challenge",
payload: ClawdbotProtocol.AnyCodable(["nonce": "test-nonce"]),
seq: nil,
stateversion: nil)
let encoder = JSONEncoder()
task.enqueue(.data(try encoder.encode(challenge)))
try await channel.connect()
let event = EventFrame(
type: "event",
event: "test.event",
payload: ClawdbotProtocol.AnyCodable([:]),
seq: nil,
stateversion: nil)
task.enqueue(.data(try encoder.encode(event)))
try await AsyncTimeout.withTimeout(seconds: 1, onTimeout: { TestError.timeout }) {
try await signal.wait()
}
}
}

View File

@@ -138,6 +138,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Can I use self-hosted models (llama.cpp, vLLM, Ollama)?](#can-i-use-selfhosted-models-llamacpp-vllm-ollama)
- [What do Clawd, Flawd, and Krill use for models?](#what-do-clawd-flawd-and-krill-use-for-models)
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
- [Can I use GPT 5.2 for daily tasks and Codex 5.2 for coding](#can-i-use-gpt-52-for-daily-tasks-and-codex-52-for-coding)
- [Why do I see “Model … is not allowed” and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
- [Why do I see “Unknown model: minimax/MiniMax-M2.1”?](#why-do-i-see-unknown-model-minimaxminimaxm21)
- [Can I use MiniMax as my default and OpenAI for complex tasks?](#can-i-use-minimax-as-my-default-and-openai-for-complex-tasks)
@@ -1947,6 +1948,16 @@ Re-run `/model` **without** the `@profile` suffix:
If you want to return to the default, pick it from `/model` (or send `/model <default provider/model>`).
Use `/model status` to confirm which auth profile is active.
### Can I use GPT 5.2 for daily tasks and Codex 5.2 for coding
Yes. Set one as default and switch as needed:
- **Quick switch (per session):** `/model gpt-5.2` for daily tasks, `/model gpt-5.2-codex` for coding.
- **Default + switch:** set `agents.defaults.model.primary` to `openai-codex/gpt-5.2`, then switch to `openai-codex/gpt-5.2-codex` when coding (or the other way around).
- **Sub-agents:** route coding tasks to sub-agents with a different default model.
See [Models](/concepts/models) and [Slash commands](/tools/slash-commands).
### Why do I see Model is not allowed and then no reply
If `agents.defaults.models` is set, it becomes the **allowlist** for `/model` and any

View File

@@ -33,7 +33,7 @@ export function registerMatrixAutoJoin(params: {
// For "allowlist" mode, handle invites manually
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
if (autoJoin !== "allowlist") return;
// Get room alias if available
let alias: string | undefined;
let altAliases: string[] = [];

View File

@@ -25,7 +25,7 @@ async function fetchMatrixMediaBuffer(params: {
// matrix-bot-sdk provides mxcToHttp helper
const url = params.client.mxcToHttp(params.mxcUrl);
if (!url) return null;
// Use the client's download method which handles auth
try {
const buffer = await params.client.downloadContent(params.mxcUrl);
@@ -61,7 +61,7 @@ async function fetchEncryptedMediaBuffer(params: {
Buffer.from(encryptedBuffer),
params.file,
);
return { buffer: decrypted };
}
@@ -77,7 +77,7 @@ export async function downloadMatrixMedia(params: {
placeholder: string;
} | null> {
let fetched: { buffer: Buffer; headerType?: string } | null;
if (params.file) {
// Encrypted media
fetched = await fetchEncryptedMediaBuffer({
@@ -93,7 +93,7 @@ export async function downloadMatrixMedia(params: {
maxBytes: params.maxBytes,
});
}
if (!fetched) return null;
const headerType = fetched.headerType ?? params.contentType ?? undefined;
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(

View File

@@ -11,4 +11,4 @@ export function resolveMattermostGroupRequireMention(
});
if (typeof account.requireMention === "boolean") return account.requireMention;
return true;
}
}

View File

@@ -112,4 +112,4 @@ export function listEnabledMattermostAccounts(cfg: ClawdbotConfig): ResolvedMatt
return listMattermostAccountIds(cfg)
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}
}

View File

@@ -205,4 +205,4 @@ export async function uploadMattermostFile(
throw new Error("Mattermost file upload failed");
}
return info;
}
}

View File

@@ -147,4 +147,4 @@ export function resolveThreadSessionKeys(params: {
? `${params.baseSessionKey}:thread:${threadId}`
: params.baseSessionKey;
return { sessionKey, parentSessionKey: params.parentSessionKey };
}
}

View File

@@ -67,4 +67,4 @@ export async function probeMattermost(
} finally {
if (timer) clearTimeout(timer);
}
}
}

View File

@@ -39,4 +39,4 @@ export async function promptAccountId(params: PromptAccountIdParams): Promise<st
);
}
return normalized;
}
}

View File

@@ -184,4 +184,4 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
mattermost: { ...cfg.channels?.mattermost, enabled: false },
},
}),
};
};

View File

@@ -22,11 +22,11 @@ parallel:
security = session: security_expert
prompt: "Perform a deep security audit of the changes. Look for OWASP top 10 issues."
context: overview
perf = session: performance_expert
prompt: "Analyze the performance implications. Identify potential bottlenecks or regressions."
context: overview
style = session: reviewer
prompt: "Review for code style, maintainability, and adherence to best practices."
context: overview

View File

@@ -19,4 +19,3 @@ export type CallManagerContext = {
transcriptWaiters: Map<CallId, TranscriptWaiter>;
maxDurationTimers: Map<CallId, NodeJS.Timeout>;
};

View File

@@ -175,4 +175,3 @@ export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): v
persistCallRecord(ctx.storePath, call);
}

View File

@@ -31,4 +31,3 @@ export function findCall(params: {
providerCallId: params.callIdOrProviderCallId,
});
}

View File

@@ -48,4 +48,3 @@ export function addTranscriptEntry(
};
call.transcript.push(entry);
}

View File

@@ -86,4 +86,3 @@ export async function getCallHistoryFromStore(
return calls;
}

View File

@@ -84,4 +84,3 @@ export function waitForFinalTranscript(
ctx.transcriptWaiters.set(callId, { resolve, reject, timeout });
});
}

View File

@@ -7,4 +7,3 @@ export function generateNotifyTwiml(message: string, voice: string): string {
<Hangup/>
</Response>`;
}

View File

@@ -26,4 +26,3 @@ describe("PlivoProvider", () => {
expect(result.providerResponseBody).toContain('length="300"');
});
});

View File

@@ -27,4 +27,3 @@ export function verifyTwilioProviderWebhook(params: {
reason: result.reason,
};
}

View File

@@ -15,4 +15,3 @@ describe("zalouser outbound chunker", () => {
expect(chunks.every((c) => c.length <= limit)).toBe(true);
});
});

View File

@@ -124,7 +124,7 @@ EOF
# Function to list categories
list_categories() {
echo -e "${BLUE}Fetching VibeTunnel log categories from the last hour...${NC}\n"
# Get unique categories from recent logs
log show --predicate "subsystem == \"$SUBSYSTEM\"" --last 1h 2>/dev/null | \
grep -E "category: \"[^\"]+\"" | \
@@ -133,7 +133,7 @@ list_categories() {
while read -r cat; do
echo "$cat"
done
echo -e "\n${YELLOW}Note: Only categories with recent activity are shown${NC}"
}
@@ -230,29 +230,29 @@ fi
if [[ "$STREAM_MODE" == true ]]; then
# Streaming mode
CMD="sudo log stream --predicate '$PREDICATE' --level $LOG_LEVEL --info"
echo -e "${GREEN}Streaming VibeTunnel logs continuously...${NC}"
echo -e "${YELLOW}Press Ctrl+C to stop${NC}\n"
else
# Show mode
CMD="sudo log show --predicate '$PREDICATE'"
# Add log level for show command
if [[ "$LOG_LEVEL" == "debug" ]]; then
CMD="$CMD --debug"
else
CMD="$CMD --info"
fi
# Add time range
CMD="$CMD --last $TIME_RANGE"
if [[ "$SHOW_TAIL" == true ]]; then
echo -e "${GREEN}Showing last $TAIL_LINES log lines from the past $TIME_RANGE${NC}"
else
echo -e "${GREEN}Showing all logs from the past $TIME_RANGE${NC}"
fi
# Show applied filters
if [[ "$ERRORS_ONLY" == true ]]; then
echo -e "${RED}Filter: Errors only${NC}"
@@ -277,14 +277,14 @@ if [[ -n "$OUTPUT_FILE" ]]; then
if sudo -n /usr/bin/log show --last 1s 2>&1 | grep -q "password"; then
handle_sudo_error
fi
echo -e "${BLUE}Exporting logs to: $OUTPUT_FILE${NC}\n"
if [[ "$SHOW_TAIL" == true ]] && [[ "$STREAM_MODE" == false ]]; then
eval "$CMD" 2>&1 | tail -n "$TAIL_LINES" > "$OUTPUT_FILE"
else
eval "$CMD" > "$OUTPUT_FILE" 2>&1
fi
# Check if file was created and has content
if [[ -s "$OUTPUT_FILE" ]]; then
LINE_COUNT=$(wc -l < "$OUTPUT_FILE" | tr -d ' ')
@@ -298,7 +298,7 @@ else
if sudo -n /usr/bin/log show --last 1s 2>&1 | grep -q "password"; then
handle_sudo_error
fi
if [[ "$SHOW_TAIL" == true ]] && [[ "$STREAM_MODE" == false ]]; then
# Apply tail for non-streaming mode
eval "$CMD" 2>&1 | tail -n "$TAIL_LINES"

View File

@@ -102,12 +102,12 @@ ws.send(
);
const connectRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"c1\");
if (!connectRes.ok) throw new Error(\"connect failed: \" + (connectRes.error?.message ?? \"unknown\"));
ws.send(JSON.stringify({ type: \"req\", id: \"h1\", method: \"health\" }));
const healthRes = await onceFrame((o) => o?.type === \"res\" && o?.id === \"h1\", 10000);
if (!healthRes.ok) throw new Error(\"health failed: \" + (healthRes.error?.message ?? \"unknown\"));
if (healthRes.payload?.ok !== true) throw new Error(\"unexpected health payload\");
ws.close();
console.log(\"ok\");
NODE"

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
if [[ $# -lt 1 ]]; then
echo "usage: run-node-tool.sh <tool> [args...]" >&2
exit 2
fi
tool="$1"
shift
if [[ -f "$ROOT_DIR/pnpm-lock.yaml" ]] && command -v pnpm >/dev/null 2>&1; then
exec pnpm exec "$tool" "$@"
fi
if { [[ -f "$ROOT_DIR/bun.lockb" ]] || [[ -f "$ROOT_DIR/bun.lock" ]]; } && command -v bun >/dev/null 2>&1; then
exec bunx --bun "$tool" "$@"
fi
if command -v npm >/dev/null 2>&1; then
exec npm exec -- "$tool" "$@"
fi
if command -v npx >/dev/null 2>&1; then
exec npx "$tool" "$@"
fi
echo "Missing package manager: pnpm, bun, or npm required." >&2
exit 1

View File

@@ -30,4 +30,3 @@ export type Entry = {
avatar_url: string;
lines: number;
};

View File

@@ -84,7 +84,7 @@ curl http://127.0.0.1:8000/places/{place_id}
"open_now": true
}
],
"next_page_token": "..."
"next_page_token": "..."
}
```

View File

@@ -1,3 +1,2 @@
import "./styles.css";
import "./ui/app.ts";

View File

@@ -279,4 +279,3 @@
min-width: 120px;
}
}

View File

@@ -122,4 +122,3 @@
border-top: 1px solid var(--border);
margin: 1em 0;
}

View File

@@ -196,4 +196,3 @@
transform: scale(1);
}
}

View File

@@ -242,8 +242,10 @@
.config-main {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
background: var(--panel);
overflow-y: auto;
}
/* Actions Bar */

View File

@@ -3,4 +3,3 @@ export type EventLogEntry = {
event: string;
payload?: unknown;
};

View File

@@ -154,13 +154,13 @@ const COMPACTION_TOAST_DURATION_MS = 5000;
export function handleCompactionEvent(host: CompactionHost, payload: AgentEventPayload) {
const data = payload.data ?? {};
const phase = typeof data.phase === "string" ? data.phase : "";
// Clear any existing timer
if (host.compactionClearTimer != null) {
window.clearTimeout(host.compactionClearTimer);
host.compactionClearTimer = null;
}
if (phase === "start") {
host.compactionStatus = {
active: true,
@@ -183,13 +183,13 @@ export function handleCompactionEvent(host: CompactionHost, payload: AgentEventP
export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) {
if (!payload) return;
// Handle compaction events
if (payload.stream === "compaction") {
handleCompactionEvent(host as CompactionHost, payload);
return;
}
if (payload.stream !== "tool") return;
const sessionKey =
typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;

View File

@@ -74,4 +74,3 @@ export function removePathValue(
delete (current as Record<string, unknown>)[lastKey];
}
}

View File

@@ -54,4 +54,3 @@ export async function callDebugMethod(state: DebugState) {
state.debugCallError = String(err);
}
}

View File

@@ -33,4 +33,3 @@ export async function loadPresence(state: PresenceState) {
state.presenceLoading = false;
}
}

View File

@@ -39,4 +39,3 @@ describe("stripThinkingTags", () => {
expect(stripThinkingTags("Hello</final>")).toBe("Hello");
});
});

View File

@@ -30,4 +30,3 @@ describe("toSanitizedMarkdownHtml", () => {
expect(html).toContain("console.log(1)");
});
});

View File

@@ -55,4 +55,3 @@ export function formatCronPayload(job: CronJob) {
if (p.kind === "systemEvent") return `System: ${p.text}`;
return `Agent: ${p.message}`;
}

View File

@@ -30,4 +30,3 @@ describe("generateUUID", () => {
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
});
});

View File

@@ -40,4 +40,3 @@ export function generateUUID(cryptoLike: CryptoLike | null = globalThis.crypto):
return uuidFromBytes(weakRandomBytes());
}

View File

@@ -43,4 +43,3 @@ export function renderChannelAccountCount(
if (count < 2) return nothing;
return html`<div class="account-count">Accounts (${count})</div>`;
}

View File

@@ -116,4 +116,3 @@ export function renderWhatsAppCard(params: {
</div>
`;
}

View File

@@ -70,7 +70,7 @@ const COMPACTION_TOAST_DURATION_MS = 5000;
function renderCompactionIndicator(status: CompactionIndicatorStatus | null | undefined) {
if (!status) return nothing;
// Show "compacting..." while active
if (status.active) {
return html`
@@ -91,7 +91,7 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
`;
}
}
return nothing;
}

View File

@@ -120,7 +120,7 @@ export function renderNode(params: {
const hasString = normalizedTypes.has("string");
const hasNumber = normalizedTypes.has("number");
const hasBoolean = normalizedTypes.has("boolean");
if (hasBoolean && normalizedTypes.size === 1) {
return renderNode({
...params,
@@ -383,14 +383,14 @@ function renderObject(params: {
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const fallback = value ?? schema.default;
const obj = fallback && typeof fallback === "object" && !Array.isArray(fallback)
? (fallback as Record<string, unknown>)
: {};
const props = schema.properties ?? {};
const entries = Object.entries(props);
// Sort by hint order
const sorted = entries.sort((a, b) => {
const orderA = hintForPath([...path, a[0]], hints)?.order ?? 0;
@@ -514,7 +514,7 @@ function renderArray(params: {
</button>
</div>
${help ? html`<div class="cfg-array__help">${help}</div>` : nothing}
${arr.length === 0 ? html`
<div class="cfg-array__empty">
No items yet. Click "Add" to create one.
@@ -597,7 +597,7 @@ function renderMapField(params: {
Add Entry
</button>
</div>
${entries.length === 0 ? html`
<div class="cfg-map__empty">No custom entries.</div>
` : html`

View File

@@ -94,16 +94,16 @@ function matchesSearch(key: string, schema: JsonSchema, query: string): boolean
if (!query) return true;
const q = query.toLowerCase();
const meta = SECTION_META[key];
// Check key name
if (key.toLowerCase().includes(q)) return true;
// Check label and description
if (meta) {
if (meta.label.toLowerCase().includes(q)) return true;
if (meta.description.toLowerCase().includes(q)) return true;
}
return schemaMatches(schema, q);
}
@@ -192,8 +192,8 @@ export function renderConfigForm(props: ConfigFormProps) {
<div class="config-empty">
<div class="config-empty__icon">${icons.search}</div>
<div class="config-empty__text">
${searchQuery
? `No settings match "${searchQuery}"`
${searchQuery
? `No settings match "${searchQuery}"`
: "No settings in this section"}
</div>
</div>

View File

@@ -89,4 +89,3 @@ export function isSensitivePath(path: Array<string | number>): boolean {
key.endsWith("key")
);
}

View File

@@ -5,4 +5,3 @@ export {
} from "./config-form.analyze";
export { renderNode } from "./config-form.node";
export { schemaType, type JsonSchema } from "./config-form.shared";

View File

@@ -138,7 +138,7 @@ function computeDiff(
): Array<{ path: string; from: unknown; to: unknown }> {
if (!original || !current) return [];
const changes: Array<{ path: string; from: unknown; to: unknown }> = [];
function compare(orig: unknown, curr: unknown, path: string) {
if (orig === curr) return;
if (typeof orig !== typeof curr) {
@@ -164,7 +164,7 @@ function computeDiff(
compare(origObj[key], currObj[key], path ? `${path}.${key}` : key);
}
}
compare(original, current, "");
return changes;
}
@@ -258,7 +258,7 @@ export function renderConfig(props: ConfigProps) {
<div class="config-sidebar__title">Settings</div>
<span class="pill pill--sm ${validity === "valid" ? "pill--ok" : validity === "invalid" ? "pill--danger" : ""}">${validity}</span>
</div>
<!-- Search -->
<div class="config-search">
<svg class="config-search__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -273,13 +273,13 @@ export function renderConfig(props: ConfigProps) {
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
/>
${props.searchQuery ? html`
<button
<button
class="config-search__clear"
@click=${() => props.onSearchChange("")}
>×</button>
` : nothing}
</div>
<!-- Section nav -->
<nav class="config-nav">
<button
@@ -299,7 +299,7 @@ export function renderConfig(props: ConfigProps) {
</button>
`)}
</nav>
<!-- Mode toggle at bottom -->
<div class="config-sidebar__footer">
<div class="config-mode-toggle">
@@ -319,7 +319,7 @@ export function renderConfig(props: ConfigProps) {
</div>
</div>
</aside>
<!-- Main content -->
<main class="config-main">
<!-- Action bar -->
@@ -358,7 +358,7 @@ export function renderConfig(props: ConfigProps) {
</button>
</div>
</div>
<!-- Diff panel (form mode only - raw mode doesn't have granular diff) -->
${hasChanges && props.formMode === "form" ? html`
<details class="config-diff">

17
zizmor.yml Normal file
View File

@@ -0,0 +1,17 @@
# zizmor configuration
# https://docs.zizmor.sh/configuration/
rules:
# Disable unpinned-uses - pinning to SHA hashes is a significant change
# that should be done deliberately, not enforced by pre-commit
unpinned-uses:
disable: true
# Disable excessive-permissions for now - adding explicit permissions
# blocks requires careful review of each workflow's needs
excessive-permissions:
disable: true
# Disable artipacked (persist-credentials) - low confidence finding
artipacked:
disable: true