Compare commits

..
124 changed files with 1101 additions and 4470 deletions

No files matched your search

+1 -2
View File
@@ -1,6 +1,6 @@
# Repository Guidelines # Repository Guidelines
- Repo: https://github.com/clawdbot/clawdbot - Repo: https://github.com/clawdbot/clawdbot
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n". - GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
## Project Structure & Module Organization ## 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`). - 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,7 +84,6 @@
- When answering questions, respond with high-confidence answers only: verify in code; do not guess. - When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency. - Never update the Carbon dependency.
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`). - 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); don’t hand-roll spinners/bars. - CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes. - 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.** - 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.**
+25 -58
View File
@@ -2,63 +2,26 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.17-7 ## 2026.1.18-4
### Changes ### 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: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
- macOS: stop syncing Peekaboo as a git submodule in postinstall. - macOS: stop syncing Peekaboo as a git submodule in postinstall.
- Swabble: use the tagged Commander Swift package release. - Swabble: use the tagged Commander Swift package release.
- CLI: add `clawdbot acp client` interactive ACP harness for debugging. - 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. - 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) - Memory: add native Gemini embeddings provider for memory search. (#1151) — thanks @gumadeiras.
- 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 ### Fixes
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee. - 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) - 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: 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) - Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151) — thanks @gumadeiras.
- Voice call: separate TwiML fetches from status callbacks to avoid stuck calls. (#1180) — thanks @andrew-kurin. - Agents: surface tool failures when no assistant output is emitted. (#1175) — thanks @vrknetha.
## 2026.1.18-5
### Changes
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
## 2026.1.18-3 ## 2026.1.18-3
### Changes ### 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 host/security/ask routing for gateway + node exec.
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node). - 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. - macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
@@ -72,29 +35,17 @@ Docs: https://docs.clawd.bot
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals - 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 - 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. - 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 ### 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. - 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. - 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. - 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. - 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. - 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 ## 2026.1.17-6
@@ -122,6 +73,22 @@ Docs: https://docs.clawd.bot
### Fixes ### Fixes
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864) - 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 ## 2026.1.17-3
### Changes ### Changes
+1 -1
View File
@@ -43,7 +43,7 @@
"location" : "https://github.com/steipete/Peekaboo.git", "location" : "https://github.com/steipete/Peekaboo.git",
"state" : { "state" : {
"branch" : "main", "branch" : "main",
"revision" : "bace59f90bb276f1c6fb613acfda3935ec4a7a90" "revision" : "b2d0384d9f0f45b945d5f718f8a865bd574d83c2"
} }
}, },
{ {
@@ -8,8 +8,6 @@ struct BridgeNodeInfo: Sendable {
var displayName: String? var displayName: String?
var platform: String? var platform: String?
var version: String? var version: String?
var coreVersion: String?
var uiVersion: String?
var deviceFamily: String? var deviceFamily: String?
var modelIdentifier: String? var modelIdentifier: String?
var remoteAddress: String? var remoteAddress: String?
@@ -149,8 +147,6 @@ actor BridgeConnectionHandler {
displayName: hello.displayName, displayName: hello.displayName,
platform: hello.platform, platform: hello.platform,
version: hello.version, version: hello.version,
coreVersion: hello.coreVersion,
uiVersion: hello.uiVersion,
deviceFamily: hello.deviceFamily, deviceFamily: hello.deviceFamily,
modelIdentifier: hello.modelIdentifier, modelIdentifier: hello.modelIdentifier,
remoteAddress: self.remoteAddressString(), remoteAddress: self.remoteAddressString(),
@@ -175,8 +171,6 @@ actor BridgeConnectionHandler {
displayName: req.displayName, displayName: req.displayName,
platform: req.platform, platform: req.platform,
version: req.version, version: req.version,
coreVersion: req.coreVersion,
uiVersion: req.uiVersion,
deviceFamily: req.deviceFamily, deviceFamily: req.deviceFamily,
modelIdentifier: req.modelIdentifier, modelIdentifier: req.modelIdentifier,
caps: req.caps, caps: req.caps,
@@ -192,8 +186,6 @@ actor BridgeConnectionHandler {
displayName: enriched.displayName, displayName: enriched.displayName,
platform: enriched.platform, platform: enriched.platform,
version: enriched.version, version: enriched.version,
coreVersion: enriched.coreVersion,
uiVersion: enriched.uiVersion,
deviceFamily: enriched.deviceFamily, deviceFamily: enriched.deviceFamily,
modelIdentifier: enriched.modelIdentifier, modelIdentifier: enriched.modelIdentifier,
remoteAddress: enriched.remoteAddress, remoteAddress: enriched.remoteAddress,
@@ -1,4 +1,3 @@
import CryptoKit
import Foundation import Foundation
import OSLog import OSLog
import Security import Security
@@ -122,13 +121,6 @@ struct ExecApprovalsFile: Codable {
var agents: [String: ExecApprovalsAgent]? var agents: [String: ExecApprovalsAgent]?
} }
struct ExecApprovalsSnapshot: Codable {
var path: String
var exists: Bool
var hash: String
var file: ExecApprovalsFile
}
struct ExecApprovalsResolved { struct ExecApprovalsResolved {
let url: URL let url: URL
let socketPath: String let socketPath: String
@@ -161,58 +153,6 @@ enum ExecApprovalsStore {
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path 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 { static func loadFile() -> ExecApprovalsFile {
let url = self.fileURL() let url = self.fileURL()
guard FileManager.default.fileExists(atPath: url.path) else { guard FileManager.default.fileExists(atPath: url.path) else {
@@ -432,12 +372,6 @@ enum ExecApprovalsStore {
return UUID().uuidString 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 { private static func expandPath(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "~" { if trimmed == "~" {
@@ -249,13 +249,6 @@ actor GatewayConnection {
return trimmed.isEmpty ? nil : trimmed 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?) { func snapshotPaths() -> (configPath: String?, stateDir: String?) {
guard let snapshot = self.lastSnapshot else { return (nil, nil) } guard let snapshot = self.lastSnapshot else { return (nil, nil) }
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines) let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -747,8 +747,8 @@ extension MenuSessionsInjector {
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform)) menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
} }
if let version = NodeMenuEntryFormatter.detailRightVersion(entry)?.nonEmpty { if let version = entry.version?.nonEmpty {
menu.addItem(self.makeNodeCopyItem(label: "Version", value: version)) menu.addItem(self.makeNodeCopyItem(label: "Version", value: self.formatVersionLabel(version)))
} }
menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No")) menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No"))
@@ -95,8 +95,6 @@ actor MacNodeBridgePairingClient {
displayName: hello.displayName, displayName: hello.displayName,
platform: hello.platform, platform: hello.platform,
version: hello.version, version: hello.version,
coreVersion: hello.coreVersion,
uiVersion: hello.uiVersion,
deviceFamily: hello.deviceFamily, deviceFamily: hello.deviceFamily,
modelIdentifier: hello.modelIdentifier, modelIdentifier: hello.modelIdentifier,
caps: hello.caps, caps: hello.caps,
@@ -114,19 +114,12 @@ final class MacNodeModeCoordinator {
let caps = self.currentCaps() let caps = self.currentCaps()
let commands = self.currentCommands(caps: caps) let commands = self.currentCommands(caps: caps)
let permissions = await self.currentPermissions() 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( return BridgeHello(
nodeId: Self.nodeId(), nodeId: Self.nodeId(),
displayName: InstanceIdentity.displayName, displayName: InstanceIdentity.displayName,
token: token, token: token,
platform: "macos", platform: "macos",
version: uiVersion, version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
coreVersion: coreVersion?.isEmpty == false ? coreVersion : nil,
uiVersion: uiVersion,
deviceFamily: "Mac", deviceFamily: "Mac",
modelIdentifier: InstanceIdentity.modelIdentifier, modelIdentifier: InstanceIdentity.modelIdentifier,
caps: caps, caps: caps,
@@ -165,8 +158,6 @@ final class MacNodeModeCoordinator {
ClawdbotSystemCommand.notify.rawValue, ClawdbotSystemCommand.notify.rawValue,
ClawdbotSystemCommand.which.rawValue, ClawdbotSystemCommand.which.rawValue,
ClawdbotSystemCommand.run.rawValue, ClawdbotSystemCommand.run.rawValue,
ClawdbotSystemCommand.execApprovalsGet.rawValue,
ClawdbotSystemCommand.execApprovalsSet.rawValue,
] ]
let capsSet = Set(caps) let capsSet = Set(caps)
@@ -64,10 +64,6 @@ actor MacNodeRuntime {
return try await self.handleSystemWhich(req) return try await self.handleSystemWhich(req)
case ClawdbotSystemCommand.notify.rawValue: case ClawdbotSystemCommand.notify.rawValue:
return try await self.handleSystemNotify(req) 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: default:
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command") return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
} }
@@ -680,72 +676,6 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) 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 { private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
guard let sender = self.eventSender else { return } guard let sender = self.eventSender else { return }
guard let data = try? JSONEncoder().encode(payload), guard let data = try? JSONEncoder().encode(payload),
+4 -39
View File
@@ -35,9 +35,8 @@ struct NodeMenuEntryFormatter {
if let platform = self.platformText(entry) { if let platform = self.platformText(entry) {
parts.append("platform \(platform)") parts.append("platform \(platform)")
} }
let versionLabels = self.versionLabels(entry) if let version = entry.version?.nonEmpty {
if !versionLabels.isEmpty { parts.append("app \(self.compactVersion(version))")
parts.append(versionLabels.joined(separator: " · "))
} }
parts.append("status \(self.roleText(entry))") parts.append("status \(self.roleText(entry))")
return parts.joined(separator: " · ") return parts.joined(separator: " · ")
@@ -61,9 +60,8 @@ struct NodeMenuEntryFormatter {
} }
static func detailRightVersion(_ entry: NodeInfo) -> String? { static func detailRightVersion(_ entry: NodeInfo) -> String? {
let labels = self.versionLabels(entry, compact: false) guard let version = entry.version?.nonEmpty else { return nil }
if labels.isEmpty { return nil } return self.shortVersionLabel(version)
return labels.joined(separator: " · ")
} }
static func platformText(_ entry: NodeInfo) -> String? { static func platformText(_ entry: NodeInfo) -> String? {
@@ -129,39 +127,6 @@ struct NodeMenuEntryFormatter {
return compact 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 { static func leadingSymbol(_ entry: NodeInfo) -> String {
if self.isGateway(entry) { if self.isGateway(entry) {
return self.safeSystemSymbol( return self.safeSystemSymbol(
@@ -7,8 +7,6 @@ struct NodeInfo: Identifiable, Codable {
let displayName: String? let displayName: String?
let platform: String? let platform: String?
let version: String? let version: String?
let coreVersion: String?
let uiVersion: String?
let deviceFamily: String? let deviceFamily: String?
let modelIdentifier: String? let modelIdentifier: String?
let remoteIp: String? let remoteIp: String?
@@ -530,8 +530,6 @@ public struct NodePairRequestParams: Codable, Sendable {
public let displayname: String? public let displayname: String?
public let platform: String? public let platform: String?
public let version: String? public let version: String?
public let coreversion: String?
public let uiversion: String?
public let devicefamily: String? public let devicefamily: String?
public let modelidentifier: String? public let modelidentifier: String?
public let caps: [String]? public let caps: [String]?
@@ -544,8 +542,6 @@ public struct NodePairRequestParams: Codable, Sendable {
displayname: String?, displayname: String?,
platform: String?, platform: String?,
version: String?, version: String?,
coreversion: String?,
uiversion: String?,
devicefamily: String?, devicefamily: String?,
modelidentifier: String?, modelidentifier: String?,
caps: [String]?, caps: [String]?,
@@ -557,8 +553,6 @@ public struct NodePairRequestParams: Codable, Sendable {
self.displayname = displayname self.displayname = displayname
self.platform = platform self.platform = platform
self.version = version self.version = version
self.coreversion = coreversion
self.uiversion = uiversion
self.devicefamily = devicefamily self.devicefamily = devicefamily
self.modelidentifier = modelidentifier self.modelidentifier = modelidentifier
self.caps = caps self.caps = caps
@@ -571,8 +565,6 @@ public struct NodePairRequestParams: Codable, Sendable {
case displayname = "displayName" case displayname = "displayName"
case platform case platform
case version case version
case coreversion = "coreVersion"
case uiversion = "uiVersion"
case devicefamily = "deviceFamily" case devicefamily = "deviceFamily"
case modelidentifier = "modelIdentifier" case modelidentifier = "modelIdentifier"
case caps case caps
@@ -63,8 +63,6 @@ public struct BridgeHello: Codable, Sendable {
public let token: String? public let token: String?
public let platform: String? public let platform: String?
public let version: String? public let version: String?
public let coreVersion: String?
public let uiVersion: String?
public let deviceFamily: String? public let deviceFamily: String?
public let modelIdentifier: String? public let modelIdentifier: String?
public let caps: [String]? public let caps: [String]?
@@ -78,8 +76,6 @@ public struct BridgeHello: Codable, Sendable {
token: String?, token: String?,
platform: String?, platform: String?,
version: String?, version: String?,
coreVersion: String? = nil,
uiVersion: String? = nil,
deviceFamily: String? = nil, deviceFamily: String? = nil,
modelIdentifier: String? = nil, modelIdentifier: String? = nil,
caps: [String]? = nil, caps: [String]? = nil,
@@ -92,8 +88,6 @@ public struct BridgeHello: Codable, Sendable {
self.token = token self.token = token
self.platform = platform self.platform = platform
self.version = version self.version = version
self.coreVersion = coreVersion
self.uiVersion = uiVersion
self.deviceFamily = deviceFamily self.deviceFamily = deviceFamily
self.modelIdentifier = modelIdentifier self.modelIdentifier = modelIdentifier
self.caps = caps self.caps = caps
@@ -127,8 +121,6 @@ public struct BridgePairRequest: Codable, Sendable {
public let displayName: String? public let displayName: String?
public let platform: String? public let platform: String?
public let version: String? public let version: String?
public let coreVersion: String?
public let uiVersion: String?
public let deviceFamily: String? public let deviceFamily: String?
public let modelIdentifier: String? public let modelIdentifier: String?
public let caps: [String]? public let caps: [String]?
@@ -143,8 +135,6 @@ public struct BridgePairRequest: Codable, Sendable {
displayName: String?, displayName: String?,
platform: String?, platform: String?,
version: String?, version: String?,
coreVersion: String? = nil,
uiVersion: String? = nil,
deviceFamily: String? = nil, deviceFamily: String? = nil,
modelIdentifier: String? = nil, modelIdentifier: String? = nil,
caps: [String]? = nil, caps: [String]? = nil,
@@ -158,8 +148,6 @@ public struct BridgePairRequest: Codable, Sendable {
self.displayName = displayName self.displayName = displayName
self.platform = platform self.platform = platform
self.version = version self.version = version
self.coreVersion = coreVersion
self.uiVersion = uiVersion
self.deviceFamily = deviceFamily self.deviceFamily = deviceFamily
self.modelIdentifier = modelIdentifier self.modelIdentifier = modelIdentifier
self.caps = caps self.caps = caps
@@ -4,8 +4,6 @@ public enum ClawdbotSystemCommand: String, Codable, Sendable {
case run = "system.run" case run = "system.run"
case which = "system.which" case which = "system.which"
case notify = "system.notify" case notify = "system.notify"
case execApprovalsGet = "system.execApprovals.get"
case execApprovalsSet = "system.execApprovals.set"
} }
public enum ClawdbotNotificationPriority: String, Codable, Sendable { public enum ClawdbotNotificationPriority: String, Codable, Sendable {
+10 -45
View File
@@ -1,13 +1,11 @@
--- ---
summary: "Slack setup for socket or HTTP webhook mode" summary: "Slack socket mode setup and Clawdbot config"
read_when: "Setting up Slack or debugging Slack socket/HTTP mode" read_when: "Setting up Slack or debugging Slack socket mode"
--- ---
# Slack # Slack (socket mode)
## Socket mode (default) ## Quick setup (beginner)
### Quick setup (beginner)
1) Create a Slack app and enable **Socket Mode**. 1) Create a Slack app and enable **Socket Mode**.
2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`). 2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`).
3) Set tokens for Clawdbot and start the gateway. 3) Set tokens for Clawdbot and start the gateway.
@@ -25,7 +23,7 @@ Minimal config:
} }
``` ```
### Setup ## Setup
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps. 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-...`). 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-...`). 3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
@@ -45,7 +43,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. 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): Set tokens via env vars (recommended):
- `SLACK_APP_TOKEN=xapp-...` - `SLACK_APP_TOKEN=xapp-...`
@@ -65,7 +63,7 @@ Or via config:
} }
``` ```
### User token (optional) ## User token (optional)
Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history, Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
pins, reactions, emoji, member info). By default this stays read-only: reads 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 prefer the user token when present, and writes still use the bot token unless
@@ -104,51 +102,18 @@ Example with userTokenReadOnly explicitly set (allow user token writes):
} }
``` ```
#### Token usage ### Token usage
- Read operations (history, reactions list, pins list, emoji list, member info, - Read operations (history, reactions list, pins list, emoji list, member info,
search) prefer the user token when configured, otherwise the bot token. search) prefer the user token when configured, otherwise the bot token.
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin, - Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
file uploads) use the bot token by default. If `userTokenReadOnly: false` and file uploads) use the bot token by default. If `userTokenReadOnly: false` and
no bot token is available, Clawdbot falls back to the user token. 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. - `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). - Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
## HTTP mode (Events API) ## Manifest (optional)
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 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. user scopes if you plan to configure a user token.
-44
View File
@@ -1,44 +0,0 @@
---
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 node’s 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`.
-3
View File
@@ -9,9 +9,6 @@ read_when:
Manage the Gateway daemon (background service). Manage the Gateway daemon (background service).
Note: `clawdbot service gateway …` is the preferred surface; `daemon` remains
as a legacy alias for compatibility.
Related: Related:
- Gateway CLI: [Gateway](/cli/gateway) - Gateway CLI: [Gateway](/cli/gateway)
- macOS platform notes: [macOS](/platforms/macos) - macOS platform notes: [macOS](/platforms/macos)
+4 -19
View File
@@ -7,7 +7,7 @@ read_when:
# `clawdbot hooks` # `clawdbot hooks`
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, and gateway startup). Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
Related: Related:
- Hooks: [Hooks](/hooks) - Hooks: [Hooks](/hooks)
@@ -29,10 +29,9 @@ List all discovered hooks from workspace, managed, and bundled directories.
**Example output:** **Example output:**
``` ```
Hooks (4/4 ready) Hooks (3/3 ready)
Ready: Ready:
🚀 boot-md ✓ - Run BOOT.md on gateway startup
📝 command-logger ✓ - Log all command events to a centralized audit file 📝 command-logger ✓ - Log all command events to a centralized audit file
💾 session-memory ✓ - Save session context to memory when /new command is issued 💾 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 😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
@@ -108,8 +107,8 @@ Show summary of hook eligibility status (how many are ready vs. not ready).
``` ```
Hooks Status Hooks Status
Total hooks: 4 Total hooks: 2
Ready: 4 Ready: 2
Not ready: 0 Not ready: 0
``` ```
@@ -274,17 +273,3 @@ clawdbot hooks enable soul-evil
``` ```
**See:** [SOUL Evil Hook](/hooks/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)
+6 -33
View File
@@ -29,13 +29,11 @@ This page describes the current CLI behavior. If commands change, update this do
- [`sessions`](/cli/sessions) - [`sessions`](/cli/sessions)
- [`gateway`](/cli/gateway) - [`gateway`](/cli/gateway)
- [`daemon`](/cli/daemon) - [`daemon`](/cli/daemon)
- [`service`](/cli/service)
- [`logs`](/cli/logs) - [`logs`](/cli/logs)
- [`models`](/cli/models) - [`models`](/cli/models)
- [`memory`](/cli/memory) - [`memory`](/cli/memory)
- [`nodes`](/cli/nodes) - [`nodes`](/cli/nodes)
- [`node`](/cli/node) - [`node`](/cli/node)
- [`approvals`](/cli/approvals)
- [`sandbox`](/cli/sandbox) - [`sandbox`](/cli/sandbox)
- [`tui`](/cli/tui) - [`tui`](/cli/tui)
- [`browser`](/cli/browser) - [`browser`](/cli/browser)
@@ -145,21 +143,6 @@ clawdbot [--dev] [--profile <name>] <command>
start start
stop stop
restart restart
service
gateway
status
install
uninstall
start
stop
restart
node
status
install
uninstall
start
stop
restart
logs logs
models models
list list
@@ -197,10 +180,6 @@ clawdbot [--dev] [--profile <name>] <command>
start start
stop stop
restart restart
approvals
get
set
allowlist add|remove
browser browser
status status
start start
@@ -541,9 +520,6 @@ Options:
- `--verbose` - `--verbose`
- `--debug` (alias for `--verbose`) - `--debug` (alias for `--verbose`)
Notes:
- Overview includes Gateway + Node service status when available.
### Usage tracking ### Usage tracking
Clawdbot can surface provider usage/quota when OAuth/API creds are available. Clawdbot can surface provider usage/quota when OAuth/API creds are available.
@@ -805,15 +781,12 @@ All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
Subcommands: Subcommands:
- `node start --host <gateway-host> --port 18790` - `node start --host <gateway-host> --port 18790`
- `node service status` - `node daemon status`
- `node service install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]` - `node daemon install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
- `node service uninstall` - `node daemon uninstall`
- `node service start` - `node daemon start`
- `node service stop` - `node daemon stop`
- `node service restart` - `node daemon restart`
Legacy alias:
- `node daemon …` (same as `node service …`)
## Nodes ## Nodes
+6 -17
View File
@@ -37,14 +37,12 @@ Options:
- `--node-id <id>`: Override node id (clears pairing token) - `--node-id <id>`: Override node id (clears pairing token)
- `--display-name <name>`: Override the node display name - `--display-name <name>`: Override the node display name
## Service (background) ## Daemon (background service)
Install a headless node host as a user service. Install a headless node host as a user service.
```bash ```bash
clawdbot node service install --host <gateway-host> --port 18790 clawdbot node daemon install --host <gateway-host> --port 18790
# or
clawdbot service node install --host <gateway-host> --port 18790
``` ```
Options: Options:
@@ -59,20 +57,12 @@ Options:
Manage the service: 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 ```bash
clawdbot node daemon status clawdbot node daemon status
clawdbot node daemon start
clawdbot node daemon stop
clawdbot node daemon restart
clawdbot node daemon uninstall
``` ```
## Pairing ## Pairing
@@ -93,4 +83,3 @@ The node host stores its node id + token in `~/.clawdbot/node.json`.
- `~/.clawdbot/exec-approvals.json` - `~/.clawdbot/exec-approvals.json`
- [Exec approvals](/tools/exec-approvals) - [Exec approvals](/tools/exec-approvals)
- `clawdbot approvals --node <id|name|ip>` (edit from the Gateway)
-51
View File
@@ -1,51 +0,0 @@
---
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)
-1
View File
@@ -19,5 +19,4 @@ clawdbot status --usage
Notes: Notes:
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal). - `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
- Output includes per-agent session stores when multiple agents are configured. - 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)). - Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
+4 -78
View File
@@ -5,19 +5,13 @@ read_when:
--- ---
# Agent Loop (Clawdbot) # Agent Loop (Clawdbot)
An agentic loop is the full “real” run of an agent: intake → context assembly → model inference → Short, exact flow of one agent run.
tool execution → streaming replies → persistence. It’s 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 ## Entry points
- Gateway RPC: `agent` and `agent.wait`. - Gateway RPC: `agent` and `agent.wait`.
- CLI: `agent` command. - CLI: `agent` command.
## How it works (high-level) ## High-level flow
1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately. 1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately.
2) `agentCommand` runs the agent: 2) `agentCommand` runs the agent:
- resolves model + thinking/verbose defaults - resolves model + thinking/verbose defaults
@@ -25,9 +19,8 @@ wired end-to-end.
- calls `runEmbeddedPiAgent` (pi-agent-core runtime) - calls `runEmbeddedPiAgent` (pi-agent-core runtime)
- emits **lifecycle end/error** if the embedded loop does not emit one - emits **lifecycle end/error** if the embedded loop does not emit one
3) `runEmbeddedPiAgent`: 3) `runEmbeddedPiAgent`:
- serializes runs via per-session + global queues - builds `AgentSession` and subscribes to pi events
- resolves model + auth profile and builds the pi session - streams assistant deltas + tool events
- subscribes to pi events and streams assistant/tool deltas
- enforces timeout -> aborts run if exceeded - enforces timeout -> aborts run if exceeded
- returns payloads + usage metadata - returns payloads + usage metadata
4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdbot `agent` stream: 4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdbot `agent` stream:
@@ -38,73 +31,6 @@ wired end-to-end.
- waits for **lifecycle end/error** for `runId` - waits for **lifecycle end/error** for `runId`
- returns `{ status: ok|error|timeout, startedAt, endedAt, error? }` - 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 Clawdbot’s 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) ## Event streams (today)
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`) - `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
- `assistant`: streamed deltas from pi-agent-core - `assistant`: streamed deltas from pi-agent-core
-4
View File
@@ -86,10 +86,6 @@ These are the standard files Clawdbot expects inside the workspace:
- Optional tiny checklist for heartbeat runs. - Optional tiny checklist for heartbeat runs.
- Keep it short to avoid token burn. - 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` - `BOOTSTRAP.md`
- One-time first-run ritual. - One-time first-run ritual.
- Only created for a brand-new workspace. - Only created for a brand-new workspace.
-10
View File
@@ -18,7 +18,6 @@ The prompt is intentionally compact and uses fixed sections:
- **Skills** (when available): tells the model how to load skill instructions on demand. - **Skills** (when available): tells the model how to load skill instructions on demand.
- **Clawdbot Self-Update**: how to run `config.apply` and `update.run`. - **Clawdbot Self-Update**: how to run `config.apply` and `update.run`.
- **Workspace**: working directory (`agents.defaults.workspace`). - **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. - **Workspace Files (injected)**: indicates bootstrap files are included below.
- **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available. - **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available.
- **Current Date & Time**: user-local time, timezone, and time format. - **Current Date & Time**: user-local time, timezone, and time format.
@@ -99,12 +98,3 @@ Skills section is omitted.
``` ```
This keeps the base prompt small while still enabling targeted skill usage. 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).
-7
View File
@@ -657,10 +657,6 @@
"source": "/templates/AGENTS", "source": "/templates/AGENTS",
"destination": "/reference/templates/AGENTS" "destination": "/reference/templates/AGENTS"
}, },
{
"source": "/templates/BOOT",
"destination": "/reference/templates/BOOT"
},
{ {
"source": "/templates/BOOTSTRAP", "source": "/templates/BOOTSTRAP",
"destination": "/reference/templates/BOOTSTRAP" "destination": "/reference/templates/BOOTSTRAP"
@@ -826,10 +822,8 @@
"cli/models", "cli/models",
"cli/logs", "cli/logs",
"cli/nodes", "cli/nodes",
"cli/approvals",
"cli/gateway", "cli/gateway",
"cli/daemon", "cli/daemon",
"cli/service",
"cli/tui", "cli/tui",
"cli/voicecall", "cli/voicecall",
"cli/wake", "cli/wake",
@@ -1057,7 +1051,6 @@
"reference/RELEASING", "reference/RELEASING",
"reference/AGENTS.default", "reference/AGENTS.default",
"reference/templates/AGENTS", "reference/templates/AGENTS",
"reference/templates/BOOT",
"reference/templates/BOOTSTRAP", "reference/templates/BOOTSTRAP",
"reference/templates/HEARTBEAT", "reference/templates/HEARTBEAT",
"reference/templates/IDENTITY", "reference/templates/IDENTITY",
+2 -30
View File
@@ -37,11 +37,10 @@ The hooks system allows you to:
### Bundled Hooks ### Bundled Hooks
Clawdbot ships with four bundled hooks that are automatically discovered: Clawdbot ships with three bundled hooks that are automatically discovered:
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new` - **💾 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` - **📝 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 - **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
List available hooks: List available hooks:
@@ -196,7 +195,7 @@ Each event includes:
```typescript ```typescript
{ {
type: 'command' | 'session' | 'agent' | 'gateway', type: 'command' | 'session' | 'agent',
action: string, // e.g., 'new', 'reset', 'stop' action: string, // e.g., 'new', 'reset', 'stop'
sessionKey: string, // Session identifier sessionKey: string, // Session identifier
timestamp: Date, // When the event occurred timestamp: Date, // When the event occurred
@@ -229,12 +228,6 @@ Triggered when agent commands are issued:
- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`) - **`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 ### Future Events
Planned event types: Planned event types:
@@ -549,26 +542,6 @@ 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 ## Best Practices
### Keep Handlers Fast ### Keep Handlers Fast
@@ -641,7 +614,6 @@ The gateway logs hook loading at startup:
``` ```
Registered hook: session-memory -> command:new Registered hook: session-memory -> command:new
Registered hook: command-logger -> command Registered hook: command-logger -> command
Registered hook: boot-md -> gateway:startup
``` ```
### Check Discovery ### Check Discovery
+2 -2
View File
@@ -149,8 +149,8 @@ Notes:
## System commands (node host / mac node) ## System commands (node host / mac node)
The macOS node exposes `system.run`, `system.notify`, and `system.execApprovals.get/set`. The macOS node exposes `system.run` and `system.notify`. The headless node host
The headless node host exposes `system.run`, `system.which`, and `system.execApprovals.get/set`. exposes `system.run` and `system.which`.
Examples: Examples:
-23
View File
@@ -104,29 +104,6 @@ Rules:
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the - If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
**active reply model** when its provider supports the capability. **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 haven’t 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) ## Capabilities (optional)
If you set `capabilities`, the entry only runs for those media types. For shared If you set `capabilities`, the entry only runs for those media types. For shared
lists, Clawdbot can infer defaults: lists, Clawdbot can infer defaults:
+1 -2
View File
@@ -37,7 +37,6 @@ 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. - 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 (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` - [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser` - [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
- [Matrix](/channels/matrix) — `@clawdbot/matrix` - [Matrix](/channels/matrix) — `@clawdbot/matrix`
@@ -46,7 +45,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) - 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) - 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) - Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (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) - Copilot Proxy (provider auth) — bundled as `copilot-proxy` (disabled by default)
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
register: register:
-21
View File
@@ -6,27 +6,6 @@ read_when:
--- ---
# Github Copilot # 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 proxy’s `/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 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 the GitHub device flow, saves an auth profile, and updates your config to use that
profile. profile.
-116
View File
@@ -1,116 +0,0 @@
---
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
- You’re 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 isn’t 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 skill’s provider.
See [Skills](/tools/skills).
-9
View File
@@ -1,9 +0,0 @@
---
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.
+2 -6
View File
@@ -107,12 +107,8 @@ 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 add/remove allowlist patterns, then **Save**. The UI shows **last used** metadata
per pattern so you can keep the list tidy. per pattern so you can keep the list tidy.
The target selector chooses **Gateway** (local approvals) or a **Node**. Nodes Note: the Control UI edits the approvals file on the **Gateway host**. For a
must advertise `system.execApprovals.get/set` (macOS app or headless node host). headless node host, edit its local `~/.clawdbot/exec-approvals.json` directly.
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 ## Approval flow
+1 -1
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.*`) - Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
- Skills: status, enable/disable, install, API key updates (`skills.*`) - Skills: status, enable/disable, install, API key updates (`skills.*`)
- Nodes: list + caps (`node.list`) - Nodes: list + caps (`node.list`)
- Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`) - Exec approvals: edit allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`)
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`) - Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
- Config: apply + restart with validation (`config.apply`) and wake the last active session - 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 - Config writes include a base-hash guard to prevent clobbering concurrent edits
@@ -1,4 +1,5 @@
import os from "node:os"; import os from "node:os";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk"; import type { PluginRuntime } from "clawdbot/plugin-sdk";
+4 -4
View File
@@ -37,8 +37,8 @@ describeWithKey("memory plugin e2e", () => {
// Dynamic import to avoid loading LanceDB when not testing // Dynamic import to avoid loading LanceDB when not testing
const { default: memoryPlugin } = await import("./index.js"); const { default: memoryPlugin } = await import("./index.js");
expect(memoryPlugin.id).toBe("memory-lancedb"); expect(memoryPlugin.id).toBe("memory");
expect(memoryPlugin.name).toBe("Memory (LanceDB)"); expect(memoryPlugin.name).toBe("Memory (Vector)");
expect(memoryPlugin.kind).toBe("memory"); expect(memoryPlugin.kind).toBe("memory");
expect(memoryPlugin.configSchema).toBeDefined(); expect(memoryPlugin.configSchema).toBeDefined();
expect(memoryPlugin.register).toBeInstanceOf(Function); expect(memoryPlugin.register).toBeInstanceOf(Function);
@@ -185,8 +185,8 @@ describeWithKey("memory plugin live tests", () => {
const logs: string[] = []; const logs: string[] = [];
const mockApi = { const mockApi = {
id: "memory-lancedb", id: "memory",
name: "Memory (LanceDB)", name: "Memory (Vector)",
source: "test", source: "test",
config: {}, config: {},
pluginConfig: { pluginConfig: {
+13 -15
View File
@@ -1,5 +1,5 @@
/** /**
* Clawdbot Memory (LanceDB) Plugin * Clawdbot Memory Plugin
* *
* Long-term memory with vector search for AI conversations. * Long-term memory with vector search for AI conversations.
* Uses LanceDB for storage and OpenAI for embeddings. * Uses LanceDB for storage and OpenAI for embeddings.
@@ -214,9 +214,9 @@ function detectCategory(text: string): MemoryCategory {
// ============================================================================ // ============================================================================
const memoryPlugin = { const memoryPlugin = {
id: "memory-lancedb", id: "memory",
name: "Memory (LanceDB)", name: "Memory (Vector)",
description: "LanceDB-backed long-term memory with auto-recall/capture", description: "Long-term memory with vector search and seamless auto-recall/capture",
kind: "memory" as const, kind: "memory" as const,
configSchema: memoryConfigSchema, configSchema: memoryConfigSchema,
@@ -227,9 +227,7 @@ const memoryPlugin = {
const db = new MemoryDB(resolvedDbPath, vectorDim); const db = new MemoryDB(resolvedDbPath, vectorDim);
const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model!); const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model!);
api.logger.info( api.logger.info(`memory: plugin registered (db: ${resolvedDbPath}, lazy init)`);
`memory-lancedb: plugin registered (db: ${resolvedDbPath}, lazy init)`,
);
// ======================================================================== // ========================================================================
// Tools // Tools
@@ -419,7 +417,7 @@ const memoryPlugin = {
({ program }) => { ({ program }) => {
const memory = program const memory = program
.command("ltm") .command("ltm")
.description("LanceDB memory plugin commands"); .description("Long-term memory plugin commands");
memory memory
.command("list") .command("list")
@@ -479,14 +477,14 @@ const memoryPlugin = {
.join("\n"); .join("\n");
api.logger.info?.( api.logger.info?.(
`memory-lancedb: injecting ${results.length} memories into context`, `memory: injecting ${results.length} memories into context`,
); );
return { return {
prependContext: `<relevant-memories>\nThe following memories may be relevant to this conversation:\n${memoryContext}\n</relevant-memories>`, prependContext: `<relevant-memories>\nThe following memories may be relevant to this conversation:\n${memoryContext}\n</relevant-memories>`,
}; };
} catch (err) { } catch (err) {
api.logger.warn(`memory-lancedb: recall failed: ${String(err)}`); api.logger.warn(`memory: recall failed: ${String(err)}`);
} }
}); });
} }
@@ -561,10 +559,10 @@ const memoryPlugin = {
} }
if (stored > 0) { if (stored > 0) {
api.logger.info(`memory-lancedb: auto-captured ${stored} memories`); api.logger.info(`memory: auto-captured ${stored} memories`);
} }
} catch (err) { } catch (err) {
api.logger.warn(`memory-lancedb: capture failed: ${String(err)}`); api.logger.warn(`memory: capture failed: ${String(err)}`);
} }
}); });
} }
@@ -574,14 +572,14 @@ const memoryPlugin = {
// ======================================================================== // ========================================================================
api.registerService({ api.registerService({
id: "memory-lancedb", id: "memory",
start: () => { start: () => {
api.logger.info( api.logger.info(
`memory-lancedb: initialized (db: ${resolvedDbPath}, model: ${cfg.embedding.model})`, `memory: initialized (db: ${resolvedDbPath}, model: ${cfg.embedding.model})`,
); );
}, },
stop: () => { stop: () => {
api.logger.info("memory-lancedb: stopped"); api.logger.info("memory: stopped");
}, },
}); });
}, },
+5 -7
View File
@@ -1,16 +1,14 @@
{ {
"name": "@clawdbot/memory-lancedb", "name": "@clawdbot/memory",
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture", "description": "Clawdbot long-term memory plugin with vector search and seamless auto-recall/capture",
"dependencies": { "dependencies": {
"@lancedb/lancedb": "^0.15.0",
"@sinclair/typebox": "0.34.47", "@sinclair/typebox": "0.34.47",
"openai": "^4.104.0" "@lancedb/lancedb": "^0.15.0",
"openai": "^4.77.0"
}, },
"clawdbot": { "clawdbot": {
"extensions": [ "extensions": ["./index.ts"]
"./index.ts"
]
} }
} }
+3 -3
View File
@@ -9,10 +9,10 @@
] ]
}, },
"dependencies": { "dependencies": {
"@microsoft/agents-hosting": "^1.2.2",
"@microsoft/agents-hosting-express": "^1.2.2",
"@microsoft/agents-hosting-extensions-teams": "^1.2.2",
"clawdbot": "workspace:*", "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", "express": "^5.2.1",
"proper-lockfile": "^4.1.2" "proper-lockfile": "^4.1.2"
} }
@@ -1,92 +0,0 @@
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);
});
});
+16 -88
View File
@@ -62,37 +62,6 @@ export class TwilioProvider implements VoiceCallProvider {
/** Map of call SID to stream SID for media streams */ /** Map of call SID to stream SID for media streams */
private callStreamMap = new Map<string, string>(); 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 = {}) { constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
if (!config.accountSid) { if (!config.accountSid) {
throw new Error("Twilio Account SID is required"); throw new Error("Twilio Account SID is required");
@@ -180,14 +149,7 @@ export class TwilioProvider implements VoiceCallProvider {
typeof ctx.query?.callId === "string" && ctx.query.callId.trim() typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
? ctx.query.callId.trim() ? ctx.query.callId.trim()
: undefined; : undefined;
const requestType = const event = this.normalizeEvent(params, callIdFromQuery);
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, // 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. // so the webhook response is typically a pause to keep the call alive.
@@ -266,14 +228,8 @@ export class TwilioProvider implements VoiceCallProvider {
case "busy": case "busy":
case "no-answer": case "no-answer":
case "failed": case "failed":
if (callIdOverride) {
this.deleteStoredTwiml(callIdOverride);
}
return { ...baseEvent, type: "call.ended", reason: callStatus }; return { ...baseEvent, type: "call.ended", reason: callStatus };
case "canceled": case "canceled":
if (callIdOverride) {
this.deleteStoredTwiml(callIdOverride);
}
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" }; return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
default: default:
return null; return null;
@@ -298,29 +254,11 @@ export class TwilioProvider implements VoiceCallProvider {
const params = new URLSearchParams(ctx.rawBody); const params = new URLSearchParams(ctx.rawBody);
const callStatus = params.get("CallStatus"); const callStatus = params.get("CallStatus");
const direction = params.get("Direction"); 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;
// Avoid logging webhook params/TwiML (may contain PII). console.log(
`[voice-call] generateTwimlResponse: status=${callStatus} direction=${direction}`,
);
// 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 // For inbound calls, answer immediately with stream
if (direction === "inbound") { if (direction === "inbound") {
const streamUrl = this.getStreamUrl(); const streamUrl = this.getStreamUrl();
@@ -387,39 +325,31 @@ export class TwilioProvider implements VoiceCallProvider {
* Otherwise, uses webhook URL for dynamic TwiML. * Otherwise, uses webhook URL for dynamic TwiML.
*/ */
async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> { async initiateCall(input: InitiateCallInput): Promise<InitiateCallResult> {
const webhookUrl = new URL(input.webhookUrl); const url = new URL(input.webhookUrl);
webhookUrl.searchParams.set("callId", input.callId); url.searchParams.set("callId", input.callId);
const twimlUrl = new URL(webhookUrl); // Build request params
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> = { const params: Record<string, string> = {
To: input.to, To: input.to,
From: input.from, From: input.from,
Url: twimlUrl.toString(), // TwiML serving endpoint StatusCallback: url.toString(),
StatusCallback: statusUrl.toString(), // Separate status callback endpoint
StatusCallbackEvent: "initiated ringing answered completed", StatusCallbackEvent: "initiated ringing answered completed",
Timeout: "30", 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>( const result = await this.apiRequest<TwilioCallResponse>(
"/Calls.json", "/Calls.json",
params, params,
); );
this.callWebhookUrls.set(result.sid, webhookUrl.toString()); this.callWebhookUrls.set(result.sid, url.toString());
return { return {
providerCallId: result.sid, providerCallId: result.sid,
@@ -431,8 +361,6 @@ export class TwilioProvider implements VoiceCallProvider {
* Hang up a call via Twilio API. * Hang up a call via Twilio API.
*/ */
async hangupCall(input: HangupCallInput): Promise<void> { async hangupCall(input: HangupCallInput): Promise<void> {
this.deleteStoredTwimlForProviderCall(input.providerCallId);
this.callWebhookUrls.delete(input.providerCallId); this.callWebhookUrls.delete(input.providerCallId);
await this.apiRequest( await this.apiRequest(
+6 -6
View File
@@ -168,7 +168,7 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.2.1", "express": "^5.2.1",
"file-type": "^21.3.0", "file-type": "^21.3.0",
"grammy": "^1.39.3", "grammy": "^1.39.2",
"hono": "4.11.4", "hono": "4.11.4",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"json5": "^2.2.3", "json5": "^2.2.3",
@@ -182,7 +182,7 @@
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"sqlite-vec": "0.1.7-alpha.2", "sqlite-vec": "0.1.7-alpha.2",
"tar": "7.5.3", "tar": "^7.5.3",
"tslog": "^4.10.2", "tslog": "^4.10.2",
"undici": "^7.18.2", "undici": "^7.18.2",
"ws": "^8.19.0", "ws": "^8.19.0",
@@ -200,24 +200,24 @@
"@types/body-parser": "^1.19.6", "@types/body-parser": "^1.19.6",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/node": "^25.0.9", "@types/node": "^25.0.6",
"@types/proper-lockfile": "^4.1.4", "@types/proper-lockfile": "^4.1.4",
"@types/qrcode-terminal": "^0.12.2", "@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.0.17", "@vitest/coverage-v8": "^4.0.16",
"docx-preview": "^0.3.7", "docx-preview": "^0.3.7",
"lit": "^3.3.2", "lit": "^3.3.2",
"lucide": "^0.562.0", "lucide": "^0.562.0",
"ollama": "^0.6.3", "ollama": "^0.6.3",
"oxfmt": "0.24.0", "oxfmt": "0.24.0",
"oxlint": "^1.39.0", "oxlint": "^1.39.0",
"oxlint-tsgolint": "^0.11.1", "oxlint-tsgolint": "^0.11.0",
"quicktype-core": "^23.2.6", "quicktype-core": "^23.2.6",
"rolldown": "1.0.0-beta.59", "rolldown": "1.0.0-beta.59",
"signal-utils": "^0.21.1", "signal-utils": "^0.21.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^4.0.17", "vitest": "^4.0.16",
"wireit": "^0.14.12" "wireit": "^0.14.12"
}, },
"pnpm": { "pnpm": {
+737 -700
View File
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -50,15 +50,15 @@ import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
const DEFAULT_MAX_OUTPUT = clampNumber( const DEFAULT_MAX_OUTPUT = clampNumber(
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"), readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
200_000, 30_000,
1_000, 1_000,
200_000, 150_000,
); );
const DEFAULT_PENDING_MAX_OUTPUT = clampNumber( const DEFAULT_PENDING_MAX_OUTPUT = clampNumber(
readEnvInt("CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS"), readEnvInt("CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS"),
200_000, 30_000,
1_000, 1_000,
200_000, 150_000,
); );
const DEFAULT_PATH = const DEFAULT_PATH =
process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
-8
View File
@@ -6,7 +6,6 @@ import { shouldLogVerbose } from "../globals.js";
import { createSubsystemLogger } from "../logging.js"; import { createSubsystemLogger } from "../logging.js";
import { runCommandWithTimeout } from "../process/exec.js"; import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { resolveClawdbotDocsPath } from "./docs-path.js";
import { resolveSessionAgentIds } from "./agent-scope.js"; import { resolveSessionAgentIds } from "./agent-scope.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js";
import { resolveCliBackendConfig } from "./cli-backends.js"; import { resolveCliBackendConfig } from "./cli-backends.js";
@@ -84,12 +83,6 @@ export async function runCliAgent(params: {
sessionAgentId === defaultAgentId sessionAgentId === defaultAgentId
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined; : undefined;
const docsPath = await resolveClawdbotDocsPath({
workspaceDir,
argv1: process.argv[1],
cwd: process.cwd(),
moduleUrl: import.meta.url,
});
const systemPrompt = buildSystemPrompt({ const systemPrompt = buildSystemPrompt({
workspaceDir, workspaceDir,
config: params.config, config: params.config,
@@ -97,7 +90,6 @@ export async function runCliAgent(params: {
extraSystemPrompt, extraSystemPrompt,
ownerNumbers: params.ownerNumbers, ownerNumbers: params.ownerNumbers,
heartbeatPrompt, heartbeatPrompt,
docsPath: docsPath ?? undefined,
tools: [], tools: [],
contextFiles, contextFiles,
modelDisplay, modelDisplay,
-2
View File
@@ -168,7 +168,6 @@ export function buildSystemPrompt(params: {
extraSystemPrompt?: string; extraSystemPrompt?: string;
ownerNumbers?: string[]; ownerNumbers?: string[];
heartbeatPrompt?: string; heartbeatPrompt?: string;
docsPath?: string;
tools: AgentTool[]; tools: AgentTool[];
contextFiles?: EmbeddedContextFile[]; contextFiles?: EmbeddedContextFile[];
modelDisplay: string; modelDisplay: string;
@@ -183,7 +182,6 @@ export function buildSystemPrompt(params: {
ownerNumbers: params.ownerNumbers, ownerNumbers: params.ownerNumbers,
reasoningTagHint: false, reasoningTagHint: false,
heartbeatPrompt: params.heartbeatPrompt, heartbeatPrompt: params.heartbeatPrompt,
docsPath: params.docsPath,
runtimeInfo: { runtimeInfo: {
host: "clawdbot", host: "clawdbot",
os: `${os.type()} ${os.release()}`, os: `${os.type()} ${os.release()}`,
-27
View File
@@ -1,27 +0,0 @@
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;
}
@@ -26,11 +26,6 @@ describe("classifyFailoverReason", () => {
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout"); expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
expect(classifyFailoverReason("string should match pattern")).toBe("format"); expect(classifyFailoverReason("string should match pattern")).toBe("format");
expect(classifyFailoverReason("bad request")).toBeNull(); 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", () => { it("classifies OpenAI usage limit errors as rate_limit", () => {
expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe( expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe(
@@ -1,16 +0,0 @@
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);
});
});
@@ -23,10 +23,5 @@ describe("isCloudCodeAssistFormatError", () => {
}); });
it("ignores unrelated errors", () => { it("ignores unrelated errors", () => {
expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false); 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);
}); });
}); });
-2
View File
@@ -21,13 +21,11 @@ export {
isContextOverflowError, isContextOverflowError,
isFailoverAssistantError, isFailoverAssistantError,
isFailoverErrorMessage, isFailoverErrorMessage,
isImageDimensionErrorMessage,
isOverloadedErrorMessage, isOverloadedErrorMessage,
isRawApiErrorPayload, isRawApiErrorPayload,
isRateLimitAssistantError, isRateLimitAssistantError,
isRateLimitErrorMessage, isRateLimitErrorMessage,
isTimeoutErrorMessage, isTimeoutErrorMessage,
parseImageDimensionError,
} from "./pi-embedded-helpers/errors.js"; } from "./pi-embedded-helpers/errors.js";
export { export {
downgradeGeminiHistory, downgradeGeminiHistory,
+2 -29
View File
@@ -339,6 +339,7 @@ const ERROR_PATTERNS = {
"no api key found", "no api key found",
], ],
format: [ format: [
"invalid_request_error",
"string should match pattern", "string should match pattern",
"tool_use.id", "tool_use.id",
"tool_use_id", "tool_use_id",
@@ -347,10 +348,6 @@ const ERROR_PATTERNS = {
], ],
} as const; } 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 { function matchesErrorPatterns(raw: string, patterns: readonly ErrorPattern[]): boolean {
if (!raw) return false; if (!raw) return false;
const value = raw.toLowerCase(); const value = raw.toLowerCase();
@@ -393,31 +390,8 @@ export function isOverloadedErrorMessage(raw: string): boolean {
return matchesErrorPatterns(raw, ERROR_PATTERNS.overloaded); 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 { export function isCloudCodeAssistFormatError(raw: string): boolean {
return !isImageDimensionErrorMessage(raw) && matchesErrorPatterns(raw, ERROR_PATTERNS.format); return matchesErrorPatterns(raw, ERROR_PATTERNS.format);
} }
export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean { export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean {
@@ -426,7 +400,6 @@ export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean
} }
export function classifyFailoverReason(raw: string): FailoverReason | null { export function classifyFailoverReason(raw: string): FailoverReason | null {
if (isImageDimensionErrorMessage(raw)) return null;
if (isRateLimitErrorMessage(raw)) return "rate_limit"; if (isRateLimitErrorMessage(raw)) return "rate_limit";
if (isOverloadedErrorMessage(raw)) return "rate_limit"; if (isOverloadedErrorMessage(raw)) return "rate_limit";
if (isCloudCodeAssistFormatError(raw)) return "format"; if (isCloudCodeAssistFormatError(raw)) return "format";
-8
View File
@@ -17,7 +17,6 @@ import { resolveUserPath } from "../../utils.js";
import { resolveClawdbotAgentDir } from "../agent-paths.js"; import { resolveClawdbotAgentDir } from "../agent-paths.js";
import { resolveSessionAgentIds } from "../agent-scope.js"; import { resolveSessionAgentIds } from "../agent-scope.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js";
import { resolveClawdbotDocsPath } from "../docs-path.js";
import type { ExecElevatedDefaults } from "../bash-tools.js"; import type { ExecElevatedDefaults } from "../bash-tools.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js"; import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js";
@@ -251,12 +250,6 @@ export async function compactEmbeddedPiSession(params: {
}); });
const isDefaultAgent = sessionAgentId === defaultAgentId; const isDefaultAgent = sessionAgentId === defaultAgentId;
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full"; 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({ const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace, workspaceDir: effectiveWorkspace,
defaultThinkLevel: params.thinkLevel, defaultThinkLevel: params.thinkLevel,
@@ -268,7 +261,6 @@ export async function compactEmbeddedPiSession(params: {
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined, : undefined,
skillsPrompt, skillsPrompt,
docsPath: docsPath ?? undefined,
promptMode, promptMode,
runtimeInfo, runtimeInfo,
sandboxInfo, sandboxInfo,
+1 -21
View File
@@ -31,7 +31,6 @@ import {
isContextOverflowError, isContextOverflowError,
isFailoverAssistantError, isFailoverAssistantError,
isFailoverErrorMessage, isFailoverErrorMessage,
parseImageDimensionError,
isRateLimitAssistantError, isRateLimitAssistantError,
isTimeoutErrorMessage, isTimeoutErrorMessage,
pickFallbackThinkingLevel, pickFallbackThinkingLevel,
@@ -358,26 +357,6 @@ export async function runEmbeddedPiAgent(
const failoverFailure = isFailoverAssistantError(lastAssistant); const failoverFailure = isFailoverAssistantError(lastAssistant);
const assistantFailoverReason = classifyFailoverReason(lastAssistant?.errorMessage ?? ""); const assistantFailoverReason = classifyFailoverReason(lastAssistant?.errorMessage ?? "");
const cloudCodeAssistFormatError = attempt.cloudCodeAssistFormatError; 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) // Treat timeout as potential rate limit (Antigravity hangs on rate limit)
const shouldRotate = (!aborted && failoverFailure) || timedOut; const shouldRotate = (!aborted && failoverFailure) || timedOut;
@@ -453,6 +432,7 @@ export async function runEmbeddedPiAgent(
toolMetas: attempt.toolMetas, toolMetas: attempt.toolMetas,
lastAssistant: attempt.lastAssistant, lastAssistant: attempt.lastAssistant,
lastToolError: attempt.lastToolError, lastToolError: attempt.lastToolError,
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
config: params.config, config: params.config,
sessionKey: params.sessionKey ?? params.sessionId, sessionKey: params.sessionKey ?? params.sessionId,
verboseLevel: params.verboseLevel, verboseLevel: params.verboseLevel,
@@ -18,7 +18,6 @@ import { resolveUserPath } from "../../../utils.js";
import { resolveClawdbotAgentDir } from "../../agent-paths.js"; import { resolveClawdbotAgentDir } from "../../agent-paths.js";
import { resolveSessionAgentIds } from "../../agent-scope.js"; import { resolveSessionAgentIds } from "../../agent-scope.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
import { resolveClawdbotDocsPath } from "../../docs-path.js";
import { resolveModelAuthMode } from "../../model-auth.js"; import { resolveModelAuthMode } from "../../model-auth.js";
import { import {
isCloudCodeAssistFormatError, isCloudCodeAssistFormatError,
@@ -217,12 +216,6 @@ export async function runEmbeddedAttempt(
}); });
const isDefaultAgent = sessionAgentId === defaultAgentId; const isDefaultAgent = sessionAgentId === defaultAgentId;
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full"; 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({ const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace, workspaceDir: effectiveWorkspace,
@@ -235,7 +228,6 @@ export async function runEmbeddedAttempt(
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined, : undefined,
skillsPrompt, skillsPrompt,
docsPath: docsPath ?? undefined,
reactionGuidance, reactionGuidance,
promptMode, promptMode,
runtimeInfo, runtimeInfo,
+2 -23
View File
@@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url";
import type { ImageContent } from "@mariozechner/pi-ai"; import type { ImageContent } from "@mariozechner/pi-ai";
import { assertSandboxPath } from "../../sandbox-paths.js"; import { assertSandboxPath } from "../../sandbox-paths.js";
import { sanitizeImageBlocks } from "../../tool-images.js";
import { extractTextFromMessage } from "../../../tui/tui-formatters.js"; import { extractTextFromMessage } from "../../../tui/tui-formatters.js";
import { loadWebMedia } from "../../../web/media.js"; import { loadWebMedia } from "../../../web/media.js";
import { resolveUserPath } from "../../../utils.js"; import { resolveUserPath } from "../../../utils.js";
@@ -49,17 +48,6 @@ function isImageExtension(filePath: string): boolean {
return IMAGE_EXTENSIONS.has(ext); 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. * Detects image references in a user prompt.
* *
@@ -404,18 +392,9 @@ 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 { return {
images: sanitizedPromptImages, images: promptImages,
historyImagesByIndex: sanitizedHistoryImagesByIndex, historyImagesByIndex,
detectedRefs: allRefs, detectedRefs: allRefs,
loadedCount, loadedCount,
skippedCount, skippedCount,
@@ -147,4 +147,40 @@ describe("buildEmbeddedRunPayloads", () => {
expect(payloads).toHaveLength(1); expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe("All good"); 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);
});
}); });
+35 -22
View File
@@ -24,6 +24,7 @@ export function buildEmbeddedRunPayloads(params: {
toolMetas: ToolMetaEntry[]; toolMetas: ToolMetaEntry[];
lastAssistant: AssistantMessage | undefined; lastAssistant: AssistantMessage | undefined;
lastToolError?: { toolName: string; meta?: string; error?: string }; lastToolError?: { toolName: string; meta?: string; error?: string };
didSendViaMessagingTool?: boolean;
config?: ClawdbotConfig; config?: ClawdbotConfig;
sessionKey: string; sessionKey: string;
verboseLevel?: VerboseLevel; verboseLevel?: VerboseLevel;
@@ -156,34 +157,46 @@ export function buildEmbeddedRunPayloads(params: {
}); });
} }
if (replyItems.length === 0 && params.lastToolError) { 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
) {
const toolSummary = formatToolAggregate( const toolSummary = formatToolAggregate(
params.lastToolError.toolName, params.lastToolError.toolName,
params.lastToolError.meta ? [params.lastToolError.meta] : undefined, params.lastToolError.meta ? [params.lastToolError.meta] : undefined,
{ markdown: useMarkdown }, { markdown: useMarkdown },
); );
const errorSuffix = params.lastToolError.error ? `: ${params.lastToolError.error}` : ""; const errorSuffix = params.lastToolError.error ? `: ${params.lastToolError.error}` : "";
replyItems.push({ payloads = buildPayloads([
text: `⚠️ ${toolSummary} failed${errorSuffix}`, {
isError: true, text: `⚠️ ${toolSummary} failed${errorSuffix}`,
}); isError: true,
},
]);
} }
const hasAudioAsVoiceTag = replyItems.some((item) => item.audioAsVoice); return payloads;
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;
});
} }
@@ -15,7 +15,6 @@ export function buildEmbeddedSystemPrompt(params: {
reasoningTagHint: boolean; reasoningTagHint: boolean;
heartbeatPrompt?: string; heartbeatPrompt?: string;
skillsPrompt?: string; skillsPrompt?: string;
docsPath?: string;
reactionGuidance?: { reactionGuidance?: {
level: "minimal" | "extensive"; level: "minimal" | "extensive";
channel: string; channel: string;
@@ -49,7 +48,6 @@ export function buildEmbeddedSystemPrompt(params: {
reasoningTagHint: params.reasoningTagHint, reasoningTagHint: params.reasoningTagHint,
heartbeatPrompt: params.heartbeatPrompt, heartbeatPrompt: params.heartbeatPrompt,
skillsPrompt: params.skillsPrompt, skillsPrompt: params.skillsPrompt,
docsPath: params.docsPath,
reactionGuidance: params.reactionGuidance, reactionGuidance: params.reactionGuidance,
promptMode: params.promptMode, promptMode: params.promptMode,
runtimeInfo: params.runtimeInfo, runtimeInfo: params.runtimeInfo,
-17
View File
@@ -32,14 +32,12 @@ describe("buildAgentSystemPrompt", () => {
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>", "<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
heartbeatPrompt: "ping", heartbeatPrompt: "ping",
toolNames: ["message", "memory_search"], toolNames: ["message", "memory_search"],
docsPath: "/tmp/clawd/docs",
extraSystemPrompt: "Subagent details", extraSystemPrompt: "Subagent details",
}); });
expect(prompt).not.toContain("## User Identity"); expect(prompt).not.toContain("## User Identity");
expect(prompt).not.toContain("## Skills"); expect(prompt).not.toContain("## Skills");
expect(prompt).not.toContain("## Memory Recall"); expect(prompt).not.toContain("## Memory Recall");
expect(prompt).not.toContain("## Documentation");
expect(prompt).not.toContain("## Reply Tags"); expect(prompt).not.toContain("## Reply Tags");
expect(prompt).not.toContain("## Messaging"); expect(prompt).not.toContain("## Messaging");
expect(prompt).not.toContain("## Silent Replies"); expect(prompt).not.toContain("## Silent Replies");
@@ -88,7 +86,6 @@ describe("buildAgentSystemPrompt", () => {
toolNames: ["Read", "Exec", "process"], toolNames: ["Read", "Exec", "process"],
skillsPrompt: skillsPrompt:
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>", "<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
docsPath: "/tmp/clawd/docs",
}); });
expect(prompt).toContain("- Read: Read file contents"); expect(prompt).toContain("- Read: Read file contents");
@@ -96,20 +93,6 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain( expect(prompt).toContain(
"Use `Read` to load the SKILL.md at the location listed for that skill.", "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)", () => { it("includes user time when provided (12-hour)", () => {
-27
View File
@@ -109,26 +109,6 @@ 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: { export function buildAgentSystemPrompt(params: {
workspaceDir: string; workspaceDir: string;
defaultThinkLevel?: ThinkLevel; defaultThinkLevel?: ThinkLevel;
@@ -145,7 +125,6 @@ export function buildAgentSystemPrompt(params: {
contextFiles?: EmbeddedContextFile[]; contextFiles?: EmbeddedContextFile[];
skillsPrompt?: string; skillsPrompt?: string;
heartbeatPrompt?: string; heartbeatPrompt?: string;
docsPath?: string;
/** Controls which hardcoded sections to include. Defaults to "full". */ /** Controls which hardcoded sections to include. Defaults to "full". */
promptMode?: PromptMode; promptMode?: PromptMode;
runtimeInfo?: { runtimeInfo?: {
@@ -316,11 +295,6 @@ export function buildAgentSystemPrompt(params: {
readToolName, readToolName,
}); });
const memorySection = buildMemorySection({ isMinimal, availableTools }); const memorySection = buildMemorySection({ isMinimal, availableTools });
const docsSection = buildDocsSection({
docsPath: params.docsPath,
isMinimal,
readToolName,
});
// For "none" mode, return just the basic identity line // For "none" mode, return just the basic identity line
if (promptMode === "none") { if (promptMode === "none") {
@@ -397,7 +371,6 @@ export function buildAgentSystemPrompt(params: {
`Your working directory is: ${params.workspaceDir}`, `Your working directory is: ${params.workspaceDir}`,
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.", "Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
"", "",
...docsSection,
params.sandboxInfo?.enabled ? "## Sandbox" : "", params.sandboxInfo?.enabled ? "## Sandbox" : "",
params.sandboxInfo?.enabled params.sandboxInfo?.enabled
? [ ? [
+1 -51
View File
@@ -1,7 +1,7 @@
import sharp from "sharp"; import sharp from "sharp";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { sanitizeContentBlocksImages, sanitizeImageBlocks } from "./tool-images.js"; import { sanitizeContentBlocksImages } from "./tool-images.js";
describe("tool image sanitizing", () => { describe("tool image sanitizing", () => {
it("shrinks oversized images to <=5MB", async () => { it("shrinks oversized images to <=5MB", async () => {
@@ -33,56 +33,6 @@ describe("tool image sanitizing", () => {
expect(image.mimeType).toBe("image/jpeg"); expect(image.mimeType).toBe("image/jpeg");
}, 20_000); }, 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 () => { it("corrects mismatched jpeg mimeType", async () => {
const jpeg = await sharp({ const jpeg = await sharp({
create: { create: {
+12 -55
View File
@@ -1,7 +1,5 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core"; 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"; import { getImageMetadata, resizeToJpeg } from "../media/image-ops.js";
type ToolContentBlock = AgentToolResult<unknown>["content"][number]; type ToolContentBlock = AgentToolResult<unknown>["content"][number];
@@ -16,7 +14,6 @@ type TextContentBlock = Extract<ToolContentBlock, { type: "text" }>;
// and recompress base64 image blocks when they exceed these limits. // and recompress base64 image blocks when they exceed these limits.
const MAX_IMAGE_DIMENSION_PX = 2000; const MAX_IMAGE_DIMENSION_PX = 2000;
const MAX_IMAGE_BYTES = 5 * 1024 * 1024; const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const log = createSubsystemLogger("agents/tool-images");
function isImageBlock(block: unknown): block is ImageContentBlock { function isImageBlock(block: unknown): block is ImageContentBlock {
if (!block || typeof block !== "object") return false; if (!block || typeof block !== "object") return false;
@@ -44,41 +41,26 @@ async function resizeImageBase64IfNeeded(params: {
mimeType: string; mimeType: string;
maxDimensionPx: number; maxDimensionPx: number;
maxBytes: number; maxBytes: number;
label?: string; }): Promise<{ base64: string; mimeType: string; resized: boolean }> {
}): Promise<{
base64: string;
mimeType: string;
resized: boolean;
width?: number;
height?: number;
}> {
const buf = Buffer.from(params.base64, "base64"); const buf = Buffer.from(params.base64, "base64");
const meta = await getImageMetadata(buf); const meta = await getImageMetadata(buf);
const width = meta?.width; const width = meta?.width;
const height = meta?.height; const height = meta?.height;
const overBytes = buf.byteLength > params.maxBytes; const overBytes = buf.byteLength > params.maxBytes;
const hasDimensions = typeof width === "number" && typeof height === "number"; const maxDim = Math.max(width ?? 0, height ?? 0);
if (hasDimensions && !overBytes && width <= params.maxDimensionPx && height <= params.maxDimensionPx) { if (typeof width !== "number" || typeof height !== "number") {
return { if (!overBytes) {
base64: params.base64, return {
mimeType: params.mimeType, base64: params.base64,
resized: false, mimeType: params.mimeType,
width, resized: false,
height, };
}; }
} } else if (!overBytes && width <= params.maxDimensionPx && height <= params.maxDimensionPx) {
if (hasDimensions && (width > params.maxDimensionPx || height > params.maxDimensionPx || overBytes)) { return { base64: params.base64, mimeType: params.mimeType, resized: false };
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 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 sideStart = maxDim > 0 ? Math.min(params.maxDimensionPx, maxDim) : params.maxDimensionPx;
const sideGrid = [sideStart, 1800, 1600, 1400, 1200, 1000, 800] const sideGrid = [sideStart, 1800, 1600, 1400, 1200, 1000, 800]
.map((v) => Math.min(params.maxDimensionPx, v)) .map((v) => Math.min(params.maxDimensionPx, v))
@@ -98,23 +80,10 @@ async function resizeImageBase64IfNeeded(params: {
smallest = { buffer: out, size: out.byteLength }; smallest = { buffer: out, size: out.byteLength };
} }
if (out.byteLength <= params.maxBytes) { 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 { return {
base64: out.toString("base64"), base64: out.toString("base64"),
mimeType: "image/jpeg", mimeType: "image/jpeg",
resized: true, resized: true,
width,
height,
}; };
} }
} }
@@ -158,7 +127,6 @@ export async function sanitizeContentBlocksImages(
mimeType, mimeType,
maxDimensionPx, maxDimensionPx,
maxBytes, maxBytes,
label,
}); });
out.push({ out.push({
...block, ...block,
@@ -176,17 +144,6 @@ export async function sanitizeContentBlocksImages(
return out; 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( export async function sanitizeToolResultImages(
result: AgentToolResult<unknown>, result: AgentToolResult<unknown>,
label: string, label: string,
-7
View File
@@ -4,9 +4,6 @@ export type NodeListNode = {
nodeId: string; nodeId: string;
displayName?: string; displayName?: string;
platform?: string; platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
remoteIp?: string; remoteIp?: string;
deviceFamily?: string; deviceFamily?: string;
modelIdentifier?: string; modelIdentifier?: string;
@@ -23,8 +20,6 @@ type PendingRequest = {
displayName?: string; displayName?: string;
platform?: string; platform?: string;
version?: string; version?: string;
coreVersion?: string;
uiVersion?: string;
remoteIp?: string; remoteIp?: string;
isRepair?: boolean; isRepair?: boolean;
ts: number; ts: number;
@@ -36,8 +31,6 @@ type PairedNode = {
displayName?: string; displayName?: string;
platform?: string; platform?: string;
version?: string; version?: string;
coreVersion?: string;
uiVersion?: string;
remoteIp?: string; remoteIp?: string;
permissions?: Record<string, boolean>; permissions?: Record<string, boolean>;
createdAtMs?: number; createdAtMs?: number;
+1 -1
View File
@@ -189,7 +189,7 @@ export async function handleBashChatCommand(params: {
}): Promise<ReplyPayload> { }): Promise<ReplyPayload> {
if (params.cfg.commands?.bash !== true) { if (params.cfg.commands?.bash !== true) {
return { return {
text: "⚠️ bash is disabled. Set commands.bash=true to enable. Docs: https://docs.clawd.bot/tools/slash-commands#config", text: "⚠️ bash is disabled. Set commands.bash=true to enable.",
}; };
} }
-87
View File
@@ -1,87 +0,0 @@
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);
});
});
-243
View File
@@ -1,243 +0,0 @@
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);
}
+4 -4
View File
@@ -46,7 +46,7 @@ type NodeDaemonStatusOptions = {
}; };
function renderNodeServiceStartHints(): string[] { function renderNodeServiceStartHints(): string[] {
const base = ["clawdbot node service install", "clawdbot node start"]; const base = ["clawdbot node daemon install", "clawdbot node start"];
switch (process.platform) { switch (process.platform) {
case "darwin": case "darwin":
return [ return [
@@ -133,7 +133,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
}; };
if (resolveIsNixMode(process.env)) { if (resolveIsNixMode(process.env)) {
fail("Nix mode detected; service install is disabled."); fail("Nix mode detected; daemon install is disabled.");
return; return;
} }
@@ -168,7 +168,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
}); });
if (!json) { if (!json) {
defaultRuntime.log(`Node service already ${service.loadedText}.`); defaultRuntime.log(`Node service already ${service.loadedText}.`);
defaultRuntime.log("Reinstall with: clawdbot node service install --force"); defaultRuntime.log("Reinstall with: clawdbot node daemon install --force");
} }
return; return;
} }
@@ -244,7 +244,7 @@ export async function runNodeDaemonUninstall(opts: NodeDaemonLifecycleOptions =
}; };
if (resolveIsNixMode(process.env)) { if (resolveIsNixMode(process.env)) {
fail("Nix mode detected; service uninstall is disabled."); fail("Nix mode detected; daemon uninstall is disabled.");
return; return;
} }
+58 -66
View File
@@ -51,71 +51,63 @@ 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 const daemon = node
.command("daemon", { hidden: true }) .command("daemon")
.description("Legacy alias for node service commands"); .description("Manage the headless node daemon service (launchd/systemd/schtasks)");
registerNodeServiceCommands(daemon);
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);
});
} }
+1 -47
View File
@@ -4,43 +4,6 @@ import { formatAge, formatPermissions, parseNodeList, parsePairingList } from ".
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.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) { export function registerNodesStatusCommands(nodes: Command) {
nodesCallOpts( nodesCallOpts(
nodes nodes
@@ -66,8 +29,6 @@ export function registerNodesStatusCommands(nodes: Command) {
const hw = n.modelIdentifier ? ` · hw: ${n.modelIdentifier}` : ""; const hw = n.modelIdentifier ? ` · hw: ${n.modelIdentifier}` : "";
const perms = formatPermissions(n.permissions); const perms = formatPermissions(n.permissions);
const permsText = perms ? ` · perms: ${perms}` : ""; const permsText = perms ? ` · perms: ${perms}` : "";
const versions = formatNodeVersions(n);
const versionText = versions ? ` · ${versions}` : "";
const caps = const caps =
Array.isArray(n.caps) && n.caps.length > 0 Array.isArray(n.caps) && n.caps.length > 0
? `[${n.caps.map(String).filter(Boolean).sort().join(",")}]` ? `[${n.caps.map(String).filter(Boolean).sort().join(",")}]`
@@ -76,7 +37,7 @@ export function registerNodesStatusCommands(nodes: Command) {
: "?"; : "?";
const pairing = n.paired ? "paired" : "unpaired"; const pairing = n.paired ? "paired" : "unpaired";
defaultRuntime.log( defaultRuntime.log(
`- ${name} · ${n.nodeId}${ip}${device}${hw}${permsText}${versionText} · ${pairing} · ${n.connected ? "connected" : "disconnected"} · caps: ${caps}`, `- ${name} · ${n.nodeId}${ip}${device}${hw}${permsText} · ${pairing} · ${n.connected ? "connected" : "disconnected"} · caps: ${caps}`,
); );
} }
} catch (err) { } catch (err) {
@@ -116,19 +77,12 @@ export function registerNodesStatusCommands(nodes: Command) {
const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null; const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null;
const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null; const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null;
const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : 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]; const parts: string[] = ["Node:", displayName, nodeId];
if (ip) parts.push(ip); if (ip) parts.push(ip);
if (family) parts.push(`device: ${family}`); if (family) parts.push(`device: ${family}`);
if (model) parts.push(`hw: ${model}`); if (model) parts.push(`hw: ${model}`);
if (perms) parts.push(`perms: ${perms}`); if (perms) parts.push(`perms: ${perms}`);
if (versions) parts.push(versions);
parts.push(connected ? "connected" : "disconnected"); parts.push(connected ? "connected" : "disconnected");
parts.push(`caps: ${caps ? `[${caps.join(",")}]` : "?"}`); parts.push(`caps: ${caps ? `[${caps.join(",")}]` : "?"}`);
defaultRuntime.log(parts.join(" · ")); defaultRuntime.log(parts.join(" · "));
-6
View File
@@ -46,8 +46,6 @@ export type NodeListNode = {
displayName?: string; displayName?: string;
platform?: string; platform?: string;
version?: string; version?: string;
coreVersion?: string;
uiVersion?: string;
remoteIp?: string; remoteIp?: string;
deviceFamily?: string; deviceFamily?: string;
modelIdentifier?: string; modelIdentifier?: string;
@@ -64,8 +62,6 @@ export type PendingRequest = {
displayName?: string; displayName?: string;
platform?: string; platform?: string;
version?: string; version?: string;
coreVersion?: string;
uiVersion?: string;
remoteIp?: string; remoteIp?: string;
isRepair?: boolean; isRepair?: boolean;
ts: number; ts: number;
@@ -77,8 +73,6 @@ export type PairedNode = {
displayName?: string; displayName?: string;
platform?: string; platform?: string;
version?: string; version?: string;
coreVersion?: string;
uiVersion?: string;
remoteIp?: string; remoteIp?: string;
permissions?: Record<string, boolean>; permissions?: Record<string, boolean>;
createdAtMs?: number; createdAtMs?: number;
-4
View File
@@ -8,7 +8,6 @@ import { registerDaemonCli } from "../daemon-cli.js";
import { registerDnsCli } from "../dns-cli.js"; import { registerDnsCli } from "../dns-cli.js";
import { registerDirectoryCli } from "../directory-cli.js"; import { registerDirectoryCli } from "../directory-cli.js";
import { registerDocsCli } from "../docs-cli.js"; import { registerDocsCli } from "../docs-cli.js";
import { registerExecApprovalsCli } from "../exec-approvals-cli.js";
import { registerGatewayCli } from "../gateway-cli.js"; import { registerGatewayCli } from "../gateway-cli.js";
import { registerHooksCli } from "../hooks-cli.js"; import { registerHooksCli } from "../hooks-cli.js";
import { registerWebhooksCli } from "../webhooks-cli.js"; import { registerWebhooksCli } from "../webhooks-cli.js";
@@ -20,7 +19,6 @@ import { registerPairingCli } from "../pairing-cli.js";
import { registerPluginsCli } from "../plugins-cli.js"; import { registerPluginsCli } from "../plugins-cli.js";
import { registerSandboxCli } from "../sandbox-cli.js"; import { registerSandboxCli } from "../sandbox-cli.js";
import { registerSecurityCli } from "../security-cli.js"; import { registerSecurityCli } from "../security-cli.js";
import { registerServiceCli } from "../service-cli.js";
import { registerSkillsCli } from "../skills-cli.js"; import { registerSkillsCli } from "../skills-cli.js";
import { registerTuiCli } from "../tui-cli.js"; import { registerTuiCli } from "../tui-cli.js";
import { registerUpdateCli } from "../update-cli.js"; import { registerUpdateCli } from "../update-cli.js";
@@ -29,10 +27,8 @@ export function registerSubCliCommands(program: Command) {
registerAcpCli(program); registerAcpCli(program);
registerDaemonCli(program); registerDaemonCli(program);
registerGatewayCli(program); registerGatewayCli(program);
registerServiceCli(program);
registerLogsCli(program); registerLogsCli(program);
registerModelsCli(program); registerModelsCli(program);
registerExecApprovalsCli(program);
registerNodesCli(program); registerNodesCli(program);
registerNodeCli(program); registerNodeCli(program);
registerSandboxCli(program); registerSandboxCli(program);
-59
View File
@@ -1,59 +0,0 @@
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
View File
@@ -1,157 +0,0 @@
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();
}
+6 -18
View File
@@ -2,9 +2,7 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { withProgress } from "../cli/progress.js"; import { withProgress } from "../cli/progress.js";
import { loadConfig, readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js"; import { loadConfig, readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js"; import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import type { GatewayService } from "../daemon/service.js";
import { resolveGatewayService } 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 { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { normalizeControlUiBasePath } from "../gateway/control-ui.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
import { probeGateway } from "../gateway/probe.js"; import { probeGateway } from "../gateway/probe.js";
@@ -132,9 +130,10 @@ export async function statusAllCommand(
const gatewaySelf = pickGatewaySelfPresence(gatewayProbe?.presence ?? null); const gatewaySelf = pickGatewaySelfPresence(gatewayProbe?.presence ?? null);
progress.tick(); progress.tick();
progress.setLabel("Checking services…"); progress.setLabel("Checking daemon…");
const readServiceSummary = async (service: GatewayService) => { const daemon = await (async () => {
try { try {
const service = resolveGatewayService();
const [loaded, runtimeInfo, command] = await Promise.all([ const [loaded, runtimeInfo, command] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false), service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined), service.readRuntime(process.env).catch(() => undefined),
@@ -151,9 +150,7 @@ export async function statusAllCommand(
} catch { } catch {
return null; return null;
} }
}; })();
const daemon = await readServiceSummary(resolveGatewayService());
const nodeService = await readServiceSummary(resolveNodeService());
progress.tick(); progress.tick();
progress.setLabel("Scanning agents…"); progress.setLabel("Scanning agents…");
@@ -343,22 +340,13 @@ export async function statusAllCommand(
: { Item: "Gateway self", Value: "unknown" }, : { Item: "Gateway self", Value: "unknown" },
daemon daemon
? { ? {
Item: "Gateway service", Item: "Daemon",
Value: Value:
daemon.installed === false daemon.installed === false
? `${daemon.label} not installed` ? `${daemon.label} not installed`
: `${daemon.label} ${daemon.installed ? "installed · " : ""}${daemon.loadedText}${daemon.runtime?.status ? ` · ${daemon.runtime.status}` : ""}${daemon.runtime?.pid ? ` (pid ${daemon.runtime.pid})` : ""}`, : `${daemon.label} ${daemon.installed ? "installed · " : ""}${daemon.loadedText}${daemon.runtime?.status ? ` · ${daemon.runtime.status}` : ""}${daemon.runtime?.pid ? ` (pid ${daemon.runtime.pid})` : ""}`,
} }
: { Item: "Gateway service", Value: "unknown" }, : { Item: "Daemon", 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", Item: "Agents",
Value: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`, Value: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`,
+3 -18
View File
@@ -15,7 +15,7 @@ import {
} from "../memory/status-format.js"; } from "../memory/status-format.js";
import { formatHealthChannelLines, type HealthSummary } from "./health.js"; import { formatHealthChannelLines, type HealthSummary } from "./health.js";
import { resolveControlUiLinks } from "./onboard-helpers.js"; import { resolveControlUiLinks } from "./onboard-helpers.js";
import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js"; import { getDaemonStatusSummary } from "./status.daemon.js";
import { import {
formatAge, formatAge,
formatDuration, formatDuration,
@@ -116,10 +116,6 @@ export async function statusCommand(
: undefined; : undefined;
if (opts.json) { if (opts.json) {
const [daemon, nodeDaemon] = await Promise.all([
getDaemonStatusSummary(),
getNodeDaemonStatusSummary(),
]);
runtime.log( runtime.log(
JSON.stringify( JSON.stringify(
{ {
@@ -138,8 +134,6 @@ export async function statusCommand(
self: gatewaySelf, self: gatewaySelf,
error: gatewayProbe?.error ?? null, error: gatewayProbe?.error ?? null,
}, },
gatewayService: daemon,
nodeService: nodeDaemon,
agents: agentStatus, agents: agentStatus,
securityAudit, securityAudit,
...(health || usage ? { health, usage } : {}), ...(health || usage ? { health, usage } : {}),
@@ -216,20 +210,12 @@ export async function statusCommand(
return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`; return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`;
})(); })();
const [daemon, nodeDaemon] = await Promise.all([ const daemon = await getDaemonStatusSummary();
getDaemonStatusSummary(),
getNodeDaemonStatusSummary(),
]);
const daemonValue = (() => { const daemonValue = (() => {
if (daemon.installed === false) return `${daemon.label} not installed`; if (daemon.installed === false) return `${daemon.label} not installed`;
const installedPrefix = daemon.installed === true ? "installed · " : ""; const installedPrefix = daemon.installed === true ? "installed · " : "";
return `${daemon.label} ${installedPrefix}${daemon.loadedText}${daemon.runtimeShort ? ` · ${daemon.runtimeShort}` : ""}`; 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 defaults = summary.sessions.defaults;
const defaultCtx = defaults.contextTokens const defaultCtx = defaults.contextTokens
@@ -312,8 +298,7 @@ export async function statusCommand(
Value: updateAvailability.available ? warn(`available · ${updateLine}`) : updateLine, Value: updateAvailability.available ? warn(`available · ${updateLine}`) : updateLine,
}, },
{ Item: "Gateway", Value: gatewayValue }, { Item: "Gateway", Value: gatewayValue },
{ Item: "Gateway service", Value: daemonValue }, { Item: "Daemon", Value: daemonValue },
{ Item: "Node service", Value: nodeDaemonValue },
{ Item: "Agents", Value: agentsValue }, { Item: "Agents", Value: agentsValue },
{ Item: "Memory", Value: memoryValue }, { Item: "Memory", Value: memoryValue },
{ Item: "Probes", Value: probesValue }, { Item: "Probes", Value: probesValue },
+4 -18
View File
@@ -1,20 +1,14 @@
import type { GatewayService } from "../daemon/service.js";
import { resolveGatewayService } from "../daemon/service.js"; import { resolveGatewayService } from "../daemon/service.js";
import { resolveNodeService } from "../daemon/node-service.js";
import { formatDaemonRuntimeShort } from "./status.format.js"; import { formatDaemonRuntimeShort } from "./status.format.js";
type DaemonStatusSummary = { export async function getDaemonStatusSummary(): Promise<{
label: string; label: string;
installed: boolean | null; installed: boolean | null;
loadedText: string; loadedText: string;
runtimeShort: string | null; runtimeShort: string | null;
}; }> {
async function buildDaemonStatusSummary(
service: GatewayService,
fallbackLabel: string,
): Promise<DaemonStatusSummary> {
try { try {
const service = resolveGatewayService();
const [loaded, runtime, command] = await Promise.all([ const [loaded, runtime, command] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false), service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined), service.readRuntime(process.env).catch(() => undefined),
@@ -26,18 +20,10 @@ async function buildDaemonStatusSummary(
return { label: service.label, installed, loadedText, runtimeShort }; return { label: service.label, installed, loadedText, runtimeShort };
} catch { } catch {
return { return {
label: fallbackLabel, label: "Daemon",
installed: null, installed: null,
loadedText: "unknown", loadedText: "unknown",
runtimeShort: null, runtimeShort: null,
}; };
} }
} }
export async function getDaemonStatusSummary(): Promise<DaemonStatusSummary> {
return await buildDaemonStatusSummary(resolveGatewayService(), "Daemon");
}
export async function getNodeDaemonStatusSummary(): Promise<DaemonStatusSummary> {
return await buildDaemonStatusSummary(resolveNodeService(), "Node");
}
-15
View File
@@ -243,19 +243,6 @@ 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", () => ({ vi.mock("../security/audit.js", () => ({
runSecurityAudit: mocks.runSecurityAudit, runSecurityAudit: mocks.runSecurityAudit,
})); }));
@@ -286,8 +273,6 @@ describe("statusCommand", () => {
expect(payload.sessions.recent[0].flags).toContain("verbose:on"); expect(payload.sessions.recent[0].flags).toContain("verbose:on");
expect(payload.securityAudit.summary.critical).toBe(1); expect(payload.securityAudit.summary.critical).toBe(1);
expect(payload.securityAudit.summary.warn).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 () => { it("prints formatted lines otherwise", async () => {
-65
View File
@@ -1,65 +0,0 @@
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");
}
});
});
-6
View File
@@ -70,12 +70,6 @@ export type SlackThreadConfig = {
export type SlackAccountConfig = { export type SlackAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */ /** Optional display name for this account (used in CLI/UI lists). */
name?: string; 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. */ /** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[]; capabilities?: string[];
/** Override native command registration for Slack (bool or "auto"). */ /** Override native command registration for Slack (bool or "auto"). */
-31
View File
@@ -258,9 +258,6 @@ export const SlackThreadSchema = z.object({
export const SlackAccountSchema = z.object({ export const SlackAccountSchema = z.object({
name: z.string().optional(), name: z.string().optional(),
mode: z.enum(["socket", "http"]).optional(),
signingSecret: z.string().optional(),
webhookPath: z.string().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
commands: ProviderCommandsSchema, commands: ProviderCommandsSchema,
@@ -308,35 +305,7 @@ export const SlackAccountSchema = z.object({
}); });
export const SlackConfigSchema = SlackAccountSchema.extend({ 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(), 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({ export const SignalAccountSchemaBase = z.object({
-71
View File
@@ -1,71 +0,0 @@
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
View File
@@ -1,92 +0,0 @@
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 };
}
}
-10
View File
@@ -58,10 +58,6 @@ import {
CronUpdateParamsSchema, CronUpdateParamsSchema,
type ExecApprovalsGetParams, type ExecApprovalsGetParams,
ExecApprovalsGetParamsSchema, ExecApprovalsGetParamsSchema,
type ExecApprovalsNodeGetParams,
ExecApprovalsNodeGetParamsSchema,
type ExecApprovalsNodeSetParams,
ExecApprovalsNodeSetParamsSchema,
type ExecApprovalsSetParams, type ExecApprovalsSetParams,
ExecApprovalsSetParamsSchema, ExecApprovalsSetParamsSchema,
type ExecApprovalsSnapshot, type ExecApprovalsSnapshot,
@@ -245,12 +241,6 @@ export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams
export const validateExecApprovalsSetParams = ajv.compile<ExecApprovalsSetParams>( export const validateExecApprovalsSetParams = ajv.compile<ExecApprovalsSetParams>(
ExecApprovalsSetParamsSchema, ExecApprovalsSetParamsSchema,
); );
export const validateExecApprovalsNodeGetParams = ajv.compile<ExecApprovalsNodeGetParams>(
ExecApprovalsNodeGetParamsSchema,
);
export const validateExecApprovalsNodeSetParams = ajv.compile<ExecApprovalsNodeSetParams>(
ExecApprovalsNodeSetParamsSchema,
);
export const validateLogsTailParams = ajv.compile<LogsTailParams>(LogsTailParamsSchema); export const validateLogsTailParams = ajv.compile<LogsTailParams>(LogsTailParamsSchema);
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema); export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema); export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
@@ -70,19 +70,3 @@ export const ExecApprovalsSetParamsSchema = Type.Object(
}, },
{ additionalProperties: false }, { 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 },
);
-2
View File
@@ -8,8 +8,6 @@ export const NodePairRequestParamsSchema = Type.Object(
displayName: Type.Optional(NonEmptyString), displayName: Type.Optional(NonEmptyString),
platform: Type.Optional(NonEmptyString), platform: Type.Optional(NonEmptyString),
version: Type.Optional(NonEmptyString), version: Type.Optional(NonEmptyString),
coreVersion: Type.Optional(NonEmptyString),
uiVersion: Type.Optional(NonEmptyString),
deviceFamily: Type.Optional(NonEmptyString), deviceFamily: Type.Optional(NonEmptyString),
modelIdentifier: Type.Optional(NonEmptyString), modelIdentifier: Type.Optional(NonEmptyString),
caps: Type.Optional(Type.Array(NonEmptyString)), caps: Type.Optional(Type.Array(NonEmptyString)),
@@ -49,8 +49,6 @@ import {
} from "./cron.js"; } from "./cron.js";
import { import {
ExecApprovalsGetParamsSchema, ExecApprovalsGetParamsSchema,
ExecApprovalsNodeGetParamsSchema,
ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSetParamsSchema, ExecApprovalsSetParamsSchema,
ExecApprovalsSnapshotSchema, ExecApprovalsSnapshotSchema,
} from "./exec-approvals.js"; } from "./exec-approvals.js";
@@ -179,8 +177,6 @@ export const ProtocolSchemas: Record<string, TSchema> = {
LogsTailResult: LogsTailResultSchema, LogsTailResult: LogsTailResultSchema,
ExecApprovalsGetParams: ExecApprovalsGetParamsSchema, ExecApprovalsGetParams: ExecApprovalsGetParamsSchema,
ExecApprovalsSetParams: ExecApprovalsSetParamsSchema, ExecApprovalsSetParams: ExecApprovalsSetParamsSchema,
ExecApprovalsNodeGetParams: ExecApprovalsNodeGetParamsSchema,
ExecApprovalsNodeSetParams: ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema, ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema,
ChatHistoryParams: ChatHistoryParamsSchema, ChatHistoryParams: ChatHistoryParamsSchema,
ChatSendParams: ChatSendParamsSchema, ChatSendParams: ChatSendParamsSchema,
-4
View File
@@ -47,8 +47,6 @@ import type {
} from "./cron.js"; } from "./cron.js";
import type { import type {
ExecApprovalsGetParamsSchema, ExecApprovalsGetParamsSchema,
ExecApprovalsNodeGetParamsSchema,
ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSetParamsSchema, ExecApprovalsSetParamsSchema,
ExecApprovalsSnapshotSchema, ExecApprovalsSnapshotSchema,
} from "./exec-approvals.js"; } from "./exec-approvals.js";
@@ -172,8 +170,6 @@ export type LogsTailParams = Static<typeof LogsTailParamsSchema>;
export type LogsTailResult = Static<typeof LogsTailResultSchema>; export type LogsTailResult = Static<typeof LogsTailResultSchema>;
export type ExecApprovalsGetParams = Static<typeof ExecApprovalsGetParamsSchema>; export type ExecApprovalsGetParams = Static<typeof ExecApprovalsGetParamsSchema>;
export type ExecApprovalsSetParams = Static<typeof ExecApprovalsSetParamsSchema>; 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 ExecApprovalsSnapshot = Static<typeof ExecApprovalsSnapshotSchema>;
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>; export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>; export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
-2
View File
@@ -8,7 +8,6 @@ import type { WebSocketServer } from "ws";
import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
import type { CanvasHostHandler } from "../canvas-host/server.js"; import type { CanvasHostHandler } from "../canvas-host/server.js";
import type { createSubsystemLogger } from "../logging.js"; import type { createSubsystemLogger } from "../logging.js";
import { handleSlackHttpRequest } from "../slack/http/index.js";
import { handleControlUiHttpRequest } from "./control-ui.js"; import { handleControlUiHttpRequest } from "./control-ui.js";
import { import {
extractHookToken, extractHookToken,
@@ -209,7 +208,6 @@ export function createGatewayHttpServer(opts: {
void (async () => { void (async () => {
if (await handleHooksRequest(req, res)) return; if (await handleHooksRequest(req, res)) return;
if (await handleSlackHttpRequest(req, res)) return;
if (handlePluginRequest && (await handlePluginRequest(req, res))) return; if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
if (openAiChatCompletionsEnabled) { if (openAiChatCompletionsEnabled) {
if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return; if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return;
-2
View File
@@ -14,8 +14,6 @@ const BASE_METHODS = [
"config.schema", "config.schema",
"exec.approvals.get", "exec.approvals.get",
"exec.approvals.set", "exec.approvals.set",
"exec.approvals.node.get",
"exec.approvals.node.set",
"wizard.start", "wizard.start",
"wizard.next", "wizard.next",
"wizard.cancel", "wizard.cancel",
@@ -12,11 +12,8 @@ import {
errorShape, errorShape,
formatValidationErrors, formatValidationErrors,
validateExecApprovalsGetParams, validateExecApprovalsGetParams,
validateExecApprovalsNodeGetParams,
validateExecApprovalsNodeSetParams,
validateExecApprovalsSetParams, validateExecApprovalsSetParams,
} from "../protocol/index.js"; } from "../protocol/index.js";
import { respondUnavailableOnThrow, safeParseJson } from "./nodes.helpers.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js"; import type { GatewayRequestHandlers, RespondFn } from "./types.js";
function resolveBaseHash(params: unknown): string | null { function resolveBaseHash(params: unknown): string | null {
@@ -155,94 +152,4 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
undefined, 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);
});
},
}; };
-8
View File
@@ -42,8 +42,6 @@ export const nodeHandlers: GatewayRequestHandlers = {
displayName?: string; displayName?: string;
platform?: string; platform?: string;
version?: string; version?: string;
coreVersion?: string;
uiVersion?: string;
deviceFamily?: string; deviceFamily?: string;
modelIdentifier?: string; modelIdentifier?: string;
caps?: string[]; caps?: string[];
@@ -57,8 +55,6 @@ export const nodeHandlers: GatewayRequestHandlers = {
displayName: p.displayName, displayName: p.displayName,
platform: p.platform, platform: p.platform,
version: p.version, version: p.version,
coreVersion: p.coreVersion,
uiVersion: p.uiVersion,
deviceFamily: p.deviceFamily, deviceFamily: p.deviceFamily,
modelIdentifier: p.modelIdentifier, modelIdentifier: p.modelIdentifier,
caps: p.caps, caps: p.caps,
@@ -219,8 +215,6 @@ export const nodeHandlers: GatewayRequestHandlers = {
displayName: live?.displayName ?? paired?.displayName, displayName: live?.displayName ?? paired?.displayName,
platform: live?.platform ?? paired?.platform, platform: live?.platform ?? paired?.platform,
version: live?.version ?? paired?.version, version: live?.version ?? paired?.version,
coreVersion: live?.coreVersion ?? paired?.coreVersion,
uiVersion: live?.uiVersion ?? paired?.uiVersion,
deviceFamily: live?.deviceFamily ?? paired?.deviceFamily, deviceFamily: live?.deviceFamily ?? paired?.deviceFamily,
modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier, modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier,
remoteIp: live?.remoteIp ?? paired?.remoteIp, remoteIp: live?.remoteIp ?? paired?.remoteIp,
@@ -281,8 +275,6 @@ export const nodeHandlers: GatewayRequestHandlers = {
displayName: live?.displayName ?? paired?.displayName, displayName: live?.displayName ?? paired?.displayName,
platform: live?.platform ?? paired?.platform, platform: live?.platform ?? paired?.platform,
version: live?.version ?? paired?.version, version: live?.version ?? paired?.version,
coreVersion: live?.coreVersion ?? paired?.coreVersion,
uiVersion: live?.uiVersion ?? paired?.uiVersion,
deviceFamily: live?.deviceFamily ?? paired?.deviceFamily, deviceFamily: live?.deviceFamily ?? paired?.deviceFamily,
modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier, modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier,
remoteIp: live?.remoteIp ?? paired?.remoteIp, remoteIp: live?.remoteIp ?? paired?.remoteIp,
+1 -23
View File
@@ -43,25 +43,6 @@ export async function startGatewayNodeBridge(params: {
}): Promise<GatewayNodeBridgeRuntime> { }): Promise<GatewayNodeBridgeRuntime> {
const nodePresenceTimers = new Map<string, ReturnType<typeof setInterval>>(); 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 stopNodePresenceTimer = (nodeId: string) => {
const timer = nodePresenceTimers.get(nodeId); const timer = nodePresenceTimers.get(nodeId);
if (timer) { if (timer) {
@@ -76,8 +57,6 @@ export async function startGatewayNodeBridge(params: {
displayName?: string; displayName?: string;
remoteIp?: string; remoteIp?: string;
version?: string; version?: string;
coreVersion?: string;
uiVersion?: string;
platform?: string; platform?: string;
deviceFamily?: string; deviceFamily?: string;
modelIdentifier?: string; modelIdentifier?: string;
@@ -87,7 +66,7 @@ export async function startGatewayNodeBridge(params: {
const host = node.displayName?.trim() || node.nodeId; const host = node.displayName?.trim() || node.nodeId;
const rawIp = node.remoteIp?.trim(); const rawIp = node.remoteIp?.trim();
const ip = rawIp && !isLoopbackAddress(rawIp) ? rawIp : undefined; const ip = rawIp && !isLoopbackAddress(rawIp) ? rawIp : undefined;
const version = resolveNodeVersionLabel(node) ?? node.version?.trim() ?? "unknown"; const version = node.version?.trim() || "unknown";
const platform = node.platform?.trim() || undefined; const platform = node.platform?.trim() || undefined;
const deviceFamily = node.deviceFamily?.trim() || undefined; const deviceFamily = node.deviceFamily?.trim() || undefined;
const modelIdentifier = node.modelIdentifier?.trim() || undefined; const modelIdentifier = node.modelIdentifier?.trim() || undefined;
@@ -148,7 +127,6 @@ export async function startGatewayNodeBridge(params: {
platform: node.platform, platform: node.platform,
deviceFamily: node.deviceFamily, deviceFamily: node.deviceFamily,
commands: node.commands, commands: node.commands,
remoteIp: node.remoteIp,
}); });
bumpSkillsSnapshotVersion({ reason: "remote-node" }); bumpSkillsSnapshotVersion({ reason: "remote-node" });
await refreshRemoteNodeBins({ await refreshRemoteNodeBins({
+3 -1
View File
@@ -20,7 +20,9 @@ import type { DedupeEntry } from "./server-shared.js";
import type { PluginRegistry } from "../plugins/registry.js"; import type { PluginRegistry } from "../plugins/registry.js";
export async function createGatewayRuntimeState(params: { export async function createGatewayRuntimeState(params: {
cfg: import("../config/config.js").ClawdbotConfig; cfg: {
canvasHost?: { root?: string; enabled?: boolean; liveReload?: boolean };
};
bindHost: string; bindHost: string;
port: number; port: number;
controlUiEnabled: boolean; controlUiEnabled: boolean;
+1 -16
View File
@@ -8,11 +8,7 @@ import {
import type { CliDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.js";
import type { loadConfig } from "../config/config.js"; import type { loadConfig } from "../config/config.js";
import { startGmailWatcher } from "../hooks/gmail-watcher.js"; import { startGmailWatcher } from "../hooks/gmail-watcher.js";
import { import { clearInternalHooks } from "../hooks/internal-hooks.js";
clearInternalHooks,
createInternalHookEvent,
triggerInternalHook,
} from "../hooks/internal-hooks.js";
import { loadInternalHooks } from "../hooks/loader.js"; import { loadInternalHooks } from "../hooks/loader.js";
import type { loadClawdbotPlugins } from "../plugins/loader.js"; import type { loadClawdbotPlugins } from "../plugins/loader.js";
import { type PluginServicesHandle, startPluginServices } from "../plugins/services.js"; import { type PluginServicesHandle, startPluginServices } from "../plugins/services.js";
@@ -126,17 +122,6 @@ 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; let pluginServices: PluginServicesHandle | null = null;
try { try {
pluginServices = await startPluginServices({ pluginServices = await startPluginServices({
+1 -16
View File
@@ -47,20 +47,6 @@ Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by
clawdbot hooks enable soul-evil 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 ## Hook Structure
Each hook is a directory containing: Each hook is a directory containing:
@@ -170,7 +156,6 @@ Currently supported events:
- **command:reset**: `/reset` command - **command:reset**: `/reset` command
- **command:stop**: `/stop` command - **command:stop**: `/stop` command
- **agent:bootstrap**: Before workspace bootstrap files are injected - **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.). More event types coming soon (session lifecycle, agent errors, etc.).
@@ -180,7 +165,7 @@ Hook handlers receive an `InternalHookEvent` object:
```typescript ```typescript
interface InternalHookEvent { interface InternalHookEvent {
type: "command" | "session" | "agent" | "gateway"; type: "command" | "session" | "agent";
action: string; // e.g., 'new', 'reset', 'stop' action: string; // e.g., 'new', 'reset', 'stop'
sessionKey: string; sessionKey: string;
context: Record<string, unknown>; context: Record<string, unknown>;
-19
View File
@@ -1,19 +0,0 @@
---
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.
-27
View File
@@ -1,27 +0,0 @@
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;
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more