Compare commits

...

53 Commits

Author SHA1 Message Date
Peter Steinberger
65dd73b4c3 fix: clean up slack threading landings (#1360) (thanks @SocialNerd42069) 2026-01-21 06:29:36 +00:00
SocialNerd42069
b69aa011fe Add auto-notify on completion to coding-agent skill 2026-01-21 06:29:36 +00:00
SocialNerd42069
e3a44b10bc fix: prevent duplicate assistant texts from whitespace differences
- Add per-message dedup tracking in subscribeEmbeddedPiSession
- Compare both trimmed and normalized text to catch near-duplicates
- Reset dedup state on each new assistant message
- Add test for trailing whitespace edge case

Fixes duplicate Slack message delivery when the same text appears
with minor whitespace differences (e.g., trailing newline).
2026-01-21 06:29:36 +00:00
SocialNerd42069
5b8007784b fix(slack): handle Bolt ESM/CJS import for Node 25.x
The slackBoltModule.default points to App class directly on Node 25.x,
not the module object. Check for App property first before using default.
2026-01-21 06:29:36 +00:00
SocialNerd42069
0d6e78b718 fix(slack): respect verbose setting and preserve thread context for tool notifications
Fixes two bugs in Slack tool notification delivery:

1. Tool notifications ignored verbose=false - normalized verbose values so
   boolean false/'false' are properly treated as 'off'

2. Thread context lost - Slack outbound adapter now falls back to threadId
   when replyToId is missing, and MessageThreadId is set for thread replies

Closes #1333
2026-01-21 06:29:36 +00:00
SocialNerd42069
46ab4cb19e my local tweaks 2026-01-21 06:29:36 +00:00
Peter Steinberger
32edaad823 fix: address update cli type import 2026-01-21 06:10:27 +00:00
Peter Steinberger
5dcd48544a feat: align update channel installs 2026-01-21 06:00:54 +00:00
Peter Steinberger
1e05925e47 fix: normalize model override auth handling 2026-01-21 06:00:21 +00:00
Peter Steinberger
fb47f1cbeb chore: rename clawlog references 2026-01-21 05:53:32 +00:00
Peter Steinberger
15d1421cf2 Merge pull request #1357 from vignesh07/fix/node-invoke-timeout
fix(node): enforce timeout for node.invoke handlers
2026-01-21 05:49:36 +00:00
Peter Steinberger
899bbd40d7 Merge pull request #1358 from vignesh07/fix/ios-talkmode-simulator
fix(ios): prevent Talk mode crash on simulator
2026-01-21 05:42:17 +00:00
Peter Steinberger
555b2578a8 feat: add /allowlist command 2026-01-21 05:34:53 +00:00
Peter Steinberger
0229b8bbd8 docs: expand 2026.1.20 highlights 2026-01-21 05:34:29 +00:00
Peter Steinberger
552f9eff7b docs: add 2026.1.20 highlight 2026-01-21 05:31:37 +00:00
Peter Steinberger
36e0cffaaf fix: stabilize directory cli output 2026-01-21 05:25:28 +00:00
Peter Steinberger
e17a9c6abf docs: expand 2026.1.20 changelog 2026-01-21 05:24:23 +00:00
Peter Steinberger
6180603ef4 feat: improve doctor update flow 2026-01-21 05:23:37 +00:00
Peter Steinberger
810374d648 fix: align cli output tests and help examples 2026-01-21 05:20:31 +00:00
Peter Steinberger
968b967854 Merge pull request #1354 from vignesh07/fix/gateway-ios-client-id
fix(gateway): allow clawdbot-ios client id
2026-01-21 05:09:15 +00:00
Peter Steinberger
110079d99d fix: guard nodes status duration parsing (#1354) (thanks @vignesh07) 2026-01-21 05:07:27 +00:00
Peter Steinberger
34a126a6d7 fix: allow mobile node client ids (#1354) (thanks @vignesh07) 2026-01-21 05:07:26 +00:00
Vignesh Natarajan
31462f64d8 fix: allow clawdbot-ios gateway client id
The iOS app currently identifies as clientId=clawdbot-ios when
connecting in node mode. Add this ID to the allowed gateway client
IDs so the handshake schema accepts it.

Also fixes a TS strictness issue in auto-reply status formatting
(parts filter) that caused
> clawdbot@2026.1.20 build /Users/vignesh/clawd/clawdbot-upstream
> tsc -p tsconfig.json && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts

[copy-hook-metadata] Copied boot-md/HOOK.md
[copy-hook-metadata] Copied command-logger/HOOK.md
[copy-hook-metadata] Copied session-memory/HOOK.md
[copy-hook-metadata] Copied soul-evil/HOOK.md
[copy-hook-metadata] Done to fail.
2026-01-21 05:07:26 +00:00
Peter Steinberger
de0a488985 refactor: unify gateway connectivity state 2026-01-21 05:01:32 +00:00
Peter Steinberger
15f16de651 docs: update nodes list/status flags 2026-01-21 04:52:54 +00:00
Vignesh Natarajan
b46855d8c4 fix(ios): prevent Talk mode crash on simulator
- Disable Talk mode start on iOS simulator (no audio input)
- Validate audio input format before installing tap to avoid
  AVFAudio assertion crashes on misconfigured devices.

Tested:
- Launched app on iOS simulator and tapping Talk no longer crashes
  (shows error path instead).
2026-01-20 20:52:42 -08:00
Vignesh Natarajan
feaad8250b fix(node): enforce node.invoke timeout in node client
Use the timeout provided on node invoke requests to ensure node
clients always respond with a result.

This prevents gateway-side node.invoke calls from hanging until the
gateway timeout when a node command stalls.

Tests:
- swift test --filter GatewayNodeSessionTests
2026-01-20 20:50:20 -08:00
Peter Steinberger
fa7df1976d feat: theme hooks/skills/plugins output 2026-01-21 04:48:34 +00:00
Peter Steinberger
2cd62f94a5 feat: tableize device/directory outputs 2026-01-21 04:48:33 +00:00
Peter Steinberger
a74c19feed docs: unify cli help examples 2026-01-21 04:48:33 +00:00
Peter Steinberger
1ad4a7194e fix: allow node exec fallback and defer node approvals 2026-01-21 04:46:50 +00:00
Peter Steinberger
beec504ebd feat: filter nodes list/status 2026-01-21 04:39:15 +00:00
Peter Steinberger
fe1133e2c5 Merge pull request #1348 from vignesh07/feat/tui-input-history
feat(tui): add input history (↑/↓) for submitted messages
2026-01-21 04:37:49 +00:00
Peter Steinberger
6f37f1d8ff fix: record tui input history (#1348) (thanks @vignesh07) 2026-01-21 04:37:22 +00:00
Peter Steinberger
57700f33a9 fix: record node last-connect by instance id 2026-01-21 04:32:53 +00:00
Vignesh Natarajan
2700794228 feat(tui): add input history for submitted messages (WIP)
Record submitted inputs in the editor history so up/down arrow
can recall previous messages.

Adds a small helper to wire submit handling and unit tests for
routing/recording behavior.

No PR yet (per request).
2026-01-21 04:31:33 +00:00
dependabot[bot]
416894c642 chore(deps): bump tar in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [tar](https://github.com/isaacs/node-tar).


Updates `tar` from 7.5.3 to 7.5.4
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.3...v7.5.4)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.4
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-21 04:22:46 +00:00
Peter Steinberger
db88378ae3 fix: normalize node invoke result payloadJSON 2026-01-21 04:17:35 +00:00
Peter Steinberger
e97b4973bb docs: clarify node host exec flow 2026-01-21 04:14:34 +00:00
Peter Steinberger
832dfb02fe fix: omit null payloadJSON in node invoke results 2026-01-21 04:13:47 +00:00
Peter Steinberger
15e3a2a395 fix: sanitize node invoke result params 2026-01-21 04:13:47 +00:00
Peter Steinberger
8c472c210f docs: colorize update help 2026-01-21 04:08:50 +00:00
Peter Steinberger
833bbcd166 fix: show subcommand help on --help 2026-01-21 04:08:50 +00:00
Peter Steinberger
d7440baef6 docs: clarify update help 2026-01-21 04:08:50 +00:00
Peter Steinberger
58b131919f feat: use tsgo for dev/watch builds 2026-01-21 04:06:09 +00:00
Peter Steinberger
186e86660a Merge pull request #1350 from Jackten/fix/fallback-authprofile-provider-scope
test(auto-reply): regression for authProfileId across provider fallback
2026-01-21 03:53:07 +00:00
Peter Steinberger
18d47b47d2 Merge pull request #1349 from siddhantjain/fix/immediate-callback-answer
fix(telegram): answer callback queries immediately to prevent retries
2026-01-21 03:51:50 +00:00
Peter Steinberger
eb1e2c7a3b fix: suppress node warnings unless verbose 2026-01-21 03:47:50 +00:00
Peter Steinberger
6ea4cb0012 fix: suppress npm fund prompts 2026-01-21 03:47:50 +00:00
Peter Steinberger
184f5a5fc3 fix: suppress update deprecation warnings 2026-01-21 03:47:50 +00:00
Peter Steinberger
4ad359ffcd feat: add non-interactive update option 2026-01-21 03:47:50 +00:00
Vultr-Clawd Admin
c05a7b5390 test(auto-reply): drop auth profile on provider fallback 2026-01-20 23:07:33 -04:00
Siddhant Jain
020fecef5c fix(telegram): answer callback queries immediately to prevent retries
Telegram retries callback queries if they aren't acknowledged quickly.
Previously, answerCallbackQuery was called in a finally block AFTER
processing, which could take several seconds for agent responses.

This change moves answerCallbackQuery to immediately after basic
validation, before any processing begins. This prevents Telegram
from sending duplicate callbacks while the agent is thinking.

Fixes duplicate callback handling when agent processing is slow.
2026-01-21 03:04:28 +00:00
97 changed files with 4053 additions and 1076 deletions

View File

@@ -100,7 +100,7 @@
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- macOS logs: use `./scripts/clawlog.sh` to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.

View File

@@ -2,72 +2,14 @@
Docs: https://docs.clawd.bot
## 2026.1.20
### Changes
- Deps: update workspace + memory-lancedb dependencies.
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) — thanks @sebslight.
- Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo.
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow.
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0.
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
- Security: warn when <=300B models run without sandboxing and with web tools enabled.
- Skills: add download installs with OS-filtered install options; add local sherpa-onnx-tts skill.
- Docs: clarify WhatsApp voice notes and Windows WSL portproxy LAN access notes.
- UI: add copy-as-markdown with error feedback and drop legacy list view. (#1345) — thanks @bradleypriest.
### Fixes
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch; gate heartbeat/webhook logging. (#1244) — thanks @oscargavin.
- CLI: preserve cron delivery settings when editing message payloads. (#1322) — thanks @KrauseFx.
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
- Doctor: clarify plugin auto-enable hint text in the startup banner.
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
- Gateway: clarify connect/validation errors for gateway params. (#1347) — thanks @vignesh07.
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337) — thanks @John-Rood.
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
- Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
- UI: preserve ordered list numbering in chat markdown. (#1341) — thanks @bradleypriest.
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342) — thanks @ameno-.
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346) — thanks @fogboots.
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
- Anthropic: default API prompt caching to 1h with configurable TTL override; ignore TTL for OAuth.
- Discord: make resolve warnings avoid raw JSON payloads on rate limits.
- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
## 2026.1.19-3
### Changes
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
- Gateway: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229) — thanks @RyanLisse.
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete.
### Fixes
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
- Browser: register AI snapshot refs for act commands. (#1282) — thanks @John-Rood.
- Slack: respect verbose tool summaries and keep tool notifications threaded. (#1360) — thanks @SocialNerd42069.
## 2026.1.19-2
@@ -86,11 +28,9 @@ Docs: https://docs.clawd.bot
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
### Changes
- Gateway: add `/v1/responses` endpoint (OpenResponses API) for agentic workflows with item-based input and semantic streaming events. Enable via `gateway.http.endpoints.responses.enabled: true`.
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
- Agents: clarify node_modules read-only guidance in agent instructions.
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) — thanks @Whoaa512.
### Fixes
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
@@ -99,11 +39,9 @@ Docs: https://docs.clawd.bot
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214) — thanks @ameno-.
- Memory: show total file counts + scan issues in `clawdbot memory status`; fall back to non-batch embeddings after repeated batch failures.
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226) — thanks @VACInc. (fixes #1225)
## 2026.1.18-5
@@ -129,8 +67,8 @@ Docs: https://docs.clawd.bot
## 2026.1.18-4
### Changes
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.
- macOS: stop syncing Peekaboo in postinstall.
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
- macOS: stop syncing Peekaboo as a git submodule in postinstall.
- Swabble: use the tagged Commander Swift package release.
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.

View File

@@ -529,7 +529,7 @@ class NodeRuntime(context: Context) {
caps = buildCapabilities(),
commands = buildInvokeCommands(),
permissions = emptyMap(),
client = buildClientInfo(clientId = "node-host", clientMode = "node"),
client = buildClientInfo(clientId = "clawdbot-android", clientMode = "node"),
userAgent = buildUserAgent(),
)
}

View File

@@ -132,6 +132,12 @@ final class TalkModeManager: NSObject {
}
private func startRecognition() throws {
#if targetEnvironment(simulator)
throw NSError(domain: "TalkMode", code: 2, userInfo: [
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
])
#endif
self.stopRecognition()
self.speechRecognizer = SFSpeechRecognizer()
guard let recognizer = self.speechRecognizer else {
@@ -146,6 +152,11 @@ final class TalkModeManager: NSObject {
let input = self.audioEngine.inputNode
let format = input.outputFormat(forBus: 0)
guard format.sampleRate > 0, format.channelCount > 0 else {
throw NSError(domain: "TalkMode", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Invalid audio input format",
])
}
input.removeTap(onBus: 0)
let tapBlock = Self.makeAudioTapAppendCallback(request: request)
input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock)

View File

@@ -87,15 +87,7 @@ final class ControlChannel {
func configure() async {
self.logger.info("control channel configure mode=local")
self.state = .connecting
do {
try await GatewayConnection.shared.refresh()
self.state = .connected
PresenceReporter.shared.sendImmediate(reason: "connect")
} catch {
let message = self.friendlyGatewayMessage(error)
self.state = .degraded(message)
}
await self.refreshEndpoint(reason: "configure")
}
func configure(mode: Mode = .local) async throws {
@@ -111,7 +103,7 @@ final class ControlChannel {
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
self.state = .connecting
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
await self.configure()
await self.refreshEndpoint(reason: "configure")
} catch {
self.state = .degraded(error.localizedDescription)
throw error
@@ -119,6 +111,19 @@ final class ControlChannel {
}
}
func refreshEndpoint(reason: String) async {
self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)")
self.state = .connecting
do {
try await self.establishGatewayConnection()
self.state = .connected
PresenceReporter.shared.sendImmediate(reason: "connect")
} catch {
let message = self.friendlyGatewayMessage(error)
self.state = .degraded(message)
}
}
func disconnect() async {
await GatewayConnection.shared.shutdown()
self.state = .disconnected
@@ -275,18 +280,28 @@ final class ControlChannel {
}
}
do {
try await GatewayConnection.shared.refresh()
await self.refreshEndpoint(reason: "recovery:\(reasonText)")
if case .connected = self.state {
self.logger.info("control channel recovery finished")
} catch {
self.logger.error(
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
} else if case let .degraded(message) = self.state {
self.logger.error("control channel recovery failed \(message, privacy: .public)")
}
self.recoveryTask = nil
}
}
private func establishGatewayConnection(timeoutMs: Int = 5000) async throws {
try await GatewayConnection.shared.refresh()
let ok = try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
if ok == false {
throw NSError(
domain: "Gateway",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
}
}
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
var merged = params
merged["text"] = AnyHashable(text)

View File

@@ -0,0 +1,63 @@
import Foundation
import Observation
import OSLog
@MainActor
@Observable
final class GatewayConnectivityCoordinator {
static let shared = GatewayConnectivityCoordinator()
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.connectivity")
private var endpointTask: Task<Void, Never>?
private var lastResolvedURL: URL?
private(set) var endpointState: GatewayEndpointState?
private(set) var resolvedURL: URL?
private(set) var resolvedMode: AppState.ConnectionMode?
private(set) var resolvedHostLabel: String?
private init() {
self.start()
}
func start() {
guard self.endpointTask == nil else { return }
self.endpointTask = Task { [weak self] in
guard let self else { return }
let stream = await GatewayEndpointStore.shared.subscribe()
for await state in stream {
await MainActor.run { self.handleEndpointState(state) }
}
}
}
var localEndpointHostLabel: String? {
guard self.resolvedMode == .local, let url = self.resolvedURL else { return nil }
return Self.hostLabel(for: url)
}
private func handleEndpointState(_ state: GatewayEndpointState) {
self.endpointState = state
switch state {
case let .ready(mode, url, _, _):
self.resolvedMode = mode
self.resolvedURL = url
self.resolvedHostLabel = Self.hostLabel(for: url)
let urlChanged = self.lastResolvedURL?.absoluteString != url.absoluteString
if urlChanged {
self.lastResolvedURL = url
Task { await ControlChannel.shared.refreshEndpoint(reason: "endpoint changed") }
}
case let .connecting(mode, _):
self.resolvedMode = mode
case let .unavailable(mode, _):
self.resolvedMode = mode
}
}
private static func hostLabel(for url: URL) -> String {
let host = url.host ?? url.absoluteString
if let port = url.port { return "\(host):\(port)" }
return host
}
}

View File

@@ -68,6 +68,7 @@ actor GatewayEndpointStore {
env: ProcessInfo.processInfo.environment)
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
?? TailscaleService.fallbackTailnetIPv4()
return GatewayEndpointStore.resolveLocalGatewayHost(
bindMode: bind,
customBindHost: customBindHost,
@@ -487,6 +488,7 @@ actor GatewayEndpointStore {
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
?? TailscaleService.fallbackTailnetIPv4()
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
let scheme = GatewayEndpointStore.resolveGatewayScheme(

View File

@@ -235,8 +235,8 @@ final class HealthStore {
let lower = error.lowercased()
if lower.contains("connection refused") {
let port = GatewayEnvironment.gatewayPort()
return "The gateway control port (127.0.0.1:\(port)) isn’t listening — " +
"restart Clawdbot to bring it back."
let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
return "The gateway control port (\(host)) isn’t listening — restart Clawdbot to bring it back."
}
if lower.contains("timeout") {
return "Timed out waiting for the control server; the gateway may be crashed or still starting."

View File

@@ -13,6 +13,7 @@ struct ClawdbotApp: App {
private let gatewayManager = GatewayProcessManager.shared
private let controlChannel = ControlChannel.shared
private let activityStore = WorkActivityStore.shared
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
@State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false
@State private var isPanelVisible = false

View File

@@ -469,7 +469,7 @@ extension MenuSessionsInjector {
}
case .local:
platform = "local"
host = "127.0.0.1:\(port)"
host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
case .unconfigured:
platform = nil
host = nil

View File

@@ -103,6 +103,7 @@ final class TailscaleService {
}
func checkTailscaleStatus() async {
let previousIP = self.tailscaleIP
self.isInstalled = self.checkAppInstallation()
if !self.isInstalled {
self.isRunning = false
@@ -147,6 +148,10 @@ final class TailscaleService {
self.statusError = nil
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
}
if previousIP != self.tailscaleIP {
await GatewayEndpointStore.shared.refresh()
}
}
func openTailscaleApp() {
@@ -214,4 +219,8 @@ final class TailscaleService {
return nil
}
nonisolated static func fallbackTailnetIPv4() -> String? {
Self.detectTailnetIPv4()
}
}

View File

@@ -23,6 +23,35 @@ public actor GatewayNodeSession {
private var onConnected: (@Sendable () async -> Void)?
private var onDisconnected: (@Sendable (String) async -> Void)?
private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)?
static func invokeWithTimeout(
request: BridgeInvokeRequest,
timeoutMs: Int?,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
) async -> BridgeInvokeResponse {
let timeout = max(0, timeoutMs ?? 0)
guard timeout > 0 else {
return await onInvoke(request)
}
return await withTaskGroup(of: BridgeInvokeResponse.self) { group in
group.addTask { await onInvoke(request) }
group.addTask {
try? await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000)
return BridgeInvokeResponse(
id: request.id,
ok: false,
error: ClawdbotNodeError(
code: .unavailable,
message: "node invoke timed out")
)
}
let first = await group.next()!
group.cancelAll()
return first
}
}
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
private var canvasHostUrl: String?
@@ -167,7 +196,11 @@ public actor GatewayNodeSession {
let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
guard let onInvoke else { return }
let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
let response = await onInvoke(req)
let response = await Self.invokeWithTimeout(
request: req,
timeoutMs: request.timeoutMs,
onInvoke: onInvoke
)
await self.sendInvokeResult(request: request, response: response)
} catch {
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
@@ -180,8 +213,10 @@ public actor GatewayNodeSession {
"id": AnyCodable(request.id),
"nodeId": AnyCodable(request.nodeId),
"ok": AnyCodable(response.ok),
"payloadJSON": AnyCodable(response.payloadJSON ?? NSNull()),
]
if let payloadJSON = response.payloadJSON {
params["payloadJSON"] = AnyCodable(payloadJSON)
}
if let error = response.error {
params["error"] = AnyCodable([
"code": AnyCodable(error.code.rawValue),

View File

@@ -0,0 +1,56 @@
import Foundation
import Testing
@testable import ClawdbotKit
import ClawdbotProtocol
struct GatewayNodeSessionTests {
@Test
func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
timeoutMs: 50,
onInvoke: { req in
#expect(req.id == "1")
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: "{}", error: nil)
}
)
#expect(response.ok == true)
#expect(response.error == nil)
#expect(response.payloadJSON == "{}")
}
@Test
func invokeWithTimeoutReturnsTimeoutError() async {
let request = BridgeInvokeRequest(id: "abc", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
timeoutMs: 10,
onInvoke: { _ in
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms
return BridgeInvokeResponse(id: "abc", ok: true, payloadJSON: "{}", error: nil)
}
)
#expect(response.ok == false)
#expect(response.error?.code == .unavailable)
#expect(response.error?.message.contains("timed out") == true)
}
@Test
func invokeWithTimeoutZeroDisablesTimeout() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
timeoutMs: 0,
onInvoke: { req in
try? await Task.sleep(nanoseconds: 5_000_000)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
}
)
#expect(response.ok == true)
#expect(response.error == nil)
}
}

View File

@@ -825,9 +825,9 @@ Common options:
- `--url`, `--token`, `--timeout`, `--json`
Subcommands:
- `nodes status`
- `nodes status [--connected] [--last-connected <duration>]`
- `nodes describe --node <id|name|ip>`
- `nodes list`
- `nodes list [--connected] [--last-connected <duration>]`
- `nodes pending`
- `nodes approve <requestId>`
- `nodes reject <requestId>`

View File

@@ -18,12 +18,18 @@ Related:
```bash
clawdbot nodes list
clawdbot nodes list --connected
clawdbot nodes list --last-connected 24h
clawdbot nodes pending
clawdbot nodes approve <requestId>
clawdbot nodes status
clawdbot nodes status --connected
clawdbot nodes status --last-connected 24h
```
`nodes list` prints pending/paired tables. Paired rows include the most recent connect age (Last Connect).
Use `--connected` to only show currently-connected nodes. Use `--last-connected <duration>` to
filter to nodes that connected within a duration (e.g. `24h`, `7d`).
## Invoke / run

View File

@@ -7,9 +7,9 @@ read_when:
# `clawdbot update`
Safely update a **source checkout** (git install) of Clawdbot.
Safely update Clawdbot and switch between stable/beta/dev channels.
If you installed via **npm/pnpm** (global install, no git metadata), use the package manager flow in [Updating](/install/updating).
If you installed via **npm/pnpm** (global install, no git metadata), updates happen via the package manager flow in [Updating](/install/updating).
## Usage
@@ -48,7 +48,16 @@ Options:
- `--json`: print machine-readable status JSON.
- `--timeout <seconds>`: timeout for checks (default is 3s).
## What it does (git checkout)
## What it does
When you switch channels explicitly (`--channel ...`), Clawdbot also keeps the
install method aligned:
- `dev` → ensures a git checkout (default: `~/clawdbot`, override with `CLAWDBOT_GIT_DIR`),
updates it, and installs the global CLI from that checkout.
- `stable`/`beta` → installs from npm using the matching dist-tag.
## Git checkout flow
Channels:

View File

@@ -7,7 +7,7 @@ read_when:
# Development channels
Last updated: 2026-01-20
Last updated: 2026-01-21
Clawdbot ships three update channels:
@@ -38,6 +38,13 @@ clawdbot update --channel dev
This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`).
When you **explicitly** switch channels with `--channel`, Clawdbot also aligns
the install method:
- `dev` ensures a git checkout (default `~/clawdbot`, override with `CLAWDBOT_GIT_DIR`),
updates it, and installs the global CLI from that checkout.
- `stable`/`beta` installs from npm using the matching dist-tag.
Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one.
## Plugins and channels

View File

@@ -34,6 +34,81 @@ clawdbot nodes rename --node <idOrNameOrIp> --name "Kitchen iPad"
Notes:
- `nodes rename` stores a display name override in the gateway pairing store.
## Remote node host (system.run)
Use a **node host** when your Gateway runs on one machine and you want commands
to execute on another. The model still talks to the **gateway**; the gateway
forwards `exec` calls to the **node host** when `host=node` is selected.
### What runs where
- **Gateway host**: receives messages, runs the model, routes tool calls.
- **Node host**: executes `system.run`/`system.which` on the node machine.
- **Approvals**: enforced on the node host via `~/.clawdbot/exec-approvals.json`.
### Start a node host (foreground)
On the node machine:
```bash
clawdbot node start --host <gateway-host> --port 18789 --display-name "Build Node"
```
### Start a node host (service)
```bash
clawdbot node service install --host <gateway-host> --port 18789 --display-name "Build Node"
clawdbot node service start
```
### Pair + name
On the gateway host:
```bash
clawdbot nodes pending
clawdbot nodes approve <requestId>
clawdbot nodes list
```
Naming options:
- `--display-name` on `clawdbot node start/service install` (persists in `~/.clawdbot/node.json` on the node).
- `clawdbot nodes rename --node <id|name|ip> --name "Build Node"` (gateway override).
### Allowlist the commands
Exec approvals are **per node host**. Add allowlist entries from the gateway:
```bash
clawdbot approvals allowlist add --node <id|name|ip> "/usr/bin/uname"
clawdbot approvals allowlist add --node <id|name|ip> "/usr/bin/sw_vers"
```
Approvals live on the node host at `~/.clawdbot/exec-approvals.json`.
### Point exec at the node
Configure defaults (gateway config):
```bash
clawdbot config set tools.exec.host node
clawdbot config set tools.exec.security allowlist
clawdbot config set tools.exec.node "<id-or-name>"
```
Or per session:
```
/exec host=node security=allowlist node=<id-or-name>
```
Once set, any `exec` call with `host=node` runs on the node host (subject to the
node allowlist/approvals).
Related:
- [Node host CLI](/cli/node)
- [Exec tool](/tools/exec)
- [Exec approvals](/tools/exec-approvals)
## Invoking commands
Low-level (raw RPC):
@@ -214,6 +289,9 @@ Notes:
- The node host stores its node id + pairing token in `~/.clawdbot/node.json`.
- Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json`
(see [Exec approvals](/tools/exec-approvals)).
- On macOS, the headless node host prefers the companion app exec host when reachable and falls
back to local execution if the app is unavailable. Set `CLAWDBOT_NODE_EXEC_HOST=app` to require
the app, or `CLAWDBOT_NODE_EXEC_FALLBACK=0` to disable fallback.
- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
## Mac node mode

View File

@@ -60,6 +60,7 @@ Text + native (when enabled):
- `/commands`
- `/skill <name> [input]` (run a skill by name)
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
- `/allowlist` (list/add/remove allowlist entries)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/whoami` (show your sender id; alias: `/id`)
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
@@ -93,6 +94,7 @@ Notes:
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
- `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body.
- For full provider usage breakdown, use `clawdbot status --usage`.
- `/allowlist add|remove` requires `commands.config=true` and honors channel `configWrites`.
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs.
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.

View File

@@ -210,6 +210,7 @@
"@types/proper-lockfile": "^4.1.4",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260120.1",
"@vitest/coverage-v8": "^4.0.17",
"docx-preview": "^0.3.7",
"lit": "^3.3.2",
@@ -231,7 +232,7 @@
"overrides": {
"@sinclair/typebox": "0.34.47",
"hono": "4.11.4",
"tar": "7.5.3"
"tar": "7.5.4"
},
"patchedDependencies": {
"@mariozechner/pi-ai@0.49.2": "patches/@mariozechner__pi-ai@0.49.2.patch"

88
pnpm-lock.yaml generated
View File

@@ -7,7 +7,7 @@ settings:
overrides:
'@sinclair/typebox': 0.34.47
hono: 4.11.4
tar: 7.5.3
tar: 7.5.4
patchedDependencies:
'@mariozechner/pi-ai@0.49.2':
@@ -151,8 +151,8 @@ importers:
specifier: 0.1.7-alpha.2
version: 0.1.7-alpha.2
tar:
specifier: 7.5.3
version: 7.5.3
specifier: 7.5.4
version: 7.5.4
tslog:
specifier: ^4.10.2
version: 4.10.2
@@ -202,6 +202,9 @@ importers:
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
'@typescript/native-preview':
specifier: 7.0.0-dev.20260120.1
version: 7.0.0-dev.20260120.1
'@vitest/coverage-v8':
specifier: ^4.0.17
version: 4.0.17(@vitest/browser@4.0.17(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.17))(vitest@4.0.17)
@@ -2530,6 +2533,45 @@ packages:
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260120.1':
resolution: {integrity: sha512-r3pWFuR2H7mn6ScwpH5jJljKQqKto0npVuJSk6pRwFwexpTyxOGmJTZJ1V0AWiisaNxU2+CNAqWFJSJYIE/QTg==}
cpu: [arm64]
os: [darwin]
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260120.1':
resolution: {integrity: sha512-cuC1+wLbUP+Ip2UT94G134fqRdp5w3b3dhcCO6/FQ4yXxvRNyv/WK+upHBUFDaeSOeHgDTyO9/QFYUWwC4If1A==}
cpu: [x64]
os: [darwin]
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260120.1':
resolution: {integrity: sha512-zZGvEGY7wcHYefMZ87KNmvjN3NLIhsCMHEpHZiGCS3khKf+8z6ZsanrzCjOTodvL01VPyBzHxV1EtkSxAcLiQg==}
cpu: [arm64]
os: [linux]
'@typescript/native-preview-linux-arm@7.0.0-dev.20260120.1':
resolution: {integrity: sha512-vN6OYVySol/kQZjJGmAzd6L30SyVlCgmCXS8WjUYtE5clN0YrzQHop16RK29fYZHMxpkOniVBtRPxUYQANZBlQ==}
cpu: [arm]
os: [linux]
'@typescript/native-preview-linux-x64@7.0.0-dev.20260120.1':
resolution: {integrity: sha512-JBfNhWd/asd5MDeS3VgRvE24pGKBkmvLub6tsux6ypr+Yhy+o0WaAEzVpmlRYZUqss2ai5tvOu4dzPBXzZAtFw==}
cpu: [x64]
os: [linux]
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260120.1':
resolution: {integrity: sha512-tTndRtYCq2xwgE0VkTi9ACNiJaV43+PqvBqCxk8ceYi3X36Ve+CCnwlZfZJ4k9NxZthtrAwF/kUmpC9iIYbq1w==}
cpu: [arm64]
os: [win32]
'@typescript/native-preview-win32-x64@7.0.0-dev.20260120.1':
resolution: {integrity: sha512-oZia7hFL6k9pVepfonuPI86Jmyz6WlJKR57tWCDwRNmpA7odxuTq1PbvcYgy1z4+wHF1nnKKJY0PMAiq6ac18w==}
cpu: [x64]
os: [win32]
'@typescript/native-preview@7.0.0-dev.20260120.1':
resolution: {integrity: sha512-nnEf37C9ue7OBRnF2zmV/OCBmV5Y7T/K4mCHa+nxgiXcF/1w8sA0cgdFl+gHQ0mysqUJ+Bu5btAMeWgpLyjrgg==}
hasBin: true
'@typespec/ts-http-runtime@0.3.2':
resolution: {integrity: sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==}
engines: {node: '>=20.0.0'}
@@ -4863,10 +4905,9 @@ packages:
tailwindcss@4.1.17:
resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==}
tar@7.5.3:
resolution: {integrity: sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==}
tar@7.5.4:
resolution: {integrity: sha512-AN04xbWGrSTDmVwlI4/GTlIIwMFk/XEv7uL8aa57zuvRy6s4hdBed+lVq2fAZ89XDa7Us3ANXcE3Tvqvja1kTA==}
engines: {node: '>=18'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
@@ -7699,6 +7740,37 @@ snapshots:
dependencies:
'@types/node': 25.0.9
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260120.1':
optional: true
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260120.1':
optional: true
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260120.1':
optional: true
'@typescript/native-preview-linux-arm@7.0.0-dev.20260120.1':
optional: true
'@typescript/native-preview-linux-x64@7.0.0-dev.20260120.1':
optional: true
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260120.1':
optional: true
'@typescript/native-preview-win32-x64@7.0.0-dev.20260120.1':
optional: true
'@typescript/native-preview@7.0.0-dev.20260120.1':
optionalDependencies:
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260120.1
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20260120.1
'@typescript/native-preview-linux-arm': 7.0.0-dev.20260120.1
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20260120.1
'@typescript/native-preview-linux-x64': 7.0.0-dev.20260120.1
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260120.1
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260120.1
'@typespec/ts-http-runtime@0.3.2':
dependencies:
http-proxy-agent: 7.0.2
@@ -8196,7 +8268,7 @@ snapshots:
npmlog: 6.0.2
rc: 1.2.8
semver: 7.7.3
tar: 7.5.3
tar: 7.5.4
url-join: 4.0.1
which: 2.0.2
yargs: 17.7.2
@@ -10438,7 +10510,7 @@ snapshots:
tailwindcss@4.1.17: {}
tar@7.5.3:
tar@7.5.4:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0

View File

@@ -21,7 +21,7 @@ handle_sudo_error() {
echo -e "\n${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo -e "${YELLOW}⚠️ Password Required for Log Access${NC}"
echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
echo -e "vtlog needs to use sudo to show complete log data (Apple hides sensitive info by default)."
echo -e "clawlog needs to use sudo to show complete log data (Apple hides sensitive info by default)."
echo -e "\nTo avoid password prompts, configure passwordless sudo for the log command:"
echo -e "See: ${BLUE}apple/docs/logging-private-fix.md${NC}\n"
echo -e "Quick fix:"
@@ -51,7 +51,7 @@ show_usage() {
clawlog - Clawdbot Logging Utility
USAGE:
vtlog [OPTIONS]
clawlog [OPTIONS]
DESCRIPTION:
View Clawdbot logs with full details (bypasses Apple's privacy redaction).
@@ -69,10 +69,10 @@ LOG CATEGORIES (examples):
• shell - ShellExecutor
QUICK START:
vtlog -n 100 Show last 100 lines from all components
vtlog -f Follow logs in real-time
vtlog -e Show only errors
vtlog -c ServerManager Show logs from ServerManager only
clawlog -n 100 Show last 100 lines from all components
clawlog -f Follow logs in real-time
clawlog -e Show only errors
clawlog -c ServerManager Show logs from ServerManager only
OPTIONS:
-h, --help Show this help message
@@ -91,15 +91,15 @@ OPTIONS:
--json Output in JSON format
EXAMPLES:
vtlog Show last 50 lines from past 5 minutes (default)
vtlog -f Stream logs continuously
vtlog -n 100 Show last 100 lines
vtlog -e Show only recent errors
vtlog -l 30m -n 200 Show last 200 lines from past 30 minutes
vtlog -c ServerManager Show recent ServerManager logs
vtlog -s "fail" Search for "fail" in recent logs
vtlog --server -e Show recent server errors
vtlog -f -d Stream debug logs continuously
clawlog Show last 50 lines from past 5 minutes (default)
clawlog -f Stream logs continuously
clawlog -n 100 Show last 100 lines
clawlog -e Show only recent errors
clawlog -l 30m -n 200 Show last 200 lines from past 30 minutes
clawlog -c ServerManager Show recent ServerManager logs
clawlog -s "fail" Search for "fail" in recent logs
clawlog --server -e Show recent server errors
clawlog -f -d Stream debug logs continuously
CATEGORIES:
Common categories include:

View File

@@ -7,6 +7,8 @@ import process from "node:process";
const args = process.argv.slice(2);
const env = { ...process.env };
const cwd = process.cwd();
const compiler = env.CLAWDBOT_TS_COMPILER === "tsc" ? "tsc" : "tsgo";
const projectArgs = ["--project", "tsconfig.json"];
const distRoot = path.join(cwd, "dist");
const distEntry = path.join(distRoot, "entry.js");
@@ -113,7 +115,7 @@ if (!shouldBuild()) {
runNode();
} else {
logRunner("Building TypeScript (dist is stale).");
const build = spawn("pnpm", ["exec", "tsc", "-p", "tsconfig.json"], {
const build = spawn("pnpm", ["exec", compiler, ...projectArgs], {
cwd,
env,
stdio: "inherit",

View File

@@ -5,8 +5,10 @@ import process from "node:process";
const args = process.argv.slice(2);
const env = { ...process.env };
const cwd = process.cwd();
const compiler = env.CLAWDBOT_TS_COMPILER === "tsc" ? "tsc" : "tsgo";
const projectArgs = ["--project", "tsconfig.json"];
const initialBuild = spawnSync("pnpm", ["exec", "tsc", "-p", "tsconfig.json"], {
const initialBuild = spawnSync("pnpm", ["exec", compiler, ...projectArgs], {
cwd,
env,
stdio: "inherit",
@@ -16,7 +18,12 @@ if (initialBuild.status !== 0) {
process.exit(initialBuild.status ?? 1);
}
const tsc = spawn("pnpm", ["exec", "tsc", "--watch", "--preserveWatchOutput"], {
const watchArgs =
compiler === "tsc"
? [...projectArgs, "--watch", "--preserveWatchOutput"]
: [...projectArgs, "--watch"];
const compilerProcess = spawn("pnpm", ["exec", compiler, ...watchArgs], {
cwd,
env,
stdio: "inherit",
@@ -34,14 +41,14 @@ function cleanup(code = 0) {
if (exiting) return;
exiting = true;
nodeProcess.kill("SIGTERM");
tsc.kill("SIGTERM");
compilerProcess.kill("SIGTERM");
process.exit(code);
}
process.on("SIGINT", () => cleanup(130));
process.on("SIGTERM", () => cleanup(143));
tsc.on("exit", (code) => {
compilerProcess.on("exit", (code) => {
if (exiting) return;
cleanup(code ?? 1);
});

View File

@@ -76,7 +76,7 @@ bash pty:true workdir:~/project background:true command:"codex exec --full-auto
# Monitor progress
process action:log sessionId:XXX
# Check if done
# Check if done
process action:poll sessionId:XXX
# Send input (if agent asks a question)
@@ -217,15 +217,55 @@ git worktree remove /tmp/issue-99
## ⚠️ Rules
1. **Always use pty:true** — coding agents need a terminal!
2. **Respect tool choice** — if user asks for Codex, use Codex. NEVER offer to build it yourself!
3. **Be patient** — don't kill sessions because they're "slow"
4. **Monitor with process:log** — check progress without interfering
5. **--full-auto for building** — auto-approves changes
6. **vanilla for reviewing** — no special flags needed
7. **Parallel is OK** — run many Codex processes at once for batch work
8. **NEVER start Codex in ~/clawd/** — it'll read your soul docs and get weird ideas about the org chart!
9. **NEVER checkout branches in ~/Projects/clawdbot/** — that's the LIVE Clawdbot instance!
1. **Always use pty:true** - coding agents need a terminal!
2. **Respect tool choice** - if user asks for Codex, use Codex.
- Orchestrator mode: do NOT hand-code patches yourself.
- If an agent fails/hangs, respawn it or ask the user for direction, but don't silently take over.
3. **Be patient** - don't kill sessions because they're "slow"
4. **Monitor with process:log** - check progress without interfering
5. **--full-auto for building** - auto-approves changes
6. **vanilla for reviewing** - no special flags needed
7. **Parallel is OK** - run many Codex processes at once for batch work
8. **NEVER start Codex in ~/clawd/** - it'll read your soul docs and get weird ideas about the org chart!
9. **NEVER checkout branches in ~/Projects/clawdbot/** - that's the LIVE Clawdbot instance!
---
## Progress Updates (Critical)
When you spawn coding agents in the background, keep the user in the loop.
- Send 1 short message when you start (what's running + where).
- Then only update again when something changes:
- a milestone completes (build finished, tests passed)
- the agent asks a question / needs input
- you hit an error or need user action
- the agent finishes (include what changed + where)
- If you kill a session, immediately say you killed it and why.
This prevents the user from seeing only "Agent failed before reply" and having no idea what happened.
---
## Auto-Notify on Completion
For long-running background tasks, append a wake trigger to your prompt so Clawdbot gets notified immediately when the agent finishes (instead of waiting for the next heartbeat):
```
... your task here.
When completely finished, run this command to notify me:
clawdbot gateway wake --text "Done: [brief summary of what was built]" --mode now
```
**Example:**
```bash
bash pty:true workdir:~/project background:true command:"codex --yolo exec 'Build a REST API for todos.
When completely finished, run: clawdbot gateway wake --text \"Done: Built todos REST API with CRUD endpoints\" --mode now'"
```
This triggers an immediate wake event — Skippy gets pinged in seconds, not 10 minutes.
---

View File

@@ -0,0 +1,61 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { resolveSessionAuthProfileOverride } from "./session-override.js";
async function writeAuthStore(agentDir: string) {
const authPath = path.join(agentDir, "auth-profiles.json");
const payload = {
version: 1,
profiles: {
"zai:work": { type: "api_key", provider: "zai", key: "sk-test" },
},
order: {
zai: ["zai:work"],
},
};
await fs.writeFile(authPath, JSON.stringify(payload), "utf-8");
}
describe("resolveSessionAuthProfileOverride", () => {
it("keeps user override when provider alias differs", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
process.env.CLAWDBOT_STATE_DIR = tmpDir;
try {
const agentDir = path.join(tmpDir, "agent");
await fs.mkdir(agentDir, { recursive: true });
await writeAuthStore(agentDir);
const sessionEntry: SessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
authProfileOverride: "zai:work",
authProfileOverrideSource: "user",
};
const sessionStore = { "agent:main:main": sessionEntry };
const resolved = await resolveSessionAuthProfileOverride({
cfg: {} as ClawdbotConfig,
provider: "z.ai",
agentDir,
sessionEntry,
sessionStore,
sessionKey: "agent:main:main",
storePath: undefined,
isNewSession: false,
});
expect(resolved).toBe("zai:work");
expect(sessionEntry.authProfileOverride).toBe("zai:work");
} finally {
if (prevStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
else process.env.CLAWDBOT_STATE_DIR = prevStateDir;
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,139 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { updateSessionStore, type SessionEntry } from "../../config/sessions.js";
import { normalizeProviderId } from "../model-selection.js";
import {
ensureAuthProfileStore,
isProfileInCooldown,
resolveAuthProfileOrder,
} from "../auth-profiles.js";
function isProfileForProvider(params: {
provider: string;
profileId: string;
store: ReturnType<typeof ensureAuthProfileStore>;
}): boolean {
const entry = params.store.profiles[params.profileId];
if (!entry?.provider) return false;
return normalizeProviderId(entry.provider) === normalizeProviderId(params.provider);
}
export async function clearSessionAuthProfileOverride(params: {
sessionEntry: SessionEntry;
sessionStore: Record<string, SessionEntry>;
sessionKey: string;
storePath?: string;
}) {
const { sessionEntry, sessionStore, sessionKey, storePath } = params;
delete sessionEntry.authProfileOverride;
delete sessionEntry.authProfileOverrideSource;
delete sessionEntry.authProfileOverrideCompactionCount;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
}
export async function resolveSessionAuthProfileOverride(params: {
cfg: ClawdbotConfig;
provider: string;
agentDir: string;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
isNewSession: boolean;
}): Promise<string | undefined> {
const {
cfg,
provider,
agentDir,
sessionEntry,
sessionStore,
sessionKey,
storePath,
isNewSession,
} = params;
if (!sessionEntry || !sessionStore || !sessionKey) return sessionEntry?.authProfileOverride;
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
const order = resolveAuthProfileOrder({ cfg, store, provider });
let current = sessionEntry.authProfileOverride?.trim();
if (current && !store.profiles[current]) {
await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
current = undefined;
}
if (current && !isProfileForProvider({ provider, profileId: current, store })) {
await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
current = undefined;
}
if (current && order.length > 0 && !order.includes(current)) {
await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
current = undefined;
}
if (order.length === 0) return undefined;
const pickFirstAvailable = () =>
order.find((profileId) => !isProfileInCooldown(store, profileId)) ?? order[0];
const pickNextAvailable = (active: string) => {
const startIndex = order.indexOf(active);
if (startIndex < 0) return pickFirstAvailable();
for (let offset = 1; offset <= order.length; offset += 1) {
const candidate = order[(startIndex + offset) % order.length];
if (!isProfileInCooldown(store, candidate)) return candidate;
}
return order[startIndex] ?? order[0];
};
const compactionCount = sessionEntry.compactionCount ?? 0;
const storedCompaction =
typeof sessionEntry.authProfileOverrideCompactionCount === "number"
? sessionEntry.authProfileOverrideCompactionCount
: compactionCount;
const source =
sessionEntry.authProfileOverrideSource ??
(typeof sessionEntry.authProfileOverrideCompactionCount === "number"
? "auto"
: current
? "user"
: undefined);
if (source === "user" && current && !isNewSession) {
return current;
}
let next = current;
if (isNewSession) {
next = current ? pickNextAvailable(current) : pickFirstAvailable();
} else if (current && compactionCount > storedCompaction) {
next = pickNextAvailable(current);
} else if (!current || isProfileInCooldown(store, current)) {
next = pickFirstAvailable();
}
if (!next) return current;
const shouldPersist =
next !== sessionEntry.authProfileOverride ||
sessionEntry.authProfileOverrideSource !== "auto" ||
sessionEntry.authProfileOverrideCompactionCount !== compactionCount;
if (shouldPersist) {
sessionEntry.authProfileOverride = next;
sessionEntry.authProfileOverrideSource = "auto";
sessionEntry.authProfileOverrideCompactionCount = compactionCount;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
}
return next;
}

View File

@@ -494,12 +494,7 @@ export function createExecTool(
if (nodeEnv) {
applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true });
}
const resolution = resolveCommandResolution(params.command, workdir, env);
const allowlistMatch =
hostSecurity === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null;
const requiresAsk =
hostAsk === "always" ||
(hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch);
const requiresAsk = hostAsk === "always" || hostAsk === "on-miss";
let approvedByAsk = false;
let approvalDecision: "allow-once" | "allow-always" | null = null;
@@ -514,7 +509,7 @@ export function createExecTool(
security: hostSecurity,
ask: hostAsk,
agentId: defaults?.agentId,
resolvedPath: resolution?.resolvedPath ?? null,
resolvedPath: null,
sessionKey: defaults?.sessionKey ?? null,
timeoutMs: 120_000,
},
@@ -532,11 +527,7 @@ export function createExecTool(
approvedByAsk = true;
approvalDecision = "allow-once";
} else if (askFallback === "allowlist") {
if (!allowlistMatch) {
throw new Error("exec denied: approval required (approval UI not available)");
}
approvedByAsk = true;
approvalDecision = "allow-once";
// Defer allowlist enforcement to the node host.
} else {
throw new Error("exec denied: approval required (approval UI not available)");
}
@@ -548,32 +539,8 @@ export function createExecTool(
if (decision === "allow-always") {
approvedByAsk = true;
approvalDecision = "allow-always";
if (hostSecurity === "allowlist") {
const pattern =
resolution?.resolvedPath ??
resolution?.rawExecutable ??
params.command.split(/\s+/).shift() ??
"";
if (pattern) {
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
}
}
}
}
if (hostSecurity === "allowlist" && !allowlistMatch && !approvedByAsk) {
throw new Error("exec denied: allowlist miss");
}
if (allowlistMatch) {
recordAllowlistUse(
approvals.file,
defaults?.agentId,
allowlistMatch,
params.command,
resolution?.resolvedPath,
);
}
const invokeParams: Record<string, unknown> = {
nodeId,
command: "system.run",

View File

@@ -92,13 +92,16 @@ const makeConfig = (): ClawdbotConfig =>
},
}) satisfies ClawdbotConfig;
const writeAuthStore = async (agentDir: string) => {
const writeAuthStore = async (agentDir: string, opts?: { includeAnthropic?: boolean }) => {
const authPath = path.join(agentDir, "auth-profiles.json");
const payload = {
version: 1,
profiles: {
"openai:p1": { type: "api_key", provider: "openai", key: "sk-one" },
"openai:p2": { type: "api_key", provider: "openai", key: "sk-two" },
...(opts?.includeAnthropic
? { "anthropic:default": { type: "api_key", provider: "anthropic", key: "sk-anth" } }
: {}),
},
usageStats: {
"openai:p1": { lastUsed: 1 },
@@ -206,4 +209,43 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("ignores user-locked profile when provider mismatches", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
try {
await writeAuthStore(agentDir, { includeAnthropic: true });
runEmbeddedAttemptMock.mockResolvedValueOnce(
makeAttempt({
assistantTexts: ["ok"],
lastAssistant: buildAssistant({
stopReason: "stop",
content: [{ type: "text", text: "ok" }],
}),
}),
);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:test:mismatch",
sessionFile: path.join(workspaceDir, "session.jsonl"),
workspaceDir,
agentDir,
config: makeConfig(),
prompt: "hello",
provider: "openai",
model: "mock-1",
authProfileId: "anthropic:default",
authProfileIdSource: "user",
timeoutMs: 5_000,
runId: "run:mismatch",
});
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
});

View File

@@ -23,6 +23,7 @@ import {
resolveAuthProfileOrder,
type ResolvedProviderAuth,
} from "../model-auth.js";
import { normalizeProviderId } from "../model-selection.js";
import { ensureClawdbotModelsJson } from "../models-config.js";
import {
classifyFailoverReason,
@@ -116,8 +117,16 @@ export async function runEmbeddedPiAgent(
const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
const preferredProfileId = params.authProfileId?.trim();
const lockedProfileId =
params.authProfileIdSource === "user" ? preferredProfileId : undefined;
let lockedProfileId = params.authProfileIdSource === "user" ? preferredProfileId : undefined;
if (lockedProfileId) {
const lockedProfile = authStore.profiles[lockedProfileId];
if (
!lockedProfile ||
normalizeProviderId(lockedProfile.provider) !== normalizeProviderId(provider)
) {
lockedProfileId = undefined;
}
}
const profileOrder = resolveAuthProfileOrder({
cfg: params.config,
store: authStore,

View File

@@ -39,6 +39,10 @@ export type EmbeddedPiSubscribeState = {
lastStreamedAssistant?: string;
lastStreamedReasoning?: string;
lastBlockReplyText?: string;
assistantMessageIndex: number;
lastAssistantTextMessageIndex: number;
lastAssistantTextNormalized?: string;
lastAssistantTextTrimmed?: string;
assistantTextBaseline: number;
suppressBlockChunks: boolean;
lastReasoningSent?: string;

View File

@@ -86,6 +86,35 @@ describe("subscribeEmbeddedPiSession", () => {
expect(subscription.assistantTexts).toEqual(["Hello world"]);
});
it("does not duplicate assistantTexts when message_end repeats with trailing whitespace changes", () => {
let handler: SessionEventHandler | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const subscription = subscribeEmbeddedPiSession({
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
runId: "run",
});
const assistantMessageWithNewline = {
role: "assistant",
content: [{ type: "text", text: "Hello world\n" }],
} as AssistantMessage;
const assistantMessageTrimmed = {
role: "assistant",
content: [{ type: "text", text: "Hello world" }],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessageWithNewline });
handler?.({ type: "message_end", message: assistantMessageTrimmed });
expect(subscription.assistantTexts).toEqual(["Hello world"]);
});
it("does not duplicate assistantTexts when message_end repeats with reasoning blocks", () => {
let handler: SessionEventHandler | undefined;
const session: StubSession = {

View File

@@ -48,6 +48,10 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
lastStreamedAssistant: undefined,
lastStreamedReasoning: undefined,
lastBlockReplyText: undefined,
assistantMessageIndex: 0,
lastAssistantTextMessageIndex: -1,
lastAssistantTextNormalized: undefined,
lastAssistantTextTrimmed: undefined,
assistantTextBaseline: 0,
suppressBlockChunks: false, // Avoid late chunk inserts after final text merge.
lastReasoningSent: undefined,
@@ -84,9 +88,36 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
state.lastStreamedReasoning = undefined;
state.lastReasoningSent = undefined;
state.suppressBlockChunks = false;
state.assistantMessageIndex += 1;
state.lastAssistantTextMessageIndex = -1;
state.lastAssistantTextNormalized = undefined;
state.lastAssistantTextTrimmed = undefined;
state.assistantTextBaseline = nextAssistantTextBaseline;
};
const rememberAssistantText = (text: string) => {
state.lastAssistantTextMessageIndex = state.assistantMessageIndex;
state.lastAssistantTextTrimmed = text.trimEnd();
const normalized = normalizeTextForComparison(text);
state.lastAssistantTextNormalized = normalized.length > 0 ? normalized : undefined;
};
const shouldSkipAssistantText = (text: string) => {
if (state.lastAssistantTextMessageIndex !== state.assistantMessageIndex) return false;
const trimmed = text.trimEnd();
if (trimmed && trimmed === state.lastAssistantTextTrimmed) return true;
const normalized = normalizeTextForComparison(text);
if (normalized.length > 0 && normalized === state.lastAssistantTextNormalized) return true;
return false;
};
const pushAssistantText = (text: string) => {
if (!text) return;
if (shouldSkipAssistantText(text)) return;
assistantTexts.push(text);
rememberAssistantText(text);
};
const finalizeAssistantTexts = (args: {
text: string;
addedDuringMessage: boolean;
@@ -103,16 +134,15 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
assistantTexts.length - state.assistantTextBaseline,
text,
);
rememberAssistantText(text);
} else {
const last = assistantTexts.at(-1);
if (!last || last !== text) assistantTexts.push(text);
pushAssistantText(text);
}
state.suppressBlockChunks = true;
} else if (!addedDuringMessage && !chunkerHasBuffered && text) {
// Non-streaming models (no text_delta): ensure assistantTexts gets the final
// text when the chunker has nothing buffered to drain.
const last = assistantTexts.at(-1);
if (!last || last !== text) assistantTexts.push(text);
pushAssistantText(text);
}
state.assistantTextBaseline = assistantTexts.length;
@@ -338,8 +368,11 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
return;
}
if (shouldSkipAssistantText(chunk)) return;
state.lastBlockReplyText = chunk;
assistantTexts.push(chunk);
rememberAssistantText(chunk);
if (!params.onBlockReply) return;
const splitResult = parseReplyDirectives(chunk);
const {

View File

@@ -36,6 +36,7 @@ import {
DEFAULT_AGENT_ID,
resolveAgentIdFromSessionKey,
} from "../../routing/session-key.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import type { AnyAgentTool } from "./common.js";
import { readStringParam } from "./common.js";
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
@@ -240,6 +241,7 @@ export function createSessionStatusTool(opts?: {
throw new Error(`Unknown sessionKey: ${requestedKeyRaw}`);
}
const configured = resolveDefaultModelForAgent({ cfg, agentId });
const modelRaw = readStringParam(params, "model");
let changedModel = false;
if (typeof modelRaw === "string") {
@@ -249,33 +251,33 @@ export function createSessionStatusTool(opts?: {
sessionEntry: resolved.entry,
agentId,
});
const nextEntry: SessionEntry = {
...resolved.entry,
updatedAt: Date.now(),
};
if (selection.kind === "reset" || selection.isDefault) {
delete nextEntry.providerOverride;
delete nextEntry.modelOverride;
delete nextEntry.authProfileOverride;
delete nextEntry.authProfileOverrideSource;
delete nextEntry.authProfileOverrideCompactionCount;
} else {
nextEntry.providerOverride = selection.provider;
nextEntry.modelOverride = selection.model;
delete nextEntry.authProfileOverride;
delete nextEntry.authProfileOverrideSource;
delete nextEntry.authProfileOverrideCompactionCount;
}
store[resolved.key] = nextEntry;
await updateSessionStore(storePath, (nextStore) => {
nextStore[resolved.key] = nextEntry;
const nextEntry: SessionEntry = { ...resolved.entry };
const applied = applyModelOverrideToSessionEntry({
entry: nextEntry,
selection:
selection.kind === "reset"
? {
provider: configured.provider,
model: configured.model,
isDefault: true,
}
: {
provider: selection.provider,
model: selection.model,
isDefault: selection.isDefault,
},
});
resolved.entry = nextEntry;
changedModel = true;
if (applied.updated) {
store[resolved.key] = nextEntry;
await updateSessionStore(storePath, (nextStore) => {
nextStore[resolved.key] = nextEntry;
});
resolved.entry = nextEntry;
changedModel = true;
}
}
const agentDir = resolveAgentDir(cfg, agentId);
const configured = resolveDefaultModelForAgent({ cfg, agentId });
const providerForCard = resolved.entry.providerOverride?.trim() || configured.provider;
const usageProvider = resolveUsageProviderId(providerForCard);
let usageLine: string | undefined;

View File

@@ -157,6 +157,13 @@ function buildChatCommands(): ChatCommandDefinition[] {
description: "Show current status.",
textAlias: "/status",
}),
defineChatCommand({
key: "allowlist",
description: "List/add/remove allowlist entries.",
textAlias: "/allowlist",
acceptsArgs: true,
scope: "text",
}),
defineChatCommand({
key: "context",
nativeName: "context",

View File

@@ -16,19 +16,21 @@ export const createShouldEmitToolResult = (params: {
storePath?: string;
resolvedVerboseLevel: VerboseLevel;
}): (() => boolean) => {
// Normalize verbose values from session store/config so false/"false" still means off.
const fallbackVerbose = normalizeVerboseLevel(String(params.resolvedVerboseLevel ?? "")) ?? "off";
return () => {
if (!params.sessionKey || !params.storePath) {
return params.resolvedVerboseLevel !== "off";
return fallbackVerbose !== "off";
}
try {
const store = loadSessionStore(params.storePath);
const entry = store[params.sessionKey];
const current = normalizeVerboseLevel(entry?.verboseLevel);
const current = normalizeVerboseLevel(String(entry?.verboseLevel ?? ""));
if (current) return current !== "off";
} catch {
// ignore store read failures
}
return params.resolvedVerboseLevel !== "off";
return fallbackVerbose !== "off";
};
};
@@ -37,19 +39,21 @@ export const createShouldEmitToolOutput = (params: {
storePath?: string;
resolvedVerboseLevel: VerboseLevel;
}): (() => boolean) => {
// Normalize verbose values from session store/config so false/"false" still means off.
const fallbackVerbose = normalizeVerboseLevel(String(params.resolvedVerboseLevel ?? "")) ?? "off";
return () => {
if (!params.sessionKey || !params.storePath) {
return params.resolvedVerboseLevel === "full";
return fallbackVerbose === "full";
}
try {
const store = loadSessionStore(params.storePath);
const entry = store[params.sessionKey];
const current = normalizeVerboseLevel(entry?.verboseLevel);
const current = normalizeVerboseLevel(String(entry?.verboseLevel ?? ""));
if (current) return current === "full";
} catch {
// ignore store read failures
}
return params.resolvedVerboseLevel === "full";
return fallbackVerbose === "full";
};
};

View File

@@ -0,0 +1,149 @@
import { describe, expect, it, vi } from "vitest";
import type { TemplateContext } from "../templating.js";
import type { FollowupRun, QueueSettings } from "./queue.js";
import { createMockTypingController } from "./test-helpers.js";
const runEmbeddedPiAgentMock = vi.fn();
vi.mock("../../agents/model-fallback.js", () => ({
runWithModelFallback: async ({
run,
}: {
run: (provider: string, model: string) => Promise<unknown>;
}) => ({
// Force a cross-provider fallback candidate
result: await run("openai-codex", "gpt-5.2"),
provider: "openai-codex",
model: "gpt-5.2",
}),
}));
vi.mock("../../agents/pi-embedded.js", () => ({
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
}));
vi.mock("./queue.js", async () => {
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),
scheduleFollowupDrain: vi.fn(),
};
});
import { runReplyAgent } from "./agent-runner.js";
function createBaseRun(params: { runOverrides?: Partial<FollowupRun["run"]> }) {
const typing = createMockTypingController();
const sessionCtx = {
Provider: "telegram",
OriginatingTo: "chat",
AccountId: "primary",
MessageSid: "msg",
Surface: "telegram",
} as unknown as TemplateContext;
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
const followupRun = {
prompt: "hello",
summaryLine: "hello",
enqueuedAt: Date.now(),
run: {
agentId: "main",
agentDir: "/tmp/agent",
sessionId: "session",
sessionKey: "main",
messageProvider: "telegram",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {},
skillsSnapshot: {},
provider: "anthropic",
model: "claude-opus",
authProfileId: "anthropic:clawd",
authProfileIdSource: "manual",
thinkLevel: "low",
verboseLevel: "off",
elevatedLevel: "off",
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
timeoutMs: 5_000,
blockReplyBreak: "message_end",
},
} as unknown as FollowupRun;
return {
typing,
sessionCtx,
resolvedQueue,
followupRun: {
...followupRun,
run: { ...followupRun.run, ...params.runOverrides },
},
};
}
describe("authProfileId fallback scoping", () => {
it("drops authProfileId when provider changes during fallback", async () => {
runEmbeddedPiAgentMock.mockReset();
runEmbeddedPiAgentMock.mockResolvedValue({ payloads: [{ text: "ok" }], meta: {} });
const sessionKey = "main";
const sessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
totalTokens: 1,
compactionCount: 0,
};
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
runOverrides: {
provider: "anthropic",
model: "claude-opus",
authProfileId: "anthropic:clawd",
authProfileIdSource: "manual",
},
});
await runReplyAgent({
commandBody: "hello",
followupRun,
queueKey: sessionKey,
resolvedQueue,
shouldSteer: false,
shouldFollowup: false,
isActive: false,
isStreaming: false,
typing,
sessionCtx,
sessionEntry,
sessionStore: { [sessionKey]: sessionEntry },
sessionKey,
storePath: undefined,
defaultModel: "anthropic/claude-opus-4-5",
agentCfgContextTokens: 100_000,
resolvedVerboseLevel: "off",
isNewSession: false,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
shouldInjectGroupIntro: false,
typingMode: "instant",
});
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0] as {
authProfileId?: unknown;
authProfileIdSource?: unknown;
provider?: unknown;
};
expect(call.provider).toBe("openai-codex");
expect(call.authProfileId).toBeUndefined();
expect(call.authProfileIdSource).toBeUndefined();
});
});

View File

@@ -0,0 +1,139 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
const writeConfigFileMock = vi.hoisted(() => vi.fn());
vi.mock("../../config/config.js", async () => {
const actual =
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
return {
...actual,
readConfigFileSnapshot: readConfigFileSnapshotMock,
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
writeConfigFile: writeConfigFileMock,
};
});
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
vi.mock("../../pairing/pairing-store.js", async () => {
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
"../../pairing/pairing-store.js",
);
return {
...actual,
readChannelAllowFromStore: readChannelAllowFromStoreMock,
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
};
});
vi.mock("../../channels/plugins/pairing.js", async () => {
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
"../../channels/plugins/pairing.js",
);
return {
...actual,
listPairingChannels: () => ["telegram"],
};
});
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
const ctx = {
Body: commandBody,
CommandBody: commandBody,
CommandSource: "text",
CommandAuthorized: true,
Provider: "telegram",
Surface: "telegram",
...ctxOverrides,
} as MsgContext;
const command = buildCommandContext({
ctx,
cfg,
isGroup: false,
triggerBodyNormalized: commandBody.trim().toLowerCase(),
commandAuthorized: true,
});
return {
ctx,
cfg,
command,
directives: parseInlineDirectives(commandBody),
elevated: { enabled: true, allowed: true, failures: [] },
sessionKey: "agent:main:main",
workspaceDir: "/tmp",
defaultGroupActivation: () => "mention",
resolvedVerboseLevel: "off" as const,
resolvedReasoningLevel: "off" as const,
resolveDefaultThinkingLevel: async () => undefined,
provider: "telegram",
model: "test-model",
contextTokens: 0,
isGroup: false,
};
}
describe("handleCommands /allowlist", () => {
it("lists config + store allowFrom entries", async () => {
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
const cfg = {
commands: { text: true },
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
} as ClawdbotConfig;
const params = buildParams("/allowlist list dm", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Channel: telegram");
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
});
it("adds entries to config and pairing store", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: {
channels: { telegram: { allowFrom: ["123"] } },
},
});
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
ok: true,
config,
}));
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
changed: true,
allowFrom: ["123", "789"],
});
const cfg = {
commands: { text: true, config: true },
channels: { telegram: { allowFrom: ["123"] } },
} as ClawdbotConfig;
const params = buildParams("/allowlist add dm 789", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
channels: { telegram: { allowFrom: ["123", "789"] } },
}),
);
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
channel: "telegram",
entry: "789",
});
expect(result.reply?.text).toContain("DM allowlist added");
});
});

View File

@@ -0,0 +1,657 @@
import {
readConfigFileSnapshot,
validateConfigObjectWithPlugins,
writeConfigFile,
} from "../../config/config.js";
import { resolveChannelConfigWrites } from "../../channels/plugins/config-writes.js";
import { getChannelDock } from "../../channels/dock.js";
import { normalizeChannelId } from "../../channels/registry.js";
import { listPairingChannels } from "../../channels/plugins/pairing.js";
import { logVerbose } from "../../globals.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { resolveDiscordAccount } from "../../discord/accounts.js";
import { resolveIMessageAccount } from "../../imessage/accounts.js";
import { resolveSignalAccount } from "../../signal/accounts.js";
import { resolveSlackAccount } from "../../slack/accounts.js";
import { resolveTelegramAccount } from "../../telegram/accounts.js";
import { resolveWhatsAppAccount } from "../../web/accounts.js";
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js";
import {
addChannelAllowFromStoreEntry,
readChannelAllowFromStore,
removeChannelAllowFromStoreEntry,
} from "../../pairing/pairing-store.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import type { CommandHandler } from "./commands-types.js";
type AllowlistScope = "dm" | "group" | "all";
type AllowlistAction = "list" | "add" | "remove";
type AllowlistTarget = "both" | "config" | "store";
type AllowlistCommand =
| {
action: "list";
scope: AllowlistScope;
channel?: string;
account?: string;
resolve?: boolean;
}
| {
action: "add" | "remove";
scope: AllowlistScope;
channel?: string;
account?: string;
entry: string;
resolve?: boolean;
target: AllowlistTarget;
}
| { action: "error"; message: string };
const ACTIONS = new Set(["list", "add", "remove"]);
const SCOPES = new Set<AllowlistScope>(["dm", "group", "all"]);
function parseAllowlistCommand(raw: string): AllowlistCommand | null {
const trimmed = raw.trim();
if (!trimmed.toLowerCase().startsWith("/allowlist")) return null;
const rest = trimmed.slice("/allowlist".length).trim();
if (!rest) return { action: "list", scope: "dm" };
const tokens = rest.split(/\s+/);
let action: AllowlistAction = "list";
let scope: AllowlistScope = "dm";
let resolve = false;
let target: AllowlistTarget = "both";
let channel: string | undefined;
let account: string | undefined;
const entryTokens: string[] = [];
let i = 0;
if (tokens[i] && ACTIONS.has(tokens[i].toLowerCase())) {
action = tokens[i].toLowerCase() as AllowlistAction;
i += 1;
}
if (tokens[i] && SCOPES.has(tokens[i].toLowerCase() as AllowlistScope)) {
scope = tokens[i].toLowerCase() as AllowlistScope;
i += 1;
}
for (; i < tokens.length; i += 1) {
const token = tokens[i];
const lowered = token.toLowerCase();
if (lowered === "--resolve" || lowered === "resolve") {
resolve = true;
continue;
}
if (lowered === "--config" || lowered === "config") {
target = "config";
continue;
}
if (lowered === "--store" || lowered === "store") {
target = "store";
continue;
}
if (lowered === "--channel" && tokens[i + 1]) {
channel = tokens[i + 1];
i += 1;
continue;
}
if (lowered === "--account" && tokens[i + 1]) {
account = tokens[i + 1];
i += 1;
continue;
}
const kv = token.split("=");
if (kv.length === 2) {
const key = kv[0]?.trim().toLowerCase();
const value = kv[1]?.trim();
if (key === "channel") {
if (value) channel = value;
continue;
}
if (key === "account") {
if (value) account = value;
continue;
}
if (key === "scope" && value && SCOPES.has(value.toLowerCase() as AllowlistScope)) {
scope = value.toLowerCase() as AllowlistScope;
continue;
}
}
entryTokens.push(token);
}
if (action === "add" || action === "remove") {
const entry = entryTokens.join(" ").trim();
if (!entry) {
return { action: "error", message: "Usage: /allowlist add|remove <entry>" };
}
return { action, scope, entry, channel, account, resolve, target };
}
return { action: "list", scope, channel, account, resolve };
}
function normalizeAllowFrom(params: {
cfg: ClawdbotConfig;
channelId: ChannelId;
accountId?: string | null;
values: Array<string | number>;
}): string[] {
const dock = getChannelDock(params.channelId);
if (dock?.config?.formatAllowFrom) {
return dock.config.formatAllowFrom({
cfg: params.cfg,
accountId: params.accountId,
allowFrom: params.values,
});
}
return params.values.map((entry) => String(entry).trim()).filter(Boolean);
}
function formatEntryList(entries: string[], resolved?: Map<string, string>): string {
if (entries.length === 0) return "(none)";
return entries
.map((entry) => {
const name = resolved?.get(entry);
return name ? `${entry} (${name})` : entry;
})
.join(", ");
}
function resolveAccountTarget(
parsed: Record<string, unknown>,
channelId: ChannelId,
accountId?: string | null,
) {
const channels = (parsed.channels ??= {}) as Record<string, unknown>;
const channel = (channels[channelId] ??= {}) as Record<string, unknown>;
const normalizedAccountId = normalizeAccountId(accountId);
const hasAccounts = Boolean(channel.accounts && typeof channel.accounts === "object");
const useAccount = normalizedAccountId !== DEFAULT_ACCOUNT_ID || hasAccounts;
if (!useAccount) {
return { target: channel, pathPrefix: `channels.${channelId}`, accountId: normalizedAccountId };
}
const accounts = (channel.accounts ??= {}) as Record<string, unknown>;
const account = (accounts[normalizedAccountId] ??= {}) as Record<string, unknown>;
return {
target: account,
pathPrefix: `channels.${channelId}.accounts.${normalizedAccountId}`,
accountId: normalizedAccountId,
};
}
function getNestedValue(root: Record<string, unknown>, path: string[]): unknown {
let current: unknown = root;
for (const key of path) {
if (!current || typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[key];
}
return current;
}
function ensureNestedObject(
root: Record<string, unknown>,
path: string[],
): Record<string, unknown> {
let current = root;
for (const key of path) {
const existing = current[key];
if (!existing || typeof existing !== "object") {
current[key] = {};
}
current = current[key] as Record<string, unknown>;
}
return current;
}
function setNestedValue(root: Record<string, unknown>, path: string[], value: unknown) {
if (path.length === 0) return;
if (path.length === 1) {
root[path[0]] = value;
return;
}
const parent = ensureNestedObject(root, path.slice(0, -1));
parent[path[path.length - 1]] = value;
}
function deleteNestedValue(root: Record<string, unknown>, path: string[]) {
if (path.length === 0) return;
if (path.length === 1) {
delete root[path[0]];
return;
}
const parent = getNestedValue(root, path.slice(0, -1));
if (!parent || typeof parent !== "object") return;
delete (parent as Record<string, unknown>)[path[path.length - 1]];
}
function resolveChannelAllowFromPaths(
channelId: ChannelId,
scope: AllowlistScope,
): string[] | null {
if (scope === "all") return null;
if (scope === "dm") {
if (channelId === "slack" || channelId === "discord") return ["dm", "allowFrom"];
if (
channelId === "telegram" ||
channelId === "whatsapp" ||
channelId === "signal" ||
channelId === "imessage"
) {
return ["allowFrom"];
}
return null;
}
if (scope === "group") {
if (
channelId === "telegram" ||
channelId === "whatsapp" ||
channelId === "signal" ||
channelId === "imessage"
) {
return ["groupAllowFrom"];
}
return null;
}
return null;
}
async function resolveSlackNames(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
entries: string[];
}) {
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const token = account.config.userToken?.trim() || account.botToken?.trim();
if (!token) return new Map<string, string>();
const resolved = await resolveSlackUserAllowlist({ token, entries: params.entries });
const map = new Map<string, string>();
for (const entry of resolved) {
if (entry.resolved && entry.name) map.set(entry.input, entry.name);
}
return map;
}
async function resolveDiscordNames(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
entries: string[];
}) {
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
const token = account.token?.trim();
if (!token) return new Map<string, string>();
const resolved = await resolveDiscordUserAllowlist({ token, entries: params.entries });
const map = new Map<string, string>();
for (const entry of resolved) {
if (entry.resolved && entry.name) map.set(entry.input, entry.name);
}
return map;
}
export const handleAllowlistCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const parsed = parseAllowlistCommand(params.command.commandBodyNormalized);
if (!parsed) return null;
if (parsed.action === "error") {
return { shouldContinue: false, reply: { text: `⚠️ ${parsed.message}` } };
}
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /allowlist from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
const channelId =
normalizeChannelId(parsed.channel) ??
params.command.channelId ??
normalizeChannelId(params.command.channel);
if (!channelId) {
return {
shouldContinue: false,
reply: { text: "⚠️ Unknown channel. Add channel=<id> to the command." },
};
}
const accountId = normalizeAccountId(parsed.account ?? params.ctx.AccountId);
const scope = parsed.scope;
if (parsed.action === "list") {
const pairingChannels = listPairingChannels();
const supportsStore = pairingChannels.includes(channelId);
const storeAllowFrom = supportsStore
? await readChannelAllowFromStore(channelId).catch(() => [])
: [];
let dmAllowFrom: string[] = [];
let groupAllowFrom: string[] = [];
let groupOverrides: Array<{ label: string; entries: string[] }> = [];
let dmPolicy: string | undefined;
let groupPolicy: string | undefined;
if (channelId === "telegram") {
const account = resolveTelegramAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
dmPolicy = account.config.dmPolicy;
groupPolicy = account.config.groupPolicy;
const groups = account.config.groups ?? {};
for (const [groupId, groupCfg] of Object.entries(groups)) {
const entries = (groupCfg?.allowFrom ?? []).map(String).filter(Boolean);
if (entries.length > 0) {
groupOverrides.push({ label: groupId, entries });
}
const topics = groupCfg?.topics ?? {};
for (const [topicId, topicCfg] of Object.entries(topics)) {
const topicEntries = (topicCfg?.allowFrom ?? []).map(String).filter(Boolean);
if (topicEntries.length > 0) {
groupOverrides.push({ label: `${groupId} topic ${topicId}`, entries: topicEntries });
}
}
}
} else if (channelId === "whatsapp") {
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.allowFrom ?? []).map(String);
groupAllowFrom = (account.groupAllowFrom ?? []).map(String);
dmPolicy = account.dmPolicy;
groupPolicy = account.groupPolicy;
} else if (channelId === "signal") {
const account = resolveSignalAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
dmPolicy = account.config.dmPolicy;
groupPolicy = account.config.groupPolicy;
} else if (channelId === "imessage") {
const account = resolveIMessageAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
dmPolicy = account.config.dmPolicy;
groupPolicy = account.config.groupPolicy;
} else if (channelId === "slack") {
const account = resolveSlackAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.dm?.allowFrom ?? []).map(String);
groupPolicy = account.groupPolicy;
const channels = account.channels ?? {};
groupOverrides = Object.entries(channels)
.map(([key, value]) => {
const entries = (value?.users ?? []).map(String).filter(Boolean);
return entries.length > 0 ? { label: key, entries } : null;
})
.filter(Boolean) as Array<{ label: string; entries: string[] }>;
} else if (channelId === "discord") {
const account = resolveDiscordAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.config.dm?.allowFrom ?? []).map(String);
groupPolicy = account.config.groupPolicy;
const guilds = account.config.guilds ?? {};
for (const [guildKey, guildCfg] of Object.entries(guilds)) {
const entries = (guildCfg?.users ?? []).map(String).filter(Boolean);
if (entries.length > 0) {
groupOverrides.push({ label: `guild ${guildKey}`, entries });
}
const channels = guildCfg?.channels ?? {};
for (const [channelKey, channelCfg] of Object.entries(channels)) {
const channelEntries = (channelCfg?.users ?? []).map(String).filter(Boolean);
if (channelEntries.length > 0) {
groupOverrides.push({
label: `guild ${guildKey} / channel ${channelKey}`,
entries: channelEntries,
});
}
}
}
}
const dmDisplay = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId,
values: dmAllowFrom,
});
const groupDisplay = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId,
values: groupAllowFrom,
});
const groupOverrideEntries = groupOverrides.flatMap((entry) => entry.entries);
const groupOverrideDisplay = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId,
values: groupOverrideEntries,
});
const resolvedDm =
parsed.resolve && dmDisplay.length > 0 && channelId === "slack"
? await resolveSlackNames({ cfg: params.cfg, accountId, entries: dmDisplay })
: parsed.resolve && dmDisplay.length > 0 && channelId === "discord"
? await resolveDiscordNames({ cfg: params.cfg, accountId, entries: dmDisplay })
: undefined;
const resolvedGroup =
parsed.resolve && groupOverrideDisplay.length > 0 && channelId === "slack"
? await resolveSlackNames({
cfg: params.cfg,
accountId,
entries: groupOverrideDisplay,
})
: parsed.resolve && groupOverrideDisplay.length > 0 && channelId === "discord"
? await resolveDiscordNames({
cfg: params.cfg,
accountId,
entries: groupOverrideDisplay,
})
: undefined;
const lines: string[] = ["🧾 Allowlist"];
lines.push(`Channel: ${channelId}${accountId ? ` (account ${accountId})` : ""}`);
if (dmPolicy) lines.push(`DM policy: ${dmPolicy}`);
if (groupPolicy) lines.push(`Group policy: ${groupPolicy}`);
const showDm = scope === "dm" || scope === "all";
const showGroup = scope === "group" || scope === "all";
if (showDm) {
lines.push(`DM allowFrom (config): ${formatEntryList(dmDisplay, resolvedDm)}`);
}
if (supportsStore && storeAllowFrom.length > 0) {
const storeLabel = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId,
values: storeAllowFrom,
});
lines.push(`Paired allowFrom (store): ${formatEntryList(storeLabel)}`);
}
if (showGroup) {
if (groupAllowFrom.length > 0) {
lines.push(`Group allowFrom (config): ${formatEntryList(groupDisplay)}`);
}
if (groupOverrides.length > 0) {
lines.push("Group overrides:");
for (const entry of groupOverrides) {
const normalized = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId,
values: entry.entries,
});
lines.push(`- ${entry.label}: ${formatEntryList(normalized, resolvedGroup)}`);
}
}
}
return { shouldContinue: false, reply: { text: lines.join("\n") } };
}
if (params.cfg.commands?.config !== true) {
return {
shouldContinue: false,
reply: { text: "⚠️ /allowlist edits are disabled. Set commands.config=true to enable." },
};
}
const shouldUpdateConfig = parsed.target !== "store";
const shouldTouchStore = parsed.target !== "config" && listPairingChannels().includes(channelId);
if (shouldUpdateConfig) {
const allowWrites = resolveChannelConfigWrites({
cfg: params.cfg,
channelId,
accountId: params.ctx.AccountId,
});
if (!allowWrites) {
const hint = `channels.${channelId}.configWrites=true`;
return {
shouldContinue: false,
reply: { text: `⚠️ Config writes are disabled for ${channelId}. Set ${hint} to enable.` },
};
}
const allowlistPath = resolveChannelAllowFromPaths(channelId, scope);
if (!allowlistPath) {
return {
shouldContinue: false,
reply: {
text: `⚠️ ${channelId} does not support ${scope} allowlist edits via /allowlist.`,
},
};
}
const snapshot = await readConfigFileSnapshot();
if (!snapshot.valid || !snapshot.parsed || typeof snapshot.parsed !== "object") {
return {
shouldContinue: false,
reply: { text: "⚠️ Config file is invalid; fix it before using /allowlist." },
};
}
const parsedConfig = structuredClone(snapshot.parsed as Record<string, unknown>);
const {
target,
pathPrefix,
accountId: normalizedAccountId,
} = resolveAccountTarget(parsedConfig, channelId, accountId);
const existingRaw = getNestedValue(target, allowlistPath);
const existing = Array.isArray(existingRaw)
? existingRaw.map((entry) => String(entry).trim()).filter(Boolean)
: [];
const normalizedEntry = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId: normalizedAccountId,
values: [parsed.entry],
});
if (normalizedEntry.length === 0) {
return {
shouldContinue: false,
reply: { text: "⚠️ Invalid allowlist entry." },
};
}
const existingNormalized = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId: normalizedAccountId,
values: existing,
});
const shouldMatch = (value: string) => normalizedEntry.includes(value);
let configChanged = false;
let next = existing;
const configHasEntry = existingNormalized.some((value) => shouldMatch(value));
if (parsed.action === "add") {
if (!configHasEntry) {
next = [...existing, parsed.entry.trim()];
configChanged = true;
}
}
if (parsed.action === "remove") {
const keep: string[] = [];
for (const entry of existing) {
const normalized = normalizeAllowFrom({
cfg: params.cfg,
channelId,
accountId: normalizedAccountId,
values: [entry],
});
if (normalized.some((value) => shouldMatch(value))) {
configChanged = true;
continue;
}
keep.push(entry);
}
next = keep;
}
if (configChanged) {
if (next.length === 0) {
deleteNestedValue(target, allowlistPath);
} else {
setNestedValue(target, allowlistPath, next);
}
}
if (configChanged) {
const validated = validateConfigObjectWithPlugins(parsedConfig);
if (!validated.ok) {
const issue = validated.issues[0];
return {
shouldContinue: false,
reply: { text: `⚠️ Config invalid after update (${issue.path}: ${issue.message}).` },
};
}
await writeConfigFile(validated.config);
}
if (!configChanged && !shouldTouchStore) {
const message = parsed.action === "add" ? "✅ Already allowlisted." : "⚠️ Entry not found.";
return { shouldContinue: false, reply: { text: message } };
}
if (shouldTouchStore) {
if (parsed.action === "add") {
await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
} else if (parsed.action === "remove") {
await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
}
}
const actionLabel = parsed.action === "add" ? "added" : "removed";
const scopeLabel = scope === "dm" ? "DM" : "group";
const locations: string[] = [];
if (configChanged) {
locations.push(`${pathPrefix}.${allowlistPath.join(".")}`);
}
if (shouldTouchStore) {
locations.push("pairing store");
}
const targetLabel = locations.length > 0 ? locations.join(" + ") : "no-op";
return {
shouldContinue: false,
reply: {
text: `✅ ${scopeLabel} allowlist ${actionLabel}: ${targetLabel}.`,
},
};
}
if (!shouldTouchStore) {
return {
shouldContinue: false,
reply: { text: "⚠️ This channel does not support allowlist storage." },
};
}
if (parsed.action === "add") {
await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
} else if (parsed.action === "remove") {
await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
}
const actionLabel = parsed.action === "add" ? "added" : "removed";
const scopeLabel = scope === "dm" ? "DM" : "group";
return {
shouldContinue: false,
reply: { text: `✅ ${scopeLabel} allowlist ${actionLabel} in pairing store.` },
};
};

View File

@@ -13,6 +13,7 @@ import {
handleStatusCommand,
handleWhoamiCommand,
} from "./commands-info.js";
import { handleAllowlistCommand } from "./commands-allowlist.js";
import { handleSubagentsCommand } from "./commands-subagents.js";
import {
handleAbortTrigger,
@@ -37,6 +38,7 @@ const HANDLERS: CommandHandler[] = [
handleHelpCommand,
handleCommandsListCommand,
handleStatusCommand,
handleAllowlistCommand,
handleContextCommand,
handleWhoamiCommand,
handleSubagentsCommand,

View File

@@ -10,6 +10,7 @@ import { type SessionEntry, updateSessionStore } from "../../config/sessions.js"
import type { ExecAsk, ExecHost, ExecSecurity } from "../../infra/exec-approvals.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import { formatThinkingLevels, formatXHighModelHint, supportsXHighThinking } from "../thinking.js";
import type { ReplyPayload } from "../types.js";
import {
@@ -340,22 +341,11 @@ export async function handleDirectiveOnly(params: {
}
}
if (modelSelection) {
if (modelSelection.isDefault) {
delete sessionEntry.providerOverride;
delete sessionEntry.modelOverride;
} else {
sessionEntry.providerOverride = modelSelection.provider;
sessionEntry.modelOverride = modelSelection.model;
}
if (profileOverride) {
sessionEntry.authProfileOverride = profileOverride;
sessionEntry.authProfileOverrideSource = "user";
delete sessionEntry.authProfileOverrideCompactionCount;
} else if (directives.hasModelDirective) {
delete sessionEntry.authProfileOverride;
delete sessionEntry.authProfileOverrideSource;
delete sessionEntry.authProfileOverrideCompactionCount;
}
applyModelOverrideToSessionEntry({
entry: sessionEntry,
selection: modelSelection,
profileOverride,
});
}
if (directives.hasQueueDirective && directives.queueReset) {
delete sessionEntry.queueMode;

View File

@@ -16,6 +16,7 @@ import type { ClawdbotConfig } from "../../config/config.js";
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { applyVerboseOverride } from "../../sessions/level-overrides.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import { resolveProfileOverride } from "./directive-handling.auth.js";
import type { InlineDirectives } from "./directive-handling.parse.js";
import { formatElevatedEvent, formatReasoningEvent } from "./directive-handling.shared.js";
@@ -164,22 +165,15 @@ export async function persistInlineDirectives(params: {
}
const isDefault =
resolved.ref.provider === defaultProvider && resolved.ref.model === defaultModel;
if (isDefault) {
delete sessionEntry.providerOverride;
delete sessionEntry.modelOverride;
} else {
sessionEntry.providerOverride = resolved.ref.provider;
sessionEntry.modelOverride = resolved.ref.model;
}
if (profileOverride) {
sessionEntry.authProfileOverride = profileOverride;
sessionEntry.authProfileOverrideSource = "user";
delete sessionEntry.authProfileOverrideCompactionCount;
} else if (directives.hasModelDirective) {
delete sessionEntry.authProfileOverride;
delete sessionEntry.authProfileOverrideSource;
delete sessionEntry.authProfileOverrideCompactionCount;
}
const { updated: modelUpdated } = applyModelOverrideToSessionEntry({
entry: sessionEntry,
selection: {
provider: resolved.ref.provider,
model: resolved.ref.model,
isDefault,
},
profileOverride,
});
provider = resolved.ref.provider;
model = resolved.ref.model;
const nextLabel = `${provider}/${model}`;
@@ -189,7 +183,7 @@ export async function persistInlineDirectives(params: {
contextKey: `model:${nextLabel}`,
});
}
updated = true;
updated = updated || modelUpdated;
}
}
}

View File

@@ -5,16 +5,11 @@ import {
isEmbeddedPiRunStreaming,
resolveEmbeddedSessionLane,
} from "../../agents/pi-embedded.js";
import {
ensureAuthProfileStore,
isProfileInCooldown,
resolveAuthProfileOrder,
} from "../../agents/auth-profiles.js";
import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js";
import type { ExecToolDefaults } from "../../agents/bash-tools.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
resolveSessionFilePath,
saveSessionStore,
type SessionEntry,
updateSessionStore,
} from "../../config/sessions.js";
@@ -108,92 +103,6 @@ type RunPreparedReplyParams = {
abortedLastRun: boolean;
};
async function resolveSessionAuthProfileOverride(params: {
cfg: ClawdbotConfig;
provider: string;
agentDir: string;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
isNewSession: boolean;
}): Promise<string | undefined> {
const {
cfg,
provider,
agentDir,
sessionEntry,
sessionStore,
sessionKey,
storePath,
isNewSession,
} = params;
if (!sessionEntry || !sessionStore || !sessionKey) return sessionEntry?.authProfileOverride;
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
const order = resolveAuthProfileOrder({ cfg, store, provider });
if (order.length === 0) return sessionEntry.authProfileOverride;
const pickFirstAvailable = () =>
order.find((profileId) => !isProfileInCooldown(store, profileId)) ?? order[0];
const pickNextAvailable = (current: string) => {
const startIndex = order.indexOf(current);
if (startIndex < 0) return pickFirstAvailable();
for (let offset = 1; offset <= order.length; offset += 1) {
const candidate = order[(startIndex + offset) % order.length];
if (!isProfileInCooldown(store, candidate)) return candidate;
}
return order[startIndex] ?? order[0];
};
const compactionCount = sessionEntry.compactionCount ?? 0;
const storedCompaction =
typeof sessionEntry.authProfileOverrideCompactionCount === "number"
? sessionEntry.authProfileOverrideCompactionCount
: compactionCount;
let current = sessionEntry.authProfileOverride?.trim();
if (current && !order.includes(current)) current = undefined;
const source =
sessionEntry.authProfileOverrideSource ??
(typeof sessionEntry.authProfileOverrideCompactionCount === "number"
? "auto"
: current
? "user"
: undefined);
if (source === "user" && current && !isNewSession) {
return current;
}
let next = current;
if (isNewSession) {
next = current ? pickNextAvailable(current) : pickFirstAvailable();
} else if (current && compactionCount > storedCompaction) {
next = pickNextAvailable(current);
} else if (!current || isProfileInCooldown(store, current)) {
next = pickFirstAvailable();
}
if (!next) return current;
const shouldPersist =
next !== sessionEntry.authProfileOverride ||
sessionEntry.authProfileOverrideSource !== "auto" ||
sessionEntry.authProfileOverrideCompactionCount !== compactionCount;
if (shouldPersist) {
sessionEntry.authProfileOverride = next;
sessionEntry.authProfileOverrideSource = "auto";
sessionEntry.authProfileOverrideCompactionCount = compactionCount;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
}
return next;
}
export async function runPreparedReply(
params: RunPreparedReplyParams,
): Promise<ReplyPayload | ReplyPayload[] | undefined> {

View File

@@ -11,6 +11,8 @@ import {
} from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
import { clearSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import type { ThinkLevel } from "./directives.js";
export type ModelDirectiveSelection = {
@@ -184,16 +186,19 @@ export async function createModelSelectionState(params: {
if (overrideModel) {
const key = modelKey(overrideProvider, overrideModel);
if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) {
delete sessionEntry.providerOverride;
delete sessionEntry.modelOverride;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
const { updated } = applyModelOverrideToSessionEntry({
entry: sessionEntry,
selection: { provider: defaultProvider, model: defaultModel, isDefault: true },
});
if (updated) {
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
}
resetModelOverride = true;
resetModelOverride = updated;
}
}
}
@@ -215,17 +220,14 @@ export async function createModelSelectionState(params: {
allowKeychainPrompt: false,
});
const profile = store.profiles[sessionEntry.authProfileOverride];
if (!profile || profile.provider !== provider) {
delete sessionEntry.authProfileOverride;
delete sessionEntry.authProfileOverrideSource;
delete sessionEntry.authProfileOverrideCompactionCount;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
const providerKey = normalizeProviderId(provider);
if (!profile || normalizeProviderId(profile.provider) !== providerKey) {
await clearSessionAuthProfileOverride({
sessionEntry,
sessionStore,
sessionKey,
storePath,
});
}
}

View File

@@ -209,6 +209,22 @@ describe("routeReply", () => {
expect(mocks.sendMessageSlack).toHaveBeenCalledWith("channel:C123", "hi", expect.any(Object));
});
it("uses threadId for Slack when replyToId is missing", async () => {
mocks.sendMessageSlack.mockClear();
await routeReply({
payload: { text: "hi" },
channel: "slack",
to: "channel:C123",
threadId: "456.789",
cfg: {} as never,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"hi",
expect.objectContaining({ threadTs: "456.789" }),
);
});
it("passes thread id to Telegram sends", async () => {
mocks.sendMessageTelegram.mockClear();
await routeReply({

View File

@@ -42,6 +42,39 @@ describe("applyResetModelOverride", () => {
expect(sessionCtx.BodyStripped).toBe("summarize");
});
it("clears auth profile overrides when reset applies a model", async () => {
const cfg = {} as ClawdbotConfig;
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
const sessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
authProfileOverride: "anthropic:default",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: 2,
};
const sessionStore = { "agent:main:dm:1": sessionEntry };
const sessionCtx = { BodyStripped: "minimax summarize" };
const ctx = { ChatType: "direct" };
await applyResetModelOverride({
cfg,
resetTriggered: true,
bodyStripped: "minimax summarize",
sessionCtx,
ctx,
sessionEntry,
sessionStore,
sessionKey: "agent:main:dm:1",
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex,
});
expect(sessionEntry.authProfileOverride).toBeUndefined();
expect(sessionEntry.authProfileOverrideSource).toBeUndefined();
expect(sessionEntry.authProfileOverrideCompactionCount).toBeUndefined();
});
it("skips when resetTriggered is false", async () => {
const cfg = {} as ClawdbotConfig;
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });

View File

@@ -12,6 +12,7 @@ import { updateSessionStore } from "../../config/sessions.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import { formatInboundBodyWithSenderMeta } from "./inbound-sender-meta.js";
import { resolveModelDirectiveSelection, type ModelDirectiveSelection } from "./model-selection.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
type ResetModelResult = {
selection?: ModelDirectiveSelection;
@@ -62,25 +63,11 @@ function applySelectionToSession(params: {
}) {
const { selection, sessionEntry, sessionStore, sessionKey, storePath } = params;
if (!sessionEntry || !sessionStore || !sessionKey) return;
let updated = false;
if (selection.isDefault) {
if (sessionEntry.providerOverride || sessionEntry.modelOverride) {
delete sessionEntry.providerOverride;
delete sessionEntry.modelOverride;
updated = true;
}
} else {
if (sessionEntry.providerOverride !== selection.provider) {
sessionEntry.providerOverride = selection.provider;
updated = true;
}
if (sessionEntry.modelOverride !== selection.model) {
sessionEntry.modelOverride = selection.model;
updated = true;
}
}
const { updated } = applyModelOverrideToSessionEntry({
entry: sessionEntry,
selection,
});
if (!updated) return;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
updateSessionStore(storePath, (store) => {

View File

@@ -5,19 +5,23 @@ export const slackOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: null,
textChunkLimit: 4000,
sendText: async ({ to, text, accountId, deps, replyToId }) => {
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
const send = deps?.sendSlack ?? sendMessageSlack;
// Use threadId fallback so routed tool notifications stay in the Slack thread.
const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined);
const result = await send(to, text, {
threadTs: replyToId ?? undefined,
threadTs,
accountId: accountId ?? undefined,
});
return { channel: "slack", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId }) => {
const send = deps?.sendSlack ?? sendMessageSlack;
// Use threadId fallback so routed tool notifications stay in the Slack thread.
const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined);
const result = await send(to, text, {
mediaUrl,
threadTs: replyToId ?? undefined,
threadTs,
accountId: accountId ?? undefined,
});
return { channel: "slack", ...result };

View File

@@ -5,6 +5,7 @@ import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { formatCliCommand } from "./command-format.js";
import { formatHelpExamples } from "./help-format.js";
import { registerBrowserActionInputCommands } from "./browser-cli-actions-input.js";
import { registerBrowserActionObserveCommands } from "./browser-cli-actions-observe.js";
import { registerBrowserDebugCommands } from "./browser-cli-debug.js";
@@ -26,7 +27,10 @@ export function registerBrowserCli(program: Command) {
.addHelpText(
"after",
() =>
`\nExamples:\n ${[...browserCoreExamples, ...browserActionExamples].join("\n ")}\n\n${theme.muted("Docs:")} ${formatDocsLink(
`\n${theme.heading("Examples:")}\n${formatHelpExamples(
[...browserCoreExamples, ...browserActionExamples].map((cmd) => [cmd, ""]),
true,
)}\n\n${theme.muted("Docs:")} ${formatDocsLink(
"/cli/browser",
"docs.clawd.bot/cli/browser",
)}\n`,

View File

@@ -3,6 +3,8 @@ import type { Command } from "commander";
import { callGateway } from "../gateway/call.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { defaultRuntime } from "../runtime.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { withProgress } from "./progress.js";
type DevicesRpcOpts = {
@@ -96,11 +98,11 @@ function parseDevicePairingList(value: unknown): DevicePairingList {
}
function formatTokenSummary(tokens: DeviceTokenSummary[] | undefined) {
if (!tokens || tokens.length === 0) return "tokens: none";
if (!tokens || tokens.length === 0) return "none";
const parts = tokens
.map((t) => `${t.role}${t.revokedAtMs ? " (revoked)" : ""}`)
.sort((a, b) => a.localeCompare(b));
return `tokens: ${parts.join(", ")}`;
return parts.join(", ");
}
export function registerDevicesCli(program: Command) {
@@ -118,32 +120,59 @@ export function registerDevicesCli(program: Command) {
return;
}
if (list.pending?.length) {
defaultRuntime.log("Pending:");
for (const req of list.pending) {
const name = req.displayName || req.deviceId;
const repair = req.isRepair ? " (repair)" : "";
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
const age =
typeof req.ts === "number" ? ` · ${formatAge(Date.now() - req.ts)} ago` : "";
const role = req.role ? ` · role: ${req.role}` : "";
defaultRuntime.log(`- ${req.requestId}: ${name}${repair}${role}${ip}${age}`);
}
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
defaultRuntime.log(
`${theme.heading("Pending")} ${theme.muted(`(${list.pending.length})`)}`,
);
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Request", header: "Request", minWidth: 10 },
{ key: "Device", header: "Device", minWidth: 16, flex: true },
{ key: "Role", header: "Role", minWidth: 8 },
{ key: "IP", header: "IP", minWidth: 12 },
{ key: "Age", header: "Age", minWidth: 8 },
{ key: "Flags", header: "Flags", minWidth: 8 },
],
rows: list.pending.map((req) => ({
Request: req.requestId,
Device: req.displayName || req.deviceId,
Role: req.role ?? "",
IP: req.remoteIp ?? "",
Age: typeof req.ts === "number" ? `${formatAge(Date.now() - req.ts)} ago` : "",
Flags: req.isRepair ? "repair" : "",
})),
}).trimEnd(),
);
}
if (list.paired?.length) {
defaultRuntime.log("Paired:");
for (const device of list.paired) {
const name = device.displayName || device.deviceId;
const roles = device.roles?.length ? `roles: ${device.roles.join(", ")}` : "roles: -";
const scopes = device.scopes?.length
? `scopes: ${device.scopes.join(", ")}`
: "scopes: -";
const ip = device.remoteIp ? ` · ${device.remoteIp}` : "";
const tokens = formatTokenSummary(device.tokens);
defaultRuntime.log(`- ${name} · ${roles} · ${scopes} · ${tokens}${ip}`);
}
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
defaultRuntime.log(
`${theme.heading("Paired")} ${theme.muted(`(${list.paired.length})`)}`,
);
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Device", header: "Device", minWidth: 16, flex: true },
{ key: "Roles", header: "Roles", minWidth: 12, flex: true },
{ key: "Scopes", header: "Scopes", minWidth: 12, flex: true },
{ key: "Tokens", header: "Tokens", minWidth: 12, flex: true },
{ key: "IP", header: "IP", minWidth: 12 },
],
rows: list.paired.map((device) => ({
Device: device.displayName || device.deviceId,
Roles: device.roles?.length ? device.roles.join(", ") : "",
Scopes: device.scopes?.length ? device.scopes.join(", ") : "",
Tokens: formatTokenSummary(device.tokens),
IP: device.remoteIp ?? "",
})),
}).trimEnd(),
);
}
if (!list.pending?.length && !list.paired?.length) {
defaultRuntime.log("No device pairing entries.");
defaultRuntime.log(theme.muted("No device pairing entries."));
}
}),
);
@@ -160,7 +189,7 @@ export function registerDevicesCli(program: Command) {
return;
}
const deviceId = (result as { device?: { deviceId?: string } })?.device?.deviceId;
defaultRuntime.log(`device approved: ${deviceId ?? "ok"}`);
defaultRuntime.log(`${theme.success("Approved")} ${theme.command(deviceId ?? "ok")}`);
}),
);
@@ -176,7 +205,7 @@ export function registerDevicesCli(program: Command) {
return;
}
const deviceId = (result as { deviceId?: string })?.deviceId;
defaultRuntime.log(`device rejected: ${deviceId ?? "ok"}`);
defaultRuntime.log(`${theme.warn("Rejected")} ${theme.command(deviceId ?? "ok")}`);
}),
);

View File

@@ -8,6 +8,7 @@ import { resolveMessageChannelSelection } from "../infra/outbound/channel-select
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { renderTable } from "../terminal/table.js";
function parseLimit(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
@@ -22,9 +23,11 @@ function parseLimit(value: unknown): number | null {
return parsed;
}
function formatEntry(entry: { kind: string; id: string; name?: string | undefined }): string {
const name = entry.name?.trim();
return name ? `${entry.id}\t${name}` : entry.id;
function buildRows(entries: Array<{ id: string; name?: string | undefined }>) {
return entries.map((entry) => ({
ID: entry.id,
Name: entry.name?.trim() ?? "",
}));
}
export function registerDirectoryCli(program: Command) {
@@ -77,10 +80,21 @@ export function registerDirectoryCli(program: Command) {
return;
}
if (!result) {
defaultRuntime.log("not available");
defaultRuntime.log(theme.muted("Not available."));
return;
}
defaultRuntime.log(formatEntry(result));
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
defaultRuntime.log(`${theme.heading("Self")}`);
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "ID", header: "ID", minWidth: 16, flex: true },
{ key: "Name", header: "Name", minWidth: 18, flex: true },
],
rows: buildRows([result]),
}).trimEnd(),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
@@ -111,9 +125,22 @@ export function registerDirectoryCli(program: Command) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
for (const entry of result) {
defaultRuntime.log(formatEntry(entry));
if (result.length === 0) {
defaultRuntime.log(theme.muted("No peers found."));
return;
}
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
defaultRuntime.log(`${theme.heading("Peers")} ${theme.muted(`(${result.length})`)}`);
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "ID", header: "ID", minWidth: 16, flex: true },
{ key: "Name", header: "Name", minWidth: 18, flex: true },
],
rows: buildRows(result),
}).trimEnd(),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
@@ -143,9 +170,22 @@ export function registerDirectoryCli(program: Command) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
for (const entry of result) {
defaultRuntime.log(formatEntry(entry));
if (result.length === 0) {
defaultRuntime.log(theme.muted("No groups found."));
return;
}
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
defaultRuntime.log(`${theme.heading("Groups")} ${theme.muted(`(${result.length})`)}`);
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "ID", header: "ID", minWidth: 16, flex: true },
{ key: "Name", header: "Name", minWidth: 18, flex: true },
],
rows: buildRows(result),
}).trimEnd(),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
@@ -180,9 +220,24 @@ export function registerDirectoryCli(program: Command) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
for (const entry of result) {
defaultRuntime.log(formatEntry(entry));
if (result.length === 0) {
defaultRuntime.log(theme.muted("No group members found."));
return;
}
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
defaultRuntime.log(
`${theme.heading("Group Members")} ${theme.muted(`(${result.length})`)}`,
);
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "ID", header: "ID", minWidth: 16, flex: true },
{ key: "Name", header: "Name", minWidth: 18, flex: true },
],
rows: buildRows(result),
}).trimEnd(),
);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);

View File

@@ -7,6 +7,8 @@ describe("dns cli", () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const program = buildProgram();
await program.parseAsync(["dns", "setup"], { from: "user" });
expect(log).toHaveBeenCalledWith(expect.stringContaining("Domain:"));
const output = log.mock.calls.map((call) => call.join(" ")).join("\n");
expect(output).toContain("DNS setup");
expect(output).toContain("clawdbot.internal");
});
});

View File

@@ -7,7 +7,9 @@ import type { Command } from "commander";
import { loadConfig } from "../config/config.js";
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
import { getWideAreaZonePath, WIDE_AREA_DISCOVERY_DOMAIN } from "../infra/widearea-dns.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
type RunOpts = { allowFailure?: boolean; inherit?: boolean };
@@ -112,14 +114,28 @@ export function registerDnsCli(program: Command) {
const tailnetIPv6 = pickPrimaryTailnetIPv6();
const zonePath = getWideAreaZonePath();
console.log(`Domain: ${WIDE_AREA_DISCOVERY_DOMAIN}`);
console.log(`Zone file (gateway-owned): ${zonePath}`);
console.log(
`Detected tailnet IP: ${tailnetIPv4 ?? "—"}${tailnetIPv6 ? ` (v6 ${tailnetIPv6})` : ""}`,
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
defaultRuntime.log(theme.heading("DNS setup"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Key", header: "Key", minWidth: 18 },
{ key: "Value", header: "Value", minWidth: 24, flex: true },
],
rows: [
{ Key: "Domain", Value: WIDE_AREA_DISCOVERY_DOMAIN },
{ Key: "Zone file", Value: zonePath },
{
Key: "Tailnet IP",
Value: `${tailnetIPv4 ?? "—"}${tailnetIPv6 ? ` (v6 ${tailnetIPv6})` : ""}`,
},
],
}).trimEnd(),
);
console.log("");
console.log("Recommended ~/.clawdbot/clawdbot.json:");
console.log(
defaultRuntime.log("");
defaultRuntime.log(theme.heading("Recommended ~/.clawdbot/clawdbot.json:"));
defaultRuntime.log(
JSON.stringify(
{
gateway: { bind: "auto" },
@@ -129,14 +145,16 @@ export function registerDnsCli(program: Command) {
2,
),
);
console.log("");
console.log("Tailscale admin (DNS → Nameservers):");
console.log(`- Add nameserver: ${tailnetIPv4 ?? "<this machine's tailnet IPv4>"}`);
console.log(`- Restrict to domain (Split DNS): clawdbot.internal`);
defaultRuntime.log("");
defaultRuntime.log(theme.heading("Tailscale admin (DNS → Nameservers):"));
defaultRuntime.log(
theme.muted(`- Add nameserver: ${tailnetIPv4 ?? "<this machine's tailnet IPv4>"}`),
);
defaultRuntime.log(theme.muted("- Restrict to domain (Split DNS): clawdbot.internal"));
if (!opts.apply) {
console.log("");
console.log("Run with --apply to install CoreDNS and configure it.");
defaultRuntime.log("");
defaultRuntime.log(theme.muted("Run with --apply to install CoreDNS and configure it."));
return;
}
@@ -205,16 +223,18 @@ export function registerDnsCli(program: Command) {
fs.writeFileSync(zonePath, zoneLines.join("\n"), "utf-8");
}
console.log("");
console.log("Starting CoreDNS (sudo)…");
defaultRuntime.log("");
defaultRuntime.log(theme.heading("Starting CoreDNS (sudo)…"));
run("sudo", ["brew", "services", "restart", "coredns"], {
inherit: true,
});
if (cfg.discovery?.wideArea?.enabled !== true) {
console.log("");
console.log(
"Note: enable discovery.wideArea.enabled in ~/.clawdbot/clawdbot.json on the gateway and restart the gateway so it writes the DNS-SD zone.",
defaultRuntime.log("");
defaultRuntime.log(
theme.muted(
"Note: enable discovery.wideArea.enabled in ~/.clawdbot/clawdbot.json on the gateway and restart the gateway so it writes the DNS-SD zone.",
),
);
}
});

25
src/cli/help-format.ts Normal file
View File

@@ -0,0 +1,25 @@
import { theme } from "../terminal/theme.js";
export type HelpExample = readonly [command: string, description: string];
export function formatHelpExample(command: string, description: string): string {
return ` ${theme.command(command)}\n ${theme.muted(description)}`;
}
export function formatHelpExampleLine(command: string, description: string): string {
if (!description) return ` ${theme.command(command)}`;
return ` ${theme.command(command)} ${theme.muted(`# ${description}`)}`;
}
export function formatHelpExamples(examples: ReadonlyArray<HelpExample>, inline = false): string {
const formatter = inline ? formatHelpExampleLine : formatHelpExample;
return examples.map(([command, description]) => formatter(command, description)).join("\n");
}
export function formatHelpExampleGroup(
label: string,
examples: ReadonlyArray<HelpExample>,
inline = false,
) {
return `${theme.muted(label)}\n${formatHelpExamples(examples, inline)}`;
}

View File

@@ -1,7 +1,6 @@
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import chalk from "chalk";
import type { Command } from "commander";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { ClawdbotConfig } from "../config/config.js";
@@ -23,6 +22,7 @@ import { recordHookInstall } from "../hooks/installs.js";
import { buildPluginStatusReport } from "../plugins/status.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { formatCliCommand } from "./command-format.js";
import { resolveUserPath } from "../utils.js";
@@ -66,50 +66,40 @@ function buildHooksReport(config: ClawdbotConfig): HookStatusReport {
return buildWorkspaceHookStatus(workspaceDir, { config, entries });
}
/**
* Format a single hook for display in the list
*/
function formatHookLine(hook: HookStatusEntry, verbose = false): string {
function formatHookStatus(hook: HookStatusEntry): string {
if (hook.eligible) return theme.success("✓ ready");
if (hook.disabled) return theme.warn("⏸ disabled");
return theme.error("✗ missing");
}
function formatHookName(hook: HookStatusEntry): string {
const emoji = hook.emoji ?? "🔗";
const status = hook.eligible
? chalk.green("✓")
: hook.disabled
? chalk.yellow("disabled")
: chalk.red("missing reqs");
return `${emoji} ${theme.command(hook.name)}`;
}
const name = hook.eligible ? chalk.white(hook.name) : chalk.gray(hook.name);
function formatHookSource(hook: HookStatusEntry): string {
if (!hook.managedByPlugin) return hook.source;
return `plugin:${hook.pluginId ?? "unknown"}`;
}
const desc = chalk.gray(
hook.description.length > 50 ? `${hook.description.slice(0, 47)}...` : hook.description,
);
const sourceLabel = hook.managedByPlugin
? chalk.magenta(`plugin:${hook.pluginId ?? "unknown"}`)
: "";
if (verbose) {
const missing: string[] = [];
if (hook.missing.bins.length > 0) {
missing.push(`bins: ${hook.missing.bins.join(", ")}`);
}
if (hook.missing.anyBins.length > 0) {
missing.push(`anyBins: ${hook.missing.anyBins.join(", ")}`);
}
if (hook.missing.env.length > 0) {
missing.push(`env: ${hook.missing.env.join(", ")}`);
}
if (hook.missing.config.length > 0) {
missing.push(`config: ${hook.missing.config.join(", ")}`);
}
if (hook.missing.os.length > 0) {
missing.push(`os: ${hook.missing.os.join(", ")}`);
}
const missingStr = missing.length > 0 ? chalk.red(` [${missing.join("; ")}]`) : "";
const sourceSuffix = sourceLabel ? ` ${sourceLabel}` : "";
return `${emoji} ${name} ${status}${missingStr}\n ${desc}${sourceSuffix}`;
function formatHookMissingSummary(hook: HookStatusEntry): string {
const missing: string[] = [];
if (hook.missing.bins.length > 0) {
missing.push(`bins: ${hook.missing.bins.join(", ")}`);
}
const sourceSuffix = sourceLabel ? ` ${sourceLabel}` : "";
return `${emoji} ${name} ${status} - ${desc}${sourceSuffix}`;
if (hook.missing.anyBins.length > 0) {
missing.push(`anyBins: ${hook.missing.anyBins.join(", ")}`);
}
if (hook.missing.env.length > 0) {
missing.push(`env: ${hook.missing.env.join(", ")}`);
}
if (hook.missing.config.length > 0) {
missing.push(`config: ${hook.missing.config.join(", ")}`);
}
if (hook.missing.os.length > 0) {
missing.push(`os: ${hook.missing.os.join(", ")}`);
}
return missing.join("; ");
}
async function readInstalledPackageVersion(dir: string): Promise<string | undefined> {
@@ -157,27 +147,39 @@ export function formatHooksList(report: HookStatusReport, opts: HooksListOptions
}
const eligible = hooks.filter((h) => h.eligible);
const notEligible = hooks.filter((h) => !h.eligible);
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const rows = hooks.map((hook) => {
const missing = formatHookMissingSummary(hook);
return {
Status: formatHookStatus(hook),
Hook: formatHookName(hook),
Description: theme.muted(hook.description),
Source: formatHookSource(hook),
Missing: missing ? theme.warn(missing) : "",
};
});
const columns = [
{ key: "Status", header: "Status", minWidth: 10 },
{ key: "Hook", header: "Hook", minWidth: 18, flex: true },
{ key: "Description", header: "Description", minWidth: 24, flex: true },
{ key: "Source", header: "Source", minWidth: 12, flex: true },
];
if (opts.verbose) {
columns.push({ key: "Missing", header: "Missing", minWidth: 18, flex: true });
}
const lines: string[] = [];
lines.push(chalk.bold.cyan("Hooks") + chalk.gray(` (${eligible.length}/${hooks.length} ready)`));
lines.push("");
if (eligible.length > 0) {
lines.push(chalk.bold.green("Ready:"));
for (const hook of eligible) {
lines.push(` ${formatHookLine(hook, opts.verbose)}`);
}
}
if (notEligible.length > 0 && !opts.eligible) {
if (eligible.length > 0) lines.push("");
lines.push(chalk.bold.yellow("Not ready:"));
for (const hook of notEligible) {
lines.push(` ${formatHookLine(hook, opts.verbose)}`);
}
}
lines.push(
`${theme.heading("Hooks")} ${theme.muted(`(${eligible.length}/${hooks.length} ready)`)}`,
);
lines.push(
renderTable({
width: tableWidth,
columns,
rows,
}).trimEnd(),
);
return lines.join("\n");
}
@@ -205,33 +207,33 @@ export function formatHookInfo(
const lines: string[] = [];
const emoji = hook.emoji ?? "🔗";
const status = hook.eligible
? chalk.green("✓ Ready")
? theme.success("✓ Ready")
: hook.disabled
? chalk.yellow("⏸ Disabled")
: chalk.red("✗ Missing requirements");
? theme.warn("⏸ Disabled")
: theme.error("✗ Missing requirements");
lines.push(`${emoji} ${chalk.bold.cyan(hook.name)} ${status}`);
lines.push(`${emoji} ${theme.heading(hook.name)} ${status}`);
lines.push("");
lines.push(chalk.white(hook.description));
lines.push(hook.description);
lines.push("");
// Details
lines.push(chalk.bold("Details:"));
lines.push(theme.heading("Details:"));
if (hook.managedByPlugin) {
lines.push(` Source: ${hook.source} (${hook.pluginId ?? "unknown"})`);
lines.push(`${theme.muted(" Source:")} ${hook.source} (${hook.pluginId ?? "unknown"})`);
} else {
lines.push(` Source: ${hook.source}`);
lines.push(`${theme.muted(" Source:")} ${hook.source}`);
}
lines.push(` Path: ${chalk.gray(hook.filePath)}`);
lines.push(` Handler: ${chalk.gray(hook.handlerPath)}`);
lines.push(`${theme.muted(" Path:")} ${hook.filePath}`);
lines.push(`${theme.muted(" Handler:")} ${hook.handlerPath}`);
if (hook.homepage) {
lines.push(` Homepage: ${chalk.blue(hook.homepage)}`);
lines.push(`${theme.muted(" Homepage:")} ${hook.homepage}`);
}
if (hook.events.length > 0) {
lines.push(` Events: ${hook.events.join(", ")}`);
lines.push(`${theme.muted(" Events:")} ${hook.events.join(", ")}`);
}
if (hook.managedByPlugin) {
lines.push(` Managed by plugin; enable/disable via hooks CLI not available.`);
lines.push(theme.muted(" Managed by plugin; enable/disable via hooks CLI not available."));
}
// Requirements
@@ -244,40 +246,40 @@ export function formatHookInfo(
if (hasRequirements) {
lines.push("");
lines.push(chalk.bold("Requirements:"));
lines.push(theme.heading("Requirements:"));
if (hook.requirements.bins.length > 0) {
const binsStatus = hook.requirements.bins.map((bin) => {
const missing = hook.missing.bins.includes(bin);
return missing ? chalk.red(`✗ ${bin}`) : chalk.green(`✓ ${bin}`);
return missing ? theme.error(`✗ ${bin}`) : theme.success(`✓ ${bin}`);
});
lines.push(` Binaries: ${binsStatus.join(", ")}`);
lines.push(`${theme.muted(" Binaries:")} ${binsStatus.join(", ")}`);
}
if (hook.requirements.anyBins.length > 0) {
const anyBinsStatus =
hook.missing.anyBins.length > 0
? chalk.red(`✗ (any of: ${hook.requirements.anyBins.join(", ")})`)
: chalk.green(`✓ (any of: ${hook.requirements.anyBins.join(", ")})`);
lines.push(` Any binary: ${anyBinsStatus}`);
? theme.error(`✗ (any of: ${hook.requirements.anyBins.join(", ")})`)
: theme.success(`✓ (any of: ${hook.requirements.anyBins.join(", ")})`);
lines.push(`${theme.muted(" Any binary:")} ${anyBinsStatus}`);
}
if (hook.requirements.env.length > 0) {
const envStatus = hook.requirements.env.map((env) => {
const missing = hook.missing.env.includes(env);
return missing ? chalk.red(`✗ ${env}`) : chalk.green(`✓ ${env}`);
return missing ? theme.error(`✗ ${env}`) : theme.success(`✓ ${env}`);
});
lines.push(` Environment: ${envStatus.join(", ")}`);
lines.push(`${theme.muted(" Environment:")} ${envStatus.join(", ")}`);
}
if (hook.requirements.config.length > 0) {
const configStatus = hook.configChecks.map((check) => {
return check.satisfied ? chalk.green(`✓ ${check.path}`) : chalk.red(`✗ ${check.path}`);
return check.satisfied ? theme.success(`✓ ${check.path}`) : theme.error(`✗ ${check.path}`);
});
lines.push(` Config: ${configStatus.join(", ")}`);
lines.push(`${theme.muted(" Config:")} ${configStatus.join(", ")}`);
}
if (hook.requirements.os.length > 0) {
const osStatus =
hook.missing.os.length > 0
? chalk.red(`✗ (${hook.requirements.os.join(", ")})`)
: chalk.green(`✓ (${hook.requirements.os.join(", ")})`);
lines.push(` OS: ${osStatus}`);
? theme.error(`✗ (${hook.requirements.os.join(", ")})`)
: theme.success(`✓ (${hook.requirements.os.join(", ")})`);
lines.push(`${theme.muted(" OS:")} ${osStatus}`);
}
}
@@ -313,15 +315,15 @@ export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptio
const notEligible = report.hooks.filter((h) => !h.eligible);
const lines: string[] = [];
lines.push(chalk.bold.cyan("Hooks Status"));
lines.push(theme.heading("Hooks Status"));
lines.push("");
lines.push(`Total hooks: ${report.hooks.length}`);
lines.push(chalk.green(`Ready: ${eligible.length}`));
lines.push(chalk.yellow(`Not ready: ${notEligible.length}`));
lines.push(`${theme.muted("Total hooks:")} ${report.hooks.length}`);
lines.push(`${theme.success("Ready:")} ${eligible.length}`);
lines.push(`${theme.warn("Not ready:")} ${notEligible.length}`);
if (notEligible.length > 0) {
lines.push("");
lines.push(chalk.bold.yellow("Hooks not ready:"));
lines.push(theme.heading("Hooks not ready:"));
for (const hook of notEligible) {
const reasons = [];
if (hook.disabled) reasons.push("disabled");
@@ -374,7 +376,9 @@ export async function enableHook(hookName: string): Promise<void> {
};
await writeConfigFile(nextConfig);
console.log(`${chalk.green("✓")} Enabled hook: ${hook.emoji ?? "🔗"} ${hookName}`);
defaultRuntime.log(
`${theme.success("✓")} Enabled hook: ${hook.emoji ?? "🔗"} ${theme.command(hookName)}`,
);
}
export async function disableHook(hookName: string): Promise<void> {
@@ -408,7 +412,9 @@ export async function disableHook(hookName: string): Promise<void> {
};
await writeConfigFile(nextConfig);
console.log(`${chalk.yellow("⏸")} Disabled hook: ${hook.emoji ?? "🔗"} ${hookName}`);
defaultRuntime.log(
`${theme.warn("⏸")} Disabled hook: ${hook.emoji ?? "🔗"} ${theme.command(hookName)}`,
);
}
export function registerHooksCli(program: Command): void {
@@ -431,9 +437,11 @@ export function registerHooksCli(program: Command): void {
try {
const config = loadConfig();
const report = buildHooksReport(config);
console.log(formatHooksList(report, opts));
defaultRuntime.log(formatHooksList(report, opts));
} catch (err) {
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
defaultRuntime.error(
`${theme.error("Error:")} ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
});
@@ -446,9 +454,11 @@ export function registerHooksCli(program: Command): void {
try {
const config = loadConfig();
const report = buildHooksReport(config);
console.log(formatHookInfo(report, name, opts));
defaultRuntime.log(formatHookInfo(report, name, opts));
} catch (err) {
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
defaultRuntime.error(
`${theme.error("Error:")} ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
});
@@ -461,9 +471,11 @@ export function registerHooksCli(program: Command): void {
try {
const config = loadConfig();
const report = buildHooksReport(config);
console.log(formatHooksCheck(report, opts));
defaultRuntime.log(formatHooksCheck(report, opts));
} catch (err) {
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
defaultRuntime.error(
`${theme.error("Error:")} ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
});
@@ -475,7 +487,9 @@ export function registerHooksCli(program: Command): void {
try {
await enableHook(name);
} catch (err) {
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
defaultRuntime.error(
`${theme.error("Error:")} ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
});
@@ -487,7 +501,9 @@ export function registerHooksCli(program: Command): void {
try {
await disableHook(name);
} catch (err) {
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
defaultRuntime.error(
`${theme.error("Error:")} ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
});
@@ -570,7 +586,7 @@ export function registerHooksCli(program: Command): void {
path: resolved,
logger: {
info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
},
});
if (!result.ok) {
@@ -650,7 +666,7 @@ export function registerHooksCli(program: Command): void {
spec: raw,
logger: {
info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
},
});
if (!result.ok) {
@@ -726,15 +742,15 @@ export function registerHooksCli(program: Command): void {
for (const hookId of targets) {
const record = installs[hookId];
if (!record) {
defaultRuntime.log(chalk.yellow(`No install record for "${hookId}".`));
defaultRuntime.log(theme.warn(`No install record for "${hookId}".`));
continue;
}
if (record.source !== "npm") {
defaultRuntime.log(chalk.yellow(`Skipping "${hookId}" (source: ${record.source}).`));
defaultRuntime.log(theme.warn(`Skipping "${hookId}" (source: ${record.source}).`));
continue;
}
if (!record.spec) {
defaultRuntime.log(chalk.yellow(`Skipping "${hookId}" (missing npm spec).`));
defaultRuntime.log(theme.warn(`Skipping "${hookId}" (missing npm spec).`));
continue;
}
@@ -749,11 +765,11 @@ export function registerHooksCli(program: Command): void {
expectedHookPackId: hookId,
logger: {
info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
},
});
if (!probe.ok) {
defaultRuntime.log(chalk.red(`Failed to check ${hookId}: ${probe.error}`));
defaultRuntime.log(theme.error(`Failed to check ${hookId}: ${probe.error}`));
continue;
}
@@ -773,11 +789,11 @@ export function registerHooksCli(program: Command): void {
expectedHookPackId: hookId,
logger: {
info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
},
});
if (!result.ok) {
defaultRuntime.log(chalk.red(`Failed to update ${hookId}: ${result.error}`));
defaultRuntime.log(theme.error(`Failed to update ${hookId}: ${result.error}`));
continue;
}
@@ -811,9 +827,11 @@ export function registerHooksCli(program: Command): void {
try {
const config = loadConfig();
const report = buildHooksReport(config);
console.log(formatHooksList(report, {}));
defaultRuntime.log(formatHooksList(report, {}));
} catch (err) {
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
defaultRuntime.error(
`${theme.error("Error:")} ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
});

View File

@@ -5,6 +5,7 @@ import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
import { renderTable } from "../../terminal/table.js";
import { parseDurationMs } from "../parse-duration.js";
function formatVersionLabel(raw: string) {
const trimmed = raw.trim();
@@ -43,30 +44,85 @@ function formatNodeVersions(node: {
return parts.length > 0 ? parts.join(" · ") : null;
}
function parseSinceMs(raw: unknown, label: string): number | undefined {
if (raw === undefined || raw === null) return undefined;
const value =
typeof raw === "string" ? raw.trim() : typeof raw === "number" ? String(raw).trim() : null;
if (value === null) {
defaultRuntime.error(`${label}: invalid duration value`);
defaultRuntime.exit(1);
return undefined;
}
if (!value) return undefined;
try {
return parseDurationMs(value);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
defaultRuntime.error(`${label}: ${message}`);
defaultRuntime.exit(1);
return undefined;
}
}
export function registerNodesStatusCommands(nodes: Command) {
nodesCallOpts(
nodes
.command("status")
.description("List known nodes with connection status and capabilities")
.option("--connected", "Only show connected nodes")
.option("--last-connected <duration>", "Only show nodes connected within duration (e.g. 24h)")
.action(async (opts: NodesRpcOpts) => {
await runNodesCommand("status", async () => {
const connectedOnly = Boolean(opts.connected);
const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected");
const result = (await callGatewayCli("node.list", opts, {})) as unknown;
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const obj =
typeof result === "object" && result !== null
? (result as Record<string, unknown>)
: {};
const { ok, warn, muted } = getNodesTheme();
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const now = Date.now();
const nodes = parseNodeList(result);
const pairedCount = nodes.filter((n) => Boolean(n.paired)).length;
const connectedCount = nodes.filter((n) => Boolean(n.connected)).length;
defaultRuntime.log(
`Known: ${nodes.length} · Paired: ${pairedCount} · Connected: ${connectedCount}`,
);
if (nodes.length === 0) return;
const lastConnectedById =
sinceMs !== undefined
? new Map(
parsePairingList(await callGatewayCli("node.pair.list", opts, {})).paired.map(
(entry) => [entry.nodeId, entry],
),
)
: null;
const filtered = nodes.filter((n) => {
if (connectedOnly && !n.connected) return false;
if (sinceMs !== undefined) {
const paired = lastConnectedById?.get(n.nodeId);
const lastConnectedAtMs =
typeof paired?.lastConnectedAtMs === "number"
? paired.lastConnectedAtMs
: typeof n.connectedAtMs === "number"
? n.connectedAtMs
: undefined;
if (typeof lastConnectedAtMs !== "number") return false;
if (now - lastConnectedAtMs > sinceMs) return false;
}
return true;
});
const rows = nodes.map((n) => {
if (opts.json) {
const ts = typeof obj.ts === "number" ? obj.ts : Date.now();
defaultRuntime.log(JSON.stringify({ ...obj, ts, nodes: filtered }, null, 2));
return;
}
const pairedCount = filtered.filter((n) => Boolean(n.paired)).length;
const connectedCount = filtered.filter((n) => Boolean(n.connected)).length;
const filteredLabel = filtered.length !== nodes.length ? ` (of ${nodes.length})` : "";
defaultRuntime.log(
`Known: ${filtered.length}${filteredLabel} · Paired: ${pairedCount} · Connected: ${connectedCount}`,
);
if (filtered.length === 0) return;
const rows = filtered.map((n) => {
const name = n.displayName?.trim() ? n.displayName.trim() : n.nodeId;
const perms = formatPermissions(n.permissions);
const versions = formatNodeVersions(n);
@@ -197,21 +253,60 @@ export function registerNodesStatusCommands(nodes: Command) {
nodes
.command("list")
.description("List pending and paired nodes")
.option("--connected", "Only show connected nodes")
.option("--last-connected <duration>", "Only show nodes connected within duration (e.g. 24h)")
.action(async (opts: NodesRpcOpts) => {
await runNodesCommand("list", async () => {
const connectedOnly = Boolean(opts.connected);
const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected");
const result = (await callGatewayCli("node.pair.list", opts, {})) as unknown;
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const { pending, paired } = parsePairingList(result);
defaultRuntime.log(`Pending: ${pending.length} · Paired: ${paired.length}`);
const { heading, muted, warn } = getNodesTheme();
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const now = Date.now();
const hasFilters = connectedOnly || sinceMs !== undefined;
const pendingRows = hasFilters ? [] : pending;
const connectedById = hasFilters
? new Map(
parseNodeList(await callGatewayCli("node.list", opts, {})).map((node) => [
node.nodeId,
node,
]),
)
: null;
const filteredPaired = paired.filter((node) => {
if (connectedOnly) {
const live = connectedById?.get(node.nodeId);
if (!live?.connected) return false;
}
if (sinceMs !== undefined) {
const live = connectedById?.get(node.nodeId);
const lastConnectedAtMs =
typeof node.lastConnectedAtMs === "number"
? node.lastConnectedAtMs
: typeof live?.connectedAtMs === "number"
? live.connectedAtMs
: undefined;
if (typeof lastConnectedAtMs !== "number") return false;
if (now - lastConnectedAtMs > sinceMs) return false;
}
return true;
});
const filteredLabel =
hasFilters && filteredPaired.length !== paired.length ? ` (of ${paired.length})` : "";
defaultRuntime.log(
`Pending: ${pendingRows.length} · Paired: ${filteredPaired.length}${filteredLabel}`,
);
if (pending.length > 0) {
const pendingRows = pending.map((r) => ({
if (opts.json) {
defaultRuntime.log(
JSON.stringify({ pending: pendingRows, paired: filteredPaired }, null, 2),
);
return;
}
if (pendingRows.length > 0) {
const pendingRowsRendered = pendingRows.map((r) => ({
Request: r.requestId,
Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId,
IP: r.remoteIp ?? "",
@@ -233,21 +328,30 @@ export function registerNodesStatusCommands(nodes: Command) {
{ key: "Requested", header: "Requested", minWidth: 12 },
{ key: "Repair", header: "Repair", minWidth: 6 },
],
rows: pendingRows,
rows: pendingRowsRendered,
}).trimEnd(),
);
}
if (paired.length > 0) {
const pairedRows = paired.map((n) => ({
Node: n.displayName?.trim() ? n.displayName.trim() : n.nodeId,
Id: n.nodeId,
IP: n.remoteIp ?? "",
LastConnect:
if (filteredPaired.length > 0) {
const pairedRows = filteredPaired.map((n) => {
const live = connectedById?.get(n.nodeId);
const lastConnectedAtMs =
typeof n.lastConnectedAtMs === "number"
? `${formatAge(Math.max(0, now - n.lastConnectedAtMs))} ago`
: muted("unknown"),
}));
? n.lastConnectedAtMs
: typeof live?.connectedAtMs === "number"
? live.connectedAtMs
: undefined;
return {
Node: n.displayName?.trim() ? n.displayName.trim() : n.nodeId,
Id: n.nodeId,
IP: n.remoteIp ?? "",
LastConnect:
typeof lastConnectedAtMs === "number"
? `${formatAge(Math.max(0, now - lastConnectedAtMs))} ago`
: muted("unknown"),
};
});
defaultRuntime.log("");
defaultRuntime.log(heading("Paired"));
defaultRuntime.log(

View File

@@ -8,6 +8,8 @@ export type NodesRpcOpts = {
params?: string;
invokeTimeout?: string;
idempotencyKey?: string;
connected?: boolean;
lastConnected?: string;
target?: string;
x?: string;
y?: string;

View File

@@ -71,7 +71,9 @@ describe("pairing cli", () => {
await program.parseAsync(["pairing", "list", "--channel", "telegram"], {
from: "user",
});
expect(log).toHaveBeenCalledWith(expect.stringContaining("telegramUserId=123"));
const output = log.mock.calls.map((call) => call.join(" ")).join("\n");
expect(output).toContain("telegramUserId");
expect(output).toContain("123");
});
it("accepts channel as positional for list", async () => {
@@ -131,7 +133,9 @@ describe("pairing cli", () => {
await program.parseAsync(["pairing", "list", "--channel", "discord"], {
from: "user",
});
expect(log).toHaveBeenCalledWith(expect.stringContaining("discordUserId=999"));
const output = log.mock.calls.map((call) => call.join(" ")).join("\n");
expect(output).toContain("discordUserId");
expect(output).toContain("999");
});
it("accepts channel as positional for approve (npm-run compatible)", async () => {

View File

@@ -8,7 +8,9 @@ import {
listChannelPairingRequests,
type PairingChannel,
} from "../pairing/pairing-store.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { formatCliCommand } from "./command-format.js";
@@ -70,18 +72,35 @@ export function registerPairingCli(program: Command) {
const channel = parseChannel(channelRaw, channels);
const requests = await listChannelPairingRequests(channel);
if (opts.json) {
console.log(JSON.stringify({ channel, requests }, null, 2));
defaultRuntime.log(JSON.stringify({ channel, requests }, null, 2));
return;
}
if (requests.length === 0) {
console.log(`No pending ${channel} pairing requests.`);
defaultRuntime.log(theme.muted(`No pending ${channel} pairing requests.`));
return;
}
for (const r of requests) {
const meta = r.meta ? JSON.stringify(r.meta) : "";
const idLabel = resolvePairingIdLabel(channel);
console.log(`${r.code} ${idLabel}=${r.id}${meta ? ` meta=${meta}` : ""} ${r.createdAt}`);
}
const idLabel = resolvePairingIdLabel(channel);
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
defaultRuntime.log(
`${theme.heading("Pairing requests")} ${theme.muted(`(${requests.length})`)}`,
);
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Code", header: "Code", minWidth: 10 },
{ key: "ID", header: idLabel, minWidth: 12, flex: true },
{ key: "Meta", header: "Meta", minWidth: 8, flex: true },
{ key: "Requested", header: "Requested", minWidth: 12 },
],
rows: requests.map((r) => ({
Code: r.code,
ID: r.id,
Meta: r.meta ? JSON.stringify(r.meta) : "",
Requested: r.createdAt,
})),
}).trimEnd(),
);
});
pairing
@@ -113,11 +132,13 @@ export function registerPairingCli(program: Command) {
throw new Error(`No pending pairing request found for code: ${String(resolvedCode)}`);
}
console.log(`Approved ${channel} sender ${approved.id}.`);
defaultRuntime.log(
`${theme.success("Approved")} ${theme.muted(channel)} sender ${theme.command(approved.id)}.`,
);
if (!opts.notify) return;
await notifyApproved(channel, approved.id).catch((err) => {
console.log(`Failed to notify requester: ${String(err)}`);
defaultRuntime.log(theme.warn(`Failed to notify requester: ${String(err)}`));
});
});
}

View File

@@ -1,6 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import chalk from "chalk";
import type { Command } from "commander";
import { loadConfig, writeConfigFile } from "../config/config.js";
@@ -14,6 +13,7 @@ import { buildPluginStatusReport } from "../plugins/status.js";
import { updateNpmInstalledPlugins } from "../plugins/update.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { resolveUserPath } from "../utils.js";
@@ -35,19 +35,19 @@ export type PluginUpdateOptions = {
function formatPluginLine(plugin: PluginRecord, verbose = false): string {
const status =
plugin.status === "loaded"
? chalk.green("✓")
? theme.success("loaded")
: plugin.status === "disabled"
? chalk.yellow("disabled")
: chalk.red("error");
const name = plugin.name ? chalk.white(plugin.name) : chalk.white(plugin.id);
const idSuffix = plugin.name !== plugin.id ? chalk.gray(` (${plugin.id})`) : "";
? theme.warn("disabled")
: theme.error("error");
const name = theme.command(plugin.name || plugin.id);
const idSuffix = plugin.name && plugin.name !== plugin.id ? theme.muted(` (${plugin.id})`) : "";
const desc = plugin.description
? chalk.gray(
? theme.muted(
plugin.description.length > 60
? `${plugin.description.slice(0, 57)}...`
: plugin.description,
)
: chalk.gray("(no description)");
: theme.muted("(no description)");
if (!verbose) {
return `${name}${idSuffix} ${status} - ${desc}`;
@@ -55,14 +55,14 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string {
const parts = [
`${name}${idSuffix} ${status}`,
` source: ${chalk.gray(plugin.source)}`,
` source: ${theme.muted(plugin.source)}`,
` origin: ${plugin.origin}`,
];
if (plugin.version) parts.push(` version: ${plugin.version}`);
if (plugin.providerIds.length > 0) {
parts.push(` providers: ${plugin.providerIds.join(", ")}`);
}
if (plugin.error) parts.push(chalk.red(` error: ${plugin.error}`));
if (plugin.error) parts.push(theme.error(` error: ${plugin.error}`));
return parts.join("\n");
}
@@ -87,7 +87,7 @@ function applySlotSelectionForPlugin(
function logSlotWarnings(warnings: string[]) {
if (warnings.length === 0) return;
for (const warning of warnings) {
defaultRuntime.log(chalk.yellow(warning));
defaultRuntime.log(theme.warn(warning));
}
}
@@ -124,19 +124,51 @@ export function registerPluginsCli(program: Command) {
}
if (list.length === 0) {
defaultRuntime.log("No plugins found.");
defaultRuntime.log(theme.muted("No plugins found."));
return;
}
const loaded = list.filter((p) => p.status === "loaded").length;
defaultRuntime.log(
`${theme.heading("Plugins")} ${theme.muted(`(${loaded}/${list.length} loaded)`)}`,
);
if (!opts.verbose) {
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const rows = list.map((plugin) => ({
Name: plugin.name || plugin.id,
ID: plugin.name && plugin.name !== plugin.id ? plugin.id : "",
Status:
plugin.status === "loaded"
? theme.success("loaded")
: plugin.status === "disabled"
? theme.warn("disabled")
: theme.error("error"),
Source: plugin.source,
Version: plugin.version ?? "",
Description: plugin.description ?? "",
}));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Name", header: "Name", minWidth: 14, flex: true },
{ key: "ID", header: "ID", minWidth: 10, flex: true },
{ key: "Status", header: "Status", minWidth: 10 },
{ key: "Source", header: "Source", minWidth: 10 },
{ key: "Version", header: "Version", minWidth: 8 },
{ key: "Description", header: "Description", minWidth: 18, flex: true },
],
rows,
}).trimEnd(),
);
return;
}
const lines: string[] = [];
const loaded = list.filter((p) => p.status === "loaded").length;
lines.push(
`${chalk.bold.cyan("Plugins")} ${chalk.gray(`(${loaded}/${list.length} loaded)`)}`,
);
lines.push("");
for (const plugin of list) {
lines.push(formatPluginLine(plugin, opts.verbose));
if (opts.verbose) lines.push("");
lines.push(formatPluginLine(plugin, true));
lines.push("");
}
defaultRuntime.log(lines.join("\n").trim());
});
@@ -162,43 +194,45 @@ export function registerPluginsCli(program: Command) {
}
const lines: string[] = [];
lines.push(chalk.bold.cyan(plugin.name || plugin.id));
lines.push(theme.heading(plugin.name || plugin.id));
if (plugin.name && plugin.name !== plugin.id) {
lines.push(chalk.gray(`id: ${plugin.id}`));
lines.push(theme.muted(`id: ${plugin.id}`));
}
if (plugin.description) lines.push(plugin.description);
lines.push("");
lines.push(`Status: ${plugin.status}`);
lines.push(`Source: ${plugin.source}`);
lines.push(`Origin: ${plugin.origin}`);
if (plugin.version) lines.push(`Version: ${plugin.version}`);
lines.push(`${theme.muted("Status:")} ${plugin.status}`);
lines.push(`${theme.muted("Source:")} ${plugin.source}`);
lines.push(`${theme.muted("Origin:")} ${plugin.origin}`);
if (plugin.version) lines.push(`${theme.muted("Version:")} ${plugin.version}`);
if (plugin.toolNames.length > 0) {
lines.push(`Tools: ${plugin.toolNames.join(", ")}`);
lines.push(`${theme.muted("Tools:")} ${plugin.toolNames.join(", ")}`);
}
if (plugin.hookNames.length > 0) {
lines.push(`Hooks: ${plugin.hookNames.join(", ")}`);
lines.push(`${theme.muted("Hooks:")} ${plugin.hookNames.join(", ")}`);
}
if (plugin.gatewayMethods.length > 0) {
lines.push(`Gateway methods: ${plugin.gatewayMethods.join(", ")}`);
lines.push(`${theme.muted("Gateway methods:")} ${plugin.gatewayMethods.join(", ")}`);
}
if (plugin.providerIds.length > 0) {
lines.push(`Providers: ${plugin.providerIds.join(", ")}`);
lines.push(`${theme.muted("Providers:")} ${plugin.providerIds.join(", ")}`);
}
if (plugin.cliCommands.length > 0) {
lines.push(`CLI commands: ${plugin.cliCommands.join(", ")}`);
lines.push(`${theme.muted("CLI commands:")} ${plugin.cliCommands.join(", ")}`);
}
if (plugin.services.length > 0) {
lines.push(`Services: ${plugin.services.join(", ")}`);
lines.push(`${theme.muted("Services:")} ${plugin.services.join(", ")}`);
}
if (plugin.error) lines.push(chalk.red(`Error: ${plugin.error}`));
if (plugin.error) lines.push(`${theme.error("Error:")} ${plugin.error}`);
if (install) {
lines.push("");
lines.push(`Install: ${install.source}`);
if (install.spec) lines.push(`Spec: ${install.spec}`);
if (install.sourcePath) lines.push(`Source path: ${install.sourcePath}`);
if (install.installPath) lines.push(`Install path: ${install.installPath}`);
if (install.version) lines.push(`Recorded version: ${install.version}`);
if (install.installedAt) lines.push(`Installed at: ${install.installedAt}`);
lines.push(`${theme.muted("Install:")} ${install.source}`);
if (install.spec) lines.push(`${theme.muted("Spec:")} ${install.spec}`);
if (install.sourcePath) lines.push(`${theme.muted("Source path:")} ${install.sourcePath}`);
if (install.installPath)
lines.push(`${theme.muted("Install path:")} ${install.installPath}`);
if (install.version) lines.push(`${theme.muted("Recorded version:")} ${install.version}`);
if (install.installedAt)
lines.push(`${theme.muted("Installed at:")} ${install.installedAt}`);
}
defaultRuntime.log(lines.join("\n"));
});
@@ -308,7 +342,7 @@ export function registerPluginsCli(program: Command) {
path: resolved,
logger: {
info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
},
});
if (!result.ok) {
@@ -372,7 +406,7 @@ export function registerPluginsCli(program: Command) {
spec: raw,
logger: {
info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
},
});
if (!result.ok) {
@@ -420,6 +454,10 @@ export function registerPluginsCli(program: Command) {
const targets = opts.all ? Object.keys(installs) : id ? [id] : [];
if (targets.length === 0) {
if (opts.all) {
defaultRuntime.log("No npm-installed plugins to update.");
return;
}
defaultRuntime.error("Provide a plugin id or use --all.");
process.exit(1);
}
@@ -430,17 +468,17 @@ export function registerPluginsCli(program: Command) {
dryRun: opts.dryRun,
logger: {
info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
},
});
for (const outcome of result.outcomes) {
if (outcome.status === "error") {
defaultRuntime.log(chalk.red(outcome.message));
defaultRuntime.log(theme.error(outcome.message));
continue;
}
if (outcome.status === "skipped") {
defaultRuntime.log(chalk.yellow(outcome.message));
defaultRuntime.log(theme.warn(outcome.message));
continue;
}
defaultRuntime.log(outcome.message);
@@ -467,14 +505,14 @@ export function registerPluginsCli(program: Command) {
const lines: string[] = [];
if (errors.length > 0) {
lines.push(chalk.bold.red("Plugin errors:"));
lines.push(theme.error("Plugin errors:"));
for (const entry of errors) {
lines.push(`- ${entry.id}: ${entry.error ?? "failed to load"} (${entry.source})`);
}
}
if (diags.length > 0) {
if (lines.length > 0) lines.push("");
lines.push(chalk.bold.yellow("Diagnostics:"));
lines.push(theme.warn("Diagnostics:"));
for (const diag of diags) {
const target = diag.pluginId ? `${diag.pluginId}: ` : "";
lines.push(`- ${target}${diag.message}`);

View File

@@ -68,6 +68,83 @@ describe("cli program (nodes basics)", () => {
expect(runtime.log).toHaveBeenCalledWith("Pending: 0 · Paired: 0");
});
it("runs nodes list --connected and filters to connected nodes", async () => {
const now = Date.now();
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.pair.list") {
return {
pending: [],
paired: [
{
nodeId: "n1",
displayName: "One",
remoteIp: "10.0.0.1",
lastConnectedAtMs: now - 1_000,
},
{
nodeId: "n2",
displayName: "Two",
remoteIp: "10.0.0.2",
lastConnectedAtMs: now - 1_000,
},
],
};
}
if (opts.method === "node.list") {
return {
nodes: [
{ nodeId: "n1", connected: true },
{ nodeId: "n2", connected: false },
],
};
}
return { ok: true };
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(["nodes", "list", "--connected"], { from: "user" });
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.list" }));
const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
expect(output).toContain("One");
expect(output).not.toContain("Two");
});
it("runs nodes status --last-connected and filters by age", async () => {
const now = Date.now();
callGateway.mockImplementation(async (opts: { method?: string }) => {
if (opts.method === "node.list") {
return {
ts: now,
nodes: [
{ nodeId: "n1", displayName: "One", connected: false },
{ nodeId: "n2", displayName: "Two", connected: false },
],
};
}
if (opts.method === "node.pair.list") {
return {
pending: [],
paired: [
{ nodeId: "n1", lastConnectedAtMs: now - 1_000 },
{ nodeId: "n2", lastConnectedAtMs: now - 2 * 24 * 60 * 60 * 1000 },
],
};
}
return { ok: true };
});
const program = buildProgram();
runtime.log.mockClear();
await program.parseAsync(["nodes", "status", "--last-connected", "24h"], {
from: "user",
});
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.pair.list" }));
const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
expect(output).toContain("One");
expect(output).not.toContain("Two");
});
it("runs nodes status and calls node.list", async () => {
callGateway.mockResolvedValue({
ts: Date.now(),

View File

@@ -4,7 +4,6 @@ import { agentsListCommand } from "../../commands/agents.js";
import { healthCommand } from "../../commands/health.js";
import { sessionsCommand } from "../../commands/sessions.js";
import { statusCommand } from "../../commands/status.js";
import { setVerbose } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import { getFlagValue, getPositiveIntFlagValue, getVerboseFlag, hasFlag } from "../argv.js";
import { registerBrowserCli } from "../browser-cli.js";
@@ -46,7 +45,6 @@ const routeHealth: RouteSpec = {
const verbose = getVerboseFlag(argv, { includeDebug: true });
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
if (timeoutMs === null) return false;
setVerbose(verbose);
await healthCommand({ json, timeoutMs, verbose }, defaultRuntime);
return true;
},
@@ -63,7 +61,6 @@ const routeStatus: RouteSpec = {
const verbose = getVerboseFlag(argv, { includeDebug: true });
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
if (timeoutMs === null) return false;
setVerbose(verbose);
await statusCommand({ json, deep, all, usage, timeoutMs, verbose }, defaultRuntime);
return true;
},
@@ -73,12 +70,10 @@ const routeSessions: RouteSpec = {
match: (path) => path[0] === "sessions",
run: async (argv) => {
const json = hasFlag(argv, "--json");
const verbose = getVerboseFlag(argv);
const store = getFlagValue(argv, "--store");
if (store === null) return false;
const active = getFlagValue(argv, "--active");
if (active === null) return false;
setVerbose(verbose);
await sessionsCommand({ json, store, active }, defaultRuntime);
return true;
},

View File

@@ -1,9 +1,10 @@
import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js";
import { emitCliBanner } from "../banner.js";
import { getCommandPath, hasHelpOrVersion } from "../argv.js";
import { getCommandPath, getVerboseFlag, hasHelpOrVersion } from "../argv.js";
import { ensureConfigReady } from "./config-guard.js";
import { ensurePluginRegistryLoaded } from "../plugin-registry.js";
import { setVerbose } from "../../globals.js";
function setProcessTitleForCommand(actionCommand: Command) {
let current: Command = actionCommand;
@@ -24,6 +25,11 @@ export function registerPreActionHooks(program: Command, programVersion: string)
emitCliBanner(programVersion);
const argv = process.argv;
if (hasHelpOrVersion(argv)) return;
const verbose = getVerboseFlag(argv, { includeDebug: true });
setVerbose(verbose);
if (!verbose) {
process.env.NODE_NO_WARNINGS ??= "1";
}
const commandPath = getCommandPath(argv, 2);
if (commandPath[0] === "doctor") return;
await ensureConfigReady({ runtime: defaultRuntime, commandPath });

View File

@@ -12,6 +12,7 @@ import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { hasExplicitOptions } from "../command-options.js";
import { formatHelpExamples } from "../help-format.js";
import { createDefaultDeps } from "../deps.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { collectOption } from "./helpers.js";
@@ -48,13 +49,24 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
"after",
() =>
`
Examples:
clawdbot agent --to +15555550123 --message "status update"
clawdbot agent --agent ops --message "Summarize logs"
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
clawdbot agent --to +15555550123 --message "Trace logs" --verbose on --json
clawdbot agent --to +15555550123 --message "Summon reply" --deliver
clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
${theme.heading("Examples:")}
${formatHelpExamples([
['clawdbot agent --to +15555550123 --message "status update"', "Start a new session."],
['clawdbot agent --agent ops --message "Summarize logs"', "Use a specific agent."],
[
'clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium',
"Target a session with explicit thinking level.",
],
[
'clawdbot agent --to +15555550123 --message "Trace logs" --verbose on --json',
"Enable verbose logging and JSON output.",
],
['clawdbot agent --to +15555550123 --message "Summon reply" --deliver', "Deliver reply."],
[
'clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"',
"Send reply to a different channel/target.",
],
])}
${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent")}`,
)
@@ -140,10 +152,15 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
"after",
() =>
`
Examples:
clawdbot agents set-identity --agent main --name "Clawd" --emoji "🦞"
clawdbot agents set-identity --workspace ~/clawd --from-identity
clawdbot agents set-identity --identity-file ~/clawd/IDENTITY.md --agent main
${theme.heading("Examples:")}
${formatHelpExamples([
['clawdbot agents set-identity --agent main --name "Clawd" --emoji "🦞"', "Set name + emoji."],
["clawdbot agents set-identity --workspace ~/clawd --from-identity", "Load from IDENTITY.md."],
[
"clawdbot agents set-identity --identity-file ~/clawd/IDENTITY.md --agent main",
"Use a specific IDENTITY.md.",
],
])}
`,
)
.action(async (opts) => {

View File

@@ -1,6 +1,7 @@
import type { Command } from "commander";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { formatHelpExamples } from "../help-format.js";
import type { ProgramContext } from "./context.js";
import { createMessageCliHelpers } from "./message/helpers.js";
import { registerMessageDiscordAdminCommands } from "./message/register.discord-admin.js";
@@ -28,11 +29,22 @@ export function registerMessageCommands(program: Command, ctx: ProgramContext) {
"after",
() =>
`
Examples:
clawdbot message send --target +15555550123 --message "Hi"
clawdbot message send --target +15555550123 --message "Hi" --media photo.jpg
clawdbot message poll --channel discord --target channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi
clawdbot message react --channel discord --target 123 --message-id 456 --emoji "✅"
${theme.heading("Examples:")}
${formatHelpExamples([
['clawdbot message send --target +15555550123 --message "Hi"', "Send a text message."],
[
'clawdbot message send --target +15555550123 --message "Hi" --media photo.jpg',
"Send a message with media.",
],
[
'clawdbot message poll --channel discord --target channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi',
"Create a Discord poll.",
],
[
'clawdbot message react --channel discord --target 123 --message-id 456 --emoji "✅"',
"React to a message.",
],
])}
${theme.muted("Docs:")} ${formatDocsLink("/cli/message", "docs.clawd.bot/cli/message")}`,
)

View File

@@ -7,6 +7,7 @@ import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { formatHelpExamples } from "../help-format.js";
import { parsePositiveIntOrUndefined } from "./helpers.js";
function resolveVerbose(opts: { verbose?: boolean; debug?: boolean }): boolean {
@@ -36,15 +37,18 @@ export function registerStatusHealthSessionsCommands(program: Command) {
.option("--debug", "Alias for --verbose", false)
.addHelpText(
"after",
`
Examples:
clawdbot status # show linked account + session store summary
clawdbot status --all # full diagnosis (read-only)
clawdbot status --json # machine-readable output
clawdbot status --usage # show model provider usage/quota snapshots
clawdbot status --deep # run channel probes (WA + Telegram + Discord + Slack + Signal)
clawdbot status --deep --timeout 5000 # tighten probe timeout
clawdbot channels status # gateway channel runtime + probes`,
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
["clawdbot status", "Show channel health + session summary."],
["clawdbot status --all", "Full diagnosis (read-only)."],
["clawdbot status --json", "Machine-readable output."],
["clawdbot status --usage", "Show model provider usage/quota snapshots."],
[
"clawdbot status --deep",
"Run channel probes (WA + Telegram + Discord + Slack + Signal).",
],
["clawdbot status --deep --timeout 5000", "Tighten probe timeout."],
])}`,
)
.addHelpText(
"after",
@@ -113,14 +117,15 @@ Examples:
.option("--active <minutes>", "Only show sessions updated within the past N minutes")
.addHelpText(
"after",
`
Examples:
clawdbot sessions # list all sessions
clawdbot sessions --active 120 # only last 2 hours
clawdbot sessions --json # machine-readable output
clawdbot sessions --store ./tmp/sessions.json
Shows token usage per session when the agent reports it; set agents.defaults.contextTokens to see % of your model window.`,
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
["clawdbot sessions", "List all sessions."],
["clawdbot sessions --active 120", "Only last 2 hours."],
["clawdbot sessions --json", "Machine-readable output."],
["clawdbot sessions --store ./tmp/sessions.json", "Use a specific session store."],
])}\n\n${theme.muted(
"Shows token usage per session when the agent reports it; set agents.defaults.contextTokens to see % of your model window.",
)}`,
)
.addHelpText(
"after",

View File

@@ -21,7 +21,7 @@ const { nodesAction, registerNodesCli } = vi.hoisted(() => {
vi.mock("../acp-cli.js", () => ({ registerAcpCli }));
vi.mock("../nodes-cli.js", () => ({ registerNodesCli }));
const { registerSubCliCommands } = await import("./register.subclis.js");
const { registerSubCliByName, registerSubCliCommands } = await import("./register.subclis.js");
describe("registerSubCliCommands", () => {
const originalArgv = process.argv;
@@ -78,4 +78,20 @@ describe("registerSubCliCommands", () => {
expect(registerNodesCli).toHaveBeenCalledTimes(1);
expect(nodesAction).toHaveBeenCalledTimes(1);
});
it("replaces placeholder when registering a subcommand by name", async () => {
process.argv = ["node", "clawdbot", "acp", "--help"];
const program = new Command();
program.name("clawdbot");
registerSubCliCommands(program, process.argv);
await registerSubCliByName(program, "acp");
const names = program.commands.map((cmd) => cmd.name());
expect(names.filter((name) => name === "acp")).toHaveLength(1);
await program.parseAsync(["node", "clawdbot", "acp"], { from: "user" });
expect(registerAcpCli).toHaveBeenCalledTimes(1);
expect(acpAction).toHaveBeenCalledTimes(1);
});
});

View File

@@ -18,10 +18,8 @@ const shouldRegisterPrimaryOnly = (argv: string[]) => {
return true;
};
const shouldEagerRegisterSubcommands = (argv: string[]) => {
if (isTruthyEnvValue(process.env.CLAWDBOT_DISABLE_LAZY_SUBCOMMANDS)) return true;
if (hasHelpOrVersion(argv)) return true;
return false;
const shouldEagerRegisterSubcommands = (_argv: string[]) => {
return isTruthyEnvValue(process.env.CLAWDBOT_DISABLE_LAZY_SUBCOMMANDS);
};
const loadConfig = async (): Promise<ClawdbotConfig> => {
@@ -234,6 +232,15 @@ function removeCommand(program: Command, command: Command) {
}
}
export async function registerSubCliByName(program: Command, name: string): Promise<boolean> {
const entry = entries.find((candidate) => candidate.name === name);
if (!entry) return false;
const existing = program.commands.find((cmd) => cmd.name() === entry.name);
if (existing) removeCommand(program, existing);
await entry.register(program);
return true;
}
function registerLazyCommand(program: Command, entry: SubCliEntry) {
const placeholder = program.command(entry.name).description(entry.description);
placeholder.allowUnknownOption(true);

View File

@@ -10,6 +10,7 @@ import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { formatUncaughtError } from "../infra/errors.js";
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { enableConsoleCapture } from "../logging.js";
import { getPrimaryCommand, hasHelpOrVersion } from "./argv.js";
import { tryRouteCli } from "./route.js";
export function rewriteUpdateFlagArgv(argv: string[]): string[] {
@@ -47,7 +48,15 @@ export async function runCli(argv: string[] = process.argv) {
process.exit(1);
});
await program.parseAsync(rewriteUpdateFlagArgv(normalizedArgv));
const parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
if (hasHelpOrVersion(parseArgv)) {
const primary = getPrimaryCommand(parseArgv);
if (primary) {
const { registerSubCliByName } = await import("./program/register.subclis.js");
await registerSubCliByName(program, primary);
}
}
await program.parseAsync(parseArgv);
}
function stripWindowsNodeExec(argv: string[]): string[] {

View File

@@ -5,6 +5,7 @@ import { sandboxExplainCommand } from "../commands/sandbox-explain.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { formatHelpExamples } from "./help-format.js";
// --- Types ---
@@ -12,58 +13,34 @@ type CommandOptions = Record<string, unknown>;
// --- Helpers ---
const EXAMPLES = {
main: `
Examples:
clawdbot sandbox list # List all sandbox containers
clawdbot sandbox list --browser # List only browser containers
clawdbot sandbox recreate --all # Recreate all containers
clawdbot sandbox recreate --session main # Recreate specific session
clawdbot sandbox recreate --agent mybot # Recreate agent containers
clawdbot sandbox explain # Explain effective sandbox config`,
list: `
Examples:
clawdbot sandbox list # List all sandbox containers
clawdbot sandbox list --browser # List only browser containers
clawdbot sandbox list --json # JSON output
Output includes:
• Container name and status (running/stopped)
• Docker image and whether it matches current config
• Age (time since creation)
• Idle time (time since last use)
• Associated session/agent ID`,
recreate: `
Examples:
clawdbot sandbox recreate --all # Recreate all containers
clawdbot sandbox recreate --session main # Specific session
clawdbot sandbox recreate --agent mybot # Specific agent (includes sub-agents)
clawdbot sandbox recreate --browser --all # All browser containers only
clawdbot sandbox recreate --all --force # Skip confirmation
Why use this?
After updating Docker images or sandbox configuration, existing containers
continue running with old settings. This command removes them so they'll be
recreated automatically with current config when next needed.
Filter options:
--all Remove all sandbox containers
--session Remove container for specific session key
--agent Remove containers for agent (includes agent:id:* variants)
Modifiers:
--browser Only affect browser containers (not regular sandbox)
--force Skip confirmation prompt`,
explain: `
Examples:
clawdbot sandbox explain
clawdbot sandbox explain --session agent:main:main
clawdbot sandbox explain --agent work
clawdbot sandbox explain --json`,
};
const SANDBOX_EXAMPLES = {
main: [
["clawdbot sandbox list", "List all sandbox containers."],
["clawdbot sandbox list --browser", "List only browser containers."],
["clawdbot sandbox recreate --all", "Recreate all containers."],
["clawdbot sandbox recreate --session main", "Recreate a specific session."],
["clawdbot sandbox recreate --agent mybot", "Recreate agent containers."],
["clawdbot sandbox explain", "Explain effective sandbox config."],
],
list: [
["clawdbot sandbox list", "List all sandbox containers."],
["clawdbot sandbox list --browser", "List only browser containers."],
["clawdbot sandbox list --json", "JSON output."],
],
recreate: [
["clawdbot sandbox recreate --all", "Recreate all containers."],
["clawdbot sandbox recreate --session main", "Recreate a specific session."],
["clawdbot sandbox recreate --agent mybot", "Recreate a specific agent (includes sub-agents)."],
["clawdbot sandbox recreate --browser --all", "Recreate only browser containers."],
["clawdbot sandbox recreate --all --force", "Skip confirmation."],
],
explain: [
["clawdbot sandbox explain", "Show effective sandbox config."],
["clawdbot sandbox explain --session agent:main:main", "Explain a specific session."],
["clawdbot sandbox explain --agent work", "Explain an agent sandbox."],
["clawdbot sandbox explain --json", "JSON output."],
],
} as const;
function createRunner(
commandFn: (opts: CommandOptions, runtime: typeof defaultRuntime) => Promise<void>,
@@ -84,7 +61,10 @@ export function registerSandboxCli(program: Command) {
const sandbox = program
.command("sandbox")
.description("Manage sandbox containers (Docker-based agent isolation)")
.addHelpText("after", EXAMPLES.main)
.addHelpText(
"after",
() => `\n${theme.heading("Examples:")}\n${formatHelpExamples(SANDBOX_EXAMPLES.main)}\n`,
)
.addHelpText(
"after",
() =>
@@ -101,7 +81,17 @@ export function registerSandboxCli(program: Command) {
.description("List sandbox containers and their status")
.option("--json", "Output result as JSON", false)
.option("--browser", "List browser containers only", false)
.addHelpText("after", EXAMPLES.list)
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples(SANDBOX_EXAMPLES.list)}\n\n${theme.heading(
"Output includes:",
)}\n${theme.muted("- Container name and status (running/stopped)")}\n${theme.muted(
"- Docker image and whether it matches current config",
)}\n${theme.muted("- Age (time since creation)")}\n${theme.muted(
"- Idle time (time since last use)",
)}\n${theme.muted("- Associated session/agent ID")}`,
)
.action(
createRunner((opts) =>
sandboxListCommand(
@@ -124,7 +114,25 @@ export function registerSandboxCli(program: Command) {
.option("--agent <id>", "Recreate containers for specific agent")
.option("--browser", "Only recreate browser containers", false)
.option("--force", "Skip confirmation prompt", false)
.addHelpText("after", EXAMPLES.recreate)
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples(SANDBOX_EXAMPLES.recreate)}\n\n${theme.heading(
"Why use this?",
)}\n${theme.muted(
"After updating Docker images or sandbox configuration, existing containers continue running with old settings.",
)}\n${theme.muted(
"This command removes them so they'll be recreated automatically with current config when next needed.",
)}\n\n${theme.heading("Filter options:")}\n${theme.muted(
" --all Remove all sandbox containers",
)}\n${theme.muted(
" --session Remove container for specific session key",
)}\n${theme.muted(
" --agent Remove containers for agent (includes agent:id:* variants)",
)}\n\n${theme.heading("Modifiers:")}\n${theme.muted(
" --browser Only affect browser containers (not regular sandbox)",
)}\n${theme.muted(" --force Skip confirmation prompt")}`,
)
.action(
createRunner((opts) =>
sandboxRecreateCommand(
@@ -148,7 +156,10 @@ export function registerSandboxCli(program: Command) {
.option("--session <key>", "Session key to inspect (defaults to agent main)")
.option("--agent <id>", "Agent id to inspect (defaults to derived agent)")
.option("--json", "Output result as JSON", false)
.addHelpText("after", EXAMPLES.explain)
.addHelpText(
"after",
() => `\n${theme.heading("Examples:")}\n${formatHelpExamples(SANDBOX_EXAMPLES.explain)}\n`,
)
.action(
createRunner((opts) =>
sandboxExplainCommand(

View File

@@ -1,4 +1,3 @@
import chalk from "chalk";
import type { Command } from "commander";
import { loadConfig } from "../config/config.js";
@@ -121,7 +120,7 @@ export function registerSecurityCli(program: Command) {
lines.push("");
lines.push(heading(label));
for (const f of list) {
lines.push(`${chalk.gray(f.checkId)} ${f.title}`);
lines.push(`${theme.muted(f.checkId)} ${f.title}`);
lines.push(` ${f.detail}`);
if (f.remediation?.trim()) lines.push(` ${muted(`Fix: ${f.remediation.trim()}`)}`);
}

View File

@@ -1,6 +1,7 @@
import type { Command } from "commander";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { formatHelpExampleGroup } from "./help-format.js";
import { createDefaultDeps } from "./deps.js";
import {
runDaemonInstall,
@@ -20,12 +21,6 @@ import {
} from "./node-cli/daemon.js";
export function registerServiceCli(program: Command) {
const formatExample = (cmd: string, desc: string) =>
` ${theme.command(cmd)}\n ${theme.muted(desc)}`;
const formatGroup = (label: string, examples: Array<[string, string]>) =>
`${theme.muted(label)}\n${examples.map(([cmd, desc]) => formatExample(cmd, desc)).join("\n")}`;
const gatewayExamples: Array<[string, string]> = [
["clawdbot service gateway status", "Show gateway service status + probe."],
[
@@ -50,10 +45,12 @@ export function registerServiceCli(program: Command) {
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatGroup("Gateway:", gatewayExamples)}\n\n${formatGroup(
"Node:",
nodeExamples,
)}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/service", "docs.clawd.bot/cli/service")}\n`,
`\n${theme.heading("Examples:")}\n${formatHelpExampleGroup(
"Gateway:",
gatewayExamples,
)}\n\n${formatHelpExampleGroup("Node:", nodeExamples)}\n\n${theme.muted(
"Docs:",
)} ${formatDocsLink("/cli/service", "docs.clawd.bot/cli/service")}\n`,
);
const gateway = service.command("gateway").description("Manage the Gateway service");

View File

@@ -1,4 +1,3 @@
import chalk from "chalk";
import type { Command } from "commander";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import {
@@ -9,6 +8,7 @@ import {
import { loadConfig } from "../config/config.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { formatCliCommand } from "./command-format.js";
@@ -31,47 +31,36 @@ function appendClawdHubHint(output: string, json?: boolean): string {
return `${output}\n\nTip: use \`npx clawdhub\` to search, install, and sync skills.`;
}
/**
* Format a single skill for display in the list
*/
function formatSkillLine(skill: SkillStatusEntry, verbose = false): string {
function formatSkillStatus(skill: SkillStatusEntry): string {
if (skill.eligible) return theme.success("✓ ready");
if (skill.disabled) return theme.warn("⏸ disabled");
if (skill.blockedByAllowlist) return theme.warn("🚫 blocked");
return theme.error("✗ missing");
}
function formatSkillName(skill: SkillStatusEntry): string {
const emoji = skill.emoji ?? "📦";
const status = skill.eligible
? chalk.green("✓")
: skill.disabled
? chalk.yellow("disabled")
: skill.blockedByAllowlist
? chalk.yellow("blocked")
: chalk.red("missing reqs");
return `${emoji} ${theme.command(skill.name)}`;
}
const name = skill.eligible ? chalk.white(skill.name) : chalk.gray(skill.name);
const desc = chalk.gray(
skill.description.length > 50 ? `${skill.description.slice(0, 47)}...` : skill.description,
);
if (verbose) {
const missing: string[] = [];
if (skill.missing.bins.length > 0) {
missing.push(`bins: ${skill.missing.bins.join(", ")}`);
}
if (skill.missing.anyBins.length > 0) {
missing.push(`anyBins: ${skill.missing.anyBins.join(", ")}`);
}
if (skill.missing.env.length > 0) {
missing.push(`env: ${skill.missing.env.join(", ")}`);
}
if (skill.missing.config.length > 0) {
missing.push(`config: ${skill.missing.config.join(", ")}`);
}
if (skill.missing.os.length > 0) {
missing.push(`os: ${skill.missing.os.join(", ")}`);
}
const missingStr = missing.length > 0 ? chalk.red(` [${missing.join("; ")}]`) : "";
return `${emoji} ${name} ${status}${missingStr}\n ${desc}`;
function formatSkillMissingSummary(skill: SkillStatusEntry): string {
const missing: string[] = [];
if (skill.missing.bins.length > 0) {
missing.push(`bins: ${skill.missing.bins.join(", ")}`);
}
return `${emoji} ${name} ${status} - ${desc}`;
if (skill.missing.anyBins.length > 0) {
missing.push(`anyBins: ${skill.missing.anyBins.join(", ")}`);
}
if (skill.missing.env.length > 0) {
missing.push(`env: ${skill.missing.env.join(", ")}`);
}
if (skill.missing.config.length > 0) {
missing.push(`config: ${skill.missing.config.join(", ")}`);
}
if (skill.missing.os.length > 0) {
missing.push(`os: ${skill.missing.os.join(", ")}`);
}
return missing.join("; ");
}
/**
@@ -108,28 +97,39 @@ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOpti
}
const eligible = skills.filter((s) => s.eligible);
const notEligible = skills.filter((s) => !s.eligible);
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const rows = skills.map((skill) => {
const missing = formatSkillMissingSummary(skill);
return {
Status: formatSkillStatus(skill),
Skill: formatSkillName(skill),
Description: theme.muted(skill.description),
Source: skill.source ?? "",
Missing: missing ? theme.warn(missing) : "",
};
});
const columns = [
{ key: "Status", header: "Status", minWidth: 10 },
{ key: "Skill", header: "Skill", minWidth: 18, flex: true },
{ key: "Description", header: "Description", minWidth: 24, flex: true },
{ key: "Source", header: "Source", minWidth: 10 },
];
if (opts.verbose) {
columns.push({ key: "Missing", header: "Missing", minWidth: 18, flex: true });
}
const lines: string[] = [];
lines.push(
chalk.bold.cyan("Skills") + chalk.gray(` (${eligible.length}/${skills.length} ready)`),
`${theme.heading("Skills")} ${theme.muted(`(${eligible.length}/${skills.length} ready)`)}`,
);
lines.push(
renderTable({
width: tableWidth,
columns,
rows,
}).trimEnd(),
);
lines.push("");
if (eligible.length > 0) {
lines.push(chalk.bold.green("Ready:"));
for (const skill of eligible) {
lines.push(` ${formatSkillLine(skill, opts.verbose)}`);
}
}
if (notEligible.length > 0 && !opts.eligible) {
if (eligible.length > 0) lines.push("");
lines.push(chalk.bold.yellow("Not ready:"));
for (const skill of notEligible) {
lines.push(` ${formatSkillLine(skill, opts.verbose)}`);
}
}
return appendClawdHubHint(lines.join("\n"), opts.json);
}
@@ -161,27 +161,27 @@ export function formatSkillInfo(
const lines: string[] = [];
const emoji = skill.emoji ?? "📦";
const status = skill.eligible
? chalk.green("✓ Ready")
? theme.success("✓ Ready")
: skill.disabled
? chalk.yellow("⏸ Disabled")
? theme.warn("⏸ Disabled")
: skill.blockedByAllowlist
? chalk.yellow("🚫 Blocked by allowlist")
: chalk.red("✗ Missing requirements");
? theme.warn("🚫 Blocked by allowlist")
: theme.error("✗ Missing requirements");
lines.push(`${emoji} ${chalk.bold.cyan(skill.name)} ${status}`);
lines.push(`${emoji} ${theme.heading(skill.name)} ${status}`);
lines.push("");
lines.push(chalk.white(skill.description));
lines.push(skill.description);
lines.push("");
// Details
lines.push(chalk.bold("Details:"));
lines.push(` Source: ${skill.source}`);
lines.push(` Path: ${chalk.gray(skill.filePath)}`);
lines.push(theme.heading("Details:"));
lines.push(`${theme.muted(" Source:")} ${skill.source}`);
lines.push(`${theme.muted(" Path:")} ${skill.filePath}`);
if (skill.homepage) {
lines.push(` Homepage: ${chalk.blue(skill.homepage)}`);
lines.push(`${theme.muted(" Homepage:")} ${skill.homepage}`);
}
if (skill.primaryEnv) {
lines.push(` Primary env: ${skill.primaryEnv}`);
lines.push(`${theme.muted(" Primary env:")} ${skill.primaryEnv}`);
}
// Requirements
@@ -194,51 +194,51 @@ export function formatSkillInfo(
if (hasRequirements) {
lines.push("");
lines.push(chalk.bold("Requirements:"));
lines.push(theme.heading("Requirements:"));
if (skill.requirements.bins.length > 0) {
const binsStatus = skill.requirements.bins.map((bin) => {
const missing = skill.missing.bins.includes(bin);
return missing ? chalk.red(`✗ ${bin}`) : chalk.green(`✓ ${bin}`);
return missing ? theme.error(`✗ ${bin}`) : theme.success(`✓ ${bin}`);
});
lines.push(` Binaries: ${binsStatus.join(", ")}`);
lines.push(`${theme.muted(" Binaries:")} ${binsStatus.join(", ")}`);
}
if (skill.requirements.anyBins.length > 0) {
const anyBinsMissing = skill.missing.anyBins.length > 0;
const anyBinsStatus = skill.requirements.anyBins.map((bin) => {
const missing = anyBinsMissing;
return missing ? chalk.red(`✗ ${bin}`) : chalk.green(`✓ ${bin}`);
return missing ? theme.error(`✗ ${bin}`) : theme.success(`✓ ${bin}`);
});
lines.push(` Any binaries: ${anyBinsStatus.join(", ")}`);
lines.push(`${theme.muted(" Any binaries:")} ${anyBinsStatus.join(", ")}`);
}
if (skill.requirements.env.length > 0) {
const envStatus = skill.requirements.env.map((env) => {
const missing = skill.missing.env.includes(env);
return missing ? chalk.red(`✗ ${env}`) : chalk.green(`✓ ${env}`);
return missing ? theme.error(`✗ ${env}`) : theme.success(`✓ ${env}`);
});
lines.push(` Environment: ${envStatus.join(", ")}`);
lines.push(`${theme.muted(" Environment:")} ${envStatus.join(", ")}`);
}
if (skill.requirements.config.length > 0) {
const configStatus = skill.requirements.config.map((cfg) => {
const missing = skill.missing.config.includes(cfg);
return missing ? chalk.red(`✗ ${cfg}`) : chalk.green(`✓ ${cfg}`);
return missing ? theme.error(`✗ ${cfg}`) : theme.success(`✓ ${cfg}`);
});
lines.push(` Config: ${configStatus.join(", ")}`);
lines.push(`${theme.muted(" Config:")} ${configStatus.join(", ")}`);
}
if (skill.requirements.os.length > 0) {
const osStatus = skill.requirements.os.map((osName) => {
const missing = skill.missing.os.includes(osName);
return missing ? chalk.red(`✗ ${osName}`) : chalk.green(`✓ ${osName}`);
return missing ? theme.error(`✗ ${osName}`) : theme.success(`✓ ${osName}`);
});
lines.push(` OS: ${osStatus.join(", ")}`);
lines.push(`${theme.muted(" OS:")} ${osStatus.join(", ")}`);
}
}
// Install options
if (skill.install.length > 0 && !skill.eligible) {
lines.push("");
lines.push(chalk.bold("Install options:"));
lines.push(theme.heading("Install options:"));
for (const inst of skill.install) {
lines.push(` ${chalk.yellow("→")} ${inst.label}`);
lines.push(` ${theme.warn("→")} ${inst.label}`);
}
}
@@ -281,17 +281,17 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp
}
const lines: string[] = [];
lines.push(chalk.bold.cyan("Skills Status Check"));
lines.push(theme.heading("Skills Status Check"));
lines.push("");
lines.push(`Total: ${report.skills.length}`);
lines.push(`${chalk.green("✓")} Eligible: ${eligible.length}`);
lines.push(`${chalk.yellow("⏸")} Disabled: ${disabled.length}`);
lines.push(`${chalk.yellow("🚫")} Blocked by allowlist: ${blocked.length}`);
lines.push(`${chalk.red("✗")} Missing requirements: ${missingReqs.length}`);
lines.push(`${theme.muted("Total:")} ${report.skills.length}`);
lines.push(`${theme.success("✓")} ${theme.muted("Eligible:")} ${eligible.length}`);
lines.push(`${theme.warn("⏸")} ${theme.muted("Disabled:")} ${disabled.length}`);
lines.push(`${theme.warn("🚫")} ${theme.muted("Blocked by allowlist:")} ${blocked.length}`);
lines.push(`${theme.error("✗")} ${theme.muted("Missing requirements:")} ${missingReqs.length}`);
if (eligible.length > 0) {
lines.push("");
lines.push(chalk.bold.green("Ready to use:"));
lines.push(theme.heading("Ready to use:"));
for (const skill of eligible) {
const emoji = skill.emoji ?? "📦";
lines.push(` ${emoji} ${skill.name}`);
@@ -300,7 +300,7 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp
if (missingReqs.length > 0) {
lines.push("");
lines.push(chalk.bold.red("Missing requirements:"));
lines.push(theme.heading("Missing requirements:"));
for (const skill of missingReqs) {
const emoji = skill.emoji ?? "📦";
const missing: string[] = [];
@@ -319,7 +319,7 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp
if (skill.missing.os.length > 0) {
missing.push(`os: ${skill.missing.os.join(", ")}`);
}
lines.push(` ${emoji} ${skill.name} ${chalk.gray(`(${missing.join("; ")})`)}`);
lines.push(` ${emoji} ${skill.name} ${theme.muted(`(${missing.join("; ")})`)}`);
}
}
@@ -350,7 +350,7 @@ export function registerSkillsCli(program: Command) {
const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const report = buildWorkspaceSkillStatus(workspaceDir, { config });
console.log(formatSkillsList(report, opts));
defaultRuntime.log(formatSkillsList(report, opts));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
@@ -367,7 +367,7 @@ export function registerSkillsCli(program: Command) {
const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const report = buildWorkspaceSkillStatus(workspaceDir, { config });
console.log(formatSkillInfo(report, name, opts));
defaultRuntime.log(formatSkillInfo(report, name, opts));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
@@ -383,7 +383,7 @@ export function registerSkillsCli(program: Command) {
const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const report = buildWorkspaceSkillStatus(workspaceDir, { config });
console.log(formatSkillsCheck(report, opts));
defaultRuntime.log(formatSkillsCheck(report, opts));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
@@ -396,7 +396,7 @@ export function registerSkillsCli(program: Command) {
const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const report = buildWorkspaceSkillStatus(workspaceDir, { config });
console.log(formatSkillsList(report, {}));
defaultRuntime.log(formatSkillsList(report, {}));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);

View File

@@ -31,6 +31,10 @@ vi.mock("../infra/update-check.js", async () => {
};
});
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: vi.fn(),
}));
// Mock doctor (heavy module; should not run in unit tests)
vi.mock("../commands/doctor.js", () => ({
doctorCommand: vi.fn(),
@@ -76,6 +80,7 @@ describe("update-cli", () => {
const { readConfigFileSnapshot } = await import("../config/config.js");
const { checkUpdateStatus, fetchNpmTagVersion, resolveNpmChannelTag } =
await import("../infra/update-check.js");
const { runCommandWithTimeout } = await import("../process/exec.js");
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(process.cwd());
vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot);
vi.mocked(fetchNpmTagVersion).mockResolvedValue({
@@ -111,6 +116,13 @@ describe("update-cli", () => {
latestVersion: "1.2.3",
},
});
vi.mocked(runCommandWithTimeout).mockResolvedValue({
stdout: "",
stderr: "",
code: 0,
signal: null,
killed: false,
});
setTty(false);
setStdoutTty(false);
});
@@ -202,9 +214,21 @@ describe("update-cli", () => {
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
const { runGatewayUpdate } = await import("../infra/update-runner.js");
const { checkUpdateStatus } = await import("../infra/update-check.js");
const { updateCommand } = await import("./update-cli.js");
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
vi.mocked(checkUpdateStatus).mockResolvedValue({
root: tempDir,
installKind: "package",
packageManager: "npm",
deps: {
manager: "npm",
status: "ok",
lockfilePath: null,
markerPath: null,
},
});
vi.mocked(runGatewayUpdate).mockResolvedValue({
status: "ok",
mode: "npm",
@@ -258,12 +282,24 @@ describe("update-cli", () => {
const { resolveNpmChannelTag } = await import("../infra/update-check.js");
const { runGatewayUpdate } = await import("../infra/update-runner.js");
const { updateCommand } = await import("./update-cli.js");
const { checkUpdateStatus } = await import("../infra/update-check.js");
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
...baseSnapshot,
config: { update: { channel: "beta" } },
});
vi.mocked(checkUpdateStatus).mockResolvedValue({
root: tempDir,
installKind: "package",
packageManager: "npm",
deps: {
manager: "npm",
status: "ok",
lockfilePath: null,
markerPath: null,
},
});
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "2026.1.20-1",
@@ -447,6 +483,7 @@ describe("update-cli", () => {
it("requires confirmation on downgrade when non-interactive", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-"));
try {
setTty(false);
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "clawdbot", version: "2.0.0" }),
@@ -458,8 +495,20 @@ describe("update-cli", () => {
const { runGatewayUpdate } = await import("../infra/update-runner.js");
const { defaultRuntime } = await import("../runtime.js");
const { updateCommand } = await import("./update-cli.js");
const { checkUpdateStatus } = await import("../infra/update-check.js");
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
vi.mocked(checkUpdateStatus).mockResolvedValue({
root: tempDir,
installKind: "package",
packageManager: "npm",
deps: {
manager: "npm",
status: "ok",
lockfilePath: null,
markerPath: null,
},
});
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "0.0.1",
@@ -483,4 +532,57 @@ describe("update-cli", () => {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("allows downgrade with --yes in non-interactive mode", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-"));
try {
setTty(false);
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({ name: "clawdbot", version: "2.0.0" }),
"utf-8",
);
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
const { resolveNpmChannelTag } = await import("../infra/update-check.js");
const { runGatewayUpdate } = await import("../infra/update-runner.js");
const { defaultRuntime } = await import("../runtime.js");
const { updateCommand } = await import("./update-cli.js");
const { checkUpdateStatus } = await import("../infra/update-check.js");
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
vi.mocked(checkUpdateStatus).mockResolvedValue({
root: tempDir,
installKind: "package",
packageManager: "npm",
deps: {
manager: "npm",
status: "ok",
lockfilePath: null,
markerPath: null,
},
});
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "0.0.1",
});
vi.mocked(runGatewayUpdate).mockResolvedValue({
status: "ok",
mode: "npm",
steps: [],
durationMs: 100,
});
vi.mocked(defaultRuntime.error).mockClear();
vi.mocked(defaultRuntime.exit).mockClear();
await updateCommand({ yes: true });
expect(defaultRuntime.error).not.toHaveBeenCalledWith(
expect.stringContaining("Downgrade confirmation required."),
);
expect(runGatewayUpdate).toHaveBeenCalled();
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
});

View File

@@ -1,5 +1,6 @@
import { confirm, isCancel, spinner } from "@clack/prompts";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { Command } from "commander";
@@ -16,8 +17,17 @@ import {
runGatewayUpdate,
type UpdateRunResult,
type UpdateStepInfo,
type UpdateStepResult,
type UpdateStepProgress,
type UpdateStepResult,
} from "../infra/update-runner.js";
import {
detectGlobalInstallManagerByPresence,
detectGlobalInstallManagerForRoot,
globalInstallArgs,
resolveGlobalPackageRoot,
type GlobalInstallManager,
} from "../infra/update-global.js";
import {
channelToNpmTag,
DEFAULT_GIT_CHANNEL,
@@ -26,18 +36,21 @@ import {
normalizeUpdateChannel,
resolveEffectiveUpdateChannel,
} from "../infra/update-channels.js";
import { trimLogTail } from "../infra/restart-sentinel.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { formatCliCommand } from "./command-format.js";
import { stylePromptMessage } from "../terminal/prompt-style.js";
import { theme } from "../terminal/theme.js";
import { renderTable } from "../terminal/table.js";
import { formatHelpExamples } from "./help-format.js";
import {
formatUpdateAvailableHint,
formatUpdateOneLiner,
resolveUpdateAvailability,
} from "../commands/status.update.js";
import { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } from "../plugins/update.js";
import { runCommandWithTimeout } from "../process/exec.js";
export type UpdateCommandOptions = {
json?: boolean;
@@ -45,6 +58,7 @@ export type UpdateCommandOptions = {
channel?: string;
tag?: string;
timeout?: string;
yes?: boolean;
};
export type UpdateStatusOptions = {
json?: boolean;
@@ -56,12 +70,14 @@ const STEP_LABELS: Record<string, string> = {
"upstream check": "Upstream branch exists",
"git fetch": "Fetching latest changes",
"git rebase": "Rebasing onto upstream",
"git clone": "Cloning git checkout",
"deps install": "Installing dependencies",
build: "Building",
"ui:build": "Building UI",
"clawdbot doctor": "Running doctor checks",
"git rev-parse HEAD (after)": "Verifying update",
"global update": "Updating via package manager",
"global install": "Installing global package",
};
const UPDATE_QUIPS = [
@@ -87,6 +103,10 @@ const UPDATE_QUIPS = [
"Version bump! Same chaos energy, fewer crashes (probably).",
];
const MAX_LOG_CHARS = 8000;
const CLAWDBOT_REPO_URL = "https://github.com/clawdbot/clawdbot.git";
const DEFAULT_GIT_DIR = path.join(os.homedir(), "clawdbot");
function normalizeTag(value?: string | null): string | null {
if (!value) return null;
const trimmed = value.trim();
@@ -131,6 +151,146 @@ async function isGitCheckout(root: string): Promise<boolean> {
}
}
async function isClawdbotPackage(root: string): Promise<boolean> {
try {
const raw = await fs.readFile(path.join(root, "package.json"), "utf-8");
const parsed = JSON.parse(raw) as { name?: string };
return parsed?.name === "clawdbot";
} catch {
return false;
}
}
async function pathExists(targetPath: string): Promise<boolean> {
try {
await fs.stat(targetPath);
return true;
} catch {
return false;
}
}
async function isEmptyDir(targetPath: string): Promise<boolean> {
try {
const entries = await fs.readdir(targetPath);
return entries.length === 0;
} catch {
return false;
}
}
function resolveGitInstallDir(): string {
const override = process.env.CLAWDBOT_GIT_DIR?.trim();
if (override) return path.resolve(override);
return DEFAULT_GIT_DIR;
}
function resolveNodeRunner(): string {
const base = path.basename(process.execPath).toLowerCase();
if (base === "node" || base === "node.exe") return process.execPath;
return "node";
}
async function runUpdateStep(params: {
name: string;
argv: string[];
cwd?: string;
timeoutMs: number;
progress?: UpdateStepProgress;
}): Promise<UpdateStepResult> {
const command = params.argv.join(" ");
params.progress?.onStepStart?.({
name: params.name,
command,
index: 0,
total: 0,
});
const started = Date.now();
const res = await runCommandWithTimeout(params.argv, {
cwd: params.cwd,
timeoutMs: params.timeoutMs,
});
const durationMs = Date.now() - started;
const stderrTail = trimLogTail(res.stderr, MAX_LOG_CHARS);
params.progress?.onStepComplete?.({
name: params.name,
command,
index: 0,
total: 0,
durationMs,
exitCode: res.code,
stderrTail,
});
return {
name: params.name,
command,
cwd: params.cwd ?? process.cwd(),
durationMs,
exitCode: res.code,
stdoutTail: trimLogTail(res.stdout, MAX_LOG_CHARS),
stderrTail,
};
}
async function ensureGitCheckout(params: {
dir: string;
timeoutMs: number;
progress?: UpdateStepProgress;
}): Promise<UpdateStepResult | null> {
const dirExists = await pathExists(params.dir);
if (!dirExists) {
return await runUpdateStep({
name: "git clone",
argv: ["git", "clone", CLAWDBOT_REPO_URL, params.dir],
timeoutMs: params.timeoutMs,
progress: params.progress,
});
}
if (!(await isGitCheckout(params.dir))) {
const empty = await isEmptyDir(params.dir);
if (!empty) {
throw new Error(
`CLAWDBOT_GIT_DIR points at a non-git directory: ${params.dir}. Set CLAWDBOT_GIT_DIR to an empty folder or a clawdbot checkout.`,
);
}
return await runUpdateStep({
name: "git clone",
argv: ["git", "clone", CLAWDBOT_REPO_URL, params.dir],
cwd: params.dir,
timeoutMs: params.timeoutMs,
progress: params.progress,
});
}
if (!(await isClawdbotPackage(params.dir))) {
throw new Error(`CLAWDBOT_GIT_DIR does not look like a clawdbot checkout: ${params.dir}.`);
}
return null;
}
async function resolveGlobalManager(params: {
root: string;
installKind: "git" | "package" | "unknown";
timeoutMs: number;
}): Promise<GlobalInstallManager> {
const runCommand = async (argv: string[], options: { timeoutMs: number }) => {
const res = await runCommandWithTimeout(argv, options);
return { stdout: res.stdout, stderr: res.stderr, code: res.code };
};
if (params.installKind === "package") {
const detected = await detectGlobalInstallManagerForRoot(
runCommand,
params.root,
params.timeoutMs,
);
if (detected) return detected;
}
const byPresence = await detectGlobalInstallManagerByPresence(runCommand, params.timeoutMs);
return byPresence ?? "npm";
}
function formatGitStatusLine(params: {
branch: string | null;
tag: string | null;
@@ -375,6 +535,8 @@ function printResult(result: UpdateRunResult, opts: PrintResultOptions) {
}
export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
process.noDeprecation = true;
process.env.NODE_NO_WARNINGS = "1";
const timeoutMs = opts.timeout ? Number.parseInt(opts.timeout, 10) * 1000 : undefined;
if (timeoutMs !== undefined && (Number.isNaN(timeoutMs) || timeoutMs <= 0)) {
@@ -390,6 +552,13 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
cwd: process.cwd(),
})) ?? process.cwd();
const updateStatus = await checkUpdateStatus({
root,
timeoutMs: timeoutMs ?? 3500,
fetchGit: false,
includeRegistry: false,
});
const configSnapshot = await readConfigFileSnapshot();
let activeConfig = configSnapshot.valid ? configSnapshot.config : null;
const storedChannel = configSnapshot.valid
@@ -409,13 +578,18 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
return;
}
const gitCheckout = await isGitCheckout(root);
const defaultChannel = gitCheckout ? DEFAULT_GIT_CHANNEL : DEFAULT_PACKAGE_CHANNEL;
const installKind = updateStatus.installKind;
const switchToGit = requestedChannel === "dev" && installKind !== "git";
const switchToPackage =
requestedChannel !== null && requestedChannel !== "dev" && installKind === "git";
const updateInstallKind = switchToGit ? "git" : switchToPackage ? "package" : installKind;
const defaultChannel =
updateInstallKind === "git" ? DEFAULT_GIT_CHANNEL : DEFAULT_PACKAGE_CHANNEL;
const channel = requestedChannel ?? storedChannel ?? defaultChannel;
const explicitTag = normalizeTag(opts.tag);
let tag = explicitTag ?? channelToNpmTag(channel);
if (!gitCheckout) {
const currentVersion = await readPackageVersion(root);
if (updateInstallKind !== "git") {
const currentVersion = switchToPackage ? null : await readPackageVersion(root);
const targetVersion = explicitTag
? await resolveTargetVersion(tag, timeoutMs)
: await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => {
@@ -427,7 +601,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
const needsConfirm =
currentVersion != null && (targetVersion == null || (cmp != null && cmp > 0));
if (needsConfirm) {
if (needsConfirm && !opts.yes) {
if (!process.stdin.isTTY || opts.json) {
defaultRuntime.error(
[
@@ -483,14 +657,114 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
const { progress, stop } = createUpdateProgress(showProgress);
const result = await runGatewayUpdate({
cwd: root,
argv1: process.argv[1],
timeoutMs,
progress,
channel,
tag,
});
const startedAt = Date.now();
let result: UpdateRunResult;
if (switchToPackage) {
const manager = await resolveGlobalManager({
root,
installKind,
timeoutMs: timeoutMs ?? 20 * 60_000,
});
const runCommand = async (argv: string[], options: { timeoutMs: number }) => {
const res = await runCommandWithTimeout(argv, options);
return { stdout: res.stdout, stderr: res.stderr, code: res.code };
};
const pkgRoot = await resolveGlobalPackageRoot(manager, runCommand, timeoutMs ?? 20 * 60_000);
const beforeVersion = pkgRoot ? await readPackageVersion(pkgRoot) : null;
const updateStep = await runUpdateStep({
name: "global update",
argv: globalInstallArgs(manager, `clawdbot@${tag}`),
timeoutMs: timeoutMs ?? 20 * 60_000,
progress,
});
const steps = [updateStep];
let afterVersion = beforeVersion;
if (pkgRoot) {
afterVersion = await readPackageVersion(pkgRoot);
const entryPath = path.join(pkgRoot, "dist", "entry.js");
if (await pathExists(entryPath)) {
const doctorStep = await runUpdateStep({
name: "clawdbot doctor",
argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive"],
timeoutMs: timeoutMs ?? 20 * 60_000,
progress,
});
steps.push(doctorStep);
}
}
const failedStep = steps.find((step) => step.exitCode !== 0);
result = {
status: failedStep ? "error" : "ok",
mode: manager,
root: pkgRoot ?? root,
reason: failedStep ? failedStep.name : undefined,
before: { version: beforeVersion },
after: { version: afterVersion },
steps,
durationMs: Date.now() - startedAt,
};
} else {
const updateRoot = switchToGit ? resolveGitInstallDir() : root;
const cloneStep = switchToGit
? await ensureGitCheckout({
dir: updateRoot,
timeoutMs: timeoutMs ?? 20 * 60_000,
progress,
})
: null;
if (cloneStep && cloneStep.exitCode !== 0) {
result = {
status: "error",
mode: "git",
root: updateRoot,
reason: cloneStep.name,
steps: [cloneStep],
durationMs: Date.now() - startedAt,
};
stop();
printResult(result, { ...opts, hideSteps: showProgress });
defaultRuntime.exit(1);
return;
}
const updateResult = await runGatewayUpdate({
cwd: updateRoot,
argv1: switchToGit ? undefined : process.argv[1],
timeoutMs,
progress,
channel,
tag,
});
const steps = [...(cloneStep ? [cloneStep] : []), ...updateResult.steps];
if (switchToGit && updateResult.status === "ok") {
const manager = await resolveGlobalManager({
root,
installKind,
timeoutMs: timeoutMs ?? 20 * 60_000,
});
const installStep = await runUpdateStep({
name: "global install",
argv: globalInstallArgs(manager, updateRoot),
cwd: updateRoot,
timeoutMs: timeoutMs ?? 20 * 60_000,
progress,
});
steps.push(installStep);
const failedStep = [installStep].find((step) => step.exitCode !== 0);
result = {
...updateResult,
status: updateResult.status === "ok" && !failedStep ? "ok" : "error",
steps,
durationMs: Date.now() - startedAt,
};
} else {
result = {
...updateResult,
steps,
durationMs: Date.now() - startedAt,
};
}
}
stop();
@@ -619,7 +893,8 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
process.env.CLAWDBOT_UPDATE_IN_PROGRESS = "1";
try {
const { doctorCommand } = await import("../commands/doctor.js");
await doctorCommand(defaultRuntime, { nonInteractive: true });
const interactiveDoctor = Boolean(process.stdin.isTTY) && !opts.json && opts.yes !== true;
await doctorCommand(defaultRuntime, { nonInteractive: !interactiveDoctor });
} catch (err) {
defaultRuntime.log(theme.warn(`Doctor failed: ${String(err)}`));
} finally {
@@ -667,27 +942,46 @@ export function registerUpdateCli(program: Command) {
.option("--channel <stable|beta|dev>", "Persist update channel (git + npm)")
.option("--tag <dist-tag|version>", "Override npm dist-tag or version for this update")
.option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)")
.addHelpText(
"after",
() =>
`
Examples:
clawdbot update # Update a source checkout (git)
clawdbot update --channel beta # Switch to beta channel (git + npm)
clawdbot update --channel dev # Switch to dev channel (git + npm)
clawdbot update --tag beta # One-off update to a dist-tag or version
clawdbot update --restart # Update and restart the daemon
clawdbot update --json # Output result as JSON
clawdbot --update # Shorthand for clawdbot update
.option("--yes", "Skip confirmation prompts (non-interactive)", false)
.addHelpText("after", () => {
const examples = [
["clawdbot update", "Update a source checkout (git)"],
["clawdbot update --channel beta", "Switch to beta channel (git + npm)"],
["clawdbot update --channel dev", "Switch to dev channel (git + npm)"],
["clawdbot update --tag beta", "One-off update to a dist-tag or version"],
["clawdbot update --restart", "Update and restart the daemon"],
["clawdbot update --json", "Output result as JSON"],
["clawdbot update --yes", "Non-interactive (accept downgrade prompts)"],
["clawdbot --update", "Shorthand for clawdbot update"],
] as const;
const fmtExamples = examples
.map(([cmd, desc]) => ` ${theme.command(cmd)} ${theme.muted(`# ${desc}`)}`)
.join("\n");
return `
${theme.heading("What this does:")}
- Git checkouts: fetches, rebases, installs deps, builds, and runs doctor
- npm installs: updates via detected package manager
Notes:
- For git installs: fetches, rebases, installs deps, builds, and runs doctor
${theme.heading("Switch channels:")}
- Use --channel stable|beta|dev to persist the update channel in config
- Run clawdbot update status to see the active channel and source
- Use --tag <dist-tag|version> for a one-off npm update without persisting
${theme.heading("Non-interactive:")}
- Use --yes to accept downgrade prompts
- Combine with --channel/--tag/--restart/--json/--timeout as needed
${theme.heading("Examples:")}
${fmtExamples}
${theme.heading("Notes:")}
- Switch channels with --channel stable|beta|dev
- For global installs: auto-updates via detected package manager when possible (see docs/install/updating.md)
- Downgrades require confirmation (can break configuration)
- Skips update if the working directory has uncommitted changes
${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`,
)
${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`;
})
.action(async (opts) => {
try {
await updateCommand({
@@ -696,6 +990,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/upda
channel: opts.channel as string | undefined,
tag: opts.tag as string | undefined,
timeout: opts.timeout as string | undefined,
yes: Boolean(opts.yes),
});
} catch (err) {
defaultRuntime.error(String(err));
@@ -711,17 +1006,15 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/upda
.addHelpText(
"after",
() =>
`
Examples:
clawdbot update status
clawdbot update status --json
clawdbot update status --timeout 10
Notes:
- Shows current update channel (stable/beta/dev) and source
- Includes git tag/branch/SHA for source checkouts
${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`,
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
["clawdbot update status", "Show channel + version status."],
["clawdbot update status --json", "JSON output."],
["clawdbot update status --timeout 10", "Custom timeout."],
])}\n\n${theme.heading("Notes:")}\n${theme.muted(
"- Shows current update channel (stable/beta/dev) and source",
)}\n${theme.muted("- Includes git tag/branch/SHA for source checkouts")}\n\n${theme.muted(
"Docs:",
)} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`,
)
.action(async (opts) => {
try {

View File

@@ -50,6 +50,8 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { formatCliCommand } from "../cli/command-format.js";
import { applyVerboseOverride } from "../sessions/level-overrides.js";
import { resolveSendPolicy } from "../sessions/send-policy.js";
import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js";
import { clearSessionAuthProfileOverride } from "../agents/auth-profiles/session-override.js";
import { resolveMessageChannel } from "../utils/message-channel.js";
import { deliverAgentCommandResult } from "./agent/delivery.js";
import { resolveAgentRunContext } from "./agent/run-context.js";
@@ -283,13 +285,16 @@ export async function agentCommand(
allowedModelKeys.size > 0 &&
!allowedModelKeys.has(key)
) {
delete entry.providerOverride;
delete entry.modelOverride;
entry.updatedAt = Date.now();
sessionStore[sessionKey] = entry;
await updateSessionStore(storePath, (store) => {
store[sessionKey] = entry;
const { updated } = applyModelOverrideToSessionEntry({
entry,
selection: { provider: defaultProvider, model: defaultModel, isDefault: true },
});
if (updated) {
sessionStore[sessionKey] = entry;
await updateSessionStore(storePath, (store) => {
store[sessionKey] = entry;
});
}
}
}
}
@@ -315,14 +320,12 @@ export async function agentCommand(
const store = ensureAuthProfileStore();
const profile = store.profiles[authProfileId];
if (!profile || profile.provider !== provider) {
delete entry.authProfileOverride;
delete entry.authProfileOverrideSource;
delete entry.authProfileOverrideCompactionCount;
entry.updatedAt = Date.now();
if (sessionStore && sessionKey) {
sessionStore[sessionKey] = entry;
await updateSessionStore(storePath, (store) => {
store[sessionKey] = entry;
await clearSessionAuthProfileOverride({
sessionEntry: entry,
sessionStore,
sessionKey,
storePath,
});
}
}

View File

@@ -121,10 +121,14 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
options: DoctorOptions;
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
}) {
void params.confirm;
const shouldRepair = params.options.repair === true || params.options.yes === true;
const snapshot = await readConfigFileSnapshot();
let cfg: ClawdbotConfig = snapshot.config ?? {};
const baseCfg = snapshot.config ?? {};
let cfg: ClawdbotConfig = baseCfg;
let candidate = structuredClone(baseCfg) as ClawdbotConfig;
let pendingChanges = false;
let shouldWriteConfig = false;
const fixHints: string[] = [];
if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) {
note("Config invalid; doctor will run with best-effort config.", "Config");
}
@@ -139,52 +143,76 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n"),
"Legacy config keys detected",
);
const { config: migrated, changes } = migrateLegacyConfig(snapshot.parsed);
if (changes.length > 0) {
note(changes.join("\n"), "Doctor changes");
}
if (migrated) {
candidate = migrated;
pendingChanges = pendingChanges || changes.length > 0;
}
if (shouldRepair) {
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into channels.whatsapp.allowFrom.
const { config: migrated, changes } = migrateLegacyConfig(snapshot.parsed);
if (changes.length > 0) note(changes.join("\n"), "Doctor changes");
if (migrated) cfg = migrated;
} else {
note(
fixHints.push(
`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply legacy migrations.`,
"Doctor",
);
}
}
const normalized = normalizeLegacyConfigValues(cfg);
const normalized = normalizeLegacyConfigValues(candidate);
if (normalized.changes.length > 0) {
note(normalized.changes.join("\n"), "Doctor changes");
candidate = normalized.config;
pendingChanges = true;
if (shouldRepair) {
cfg = normalized.config;
} else {
note(`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply these changes.`, "Doctor");
fixHints.push(`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply these changes.`);
}
}
const autoEnable = applyPluginAutoEnable({ config: cfg, env: process.env });
const autoEnable = applyPluginAutoEnable({ config: candidate, env: process.env });
if (autoEnable.changes.length > 0) {
note(autoEnable.changes.join("\n"), "Doctor changes");
candidate = autoEnable.config;
pendingChanges = true;
if (shouldRepair) {
cfg = autoEnable.config;
} else {
note(`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply these changes.`, "Doctor");
fixHints.push(`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply these changes.`);
}
}
const unknown = stripUnknownConfigKeys(cfg);
const unknown = stripUnknownConfigKeys(candidate);
if (unknown.removed.length > 0) {
const lines = unknown.removed.map((path) => `- ${path}`).join("\n");
candidate = unknown.config;
pendingChanges = true;
if (shouldRepair) {
cfg = unknown.config;
note(lines, "Doctor changes");
} else {
note(lines, "Unknown config keys");
note('Run "clawdbot doctor --fix" to remove these keys.', "Doctor");
fixHints.push('Run "clawdbot doctor --fix" to remove these keys.');
}
}
if (!shouldRepair && pendingChanges) {
const shouldApply = await params.confirm({
message: "Apply recommended config repairs now?",
initialValue: true,
});
if (shouldApply) {
cfg = candidate;
shouldWriteConfig = true;
} else if (fixHints.length > 0) {
note(fixHints.join("\n"), "Doctor");
}
}
noteOpencodeProviderOverrides(cfg);
return { cfg, path: snapshot.path ?? CONFIG_PATH_CLAWDBOT };
return { cfg, path: snapshot.path ?? CONFIG_PATH_CLAWDBOT, shouldWriteConfig };
}

View File

@@ -250,7 +250,8 @@ export async function doctorCommand(
healthOk,
});
if (prompter.shouldRepair) {
const shouldWriteConfig = prompter.shouldRepair || configResult.shouldWriteConfig;
if (shouldWriteConfig) {
cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) });
await writeConfigFile(cfg);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);

View File

@@ -5,6 +5,8 @@ export const GATEWAY_CLIENT_IDS = {
CLI: "cli",
GATEWAY_CLIENT: "gateway-client",
MACOS_APP: "clawdbot-macos",
IOS_APP: "clawdbot-ios",
ANDROID_APP: "clawdbot-android",
NODE_HOST: "node-host",
TEST: "test",
FINGERPRINT: "fingerprint",

View File

@@ -38,6 +38,24 @@ function isNodeEntry(entry: { role?: string; roles?: string[] }) {
return false;
}
function normalizeNodeInvokeResultParams(params: unknown): unknown {
if (!params || typeof params !== "object") return params;
const raw = params as Record<string, unknown>;
const normalized: Record<string, unknown> = { ...raw };
if (normalized.payloadJSON === null) {
delete normalized.payloadJSON;
} else if (normalized.payloadJSON !== undefined && typeof normalized.payloadJSON !== "string") {
if (normalized.payload === undefined) {
normalized.payload = normalized.payloadJSON;
}
delete normalized.payloadJSON;
}
if (normalized.error === null) {
delete normalized.error;
}
return normalized;
}
export const nodeHandlers: GatewayRequestHandlers = {
"node.pair.request": async ({ params, respond, context }) => {
if (!validateNodePairRequestParams(params)) {
@@ -417,7 +435,8 @@ export const nodeHandlers: GatewayRequestHandlers = {
});
},
"node.invoke.result": async ({ params, respond, context, client }) => {
if (!validateNodeInvokeResultParams(params)) {
const normalizedParams = normalizeNodeInvokeResultParams(params);
if (!validateNodeInvokeResultParams(normalizedParams)) {
respondInvalidParams({
respond,
method: "node.invoke.result",
@@ -425,7 +444,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
});
return;
}
const p = params as {
const p = normalizedParams as {
id: string;
nodeId: string;
ok: boolean;

View File

@@ -0,0 +1,87 @@
import { test } from "vitest";
import WebSocket from "ws";
import { PROTOCOL_VERSION } from "./protocol/index.js";
import { getFreePort, onceMessage, startGatewayServer } from "./test-helpers.server.js";
function connectReq(
ws: WebSocket,
params: { clientId: string; platform: string; token?: string; password?: string },
): Promise<{ ok: boolean; error?: { message?: string } }> {
const id = `c-${Math.random().toString(16).slice(2)}`;
ws.send(
JSON.stringify({
type: "req",
id,
method: "connect",
params: {
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: params.clientId,
version: "dev",
platform: params.platform,
mode: "node",
},
auth: {
token: params.token,
password: params.password,
},
role: "node",
scopes: [],
caps: ["canvas"],
commands: ["system.notify"],
permissions: {},
},
}),
);
return onceMessage(
ws,
(o) => (o as { type?: string }).type === "res" && (o as { id?: string }).id === id,
);
}
test("accepts clawdbot-ios as a valid gateway client id", async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
const res = await connectReq(ws, { clientId: "clawdbot-ios", platform: "ios" });
// We don't care if auth fails here; we only care that schema validation accepts the client id.
// A schema rejection would close the socket before sending a response.
if (!res.ok) {
// allow unauthorized error when gateway requires auth
// but reject schema validation errors
const message = String(res.error?.message ?? "");
if (message.includes("invalid connect params")) {
throw new Error(message);
}
}
ws.close();
await server.close();
});
test("accepts clawdbot-android as a valid gateway client id", async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
const res = await connectReq(ws, { clientId: "clawdbot-android", platform: "android" });
// We don't care if auth fails here; we only care that schema validation accepts the client id.
// A schema rejection would close the socket before sending a response.
if (!res.ok) {
// allow unauthorized error when gateway requires auth
// but reject schema validation errors
const message = String(res.error?.message ?? "");
if (message.includes("invalid connect params")) {
throw new Error(message);
}
}
ws.close();
await server.close();
});

View File

@@ -172,4 +172,55 @@ describe("gateway node command allowlist", () => {
ws.close();
await server.close();
});
test("accepts node invoke result with null payloadJSON", async () => {
const { server, ws, port } = await startServerWithClient();
await connectOk(ws);
let resolveInvoke: ((payload: { id?: string; nodeId?: string }) => void) | null = null;
const invokeReqP = new Promise<{ id?: string; nodeId?: string }>((resolve) => {
resolveInvoke = resolve;
});
const nodeClient = await connectNodeClient({
port,
commands: ["canvas.snapshot"],
instanceId: "node-null-payloadjson",
displayName: "node-null-payloadjson",
onEvent: (evt) => {
if (evt.event === "node.invoke.request") {
const payload = evt.payload as { id?: string; nodeId?: string };
resolveInvoke?.(payload);
}
},
});
const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {});
const nodeId = listRes.payload?.nodes?.[0]?.nodeId ?? "";
expect(nodeId).toBeTruthy();
const invokeResP = rpcReq(ws, "node.invoke", {
nodeId,
command: "canvas.snapshot",
params: { format: "png" },
idempotencyKey: "allowlist-null-payloadjson",
});
const payload = await invokeReqP;
const requestId = payload?.id ?? "";
const nodeIdFromReq = payload?.nodeId ?? "node-null-payloadjson";
await nodeClient.request("node.invoke.result", {
id: requestId,
nodeId: nodeIdFromReq,
ok: true,
payloadJSON: null,
});
const invokeRes = await invokeResP;
expect(invokeRes.ok).toBe(true);
nodeClient.stop();
ws.close();
await server.close();
});
});

View File

@@ -719,13 +719,17 @@ export function attachGatewayWsMessageHandler(params: {
if (role === "node") {
const context = buildRequestContext();
const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: remoteAddr });
void updatePairedNodeMetadata(nodeSession.nodeId, {
lastConnectedAtMs: nodeSession.connectedAtMs,
}).catch((err) =>
logGateway.warn(
`failed to record last connect for ${nodeSession.nodeId}: ${formatForLog(err)}`,
),
);
const instanceIdRaw = connectParams.client.instanceId;
const instanceId = typeof instanceIdRaw === "string" ? instanceIdRaw.trim() : "";
const nodeIdsForPairing = new Set<string>([nodeSession.nodeId]);
if (instanceId) nodeIdsForPairing.add(instanceId);
for (const nodeId of nodeIdsForPairing) {
void updatePairedNodeMetadata(nodeId, {
lastConnectedAtMs: nodeSession.connectedAtMs,
}).catch((err) =>
logGateway.warn(`failed to record last connect for ${nodeId}: ${formatForLog(err)}`),
);
}
recordRemoteNodeInfo({
nodeId: nodeSession.nodeId,
displayName: nodeSession.displayName,

View File

@@ -57,4 +57,32 @@ describe("gateway sessions patch", () => {
if (res.ok) return;
expect(res.error.message).toContain("invalid elevatedLevel");
});
test("clears auth overrides when model patch changes", async () => {
const store: Record<string, SessionEntry> = {
"agent:main:main": {
sessionId: "sess",
updatedAt: 1,
providerOverride: "anthropic",
modelOverride: "claude-opus-4-5",
authProfileOverride: "anthropic:default",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: 3,
} as SessionEntry,
};
const res = await applySessionsPatchToStore({
cfg: {} as ClawdbotConfig,
store,
storeKey: "agent:main:main",
patch: { model: "openai/gpt-5.2" },
loadGatewayModelCatalog: async () => [{ provider: "openai", id: "gpt-5.2" }],
});
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.entry.providerOverride).toBe("openai");
expect(res.entry.modelOverride).toBe("gpt-5.2");
expect(res.entry.authProfileOverride).toBeUndefined();
expect(res.entry.authProfileOverrideSource).toBeUndefined();
expect(res.entry.authProfileOverrideCompactionCount).toBeUndefined();
});
});

View File

@@ -19,6 +19,7 @@ import { isSubagentSessionKey } from "../routing/session-key.js";
import { applyVerboseOverride, parseVerboseOverride } from "../sessions/level-overrides.js";
import { normalizeSendPolicy } from "../sessions/send-policy.js";
import { parseSessionLabel } from "../sessions/session-label.js";
import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js";
import {
ErrorCodes,
type ErrorShape,
@@ -220,18 +221,23 @@ export async function applySessionsPatchToStore(params: {
if ("model" in patch) {
const raw = patch.model;
const resolvedDefault = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
if (raw === null) {
delete next.providerOverride;
delete next.modelOverride;
applyModelOverrideToSessionEntry({
entry: next,
selection: {
provider: resolvedDefault.provider,
model: resolvedDefault.model,
isDefault: true,
},
});
} else if (raw !== undefined) {
const trimmed = String(raw).trim();
if (!trimmed) return invalid("invalid model: empty");
const resolvedDefault = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
if (!params.loadGatewayModelCatalog) {
return {
ok: false,
@@ -249,16 +255,17 @@ export async function applySessionsPatchToStore(params: {
if ("error" in resolved) {
return invalid(resolved.error);
}
if (
const isDefault =
resolved.ref.provider === resolvedDefault.provider &&
resolved.ref.model === resolvedDefault.model
) {
delete next.providerOverride;
delete next.modelOverride;
} else {
next.providerOverride = resolved.ref.provider;
next.modelOverride = resolved.ref.model;
}
resolved.ref.model === resolvedDefault.model;
applyModelOverrideToSessionEntry({
entry: next,
selection: {
provider: resolved.ref.provider,
model: resolved.ref.model,
isDefault,
},
});
}
}

109
src/infra/update-global.ts Normal file
View File

@@ -0,0 +1,109 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
export type GlobalInstallManager = "npm" | "pnpm" | "bun";
export type CommandRunner = (
argv: string[],
options: { timeoutMs: number; cwd?: string; env?: NodeJS.ProcessEnv },
) => Promise<{ stdout: string; stderr: string; code: number | null }>;
async function pathExists(targetPath: string): Promise<boolean> {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
}
async function tryRealpath(targetPath: string): Promise<string> {
try {
return await fs.realpath(targetPath);
} catch {
return path.resolve(targetPath);
}
}
function resolveBunGlobalRoot(): string {
const bunInstall = process.env.BUN_INSTALL?.trim() || path.join(os.homedir(), ".bun");
return path.join(bunInstall, "install", "global", "node_modules");
}
export async function resolveGlobalRoot(
manager: GlobalInstallManager,
runCommand: CommandRunner,
timeoutMs: number,
): Promise<string | null> {
if (manager === "bun") return resolveBunGlobalRoot();
const argv = manager === "pnpm" ? ["pnpm", "root", "-g"] : ["npm", "root", "-g"];
const res = await runCommand(argv, { timeoutMs }).catch(() => null);
if (!res || res.code !== 0) return null;
const root = res.stdout.trim();
return root || null;
}
export async function resolveGlobalPackageRoot(
manager: GlobalInstallManager,
runCommand: CommandRunner,
timeoutMs: number,
): Promise<string | null> {
const root = await resolveGlobalRoot(manager, runCommand, timeoutMs);
if (!root) return null;
return path.join(root, "clawdbot");
}
export async function detectGlobalInstallManagerForRoot(
runCommand: CommandRunner,
pkgRoot: string,
timeoutMs: number,
): Promise<GlobalInstallManager | null> {
const pkgReal = await tryRealpath(pkgRoot);
const candidates: Array<{
manager: "npm" | "pnpm";
argv: string[];
}> = [
{ manager: "npm", argv: ["npm", "root", "-g"] },
{ manager: "pnpm", argv: ["pnpm", "root", "-g"] },
];
for (const { manager, argv } of candidates) {
const res = await runCommand(argv, { timeoutMs }).catch(() => null);
if (!res || res.code !== 0) continue;
const globalRoot = res.stdout.trim();
if (!globalRoot) continue;
const globalReal = await tryRealpath(globalRoot);
const expected = path.join(globalReal, "clawdbot");
if (path.resolve(expected) === path.resolve(pkgReal)) return manager;
}
const bunGlobalRoot = resolveBunGlobalRoot();
const bunGlobalReal = await tryRealpath(bunGlobalRoot);
const bunExpected = path.join(bunGlobalReal, "clawdbot");
if (path.resolve(bunExpected) === path.resolve(pkgReal)) return "bun";
return null;
}
export async function detectGlobalInstallManagerByPresence(
runCommand: CommandRunner,
timeoutMs: number,
): Promise<GlobalInstallManager | null> {
for (const manager of ["npm", "pnpm"] as const) {
const root = await resolveGlobalRoot(manager, runCommand, timeoutMs);
if (!root) continue;
if (await pathExists(path.join(root, "clawdbot"))) return manager;
}
const bunRoot = resolveBunGlobalRoot();
if (await pathExists(path.join(bunRoot, "clawdbot"))) return "bun";
return null;
}
export function globalInstallArgs(manager: GlobalInstallManager, spec: string): string[] {
if (manager === "pnpm") return ["pnpm", "add", "-g", spec];
if (manager === "bun") return ["bun", "add", "-g", spec];
return ["npm", "i", "-g", spec];
}

View File

@@ -1,10 +1,10 @@
import os from "node:os";
import fs from "node:fs/promises";
import path from "node:path";
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
import { compareSemverStrings } from "./update-check.js";
import { DEV_BRANCH, isBetaTag, isStableTag, type UpdateChannel } from "./update-channels.js";
import { detectGlobalInstallManagerForRoot, globalInstallArgs } from "./update-global.js";
import { trimLogTail } from "./restart-sentinel.js";
export type UpdateStepResult = {
@@ -210,52 +210,6 @@ async function detectPackageManager(root: string) {
return "npm";
}
async function tryRealpath(value: string): Promise<string> {
try {
return await fs.realpath(value);
} catch {
return path.resolve(value);
}
}
async function detectGlobalInstallManager(
runCommand: CommandRunner,
pkgRoot: string,
timeoutMs: number,
): Promise<"npm" | "pnpm" | "bun" | null> {
const pkgReal = await tryRealpath(pkgRoot);
const candidates: Array<{
manager: "npm" | "pnpm";
argv: string[];
}> = [
{ manager: "npm", argv: ["npm", "root", "-g"] },
{ manager: "pnpm", argv: ["pnpm", "root", "-g"] },
];
for (const { manager, argv } of candidates) {
const res = await runCommand(argv, { timeoutMs }).catch(() => null);
if (!res) continue;
if (res.code !== 0) continue;
const globalRoot = res.stdout.trim();
if (!globalRoot) continue;
const globalReal = await tryRealpath(globalRoot);
const expected = path.join(globalReal, "clawdbot");
if (path.resolve(expected) === path.resolve(pkgReal)) return manager;
}
// Bun doesn't have an officially stable "global root" command across versions,
// so we check the common global install path (best-effort).
const bunInstall = process.env.BUN_INSTALL?.trim() || path.join(os.homedir(), ".bun");
const bunGlobalRoot = path.join(bunInstall, "install", "global", "node_modules");
const bunGlobalReal = await tryRealpath(bunGlobalRoot);
const bunExpected = path.join(bunGlobalReal, "clawdbot");
if (path.resolve(bunExpected) === path.resolve(pkgReal)) return "bun";
return null;
}
type RunStepOptions = {
runCommand: CommandRunner;
name: string;
@@ -324,13 +278,6 @@ function normalizeTag(tag?: string) {
return trimmed.startsWith("clawdbot@") ? trimmed.slice("clawdbot@".length) : trimmed;
}
function globalUpdateArgs(manager: "pnpm" | "npm" | "bun", tag?: string) {
const spec = `clawdbot@${normalizeTag(tag)}`;
if (manager === "pnpm") return ["pnpm", "add", "-g", spec];
if (manager === "bun") return ["bun", "add", "-g", spec];
return ["npm", "i", "-g", spec];
}
export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<UpdateRunResult> {
const startedAt = Date.now();
const runCommand =
@@ -604,12 +551,13 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
}
const beforeVersion = await readPackageVersion(pkgRoot);
const globalManager = await detectGlobalInstallManager(runCommand, pkgRoot, timeoutMs);
const globalManager = await detectGlobalInstallManagerForRoot(runCommand, pkgRoot, timeoutMs);
if (globalManager) {
const spec = `clawdbot@${normalizeTag(opts.tag)}`;
const updateStep = await runStep({
runCommand,
name: "global update",
argv: globalUpdateArgs(globalManager, opts.tag),
argv: globalInstallArgs(globalManager, spec),
cwd: pkgRoot,
timeoutMs,
progress,

View File

@@ -0,0 +1,34 @@
import { describe, expect, test } from "vitest";
import { buildNodeInvokeResultParams } from "./runner.js";
describe("buildNodeInvokeResultParams", () => {
test("omits optional fields when null/undefined", () => {
const params = buildNodeInvokeResultParams(
{ id: "invoke-1", nodeId: "node-1", command: "system.run" },
{ ok: true, payloadJSON: null, error: null },
);
expect(params).toEqual({ id: "invoke-1", nodeId: "node-1", ok: true });
expect("payloadJSON" in params).toBe(false);
expect("error" in params).toBe(false);
});
test("includes payloadJSON when provided", () => {
const params = buildNodeInvokeResultParams(
{ id: "invoke-2", nodeId: "node-2", command: "system.run" },
{ ok: true, payloadJSON: '{"ok":true}' },
);
expect(params.payloadJSON).toBe('{"ok":true}');
});
test("includes payload when provided", () => {
const params = buildNodeInvokeResultParams(
{ id: "invoke-3", nodeId: "node-3", command: "system.run" },
{ ok: false, payload: { reason: "bad" } },
);
expect(params.payload).toEqual({ reason: "bad" });
});
});

View File

@@ -104,7 +104,8 @@ const OUTPUT_CAP = 200_000;
const OUTPUT_EVENT_TAIL = 20_000;
const execHostEnforced = process.env.CLAWDBOT_NODE_EXEC_HOST?.trim().toLowerCase() === "app";
const execHostFallbackAllowed = process.env.CLAWDBOT_NODE_EXEC_FALLBACK?.trim() === "1";
const execHostFallbackAllowed =
process.env.CLAWDBOT_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0";
const blockedEnvKeys = new Set([
"PATH",
@@ -559,8 +560,7 @@ async function handleInvoke(
const skillAllow =
autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false;
const useMacAppExec =
process.platform === "darwin" && (execHostEnforced || !execHostFallbackAllowed);
const useMacAppExec = process.platform === "darwin";
if (useMacAppExec) {
const approvalDecision =
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"
@@ -579,28 +579,28 @@ async function handleInvoke(
};
const response = await runViaMacAppExecHost({ approvals, request: execRequest });
if (!response) {
await sendNodeEvent(
client,
"exec.denied",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason: "companion-unavailable",
}),
);
await sendInvokeResult(client, frame, {
ok: false,
error: {
code: "UNAVAILABLE",
message: "COMPANION_APP_UNAVAILABLE: macOS app exec host unreachable",
},
});
return;
}
if (!response.ok) {
if (execHostEnforced || !execHostFallbackAllowed) {
await sendNodeEvent(
client,
"exec.denied",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason: "companion-unavailable",
}),
);
await sendInvokeResult(client, frame, {
ok: false,
error: {
code: "UNAVAILABLE",
message: "COMPANION_APP_UNAVAILABLE: macOS app exec host unreachable",
},
});
return;
}
} else if (!response.ok) {
const reason = response.error.reason ?? "approval-required";
await sendNodeEvent(
client,
@@ -618,29 +618,29 @@ async function handleInvoke(
error: { code: "UNAVAILABLE", message: response.error.message },
});
return;
} else {
const result: ExecHostRunResult = response.payload;
const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n");
await sendNodeEvent(
client,
"exec.finished",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
output: combined,
}),
);
await sendInvokeResult(client, frame, {
ok: true,
payloadJSON: JSON.stringify(result),
});
return;
}
const result: ExecHostRunResult = response.payload;
const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n");
await sendNodeEvent(
client,
"exec.finished",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
output: combined,
}),
);
await sendInvokeResult(client, frame, {
ok: true,
payloadJSON: JSON.stringify(result),
});
return;
}
if (security === "deny") {
@@ -833,19 +833,52 @@ async function sendInvokeResult(
},
) {
try {
await client.request("node.invoke.result", {
id: frame.id,
nodeId: frame.nodeId,
ok: result.ok,
payload: result.payload,
payloadJSON: result.payloadJSON ?? null,
error: result.error ?? null,
});
await client.request("node.invoke.result", buildNodeInvokeResultParams(frame, result));
} catch {
// ignore: node invoke responses are best-effort
}
}
export function buildNodeInvokeResultParams(
frame: NodeInvokeRequestPayload,
result: {
ok: boolean;
payload?: unknown;
payloadJSON?: string | null;
error?: { code?: string; message?: string } | null;
},
): {
id: string;
nodeId: string;
ok: boolean;
payload?: unknown;
payloadJSON?: string;
error?: { code?: string; message?: string };
} {
const params: {
id: string;
nodeId: string;
ok: boolean;
payload?: unknown;
payloadJSON?: string;
error?: { code?: string; message?: string };
} = {
id: frame.id,
nodeId: frame.nodeId,
ok: result.ok,
};
if (result.payload !== undefined) {
params.payload = result.payload;
}
if (typeof result.payloadJSON === "string") {
params.payloadJSON = result.payloadJSON;
}
if (result.error) {
params.error = result.error;
}
return params;
}
async function sendNodeEvent(client: GatewayClient, event: string, payload: unknown) {
try {
await client.request("node.event", {

View File

@@ -245,6 +245,37 @@ export async function addChannelAllowFromStoreEntry(params: {
);
}
export async function removeChannelAllowFromStoreEntry(params: {
channel: PairingChannel;
entry: string | number;
env?: NodeJS.ProcessEnv;
}): Promise<{ changed: boolean; allowFrom: string[] }> {
const env = params.env ?? process.env;
const filePath = resolveAllowFromPath(params.channel, env);
return await withFileLock(
filePath,
{ version: 1, allowFrom: [] } satisfies AllowFromStore,
async () => {
const { value } = await readJsonFile<AllowFromStore>(filePath, {
version: 1,
allowFrom: [],
});
const current = (Array.isArray(value.allowFrom) ? value.allowFrom : [])
.map((v) => normalizeAllowEntry(params.channel, String(v)))
.filter(Boolean);
const normalized = normalizeAllowEntry(params.channel, normalizeId(params.entry));
if (!normalized) return { changed: false, allowFrom: current };
const next = current.filter((entry) => entry !== normalized);
if (next.length === current.length) return { changed: false, allowFrom: current };
await writeJsonFile(filePath, {
version: 1,
allowFrom: next,
} satisfies AllowFromStore);
return { changed: true, allowFrom: next };
},
);
}
export async function listChannelPairingRequests(
channel: PairingChannel,
env: NodeJS.ProcessEnv = process.env,

View File

@@ -1,4 +1,5 @@
import { execFile, spawn } from "node:child_process";
import path from "node:path";
import { promisify } from "node:util";
import { danger, shouldLogVerbose } from "../globals.js";
@@ -61,12 +62,28 @@ export async function runCommandWithTimeout(
const { windowsVerbatimArguments } = options;
const hasInput = input !== undefined;
const shouldSuppressNpmFund = (() => {
const cmd = path.basename(argv[0] ?? "");
if (cmd === "npm" || cmd === "npm.cmd" || cmd === "npm.exe") return true;
if (cmd === "node" || cmd === "node.exe") {
const script = argv[1] ?? "";
return script.includes("npm-cli.js");
}
return false;
})();
const resolvedEnv = env ? { ...process.env, ...env } : { ...process.env };
if (shouldSuppressNpmFund) {
if (resolvedEnv.NPM_CONFIG_FUND == null) resolvedEnv.NPM_CONFIG_FUND = "false";
if (resolvedEnv.npm_config_fund == null) resolvedEnv.npm_config_fund = "false";
}
// Spawn with inherited stdin (TTY) so tools like `pi` stay interactive when needed.
return await new Promise((resolve, reject) => {
const child = spawn(argv[0], argv.slice(1), {
stdio: [hasInput ? "pipe" : "inherit", "pipe", "pipe"],
cwd,
env: env ? { ...process.env, ...env } : process.env,
env: resolvedEnv,
windowsVerbatimArguments,
});
let stdout = "";

View File

@@ -0,0 +1,72 @@
import type { SessionEntry } from "../config/sessions.js";
export type ModelOverrideSelection = {
provider: string;
model: string;
isDefault?: boolean;
};
export function applyModelOverrideToSessionEntry(params: {
entry: SessionEntry;
selection: ModelOverrideSelection;
profileOverride?: string;
profileOverrideSource?: "auto" | "user";
}): { updated: boolean } {
const { entry, selection, profileOverride } = params;
const profileOverrideSource = params.profileOverrideSource ?? "user";
let updated = false;
if (selection.isDefault) {
if (entry.providerOverride) {
delete entry.providerOverride;
updated = true;
}
if (entry.modelOverride) {
delete entry.modelOverride;
updated = true;
}
} else {
if (entry.providerOverride !== selection.provider) {
entry.providerOverride = selection.provider;
updated = true;
}
if (entry.modelOverride !== selection.model) {
entry.modelOverride = selection.model;
updated = true;
}
}
if (profileOverride) {
if (entry.authProfileOverride !== profileOverride) {
entry.authProfileOverride = profileOverride;
updated = true;
}
if (entry.authProfileOverrideSource !== profileOverrideSource) {
entry.authProfileOverrideSource = profileOverrideSource;
updated = true;
}
if (entry.authProfileOverrideCompactionCount !== undefined) {
delete entry.authProfileOverrideCompactionCount;
updated = true;
}
} else {
if (entry.authProfileOverride) {
delete entry.authProfileOverride;
updated = true;
}
if (entry.authProfileOverrideSource) {
delete entry.authProfileOverrideSource;
updated = true;
}
if (entry.authProfileOverrideCompactionCount !== undefined) {
delete entry.authProfileOverrideCompactionCount;
updated = true;
}
}
if (updated) {
entry.updatedAt = Date.now();
}
return { updated };
}

View File

@@ -476,10 +476,11 @@ export async function prepareSlackMessage(params: {
Surface: "slack" as const,
MessageSid: message.ts,
ReplyToId: message.thread_ts ?? message.ts,
// Preserve thread context for routed tool notifications (thread replies only).
MessageThreadId: isThreadReply ? threadTs : undefined,
ParentSessionKey: threadKeys.parentSessionKey,
ThreadStarterBody: threadStarterBody,
ThreadLabel: threadLabel,
MessageThreadId: isThreadReply ? threadTs : undefined,
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
WasMentioned: isRoomish ? effectiveWasMentioned : undefined,
MediaPath: media?.path,

View File

@@ -1,6 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import SlackBoltDefault, * as SlackBoltModule from "@slack/bolt";
import SlackBolt from "@slack/bolt";
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js";
@@ -26,24 +26,14 @@ import { normalizeAllowList } from "./allow-list.js";
import type { MonitorSlackOpts } from "./types.js";
type SlackBoltNamespace = typeof import("@slack/bolt");
type SlackBoltDefault = SlackBoltNamespace | SlackBoltNamespace["App"];
const slackBoltDefaultImport = SlackBoltDefault as SlackBoltDefault | undefined;
const slackBoltModuleDefault = (SlackBoltModule as { default?: SlackBoltDefault }).default;
const slackBoltDefault = slackBoltDefaultImport ?? slackBoltModuleDefault;
const slackBoltNamespace =
typeof slackBoltDefault === "object" && slackBoltDefault
? (slackBoltDefault as SlackBoltNamespace)
: typeof slackBoltModuleDefault === "object" && slackBoltModuleDefault
? (slackBoltModuleDefault as SlackBoltNamespace)
: undefined;
// Bun allows named imports from CJS; Node ESM doesn't. Resolve default/module shapes for compatibility.
const App = ((typeof slackBoltDefault === "function" ? slackBoltDefault : undefined) ??
slackBoltNamespace?.App ??
SlackBoltModule.App) as SlackBoltNamespace["App"];
const HTTPReceiver = (slackBoltNamespace?.HTTPReceiver ??
SlackBoltModule.HTTPReceiver) as SlackBoltNamespace["HTTPReceiver"];
const slackBoltModule = SlackBolt as typeof import("@slack/bolt") & {
default?: typeof import("@slack/bolt");
};
// Bun allows named imports from CJS; Node ESM doesn't. Use default+fallback for compatibility.
// Fix: Check if module has App property directly (Node 25.x ESM/CJS compat issue)
const slackBolt =
(slackBoltModule.App ? slackBoltModule : slackBoltModule.default) ?? slackBoltModule;
const { App, HTTPReceiver } = slackBolt;
function parseApiAppIdFromAppToken(raw?: string) {
const token = raw?.trim();
if (!token) return undefined;
@@ -133,13 +123,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
if (!App) {
throw new Error("Slack Bolt App export missing; check @slack/bolt installation.");
}
if (slackMode === "http" && !HTTPReceiver) {
throw new Error("Slack Bolt HTTPReceiver export missing; check @slack/bolt installation.");
}
const receiver =
slackMode === "http"
? new HTTPReceiver({

View File

@@ -179,6 +179,8 @@ export const registerTelegramHandlers = ({
const callback = ctx.callbackQuery;
if (!callback) return;
if (shouldSkipUpdate(ctx)) return;
// Answer immediately to prevent Telegram from retrying while we process
await bot.api.answerCallbackQuery(callback.id).catch(() => {});
try {
const data = (callback.data ?? "").trim();
const callbackMessage = callback.message;
@@ -323,8 +325,6 @@ export const registerTelegramHandlers = ({
});
} catch (err) {
runtime.error?.(danger(`callback handler failed: ${String(err)}`));
} finally {
await bot.api.answerCallbackQuery(callback.id).catch(() => {});
}
});

View File

@@ -0,0 +1,99 @@
import { describe, expect, it, vi } from "vitest";
import { createEditorSubmitHandler } from "./tui.js";
describe("createEditorSubmitHandler", () => {
it("adds submitted messages to editor history", () => {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
};
const handler = createEditorSubmitHandler({
editor,
handleCommand: vi.fn(),
sendMessage: vi.fn(),
});
handler("hello world");
expect(editor.setText).toHaveBeenCalledWith("");
expect(editor.addToHistory).toHaveBeenCalledWith("hello world");
});
it("trims input before adding to history", () => {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
};
const handler = createEditorSubmitHandler({
editor,
handleCommand: vi.fn(),
sendMessage: vi.fn(),
});
handler(" hi ");
expect(editor.addToHistory).toHaveBeenCalledWith("hi");
});
it("does not add empty submissions to history", () => {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
};
const handler = createEditorSubmitHandler({
editor,
handleCommand: vi.fn(),
sendMessage: vi.fn(),
});
handler(" ");
expect(editor.addToHistory).not.toHaveBeenCalled();
});
it("routes slash commands to handleCommand", () => {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
};
const handleCommand = vi.fn();
const sendMessage = vi.fn();
const handler = createEditorSubmitHandler({
editor,
handleCommand,
sendMessage,
});
handler("/models");
expect(editor.addToHistory).toHaveBeenCalledWith("/models");
expect(handleCommand).toHaveBeenCalledWith("/models");
expect(sendMessage).not.toHaveBeenCalled();
});
it("routes normal messages to sendMessage", () => {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
};
const handleCommand = vi.fn();
const sendMessage = vi.fn();
const handler = createEditorSubmitHandler({
editor,
handleCommand,
sendMessage,
});
handler("hello");
expect(editor.addToHistory).toHaveBeenCalledWith("hello");
expect(sendMessage).toHaveBeenCalledWith("hello");
expect(handleCommand).not.toHaveBeenCalled();
});
});

View File

@@ -36,6 +36,30 @@ import type {
export { resolveFinalAssistantText } from "./tui-formatters.js";
export type { TuiOptions } from "./tui-types.js";
export function createEditorSubmitHandler(params: {
editor: {
setText: (value: string) => void;
addToHistory: (value: string) => void;
};
handleCommand: (value: string) => Promise<void> | void;
sendMessage: (value: string) => Promise<void> | void;
}) {
return (text: string) => {
const value = text.trim();
params.editor.setText("");
if (!value) return;
// Enable built-in editor prompt history navigation (up/down).
params.editor.addToHistory(value);
if (value.startsWith("/")) {
void params.handleCommand(value);
return;
}
void params.sendMessage(value);
};
}
export async function runTui(opts: TuiOptions) {
const config = loadConfig();
const initialSessionInput = (opts.session ?? "").trim();
@@ -473,16 +497,11 @@ export async function runTui(opts: TuiOptions) {
});
updateAutocompleteProvider();
editor.onSubmit = (text) => {
const value = text.trim();
editor.setText("");
if (!value) return;
if (value.startsWith("/")) {
void handleCommand(value);
return;
}
void sendMessage(value);
};
editor.onSubmit = createEditorSubmitHandler({
editor,
handleCommand,
sendMessage,
});
editor.onEscape = () => {
void abortActive();