Compare commits

..

29 Commits

Author SHA1 Message Date
Peter Steinberger
63ac6ccd20 fix: resolve TwiML/status callbacks (#1180) (thanks @andrew-kurin) 2026-01-18 16:34:18 +00:00
Ghost
340a054f73 fix(voice-call): resolve StatusCallback with inline TwiML
- Switch from inline to URL-based TwiML for outbound calls
- Store TwiML content temporarily and serve on webhook request
- Add twimlStorage map and cleanup helper methods
- Fix TwiML serving to handle CallStatus='in-progress' on initial request

Closes #864
2026-01-18 16:11:19 +00:00
Peter Steinberger
810394f43b fix: improve remote bin probe logging 2026-01-18 16:09:48 +00:00
Peter Steinberger
835162fb62 fix: retry openai batch indexing 2026-01-18 16:08:22 +00:00
Peter Steinberger
82883095fe docs: explain Copilot provider options 2026-01-18 16:06:48 +00:00
Peter Steinberger
49d8ad3049 feat: surface node core/ui versions in macOS 2026-01-18 16:00:36 +00:00
Peter Steinberger
1721d04405 feat: add node core/ui versions in bridge 2026-01-18 15:59:54 +00:00
Peter Steinberger
633e0d9382 Merge pull request #1164 from ngutman/feat/boot-md
feat(hooks): run BOOT.md on gateway startup
2026-01-18 15:59:53 +00:00
Peter Steinberger
f06ce98312 refactor: rename lancedb memory plugin 2026-01-18 15:48:05 +00:00
Peter Steinberger
b546b2a48d fix: stabilize slack http receiver import 2026-01-18 15:44:17 +00:00
Peter Steinberger
c11b016d22 fix: prefer node service naming 2026-01-18 15:33:22 +00:00
Peter Steinberger
3686bde783 feat: add exec approvals tooling and service status 2026-01-18 15:23:41 +00:00
Peter Steinberger
9c06689569 fix: sanitize oversized image payloads 2026-01-18 15:21:38 +00:00
Peter Steinberger
891a2cc64a docs: tighten GitHub newline guidance 2026-01-18 15:20:09 +00:00
Peter Steinberger
01211937fc fix: link bash disabled docs 2026-01-18 15:17:09 +00:00
Peter Steinberger
4726580c7e feat(slack): add HTTP receiver webhook mode (#1143) - thanks @jdrhyne
Co-authored-by: Jonathan Rhyne <jdrhyne@users.noreply.github.com>
2026-01-18 15:04:07 +00:00
Peter Steinberger
e9a08dc507 feat: enrich system prompt docs guidance 2026-01-18 15:00:36 +00:00
Peter Steinberger
f3698e360b docs: add api usage and costs overview 2026-01-18 14:55:09 +00:00
Peter Steinberger
c69947dff8 feat: auto-enable audio understanding when keys exist 2026-01-18 14:55:09 +00:00
Peter Steinberger
173bce34b0 docs: add dep patch approval rule 2026-01-18 14:46:03 +00:00
Peter Steinberger
6a27e385b1 docs: map agent loop hook points 2026-01-18 14:43:35 +00:00
Peter Steinberger
5f0d9c3eb9 docs: expand agent loop overview 2026-01-18 14:30:12 +00:00
Peter Steinberger
0e31c8153c fix: bump Peekaboo revision 2026-01-18 14:26:19 +00:00
Peter Steinberger
9c0773c469 chore: update dependencies 2026-01-18 14:16:04 +00:00
Peter Steinberger
f5533baf61 test: add vector dedupe regression coverage 2026-01-18 14:08:06 +00:00
Peter Steinberger
60bc436e99 Merge pull request #1175 from vrknetha/fix/tool-error-fallback
Agents: surface tool failures without assistant output
2026-01-18 14:08:02 +00:00
Peter Steinberger
741b984a68 docs: fix #1151 changelog attribution 2026-01-18 14:04:38 +00:00
vrknetha
65710932ff Agents: surface tool failures without assistant output 2026-01-18 18:35:03 +05:30
Nimrod Gutman
11b07f4a29 feat(hooks): run boot.md on gateway startup 2026-01-18 11:50:25 +02:00
124 changed files with 4469 additions and 1100 deletions

View File

@@ -1,6 +1,6 @@
# Repository Guidelines
- Repo: https://github.com/clawdbot/clawdbot
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
@@ -84,6 +84,7 @@
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency.
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); dont 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.**

View File

@@ -2,26 +2,63 @@
Docs: https://docs.clawd.bot
## 2026.1.18-4
## 2026.1.17-7
### Changes
- Exec approvals: add `clawdbot approvals` CLI for viewing and updating gateway/node allowlists.
- CLI: add `clawdbot service` gateway/node management and a `clawdbot node status` alias.
- Status: show gateway + node service summaries in `clawdbot status` and `status --all`.
- Control UI: add gateway/node target selector for exec approvals.
- Docs: add approvals/service references and refresh node/control UI docs.
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
- 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.
- Memory: add native Gemini embeddings provider for memory search. (#1151) — thanks @gumadeiras.
- Memory: add native Gemini embeddings provider for memory search. (#1151)
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
- Nodes: report core/ui versions in node list + presence; surface both in CLI + macOS UI.
<<<<<<< HEAD
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) thanks @jdrhyne.
||||||| parent of 903e9be49 (feat: surface node core/ui versions in macOS)
### Fixes
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) thanks @cheeeee.
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151) — thanks @gumadeiras.
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151) — thanks @gumadeiras.
- Agents: surface tool failures when no assistant output is emitted. (#1175) — thanks @vrknetha.
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
- Voice call: separate TwiML fetches from status callbacks to avoid stuck calls. (#1180) thanks @andrew-kurin.
## 2026.1.18-5
### Changes
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
## 2026.1.18-3
### Changes
=======
- Nodes: report core/ui versions in node list + presence; surface both in CLI + macOS UI.
### Fixes
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) thanks @cheeeee.
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
- Voice call: separate TwiML fetches from status callbacks to avoid stuck calls. (#1180) thanks @andrew-kurin.
## 2026.1.18-5
### Changes
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
## 2026.1.18-3
### Changes
>>>>>>> 903e9be49 (feat: surface node core/ui versions in macOS)
- Exec: add host/security/ask routing for gateway + node exec.
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
@@ -35,17 +72,29 @@ Docs: https://docs.clawd.bot
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
### Fixes
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
- Skills: improve remote bin probe logging with node labels + connectivity hints.
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
- Tools: return a companion-app-required message when node exec is requested with no paired node.
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
## 2026.1.18-2
### Fixes
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
- Memory: apply OpenAI batch defaults even without explicit remote config.
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
- Discord: only emit slow listener warnings after 30s.
## 2026.1.17-6
@@ -73,22 +122,6 @@ Docs: https://docs.clawd.bot
### Fixes
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
## 2026.1.18-1
### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
### Fixes
- Memory: apply OpenAI batch defaults even without explicit remote config.
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
- Discord: only emit slow listener warnings after 30s.
## 2026.1.17-3
### Changes

View File

@@ -43,7 +43,7 @@
"location" : "https://github.com/steipete/Peekaboo.git",
"state" : {
"branch" : "main",
"revision" : "b2d0384d9f0f45b945d5f718f8a865bd574d83c2"
"revision" : "bace59f90bb276f1c6fb613acfda3935ec4a7a90"
}
},
{

View File

@@ -8,6 +8,8 @@ struct BridgeNodeInfo: Sendable {
var displayName: String?
var platform: String?
var version: String?
var coreVersion: String?
var uiVersion: String?
var deviceFamily: String?
var modelIdentifier: String?
var remoteAddress: String?
@@ -147,6 +149,8 @@ actor BridgeConnectionHandler {
displayName: hello.displayName,
platform: hello.platform,
version: hello.version,
coreVersion: hello.coreVersion,
uiVersion: hello.uiVersion,
deviceFamily: hello.deviceFamily,
modelIdentifier: hello.modelIdentifier,
remoteAddress: self.remoteAddressString(),
@@ -171,6 +175,8 @@ actor BridgeConnectionHandler {
displayName: req.displayName,
platform: req.platform,
version: req.version,
coreVersion: req.coreVersion,
uiVersion: req.uiVersion,
deviceFamily: req.deviceFamily,
modelIdentifier: req.modelIdentifier,
caps: req.caps,
@@ -186,6 +192,8 @@ actor BridgeConnectionHandler {
displayName: enriched.displayName,
platform: enriched.platform,
version: enriched.version,
coreVersion: enriched.coreVersion,
uiVersion: enriched.uiVersion,
deviceFamily: enriched.deviceFamily,
modelIdentifier: enriched.modelIdentifier,
remoteAddress: enriched.remoteAddress,

View File

@@ -1,3 +1,4 @@
import CryptoKit
import Foundation
import OSLog
import Security
@@ -121,6 +122,13 @@ struct ExecApprovalsFile: Codable {
var agents: [String: ExecApprovalsAgent]?
}
struct ExecApprovalsSnapshot: Codable {
var path: String
var exists: Bool
var hash: String
var file: ExecApprovalsFile
}
struct ExecApprovalsResolved {
let url: URL
let socketPath: String
@@ -153,6 +161,58 @@ enum ExecApprovalsStore {
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
}
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return ExecApprovalsFile(
version: 1,
socket: ExecApprovalsSocketConfig(
path: socketPath.isEmpty ? nil : socketPath,
token: token.isEmpty ? nil : token),
defaults: file.defaults,
agents: file.agents)
}
static func readSnapshot() -> ExecApprovalsSnapshot {
let url = self.fileURL()
guard FileManager.default.fileExists(atPath: url.path) else {
return ExecApprovalsSnapshot(
path: url.path,
exists: false,
hash: self.hashRaw(nil),
file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]))
}
let raw = try? String(contentsOf: url, encoding: .utf8)
let data = raw.flatMap { $0.data(using: .utf8) }
let decoded: ExecApprovalsFile = {
if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data), file.version == 1 {
return file
}
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
}()
return ExecApprovalsSnapshot(
path: url.path,
exists: true,
hash: self.hashRaw(raw),
file: decoded)
}
static func redactForSnapshot(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if socketPath.isEmpty {
return ExecApprovalsFile(
version: file.version,
socket: nil,
defaults: file.defaults,
agents: file.agents)
}
return ExecApprovalsFile(
version: file.version,
socket: ExecApprovalsSocketConfig(path: socketPath, token: nil),
defaults: file.defaults,
agents: file.agents)
}
static func loadFile() -> ExecApprovalsFile {
let url = self.fileURL()
guard FileManager.default.fileExists(atPath: url.path) else {
@@ -372,6 +432,12 @@ enum ExecApprovalsStore {
return UUID().uuidString
}
private static func hashRaw(_ raw: String?) -> String {
let data = Data((raw ?? "").utf8)
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
private static func expandPath(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "~" {

View File

@@ -249,6 +249,13 @@ actor GatewayConnection {
return trimmed.isEmpty ? nil : trimmed
}
func cachedGatewayVersion() -> String? {
guard let snapshot = self.lastSnapshot else { return nil }
let raw = snapshot.server["version"]?.value as? String
let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
func snapshotPaths() -> (configPath: String?, stateDir: String?) {
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@@ -747,8 +747,8 @@ extension MenuSessionsInjector {
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
}
if let version = entry.version?.nonEmpty {
menu.addItem(self.makeNodeCopyItem(label: "Version", value: self.formatVersionLabel(version)))
if let version = NodeMenuEntryFormatter.detailRightVersion(entry)?.nonEmpty {
menu.addItem(self.makeNodeCopyItem(label: "Version", value: version))
}
menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No"))

View File

@@ -95,6 +95,8 @@ actor MacNodeBridgePairingClient {
displayName: hello.displayName,
platform: hello.platform,
version: hello.version,
coreVersion: hello.coreVersion,
uiVersion: hello.uiVersion,
deviceFamily: hello.deviceFamily,
modelIdentifier: hello.modelIdentifier,
caps: hello.caps,

View File

@@ -114,12 +114,19 @@ final class MacNodeModeCoordinator {
let caps = self.currentCaps()
let commands = self.currentCommands(caps: caps)
let permissions = await self.currentPermissions()
let uiVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
let liveGatewayVersion = await GatewayConnection.shared.cachedGatewayVersion()
let fallbackGatewayVersion = GatewayProcessManager.shared.environmentStatus.gatewayVersion
let coreVersion = (liveGatewayVersion ?? fallbackGatewayVersion)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return BridgeHello(
nodeId: Self.nodeId(),
displayName: InstanceIdentity.displayName,
token: token,
platform: "macos",
version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
version: uiVersion,
coreVersion: coreVersion?.isEmpty == false ? coreVersion : nil,
uiVersion: uiVersion,
deviceFamily: "Mac",
modelIdentifier: InstanceIdentity.modelIdentifier,
caps: caps,
@@ -158,6 +165,8 @@ final class MacNodeModeCoordinator {
ClawdbotSystemCommand.notify.rawValue,
ClawdbotSystemCommand.which.rawValue,
ClawdbotSystemCommand.run.rawValue,
ClawdbotSystemCommand.execApprovalsGet.rawValue,
ClawdbotSystemCommand.execApprovalsSet.rawValue,
]
let capsSet = Set(caps)

View File

@@ -64,6 +64,10 @@ actor MacNodeRuntime {
return try await self.handleSystemWhich(req)
case ClawdbotSystemCommand.notify.rawValue:
return try await self.handleSystemNotify(req)
case ClawdbotSystemCommand.execApprovalsGet.rawValue:
return try await self.handleSystemExecApprovalsGet(req)
case ClawdbotSystemCommand.execApprovalsSet.rawValue:
return try await self.handleSystemExecApprovalsSet(req)
default:
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
}
@@ -676,6 +680,72 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
_ = ExecApprovalsStore.ensureFile()
let snapshot = ExecApprovalsStore.readSnapshot()
let redacted = ExecApprovalsSnapshot(
path: snapshot.path,
exists: snapshot.exists,
hash: snapshot.hash,
file: ExecApprovalsStore.redactForSnapshot(snapshot.file))
let payload = try Self.encodePayload(redacted)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func handleSystemExecApprovalsSet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
struct SetParams: Decodable {
var file: ExecApprovalsFile
var baseHash: String?
}
let params = try Self.decodeParams(SetParams.self, from: req.paramsJSON)
let current = ExecApprovalsStore.ensureFile()
let snapshot = ExecApprovalsStore.readSnapshot()
if snapshot.exists {
if snapshot.hash.isEmpty {
return Self.errorResponse(
req,
code: .invalidRequest,
message: "INVALID_REQUEST: exec approvals base hash unavailable; reload and retry")
}
let baseHash = params.baseHash?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if baseHash.isEmpty {
return Self.errorResponse(
req,
code: .invalidRequest,
message: "INVALID_REQUEST: exec approvals base hash required; reload and retry")
}
if baseHash != snapshot.hash {
return Self.errorResponse(
req,
code: .invalidRequest,
message: "INVALID_REQUEST: exec approvals changed; reload and retry")
}
}
var normalized = ExecApprovalsStore.normalizeIncoming(params.file)
let socketPath = normalized.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines)
let token = normalized.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedPath = (socketPath?.isEmpty == false)
? socketPath!
: current.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ??
ExecApprovalsStore.socketPath()
let resolvedToken = (token?.isEmpty == false)
? token!
: current.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
normalized.socket = ExecApprovalsSocketConfig(path: resolvedPath, token: resolvedToken)
ExecApprovalsStore.saveFile(normalized)
let nextSnapshot = ExecApprovalsStore.readSnapshot()
let redacted = ExecApprovalsSnapshot(
path: nextSnapshot.path,
exists: nextSnapshot.exists,
hash: nextSnapshot.hash,
file: ExecApprovalsStore.redactForSnapshot(nextSnapshot.file))
let payload = try Self.encodePayload(redacted)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
guard let sender = self.eventSender else { return }
guard let data = try? JSONEncoder().encode(payload),

View File

@@ -35,8 +35,9 @@ struct NodeMenuEntryFormatter {
if let platform = self.platformText(entry) {
parts.append("platform \(platform)")
}
if let version = entry.version?.nonEmpty {
parts.append("app \(self.compactVersion(version))")
let versionLabels = self.versionLabels(entry)
if !versionLabels.isEmpty {
parts.append(versionLabels.joined(separator: " · "))
}
parts.append("status \(self.roleText(entry))")
return parts.joined(separator: " · ")
@@ -60,8 +61,9 @@ struct NodeMenuEntryFormatter {
}
static func detailRightVersion(_ entry: NodeInfo) -> String? {
guard let version = entry.version?.nonEmpty else { return nil }
return self.shortVersionLabel(version)
let labels = self.versionLabels(entry, compact: false)
if labels.isEmpty { return nil }
return labels.joined(separator: " · ")
}
static func platformText(_ entry: NodeInfo) -> String? {
@@ -127,6 +129,39 @@ struct NodeMenuEntryFormatter {
return compact
}
private static func versionLabels(_ entry: NodeInfo, compact: Bool = true) -> [String] {
let (core, ui) = self.resolveVersions(entry)
var labels: [String] = []
if let core {
let label = compact ? self.compactVersion(core) : self.shortVersionLabel(core)
labels.append("core \(label)")
}
if let ui {
let label = compact ? self.compactVersion(ui) : self.shortVersionLabel(ui)
labels.append("ui \(label)")
}
return labels
}
private static func resolveVersions(_ entry: NodeInfo) -> (core: String?, ui: String?) {
let core = entry.coreVersion?.nonEmpty
let ui = entry.uiVersion?.nonEmpty
if core != nil || ui != nil {
return (core, ui)
}
guard let legacy = entry.version?.nonEmpty else { return (nil, nil) }
if self.isHeadlessPlatform(entry) {
return (legacy, nil)
}
return (nil, legacy)
}
private static func isHeadlessPlatform(_ entry: NodeInfo) -> Bool {
let raw = entry.platform?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
if raw == "darwin" || raw == "linux" || raw == "win32" || raw == "windows" { return true }
return false
}
static func leadingSymbol(_ entry: NodeInfo) -> String {
if self.isGateway(entry) {
return self.safeSystemSymbol(

View File

@@ -7,6 +7,8 @@ struct NodeInfo: Identifiable, Codable {
let displayName: String?
let platform: String?
let version: String?
let coreVersion: String?
let uiVersion: String?
let deviceFamily: String?
let modelIdentifier: String?
let remoteIp: String?

View File

@@ -530,6 +530,8 @@ public struct NodePairRequestParams: Codable, Sendable {
public let displayname: String?
public let platform: String?
public let version: String?
public let coreversion: String?
public let uiversion: String?
public let devicefamily: String?
public let modelidentifier: String?
public let caps: [String]?
@@ -542,6 +544,8 @@ public struct NodePairRequestParams: Codable, Sendable {
displayname: String?,
platform: String?,
version: String?,
coreversion: String?,
uiversion: String?,
devicefamily: String?,
modelidentifier: String?,
caps: [String]?,
@@ -553,6 +557,8 @@ public struct NodePairRequestParams: Codable, Sendable {
self.displayname = displayname
self.platform = platform
self.version = version
self.coreversion = coreversion
self.uiversion = uiversion
self.devicefamily = devicefamily
self.modelidentifier = modelidentifier
self.caps = caps
@@ -565,6 +571,8 @@ public struct NodePairRequestParams: Codable, Sendable {
case displayname = "displayName"
case platform
case version
case coreversion = "coreVersion"
case uiversion = "uiVersion"
case devicefamily = "deviceFamily"
case modelidentifier = "modelIdentifier"
case caps

View File

@@ -63,6 +63,8 @@ public struct BridgeHello: Codable, Sendable {
public let token: String?
public let platform: String?
public let version: String?
public let coreVersion: String?
public let uiVersion: String?
public let deviceFamily: String?
public let modelIdentifier: String?
public let caps: [String]?
@@ -76,6 +78,8 @@ public struct BridgeHello: Codable, Sendable {
token: String?,
platform: String?,
version: String?,
coreVersion: String? = nil,
uiVersion: String? = nil,
deviceFamily: String? = nil,
modelIdentifier: String? = nil,
caps: [String]? = nil,
@@ -88,6 +92,8 @@ public struct BridgeHello: Codable, Sendable {
self.token = token
self.platform = platform
self.version = version
self.coreVersion = coreVersion
self.uiVersion = uiVersion
self.deviceFamily = deviceFamily
self.modelIdentifier = modelIdentifier
self.caps = caps
@@ -121,6 +127,8 @@ public struct BridgePairRequest: Codable, Sendable {
public let displayName: String?
public let platform: String?
public let version: String?
public let coreVersion: String?
public let uiVersion: String?
public let deviceFamily: String?
public let modelIdentifier: String?
public let caps: [String]?
@@ -135,6 +143,8 @@ public struct BridgePairRequest: Codable, Sendable {
displayName: String?,
platform: String?,
version: String?,
coreVersion: String? = nil,
uiVersion: String? = nil,
deviceFamily: String? = nil,
modelIdentifier: String? = nil,
caps: [String]? = nil,
@@ -148,6 +158,8 @@ public struct BridgePairRequest: Codable, Sendable {
self.displayName = displayName
self.platform = platform
self.version = version
self.coreVersion = coreVersion
self.uiVersion = uiVersion
self.deviceFamily = deviceFamily
self.modelIdentifier = modelIdentifier
self.caps = caps

View File

@@ -4,6 +4,8 @@ public enum ClawdbotSystemCommand: String, Codable, Sendable {
case run = "system.run"
case which = "system.which"
case notify = "system.notify"
case execApprovalsGet = "system.execApprovals.get"
case execApprovalsSet = "system.execApprovals.set"
}
public enum ClawdbotNotificationPriority: String, Codable, Sendable {

View File

@@ -1,11 +1,13 @@
---
summary: "Slack socket mode setup and Clawdbot config"
read_when: "Setting up Slack or debugging Slack socket mode"
summary: "Slack setup for socket or HTTP webhook mode"
read_when: "Setting up Slack or debugging Slack socket/HTTP mode"
---
# Slack (socket mode)
# Slack
## Quick setup (beginner)
## Socket mode (default)
### Quick setup (beginner)
1) Create a Slack app and enable **Socket Mode**.
2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`).
3) Set tokens for Clawdbot and start the gateway.
@@ -23,7 +25,7 @@ Minimal config:
}
```
## Setup
### Setup
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
2) **Socket Mode** → toggle on. Then go to **Basic Information****App-Level Tokens****Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
@@ -43,7 +45,7 @@ Use the manifest below so scopes and events stay in sync.
Multi-account support: use `channels.slack.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
## Clawdbot config (minimal)
### Clawdbot config (minimal)
Set tokens via env vars (recommended):
- `SLACK_APP_TOKEN=xapp-...`
@@ -63,7 +65,7 @@ Or via config:
}
```
## User token (optional)
### User token (optional)
Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
pins, reactions, emoji, member info). By default this stays read-only: reads
prefer the user token when present, and writes still use the bot token unless
@@ -102,18 +104,51 @@ Example with userTokenReadOnly explicitly set (allow user token writes):
}
```
### Token usage
#### Token usage
- Read operations (history, reactions list, pins list, emoji list, member info,
search) prefer the user token when configured, otherwise the bot token.
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
no bot token is available, Clawdbot falls back to the user token.
## History context
### History context
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
## Manifest (optional)
## HTTP mode (Events API)
Use HTTP webhook mode when your Gateway is reachable by Slack over HTTPS (typical for server deployments).
HTTP mode uses the Events API + Interactivity + Slash Commands with a shared request URL.
### Setup
1) Create a Slack app and **disable Socket Mode** (optional if you only use HTTP).
2) **Basic Information** → copy the **Signing Secret**.
3) **OAuth & Permissions** → install the app and copy the **Bot User OAuth Token** (`xoxb-...`).
4) **Event Subscriptions** → enable events and set the **Request URL** to your gateway webhook path (default `/slack/events`).
5) **Interactivity & Shortcuts** → enable and set the same **Request URL**.
6) **Slash Commands** → set the same **Request URL** for your command(s).
Example request URL:
`https://gateway-host/slack/events`
### Clawdbot config (minimal)
```json5
{
channels: {
slack: {
enabled: true,
mode: "http",
botToken: "xoxb-...",
signingSecret: "your-signing-secret",
webhookPath: "/slack/events"
}
}
}
```
Multi-account HTTP mode: set `channels.slack.accounts.<id>.mode = "http"` and provide a unique
`webhookPath` per account so each Slack app can point to its own URL.
### Manifest (optional)
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
user scopes if you plan to configure a user token.

44
docs/cli/approvals.md Normal file
View File

@@ -0,0 +1,44 @@
---
summary: "CLI reference for `clawdbot approvals` (exec approvals for gateway or node hosts)"
read_when:
- You want to edit exec approvals from the CLI
- You need to manage allowlists on gateway or node hosts
---
# `clawdbot approvals`
Manage exec approvals for the **gateway host** or a **node host**.
By default, commands target the gateway. Use `--node` to edit a nodes approvals.
Related:
- Exec approvals: [Exec approvals](/tools/exec-approvals)
- Nodes: [Nodes](/nodes)
## Common commands
```bash
clawdbot approvals get
clawdbot approvals get --node <id|name|ip>
```
## Replace approvals from a file
```bash
clawdbot approvals set --file ./exec-approvals.json
clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json
```
## Allowlist helpers
```bash
clawdbot approvals allowlist add "~/Projects/**/bin/rg"
clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"
clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
```
## Notes
- `--node` uses the same resolver as `clawdbot nodes` (id, name, ip, or id prefix).
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
- Approvals files are stored per host at `~/.clawdbot/exec-approvals.json`.

View File

@@ -9,6 +9,9 @@ read_when:
Manage the Gateway daemon (background service).
Note: `clawdbot service gateway …` is the preferred surface; `daemon` remains
as a legacy alias for compatibility.
Related:
- Gateway CLI: [Gateway](/cli/gateway)
- macOS platform notes: [macOS](/platforms/macos)

View File

@@ -7,7 +7,7 @@ read_when:
# `clawdbot hooks`
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, and gateway startup).
Related:
- Hooks: [Hooks](/hooks)
@@ -29,9 +29,10 @@ List all discovered hooks from workspace, managed, and bundled directories.
**Example output:**
```
Hooks (3/3 ready)
Hooks (4/4 ready)
Ready:
🚀 boot-md ✓ - Run BOOT.md on gateway startup
📝 command-logger ✓ - Log all command events to a centralized audit file
💾 session-memory ✓ - Save session context to memory when /new command is issued
😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
@@ -107,8 +108,8 @@ Show summary of hook eligibility status (how many are ready vs. not ready).
```
Hooks Status
Total hooks: 2
Ready: 2
Total hooks: 4
Ready: 4
Not ready: 0
```
@@ -273,3 +274,17 @@ clawdbot hooks enable soul-evil
```
**See:** [SOUL Evil Hook](/hooks/soul-evil)
### boot-md
Runs `BOOT.md` when the gateway starts (after channels start).
**Events**: `gateway:startup`
**Enable**:
```bash
clawdbot hooks enable boot-md
```
**See:** [boot-md documentation](/hooks#boot-md)

View File

@@ -29,11 +29,13 @@ This page describes the current CLI behavior. If commands change, update this do
- [`sessions`](/cli/sessions)
- [`gateway`](/cli/gateway)
- [`daemon`](/cli/daemon)
- [`service`](/cli/service)
- [`logs`](/cli/logs)
- [`models`](/cli/models)
- [`memory`](/cli/memory)
- [`nodes`](/cli/nodes)
- [`node`](/cli/node)
- [`approvals`](/cli/approvals)
- [`sandbox`](/cli/sandbox)
- [`tui`](/cli/tui)
- [`browser`](/cli/browser)
@@ -143,6 +145,21 @@ clawdbot [--dev] [--profile <name>] <command>
start
stop
restart
service
gateway
status
install
uninstall
start
stop
restart
node
status
install
uninstall
start
stop
restart
logs
models
list
@@ -180,6 +197,10 @@ clawdbot [--dev] [--profile <name>] <command>
start
stop
restart
approvals
get
set
allowlist add|remove
browser
status
start
@@ -520,6 +541,9 @@ Options:
- `--verbose`
- `--debug` (alias for `--verbose`)
Notes:
- Overview includes Gateway + Node service status when available.
### Usage tracking
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
@@ -781,12 +805,15 @@ All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
Subcommands:
- `node start --host <gateway-host> --port 18790`
- `node daemon status`
- `node daemon install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
- `node daemon uninstall`
- `node daemon start`
- `node daemon stop`
- `node daemon restart`
- `node service status`
- `node service install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
- `node service uninstall`
- `node service start`
- `node service stop`
- `node service restart`
Legacy alias:
- `node daemon …` (same as `node service …`)
## Nodes

View File

@@ -37,12 +37,14 @@ Options:
- `--node-id <id>`: Override node id (clears pairing token)
- `--display-name <name>`: Override the node display name
## Daemon (background service)
## Service (background)
Install a headless node host as a user service.
```bash
clawdbot node daemon install --host <gateway-host> --port 18790
clawdbot node service install --host <gateway-host> --port 18790
# or
clawdbot service node install --host <gateway-host> --port 18790
```
Options:
@@ -57,12 +59,20 @@ Options:
Manage the service:
```bash
clawdbot node status
clawdbot service node status
clawdbot node service status
clawdbot node service start
clawdbot node service stop
clawdbot node service restart
clawdbot node service uninstall
```
Legacy alias:
```bash
clawdbot node daemon status
clawdbot node daemon start
clawdbot node daemon stop
clawdbot node daemon restart
clawdbot node daemon uninstall
```
## Pairing
@@ -83,3 +93,4 @@ The node host stores its node id + token in `~/.clawdbot/node.json`.
- `~/.clawdbot/exec-approvals.json`
- [Exec approvals](/tools/exec-approvals)
- `clawdbot approvals --node <id|name|ip>` (edit from the Gateway)

51
docs/cli/service.md Normal file
View File

@@ -0,0 +1,51 @@
---
summary: "CLI reference for `clawdbot service` (manage gateway + node services)"
read_when:
- You want to manage Gateway or node services cross-platform
- You want a single surface for start/stop/install/uninstall
---
# `clawdbot service`
Manage the **Gateway** service and **node host** services.
Related:
- Gateway daemon (legacy alias): [Daemon](/cli/daemon)
- Node host: [Node](/cli/node)
## Gateway service
```bash
clawdbot service gateway status
clawdbot service gateway install --port 18789
clawdbot service gateway start
clawdbot service gateway stop
clawdbot service gateway restart
clawdbot service gateway uninstall
```
Notes:
- `service gateway status` supports `--json` and `--deep` for system checks.
- `service gateway install` supports `--runtime node|bun` and `--token`.
## Node host service
```bash
clawdbot service node status
clawdbot service node install --host <gateway-host> --port 18790
clawdbot service node start
clawdbot service node stop
clawdbot service node restart
clawdbot service node uninstall
```
Notes:
- `service node install` supports `--runtime node|bun`, `--node-id`, `--display-name`,
and TLS options (`--tls`, `--tls-fingerprint`).
## Aliases
- `clawdbot daemon …``clawdbot service gateway …`
- `clawdbot node service …``clawdbot service node …`
- `clawdbot node status``clawdbot service node status`
- `clawdbot node daemon …``clawdbot service node …` (legacy)

View File

@@ -19,4 +19,5 @@ clawdbot status --usage
Notes:
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
- Output includes per-agent session stores when multiple agents are configured.
- Overview includes Gateway + Node service install/runtime status when available.
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).

View File

@@ -5,13 +5,19 @@ read_when:
---
# Agent Loop (Clawdbot)
Short, exact flow of one agent run.
An agentic loop is the full “real” run of an agent: intake → context assembly → model inference →
tool execution → streaming replies → persistence. Its the authoritative path that turns a message
into actions and a final reply, while keeping session state consistent.
In Clawdbot, a loop is a single, serialized run per session that emits lifecycle and stream events
as the model thinks, calls tools, and streams output. This doc explains how that authentic loop is
wired end-to-end.
## Entry points
- Gateway RPC: `agent` and `agent.wait`.
- CLI: `agent` command.
## High-level flow
## How it works (high-level)
1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately.
2) `agentCommand` runs the agent:
- resolves model + thinking/verbose defaults
@@ -19,8 +25,9 @@ Short, exact flow of one agent run.
- calls `runEmbeddedPiAgent` (pi-agent-core runtime)
- emits **lifecycle end/error** if the embedded loop does not emit one
3) `runEmbeddedPiAgent`:
- builds `AgentSession` and subscribes to pi events
- streams assistant deltas + tool events
- serializes runs via per-session + global queues
- resolves model + auth profile and builds the pi session
- subscribes to pi events and streams assistant/tool deltas
- enforces timeout -> aborts run if exceeded
- returns payloads + usage metadata
4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdbot `agent` stream:
@@ -31,6 +38,73 @@ Short, exact flow of one agent run.
- waits for **lifecycle end/error** for `runId`
- returns `{ status: ok|error|timeout, startedAt, endedAt, error? }`
## Queueing + concurrency
- Runs are serialized per session key (session lane) and optionally through a global lane.
- This prevents tool/session races and keeps session history consistent.
- Messaging channels can choose queue modes (collect/steer/followup) that feed this lane system.
See [Command Queue](/concepts/queue).
## Session + workspace preparation
- Workspace is resolved and created; sandboxed runs may redirect to a sandbox workspace root.
- Skills are loaded (or reused from a snapshot) and injected into env and prompt.
- Bootstrap/context files are resolved and injected into the system prompt report.
- A session write lock is acquired; `SessionManager` is opened and prepared before streaming.
## Prompt assembly + system prompt
- System prompt is built from Clawdbots base prompt, skills prompt, bootstrap context, and per-run overrides.
- Model-specific limits and compaction reserve tokens are enforced.
- See [System prompt](/concepts/system-prompt) for what the model sees.
## Hook points (where you can intercept)
Clawdbot has two hook systems:
- **Internal hooks** (Gateway hooks): event-driven scripts for commands and lifecycle events.
- **Plugin hooks**: extension points inside the agent/tool lifecycle and gateway pipeline.
### Internal hooks (Gateway hooks)
- **`agent:bootstrap`**: runs while building bootstrap files before the system prompt is finalized.
Use this to add/remove bootstrap context files.
- **Command hooks**: `/new`, `/reset`, `/stop`, and other command events (see Hooks doc).
See [Hooks](/hooks) for setup and examples.
### Plugin hooks (agent + gateway lifecycle)
These run inside the agent loop or gateway pipeline:
- **`before_agent_start`**: inject context or override system prompt before the run starts.
- **`agent_end`**: inspect the final message list and run metadata after completion.
- **`before_compaction` / `after_compaction`**: observe or annotate compaction cycles.
- **`before_tool_call` / `after_tool_call`**: intercept tool params/results.
- **`message_received` / `message_sending` / `message_sent`**: inbound + outbound message hooks.
- **`session_start` / `session_end`**: session lifecycle boundaries.
- **`gateway_start` / `gateway_stop`**: gateway lifecycle events.
See [Plugins](/plugin#plugin-hooks) for the hook API and registration details.
## Streaming + partial replies
- Assistant deltas are streamed from pi-agent-core and emitted as `assistant` events.
- Block streaming can emit partial replies either on `text_end` or `message_end`.
- Reasoning streaming can be emitted as a separate stream or as block replies.
- See [Streaming](/concepts/streaming) for chunking and block reply behavior.
## Tool execution + messaging tools
- Tool start/update/end events are emitted on the `tool` stream.
- Tool results are sanitized for size and image payloads before logging/emitting.
- Messaging tool sends are tracked to suppress duplicate assistant confirmations.
## Reply shaping + suppression
- Final payloads are assembled from:
- assistant text (and optional reasoning)
- inline tool summaries (when verbose + allowed)
- assistant error text when the model errors
- `NO_REPLY` is treated as a silent token and filtered from outgoing payloads.
- Messaging tool duplicates are removed from the final payload list.
- If no renderable payloads remain and a tool errored, a fallback tool error reply is emitted
(unless a messaging tool already sent a user-visible reply).
## Compaction + retries
- Auto-compaction emits `compaction` stream events and can trigger a retry.
- On retry, in-memory buffers and tool summaries are reset to avoid duplicate output.
- See [Compaction](/concepts/compaction) for the compaction pipeline.
## Event streams (today)
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
- `assistant`: streamed deltas from pi-agent-core

View File

@@ -86,6 +86,10 @@ These are the standard files Clawdbot expects inside the workspace:
- Optional tiny checklist for heartbeat runs.
- Keep it short to avoid token burn.
- `BOOT.md`
- Optional startup checklist executed on gateway restart when internal hooks are enabled.
- Keep it short; use the message tool for outbound sends.
- `BOOTSTRAP.md`
- One-time first-run ritual.
- Only created for a brand-new workspace.

View File

@@ -18,6 +18,7 @@ The prompt is intentionally compact and uses fixed sections:
- **Skills** (when available): tells the model how to load skill instructions on demand.
- **Clawdbot Self-Update**: how to run `config.apply` and `update.run`.
- **Workspace**: working directory (`agents.defaults.workspace`).
- **Documentation**: local path to Clawdbot docs (repo or npm package) and when to read them.
- **Workspace Files (injected)**: indicates bootstrap files are included below.
- **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available.
- **Current Date & Time**: user-local time, timezone, and time format.
@@ -98,3 +99,12 @@ Skills section is omitted.
```
This keeps the base prompt small while still enabling targeted skill usage.
## Documentation
When available, the system prompt includes a **Documentation** section that points to the
local Clawdbot docs directory (either `docs/` in the repo workspace or the bundled npm
package docs) and also notes the public mirror, source repo, community Discord, and
ClawdHub (https://clawdhub.com) for skills discovery. The prompt instructs the model to consult local docs first
for Clawdbot behavior, commands, configuration, or architecture, and to run
`clawdbot status` itself when possible (asking the user only when it lacks access).

View File

@@ -657,6 +657,10 @@
"source": "/templates/AGENTS",
"destination": "/reference/templates/AGENTS"
},
{
"source": "/templates/BOOT",
"destination": "/reference/templates/BOOT"
},
{
"source": "/templates/BOOTSTRAP",
"destination": "/reference/templates/BOOTSTRAP"
@@ -822,8 +826,10 @@
"cli/models",
"cli/logs",
"cli/nodes",
"cli/approvals",
"cli/gateway",
"cli/daemon",
"cli/service",
"cli/tui",
"cli/voicecall",
"cli/wake",
@@ -1051,6 +1057,7 @@
"reference/RELEASING",
"reference/AGENTS.default",
"reference/templates/AGENTS",
"reference/templates/BOOT",
"reference/templates/BOOTSTRAP",
"reference/templates/HEARTBEAT",
"reference/templates/IDENTITY",

View File

@@ -37,10 +37,11 @@ The hooks system allows you to:
### Bundled Hooks
Clawdbot ships with three bundled hooks that are automatically discovered:
Clawdbot ships with four bundled hooks that are automatically discovered:
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
- **🚀 boot-md**: Runs `BOOT.md` when the gateway starts (requires internal hooks enabled)
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
List available hooks:
@@ -195,7 +196,7 @@ Each event includes:
```typescript
{
type: 'command' | 'session' | 'agent',
type: 'command' | 'session' | 'agent' | 'gateway',
action: string, // e.g., 'new', 'reset', 'stop'
sessionKey: string, // Session identifier
timestamp: Date, // When the event occurred
@@ -228,6 +229,12 @@ Triggered when agent commands are issued:
- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`)
### Gateway Events
Triggered when the gateway starts:
- **`gateway:startup`**: After channels start and hooks are loaded
### Future Events
Planned event types:
@@ -542,6 +549,26 @@ clawdbot hooks enable soul-evil
}
```
### boot-md
Runs `BOOT.md` when the gateway starts (after channels start).
Internal hooks must be enabled for this to run.
**Events**: `gateway:startup`
**Requirements**: `workspace.dir` must be configured
**What it does**:
1. Reads `BOOT.md` from your workspace
2. Runs the instructions via the agent runner
3. Sends any requested outbound messages via the message tool
**Enable**:
```bash
clawdbot hooks enable boot-md
```
## Best Practices
### Keep Handlers Fast
@@ -614,6 +641,7 @@ The gateway logs hook loading at startup:
```
Registered hook: session-memory -> command:new
Registered hook: command-logger -> command
Registered hook: boot-md -> gateway:startup
```
### Check Discovery

View File

@@ -149,8 +149,8 @@ Notes:
## System commands (node host / mac node)
The macOS node exposes `system.run` and `system.notify`. The headless node host
exposes `system.run` and `system.which`.
The macOS node exposes `system.run`, `system.notify`, and `system.execApprovals.get/set`.
The headless node host exposes `system.run`, `system.which`, and `system.execApprovals.get/set`.
Examples:

View File

@@ -104,6 +104,29 @@ Rules:
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
**active reply model** when its provider supports the capability.
### Auto-enable audio (when keys exist)
If `tools.media.audio.enabled` is **not** set to `false` and you have any supported
audio provider keys configured, Clawdbot will **auto-enable audio transcription**
even when you havent listed models explicitly.
Providers checked (in order):
1) OpenAI
2) Groq
3) Deepgram
To disable this behavior, set:
```json5
{
tools: {
media: {
audio: {
enabled: false
}
}
}
}
```
## Capabilities (optional)
If you set `capabilities`, the entry only runs for those media types. For shared
lists, Clawdbot can infer defaults:

View File

@@ -37,6 +37,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
- Memory (LanceDB) — bundled long-term memory plugin (auto-recall/capture; set `plugins.slots.memory = "memory-lancedb"`)
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
@@ -45,7 +46,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
- Copilot Proxy (provider auth) — bundled as `copilot-proxy` (disabled by default)
- Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default)
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
register:

View File

@@ -6,6 +6,27 @@ read_when:
---
# Github Copilot
## What is GitHub Copilot?
GitHub Copilot is GitHub's AI coding assistant. It provides access to Copilot
models for your GitHub account and plan. Clawdbot can use Copilot as a model
provider in two different ways.
## Two ways to use Copilot in Clawdbot
### 1) Built-in GitHub Copilot provider (`github-copilot`)
Use the native device-login flow to obtain a GitHub token, then exchange it for
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
because it does not require VS Code.
### 2) Copilot Proxy plugin (`copilot-proxy`)
Use the **Copilot Proxy** VS Code extension as a local bridge. Clawdbot talks to
the proxys `/v1` endpoint and uses the model list you configure there. Choose
this when you already run Copilot Proxy in VS Code or need to route through it.
You must enable the plugin and keep the VS Code extension running.
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs
the GitHub device flow, saves an auth profile, and updates your config to use that
profile.

View File

@@ -0,0 +1,116 @@
---
summary: "Audit what can spend money, which keys are used, and how to view usage"
read_when:
- You want to understand which features may call paid APIs
- You need to audit keys, costs, and usage visibility
- Youre explaining /status or /usage cost reporting
---
# API usage & costs
This doc lists **features that can invoke API keys** and where their costs show up. It focuses on
Clawdbot features that can generate provider usage or paid API calls.
## Where costs show up (chat + CLI)
**Per-session cost snapshot**
- `/status` shows the current session model, context usage, and last response tokens.
- If the model uses **API-key auth**, `/status` also shows **estimated cost** for the last reply.
**Per-message cost footer**
- `/usage full` appends a usage footer to every reply, including **estimated cost** (API-key only).
- `/usage tokens` shows tokens only; OAuth flows hide dollar cost.
**CLI usage windows (provider quotas)**
- `clawdbot status --usage` and `clawdbot channels list` show provider **usage windows**
(quota snapshots, not per-message costs).
See [Token use & costs](/token-use) for details and examples.
## How keys are discovered
Clawdbot can pick up credentials from:
- **Auth profiles** (per-agent, stored in `auth-profiles.json`).
- **Environment variables** (e.g. `OPENAI_API_KEY`, `BRAVE_API_KEY`, `FIRECRAWL_API_KEY`).
- **Config** (`models.providers.*.apiKey`, `tools.web.search.*`, `tools.web.fetch.firecrawl.*`,
`memorySearch.*`, `talk.apiKey`).
- **Skills** (`skills.entries.<name>.apiKey`) which may export keys to the skill process env.
## Features that can spend keys
### 1) Core model responses (chat + tools)
Every reply or tool call uses the **current model provider** (OpenAI, Anthropic, etc). This is the
primary source of usage and cost.
See [Models](/providers/models) for pricing config and [Token use & costs](/token-use) for display.
### 2) Media understanding (audio/image/video)
Inbound media can be summarized/transcribed before the reply runs. This uses model/provider APIs.
- Audio: OpenAI / Groq / Deepgram (now **auto-enabled** when keys exist).
- Image: OpenAI / Anthropic / Google.
- Video: Google.
See [Media understanding](/nodes/media-understanding).
### 3) Memory embeddings + semantic search
Semantic memory search uses **embedding APIs** when configured for remote providers:
- `memorySearch.provider = "openai"` → OpenAI embeddings
- `memorySearch.provider = "gemini"` → Gemini embeddings
- Optional fallback to OpenAI if local embeddings fail
You can keep it local with `memorySearch.provider = "local"` (no API usage).
See [Memory](/concepts/memory).
### 4) Web search tool (Brave / Perplexity via OpenRouter)
`web_search` uses API keys and may incur usage charges:
- **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
- **Perplexity** (via OpenRouter): `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
**Brave free tier (generous):**
- **2,000 requests/month**
- **1 request/second**
- **Credit card required** for verification (no charge unless you upgrade)
See [Web tools](/tools/web).
### 5) Web fetch tool (Firecrawl)
`web_fetch` can call **Firecrawl** when an API key is present:
- `FIRECRAWL_API_KEY` or `tools.web.fetch.firecrawl.apiKey`
If Firecrawl isnt configured, the tool falls back to direct fetch + readability (no paid API).
See [Web tools](/tools/web).
### 6) Provider usage snapshots (status/health)
Some status commands call **provider usage endpoints** to display quota windows or auth health.
These are typically low-volume calls but still hit provider APIs:
- `clawdbot status --usage`
- `clawdbot models status --json`
See [Models CLI](/cli/models).
### 7) Compaction safeguard summarization
The compaction safeguard can summarize session history using the **current model**, which
invokes provider APIs when it runs.
See [Session management + compaction](/reference/session-management-compaction).
### 8) Model scan / probe
`clawdbot models scan` can probe OpenRouter models and uses `OPENROUTER_API_KEY` when
probing is enabled.
See [Models CLI](/cli/models).
### 9) Talk (speech)
Talk mode can invoke **ElevenLabs** when configured:
- `ELEVENLABS_API_KEY` or `talk.apiKey`
See [Talk mode](/nodes/talk).
### 10) Skills (third-party APIs)
Skills can store `apiKey` in `skills.entries.<name>.apiKey`. If a skill uses that key for external
APIs, it can incur costs according to the skills provider.
See [Skills](/tools/skills).

View File

@@ -0,0 +1,9 @@
---
summary: "Workspace template for BOOT.md"
read_when:
- Adding a BOOT.md checklist
---
# BOOT.md
Add short, explicit instructions for what Clawdbot should do on startup (enable `hooks.internal.enabled`).
If the task sends a message, use the message tool and then reply with NO_REPLY.

View File

@@ -107,8 +107,12 @@ overrides, and allowlists. Pick a scope (Defaults or an agent), tweak the policy
add/remove allowlist patterns, then **Save**. The UI shows **last used** metadata
per pattern so you can keep the list tidy.
Note: the Control UI edits the approvals file on the **Gateway host**. For a
headless node host, edit its local `~/.clawdbot/exec-approvals.json` directly.
The target selector chooses **Gateway** (local approvals) or a **Node**. Nodes
must advertise `system.execApprovals.get/set` (macOS app or headless node host).
If a node does not advertise exec approvals yet, edit its local
`~/.clawdbot/exec-approvals.json` directly.
CLI: `clawdbot approvals` supports gateway or node editing (see [Approvals CLI](/cli/approvals)).
## Approval flow

View File

@@ -36,7 +36,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
- Skills: status, enable/disable, install, API key updates (`skills.*`)
- Nodes: list + caps (`node.list`)
- Exec approvals: edit allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`)
- Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`)
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
- Config: apply + restart with validation (`config.apply`) and wake the last active session
- Config writes include a base-hash guard to prevent clobbering concurrent edits

View File

@@ -1,5 +1,4 @@
import os from "node:os";
import { beforeEach, describe, expect, it } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";

View File

@@ -37,8 +37,8 @@ describeWithKey("memory plugin e2e", () => {
// Dynamic import to avoid loading LanceDB when not testing
const { default: memoryPlugin } = await import("./index.js");
expect(memoryPlugin.id).toBe("memory");
expect(memoryPlugin.name).toBe("Memory (Vector)");
expect(memoryPlugin.id).toBe("memory-lancedb");
expect(memoryPlugin.name).toBe("Memory (LanceDB)");
expect(memoryPlugin.kind).toBe("memory");
expect(memoryPlugin.configSchema).toBeDefined();
expect(memoryPlugin.register).toBeInstanceOf(Function);
@@ -185,8 +185,8 @@ describeWithKey("memory plugin live tests", () => {
const logs: string[] = [];
const mockApi = {
id: "memory",
name: "Memory (Vector)",
id: "memory-lancedb",
name: "Memory (LanceDB)",
source: "test",
config: {},
pluginConfig: {

View File

@@ -1,5 +1,5 @@
/**
* Clawdbot Memory Plugin
* Clawdbot Memory (LanceDB) Plugin
*
* Long-term memory with vector search for AI conversations.
* Uses LanceDB for storage and OpenAI for embeddings.
@@ -214,9 +214,9 @@ function detectCategory(text: string): MemoryCategory {
// ============================================================================
const memoryPlugin = {
id: "memory",
name: "Memory (Vector)",
description: "Long-term memory with vector search and seamless auto-recall/capture",
id: "memory-lancedb",
name: "Memory (LanceDB)",
description: "LanceDB-backed long-term memory with auto-recall/capture",
kind: "memory" as const,
configSchema: memoryConfigSchema,
@@ -227,7 +227,9 @@ const memoryPlugin = {
const db = new MemoryDB(resolvedDbPath, vectorDim);
const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model!);
api.logger.info(`memory: plugin registered (db: ${resolvedDbPath}, lazy init)`);
api.logger.info(
`memory-lancedb: plugin registered (db: ${resolvedDbPath}, lazy init)`,
);
// ========================================================================
// Tools
@@ -417,7 +419,7 @@ const memoryPlugin = {
({ program }) => {
const memory = program
.command("ltm")
.description("Long-term memory plugin commands");
.description("LanceDB memory plugin commands");
memory
.command("list")
@@ -477,14 +479,14 @@ const memoryPlugin = {
.join("\n");
api.logger.info?.(
`memory: injecting ${results.length} memories into context`,
`memory-lancedb: injecting ${results.length} memories into context`,
);
return {
prependContext: `<relevant-memories>\nThe following memories may be relevant to this conversation:\n${memoryContext}\n</relevant-memories>`,
};
} catch (err) {
api.logger.warn(`memory: recall failed: ${String(err)}`);
api.logger.warn(`memory-lancedb: recall failed: ${String(err)}`);
}
});
}
@@ -559,10 +561,10 @@ const memoryPlugin = {
}
if (stored > 0) {
api.logger.info(`memory: auto-captured ${stored} memories`);
api.logger.info(`memory-lancedb: auto-captured ${stored} memories`);
}
} catch (err) {
api.logger.warn(`memory: capture failed: ${String(err)}`);
api.logger.warn(`memory-lancedb: capture failed: ${String(err)}`);
}
});
}
@@ -572,14 +574,14 @@ const memoryPlugin = {
// ========================================================================
api.registerService({
id: "memory",
id: "memory-lancedb",
start: () => {
api.logger.info(
`memory: initialized (db: ${resolvedDbPath}, model: ${cfg.embedding.model})`,
`memory-lancedb: initialized (db: ${resolvedDbPath}, model: ${cfg.embedding.model})`,
);
},
stop: () => {
api.logger.info("memory: stopped");
api.logger.info("memory-lancedb: stopped");
},
});
},

View File

@@ -1,14 +1,16 @@
{
"name": "@clawdbot/memory",
"name": "@clawdbot/memory-lancedb",
"version": "0.0.1",
"type": "module",
"description": "Clawdbot long-term memory plugin with vector search and seamless auto-recall/capture",
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
"dependencies": {
"@sinclair/typebox": "0.34.47",
"@lancedb/lancedb": "^0.15.0",
"openai": "^4.77.0"
"@sinclair/typebox": "0.34.47",
"openai": "^4.104.0"
},
"clawdbot": {
"extensions": ["./index.ts"]
"extensions": [
"./index.ts"
]
}
}

View File

@@ -9,10 +9,10 @@
]
},
"dependencies": {
"@microsoft/agents-hosting": "^1.2.2",
"@microsoft/agents-hosting-express": "^1.2.2",
"@microsoft/agents-hosting-extensions-teams": "^1.2.2",
"clawdbot": "workspace:*",
"@microsoft/agents-hosting": "^1.1.1",
"@microsoft/agents-hosting-express": "^1.1.1",
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
"express": "^5.2.1",
"proper-lockfile": "^4.1.2"
}

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { TwilioProvider } from "./twilio.js";
const mockTwilioConfig = {
accountSid: "AC00000000000000000000000000000000",
authToken: "test-token",
};
const callId = "internal-call-id";
const twimlPayload =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><Response><Say>Hi</Say></Response>";
describe("TwilioProvider", () => {
it("serves stored TwiML on twiml requests without emitting events", async () => {
const provider = new TwilioProvider(mockTwilioConfig);
(provider as unknown as { apiRequest: (endpoint: string, params: Record<string, string>) => Promise<unknown> }).apiRequest =
async () => ({
sid: "CA00000000000000000000000000000000",
status: "queued",
direction: "outbound-api",
from: "+15550000000",
to: "+15550000001",
uri: "/Calls/CA00000000000000000000000000000000.json",
});
await provider.initiateCall({
callId,
to: "+15550000000",
from: "+15550000001",
webhookUrl: "https://example.com/voice/webhook?provider=twilio",
inlineTwiml: twimlPayload,
});
const result = provider.parseWebhookEvent({
headers: { host: "example.com" },
rawBody:
"CallSid=CA00000000000000000000000000000000&CallStatus=initiated&Direction=outbound-api",
url: `https://example.com/voice/webhook?provider=twilio&callId=${callId}&type=twiml`,
method: "POST",
query: { provider: "twilio", callId, type: "twiml" },
});
expect(result.events).toHaveLength(0);
expect(result.providerResponseBody).toBe(twimlPayload);
});
it("does not consume stored TwiML on status callbacks", async () => {
const provider = new TwilioProvider(mockTwilioConfig);
(provider as unknown as { apiRequest: (endpoint: string, params: Record<string, string>) => Promise<unknown> }).apiRequest =
async () => ({
sid: "CA00000000000000000000000000000000",
status: "queued",
direction: "outbound-api",
from: "+15550000000",
to: "+15550000001",
uri: "/Calls/CA00000000000000000000000000000000.json",
});
await provider.initiateCall({
callId,
to: "+15550000000",
from: "+15550000001",
webhookUrl: "https://example.com/voice/webhook?provider=twilio",
inlineTwiml: twimlPayload,
});
const statusResult = provider.parseWebhookEvent({
headers: { host: "example.com" },
rawBody:
"CallSid=CA00000000000000000000000000000000&CallStatus=initiated&Direction=outbound-api&From=%2B15550000000&To=%2B15550000001",
url: `https://example.com/voice/webhook?provider=twilio&callId=${callId}&type=status`,
method: "POST",
query: { provider: "twilio", callId, type: "status" },
});
expect(statusResult.events).toHaveLength(1);
expect(statusResult.events[0]?.type).toBe("call.initiated");
expect(statusResult.providerResponseBody).not.toBe(twimlPayload);
const twimlResult = provider.parseWebhookEvent({
headers: { host: "example.com" },
rawBody:
"CallSid=CA00000000000000000000000000000000&CallStatus=initiated&Direction=outbound-api",
url: `https://example.com/voice/webhook?provider=twilio&callId=${callId}&type=twiml`,
method: "POST",
query: { provider: "twilio", callId, type: "twiml" },
});
expect(twimlResult.providerResponseBody).toBe(twimlPayload);
});
});

View File

@@ -62,6 +62,37 @@ export class TwilioProvider implements VoiceCallProvider {
/** Map of call SID to stream SID for media streams */
private callStreamMap = new Map<string, string>();
/** Storage for TwiML content (for notify mode with URL-based TwiML) */
private readonly twimlStorage = new Map<string, string>();
/**
* Delete stored TwiML for a given `callId`.
*
* We keep TwiML in-memory only long enough to satisfy the initial Twilio
* webhook request (notify mode). Subsequent webhooks should not reuse it.
*/
private deleteStoredTwiml(callId: string): void {
this.twimlStorage.delete(callId);
}
/**
* Delete stored TwiML for a call, addressed by Twilio's provider call SID.
*
* This is used when we only have `providerCallId` (e.g. hangup).
*/
private deleteStoredTwimlForProviderCall(providerCallId: string): void {
const webhookUrl = this.callWebhookUrls.get(providerCallId);
if (!webhookUrl) return;
try {
const callId = new URL(webhookUrl).searchParams.get("callId");
if (!callId) return;
this.deleteStoredTwiml(callId);
} catch {
// Ignore malformed URLs; best-effort cleanup only.
}
}
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
if (!config.accountSid) {
throw new Error("Twilio Account SID is required");
@@ -149,7 +180,14 @@ export class TwilioProvider implements VoiceCallProvider {
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
? ctx.query.callId.trim()
: undefined;
const event = this.normalizeEvent(params, callIdFromQuery);
const requestType =
typeof ctx.query?.type === "string" && ctx.query.type.trim()
? ctx.query.type.trim()
: undefined;
const isTwimlRequest = requestType === "twiml";
const event = isTwimlRequest
? null
: this.normalizeEvent(params, callIdFromQuery);
// For Twilio, we must return TwiML. Most actions are driven by Calls API updates,
// so the webhook response is typically a pause to keep the call alive.
@@ -228,8 +266,14 @@ export class TwilioProvider implements VoiceCallProvider {
case "busy":
case "no-answer":
case "failed":
if (callIdOverride) {
this.deleteStoredTwiml(callIdOverride);
}
return { ...baseEvent, type: "call.ended", reason: callStatus };
case "canceled":
if (callIdOverride) {
this.deleteStoredTwiml(callIdOverride);
}
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
default:
return null;
@@ -254,11 +298,29 @@ export class TwilioProvider implements VoiceCallProvider {
const params = new URLSearchParams(ctx.rawBody);
const callStatus = params.get("CallStatus");
const direction = params.get("Direction");
const callIdFromQuery =
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
? ctx.query.callId.trim()
: undefined;
const requestType =
typeof ctx.query?.type === "string" && ctx.query.type.trim()
? ctx.query.type.trim()
: undefined;
console.log(
`[voice-call] generateTwimlResponse: status=${callStatus} direction=${direction}`,
);
// Avoid logging webhook params/TwiML (may contain PII).
// Handle initial TwiML request (when Twilio first initiates the call)
// Check if we have stored TwiML for this call (notify mode)
if (callIdFromQuery && requestType !== "status") {
const storedTwiml = this.twimlStorage.get(callIdFromQuery);
if (storedTwiml) {
// Clean up after serving (one-time use)
this.deleteStoredTwiml(callIdFromQuery);
return storedTwiml;
}
}
// Handle subsequent webhook requests (status callbacks, etc.)
// For inbound calls, answer immediately with stream
if (direction === "inbound") {
const streamUrl = this.getStreamUrl();
@@ -325,31 +387,39 @@ export class TwilioProvider implements VoiceCallProvider {
* Otherwise, uses webhook URL for dynamic TwiML.
*/
async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
const url = new URL(input.webhookUrl);
url.searchParams.set("callId", input.callId);
const webhookUrl = new URL(input.webhookUrl);
webhookUrl.searchParams.set("callId", input.callId);
// Build request params
const twimlUrl = new URL(webhookUrl);
twimlUrl.searchParams.set("type", "twiml");
// Create separate URL for status callbacks (required by Twilio)
const statusUrl = new URL(webhookUrl);
statusUrl.searchParams.set("type", "status"); // Differentiate from TwiML requests
// Store TwiML content if provided (for notify mode)
// We now serve it from the webhook endpoint instead of sending inline
if (input.inlineTwiml) {
this.twimlStorage.set(input.callId, input.inlineTwiml);
}
// Build request params - always use URL-based TwiML.
// Twilio silently ignores `StatusCallback` when using the inline `Twiml` parameter.
const params: Record<string, string> = {
To: input.to,
From: input.from,
StatusCallback: url.toString(),
Url: twimlUrl.toString(), // TwiML serving endpoint
StatusCallback: statusUrl.toString(), // Separate status callback endpoint
StatusCallbackEvent: "initiated ringing answered completed",
Timeout: "30",
};
// Use inline TwiML for notify mode (simpler, no webhook needed)
if (input.inlineTwiml) {
params.Twiml = input.inlineTwiml;
} else {
params.Url = url.toString();
}
const result = await this.apiRequest<TwilioCallResponse>(
"/Calls.json",
params,
);
this.callWebhookUrls.set(result.sid, url.toString());
this.callWebhookUrls.set(result.sid, webhookUrl.toString());
return {
providerCallId: result.sid,
@@ -361,6 +431,8 @@ export class TwilioProvider implements VoiceCallProvider {
* Hang up a call via Twilio API.
*/
async hangupCall(input: HangupCallInput): Promise<void> {
this.deleteStoredTwimlForProviderCall(input.providerCallId);
this.callWebhookUrls.delete(input.providerCallId);
await this.apiRequest(

View File

@@ -168,7 +168,7 @@
"dotenv": "^17.2.3",
"express": "^5.2.1",
"file-type": "^21.3.0",
"grammy": "^1.39.2",
"grammy": "^1.39.3",
"hono": "4.11.4",
"jiti": "^2.6.1",
"json5": "^2.2.3",
@@ -182,7 +182,7 @@
"qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5",
"sqlite-vec": "0.1.7-alpha.2",
"tar": "^7.5.3",
"tar": "7.5.3",
"tslog": "^4.10.2",
"undici": "^7.18.2",
"ws": "^8.19.0",
@@ -200,24 +200,24 @@
"@types/body-parser": "^1.19.6",
"@types/express": "^5.0.6",
"@types/markdown-it": "^14.1.2",
"@types/node": "^25.0.6",
"@types/node": "^25.0.9",
"@types/proper-lockfile": "^4.1.4",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.0.16",
"@vitest/coverage-v8": "^4.0.17",
"docx-preview": "^0.3.7",
"lit": "^3.3.2",
"lucide": "^0.562.0",
"ollama": "^0.6.3",
"oxfmt": "0.24.0",
"oxlint": "^1.39.0",
"oxlint-tsgolint": "^0.11.0",
"oxlint-tsgolint": "^0.11.1",
"quicktype-core": "^23.2.6",
"rolldown": "1.0.0-beta.59",
"signal-utils": "^0.21.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.0.16",
"vitest": "^4.0.17",
"wireit": "^0.14.12"
},
"pnpm": {

1435
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -50,15 +50,15 @@ import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
const DEFAULT_MAX_OUTPUT = clampNumber(
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
30_000,
200_000,
1_000,
150_000,
200_000,
);
const DEFAULT_PENDING_MAX_OUTPUT = clampNumber(
readEnvInt("CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS"),
30_000,
200_000,
1_000,
150_000,
200_000,
);
const DEFAULT_PATH =
process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";

View File

@@ -6,6 +6,7 @@ import { shouldLogVerbose } from "../globals.js";
import { createSubsystemLogger } from "../logging.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import { resolveClawdbotDocsPath } from "./docs-path.js";
import { resolveSessionAgentIds } from "./agent-scope.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js";
import { resolveCliBackendConfig } from "./cli-backends.js";
@@ -83,6 +84,12 @@ export async function runCliAgent(params: {
sessionAgentId === defaultAgentId
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined;
const docsPath = await resolveClawdbotDocsPath({
workspaceDir,
argv1: process.argv[1],
cwd: process.cwd(),
moduleUrl: import.meta.url,
});
const systemPrompt = buildSystemPrompt({
workspaceDir,
config: params.config,
@@ -90,6 +97,7 @@ export async function runCliAgent(params: {
extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
heartbeatPrompt,
docsPath: docsPath ?? undefined,
tools: [],
contextFiles,
modelDisplay,

View File

@@ -168,6 +168,7 @@ export function buildSystemPrompt(params: {
extraSystemPrompt?: string;
ownerNumbers?: string[];
heartbeatPrompt?: string;
docsPath?: string;
tools: AgentTool[];
contextFiles?: EmbeddedContextFile[];
modelDisplay: string;
@@ -182,6 +183,7 @@ export function buildSystemPrompt(params: {
ownerNumbers: params.ownerNumbers,
reasoningTagHint: false,
heartbeatPrompt: params.heartbeatPrompt,
docsPath: params.docsPath,
runtimeInfo: {
host: "clawdbot",
os: `${os.type()} ${os.release()}`,

27
src/agents/docs-path.ts Normal file
View File

@@ -0,0 +1,27 @@
import fs from "node:fs";
import path from "node:path";
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
export async function resolveClawdbotDocsPath(params: {
workspaceDir?: string;
argv1?: string;
cwd?: string;
moduleUrl?: string;
}): Promise<string | null> {
const workspaceDir = params.workspaceDir?.trim();
if (workspaceDir) {
const workspaceDocs = path.join(workspaceDir, "docs");
if (fs.existsSync(workspaceDocs)) return workspaceDocs;
}
const packageRoot = await resolveClawdbotPackageRoot({
cwd: params.cwd,
argv1: params.argv1,
moduleUrl: params.moduleUrl,
});
if (!packageRoot) return null;
const packageDocs = path.join(packageRoot, "docs");
return fs.existsSync(packageDocs) ? packageDocs : null;
}

View File

@@ -26,6 +26,11 @@ describe("classifyFailoverReason", () => {
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
expect(classifyFailoverReason("string should match pattern")).toBe("format");
expect(classifyFailoverReason("bad request")).toBeNull();
expect(
classifyFailoverReason(
"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels",
),
).toBeNull();
});
it("classifies OpenAI usage limit errors as rate_limit", () => {
expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe(

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { isImageDimensionErrorMessage, parseImageDimensionError } from "./pi-embedded-helpers.js";
describe("image dimension errors", () => {
it("parses anthropic image dimension errors", () => {
const raw =
"400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels\"}}";
const parsed = parseImageDimensionError(raw);
expect(parsed).not.toBeNull();
expect(parsed?.maxDimensionPx).toBe(2000);
expect(parsed?.messageIndex).toBe(84);
expect(parsed?.contentIndex).toBe(1);
expect(isImageDimensionErrorMessage(raw)).toBe(true);
});
});

View File

@@ -23,5 +23,10 @@ describe("isCloudCodeAssistFormatError", () => {
});
it("ignores unrelated errors", () => {
expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false);
expect(
isCloudCodeAssistFormatError(
"400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels\"}}",
),
).toBe(false);
});
});

View File

@@ -21,11 +21,13 @@ export {
isContextOverflowError,
isFailoverAssistantError,
isFailoverErrorMessage,
isImageDimensionErrorMessage,
isOverloadedErrorMessage,
isRawApiErrorPayload,
isRateLimitAssistantError,
isRateLimitErrorMessage,
isTimeoutErrorMessage,
parseImageDimensionError,
} from "./pi-embedded-helpers/errors.js";
export {
downgradeGeminiHistory,

View File

@@ -339,7 +339,6 @@ const ERROR_PATTERNS = {
"no api key found",
],
format: [
"invalid_request_error",
"string should match pattern",
"tool_use.id",
"tool_use_id",
@@ -348,6 +347,10 @@ const ERROR_PATTERNS = {
],
} as const;
const IMAGE_DIMENSION_ERROR_RE =
/image dimensions exceed max allowed size for many-image requests:\s*(\d+)\s*pixels/i;
const IMAGE_DIMENSION_PATH_RE = /messages\.(\d+)\.content\.(\d+)\.image/i;
function matchesErrorPatterns(raw: string, patterns: readonly ErrorPattern[]): boolean {
if (!raw) return false;
const value = raw.toLowerCase();
@@ -390,8 +393,31 @@ export function isOverloadedErrorMessage(raw: string): boolean {
return matchesErrorPatterns(raw, ERROR_PATTERNS.overloaded);
}
export function parseImageDimensionError(raw: string): {
maxDimensionPx?: number;
messageIndex?: number;
contentIndex?: number;
raw: string;
} | null {
if (!raw) return null;
const lower = raw.toLowerCase();
if (!lower.includes("image dimensions exceed max allowed size")) return null;
const limitMatch = raw.match(IMAGE_DIMENSION_ERROR_RE);
const pathMatch = raw.match(IMAGE_DIMENSION_PATH_RE);
return {
maxDimensionPx: limitMatch?.[1] ? Number.parseInt(limitMatch[1], 10) : undefined,
messageIndex: pathMatch?.[1] ? Number.parseInt(pathMatch[1], 10) : undefined,
contentIndex: pathMatch?.[2] ? Number.parseInt(pathMatch[2], 10) : undefined,
raw,
};
}
export function isImageDimensionErrorMessage(raw: string): boolean {
return Boolean(parseImageDimensionError(raw));
}
export function isCloudCodeAssistFormatError(raw: string): boolean {
return matchesErrorPatterns(raw, ERROR_PATTERNS.format);
return !isImageDimensionErrorMessage(raw) && matchesErrorPatterns(raw, ERROR_PATTERNS.format);
}
export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean {
@@ -400,6 +426,7 @@ export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean
}
export function classifyFailoverReason(raw: string): FailoverReason | null {
if (isImageDimensionErrorMessage(raw)) return null;
if (isRateLimitErrorMessage(raw)) return "rate_limit";
if (isOverloadedErrorMessage(raw)) return "rate_limit";
if (isCloudCodeAssistFormatError(raw)) return "format";

View File

@@ -17,6 +17,7 @@ import { resolveUserPath } from "../../utils.js";
import { resolveClawdbotAgentDir } from "../agent-paths.js";
import { resolveSessionAgentIds } from "../agent-scope.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js";
import { resolveClawdbotDocsPath } from "../docs-path.js";
import type { ExecElevatedDefaults } from "../bash-tools.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js";
@@ -250,6 +251,12 @@ export async function compactEmbeddedPiSession(params: {
});
const isDefaultAgent = sessionAgentId === defaultAgentId;
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
const docsPath = await resolveClawdbotDocsPath({
workspaceDir: effectiveWorkspace,
argv1: process.argv[1],
cwd: process.cwd(),
moduleUrl: import.meta.url,
});
const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
defaultThinkLevel: params.thinkLevel,
@@ -261,6 +268,7 @@ export async function compactEmbeddedPiSession(params: {
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined,
skillsPrompt,
docsPath: docsPath ?? undefined,
promptMode,
runtimeInfo,
sandboxInfo,

View File

@@ -31,6 +31,7 @@ import {
isContextOverflowError,
isFailoverAssistantError,
isFailoverErrorMessage,
parseImageDimensionError,
isRateLimitAssistantError,
isTimeoutErrorMessage,
pickFallbackThinkingLevel,
@@ -357,6 +358,26 @@ export async function runEmbeddedPiAgent(
const failoverFailure = isFailoverAssistantError(lastAssistant);
const assistantFailoverReason = classifyFailoverReason(lastAssistant?.errorMessage ?? "");
const cloudCodeAssistFormatError = attempt.cloudCodeAssistFormatError;
const imageDimensionError = parseImageDimensionError(lastAssistant?.errorMessage ?? "");
if (imageDimensionError && lastProfileId) {
const details = [
imageDimensionError.messageIndex !== undefined
? `message=${imageDimensionError.messageIndex}`
: null,
imageDimensionError.contentIndex !== undefined
? `content=${imageDimensionError.contentIndex}`
: null,
imageDimensionError.maxDimensionPx !== undefined
? `limit=${imageDimensionError.maxDimensionPx}px`
: null,
]
.filter(Boolean)
.join(" ");
log.warn(
`Profile ${lastProfileId} rejected image payload${details ? ` (${details})` : ""}.`,
);
}
// Treat timeout as potential rate limit (Antigravity hangs on rate limit)
const shouldRotate = (!aborted && failoverFailure) || timedOut;
@@ -432,7 +453,6 @@ export async function runEmbeddedPiAgent(
toolMetas: attempt.toolMetas,
lastAssistant: attempt.lastAssistant,
lastToolError: attempt.lastToolError,
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
config: params.config,
sessionKey: params.sessionKey ?? params.sessionId,
verboseLevel: params.verboseLevel,

View File

@@ -18,6 +18,7 @@ import { resolveUserPath } from "../../../utils.js";
import { resolveClawdbotAgentDir } from "../../agent-paths.js";
import { resolveSessionAgentIds } from "../../agent-scope.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
import { resolveClawdbotDocsPath } from "../../docs-path.js";
import { resolveModelAuthMode } from "../../model-auth.js";
import {
isCloudCodeAssistFormatError,
@@ -216,6 +217,12 @@ export async function runEmbeddedAttempt(
});
const isDefaultAgent = sessionAgentId === defaultAgentId;
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
const docsPath = await resolveClawdbotDocsPath({
workspaceDir: effectiveWorkspace,
argv1: process.argv[1],
cwd: process.cwd(),
moduleUrl: import.meta.url,
});
const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
@@ -228,6 +235,7 @@ export async function runEmbeddedAttempt(
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined,
skillsPrompt,
docsPath: docsPath ?? undefined,
reactionGuidance,
promptMode,
runtimeInfo,

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
import type { ImageContent } from "@mariozechner/pi-ai";
import { assertSandboxPath } from "../../sandbox-paths.js";
import { sanitizeImageBlocks } from "../../tool-images.js";
import { extractTextFromMessage } from "../../../tui/tui-formatters.js";
import { loadWebMedia } from "../../../web/media.js";
import { resolveUserPath } from "../../../utils.js";
@@ -48,6 +49,17 @@ function isImageExtension(filePath: string): boolean {
return IMAGE_EXTENSIONS.has(ext);
}
async function sanitizeImagesWithLog(
images: ImageContent[],
label: string,
): Promise<ImageContent[]> {
const { images: sanitized, dropped } = await sanitizeImageBlocks(images, label);
if (dropped > 0) {
log.warn(`Native image: dropped ${dropped} image(s) after sanitization (${label}).`);
}
return sanitized;
}
/**
* Detects image references in a user prompt.
*
@@ -392,9 +404,18 @@ export async function detectAndLoadPromptImages(params: {
}
}
const sanitizedPromptImages = await sanitizeImagesWithLog(promptImages, "prompt:images");
const sanitizedHistoryImagesByIndex = new Map<number, ImageContent[]>();
for (const [index, images] of historyImagesByIndex) {
const sanitized = await sanitizeImagesWithLog(images, `history:images:${index}`);
if (sanitized.length > 0) {
sanitizedHistoryImagesByIndex.set(index, sanitized);
}
}
return {
images: promptImages,
historyImagesByIndex,
images: sanitizedPromptImages,
historyImagesByIndex: sanitizedHistoryImagesByIndex,
detectedRefs: allRefs,
loadedCount,
skippedCount,

View File

@@ -147,40 +147,4 @@ describe("buildEmbeddedRunPayloads", () => {
expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe("All good");
});
it("adds tool error fallback when assistant output is NO_REPLY", () => {
const payloads = buildEmbeddedRunPayloads({
assistantTexts: ["NO_REPLY"],
toolMetas: [],
lastAssistant: { stopReason: "end_turn" } as AssistantMessage,
lastToolError: { toolName: "browser", error: "tab not found" },
sessionKey: "session:telegram",
inlineToolResultsAllowed: false,
verboseLevel: "off",
reasoningLevel: "off",
toolResultFormat: "plain",
});
expect(payloads).toHaveLength(1);
expect(payloads[0]?.isError).toBe(true);
expect(payloads[0]?.text).toContain("browser");
expect(payloads[0]?.text).toContain("tab not found");
});
it("skips tool error fallback when messaging tool already sent", () => {
const payloads = buildEmbeddedRunPayloads({
assistantTexts: [],
toolMetas: [],
lastAssistant: undefined,
lastToolError: { toolName: "browser", error: "tab not found" },
didSendViaMessagingTool: true,
sessionKey: "session:telegram",
inlineToolResultsAllowed: false,
verboseLevel: "off",
reasoningLevel: "off",
toolResultFormat: "plain",
});
expect(payloads).toHaveLength(0);
});
});

View File

@@ -24,7 +24,6 @@ export function buildEmbeddedRunPayloads(params: {
toolMetas: ToolMetaEntry[];
lastAssistant: AssistantMessage | undefined;
lastToolError?: { toolName: string; meta?: string; error?: string };
didSendViaMessagingTool?: boolean;
config?: ClawdbotConfig;
sessionKey: string;
verboseLevel?: VerboseLevel;
@@ -157,46 +156,34 @@ export function buildEmbeddedRunPayloads(params: {
});
}
const buildPayloads = (items: typeof replyItems) => {
const hasAudioAsVoiceTag = items.some((item) => item.audioAsVoice);
return items
.map((item) => ({
text: item.text?.trim() ? item.text.trim() : undefined,
mediaUrls: item.media?.length ? item.media : undefined,
mediaUrl: item.media?.[0],
isError: item.isError,
replyToId: item.replyToId,
replyToTag: item.replyToTag,
replyToCurrent: item.replyToCurrent,
audioAsVoice: item.audioAsVoice || Boolean(hasAudioAsVoiceTag && item.media?.length),
}))
.filter((p) => {
if (!p.text && !p.mediaUrl && (!p.mediaUrls || p.mediaUrls.length === 0)) return false;
if (p.text && isSilentReplyText(p.text, SILENT_REPLY_TOKEN)) return false;
return true;
});
};
let payloads = buildPayloads(replyItems);
if (
payloads.length === 0 &&
params.lastToolError &&
params.didSendViaMessagingTool !== true
) {
if (replyItems.length === 0 && params.lastToolError) {
const toolSummary = formatToolAggregate(
params.lastToolError.toolName,
params.lastToolError.meta ? [params.lastToolError.meta] : undefined,
{ markdown: useMarkdown },
);
const errorSuffix = params.lastToolError.error ? `: ${params.lastToolError.error}` : "";
payloads = buildPayloads([
{
text: `⚠️ ${toolSummary} failed${errorSuffix}`,
isError: true,
},
]);
replyItems.push({
text: `⚠️ ${toolSummary} failed${errorSuffix}`,
isError: true,
});
}
return payloads;
const hasAudioAsVoiceTag = replyItems.some((item) => item.audioAsVoice);
return replyItems
.map((item) => ({
text: item.text?.trim() ? item.text.trim() : undefined,
mediaUrls: item.media?.length ? item.media : undefined,
mediaUrl: item.media?.[0],
isError: item.isError,
replyToId: item.replyToId,
replyToTag: item.replyToTag,
replyToCurrent: item.replyToCurrent,
audioAsVoice: item.audioAsVoice || Boolean(hasAudioAsVoiceTag && item.media?.length),
}))
.filter((p) => {
if (!p.text && !p.mediaUrl && (!p.mediaUrls || p.mediaUrls.length === 0)) return false;
if (p.text && isSilentReplyText(p.text, SILENT_REPLY_TOKEN)) return false;
return true;
});
}

View File

@@ -15,6 +15,7 @@ export function buildEmbeddedSystemPrompt(params: {
reasoningTagHint: boolean;
heartbeatPrompt?: string;
skillsPrompt?: string;
docsPath?: string;
reactionGuidance?: {
level: "minimal" | "extensive";
channel: string;
@@ -48,6 +49,7 @@ export function buildEmbeddedSystemPrompt(params: {
reasoningTagHint: params.reasoningTagHint,
heartbeatPrompt: params.heartbeatPrompt,
skillsPrompt: params.skillsPrompt,
docsPath: params.docsPath,
reactionGuidance: params.reactionGuidance,
promptMode: params.promptMode,
runtimeInfo: params.runtimeInfo,

View File

@@ -32,12 +32,14 @@ describe("buildAgentSystemPrompt", () => {
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
heartbeatPrompt: "ping",
toolNames: ["message", "memory_search"],
docsPath: "/tmp/clawd/docs",
extraSystemPrompt: "Subagent details",
});
expect(prompt).not.toContain("## User Identity");
expect(prompt).not.toContain("## Skills");
expect(prompt).not.toContain("## Memory Recall");
expect(prompt).not.toContain("## Documentation");
expect(prompt).not.toContain("## Reply Tags");
expect(prompt).not.toContain("## Messaging");
expect(prompt).not.toContain("## Silent Replies");
@@ -86,6 +88,7 @@ describe("buildAgentSystemPrompt", () => {
toolNames: ["Read", "Exec", "process"],
skillsPrompt:
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
docsPath: "/tmp/clawd/docs",
});
expect(prompt).toContain("- Read: Read file contents");
@@ -93,6 +96,20 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain(
"Use `Read` to load the SKILL.md at the location listed for that skill.",
);
expect(prompt).toContain("Clawdbot docs: /tmp/clawd/docs");
});
it("includes docs guidance when docsPath is provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
docsPath: "/tmp/clawd/docs",
});
expect(prompt).toContain("## Documentation");
expect(prompt).toContain("Clawdbot docs: /tmp/clawd/docs");
expect(prompt).toContain(
"For Clawdbot behavior, commands, config, or architecture: consult local docs first.",
);
});
it("includes user time when provided (12-hour)", () => {

View File

@@ -109,6 +109,26 @@ function buildMessagingSection(params: {
];
}
function buildDocsSection(params: {
docsPath?: string;
isMinimal: boolean;
readToolName: string;
}) {
const docsPath = params.docsPath?.trim();
if (!docsPath || params.isMinimal) return [];
return [
"## Documentation",
`Clawdbot docs: ${docsPath}`,
"Mirror: https://docs.clawd.bot",
"Source: https://github.com/clawdbot/clawdbot",
"Community: https://discord.com/invite/clawd",
"Find new skills: https://clawdhub.com",
"For Clawdbot behavior, commands, config, or architecture: consult local docs first.",
"When diagnosing issues, run `clawdbot status` yourself when possible; only ask the user if you lack access (e.g., sandboxed).",
"",
];
}
export function buildAgentSystemPrompt(params: {
workspaceDir: string;
defaultThinkLevel?: ThinkLevel;
@@ -125,6 +145,7 @@ export function buildAgentSystemPrompt(params: {
contextFiles?: EmbeddedContextFile[];
skillsPrompt?: string;
heartbeatPrompt?: string;
docsPath?: string;
/** Controls which hardcoded sections to include. Defaults to "full". */
promptMode?: PromptMode;
runtimeInfo?: {
@@ -295,6 +316,11 @@ export function buildAgentSystemPrompt(params: {
readToolName,
});
const memorySection = buildMemorySection({ isMinimal, availableTools });
const docsSection = buildDocsSection({
docsPath: params.docsPath,
isMinimal,
readToolName,
});
// For "none" mode, return just the basic identity line
if (promptMode === "none") {
@@ -371,6 +397,7 @@ export function buildAgentSystemPrompt(params: {
`Your working directory is: ${params.workspaceDir}`,
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
"",
...docsSection,
params.sandboxInfo?.enabled ? "## Sandbox" : "",
params.sandboxInfo?.enabled
? [

View File

@@ -1,7 +1,7 @@
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { sanitizeContentBlocksImages } from "./tool-images.js";
import { sanitizeContentBlocksImages, sanitizeImageBlocks } from "./tool-images.js";
describe("tool image sanitizing", () => {
it("shrinks oversized images to <=5MB", async () => {
@@ -33,6 +33,56 @@ describe("tool image sanitizing", () => {
expect(image.mimeType).toBe("image/jpeg");
}, 20_000);
it("sanitizes image arrays and reports drops", async () => {
const width = 2600;
const height = 400;
const raw = Buffer.alloc(width * height * 3, 0x7f);
const png = await sharp(raw, {
raw: { width, height, channels: 3 },
})
.png({ compressionLevel: 9 })
.toBuffer();
const images = [
{ type: "image" as const, data: png.toString("base64"), mimeType: "image/png" },
];
const { images: out, dropped } = await sanitizeImageBlocks(images, "test");
expect(dropped).toBe(0);
expect(out.length).toBe(1);
const meta = await sharp(Buffer.from(out[0].data, "base64")).metadata();
expect(meta.width).toBeLessThanOrEqual(2000);
expect(meta.height).toBeLessThanOrEqual(2000);
}, 20_000);
it("shrinks images that exceed max dimension even if size is small", async () => {
const width = 2600;
const height = 400;
const raw = Buffer.alloc(width * height * 3, 0x7f);
const png = await sharp(raw, {
raw: { width, height, channels: 3 },
})
.png({ compressionLevel: 9 })
.toBuffer();
const blocks = [
{
type: "image" as const,
data: png.toString("base64"),
mimeType: "image/png",
},
];
const out = await sanitizeContentBlocksImages(blocks, "test");
const image = out.find((b) => b.type === "image");
if (!image || image.type !== "image") {
throw new Error("expected image block");
}
const meta = await sharp(Buffer.from(image.data, "base64")).metadata();
expect(meta.width).toBeLessThanOrEqual(2000);
expect(meta.height).toBeLessThanOrEqual(2000);
expect(image.mimeType).toBe("image/jpeg");
}, 20_000);
it("corrects mismatched jpeg mimeType", async () => {
const jpeg = await sharp({
create: {

View File

@@ -1,5 +1,7 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ImageContent } from "@mariozechner/pi-ai";
import { createSubsystemLogger } from "../logging.js";
import { getImageMetadata, resizeToJpeg } from "../media/image-ops.js";
type ToolContentBlock = AgentToolResult<unknown>["content"][number];
@@ -14,6 +16,7 @@ type TextContentBlock = Extract<ToolContentBlock, { type: "text" }>;
// and recompress base64 image blocks when they exceed these limits.
const MAX_IMAGE_DIMENSION_PX = 2000;
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const log = createSubsystemLogger("agents/tool-images");
function isImageBlock(block: unknown): block is ImageContentBlock {
if (!block || typeof block !== "object") return false;
@@ -41,26 +44,41 @@ async function resizeImageBase64IfNeeded(params: {
mimeType: string;
maxDimensionPx: number;
maxBytes: number;
}): Promise<{ base64: string; mimeType: string; resized: boolean }> {
label?: string;
}): Promise<{
base64: string;
mimeType: string;
resized: boolean;
width?: number;
height?: number;
}> {
const buf = Buffer.from(params.base64, "base64");
const meta = await getImageMetadata(buf);
const width = meta?.width;
const height = meta?.height;
const overBytes = buf.byteLength > params.maxBytes;
const maxDim = Math.max(width ?? 0, height ?? 0);
if (typeof width !== "number" || typeof height !== "number") {
if (!overBytes) {
return {
base64: params.base64,
mimeType: params.mimeType,
resized: false,
};
}
} else if (!overBytes && width <= params.maxDimensionPx && height <= params.maxDimensionPx) {
return { base64: params.base64, mimeType: params.mimeType, resized: false };
const hasDimensions = typeof width === "number" && typeof height === "number";
if (hasDimensions && !overBytes && width <= params.maxDimensionPx && height <= params.maxDimensionPx) {
return {
base64: params.base64,
mimeType: params.mimeType,
resized: false,
width,
height,
};
}
if (hasDimensions && (width > params.maxDimensionPx || height > params.maxDimensionPx || overBytes)) {
log.warn("Image exceeds limits; resizing", {
label: params.label,
width,
height,
maxDimensionPx: params.maxDimensionPx,
maxBytes: params.maxBytes,
});
}
const qualities = [85, 75, 65, 55, 45, 35];
const maxDim = hasDimensions ? Math.max(width ?? 0, height ?? 0) : params.maxDimensionPx;
const sideStart = maxDim > 0 ? Math.min(params.maxDimensionPx, maxDim) : params.maxDimensionPx;
const sideGrid = [sideStart, 1800, 1600, 1400, 1200, 1000, 800]
.map((v) => Math.min(params.maxDimensionPx, v))
@@ -80,10 +98,23 @@ async function resizeImageBase64IfNeeded(params: {
smallest = { buffer: out, size: out.byteLength };
}
if (out.byteLength <= params.maxBytes) {
log.info("Image resized", {
label: params.label,
width,
height,
maxDimensionPx: params.maxDimensionPx,
maxBytes: params.maxBytes,
originalBytes: buf.byteLength,
resizedBytes: out.byteLength,
quality,
side,
});
return {
base64: out.toString("base64"),
mimeType: "image/jpeg",
resized: true,
width,
height,
};
}
}
@@ -127,6 +158,7 @@ export async function sanitizeContentBlocksImages(
mimeType,
maxDimensionPx,
maxBytes,
label,
});
out.push({
...block,
@@ -144,6 +176,17 @@ export async function sanitizeContentBlocksImages(
return out;
}
export async function sanitizeImageBlocks(
images: ImageContent[],
label: string,
opts: { maxDimensionPx?: number; maxBytes?: number } = {},
): Promise<{ images: ImageContent[]; dropped: number }> {
if (images.length === 0) return { images, dropped: 0 };
const sanitized = await sanitizeContentBlocksImages(images as ToolContentBlock[], label, opts);
const next = sanitized.filter(isImageBlock) as ImageContent[];
return { images: next, dropped: Math.max(0, images.length - next.length) };
}
export async function sanitizeToolResultImages(
result: AgentToolResult<unknown>,
label: string,

View File

@@ -4,6 +4,9 @@ export type NodeListNode = {
nodeId: string;
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
remoteIp?: string;
deviceFamily?: string;
modelIdentifier?: string;
@@ -20,6 +23,8 @@ type PendingRequest = {
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
remoteIp?: string;
isRepair?: boolean;
ts: number;
@@ -31,6 +36,8 @@ type PairedNode = {
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
remoteIp?: string;
permissions?: Record<string, boolean>;
createdAtMs?: number;

View File

@@ -189,7 +189,7 @@ export async function handleBashChatCommand(params: {
}): Promise<ReplyPayload> {
if (params.cfg.commands?.bash !== true) {
return {
text: "⚠️ bash is disabled. Set commands.bash=true to enable.",
text: "⚠️ bash is disabled. Set commands.bash=true to enable. Docs: https://docs.clawd.bot/tools/slash-commands#config",
};
}

View File

@@ -0,0 +1,87 @@
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
const callGatewayFromCli = vi.fn(
async (method: string, _opts: unknown, params?: unknown) => {
if (method.endsWith(".get")) {
return {
path: "/tmp/exec-approvals.json",
exists: true,
hash: "hash-1",
file: { version: 1, agents: {} },
};
}
return { method, params };
},
);
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const defaultRuntime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (msg: string) => runtimeErrors.push(msg),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
vi.mock("./gateway-rpc.js", () => ({
callGatewayFromCli: (method: string, opts: unknown, params?: unknown) =>
callGatewayFromCli(method, opts, params),
}));
vi.mock("./nodes-cli/rpc.js", async () => {
const actual = await vi.importActual<typeof import("./nodes-cli/rpc.js")>(
"./nodes-cli/rpc.js",
);
return {
...actual,
resolveNodeId: vi.fn(async () => "node-1"),
};
});
vi.mock("../runtime.js", () => ({
defaultRuntime,
}));
describe("exec approvals CLI", () => {
it("loads gateway approvals by default", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGatewayFromCli.mockClear();
const { registerExecApprovalsCli } = await import("./exec-approvals-cli.js");
const program = new Command();
program.exitOverride();
registerExecApprovalsCli(program);
await program.parseAsync(["approvals", "get"], { from: "user" });
expect(callGatewayFromCli).toHaveBeenCalledWith(
"exec.approvals.get",
expect.anything(),
{},
);
expect(runtimeErrors).toHaveLength(0);
});
it("loads node approvals when --node is set", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGatewayFromCli.mockClear();
const { registerExecApprovalsCli } = await import("./exec-approvals-cli.js");
const program = new Command();
program.exitOverride();
registerExecApprovalsCli(program);
await program.parseAsync(["approvals", "get", "--node", "macbook"], { from: "user" });
expect(callGatewayFromCli).toHaveBeenCalledWith(
"exec.approvals.node.get",
expect.anything(),
{ nodeId: "node-1" },
);
expect(runtimeErrors).toHaveLength(0);
});
});

View File

@@ -0,0 +1,243 @@
import fs from "node:fs/promises";
import JSON5 from "json5";
import type { Command } from "commander";
import type { ExecApprovalsAgent, ExecApprovalsFile } from "../infra/exec-approvals.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { callGatewayFromCli } from "./gateway-rpc.js";
import { nodesCallOpts, resolveNodeId } from "./nodes-cli/rpc.js";
import type { NodesRpcOpts } from "./nodes-cli/types.js";
type ExecApprovalsSnapshot = {
path: string;
exists: boolean;
hash: string;
file: ExecApprovalsFile;
};
type ExecApprovalsCliOpts = NodesRpcOpts & {
node?: string;
file?: string;
stdin?: boolean;
agent?: string;
};
async function readStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
}
return Buffer.concat(chunks).toString("utf8");
}
async function resolveTargetNodeId(opts: ExecApprovalsCliOpts): Promise<string | null> {
const raw = opts.node?.trim() ?? "";
if (!raw) return null;
return await resolveNodeId(opts as NodesRpcOpts, raw);
}
async function loadSnapshot(
opts: ExecApprovalsCliOpts,
nodeId: string | null,
): Promise<ExecApprovalsSnapshot> {
const method = nodeId ? "exec.approvals.node.get" : "exec.approvals.get";
const params = nodeId ? { nodeId } : {};
const snapshot = (await callGatewayFromCli(method, opts, params)) as ExecApprovalsSnapshot;
return snapshot;
}
async function saveSnapshot(
opts: ExecApprovalsCliOpts,
nodeId: string | null,
file: ExecApprovalsFile,
baseHash: string,
): Promise<ExecApprovalsSnapshot> {
const method = nodeId ? "exec.approvals.node.set" : "exec.approvals.set";
const params = nodeId ? { nodeId, file, baseHash } : { file, baseHash };
const snapshot = (await callGatewayFromCli(method, opts, params)) as ExecApprovalsSnapshot;
return snapshot;
}
function resolveAgentKey(value?: string | null): string {
const trimmed = value?.trim() ?? "";
return trimmed ? trimmed : "default";
}
function normalizeAllowlistEntry(entry: { pattern?: string } | null): string | null {
const pattern = entry?.pattern?.trim() ?? "";
return pattern ? pattern : null;
}
function ensureAgent(file: ExecApprovalsFile, agentKey: string): ExecApprovalsAgent {
const agents = file.agents ?? {};
const entry = agents[agentKey] ?? {};
file.agents = agents;
return entry;
}
function isEmptyAgent(agent: ExecApprovalsAgent): boolean {
const allowlist = Array.isArray(agent.allowlist) ? agent.allowlist : [];
return (
!agent.security &&
!agent.ask &&
!agent.askFallback &&
agent.autoAllowSkills === undefined &&
allowlist.length === 0
);
}
export function registerExecApprovalsCli(program: Command) {
const approvals = program
.command("approvals")
.alias("exec-approvals")
.description("Manage exec approvals (gateway or node host)")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/approvals", "docs.clawd.bot/cli/approvals")}\n`,
);
const getCmd = approvals
.command("get")
.description("Fetch exec approvals snapshot")
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
.action(async (opts: ExecApprovalsCliOpts) => {
const nodeId = await resolveTargetNodeId(opts);
const snapshot = await loadSnapshot(opts, nodeId);
const payload = opts.json ? JSON.stringify(snapshot) : JSON.stringify(snapshot, null, 2);
defaultRuntime.log(payload);
});
nodesCallOpts(getCmd);
const setCmd = approvals
.command("set")
.description("Replace exec approvals with a JSON file")
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
.option("--file <path>", "Path to JSON file to upload")
.option("--stdin", "Read JSON from stdin", false)
.action(async (opts: ExecApprovalsCliOpts) => {
if (!opts.file && !opts.stdin) {
defaultRuntime.error("Provide --file or --stdin.");
defaultRuntime.exit(1);
return;
}
if (opts.file && opts.stdin) {
defaultRuntime.error("Use either --file or --stdin (not both).");
defaultRuntime.exit(1);
return;
}
const nodeId = await resolveTargetNodeId(opts);
const snapshot = await loadSnapshot(opts, nodeId);
if (!snapshot.hash) {
defaultRuntime.error("Exec approvals hash missing; reload and retry.");
defaultRuntime.exit(1);
return;
}
const raw = opts.stdin ? await readStdin() : await fs.readFile(String(opts.file), "utf8");
let file: ExecApprovalsFile;
try {
file = JSON5.parse(raw) as ExecApprovalsFile;
} catch (err) {
defaultRuntime.error(`Failed to parse approvals JSON: ${String(err)}`);
defaultRuntime.exit(1);
return;
}
file.version = 1;
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
defaultRuntime.log(payload);
});
nodesCallOpts(setCmd);
const allowlist = approvals
.command("allowlist")
.description("Edit the per-agent allowlist");
const allowlistAdd = allowlist
.command("add <pattern>")
.description("Add a glob pattern to an allowlist")
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
.option("--agent <id>", "Agent id (defaults to \"default\")")
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
const trimmed = pattern.trim();
if (!trimmed) {
defaultRuntime.error("Pattern required.");
defaultRuntime.exit(1);
return;
}
const nodeId = await resolveTargetNodeId(opts);
const snapshot = await loadSnapshot(opts, nodeId);
if (!snapshot.hash) {
defaultRuntime.error("Exec approvals hash missing; reload and retry.");
defaultRuntime.exit(1);
return;
}
const file = snapshot.file ?? { version: 1 };
file.version = 1;
const agentKey = resolveAgentKey(opts.agent);
const agent = ensureAgent(file, agentKey);
const allowlistEntries = Array.isArray(agent.allowlist) ? agent.allowlist : [];
if (allowlistEntries.some((entry) => normalizeAllowlistEntry(entry) === trimmed)) {
defaultRuntime.log("Already allowlisted.");
return;
}
allowlistEntries.push({ pattern: trimmed, lastUsedAt: Date.now() });
agent.allowlist = allowlistEntries;
file.agents = { ...file.agents, [agentKey]: agent };
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
defaultRuntime.log(payload);
});
nodesCallOpts(allowlistAdd);
const allowlistRemove = allowlist
.command("remove <pattern>")
.description("Remove a glob pattern from an allowlist")
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
.option("--agent <id>", "Agent id (defaults to \"default\")")
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
const trimmed = pattern.trim();
if (!trimmed) {
defaultRuntime.error("Pattern required.");
defaultRuntime.exit(1);
return;
}
const nodeId = await resolveTargetNodeId(opts);
const snapshot = await loadSnapshot(opts, nodeId);
if (!snapshot.hash) {
defaultRuntime.error("Exec approvals hash missing; reload and retry.");
defaultRuntime.exit(1);
return;
}
const file = snapshot.file ?? { version: 1 };
file.version = 1;
const agentKey = resolveAgentKey(opts.agent);
const agent = ensureAgent(file, agentKey);
const allowlistEntries = Array.isArray(agent.allowlist) ? agent.allowlist : [];
const nextEntries = allowlistEntries.filter(
(entry) => normalizeAllowlistEntry(entry) !== trimmed,
);
if (nextEntries.length === allowlistEntries.length) {
defaultRuntime.log("Pattern not found.");
return;
}
if (nextEntries.length === 0) {
delete agent.allowlist;
} else {
agent.allowlist = nextEntries;
}
if (isEmptyAgent(agent)) {
const agents = { ...file.agents };
delete agents[agentKey];
file.agents = Object.keys(agents).length > 0 ? agents : undefined;
} else {
file.agents = { ...file.agents, [agentKey]: agent };
}
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
defaultRuntime.log(payload);
});
nodesCallOpts(allowlistRemove);
}

View File

@@ -46,7 +46,7 @@ type NodeDaemonStatusOptions = {
};
function renderNodeServiceStartHints(): string[] {
const base = ["clawdbot node daemon install", "clawdbot node start"];
const base = ["clawdbot node service install", "clawdbot node start"];
switch (process.platform) {
case "darwin":
return [
@@ -133,7 +133,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
};
if (resolveIsNixMode(process.env)) {
fail("Nix mode detected; daemon install is disabled.");
fail("Nix mode detected; service install is disabled.");
return;
}
@@ -168,7 +168,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
});
if (!json) {
defaultRuntime.log(`Node service already ${service.loadedText}.`);
defaultRuntime.log("Reinstall with: clawdbot node daemon install --force");
defaultRuntime.log("Reinstall with: clawdbot node service install --force");
}
return;
}
@@ -244,7 +244,7 @@ export async function runNodeDaemonUninstall(opts: NodeDaemonLifecycleOptions =
};
if (resolveIsNixMode(process.env)) {
fail("Nix mode detected; daemon uninstall is disabled.");
fail("Nix mode detected; service uninstall is disabled.");
return;
}

View File

@@ -51,63 +51,71 @@ export function registerNodeCli(program: Command) {
});
});
const registerNodeServiceCommands = (cmd: Command) => {
cmd
.command("status")
.description("Show node service status")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStatus(opts);
});
cmd
.command("install")
.description("Install the node service (launchd/systemd/schtasks)")
.option("--host <host>", "Gateway bridge host")
.option("--port <port>", "Gateway bridge port")
.option("--tls", "Use TLS for the bridge connection", false)
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override node id (clears pairing token)")
.option("--display-name <name>", "Override node display name")
.option("--runtime <runtime>", "Service runtime (node|bun). Default: node")
.option("--force", "Reinstall/overwrite if already installed", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonInstall(opts);
});
cmd
.command("uninstall")
.description("Uninstall the node service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonUninstall(opts);
});
cmd
.command("start")
.description("Start the node service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStart(opts);
});
cmd
.command("stop")
.description("Stop the node service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStop(opts);
});
cmd
.command("restart")
.description("Restart the node service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonRestart(opts);
});
};
const service = node
.command("service")
.description("Manage the headless node service (launchd/systemd/schtasks)");
registerNodeServiceCommands(service);
const daemon = node
.command("daemon")
.description("Manage the headless node daemon service (launchd/systemd/schtasks)");
daemon
.command("status")
.description("Show node daemon status")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStatus(opts);
});
daemon
.command("install")
.description("Install the node daemon service (launchd/systemd/schtasks)")
.option("--host <host>", "Gateway bridge host")
.option("--port <port>", "Gateway bridge port")
.option("--tls", "Use TLS for the bridge connection", false)
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override node id (clears pairing token)")
.option("--display-name <name>", "Override node display name")
.option("--runtime <runtime>", "Daemon runtime (node|bun). Default: node")
.option("--force", "Reinstall/overwrite if already installed", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonInstall(opts);
});
daemon
.command("uninstall")
.description("Uninstall the node daemon service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonUninstall(opts);
});
daemon
.command("start")
.description("Start the node daemon service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStart(opts);
});
daemon
.command("stop")
.description("Stop the node daemon service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStop(opts);
});
daemon
.command("restart")
.description("Restart the node daemon service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonRestart(opts);
});
.command("daemon", { hidden: true })
.description("Legacy alias for node service commands");
registerNodeServiceCommands(daemon);
}

View File

@@ -4,6 +4,43 @@ import { formatAge, formatPermissions, parseNodeList, parsePairingList } from ".
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
function formatVersionLabel(raw: string) {
const trimmed = raw.trim();
if (!trimmed) return raw;
if (trimmed.toLowerCase().startsWith("v")) return trimmed;
return /^\d/.test(trimmed) ? `v${trimmed}` : trimmed;
}
function resolveNodeVersions(node: {
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
}) {
const core = node.coreVersion?.trim() || undefined;
const ui = node.uiVersion?.trim() || undefined;
if (core || ui) return { core, ui };
const legacy = node.version?.trim();
if (!legacy) return { core: undefined, ui: undefined };
const platform = node.platform?.trim().toLowerCase() ?? "";
const headless =
platform === "darwin" || platform === "linux" || platform === "win32" || platform === "windows";
return headless ? { core: legacy, ui: undefined } : { core: undefined, ui: legacy };
}
function formatNodeVersions(node: {
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
}) {
const { core, ui } = resolveNodeVersions(node);
const parts: string[] = [];
if (core) parts.push(`core ${formatVersionLabel(core)}`);
if (ui) parts.push(`ui ${formatVersionLabel(ui)}`);
return parts.length > 0 ? parts.join(" · ") : null;
}
export function registerNodesStatusCommands(nodes: Command) {
nodesCallOpts(
nodes
@@ -29,6 +66,8 @@ export function registerNodesStatusCommands(nodes: Command) {
const hw = n.modelIdentifier ? ` · hw: ${n.modelIdentifier}` : "";
const perms = formatPermissions(n.permissions);
const permsText = perms ? ` · perms: ${perms}` : "";
const versions = formatNodeVersions(n);
const versionText = versions ? ` · ${versions}` : "";
const caps =
Array.isArray(n.caps) && n.caps.length > 0
? `[${n.caps.map(String).filter(Boolean).sort().join(",")}]`
@@ -37,7 +76,7 @@ export function registerNodesStatusCommands(nodes: Command) {
: "?";
const pairing = n.paired ? "paired" : "unpaired";
defaultRuntime.log(
`- ${name} · ${n.nodeId}${ip}${device}${hw}${permsText} · ${pairing} · ${n.connected ? "connected" : "disconnected"} · caps: ${caps}`,
`- ${name} · ${n.nodeId}${ip}${device}${hw}${permsText}${versionText} · ${pairing} · ${n.connected ? "connected" : "disconnected"} · caps: ${caps}`,
);
}
} catch (err) {
@@ -77,12 +116,19 @@ export function registerNodesStatusCommands(nodes: Command) {
const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null;
const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null;
const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null;
const versions = formatNodeVersions(obj as {
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
});
const parts: string[] = ["Node:", displayName, nodeId];
if (ip) parts.push(ip);
if (family) parts.push(`device: ${family}`);
if (model) parts.push(`hw: ${model}`);
if (perms) parts.push(`perms: ${perms}`);
if (versions) parts.push(versions);
parts.push(connected ? "connected" : "disconnected");
parts.push(`caps: ${caps ? `[${caps.join(",")}]` : "?"}`);
defaultRuntime.log(parts.join(" · "));

View File

@@ -46,6 +46,8 @@ export type NodeListNode = {
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
remoteIp?: string;
deviceFamily?: string;
modelIdentifier?: string;
@@ -62,6 +64,8 @@ export type PendingRequest = {
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
remoteIp?: string;
isRepair?: boolean;
ts: number;
@@ -73,6 +77,8 @@ export type PairedNode = {
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
remoteIp?: string;
permissions?: Record<string, boolean>;
createdAtMs?: number;

View File

@@ -8,6 +8,7 @@ import { registerDaemonCli } from "../daemon-cli.js";
import { registerDnsCli } from "../dns-cli.js";
import { registerDirectoryCli } from "../directory-cli.js";
import { registerDocsCli } from "../docs-cli.js";
import { registerExecApprovalsCli } from "../exec-approvals-cli.js";
import { registerGatewayCli } from "../gateway-cli.js";
import { registerHooksCli } from "../hooks-cli.js";
import { registerWebhooksCli } from "../webhooks-cli.js";
@@ -19,6 +20,7 @@ import { registerPairingCli } from "../pairing-cli.js";
import { registerPluginsCli } from "../plugins-cli.js";
import { registerSandboxCli } from "../sandbox-cli.js";
import { registerSecurityCli } from "../security-cli.js";
import { registerServiceCli } from "../service-cli.js";
import { registerSkillsCli } from "../skills-cli.js";
import { registerTuiCli } from "../tui-cli.js";
import { registerUpdateCli } from "../update-cli.js";
@@ -27,8 +29,10 @@ export function registerSubCliCommands(program: Command) {
registerAcpCli(program);
registerDaemonCli(program);
registerGatewayCli(program);
registerServiceCli(program);
registerLogsCli(program);
registerModelsCli(program);
registerExecApprovalsCli(program);
registerNodesCli(program);
registerNodeCli(program);
registerSandboxCli(program);

View File

@@ -0,0 +1,59 @@
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
const runDaemonStatus = vi.fn(async () => {});
const runNodeDaemonStatus = vi.fn(async () => {});
vi.mock("./daemon-cli/runners.js", () => ({
runDaemonInstall: vi.fn(async () => {}),
runDaemonRestart: vi.fn(async () => {}),
runDaemonStart: vi.fn(async () => {}),
runDaemonStatus: (opts: unknown) => runDaemonStatus(opts),
runDaemonStop: vi.fn(async () => {}),
runDaemonUninstall: vi.fn(async () => {}),
}));
vi.mock("./node-cli/daemon.js", () => ({
runNodeDaemonInstall: vi.fn(async () => {}),
runNodeDaemonRestart: vi.fn(async () => {}),
runNodeDaemonStart: vi.fn(async () => {}),
runNodeDaemonStatus: (opts: unknown) => runNodeDaemonStatus(opts),
runNodeDaemonStop: vi.fn(async () => {}),
runNodeDaemonUninstall: vi.fn(async () => {}),
}));
vi.mock("./deps.js", () => ({
createDefaultDeps: vi.fn(),
}));
describe("service CLI coverage", () => {
it("routes service gateway status to daemon status", async () => {
runDaemonStatus.mockClear();
runNodeDaemonStatus.mockClear();
const { registerServiceCli } = await import("./service-cli.js");
const program = new Command();
program.exitOverride();
registerServiceCli(program);
await program.parseAsync(["service", "gateway", "status"], { from: "user" });
expect(runDaemonStatus).toHaveBeenCalledTimes(1);
expect(runNodeDaemonStatus).toHaveBeenCalledTimes(0);
});
it("routes service node status to node daemon status", async () => {
runDaemonStatus.mockClear();
runNodeDaemonStatus.mockClear();
const { registerServiceCli } = await import("./service-cli.js");
const program = new Command();
program.exitOverride();
registerServiceCli(program);
await program.parseAsync(["service", "node", "status"], { from: "user" });
expect(runNodeDaemonStatus).toHaveBeenCalledTimes(1);
expect(runDaemonStatus).toHaveBeenCalledTimes(0);
});
});

157
src/cli/service-cli.ts Normal file
View File

@@ -0,0 +1,157 @@
import type { Command } from "commander";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { createDefaultDeps } from "./deps.js";
import {
runDaemonInstall,
runDaemonRestart,
runDaemonStart,
runDaemonStatus,
runDaemonStop,
runDaemonUninstall,
} from "./daemon-cli/runners.js";
import {
runNodeDaemonInstall,
runNodeDaemonRestart,
runNodeDaemonStart,
runNodeDaemonStatus,
runNodeDaemonStop,
runNodeDaemonUninstall,
} from "./node-cli/daemon.js";
export function registerServiceCli(program: Command) {
const service = program
.command("service")
.description("Manage Gateway and node host services (launchd/systemd/schtasks)")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/service", "docs.clawd.bot/cli/service")}\n`,
);
const gateway = service.command("gateway").description("Manage the Gateway service");
gateway
.command("status")
.description("Show gateway service status + probe the Gateway")
.option("--url <url>", "Gateway WebSocket URL (defaults to config/remote/local)")
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (password auth)")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--no-probe", "Skip RPC probe")
.option("--deep", "Scan system-level services", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonStatus({
rpc: opts,
probe: Boolean(opts.probe),
deep: Boolean(opts.deep),
json: Boolean(opts.json),
});
});
gateway
.command("install")
.description("Install the Gateway service (launchd/systemd/schtasks)")
.option("--port <port>", "Gateway port")
.option("--runtime <runtime>", "Service runtime (node|bun). Default: node")
.option("--token <token>", "Gateway token (token auth)")
.option("--force", "Reinstall/overwrite if already installed", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonInstall(opts);
});
gateway
.command("uninstall")
.description("Uninstall the Gateway service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonUninstall(opts);
});
gateway
.command("start")
.description("Start the Gateway service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonStart(opts);
});
gateway
.command("stop")
.description("Stop the Gateway service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonStop(opts);
});
gateway
.command("restart")
.description("Restart the Gateway service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonRestart(opts);
});
const node = service.command("node").description("Manage the node host service");
node
.command("status")
.description("Show node host service status")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStatus(opts);
});
node
.command("install")
.description("Install the node host service (launchd/systemd/schtasks)")
.option("--host <host>", "Gateway bridge host")
.option("--port <port>", "Gateway bridge port")
.option("--tls", "Use TLS for the bridge connection", false)
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override node id (clears pairing token)")
.option("--display-name <name>", "Override node display name")
.option("--runtime <runtime>", "Service runtime (node|bun). Default: node")
.option("--force", "Reinstall/overwrite if already installed", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonInstall(opts);
});
node
.command("uninstall")
.description("Uninstall the node host service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonUninstall(opts);
});
node
.command("start")
.description("Start the node host service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStart(opts);
});
node
.command("stop")
.description("Stop the node host service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStop(opts);
});
node
.command("restart")
.description("Restart the node host service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonRestart(opts);
});
// Build default deps (parity with daemon CLI).
void createDefaultDeps();
}

View File

@@ -2,7 +2,9 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig, readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import type { GatewayService } from "../daemon/service.js";
import { resolveGatewayService } from "../daemon/service.js";
import { resolveNodeService } from "../daemon/node-service.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
import { probeGateway } from "../gateway/probe.js";
@@ -130,10 +132,9 @@ export async function statusAllCommand(
const gatewaySelf = pickGatewaySelfPresence(gatewayProbe?.presence ?? null);
progress.tick();
progress.setLabel("Checking daemon…");
const daemon = await (async () => {
progress.setLabel("Checking services…");
const readServiceSummary = async (service: GatewayService) => {
try {
const service = resolveGatewayService();
const [loaded, runtimeInfo, command] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined),
@@ -150,7 +151,9 @@ export async function statusAllCommand(
} catch {
return null;
}
})();
};
const daemon = await readServiceSummary(resolveGatewayService());
const nodeService = await readServiceSummary(resolveNodeService());
progress.tick();
progress.setLabel("Scanning agents…");
@@ -340,13 +343,22 @@ export async function statusAllCommand(
: { Item: "Gateway self", Value: "unknown" },
daemon
? {
Item: "Daemon",
Item: "Gateway service",
Value:
daemon.installed === false
? `${daemon.label} not installed`
: `${daemon.label} ${daemon.installed ? "installed · " : ""}${daemon.loadedText}${daemon.runtime?.status ? ` · ${daemon.runtime.status}` : ""}${daemon.runtime?.pid ? ` (pid ${daemon.runtime.pid})` : ""}`,
}
: { Item: "Daemon", Value: "unknown" },
: { Item: "Gateway service", Value: "unknown" },
nodeService
? {
Item: "Node service",
Value:
nodeService.installed === false
? `${nodeService.label} not installed`
: `${nodeService.label} ${nodeService.installed ? "installed · " : ""}${nodeService.loadedText}${nodeService.runtime?.status ? ` · ${nodeService.runtime.status}` : ""}${nodeService.runtime?.pid ? ` (pid ${nodeService.runtime.pid})` : ""}`,
}
: { Item: "Node service", Value: "unknown" },
{
Item: "Agents",
Value: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`,

View File

@@ -15,7 +15,7 @@ import {
} from "../memory/status-format.js";
import { formatHealthChannelLines, type HealthSummary } from "./health.js";
import { resolveControlUiLinks } from "./onboard-helpers.js";
import { getDaemonStatusSummary } from "./status.daemon.js";
import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js";
import {
formatAge,
formatDuration,
@@ -116,6 +116,10 @@ export async function statusCommand(
: undefined;
if (opts.json) {
const [daemon, nodeDaemon] = await Promise.all([
getDaemonStatusSummary(),
getNodeDaemonStatusSummary(),
]);
runtime.log(
JSON.stringify(
{
@@ -134,6 +138,8 @@ export async function statusCommand(
self: gatewaySelf,
error: gatewayProbe?.error ?? null,
},
gatewayService: daemon,
nodeService: nodeDaemon,
agents: agentStatus,
securityAudit,
...(health || usage ? { health, usage } : {}),
@@ -210,12 +216,20 @@ export async function statusCommand(
return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`;
})();
const daemon = await getDaemonStatusSummary();
const [daemon, nodeDaemon] = await Promise.all([
getDaemonStatusSummary(),
getNodeDaemonStatusSummary(),
]);
const daemonValue = (() => {
if (daemon.installed === false) return `${daemon.label} not installed`;
const installedPrefix = daemon.installed === true ? "installed · " : "";
return `${daemon.label} ${installedPrefix}${daemon.loadedText}${daemon.runtimeShort ? ` · ${daemon.runtimeShort}` : ""}`;
})();
const nodeDaemonValue = (() => {
if (nodeDaemon.installed === false) return `${nodeDaemon.label} not installed`;
const installedPrefix = nodeDaemon.installed === true ? "installed · " : "";
return `${nodeDaemon.label} ${installedPrefix}${nodeDaemon.loadedText}${nodeDaemon.runtimeShort ? ` · ${nodeDaemon.runtimeShort}` : ""}`;
})();
const defaults = summary.sessions.defaults;
const defaultCtx = defaults.contextTokens
@@ -298,7 +312,8 @@ export async function statusCommand(
Value: updateAvailability.available ? warn(`available · ${updateLine}`) : updateLine,
},
{ Item: "Gateway", Value: gatewayValue },
{ Item: "Daemon", Value: daemonValue },
{ Item: "Gateway service", Value: daemonValue },
{ Item: "Node service", Value: nodeDaemonValue },
{ Item: "Agents", Value: agentsValue },
{ Item: "Memory", Value: memoryValue },
{ Item: "Probes", Value: probesValue },

View File

@@ -1,14 +1,20 @@
import type { GatewayService } from "../daemon/service.js";
import { resolveGatewayService } from "../daemon/service.js";
import { resolveNodeService } from "../daemon/node-service.js";
import { formatDaemonRuntimeShort } from "./status.format.js";
export async function getDaemonStatusSummary(): Promise<{
type DaemonStatusSummary = {
label: string;
installed: boolean | null;
loadedText: string;
runtimeShort: string | null;
}> {
};
async function buildDaemonStatusSummary(
service: GatewayService,
fallbackLabel: string,
): Promise<DaemonStatusSummary> {
try {
const service = resolveGatewayService();
const [loaded, runtime, command] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined),
@@ -20,10 +26,18 @@ export async function getDaemonStatusSummary(): Promise<{
return { label: service.label, installed, loadedText, runtimeShort };
} catch {
return {
label: "Daemon",
label: fallbackLabel,
installed: null,
loadedText: "unknown",
runtimeShort: null,
};
}
}
export async function getDaemonStatusSummary(): Promise<DaemonStatusSummary> {
return await buildDaemonStatusSummary(resolveGatewayService(), "Daemon");
}
export async function getNodeDaemonStatusSummary(): Promise<DaemonStatusSummary> {
return await buildDaemonStatusSummary(resolveNodeService(), "Node");
}

View File

@@ -243,6 +243,19 @@ vi.mock("../daemon/service.js", () => ({
}),
}),
}));
vi.mock("../daemon/node-service.js", () => ({
resolveNodeService: () => ({
label: "LaunchAgent",
loadedText: "loaded",
notLoadedText: "not loaded",
isLoaded: async () => true,
readRuntime: async () => ({ status: "running", pid: 4321 }),
readCommand: async () => ({
programArguments: ["node", "dist/entry.js", "node-host"],
sourcePath: "/tmp/Library/LaunchAgents/com.clawdbot.node.plist",
}),
}),
}));
vi.mock("../security/audit.js", () => ({
runSecurityAudit: mocks.runSecurityAudit,
}));
@@ -273,6 +286,8 @@ describe("statusCommand", () => {
expect(payload.sessions.recent[0].flags).toContain("verbose:on");
expect(payload.securityAudit.summary.critical).toBe(1);
expect(payload.securityAudit.summary.warn).toBe(1);
expect(payload.gatewayService.label).toBe("LaunchAgent");
expect(payload.nodeService.label).toBe("LaunchAgent");
});
it("prints formatted lines otherwise", async () => {

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
describe("Slack HTTP mode config", () => {
it("accepts HTTP mode when signing secret is configured", () => {
const res = validateConfigObject({
channels: {
slack: {
mode: "http",
signingSecret: "secret",
},
},
});
expect(res.ok).toBe(true);
});
it("rejects HTTP mode without signing secret", () => {
const res = validateConfigObject({
channels: {
slack: {
mode: "http",
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.slack.signingSecret");
}
});
it("accepts account HTTP mode when base signing secret is set", () => {
const res = validateConfigObject({
channels: {
slack: {
signingSecret: "secret",
accounts: {
ops: {
mode: "http",
},
},
},
},
});
expect(res.ok).toBe(true);
});
it("rejects account HTTP mode without signing secret", () => {
const res = validateConfigObject({
channels: {
slack: {
accounts: {
ops: {
mode: "http",
},
},
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.slack.accounts.ops.signingSecret");
}
});
});

View File

@@ -70,6 +70,12 @@ export type SlackThreadConfig = {
export type SlackAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Slack connection mode (socket|http). Default: socket. */
mode?: "socket" | "http";
/** Slack signing secret (required for HTTP mode). */
signingSecret?: string;
/** Slack Events API webhook path (default: /slack/events). */
webhookPath?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Override native command registration for Slack (bool or "auto"). */

View File

@@ -258,6 +258,9 @@ export const SlackThreadSchema = z.object({
export const SlackAccountSchema = z.object({
name: z.string().optional(),
mode: z.enum(["socket", "http"]).optional(),
signingSecret: z.string().optional(),
webhookPath: z.string().optional(),
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
commands: ProviderCommandsSchema,
@@ -305,7 +308,35 @@ export const SlackAccountSchema = z.object({
});
export const SlackConfigSchema = SlackAccountSchema.extend({
mode: z.enum(["socket", "http"]).optional().default("socket"),
signingSecret: z.string().optional(),
webhookPath: z.string().optional().default("/slack/events"),
accounts: z.record(z.string(), SlackAccountSchema.optional()).optional(),
}).superRefine((value, ctx) => {
const baseMode = value.mode ?? "socket";
if (baseMode === "http" && !value.signingSecret) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'channels.slack.mode="http" requires channels.slack.signingSecret',
path: ["signingSecret"],
});
}
if (!value.accounts) return;
for (const [accountId, account] of Object.entries(value.accounts)) {
if (!account) continue;
if (account.enabled === false) continue;
const accountMode = account.mode ?? baseMode;
if (accountMode !== "http") continue;
const accountSecret = account.signingSecret ?? value.signingSecret;
if (!accountSecret) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'channels.slack.accounts.*.mode="http" requires channels.slack.signingSecret or channels.slack.accounts.*.signingSecret',
path: ["accounts", accountId, "signingSecret"],
});
}
}
});
export const SignalAccountSchemaBase = z.object({

71
src/gateway/boot.test.ts Normal file
View File

@@ -0,0 +1,71 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const agentCommand = vi.fn();
vi.mock("../commands/agent.js", () => ({ agentCommand }));
const { runBootOnce } = await import("./boot.js");
const { resolveMainSessionKey } = await import("../config/sessions/main-session.js");
describe("runBootOnce", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const makeDeps = () => ({
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSlack: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
});
it("skips when BOOT.md is missing", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-"));
await expect(
runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }),
).resolves.toEqual({ status: "skipped", reason: "missing" });
expect(agentCommand).not.toHaveBeenCalled();
await fs.rm(workspaceDir, { recursive: true, force: true });
});
it("skips when BOOT.md is empty", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-"));
await fs.writeFile(path.join(workspaceDir, "BOOT.md"), " \n", "utf-8");
await expect(
runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }),
).resolves.toEqual({ status: "skipped", reason: "empty" });
expect(agentCommand).not.toHaveBeenCalled();
await fs.rm(workspaceDir, { recursive: true, force: true });
});
it("runs agent command when BOOT.md exists", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-"));
const content = "Say hello when you wake up.";
await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8");
agentCommand.mockResolvedValue(undefined);
await expect(
runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }),
).resolves.toEqual({ status: "ran" });
expect(agentCommand).toHaveBeenCalledTimes(1);
const call = agentCommand.mock.calls[0]?.[0];
expect(call).toEqual(
expect.objectContaining({
deliver: false,
sessionKey: resolveMainSessionKey({}),
}),
);
expect(call?.message).toContain("BOOT.md:");
expect(call?.message).toContain(content);
expect(call?.message).toContain("NO_REPLY");
await fs.rm(workspaceDir, { recursive: true, force: true });
});
});

92
src/gateway/boot.ts Normal file
View File

@@ -0,0 +1,92 @@
import fs from "node:fs/promises";
import path from "node:path";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import type { CliDeps } from "../cli/deps.js";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveMainSessionKey } from "../config/sessions/main-session.js";
import { agentCommand } from "../commands/agent.js";
import { createSubsystemLogger } from "../logging.js";
import { type RuntimeEnv, defaultRuntime } from "../runtime.js";
const log = createSubsystemLogger("gateway/boot");
const BOOT_FILENAME = "BOOT.md";
export type BootRunResult =
| { status: "skipped"; reason: "missing" | "empty" }
| { status: "ran" }
| { status: "failed"; reason: string };
function buildBootPrompt(content: string) {
return [
"You are running a boot check. Follow BOOT.md instructions exactly.",
"",
"BOOT.md:",
content,
"",
"If BOOT.md asks you to send a message, use the message tool (action=send with channel + target).",
"Use the `target` field (not `to`) for message tool destinations.",
`After sending with the message tool, reply with ONLY: ${SILENT_REPLY_TOKEN}.`,
`If nothing needs attention, reply with ONLY: ${SILENT_REPLY_TOKEN}.`,
].join("\n");
}
async function loadBootFile(
workspaceDir: string,
): Promise<{ content?: string; status: "ok" | "missing" | "empty" }> {
const bootPath = path.join(workspaceDir, BOOT_FILENAME);
try {
const content = await fs.readFile(bootPath, "utf-8");
const trimmed = content.trim();
if (!trimmed) return { status: "empty" };
return { status: "ok", content: trimmed };
} catch (err) {
const anyErr = err as { code?: string };
if (anyErr.code === "ENOENT") return { status: "missing" };
throw err;
}
}
export async function runBootOnce(params: {
cfg: ClawdbotConfig;
deps: CliDeps;
workspaceDir: string;
}): Promise<BootRunResult> {
const bootRuntime: RuntimeEnv = {
log: () => {},
error: (message) => log.error(String(message)),
exit: defaultRuntime.exit,
};
let result: Awaited<ReturnType<typeof loadBootFile>>;
try {
result = await loadBootFile(params.workspaceDir);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.error(`boot: failed to read ${BOOT_FILENAME}: ${message}`);
return { status: "failed", reason: message };
}
if (result.status === "missing" || result.status === "empty") {
return { status: "skipped", reason: result.status };
}
const sessionKey = resolveMainSessionKey(params.cfg);
const message = buildBootPrompt(result.content ?? "");
try {
await agentCommand(
{
message,
sessionKey,
deliver: false,
},
bootRuntime,
params.deps,
);
return { status: "ran" };
} catch (err) {
const messageText = err instanceof Error ? err.message : String(err);
log.error(`boot: agent run failed: ${messageText}`);
return { status: "failed", reason: messageText };
}
}

View File

@@ -58,6 +58,10 @@ import {
CronUpdateParamsSchema,
type ExecApprovalsGetParams,
ExecApprovalsGetParamsSchema,
type ExecApprovalsNodeGetParams,
ExecApprovalsNodeGetParamsSchema,
type ExecApprovalsNodeSetParams,
ExecApprovalsNodeSetParamsSchema,
type ExecApprovalsSetParams,
ExecApprovalsSetParamsSchema,
type ExecApprovalsSnapshot,
@@ -241,6 +245,12 @@ export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams
export const validateExecApprovalsSetParams = ajv.compile<ExecApprovalsSetParams>(
ExecApprovalsSetParamsSchema,
);
export const validateExecApprovalsNodeGetParams = ajv.compile<ExecApprovalsNodeGetParams>(
ExecApprovalsNodeGetParamsSchema,
);
export const validateExecApprovalsNodeSetParams = ajv.compile<ExecApprovalsNodeSetParams>(
ExecApprovalsNodeSetParamsSchema,
);
export const validateLogsTailParams = ajv.compile<LogsTailParams>(LogsTailParamsSchema);
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);

View File

@@ -70,3 +70,19 @@ export const ExecApprovalsSetParamsSchema = Type.Object(
},
{ additionalProperties: false },
);
export const ExecApprovalsNodeGetParamsSchema = Type.Object(
{
nodeId: NonEmptyString,
},
{ additionalProperties: false },
);
export const ExecApprovalsNodeSetParamsSchema = Type.Object(
{
nodeId: NonEmptyString,
file: ExecApprovalsFileSchema,
baseHash: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);

View File

@@ -8,6 +8,8 @@ export const NodePairRequestParamsSchema = Type.Object(
displayName: Type.Optional(NonEmptyString),
platform: Type.Optional(NonEmptyString),
version: Type.Optional(NonEmptyString),
coreVersion: Type.Optional(NonEmptyString),
uiVersion: Type.Optional(NonEmptyString),
deviceFamily: Type.Optional(NonEmptyString),
modelIdentifier: Type.Optional(NonEmptyString),
caps: Type.Optional(Type.Array(NonEmptyString)),

View File

@@ -49,6 +49,8 @@ import {
} from "./cron.js";
import {
ExecApprovalsGetParamsSchema,
ExecApprovalsNodeGetParamsSchema,
ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSetParamsSchema,
ExecApprovalsSnapshotSchema,
} from "./exec-approvals.js";
@@ -177,6 +179,8 @@ export const ProtocolSchemas: Record<string, TSchema> = {
LogsTailResult: LogsTailResultSchema,
ExecApprovalsGetParams: ExecApprovalsGetParamsSchema,
ExecApprovalsSetParams: ExecApprovalsSetParamsSchema,
ExecApprovalsNodeGetParams: ExecApprovalsNodeGetParamsSchema,
ExecApprovalsNodeSetParams: ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema,
ChatHistoryParams: ChatHistoryParamsSchema,
ChatSendParams: ChatSendParamsSchema,

View File

@@ -47,6 +47,8 @@ import type {
} from "./cron.js";
import type {
ExecApprovalsGetParamsSchema,
ExecApprovalsNodeGetParamsSchema,
ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSetParamsSchema,
ExecApprovalsSnapshotSchema,
} from "./exec-approvals.js";
@@ -170,6 +172,8 @@ export type LogsTailParams = Static<typeof LogsTailParamsSchema>;
export type LogsTailResult = Static<typeof LogsTailResultSchema>;
export type ExecApprovalsGetParams = Static<typeof ExecApprovalsGetParamsSchema>;
export type ExecApprovalsSetParams = Static<typeof ExecApprovalsSetParamsSchema>;
export type ExecApprovalsNodeGetParams = Static<typeof ExecApprovalsNodeGetParamsSchema>;
export type ExecApprovalsNodeSetParams = Static<typeof ExecApprovalsNodeSetParamsSchema>;
export type ExecApprovalsSnapshot = Static<typeof ExecApprovalsSnapshotSchema>;
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;

View File

@@ -8,6 +8,7 @@ import type { WebSocketServer } from "ws";
import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
import type { CanvasHostHandler } from "../canvas-host/server.js";
import type { createSubsystemLogger } from "../logging.js";
import { handleSlackHttpRequest } from "../slack/http/index.js";
import { handleControlUiHttpRequest } from "./control-ui.js";
import {
extractHookToken,
@@ -208,6 +209,7 @@ export function createGatewayHttpServer(opts: {
void (async () => {
if (await handleHooksRequest(req, res)) return;
if (await handleSlackHttpRequest(req, res)) return;
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
if (openAiChatCompletionsEnabled) {
if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return;

View File

@@ -14,6 +14,8 @@ const BASE_METHODS = [
"config.schema",
"exec.approvals.get",
"exec.approvals.set",
"exec.approvals.node.get",
"exec.approvals.node.set",
"wizard.start",
"wizard.next",
"wizard.cancel",

View File

@@ -12,8 +12,11 @@ import {
errorShape,
formatValidationErrors,
validateExecApprovalsGetParams,
validateExecApprovalsNodeGetParams,
validateExecApprovalsNodeSetParams,
validateExecApprovalsSetParams,
} from "../protocol/index.js";
import { respondUnavailableOnThrow, safeParseJson } from "./nodes.helpers.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
function resolveBaseHash(params: unknown): string | null {
@@ -152,4 +155,94 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
undefined,
);
},
"exec.approvals.node.get": async ({ params, respond, context }) => {
if (!validateExecApprovalsNodeGetParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid exec.approvals.node.get params: ${formatValidationErrors(validateExecApprovalsNodeGetParams.errors)}`,
),
);
return;
}
const bridge = context.bridge;
if (!bridge) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
return;
}
const { nodeId } = params as { nodeId: string };
const id = nodeId.trim();
if (!id) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
return;
}
await respondUnavailableOnThrow(respond, async () => {
const res = await bridge.invoke({
nodeId: id,
command: "system.execApprovals.get",
paramsJSON: "{}",
});
if (!res.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", {
details: { nodeError: res.error ?? null },
}),
);
return;
}
const payload = safeParseJson(res.payloadJSON ?? null);
respond(true, payload, undefined);
});
},
"exec.approvals.node.set": async ({ params, respond, context }) => {
if (!validateExecApprovalsNodeSetParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid exec.approvals.node.set params: ${formatValidationErrors(validateExecApprovalsNodeSetParams.errors)}`,
),
);
return;
}
const bridge = context.bridge;
if (!bridge) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
return;
}
const { nodeId, file, baseHash } = params as {
nodeId: string;
file: ExecApprovalsFile;
baseHash?: string;
};
const id = nodeId.trim();
if (!id) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
return;
}
await respondUnavailableOnThrow(respond, async () => {
const res = await bridge.invoke({
nodeId: id,
command: "system.execApprovals.set",
paramsJSON: JSON.stringify({ file, baseHash }),
});
if (!res.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", {
details: { nodeError: res.error ?? null },
}),
);
return;
}
const payload = safeParseJson(res.payloadJSON ?? null);
respond(true, payload, undefined);
});
},
};

View File

@@ -42,6 +42,8 @@ export const nodeHandlers: GatewayRequestHandlers = {
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
deviceFamily?: string;
modelIdentifier?: string;
caps?: string[];
@@ -55,6 +57,8 @@ export const nodeHandlers: GatewayRequestHandlers = {
displayName: p.displayName,
platform: p.platform,
version: p.version,
coreVersion: p.coreVersion,
uiVersion: p.uiVersion,
deviceFamily: p.deviceFamily,
modelIdentifier: p.modelIdentifier,
caps: p.caps,
@@ -215,6 +219,8 @@ export const nodeHandlers: GatewayRequestHandlers = {
displayName: live?.displayName ?? paired?.displayName,
platform: live?.platform ?? paired?.platform,
version: live?.version ?? paired?.version,
coreVersion: live?.coreVersion ?? paired?.coreVersion,
uiVersion: live?.uiVersion ?? paired?.uiVersion,
deviceFamily: live?.deviceFamily ?? paired?.deviceFamily,
modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier,
remoteIp: live?.remoteIp ?? paired?.remoteIp,
@@ -275,6 +281,8 @@ export const nodeHandlers: GatewayRequestHandlers = {
displayName: live?.displayName ?? paired?.displayName,
platform: live?.platform ?? paired?.platform,
version: live?.version ?? paired?.version,
coreVersion: live?.coreVersion ?? paired?.coreVersion,
uiVersion: live?.uiVersion ?? paired?.uiVersion,
deviceFamily: live?.deviceFamily ?? paired?.deviceFamily,
modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier,
remoteIp: live?.remoteIp ?? paired?.remoteIp,

View File

@@ -43,6 +43,25 @@ export async function startGatewayNodeBridge(params: {
}): Promise<GatewayNodeBridgeRuntime> {
const nodePresenceTimers = new Map<string, ReturnType<typeof setInterval>>();
const formatVersionLabel = (raw: string): string => {
const trimmed = raw.trim();
if (!trimmed) return raw;
if (trimmed.toLowerCase().startsWith("v")) return trimmed;
return /^\d/.test(trimmed) ? `v${trimmed}` : trimmed;
};
const resolveNodeVersionLabel = (node: {
coreVersion?: string;
uiVersion?: string;
}): string | null => {
const core = node.coreVersion?.trim();
const ui = node.uiVersion?.trim();
const parts: string[] = [];
if (core) parts.push(`core ${formatVersionLabel(core)}`);
if (ui) parts.push(`ui ${formatVersionLabel(ui)}`);
return parts.length > 0 ? parts.join(" · ") : null;
};
const stopNodePresenceTimer = (nodeId: string) => {
const timer = nodePresenceTimers.get(nodeId);
if (timer) {
@@ -57,6 +76,8 @@ export async function startGatewayNodeBridge(params: {
displayName?: string;
remoteIp?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
platform?: string;
deviceFamily?: string;
modelIdentifier?: string;
@@ -66,7 +87,7 @@ export async function startGatewayNodeBridge(params: {
const host = node.displayName?.trim() || node.nodeId;
const rawIp = node.remoteIp?.trim();
const ip = rawIp && !isLoopbackAddress(rawIp) ? rawIp : undefined;
const version = node.version?.trim() || "unknown";
const version = resolveNodeVersionLabel(node) ?? node.version?.trim() ?? "unknown";
const platform = node.platform?.trim() || undefined;
const deviceFamily = node.deviceFamily?.trim() || undefined;
const modelIdentifier = node.modelIdentifier?.trim() || undefined;
@@ -127,6 +148,7 @@ export async function startGatewayNodeBridge(params: {
platform: node.platform,
deviceFamily: node.deviceFamily,
commands: node.commands,
remoteIp: node.remoteIp,
});
bumpSkillsSnapshotVersion({ reason: "remote-node" });
await refreshRemoteNodeBins({

View File

@@ -20,9 +20,7 @@ import type { DedupeEntry } from "./server-shared.js";
import type { PluginRegistry } from "../plugins/registry.js";
export async function createGatewayRuntimeState(params: {
cfg: {
canvasHost?: { root?: string; enabled?: boolean; liveReload?: boolean };
};
cfg: import("../config/config.js").ClawdbotConfig;
bindHost: string;
port: number;
controlUiEnabled: boolean;

View File

@@ -8,7 +8,11 @@ import {
import type { CliDeps } from "../cli/deps.js";
import type { loadConfig } from "../config/config.js";
import { startGmailWatcher } from "../hooks/gmail-watcher.js";
import { clearInternalHooks } from "../hooks/internal-hooks.js";
import {
clearInternalHooks,
createInternalHookEvent,
triggerInternalHook,
} from "../hooks/internal-hooks.js";
import { loadInternalHooks } from "../hooks/loader.js";
import type { loadClawdbotPlugins } from "../plugins/loader.js";
import { type PluginServicesHandle, startPluginServices } from "../plugins/services.js";
@@ -122,6 +126,17 @@ export async function startGatewaySidecars(params: {
);
}
if (params.cfg.hooks?.internal?.enabled) {
setTimeout(() => {
const hookEvent = createInternalHookEvent("gateway", "startup", "gateway:startup", {
cfg: params.cfg,
deps: params.deps,
workspaceDir: params.defaultWorkspaceDir,
});
void triggerInternalHook(hookEvent);
}, 250);
}
let pluginServices: PluginServicesHandle | null = null;
try {
pluginServices = await startPluginServices({

View File

@@ -47,6 +47,20 @@ Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by
clawdbot hooks enable soul-evil
```
### 🚀 boot-md
Runs `BOOT.md` whenever the gateway starts (after channels start).
**Events**: `gateway:startup`
**What it does**: Executes BOOT.md instructions via the agent runner.
**Output**: Whatever the instructions request (for example, outbound messages).
**Enable**:
```bash
clawdbot hooks enable boot-md
```
## Hook Structure
Each hook is a directory containing:
@@ -156,6 +170,7 @@ Currently supported events:
- **command:reset**: `/reset` command
- **command:stop**: `/stop` command
- **agent:bootstrap**: Before workspace bootstrap files are injected
- **gateway:startup**: Gateway startup (after channels start)
More event types coming soon (session lifecycle, agent errors, etc.).
@@ -165,7 +180,7 @@ Hook handlers receive an `InternalHookEvent` object:
```typescript
interface InternalHookEvent {
type: "command" | "session" | "agent";
type: "command" | "session" | "agent" | "gateway";
action: string; // e.g., 'new', 'reset', 'stop'
sessionKey: string;
context: Record<string, unknown>;

View File

@@ -0,0 +1,19 @@
---
name: boot-md
description: "Run BOOT.md on gateway startup"
homepage: https://docs.clawd.bot/hooks#boot-md
metadata:
{
"clawdbot":
{
"emoji": "🚀",
"events": ["gateway:startup"],
"requires": { "config": ["workspace.dir"] },
"install": [{ "id": "bundled", "kind": "bundled", "label": "Bundled with Clawdbot" }],
},
}
---
# Boot Checklist Hook
Runs `BOOT.md` every time the gateway starts, if the file exists in the workspace.

View File

@@ -0,0 +1,27 @@
import type { CliDeps } from "../../../cli/deps.js";
import { createDefaultDeps } from "../../../cli/deps.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import { runBootOnce } from "../../../gateway/boot.js";
import type { HookHandler } from "../../hooks.js";
type BootHookContext = {
cfg?: ClawdbotConfig;
workspaceDir?: string;
deps?: CliDeps;
};
const runBootChecklist: HookHandler = async (event) => {
if (event.type !== "gateway" || event.action !== "startup") {
return;
}
const context = (event.context ?? {}) as BootHookContext;
if (!context.cfg || !context.workspaceDir) {
return;
}
const deps = context.deps ?? createDefaultDeps();
await runBootOnce({ cfg: context.cfg, deps, workspaceDir: context.workspaceDir });
};
export default runBootChecklist;

Some files were not shown because too many files have changed in this diff Show More