Compare commits

..
182 changed files with 2735 additions and 8783 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.**
+4 -43
View File
@@ -2,14 +2,6 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.18-5
### Changes
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
### Fixes
- Plugins: auto-select exclusive slots when enabling/installing plugins. (#1181) — thanks @sebslight.
## 2026.1.18-4 ## 2026.1.18-4
### Changes ### Changes
@@ -18,20 +10,14 @@ Docs: https://docs.clawd.bot
- 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.
- Plugins: auto-enable bundled channel/provider plugins when configuration is present. - Memory: add native Gemini embeddings provider for memory search. (#1151) — thanks @gumadeiras.
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
- Memory: add native Gemini embeddings provider for memory search. (#1151)
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
### 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.
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166) — thanks @AlexMikhalev.
- 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.
- Agents: surface tool failures when no assistant output is emitted. (#1175) — thanks @vrknetha.
## 2026.1.18-3 ## 2026.1.18-3
@@ -49,14 +35,6 @@ 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
- macOS: add exec-host IPC for node service `system.run` with HMAC + peer UID checks.
### Fixes ### Fixes
- 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.
@@ -69,23 +47,6 @@ Docs: https://docs.clawd.bot
### Fixes ### Fixes
- Tests: stabilize plugin SDK resolution and embedded agent timeouts. - Tests: stabilize plugin SDK resolution and embedded agent timeouts.
## 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-6 ## 2026.1.17-6
### 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"
} }
}, },
{ {
-64
View File
@@ -1,64 +0,0 @@
# Clawdbot macOS app (dev + signing)
## Quick dev run
```bash
# from repo root
scripts/restart-mac.sh
```
Options:
```bash
scripts/restart-mac.sh --no-sign # fastest dev; ad-hoc signing (TCC permissions do not stick)
scripts/restart-mac.sh --sign # force code signing (requires cert)
```
## Packaging flow
```bash
scripts/package-mac-app.sh
```
Creates `dist/Clawdbot.app` and signs it via `scripts/codesign-mac-app.sh`.
## Signing behavior
Auto-selects identity (first match):
1) Developer ID Application
2) Apple Distribution
3) Apple Development
4) first available identity
If none found:
- errors by default
- set `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` to ad-hoc sign
## Team ID audit (Sparkle mismatch guard)
After signing, we read the app bundle Team ID and compare every Mach-O inside the app.
If any embedded binary has a different Team ID, signing fails.
Skip the audit:
```bash
SKIP_TEAM_ID_CHECK=1 scripts/package-mac-app.sh
```
## Library validation workaround (dev only)
If Sparkle Team ID mismatch blocks loading (common with Apple Development certs), opt in:
```bash
DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh
```
This adds `com.apple.security.cs.disable-library-validation` to app entitlements.
Use for local dev only; keep off for release builds.
## Useful env flags
- `SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"`
- `ALLOW_ADHOC_SIGNING=1` (ad-hoc, TCC permissions do not persist)
- `CODESIGN_TIMESTAMP=off` (offline debug)
- `DISABLE_LIBRARY_VALIDATION=1` (dev-only Sparkle workaround)
- `SKIP_TEAM_ID_CHECK=1` (bypass audit)
@@ -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 == "~" {
@@ -1,6 +1,5 @@
import AppKit import AppKit
import ClawdbotKit import ClawdbotKit
import CryptoKit
import Darwin import Darwin
import Foundation import Foundation
import OSLog import OSLog
@@ -28,49 +27,6 @@ private struct ExecApprovalSocketDecision: Codable {
var decision: ExecApprovalDecision var decision: ExecApprovalDecision
} }
fileprivate struct ExecHostSocketRequest: Codable {
var type: String
var id: String
var nonce: String
var ts: Int
var hmac: String
var requestJson: String
}
fileprivate struct ExecHostRequest: Codable {
var command: [String]
var rawCommand: String?
var cwd: String?
var env: [String: String]?
var timeoutMs: Int?
var needsScreenRecording: Bool?
var agentId: String?
var sessionKey: String?
}
fileprivate struct ExecHostRunResult: Codable {
var exitCode: Int?
var timedOut: Bool
var success: Bool
var stdout: String
var stderr: String
var error: String?
}
fileprivate struct ExecHostError: Codable {
var code: String
var message: String
var reason: String?
}
fileprivate struct ExecHostResponse: Codable {
var type: String
var id: String
var ok: Bool
var payload: ExecHostRunResult?
var error: ExecHostError?
}
enum ExecApprovalsSocketClient { enum ExecApprovalsSocketClient {
private struct TimeoutError: LocalizedError { private struct TimeoutError: LocalizedError {
var message: String var message: String
@@ -190,9 +146,6 @@ final class ExecApprovalsPromptServer {
token: approvals.token, token: approvals.token,
onPrompt: { request in onPrompt: { request in
await ExecApprovalsPromptPresenter.prompt(request) await ExecApprovalsPromptPresenter.prompt(request)
},
onExec: { request in
await ExecHostExecutor.handle(request)
}) })
server.start() server.start()
self.server = server self.server = server
@@ -253,182 +206,11 @@ enum ExecApprovalsPromptPresenter {
} }
} }
@MainActor
fileprivate enum ExecHostExecutor {
private static let blockedEnvKeys: Set<String> = [
"PATH",
"NODE_OPTIONS",
"PYTHONHOME",
"PYTHONPATH",
"PERL5LIB",
"PERL5OPT",
"RUBYOPT",
]
private static let blockedEnvPrefixes: [String] = [
"DYLD_",
"LD_",
]
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard !command.isEmpty else {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
}
let displayCommand = ExecCommandFormatter.displayString(
for: command,
rawCommand: request.rawCommand)
let agentId = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedAgent = (agentId?.isEmpty == false) ? agentId : nil
let approvals = ExecApprovalsStore.resolve(agentId: trimmedAgent)
let security = approvals.agent.security
let ask = approvals.agent.ask
let autoAllowSkills = approvals.agent.autoAllowSkills
let env = self.sanitizedEnv(request.env)
let resolution = ExecCommandResolution.resolve(
command: command,
rawCommand: request.rawCommand,
cwd: request.cwd,
env: env)
let allowlistMatch = security == .allowlist
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
: nil
let skillAllow: Bool
if autoAllowSkills, let name = resolution?.executableName {
let bins = await SkillBinsCache.shared.currentBins()
skillAllow = bins.contains(name)
} else {
skillAllow = false
}
if security == .deny {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny", reason: "security=deny"))
}
let requiresAsk: Bool = {
if ask == .always { return true }
if ask == .onMiss && security == .allowlist && allowlistMatch == nil && !skillAllow { return true }
return false
}()
var approvedByAsk = false
if requiresAsk {
let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: displayCommand,
cwd: request.cwd,
host: "node",
security: security.rawValue,
ask: ask.rawValue,
agentId: trimmedAgent,
resolvedPath: resolution?.resolvedPath))
switch decision {
case .deny:
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: user denied", reason: "user-denied"))
case .allowAlways:
approvedByAsk = true
if security == .allowlist {
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
if !pattern.isEmpty {
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
}
}
case .allowOnce:
approvedByAsk = true
}
}
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: allowlist miss", reason: "allowlist-miss"))
}
if let match = allowlistMatch {
ExecApprovalsStore.recordAllowlistUse(
agentId: trimmedAgent,
pattern: match.pattern,
command: displayCommand,
resolvedPath: resolution?.resolvedPath)
}
if request.needsScreenRecording == true {
let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false
if !authorized {
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: false,
payload: nil,
error: ExecHostError(code: "UNAVAILABLE", message: "PERMISSION_MISSING: screenRecording", reason: "permission:screenRecording"))
}
}
let timeoutSec = request.timeoutMs.flatMap { Double($0) / 1000.0 }
let result = await Task.detached { () -> ShellExecutor.ShellResult in
await ShellExecutor.runDetailed(
command: command,
cwd: request.cwd,
env: env,
timeout: timeoutSec)
}.value
let payload = ExecHostRunResult(
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
stdout: result.stdout,
stderr: result.stderr,
error: result.errorMessage)
return ExecHostResponse(
type: "exec-res",
id: UUID().uuidString,
ok: true,
payload: payload,
error: nil)
}
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? {
guard let overrides else { return nil }
var merged = ProcessInfo.processInfo.environment
for (rawKey, value) in overrides {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
let upper = key.uppercased()
if self.blockedEnvKeys.contains(upper) { continue }
if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
merged[key] = value
}
return merged
}
}
private final class ExecApprovalsSocketServer: @unchecked Sendable { private final class ExecApprovalsSocketServer: @unchecked Sendable {
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket") private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
private let socketPath: String private let socketPath: String
private let token: String private let token: String
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
private let onExec: @Sendable (ExecHostRequest) async -> ExecHostResponse
private var socketFD: Int32 = -1 private var socketFD: Int32 = -1
private var acceptTask: Task<Void, Never>? private var acceptTask: Task<Void, Never>?
private var isRunning = false private var isRunning = false
@@ -436,13 +218,11 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
init( init(
socketPath: String, socketPath: String,
token: String, token: String,
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision, onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision)
onExec: @escaping @Sendable (ExecHostRequest) async -> ExecHostResponse)
{ {
self.socketPath = socketPath self.socketPath = socketPath
self.token = token self.token = token
self.onPrompt = onPrompt self.onPrompt = onPrompt
self.onExec = onExec
} }
func start() { func start() {
@@ -537,39 +317,26 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
private func handleClient(fd: Int32) async { private func handleClient(fd: Int32) async {
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
do { do {
guard self.isAllowedPeer(fd: fd) else {
try self.sendApprovalResponse(handle: handle, id: UUID().uuidString, decision: .deny)
return
}
guard let line = try self.readLine(from: handle, maxBytes: 256_000), guard let line = try self.readLine(from: handle, maxBytes: 256_000),
let data = line.data(using: .utf8) let data = line.data(using: .utf8)
else { else {
return return
} }
guard let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any], guard request.type == "request", request.token == self.token else {
let type = envelope["type"] as? String let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: .deny)
else { let data = try JSONEncoder().encode(response)
return var payload = data
} payload.append(0x0A)
try handle.write(contentsOf: payload)
if type == "request" {
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
guard request.token == self.token else {
try self.sendApprovalResponse(handle: handle, id: request.id, decision: .deny)
return
}
let decision = await self.onPrompt(request.request)
try self.sendApprovalResponse(handle: handle, id: request.id, decision: decision)
return
}
if type == "exec" {
let request = try JSONDecoder().decode(ExecHostSocketRequest.self, from: data)
let response = await self.handleExecRequest(request)
try self.sendExecResponse(handle: handle, response: response)
return return
} }
let decision = await self.onPrompt(request.request)
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: decision)
let responseData = try JSONEncoder().encode(response)
var payload = responseData
payload.append(0x0A)
try handle.write(contentsOf: payload)
} catch { } catch {
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)") self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
} }
@@ -590,77 +357,4 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
let lineData = buffer.subdata(in: 0..<newlineIndex) let lineData = buffer.subdata(in: 0..<newlineIndex)
return String(data: lineData, encoding: .utf8) return String(data: lineData, encoding: .utf8)
} }
private func sendApprovalResponse(
handle: FileHandle,
id: String,
decision: ExecApprovalDecision) throws
{
let response = ExecApprovalSocketDecision(type: "decision", id: id, decision: decision)
let data = try JSONEncoder().encode(response)
var payload = data
payload.append(0x0A)
try handle.write(contentsOf: payload)
}
private func sendExecResponse(handle: FileHandle, response: ExecHostResponse) throws {
let data = try JSONEncoder().encode(response)
var payload = data
payload.append(0x0A)
try handle.write(contentsOf: payload)
}
private func isAllowedPeer(fd: Int32) -> Bool {
var uid = uid_t(0)
var gid = gid_t(0)
if getpeereid(fd, &uid, &gid) != 0 {
return false
}
return uid == geteuid()
}
private func handleExecRequest(_ request: ExecHostSocketRequest) async -> ExecHostResponse {
let nowMs = Int(Date().timeIntervalSince1970 * 1000)
if abs(nowMs - request.ts) > 10_000 {
return ExecHostResponse(
type: "exec-res",
id: request.id,
ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "expired request", reason: "ttl"))
}
let expected = self.hmacHex(nonce: request.nonce, ts: request.ts, requestJson: request.requestJson)
if expected != request.hmac {
return ExecHostResponse(
type: "exec-res",
id: request.id,
ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "invalid auth", reason: "hmac"))
}
guard let requestData = request.requestJson.data(using: .utf8),
let payload = try? JSONDecoder().decode(ExecHostRequest.self, from: requestData)
else {
return ExecHostResponse(
type: "exec-res",
id: request.id,
ok: false,
payload: nil,
error: ExecHostError(code: "INVALID_REQUEST", message: "invalid payload", reason: "json"))
}
let response = await self.onExec(payload)
return ExecHostResponse(
type: "exec-res",
id: request.id,
ok: response.ok,
payload: response.payload,
error: response.error)
}
private func hmacHex(nonce: String, ts: Int, requestJson: String) -> String {
let key = SymmetricKey(data: Data(self.token.utf8))
let message = "\(nonce):\(ts):\(requestJson)"
let mac = HMAC<SHA256>.authenticationCode(for: Data(message.utf8), using: key)
return mac.map { String(format: "%02x", $0) }.joined()
}
} }
@@ -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)
@@ -280,7 +280,9 @@ extension MenuSessionsInjector {
private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int { private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
let rows = self.usageRows let rows = self.usageRows
if rows.isEmpty { let errorText = self.cachedUsageErrorText
if rows.isEmpty, errorText == nil {
return cursor return cursor
} }
@@ -304,6 +306,25 @@ extension MenuSessionsInjector {
menu.insertItem(headerItem, at: cursor) menu.insertItem(headerItem, at: cursor)
cursor += 1 cursor += 1
if let errorText = errorText?.nonEmpty, !rows.isEmpty {
menu.insertItem(
self.makeMessageItem(
text: errorText,
symbolName: "exclamationmark.triangle",
width: width,
maxLines: 2),
at: cursor)
cursor += 1
}
if rows.isEmpty {
menu.insertItem(
self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width),
at: cursor)
cursor += 1
return cursor
}
if let selectedProvider = self.selectedUsageProviderId, if let selectedProvider = self.selectedUsageProviderId,
let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }), let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }),
rows.count > 1 rows.count > 1
@@ -419,8 +440,6 @@ extension MenuSessionsInjector {
displayName: "Gateway", displayName: "Gateway",
platform: platform, platform: platform,
version: nil, version: nil,
coreVersion: nil,
uiVersion: nil,
deviceFamily: nil, deviceFamily: nil,
modelIdentifier: nil, modelIdentifier: nil,
remoteIp: host, remoteIp: host,
@@ -540,11 +559,14 @@ extension MenuSessionsInjector {
do { do {
self.cachedUsageSummary = try await UsageLoader.loadSummary() self.cachedUsageSummary = try await UsageLoader.loadSummary()
} catch {
self.cachedUsageSummary = nil
self.cachedUsageErrorText = nil self.cachedUsageErrorText = nil
self.usageCacheUpdatedAt = Date()
} catch {
if self.cachedUsageSummary == nil {
self.cachedUsageErrorText = self.compactUsageError(error)
}
self.usageCacheUpdatedAt = Date()
} }
self.usageCacheUpdatedAt = Date()
} }
private func compactUsageError(_ error: Error) -> String { private func compactUsageError(_ error: Error) -> String {
@@ -725,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
@@ -1660,40 +1652,6 @@ public struct ExecApprovalsSetParams: Codable, Sendable {
} }
} }
public struct ExecApprovalsNodeGetParams: Codable, Sendable {
public let nodeid: String
public init(
nodeid: String
) {
self.nodeid = nodeid
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
}
}
public struct ExecApprovalsNodeSetParams: Codable, Sendable {
public let nodeid: String
public let file: [String: AnyCodable]
public let basehash: String?
public init(
nodeid: String,
file: [String: AnyCodable],
basehash: String?
) {
self.nodeid = nodeid
self.file = file
self.basehash = basehash
}
private enum CodingKeys: String, CodingKey {
case nodeid = "nodeId"
case file
case basehash = "baseHash"
}
}
public struct ExecApprovalsSnapshot: Codable, Sendable { public struct ExecApprovalsSnapshot: Codable, Sendable {
public let path: String public let path: String
public let exists: Bool public let exists: Bool
@@ -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
+5 -14
View File
@@ -7,8 +7,8 @@ read_when:
# `clawdbot memory` # `clawdbot memory`
Manage semantic memory indexing and search. Memory search tools (semantic memory status/index/search).
Provided by the active memory plugin (default: `memory-core`; set `plugins.slots.memory = "none"` to disable). Provided by the active memory plugin (default: `memory-core`; use `plugins.slots.memory = "none"` to disable).
Related: Related:
- Memory concept: [Memory](/concepts/memory) - Memory concept: [Memory](/concepts/memory)
@@ -22,20 +22,11 @@ clawdbot memory status --deep
clawdbot memory status --deep --index clawdbot memory status --deep --index
clawdbot memory status --deep --index --verbose clawdbot memory status --deep --index --verbose
clawdbot memory index clawdbot memory index
clawdbot memory index --verbose
clawdbot memory search "release checklist" clawdbot memory search "release checklist"
clawdbot memory status --agent main
clawdbot memory index --agent main --verbose
``` ```
## Options ## Options
Common: - `--verbose`: emit debug logs during memory probes and indexing.
- `--index-mode auto|batch|direct`: override batch usage when indexing (`direct` favors speed; `batch` favors OpenAI Batch pricing).
- `--agent <id>`: scope to a single agent (default: all configured agents). - `--progress auto|line|log|none`: progress output mode (`log` prints updates even without a TTY).
- `--verbose`: emit detailed logs during probes and indexing.
Notes:
- `memory status --deep` probes vector + embedding availability.
- `memory status --deep --index` runs a reindex if the store is dirty.
- `memory index --verbose` prints per-phase details (provider, model, sources, batch activity).
+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.
+18 -32
View File
@@ -79,46 +79,37 @@ semantic queries can find related notes even when wording differs.
Defaults: Defaults:
- Enabled by default. - Enabled by default.
- Watches memory files for changes (debounced). - Watches memory files for changes (debounced).
- Uses remote embeddings by default. If `memorySearch.provider` is not set, Clawdbot auto-selects: - Uses remote embeddings (OpenAI) unless configured for local.
1. `local` if a `memorySearch.local.modelPath` is configured and the file exists.
2. `openai` if an OpenAI key can be resolved.
3. `gemini` if a Gemini key can be resolved.
4. Otherwise memory search stays disabled until configured.
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`. - Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite. - Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
Remote embeddings **require** an API key for the embedding provider. Clawdbot Remote embeddings **require** an API key for the embedding provider. By default
resolves keys from auth profiles, `models.providers.*.apiKey`, or environment this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex
variables. Codex OAuth only covers chat/completions and does **not** satisfy OAuth only covers chat/completions and does **not** satisfy embeddings for
embeddings for memory search. For Gemini, use `GEMINI_API_KEY` or memory search. When using a custom OpenAI-compatible endpoint, set
`models.providers.google.apiKey`. When using a custom OpenAI-compatible endpoint, `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
### Gemini embeddings (native) If you want to use **Gemini embeddings** directly, set the provider to `gemini`:
Set the provider to `gemini` to use the Gemini embeddings API directly:
```json5 ```json5
agents: { agents: {
defaults: { defaults: {
memorySearch: { memorySearch: {
provider: "gemini", provider: "gemini",
model: "gemini-embedding-001", model: "gemini-embedding-001", // default
remote: { remote: {
apiKey: "YOUR_GEMINI_API_KEY" apiKey: "${GEMINI_API_KEY}"
} }
} }
} }
} }
``` ```
Notes: Gemini uses `GEMINI_API_KEY` (or `models.providers.google.apiKey`). Override
- `remote.baseUrl` is optional (defaults to the Gemini API base URL). `memorySearch.remote.baseUrl` to point at a custom Gemini-compatible endpoint.
- `remote.headers` lets you add extra headers if needed.
- Default model: `gemini-embedding-001`.
If you want to use a **custom OpenAI-compatible endpoint** (OpenRouter, vLLM, or a proxy), If you want to use a **custom OpenAI-compatible endpoint** (like OpenRouter or a proxy),
you can use the `remote` configuration with the OpenAI provider: you can use the `remote` configuration:
```json5 ```json5
agents: { agents: {
@@ -127,8 +118,8 @@ agents: {
provider: "openai", provider: "openai",
model: "text-embedding-3-small", model: "text-embedding-3-small",
remote: { remote: {
baseUrl: "https://api.example.com/v1/", baseUrl: "https://proxy.example/v1",
apiKey: "YOUR_OPENAI_COMPAT_API_KEY", apiKey: "YOUR_PROXY_KEY",
headers: { "X-Custom-Header": "value" } headers: { "X-Custom-Header": "value" }
} }
} }
@@ -139,16 +130,11 @@ agents: {
If you don't want to set an API key, use `memorySearch.provider = "local"` or set If you don't want to set an API key, use `memorySearch.provider = "local"` or set
`memorySearch.fallback = "none"`. `memorySearch.fallback = "none"`.
Fallbacks: Batch indexing (OpenAI only):
- `memorySearch.fallback` can be `openai`, `gemini`, `local`, or `none`. - Enabled by default for OpenAI embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
- The fallback provider is only used when the primary embedding provider fails.
Batch indexing (OpenAI + Gemini):
- Enabled by default for OpenAI and Gemini embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed. - Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
- Set `remote.batch.concurrency` to control how many batch jobs we submit in parallel (default: 2). - Set `remote.batch.concurrency` to control how many batch jobs we submit in parallel (default: 2).
- Batch mode applies when `memorySearch.provider = "openai"` or `"gemini"` and uses the corresponding API key. - Batch mode currently applies only when `memorySearch.provider = "openai"` and uses your OpenAI API key.
- Gemini batch jobs use the async embeddings batch endpoint and require Gemini Batch API availability.
Why OpenAI batch is fast + cheap: Why OpenAI batch is fast + cheap:
- For large backfills, OpenAI is typically the fastest option we support because we can submit many embedding requests in a single batch job and let OpenAI process them asynchronously. - For large backfills, OpenAI is typically the fastest option we support because we can submit many embedding requests in a single batch job and let OpenAI process them asynchronously.
-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:
+2 -5
View File
@@ -9,9 +9,8 @@ read_when:
# Gateway on macOS (external launchd) # Gateway on macOS (external launchd)
Clawdbot.app no longer bundles Node/Bun or the Gateway runtime. The macOS app Clawdbot.app no longer bundles Node/Bun or the Gateway runtime. The macOS app
expects an **external** `clawdbot` CLI install, does not spawn the Gateway as a expects an **external** `clawdbot` CLI install and manages a per‑user launchd
child process, and manages a per‑user launchd service to keep the Gateway service to keep the Gateway running.
running (or attaches to an existing local Gateway if one is already running).
## Install the CLI (required for local mode) ## Install the CLI (required for local mode)
@@ -39,8 +38,6 @@ Manager:
Behavior: Behavior:
- “Clawdbot Active” enables/disables the LaunchAgent. - “Clawdbot Active” enables/disables the LaunchAgent.
- App quit does **not** stop the gateway (launchd keeps it alive). - App quit does **not** stop the gateway (launchd keeps it alive).
- If a Gateway is already running on the configured port, the app attaches to
it instead of starting a new one.
Logging: Logging:
- launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log` - launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log`
+3 -5
View File
@@ -5,11 +5,9 @@ read_when:
--- ---
# Gateway lifecycle on macOS # Gateway lifecycle on macOS
The macOS app **manages the Gateway via launchd** by default and does not spawn The macOS app **manages the Gateway via launchd** by default. The launchd job
the Gateway as a child process. It first tries to attach to an already‑running uses the external `clawdbot` CLI (no embedded runtime). This gives you reliable
Gateway on the configured port; if none is reachable, it enables the launchd auto‑start at login and restart on crashes.
service via the external `clawdbot` CLI (no embedded runtime). This gives you
reliable auto‑start at login and restart on crashes.
Child‑process mode (Gateway spawned directly by the app) is **not in use** today. Child‑process mode (Gateway spawned directly by the app) is **not in use** today.
If you need tighter coupling to the UI, run the Gateway manually in a terminal. If you need tighter coupling to the UI, run the Gateway manually in a terminal.
-3
View File
@@ -32,9 +32,6 @@ To build the macOS app and package it into `dist/Clawdbot.app`, run:
If you don't have an Apple Developer ID certificate, the script will automatically use **ad-hoc signing** (`-`). If you don't have an Apple Developer ID certificate, the script will automatically use **ad-hoc signing** (`-`).
For dev run modes, signing flags, and Team ID troubleshooting, see the macOS app README:
https://github.com/clawdbot/clawdbot/blob/main/apps/macos/README.md
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section. > **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
## 3. Install the CLI ## 3. Install the CLI
-2
View File
@@ -14,7 +14,6 @@ This app is usually built from [`scripts/package-mac-app.sh`](https://github.com
- inject build metadata into Info.plist: `ClawdbotBuildTimestamp` (UTC) and `ClawdbotGitCommit` (short hash) so the About pane can show build, git, and debug/release channel. - inject build metadata into Info.plist: `ClawdbotBuildTimestamp` (UTC) and `ClawdbotGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
- **Packaging requires Node 22+**: the script runs TS builds and the Control UI build. - **Packaging requires Node 22+**: the script runs TS builds and the Control UI build.
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing). - reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing).
- runs a Team ID audit after signing and fails if any Mach-O inside the app bundle is signed by a different Team ID. Set `SKIP_TEAM_ID_CHECK=1` to bypass.
## Usage ## Usage
@@ -24,7 +23,6 @@ scripts/package-mac-app.sh # auto-selects identity; errors if none
SIGN_IDENTITY="Developer ID Application: Your Name" scripts/package-mac-app.sh # real cert SIGN_IDENTITY="Developer ID Application: Your Name" scripts/package-mac-app.sh # real cert
ALLOW_ADHOC_SIGNING=1 scripts/package-mac-app.sh # ad-hoc (permissions will not stick) ALLOW_ADHOC_SIGNING=1 scripts/package-mac-app.sh # ad-hoc (permissions will not stick)
SIGN_IDENTITY="-" scripts/package-mac-app.sh # explicit ad-hoc (same caveat) SIGN_IDENTITY="-" scripts/package-mac-app.sh # explicit ad-hoc (same caveat)
DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh # dev-only Sparkle Team ID mismatch workaround
``` ```
### Ad-hoc Signing Note ### Ad-hoc Signing Note
-17
View File
@@ -7,8 +7,6 @@ read_when:
**Current model:** there is **no local control socket** and no `clawdbot-mac` CLI. All agent actions go through the Gateway WebSocket and `node.invoke`. UI automation still uses PeekabooBridge. **Current model:** there is **no local control socket** and no `clawdbot-mac` CLI. All agent actions go through the Gateway WebSocket and `node.invoke`. UI automation still uses PeekabooBridge.
**Planned model:** add a local Unix socket between the **node service** and the **macOS app**. The app owns `system.run` (UI/TCC context); the node service forwards exec requests over IPC.
## Goals ## Goals
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript). - Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
- A small surface for automation: Gateway + node commands, plus PeekabooBridge for UI automation. - A small surface for automation: Gateway + node commands, plus PeekabooBridge for UI automation.
@@ -19,19 +17,6 @@ read_when:
- The app runs the Gateway (local mode) and connects to it as a node. - The app runs the Gateway (local mode) and connects to it as a node.
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`). - Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
### Node service + app IPC (planned)
- A headless node service connects to the Gateway bridge.
- `system.run` requests are forwarded to the macOS app over a local Unix socket.
- The app performs the exec in UI context, prompts if needed, and returns output.
Diagram (SCI):
```
Agent -> Gateway -> Bridge -> Node Service (TS)
| IPC (UDS + token + HMAC + TTL)
v
Mac App (UI + TCC + system.run)
```
### PeekabooBridge (UI automation) ### PeekabooBridge (UI automation)
- UI automation uses a separate UNIX socket named `bridge.sock` and the PeekabooBridge JSON protocol. - UI automation uses a separate UNIX socket named `bridge.sock` and the PeekabooBridge JSON protocol.
- Host preference order (client-side): Peekaboo.app → Claude.app → Clawdbot.app → local execution. - Host preference order (client-side): Peekaboo.app → Claude.app → Clawdbot.app → local execution.
@@ -40,7 +25,6 @@ Agent -> Gateway -> Bridge -> Node Service (TS)
### Mach/XPC ### Mach/XPC
- Not required for automation; `node.invoke` + PeekabooBridge cover current needs. - Not required for automation; `node.invoke` + PeekabooBridge cover current needs.
- Planned IPC keeps Unix sockets (no XPC helper).
## Operational flows ## Operational flows
- Restart/rebuild: `SIGN_IDENTITY="Apple Development: <Developer Name> (<TEAMID>)" scripts/restart-mac.sh` - Restart/rebuild: `SIGN_IDENTITY="Apple Development: <Developer Name> (<TEAMID>)" scripts/restart-mac.sh`
@@ -54,4 +38,3 @@ Agent -> Gateway -> Bridge -> Node Service (TS)
- PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development. - PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development.
- All communication remains local-only; no network sockets are exposed. - All communication remains local-only; no network sockets are exposed.
- TCC prompts originate only from the GUI app bundle; keep the signed bundle ID stable across rebuilds. - TCC prompts originate only from the GUI app bundle; keep the signed bundle ID stable across rebuilds.
- Planned IPC hardening: socket mode `0600`, token, peer-UID checks, HMAC challenge/response, short TTL.
+5 -21
View File
@@ -7,8 +7,8 @@ read_when:
# Clawdbot macOS Companion (menu bar + gateway broker) # Clawdbot macOS Companion (menu bar + gateway broker)
The macOS app is the **menu‑bar companion** for Clawdbot. It owns permissions, The macOS app is the **menu‑bar companion** for Clawdbot. It owns permissions,
manages/attaches to the Gateway locally (launchd or manual), and exposes macOS manages the Gateway locally, and exposes macOS capabilities to the agent as a
capabilities to the agent as a node. node.
## What it does ## What it does
@@ -20,17 +20,13 @@ capabilities to the agent as a node.
- Optionally hosts **PeekabooBridge** for UI automation. - Optionally hosts **PeekabooBridge** for UI automation.
- Installs the global CLI (`clawdbot`) via npm/pnpm on request (bun not recommended for the Gateway runtime). - Installs the global CLI (`clawdbot`) via npm/pnpm on request (bun not recommended for the Gateway runtime).
Planned:
- Run a headless **node service** locally (launchd).
- Keep `system.run` in the app (UI/TCC context), with the node service forwarding via IPC.
## Local vs remote mode ## Local vs remote mode
- **Local** (default): the app attaches to a running local Gateway if present; - **Local** (default): the app ensures a local Gateway is running via launchd.
otherwise it enables the launchd service via `clawdbot daemon`.
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts - **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
a local process. a local process.
The app does not spawn the Gateway as a child process. - **Attach‑only** (debug): the app connects to an already‑running local Gateway
and never spawns its own.
## Launchd control ## Launchd control
@@ -58,18 +54,6 @@ The macOS app presents itself as a node. Common commands:
The node reports a `permissions` map so agents can decide what’s allowed. The node reports a `permissions` map so agents can decide what’s allowed.
Planned split:
- Node service advertises the node surface to the Gateway.
- macOS app performs `system.run` in UI context over IPC.
Diagram (SCI):
```
Gateway -> Bridge -> Node Service (TS)
| IPC (UDS + token + HMAC + TTL)
v
Mac App (UI + TCC + system.run)
```
## Exec approvals (system.run) ## Exec approvals (system.run)
`system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals). `system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
+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.
+8 -21
View File
@@ -30,8 +30,6 @@ read_when:
- **Node identity:** use existing `nodeId`. - **Node identity:** use existing `nodeId`.
- **Socket auth:** Unix socket + token (cross-platform); split later if needed. - **Socket auth:** Unix socket + token (cross-platform); split later if needed.
- **Node host state:** `~/.clawdbot/node.json` (node id + pairing token). - **Node host state:** `~/.clawdbot/node.json` (node id + pairing token).
- **macOS exec host:** run `system.run` inside the macOS app; node service forwards requests over local IPC.
- **No XPC helper:** stick to Unix socket + token + peer checks.
## Key concepts ## Key concepts
### Host ### Host
@@ -141,30 +139,19 @@ Notes:
## UI integration (macOS app) ## UI integration (macOS app)
### IPC ### IPC
- Unix socket at `~/.clawdbot/exec-approvals.sock` (0600). - Unix socket at `~/.clawdbot/exec-approvals.sock`.
- Token stored in `exec-approvals.json` (0600). - Runner connects and sends an approval request; UI responds with a decision.
- Peer checks: same-UID only. - Token stored in `exec-approvals.json`.
- Challenge/response: nonce + HMAC(token, request-hash) to prevent replay.
- Short TTL (e.g., 10s) + max payload + rate limit.
### Ask flow (macOS app exec host) ### Ask flow
1) Node service receives `system.run` from gateway. 1) Runner receives `system.run` from gateway.
2) Node service connects to the local socket and sends the prompt/exec request. 2) If ask required, runner connects to the socket and sends a prompt request.
3) App validates peer + token + HMAC + TTL, then shows dialog if needed. 3) UI shows dialog; returns decision.
4) App executes the command in UI context and returns output. 4) Runner enforces decision and proceeds.
5) Node service returns output to gateway.
If UI missing: If UI missing:
- Apply `askFallback` (`deny|allowlist|full`). - Apply `askFallback` (`deny|allowlist|full`).
### Diagram (SCI)
```
Agent -> Gateway -> Bridge -> Node Service (TS)
| IPC (UDS + token + HMAC + TTL)
v
Mac App (UI + TCC + system.run)
```
## Node identity + binding ## Node identity + binding
- Use existing `nodeId` from Bridge pairing. - Use existing `nodeId` from Bridge pairing.
- Binding model: - Binding model:
-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.
+36 -22
View File
@@ -60,6 +60,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Remote gateways + nodes](#remote-gateways-nodes) - [Remote gateways + nodes](#remote-gateways-nodes)
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes) - [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon) - [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
- [Can I run a headless node host without the macOS app?](#can-i-run-a-headless-node-host-without-the-macos-app)
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config) - [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install) - [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
- [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac) - [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac)
@@ -405,7 +406,7 @@ You have three supported patterns:
Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS. Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS.
**Option B - use a macOS node (no SSH).** **Option B - use a macOS node (no SSH).**
Run the Gateway on Linux, pair a macOS node (menubar app), and set **Node Run Commands** to "Always Ask" or "Always Allow" on the Mac. Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Always Ask", approving "Always Allow" in the prompt adds that command to the allowlist. Run the Gateway on Linux, pair a macOS node (menubar app), and configure **Exec approvals** (Settings → Exec approvals) to "Ask" or "Always Allow". Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Ask", selecting "Always Allow" in the prompt adds that command to the allowlist.
**Option C - proxy macOS binaries over SSH (advanced).** **Option C - proxy macOS binaries over SSH (advanced).**
Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wrappers that run on a Mac. Then override the skill to allow Linux so it stays eligible. Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wrappers that run on a Mac. Then override the skill to allow Linux so it stays eligible.
@@ -501,23 +502,14 @@ is writable (read-only sandboxes skip it). See [Memory](/concepts/memory).
### Does semantic memory search require an OpenAI API key? ### Does semantic memory search require an OpenAI API key?
Only if you use **OpenAI embeddings**. Codex OAuth covers chat/completions and Only if you use **remote embeddings** (OpenAI). Codex OAuth covers
does **not** grant embeddings access, so **signing in with Codex (OAuth or the chat/completions and does **not** grant embeddings access, so **signing in with
Codex CLI login)** does not help for semantic memory search. OpenAI embeddings Codex (OAuth or the Codex CLI login)** does not help for semantic memory search.
still need a real API key (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Remote memory search still needs a real OpenAI API key (`OPENAI_API_KEY` or
`models.providers.openai.apiKey`). If you’d rather stay local, set
If you don’t set a provider explicitly, Clawdbot auto-selects a provider when it `memorySearch.provider = "local"` (and optionally `memorySearch.fallback =
can resolve an API key (auth profiles, `models.providers.*.apiKey`, or env vars). "none"`). We support **remote or local embedding models** — see [Memory](/concepts/memory)
It prefers OpenAI if an OpenAI key resolves, otherwise Gemini if a Gemini key for the setup details.
resolves. If neither key is available, memory search stays disabled until you
configure it. If you have a local model path configured and present, Clawdbot
prefers `local`.
If you’d rather stay local, set `memorySearch.provider = "local"` (and optionally
`memorySearch.fallback = "none"`). If you want Gemini embeddings, set
`memorySearch.provider = "gemini"` and provide `GEMINI_API_KEY` (or
`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, or local** embedding
models — see [Memory](/concepts/memory) for the setup details.
## Where things live on disk ## Where things live on disk
@@ -751,6 +743,23 @@ to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes. A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
### Can I run a headless node host without the macOS app?
Yes. The headless node host is a **command-only** node that exposes `system.run` / `system.which`
without any UI. It has no screen/camera/notify support (use the macOS app for those).
Start it:
```bash
clawdbot node start --host <gateway-host> --port 18790
```
Notes:
- Pairing is still required (`clawdbot nodes pending` → `clawdbot nodes approve <requestId>`).
- Exec approvals still apply via `~/.clawdbot/exec-approvals.json`.
- If prompts are enabled but no companion UI is reachable, `askFallback` decides (default: deny).
Docs: [Node CLI](/cli/node), [Nodes](/nodes), [Exec approvals](/tools/exec-approvals).
### Is there an API / RPC way to apply config? ### Is there an API / RPC way to apply config?
Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation. Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation.
@@ -889,14 +898,19 @@ Send `/new` or `/reset` as a standalone message. See [Session management](/conce
### Do sessions reset automatically if I never send `/new`? ### Do sessions reset automatically if I never send `/new`?
Yes. Sessions expire after `session.idleMinutes` (default **60**). The **next** Yes. By default sessions reset daily at **4:00 AM local time** on the gateway host.
message starts a fresh session id for that chat key. This does not delete You can also add an idle window; when both daily and idle resets are configured,
transcripts — it just starts a new session. whichever expires first starts a new session id on the next message. This does
not delete transcripts — it just starts a new session.
```json5 ```json5
{ {
session: { session: {
idleMinutes: 240 reset: {
mode: "daily",
atHour: 4,
idleMinutes: 240
}
} }
} }
``` ```
+2 -23
View File
@@ -22,10 +22,6 @@ Exec approvals are enforced locally on the execution host:
- **gateway host** → `clawdbot` process on the gateway machine - **gateway host** → `clawdbot` process on the gateway machine
- **node host** → node runner (macOS companion app or headless node host) - **node host** → node runner (macOS companion app or headless node host)
Planned macOS split:
- **node service** forwards `system.run` to the **macOS app** over local IPC.
- **macOS app** enforces approvals + executes the command in UI context.
## Settings and storage ## Settings and storage
Approvals live in a local JSON file on the execution host: Approvals live in a local JSON file on the execution host:
@@ -111,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
@@ -132,19 +124,6 @@ Actions:
- **Always allow** → add to allowlist + run - **Always allow** → add to allowlist + run
- **Deny** → block - **Deny** → block
### macOS IPC flow (planned)
```
Gateway -> Bridge -> Node Service (TS)
| IPC (UDS + token + HMAC + TTL)
v
Mac App (UI + approvals + system.run)
```
Security notes:
- Unix socket mode `0600`, token stored in `exec-approvals.json`.
- Same-UID peer check.
- Challenge/response (nonce + HMAC token + request hash) + short TTL.
## System events ## System events
Exec lifecycle is surfaced as system messages: Exec lifecycle is surfaced as system messages:
+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"
} }
+12 -68
View File
@@ -62,34 +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;
const callIdMatch = webhookUrl.match(/callId=([^&]+)/);
if (!callIdMatch) return;
this.deleteStoredTwiml(callIdMatch[1]);
}
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");
@@ -256,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;
@@ -288,25 +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;
// 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) {
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();
@@ -376,28 +328,22 @@ export class TwilioProvider implements VoiceCallProvider {
const url = new URL(input.webhookUrl); const url = new URL(input.webhookUrl);
url.searchParams.set("callId", input.callId); url.searchParams.set("callId", input.callId);
// Create separate URL for status callbacks (required by Twilio) // Build request params
const statusUrl = new URL(input.webhookUrl);
statusUrl.searchParams.set("callId", input.callId);
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: url.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,
@@ -415,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(
+22 -22
View File
@@ -65,27 +65,27 @@
"dist/whatsapp/**" "dist/whatsapp/**"
], ],
"scripts": { "scripts": {
"dev": "bun src/entry.ts", "dev": "tsx src/entry.ts",
"postinstall": "node scripts/postinstall.js", "postinstall": "node scripts/postinstall.js",
"prepack": "pnpm build", "prepack": "pnpm build",
"docs:list": "bun scripts/docs-list.ts", "docs:list": "tsx scripts/docs-list.ts",
"docs:bin": "bun build scripts/docs-list.ts --compile --outfile bin/docs-list", "docs:bin": "bun build scripts/docs-list.ts --compile --outfile bin/docs-list",
"docs:dev": "cd docs && mint dev", "docs:dev": "cd docs && mint dev",
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links", "docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
"build": "tsc -p tsconfig.json && bun scripts/canvas-a2ui-copy.ts && bun scripts/copy-hook-metadata.ts && bun scripts/write-build-info.ts", "build": "tsc -p tsconfig.json && tsx scripts/canvas-a2ui-copy.ts && tsx scripts/copy-hook-metadata.ts && tsx scripts/write-build-info.ts",
"plugins:sync": "bun scripts/sync-plugin-versions.ts", "plugins:sync": "tsx scripts/sync-plugin-versions.ts",
"release:check": "bun scripts/release-check.ts", "release:check": "tsx scripts/release-check.ts",
"ui:install": "node scripts/ui.js install", "ui:install": "node scripts/ui.js install",
"ui:dev": "node scripts/ui.js dev", "ui:dev": "node scripts/ui.js dev",
"ui:build": "node scripts/ui.js build", "ui:build": "node scripts/ui.js build",
"start": "bun src/entry.ts", "start": "tsx src/entry.ts",
"clawdbot": "bun src/entry.ts", "clawdbot": "tsx src/entry.ts",
"gateway:watch": "bun --watch src/entry.ts gateway --force", "gateway:watch": "tsx watch src/entry.ts gateway --force",
"gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 bun src/entry.ts --dev gateway", "gateway:dev": "CLAWDBOT_SKIP_CHANNELS=1 tsx src/entry.ts --dev gateway",
"gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 bun src/entry.ts --dev gateway --reset", "gateway:dev:reset": "CLAWDBOT_SKIP_CHANNELS=1 tsx src/entry.ts --dev gateway --reset",
"tui": "bun src/entry.ts tui", "tui": "tsx src/entry.ts tui",
"tui:dev": "CLAWDBOT_PROFILE=dev bun src/entry.ts tui", "tui:dev": "CLAWDBOT_PROFILE=dev tsx src/entry.ts tui",
"clawdbot:rpc": "bun src/entry.ts agent --mode rpc --json", "clawdbot:rpc": "tsx src/entry.ts agent --mode rpc --json",
"ios:gen": "cd apps/ios && xcodegen generate", "ios:gen": "cd apps/ios && xcodegen generate",
"ios:open": "cd apps/ios && xcodegen generate && open Clawdbot.xcodeproj", "ios:open": "cd apps/ios && xcodegen generate && open Clawdbot.xcodeproj",
"ios:build": "bash -lc 'cd apps/ios && xcodegen generate && xcodebuild -project Clawdbot.xcodeproj -scheme Clawdbot -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'", "ios:build": "bash -lc 'cd apps/ios && xcodegen generate && xcodebuild -project Clawdbot.xcodeproj -scheme Clawdbot -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'",
@@ -108,7 +108,7 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:ui": "pnpm --dir ui test", "test:ui": "pnpm --dir ui test",
"test:force": "bun scripts/test-force.ts", "test:force": "tsx scripts/test-force.ts",
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"test:e2e": "vitest run --config vitest.e2e.config.ts", "test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:live": "CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts", "test:live": "CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts",
@@ -126,8 +126,8 @@
"test:install:smoke": "bash scripts/test-install-sh-docker.sh", "test:install:smoke": "bash scripts/test-install-sh-docker.sh",
"test:install:e2e:openai": "CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh", "test:install:e2e:openai": "CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh",
"test:install:e2e:anthropic": "CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh", "test:install:e2e:anthropic": "CLAWDBOT_E2E_MODELS=anthropic bash scripts/test-install-sh-e2e-docker.sh",
"protocol:gen": "bun scripts/protocol-gen.ts", "protocol:gen": "tsx scripts/protocol-gen.ts",
"protocol:gen:swift": "bun scripts/protocol-gen-swift.ts", "protocol:gen:swift": "tsx scripts/protocol-gen-swift.ts",
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift", "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
"check:loc": "tsx scripts/check-ts-max-loc.ts --max 500" "check:loc": "tsx scripts/check-ts-max-loc.ts --max 500"
@@ -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
-77
View File
@@ -4,28 +4,11 @@ set -euo pipefail
APP_BUNDLE="${1:-dist/Clawdbot.app}" APP_BUNDLE="${1:-dist/Clawdbot.app}"
IDENTITY="${SIGN_IDENTITY:-}" IDENTITY="${SIGN_IDENTITY:-}"
TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}" TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}"
DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}"
SKIP_TEAM_ID_CHECK="${SKIP_TEAM_ID_CHECK:-0}"
ENT_TMP_BASE=$(mktemp -t clawdbot-entitlements-base.XXXXXX) ENT_TMP_BASE=$(mktemp -t clawdbot-entitlements-base.XXXXXX)
ENT_TMP_APP=$(mktemp -t clawdbot-entitlements-app.XXXXXX) ENT_TMP_APP=$(mktemp -t clawdbot-entitlements-app.XXXXXX)
ENT_TMP_APP_BASE=$(mktemp -t clawdbot-entitlements-app-base.XXXXXX) ENT_TMP_APP_BASE=$(mktemp -t clawdbot-entitlements-app-base.XXXXXX)
ENT_TMP_RUNTIME=$(mktemp -t clawdbot-entitlements-runtime.XXXXXX) ENT_TMP_RUNTIME=$(mktemp -t clawdbot-entitlements-runtime.XXXXXX)
if [[ "${APP_BUNDLE}" == "--help" || "${APP_BUNDLE}" == "-h" ]]; then
cat <<'HELP'
Usage: scripts/codesign-mac-app.sh [app-bundle]
Env:
SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"
ALLOW_ADHOC_SIGNING=1
CODESIGN_TIMESTAMP=auto|on|off
DISABLE_LIBRARY_VALIDATION=1 # dev-only Sparkle Team ID workaround
SKIP_TEAM_ID_CHECK=1 # bypass Team ID audit
ENABLE_TIME_SENSITIVE_NOTIFICATIONS=1
HELP
exit 0
fi
if [ ! -d "$APP_BUNDLE" ]; then if [ ! -d "$APP_BUNDLE" ]; then
echo "App bundle not found: $APP_BUNDLE" >&2 echo "App bundle not found: $APP_BUNDLE" >&2
exit 1 exit 1
@@ -201,14 +184,6 @@ cat > "$ENT_TMP_APP" <<'PLIST'
</plist> </plist>
PLIST PLIST
if [[ "$DISABLE_LIBRARY_VALIDATION" == "1" ]]; then
/usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP_BASE" >/dev/null 2>&1 || \
/usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP_BASE"
/usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP" >/dev/null 2>&1 || \
/usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP"
echo "Note: disable-library-validation entitlement enabled (DISABLE_LIBRARY_VALIDATION=1)."
fi
# The time-sensitive entitlement is restricted and requires explicit enablement # The time-sensitive entitlement is restricted and requires explicit enablement
# (and typically a matching provisioning profile). It is *not* safe to enable # (and typically a matching provisioning profile). It is *not* safe to enable
# unconditionally for local debug packaging since AMFI will refuse to launch. # unconditionally for local debug packaging since AMFI will refuse to launch.
@@ -234,51 +209,6 @@ sign_plain_item() {
codesign --force ${options_args+"${options_args[@]}"} "${timestamp_args[@]}" --sign "$IDENTITY" "$target" codesign --force ${options_args+"${options_args[@]}"} "${timestamp_args[@]}" --sign "$IDENTITY" "$target"
} }
team_id_for() {
codesign -dv --verbose=4 "$1" 2>&1 | awk -F= '/^TeamIdentifier=/{print $2; exit}'
}
verify_team_ids() {
if [[ "$SKIP_TEAM_ID_CHECK" == "1" ]]; then
echo "Note: skipping Team ID audit (SKIP_TEAM_ID_CHECK=1)."
return 0
fi
local expected
expected="$(team_id_for "$APP_BUNDLE" || true)"
if [[ -z "$expected" ]]; then
echo "WARN: TeamIdentifier missing on app bundle; skipping Team ID audit."
return 0
fi
local mismatches=()
while IFS= read -r -d '' f; do
if /usr/bin/file "$f" | /usr/bin/grep -q "Mach-O"; then
local team
team="$(team_id_for "$f" || true)"
if [[ -z "$team" ]]; then
team="not set"
fi
if [[ "$expected" == "not set" ]]; then
if [[ "$team" != "not set" ]]; then
mismatches+=("$f (TeamIdentifier=$team)")
fi
elif [[ "$team" != "$expected" ]]; then
mismatches+=("$f (TeamIdentifier=$team)")
fi
fi
done < <(find "$APP_BUNDLE" -type f -print0)
if [[ "${#mismatches[@]}" -gt 0 ]]; then
echo "ERROR: Team ID mismatch detected (expected: $expected)"
for entry in "${mismatches[@]}"; do
echo " - $entry"
done
echo "Hint: re-sign embedded frameworks or set DISABLE_LIBRARY_VALIDATION=1 for dev builds."
exit 1
fi
}
# Sign main binary # Sign main binary
if [ -f "$APP_BUNDLE/Contents/MacOS/Clawdbot" ]; then if [ -f "$APP_BUNDLE/Contents/MacOS/Clawdbot" ]; then
echo "Signing main binary"; sign_item "$APP_BUNDLE/Contents/MacOS/Clawdbot" "$APP_ENTITLEMENTS" echo "Signing main binary"; sign_item "$APP_BUNDLE/Contents/MacOS/Clawdbot" "$APP_ENTITLEMENTS"
@@ -288,11 +218,6 @@ fi
SPARKLE="$APP_BUNDLE/Contents/Frameworks/Sparkle.framework" SPARKLE="$APP_BUNDLE/Contents/Frameworks/Sparkle.framework"
if [ -d "$SPARKLE" ]; then if [ -d "$SPARKLE" ]; then
echo "Signing Sparkle framework and helpers" echo "Signing Sparkle framework and helpers"
find "$SPARKLE" -type f -print0 | while IFS= read -r -d '' f; do
if /usr/bin/file "$f" | /usr/bin/grep -q "Mach-O"; then
sign_plain_item "$f"
fi
done
sign_plain_item "$SPARKLE/Versions/B/Sparkle" sign_plain_item "$SPARKLE/Versions/B/Sparkle"
sign_plain_item "$SPARKLE/Versions/B/Autoupdate" sign_plain_item "$SPARKLE/Versions/B/Autoupdate"
sign_plain_item "$SPARKLE/Versions/B/Updater.app/Contents/MacOS/Updater" sign_plain_item "$SPARKLE/Versions/B/Updater.app/Contents/MacOS/Updater"
@@ -315,7 +240,5 @@ fi
# Finally sign the bundle # Finally sign the bundle
sign_item "$APP_BUNDLE" "$APP_ENTITLEMENTS" sign_item "$APP_BUNDLE" "$APP_ENTITLEMENTS"
verify_team_ids
rm -f "$ENT_TMP_BASE" "$ENT_TMP_APP_BASE" "$ENT_TMP_APP" "$ENT_TMP_RUNTIME" rm -f "$ENT_TMP_BASE" "$ENT_TMP_APP_BASE" "$ENT_TMP_APP" "$ENT_TMP_RUNTIME"
echo "Codesign complete for $APP_BUNDLE" echo "Codesign complete for $APP_BUNDLE"
+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;
}
-18
View File
@@ -22,21 +22,6 @@ describe("memory search config", () => {
expect(resolved).toBeNull(); expect(resolved).toBeNull();
}); });
it("defaults provider to auto when unspecified", () => {
const cfg = {
agents: {
defaults: {
memorySearch: {
enabled: true,
},
},
},
};
const resolved = resolveMemorySearchConfig(cfg, "main");
expect(resolved?.provider).toBe("auto");
expect(resolved?.fallback).toBe("none");
});
it("merges defaults and overrides", () => { it("merges defaults and overrides", () => {
const cfg = { const cfg = {
agents: { agents: {
@@ -141,7 +126,6 @@ describe("memory search config", () => {
agents: { agents: {
defaults: { defaults: {
memorySearch: { memorySearch: {
provider: "openai",
remote: { remote: {
baseUrl: "https://default.example/v1", baseUrl: "https://default.example/v1",
apiKey: "default-key", apiKey: "default-key",
@@ -182,7 +166,6 @@ describe("memory search config", () => {
agents: { agents: {
defaults: { defaults: {
memorySearch: { memorySearch: {
provider: "openai",
sources: ["memory", "sessions"], sources: ["memory", "sessions"],
}, },
}, },
@@ -206,7 +189,6 @@ describe("memory search config", () => {
agents: { agents: {
defaults: { defaults: {
memorySearch: { memorySearch: {
provider: "openai",
sources: ["memory", "sessions"], sources: ["memory", "sessions"],
experimental: { sessionMemory: true }, experimental: { sessionMemory: true },
}, },
+24 -30
View File
@@ -3,13 +3,13 @@ import path from "node:path";
import type { ClawdbotConfig, MemorySearchConfig } from "../config/config.js"; import type { ClawdbotConfig, MemorySearchConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js"; import { resolveStateDir } from "../config/paths.js";
import { clampInt, clampNumber, resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { resolveAgentConfig } from "./agent-scope.js"; import { resolveAgentConfig } from "./agent-scope.js";
export type ResolvedMemorySearchConfig = { export type ResolvedMemorySearchConfig = {
enabled: boolean; enabled: boolean;
sources: Array<"memory" | "sessions">; sources: Array<"memory" | "sessions">;
provider: "openai" | "local" | "gemini" | "auto"; provider: "openai" | "gemini" | "local";
remote?: { remote?: {
baseUrl?: string; baseUrl?: string;
apiKey?: string; apiKey?: string;
@@ -25,7 +25,7 @@ export type ResolvedMemorySearchConfig = {
experimental: { experimental: {
sessionMemory: boolean; sessionMemory: boolean;
}; };
fallback: "openai" | "gemini" | "local" | "none"; fallback: "openai" | "none";
model: string; model: string;
local: { local: {
modelPath?: string; modelPath?: string;
@@ -110,40 +110,34 @@ function mergeConfig(
const enabled = overrides?.enabled ?? defaults?.enabled ?? true; const enabled = overrides?.enabled ?? defaults?.enabled ?? true;
const sessionMemory = const sessionMemory =
overrides?.experimental?.sessionMemory ?? defaults?.experimental?.sessionMemory ?? false; overrides?.experimental?.sessionMemory ?? defaults?.experimental?.sessionMemory ?? false;
const provider = overrides?.provider ?? defaults?.provider ?? "auto"; const provider = overrides?.provider ?? defaults?.provider ?? "openai";
const defaultRemote = defaults?.remote; const hasRemote = Boolean(defaults?.remote || overrides?.remote);
const overrideRemote = overrides?.remote; const includeRemote = hasRemote || provider === "openai" || provider === "gemini";
const hasRemote = Boolean(defaultRemote || overrideRemote);
const includeRemote =
hasRemote || provider === "openai" || provider === "gemini" || provider === "auto";
const batch = { const batch = {
enabled: overrideRemote?.batch?.enabled ?? defaultRemote?.batch?.enabled ?? true, enabled: overrides?.remote?.batch?.enabled ?? defaults?.remote?.batch?.enabled ?? true,
wait: overrideRemote?.batch?.wait ?? defaultRemote?.batch?.wait ?? true, wait: overrides?.remote?.batch?.wait ?? defaults?.remote?.batch?.wait ?? true,
concurrency: Math.max( concurrency: Math.max(
1, 1,
overrideRemote?.batch?.concurrency ?? defaultRemote?.batch?.concurrency ?? 2, overrides?.remote?.batch?.concurrency ?? defaults?.remote?.batch?.concurrency ?? 2,
), ),
pollIntervalMs: pollIntervalMs:
overrideRemote?.batch?.pollIntervalMs ?? defaultRemote?.batch?.pollIntervalMs ?? 2000, overrides?.remote?.batch?.pollIntervalMs ?? defaults?.remote?.batch?.pollIntervalMs ?? 2000,
timeoutMinutes: timeoutMinutes:
overrideRemote?.batch?.timeoutMinutes ?? defaultRemote?.batch?.timeoutMinutes ?? 60, overrides?.remote?.batch?.timeoutMinutes ?? defaults?.remote?.batch?.timeoutMinutes ?? 60,
}; };
const remote = includeRemote const remote = includeRemote
? { ? {
baseUrl: overrideRemote?.baseUrl ?? defaultRemote?.baseUrl, baseUrl: overrides?.remote?.baseUrl ?? defaults?.remote?.baseUrl,
apiKey: overrideRemote?.apiKey ?? defaultRemote?.apiKey, apiKey: overrides?.remote?.apiKey ?? defaults?.remote?.apiKey,
headers: overrideRemote?.headers ?? defaultRemote?.headers, headers: overrides?.remote?.headers ?? defaults?.remote?.headers,
batch, batch,
} }
: undefined; : undefined;
const fallback = overrides?.fallback ?? defaults?.fallback ?? "none"; const fallback = overrides?.fallback ?? defaults?.fallback ?? "openai";
const modelDefault = const model =
provider === "gemini" overrides?.model ??
? DEFAULT_GEMINI_MODEL defaults?.model ??
: provider === "openai" (provider === "gemini" ? DEFAULT_GEMINI_MODEL : DEFAULT_OPENAI_MODEL);
? DEFAULT_OPENAI_MODEL
: undefined;
const model = overrides?.model ?? defaults?.model ?? modelDefault ?? "";
const local = { const local = {
modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath, modelPath: overrides?.local?.modelPath ?? defaults?.local?.modelPath,
modelCacheDir: overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir, modelCacheDir: overrides?.local?.modelCacheDir ?? defaults?.local?.modelCacheDir,
@@ -200,14 +194,14 @@ function mergeConfig(
maxEntries: overrides?.cache?.maxEntries ?? defaults?.cache?.maxEntries, maxEntries: overrides?.cache?.maxEntries ?? defaults?.cache?.maxEntries,
}; };
const overlap = clampNumber(chunking.overlap, 0, Math.max(0, chunking.tokens - 1)); const overlap = Math.max(0, Math.min(chunking.overlap, chunking.tokens - 1));
const minScore = clampNumber(query.minScore, 0, 1); const minScore = Math.max(0, Math.min(1, query.minScore));
const vectorWeight = clampNumber(hybrid.vectorWeight, 0, 1); const vectorWeight = Math.max(0, Math.min(1, hybrid.vectorWeight));
const textWeight = clampNumber(hybrid.textWeight, 0, 1); const textWeight = Math.max(0, Math.min(1, hybrid.textWeight));
const sum = vectorWeight + textWeight; const sum = vectorWeight + textWeight;
const normalizedVectorWeight = sum > 0 ? vectorWeight / sum : DEFAULT_HYBRID_VECTOR_WEIGHT; const normalizedVectorWeight = sum > 0 ? vectorWeight / sum : DEFAULT_HYBRID_VECTOR_WEIGHT;
const normalizedTextWeight = sum > 0 ? textWeight / sum : DEFAULT_HYBRID_TEXT_WEIGHT; const normalizedTextWeight = sum > 0 ? textWeight / sum : DEFAULT_HYBRID_TEXT_WEIGHT;
const candidateMultiplier = clampInt(hybrid.candidateMultiplier, 1, 20); const candidateMultiplier = Math.max(1, Math.min(20, Math.floor(hybrid.candidateMultiplier)));
return { return {
enabled, enabled,
sources, sources,
@@ -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,
-20
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,23 +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");
expect(prompt).toContain(
"For Clawdbot behavior, commands, config, or architecture: consult local docs first.",
);
});
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;
+6 -3
View File
@@ -5,7 +5,6 @@ import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import { killProcessTree } from "../../agents/shell-utils.js"; import { killProcessTree } from "../../agents/shell-utils.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { logVerbose } from "../../globals.js"; import { logVerbose } from "../../globals.js";
import { clampInt } from "../../utils.js";
import type { MsgContext } from "../templating.js"; import type { MsgContext } from "../templating.js";
import type { ReplyPayload } from "../types.js"; import type { ReplyPayload } from "../types.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
@@ -32,10 +31,14 @@ type ActiveBashJob =
let activeJob: ActiveBashJob | null = null; let activeJob: ActiveBashJob | null = null;
function clampNumber(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function resolveForegroundMs(cfg: ClawdbotConfig): number { function resolveForegroundMs(cfg: ClawdbotConfig): number {
const raw = cfg.commands?.bashForegroundMs; const raw = cfg.commands?.bashForegroundMs;
if (typeof raw !== "number" || Number.isNaN(raw)) return DEFAULT_FOREGROUND_MS; if (typeof raw !== "number" || Number.isNaN(raw)) return DEFAULT_FOREGROUND_MS;
return clampInt(raw, 0, MAX_FOREGROUND_MS); return clampNumber(Math.floor(raw), 0, MAX_FOREGROUND_MS);
} }
function formatSessionSnippet(sessionId: string) { function formatSessionSnippet(sessionId: string) {
@@ -186,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);
}
+89 -2
View File
@@ -248,6 +248,69 @@ describe("memory cli", () => {
expect(close).toHaveBeenCalled(); expect(close).toHaveBeenCalled();
}); });
it("applies index mode overrides", async () => {
const { registerMemoryCli } = await import("./memory-cli.js");
const close = vi.fn(async () => {});
const sync = vi.fn(async () => {});
const probeEmbeddingAvailability = vi.fn(async () => ({ ok: true }));
loadConfig.mockReturnValueOnce({
agents: {
defaults: {
memorySearch: {
remote: {
batch: { enabled: true },
},
},
},
},
});
getMemorySearchManager.mockResolvedValueOnce({
manager: {
probeVectorAvailability: vi.fn(async () => true),
probeEmbeddingAvailability,
sync,
status: () => ({
files: 1,
chunks: 1,
dirty: false,
workspaceDir: "/tmp/clawd",
dbPath: "/tmp/memory.sqlite",
provider: "openai",
model: "text-embedding-3-small",
requestedProvider: "openai",
vector: { enabled: true, available: true },
}),
close,
},
});
const program = new Command();
program.name("test");
registerMemoryCli(program);
await program.parseAsync(["memory", "status", "--index", "--index-mode", "direct"], {
from: "user",
});
expect(getMemorySearchManager).toHaveBeenCalledWith(
expect.objectContaining({
cfg: expect.objectContaining({
agents: expect.objectContaining({
defaults: expect.objectContaining({
memorySearch: expect.objectContaining({
remote: expect.objectContaining({
batch: expect.objectContaining({ enabled: false }),
}),
}),
}),
}),
}),
}),
);
expect(sync).toHaveBeenCalled();
expect(probeEmbeddingAvailability).toHaveBeenCalled();
expect(close).toHaveBeenCalled();
});
it("closes manager after index", async () => { it("closes manager after index", async () => {
const { registerMemoryCli } = await import("./memory-cli.js"); const { registerMemoryCli } = await import("./memory-cli.js");
const { defaultRuntime } = await import("../runtime.js"); const { defaultRuntime } = await import("../runtime.js");
@@ -266,9 +329,31 @@ describe("memory cli", () => {
registerMemoryCli(program); registerMemoryCli(program);
await program.parseAsync(["memory", "index"], { from: "user" }); await program.parseAsync(["memory", "index"], { from: "user" });
expect(sync).toHaveBeenCalledWith(
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
);
expect(close).toHaveBeenCalled();
expect(log).toHaveBeenCalledWith("Memory index updated.");
});
it("skips progress when --progress none", async () => {
const { registerMemoryCli } = await import("./memory-cli.js");
const close = vi.fn(async () => {});
const sync = vi.fn(async () => {});
getMemorySearchManager.mockResolvedValueOnce({
manager: {
sync,
close,
},
});
const program = new Command();
program.name("test");
registerMemoryCli(program);
await program.parseAsync(["memory", "index", "--progress", "none"], { from: "user" });
expect(sync).toHaveBeenCalledWith({ reason: "cli", force: false }); expect(sync).toHaveBeenCalledWith({ reason: "cli", force: false });
expect(close).toHaveBeenCalled(); expect(close).toHaveBeenCalled();
expect(log).toHaveBeenCalledWith("Memory index updated (main).");
}); });
it("logs close failures without failing the command", async () => { it("logs close failures without failing the command", async () => {
@@ -291,7 +376,9 @@ describe("memory cli", () => {
registerMemoryCli(program); registerMemoryCli(program);
await program.parseAsync(["memory", "index"], { from: "user" }); await program.parseAsync(["memory", "index"], { from: "user" });
expect(sync).toHaveBeenCalledWith({ reason: "cli", force: false }); expect(sync).toHaveBeenCalledWith(
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
);
expect(close).toHaveBeenCalled(); expect(close).toHaveBeenCalled();
expect(error).toHaveBeenCalledWith( expect(error).toHaveBeenCalledWith(
expect.stringContaining("Memory manager close failed: close boom"), expect.stringContaining("Memory manager close failed: close boom"),
+277 -241
View File
@@ -1,39 +1,36 @@
import os from "node:os";
import path from "node:path";
import type { Command } from "commander"; import type { Command } from "commander";
import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { MemorySearchConfig } from "../config/types.tools.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { setVerbose } from "../globals.js"; import { setVerbose } from "../globals.js";
import { withProgress, withProgressTotals } from "./progress.js"; import { withProgress, withProgressTotals } from "./progress.js";
import { formatErrorMessage, withManager } from "./cli-utils.js"; import { formatErrorMessage, withManager } from "./cli-utils.js";
import { getMemorySearchManager, type MemorySearchManagerResult } from "../memory/index.js"; import { getMemorySearchManager, type MemorySearchManagerResult } from "../memory/index.js";
import {
resolveMemoryCacheState,
resolveMemoryFtsState,
resolveMemoryVectorState,
type Tone,
} from "../memory/status-format.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { colorize, isRich, theme } from "../terminal/theme.js"; import { colorize, isRich, theme } from "../terminal/theme.js";
import { resolveStateDir } from "../config/paths.js";
type MemoryCommandOptions = { type MemoryCommandOptions = {
agent?: string; agent?: string;
json?: boolean; json?: boolean;
deep?: boolean; deep?: boolean;
index?: boolean; index?: boolean;
indexMode?: IndexMode;
progress?: ProgressMode;
verbose?: boolean; verbose?: boolean;
}; };
type MemoryManager = NonNullable<MemorySearchManagerResult["manager"]>; type MemoryManager = NonNullable<MemorySearchManagerResult["manager"]>;
type IndexMode = "auto" | "batch" | "direct";
function formatSourceLabel(source: string, workspaceDir: string, agentId: string): string { type ProgressMode = "auto" | "line" | "log" | "none";
if (source === "memory") {
return `memory (MEMORY.md + ${path.join(workspaceDir, "memory")}${path.sep}*.md)`;
}
if (source === "sessions") {
const stateDir = resolveStateDir(process.env, os.homedir);
return `sessions (${path.join(stateDir, "agents", agentId, "sessions")}${path.sep}*.jsonl)`;
}
return source;
}
function resolveAgent(cfg: ReturnType<typeof loadConfig>, agent?: string) { function resolveAgent(cfg: ReturnType<typeof loadConfig>, agent?: string) {
const trimmed = agent?.trim(); const trimmed = agent?.trim();
@@ -41,14 +38,66 @@ function resolveAgent(cfg: ReturnType<typeof loadConfig>, agent?: string) {
return resolveDefaultAgentId(cfg); return resolveDefaultAgentId(cfg);
} }
function resolveAgentIds(cfg: ReturnType<typeof loadConfig>, agent?: string): string[] { function resolveIndexMode(raw?: string): IndexMode {
const trimmed = agent?.trim(); if (!raw) return "auto";
if (trimmed) return [trimmed]; const trimmed = raw.trim().toLowerCase();
const list = cfg.agents?.list ?? []; if (trimmed === "batch") return "batch";
if (list.length > 0) { if (trimmed === "direct") return "direct";
return list.map((entry) => entry.id).filter(Boolean); return "auto";
}
function resolveProgressMode(raw?: string): ProgressMode {
if (!raw) return "auto";
const trimmed = raw.trim().toLowerCase();
if (trimmed === "line") return "line";
if (trimmed === "log") return "log";
if (trimmed === "none") return "none";
return "auto";
}
function applyIndexMode(cfg: ClawdbotConfig, agentId: string, mode: IndexMode): ClawdbotConfig {
if (mode === "auto") return cfg;
const enabled = mode === "batch";
const patchMemorySearch = (memorySearch?: MemorySearchConfig) => {
const remote = memorySearch?.remote;
const batch = remote?.batch;
return {
...memorySearch,
remote: {
...remote,
batch: {
...batch,
enabled,
},
},
};
};
const nextAgents = { ...cfg.agents };
nextAgents.defaults = {
...cfg.agents?.defaults,
memorySearch: patchMemorySearch(cfg.agents?.defaults?.memorySearch),
};
if (cfg.agents?.list?.length) {
nextAgents.list = cfg.agents.list.map((agent) =>
agent.id === agentId
? {
...agent,
memorySearch: patchMemorySearch(agent.memorySearch),
}
: agent,
);
} }
return [resolveDefaultAgentId(cfg)]; return { ...cfg, agents: nextAgents };
}
function resolveProgressOptions(
mode: ProgressMode,
verbose: boolean,
): { enabled?: boolean; fallback?: "spinner" | "line" | "log" | "none" } {
if (mode === "none") return { enabled: false, fallback: "none" };
if (mode === "line") return { fallback: "line" };
if (mode === "log") return { fallback: "log" };
return { fallback: verbose ? "line" : undefined };
} }
export function registerMemoryCli(program: Command) { export function registerMemoryCli(program: Command) {
@@ -68,194 +117,182 @@ export function registerMemoryCli(program: Command) {
.option("--json", "Print JSON") .option("--json", "Print JSON")
.option("--deep", "Probe embedding provider availability") .option("--deep", "Probe embedding provider availability")
.option("--index", "Reindex if dirty (implies --deep)") .option("--index", "Reindex if dirty (implies --deep)")
.option("--index-mode <mode>", "Index mode (auto|batch|direct) when indexing", "auto")
.option("--progress <mode>", "Progress output (auto|line|log|none)", "auto")
.option("--verbose", "Verbose logging", false) .option("--verbose", "Verbose logging", false)
.action(async (opts: MemoryCommandOptions) => { .action(async (opts: MemoryCommandOptions) => {
setVerbose(Boolean(opts.verbose)); setVerbose(Boolean(opts.verbose));
const cfg = loadConfig(); const rawCfg = loadConfig();
const agentIds = resolveAgentIds(cfg, opts.agent); const agentId = resolveAgent(rawCfg, opts.agent);
const allResults: Array<{ const indexMode = resolveIndexMode(opts.indexMode);
agentId: string; const progressMode = resolveProgressMode(opts.progress);
status: ReturnType<MemoryManager["status"]>; const progressOptions = resolveProgressOptions(progressMode, Boolean(opts.verbose));
embeddingProbe?: Awaited<ReturnType<MemoryManager["probeEmbeddingAvailability"]>>; const cfg = applyIndexMode(rawCfg, agentId, indexMode);
indexError?: string; await withManager<MemoryManager>({
}> = []; getManager: () => getMemorySearchManager({ cfg, agentId }),
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
for (const agentId of agentIds) { onCloseError: (err) =>
await withManager<MemoryManager>({ defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
getManager: () => getMemorySearchManager({ cfg, agentId }), close: (manager) => manager.close(),
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."), run: async (manager) => {
onCloseError: (err) => const deep = Boolean(opts.deep || opts.index);
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`), let embeddingProbe:
close: (manager) => manager.close(), | Awaited<ReturnType<typeof manager.probeEmbeddingAvailability>>
run: async (manager) => { | undefined;
const deep = Boolean(opts.deep || opts.index); let indexError: string | undefined;
let embeddingProbe: if (deep) {
| Awaited<ReturnType<typeof manager.probeEmbeddingAvailability>> await withProgress(
| undefined; { label: "Checking memory…", total: 2, ...progressOptions },
let indexError: string | undefined; async (progress) => {
if (deep) {
await withProgress({ label: "Checking memory…", total: 2 }, async (progress) => {
progress.setLabel("Probing vector…"); progress.setLabel("Probing vector…");
await manager.probeVectorAvailability(); await manager.probeVectorAvailability();
progress.tick(); progress.tick();
progress.setLabel("Probing embeddings…"); progress.setLabel("Probing embeddings…");
embeddingProbe = await manager.probeEmbeddingAvailability(); embeddingProbe = await manager.probeEmbeddingAvailability();
progress.tick(); progress.tick();
}); },
if (opts.index) { );
await withProgressTotals( if (opts.index) {
{ await withProgressTotals(
label: "Indexing memory…", {
total: 0, label: "Indexing memory…",
fallback: opts.verbose ? "line" : undefined, total: 0,
}, ...progressOptions,
async (update, progress) => { },
try { async (update, progress) => {
await manager.sync({ try {
reason: "cli", await manager.sync({
progress: (syncUpdate) => { reason: "cli",
update({ progress: (syncUpdate) => {
completed: syncUpdate.completed, update({
total: syncUpdate.total, completed: syncUpdate.completed,
label: syncUpdate.label, total: syncUpdate.total,
}); label: syncUpdate.label,
if (syncUpdate.label) progress.setLabel(syncUpdate.label); });
}, if (syncUpdate.label) progress.setLabel(syncUpdate.label);
}); },
} catch (err) { });
indexError = formatErrorMessage(err); } catch (err) {
defaultRuntime.error(`Memory index failed: ${indexError}`); indexError = formatErrorMessage(err);
process.exitCode = 1; defaultRuntime.error(`Memory index failed: ${indexError}`);
} process.exitCode = 1;
}, }
); },
} );
} else {
await manager.probeVectorAvailability();
} }
const status = manager.status(); } else {
allResults.push({ agentId, status, embeddingProbe, indexError }); await manager.probeVectorAvailability();
},
});
}
if (opts.json) {
defaultRuntime.log(JSON.stringify(allResults, null, 2));
return;
}
const rich = isRich();
const heading = (text: string) => colorize(rich, theme.heading, text);
const muted = (text: string) => colorize(rich, theme.muted, text);
const info = (text: string) => colorize(rich, theme.info, text);
const success = (text: string) => colorize(rich, theme.success, text);
const warn = (text: string) => colorize(rich, theme.warn, text);
const accent = (text: string) => colorize(rich, theme.accent, text);
const label = (text: string) => muted(`${text}:`);
for (const result of allResults) {
const { agentId, status, embeddingProbe, indexError } = result;
if (opts.index) {
const line = indexError
? `Memory index failed: ${indexError}`
: "Memory index complete.";
defaultRuntime.log(line);
}
const lines = [
`${heading("Memory Search")} ${muted(`(${agentId})`)}`,
`${label("Provider")} ${info(status.provider)} ${muted(
`(requested: ${status.requestedProvider})`,
)}`,
`${label("Model")} ${info(status.model)}`,
status.sources?.length
? `${label("Sources")} ${info(status.sources.join(", "))}`
: null,
`${label("Indexed")} ${success(`${status.files} files · ${status.chunks} chunks`)}`,
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
`${label("Store")} ${info(status.dbPath)}`,
`${label("Workspace")} ${info(status.workspaceDir)}`,
].filter(Boolean) as string[];
if (embeddingProbe) {
const state = embeddingProbe.ok ? "ready" : "unavailable";
const stateColor = embeddingProbe.ok ? theme.success : theme.warn;
lines.push(`${label("Embeddings")} ${colorize(rich, stateColor, state)}`);
if (embeddingProbe.error) {
lines.push(`${label("Embeddings error")} ${warn(embeddingProbe.error)}`);
} }
} const status = manager.status();
if (status.sourceCounts?.length) { if (opts.json) {
lines.push(label("By source")); defaultRuntime.log(
for (const entry of status.sourceCounts) { JSON.stringify(
const counts = `${entry.files} files · ${entry.chunks} chunks`; {
lines.push(` ${accent(entry.source)} ${muted("·")} ${muted(counts)}`); ...status,
embeddings: embeddingProbe
? { ok: embeddingProbe.ok, error: embeddingProbe.error }
: undefined,
indexError,
},
null,
2,
),
);
return;
} }
} if (opts.index) {
if (status.fallback) { const line = indexError
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`); ? `Memory index failed: ${indexError}`
} : "Memory index complete.";
if (status.vector) { defaultRuntime.log(line);
const vectorState = status.vector.enabled
? status.vector.available
? "ready"
: "unavailable"
: "disabled";
const vectorColor =
vectorState === "ready"
? theme.success
: vectorState === "unavailable"
? theme.warn
: theme.muted;
lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState)}`);
if (status.vector.dims) {
lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`);
} }
if (status.vector.extensionPath) { const rich = isRich();
lines.push(`${label("Vector path")} ${info(status.vector.extensionPath)}`); const heading = (text: string) => colorize(rich, theme.heading, text);
const muted = (text: string) => colorize(rich, theme.muted, text);
const info = (text: string) => colorize(rich, theme.info, text);
const success = (text: string) => colorize(rich, theme.success, text);
const warn = (text: string) => colorize(rich, theme.warn, text);
const accent = (text: string) => colorize(rich, theme.accent, text);
const label = (text: string) => muted(`${text}:`);
const colorForTone = (tone: Tone) =>
tone === "ok" ? theme.success : tone === "warn" ? theme.warn : theme.muted;
const lines = [
`${heading("Memory Search")} ${muted(`(${agentId})`)}`,
`${label("Provider")} ${info(status.provider)} ${muted(
`(requested: ${status.requestedProvider})`,
)}`,
`${label("Model")} ${info(status.model)}`,
status.sources?.length
? `${label("Sources")} ${info(status.sources.join(", "))}`
: null,
`${label("Indexed")} ${success(`${status.files} files · ${status.chunks} chunks`)}`,
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
`${label("Store")} ${info(status.dbPath)}`,
`${label("Workspace")} ${info(status.workspaceDir)}`,
].filter(Boolean) as string[];
if (embeddingProbe) {
const state = embeddingProbe.ok ? "ready" : "unavailable";
const stateColor = embeddingProbe.ok ? theme.success : theme.warn;
lines.push(`${label("Embeddings")} ${colorize(rich, stateColor, state)}`);
if (embeddingProbe.error) {
lines.push(`${label("Embeddings error")} ${warn(embeddingProbe.error)}`);
}
} }
if (status.vector.loadError) { if (status.sourceCounts?.length) {
lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`); lines.push(label("By source"));
for (const entry of status.sourceCounts) {
const counts = `${entry.files} files · ${entry.chunks} chunks`;
lines.push(` ${accent(entry.source)} ${muted("·")} ${muted(counts)}`);
}
} }
} if (status.fallback) {
if (status.fts) { lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
const ftsState = status.fts.enabled
? status.fts.available
? "ready"
: "unavailable"
: "disabled";
const ftsColor =
ftsState === "ready"
? theme.success
: ftsState === "unavailable"
? theme.warn
: theme.muted;
lines.push(`${label("FTS")} ${colorize(rich, ftsColor, ftsState)}`);
if (status.fts.error) {
lines.push(`${label("FTS error")} ${warn(status.fts.error)}`);
} }
} if (status.vector) {
if (status.cache) { const vectorState = resolveMemoryVectorState(status.vector);
const cacheState = status.cache.enabled ? "enabled" : "disabled"; const vectorColor = colorForTone(vectorState.tone);
const cacheColor = status.cache.enabled ? theme.success : theme.muted; lines.push(`${label("Vector")} ${colorize(rich, vectorColor, vectorState.state)}`);
const suffix = if (status.vector.dims) {
status.cache.enabled && typeof status.cache.entries === "number" lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`);
? ` (${status.cache.entries} entries)` }
: ""; if (status.vector.extensionPath) {
lines.push( lines.push(`${label("Vector path")} ${info(status.vector.extensionPath)}`);
`${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState)}${suffix}`, }
); if (status.vector.loadError) {
if (status.cache.enabled && typeof status.cache.maxEntries === "number") { lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`);
lines.push(`${label("Cache cap")} ${info(String(status.cache.maxEntries))}`); }
} }
} if (status.fts) {
if (status.fallback?.reason) { const ftsState = resolveMemoryFtsState(status.fts);
lines.push(muted(status.fallback.reason)); const ftsColor = colorForTone(ftsState.tone);
} lines.push(`${label("FTS")} ${colorize(rich, ftsColor, ftsState.state)}`);
if (indexError) { if (status.fts.error) {
lines.push(`${label("Index error")} ${warn(indexError)}`); lines.push(`${label("FTS error")} ${warn(status.fts.error)}`);
} }
defaultRuntime.log(lines.join("\n")); }
if (agentIds.length > 1) defaultRuntime.log(""); if (status.cache) {
} const cacheState = resolveMemoryCacheState(status.cache);
const cacheColor = colorForTone(cacheState.tone);
const suffix =
status.cache.enabled && typeof status.cache.entries === "number"
? ` (${status.cache.entries} entries)`
: "";
lines.push(
`${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState.state)}${suffix}`,
);
if (status.cache.enabled && typeof status.cache.maxEntries === "number") {
lines.push(`${label("Cache cap")} ${info(String(status.cache.maxEntries))}`);
}
}
if (status.fallback?.reason) {
lines.push(muted(status.fallback.reason));
}
if (indexError) {
lines.push(`${label("Index error")} ${warn(indexError)}`);
}
defaultRuntime.log(lines.join("\n"));
},
});
}); });
memory memory
@@ -263,57 +300,56 @@ export function registerMemoryCli(program: Command) {
.description("Reindex memory files") .description("Reindex memory files")
.option("--agent <id>", "Agent id (default: default agent)") .option("--agent <id>", "Agent id (default: default agent)")
.option("--force", "Force full reindex", false) .option("--force", "Force full reindex", false)
.option("--verbose", "Verbose logging", false) .option("--index-mode <mode>", "Index mode (auto|batch|direct) when indexing", "auto")
.option("--progress <mode>", "Progress output (auto|line|log|none)", "auto")
.action(async (opts: MemoryCommandOptions & { force?: boolean }) => { .action(async (opts: MemoryCommandOptions & { force?: boolean }) => {
setVerbose(Boolean(opts.verbose)); const rawCfg = loadConfig();
const cfg = loadConfig(); const agentId = resolveAgent(rawCfg, opts.agent);
const agentIds = resolveAgentIds(cfg, opts.agent); const indexMode = resolveIndexMode(opts.indexMode);
for (const agentId of agentIds) { const progressMode = resolveProgressMode(opts.progress);
await withManager<MemoryManager>({ const progressOptions = resolveProgressOptions(progressMode, Boolean(opts.verbose));
getManager: () => getMemorySearchManager({ cfg, agentId }), const cfg = applyIndexMode(rawCfg, agentId, indexMode);
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."), await withManager<MemoryManager>({
onCloseError: (err) => getManager: () => getMemorySearchManager({ cfg, agentId }),
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`), onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
close: (manager) => manager.close(), onCloseError: (err) =>
run: async (manager) => { defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
try { close: (manager) => manager.close(),
if (opts.verbose) { run: async (manager) => {
const status = manager.status(); try {
const rich = isRich(); if (progressMode === "none") {
const heading = (text: string) => colorize(rich, theme.heading, text);
const muted = (text: string) => colorize(rich, theme.muted, text);
const info = (text: string) => colorize(rich, theme.info, text);
const warn = (text: string) => colorize(rich, theme.warn, text);
const label = (text: string) => muted(`${text}:`);
const sourceLabels = status.sources.map((source) =>
formatSourceLabel(source, status.workspaceDir, agentId),
);
const lines = [
`${heading("Memory Index")} ${muted(`(${agentId})`)}`,
`${label("Provider")} ${info(status.provider)} ${muted(
`(requested: ${status.requestedProvider})`,
)}`,
`${label("Model")} ${info(status.model)}`,
sourceLabels.length
? `${label("Sources")} ${info(sourceLabels.join(", "))}`
: null,
].filter(Boolean) as string[];
if (status.fallback) {
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
}
defaultRuntime.log(lines.join("\n"));
defaultRuntime.log("");
}
await manager.sync({ reason: "cli", force: opts.force }); await manager.sync({ reason: "cli", force: opts.force });
defaultRuntime.log(`Memory index updated (${agentId}).`); } else {
} catch (err) { await withProgressTotals(
const message = formatErrorMessage(err); {
defaultRuntime.error(`Memory index failed (${agentId}): ${message}`); label: "Indexing memory…",
process.exitCode = 1; total: 0,
...progressOptions,
},
async (update, progress) => {
await manager.sync({
reason: "cli",
force: opts.force,
progress: (syncUpdate) => {
update({
completed: syncUpdate.completed,
total: syncUpdate.total,
label: syncUpdate.label,
});
if (syncUpdate.label) progress.setLabel(syncUpdate.label);
},
});
},
);
} }
}, defaultRuntime.log("Memory index updated.");
}); } catch (err) {
} const message = formatErrorMessage(err);
defaultRuntime.error(`Memory index failed: ${message}`);
process.exitCode = 1;
}
},
});
}); });
memory memory
+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;
+1 -39
View File
@@ -13,7 +13,6 @@ import {
resolvePluginInstallDir, resolvePluginInstallDir,
} from "../plugins/install.js"; } from "../plugins/install.js";
import { recordPluginInstall } from "../plugins/installs.js"; import { recordPluginInstall } from "../plugins/installs.js";
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
import type { PluginRecord } from "../plugins/registry.js"; import type { PluginRecord } from "../plugins/registry.js";
import { buildPluginStatusReport } from "../plugins/status.js"; import { buildPluginStatusReport } from "../plugins/status.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
@@ -80,31 +79,6 @@ async function readInstalledPackageVersion(dir: string): Promise<string | undefi
} }
} }
function applySlotSelectionForPlugin(
config: ClawdbotConfig,
pluginId: string,
): { config: ClawdbotConfig; warnings: string[] } {
const report = buildPluginStatusReport({ config });
const plugin = report.plugins.find((entry) => entry.id === pluginId);
if (!plugin) {
return { config, warnings: [] };
}
const result = applyExclusiveSlotSelection({
config,
selectedId: plugin.id,
selectedKind: plugin.kind,
registry: report,
});
return { config: result.config, warnings: result.warnings };
}
function logSlotWarnings(warnings: string[]) {
if (warnings.length === 0) return;
for (const warning of warnings) {
defaultRuntime.log(chalk.yellow(warning));
}
}
export function registerPluginsCli(program: Command) { export function registerPluginsCli(program: Command) {
const plugins = program const plugins = program
.command("plugins") .command("plugins")
@@ -223,7 +197,7 @@ export function registerPluginsCli(program: Command) {
.argument("<id>", "Plugin id") .argument("<id>", "Plugin id")
.action(async (id: string) => { .action(async (id: string) => {
const cfg = loadConfig(); const cfg = loadConfig();
let next: ClawdbotConfig = { const next = {
...cfg, ...cfg,
plugins: { plugins: {
...cfg.plugins, ...cfg.plugins,
@@ -236,10 +210,7 @@ export function registerPluginsCli(program: Command) {
}, },
}, },
}; };
const slotResult = applySlotSelectionForPlugin(next, id);
next = slotResult.config;
await writeConfigFile(next); await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Enabled plugin "${id}". Restart the gateway to apply.`); defaultRuntime.log(`Enabled plugin "${id}". Restart the gateway to apply.`);
}); });
@@ -309,10 +280,7 @@ export function registerPluginsCli(program: Command) {
installPath: resolved, installPath: resolved,
version: probe.version, version: probe.version,
}); });
const slotResult = applySlotSelectionForPlugin(next, probe.pluginId);
next = slotResult.config;
await writeConfigFile(next); await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Linked plugin path: ${resolved}`); defaultRuntime.log(`Linked plugin path: ${resolved}`);
defaultRuntime.log(`Restart the gateway to load plugins.`); defaultRuntime.log(`Restart the gateway to load plugins.`);
return; return;
@@ -351,10 +319,7 @@ export function registerPluginsCli(program: Command) {
installPath: result.targetDir, installPath: result.targetDir,
version: result.version, version: result.version,
}); });
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
next = slotResult.config;
await writeConfigFile(next); await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Installed plugin: ${result.pluginId}`); defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
defaultRuntime.log(`Restart the gateway to load plugins.`); defaultRuntime.log(`Restart the gateway to load plugins.`);
return; return;
@@ -414,10 +379,7 @@ export function registerPluginsCli(program: Command) {
installPath: result.targetDir, installPath: result.targetDir,
version: result.version, version: result.version,
}); });
const slotResult = applySlotSelectionForPlugin(next, result.pluginId);
next = slotResult.config;
await writeConfigFile(next); await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Installed plugin: ${result.pluginId}`); defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
defaultRuntime.log(`Restart the gateway to load plugins.`); defaultRuntime.log(`Restart the gateway to load plugins.`);
}); });
-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();
}
+2 -24
View File
@@ -13,7 +13,6 @@ export type AuthChoiceGroupId =
| "openai" | "openai"
| "anthropic" | "anthropic"
| "google" | "google"
| "copilot"
| "openrouter" | "openrouter"
| "ai-gateway" | "ai-gateway"
| "moonshot" | "moonshot"
@@ -69,14 +68,8 @@ const AUTH_CHOICE_GROUP_DEFS: {
{ {
value: "google", value: "google",
label: "Google", label: "Google",
hint: "Gemini API key + OAuth", hint: "Gemini API key",
choices: ["gemini-api-key", "google-antigravity", "google-gemini-cli"], choices: ["gemini-api-key"],
},
{
value: "copilot",
label: "Copilot",
hint: "GitHub + local proxy",
choices: ["github-copilot", "copilot-proxy"],
}, },
{ {
value: "openrouter", value: "openrouter",
@@ -202,23 +195,8 @@ export function buildAuthChoiceOptions(params: {
hint: "Uses GitHub device flow", hint: "Uses GitHub device flow",
}); });
options.push({ value: "gemini-api-key", label: "Google Gemini API key" }); options.push({ value: "gemini-api-key", label: "Google Gemini API key" });
options.push({
value: "google-antigravity",
label: "Google Antigravity OAuth",
hint: "Uses the bundled Antigravity auth plugin",
});
options.push({
value: "google-gemini-cli",
label: "Google Gemini CLI OAuth",
hint: "Uses the bundled Gemini CLI auth plugin",
});
options.push({ value: "zai-api-key", label: "Z.AI (GLM 4.7) API key" }); options.push({ value: "zai-api-key", label: "Z.AI (GLM 4.7) API key" });
options.push({ value: "qwen-portal", label: "Qwen OAuth" }); options.push({ value: "qwen-portal", label: "Qwen OAuth" });
options.push({
value: "copilot-proxy",
label: "Copilot Proxy (local)",
hint: "Local proxy for VS Code Copilot models",
});
options.push({ value: "apiKey", label: "Anthropic API key" }); options.push({ value: "apiKey", label: "Anthropic API key" });
// Token flow is currently Anthropic-only; use CLI for advanced providers. // Token flow is currently Anthropic-only; use CLI for advanced providers.
options.push({ options.push({
@@ -1,14 +0,0 @@
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
export async function applyAuthChoiceCopilotProxy(
params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult | null> {
return await applyAuthChoicePluginProvider(params, {
authChoice: "copilot-proxy",
pluginId: "copilot-proxy",
providerId: "copilot-proxy",
methodId: "local",
label: "Copilot Proxy",
});
}
@@ -1,14 +0,0 @@
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
export async function applyAuthChoiceGoogleAntigravity(
params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult | null> {
return await applyAuthChoicePluginProvider(params, {
authChoice: "google-antigravity",
pluginId: "google-antigravity-auth",
providerId: "google-antigravity",
methodId: "oauth",
label: "Google Antigravity",
});
}
@@ -1,14 +0,0 @@
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
export async function applyAuthChoiceGoogleGeminiCli(
params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult | null> {
return await applyAuthChoicePluginProvider(params, {
authChoice: "google-gemini-cli",
pluginId: "google-gemini-cli-auth",
providerId: "google-gemini-cli",
methodId: "oauth",
label: "Google Gemini CLI",
});
}
@@ -1,197 +0,0 @@
import { resolveClawdbotAgentDir } from "../agents/agent-paths.js";
import {
resolveDefaultAgentId,
resolveAgentDir,
resolveAgentWorkspaceDir,
} from "../agents/agent-scope.js";
import { upsertAuthProfile } from "../agents/auth-profiles.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import type { ClawdbotConfig } from "../config/config.js";
import { enablePluginInConfig } from "../plugins/enable.js";
import { resolvePluginProviders } from "../plugins/providers.js";
import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { applyAuthProfileConfig } from "./onboard-auth.js";
import { openUrl } from "./onboard-helpers.js";
import { createVpsAwareOAuthHandlers } from "./oauth-flow.js";
import { isRemoteEnvironment } from "./oauth-env.js";
export type PluginProviderAuthChoiceOptions = {
authChoice: string;
pluginId: string;
providerId: string;
methodId?: string;
label: string;
};
function resolveProviderMatch(
providers: ProviderPlugin[],
rawProvider: string,
): ProviderPlugin | null {
const normalized = normalizeProviderId(rawProvider);
return (
providers.find((provider) => normalizeProviderId(provider.id) === normalized) ??
providers.find(
(provider) =>
provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false,
) ??
null
);
}
function pickAuthMethod(provider: ProviderPlugin, rawMethod?: string): ProviderAuthMethod | null {
const raw = rawMethod?.trim();
if (!raw) return null;
const normalized = raw.toLowerCase();
return (
provider.auth.find((method) => method.id.toLowerCase() === normalized) ??
provider.auth.find((method) => method.label.toLowerCase() === normalized) ??
null
);
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function mergeConfigPatch<T>(base: T, patch: unknown): T {
if (!isPlainRecord(base) || !isPlainRecord(patch)) {
return patch as T;
}
const next: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(patch)) {
const existing = next[key];
if (isPlainRecord(existing) && isPlainRecord(value)) {
next[key] = mergeConfigPatch(existing, value);
} else {
next[key] = value;
}
}
return next as T;
}
function applyDefaultModel(cfg: ClawdbotConfig, model: string): ClawdbotConfig {
const models = { ...cfg.agents?.defaults?.models };
models[model] = models[model] ?? {};
const existingModel = cfg.agents?.defaults?.model;
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
model: {
...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel
? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks }
: undefined),
primary: model,
},
},
},
};
}
export async function applyAuthChoicePluginProvider(
params: ApplyAuthChoiceParams,
options: PluginProviderAuthChoiceOptions,
): Promise<ApplyAuthChoiceResult | null> {
if (params.authChoice !== options.authChoice) return null;
const enableResult = enablePluginInConfig(params.config, options.pluginId);
let nextConfig = enableResult.config;
if (!enableResult.enabled) {
await params.prompter.note(
`${options.label} plugin is disabled (${enableResult.reason ?? "blocked"}).`,
options.label,
);
return { config: nextConfig };
}
const agentId = params.agentId ?? resolveDefaultAgentId(nextConfig);
const defaultAgentId = resolveDefaultAgentId(nextConfig);
const agentDir =
params.agentDir ??
(agentId === defaultAgentId ? resolveClawdbotAgentDir() : resolveAgentDir(nextConfig, agentId));
const workspaceDir =
resolveAgentWorkspaceDir(nextConfig, agentId) ?? resolveDefaultAgentWorkspaceDir();
const providers = resolvePluginProviders({ config: nextConfig, workspaceDir });
const provider = resolveProviderMatch(providers, options.providerId);
if (!provider) {
await params.prompter.note(
`${options.label} auth plugin is not available. Enable it and re-run the wizard.`,
options.label,
);
return { config: nextConfig };
}
const method = pickAuthMethod(provider, options.methodId) ?? provider.auth[0];
if (!method) {
await params.prompter.note(`${options.label} auth method missing.`, options.label);
return { config: nextConfig };
}
const isRemote = isRemoteEnvironment();
const result = await method.run({
config: nextConfig,
agentDir,
workspaceDir,
prompter: params.prompter,
runtime: params.runtime,
isRemote,
openUrl: async (url) => {
await openUrl(url);
},
oauth: {
createVpsAwareHandlers: (opts) => createVpsAwareOAuthHandlers(opts),
},
});
if (result.configPatch) {
nextConfig = mergeConfigPatch(nextConfig, result.configPatch);
}
for (const profile of result.profiles) {
upsertAuthProfile({
profileId: profile.profileId,
credential: profile.credential,
agentDir,
});
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: profile.profileId,
provider: profile.credential.provider,
mode: profile.credential.type === "token" ? "token" : profile.credential.type,
...("email" in profile.credential && profile.credential.email
? { email: profile.credential.email }
: {}),
});
}
let agentModelOverride: string | undefined;
if (result.defaultModel) {
if (params.setDefaultModel) {
nextConfig = applyDefaultModel(nextConfig, result.defaultModel);
await params.prompter.note(
`Default model set to ${result.defaultModel}`,
"Model configured",
);
} else if (params.agentId) {
agentModelOverride = result.defaultModel;
await params.prompter.note(
`Default model set to ${result.defaultModel} for agent "${params.agentId}".`,
"Model configured",
);
}
}
if (result.notes && result.notes.length > 0) {
await params.prompter.note(result.notes.join("\n"), "Provider notes");
}
return { config: nextConfig, agentModelOverride };
}
+188 -7
View File
@@ -1,14 +1,195 @@
import { resolveClawdbotAgentDir } from "../agents/agent-paths.js";
import {
resolveDefaultAgentId,
resolveAgentDir,
resolveAgentWorkspaceDir,
} from "../agents/agent-scope.js";
import { upsertAuthProfile } from "../agents/auth-profiles.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import type { ClawdbotConfig } from "../config/config.js";
import { resolvePluginProviders } from "../plugins/providers.js";
import type { ProviderAuthMethod, ProviderPlugin } from "../plugins/types.js";
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js"; import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js"; import { applyAuthProfileConfig } from "./onboard-auth.js";
import { openUrl } from "./onboard-helpers.js";
import { createVpsAwareOAuthHandlers } from "./oauth-flow.js";
import { isRemoteEnvironment } from "./oauth-env.js";
const PLUGIN_ID = "qwen-portal-auth";
const PROVIDER_ID = "qwen-portal";
function enableBundledPlugin(cfg: ClawdbotConfig): ClawdbotConfig {
const existingEntry = cfg.plugins?.entries?.[PLUGIN_ID];
return {
...cfg,
plugins: {
...cfg.plugins,
entries: {
...cfg.plugins?.entries,
[PLUGIN_ID]: {
...existingEntry,
enabled: true,
},
},
},
};
}
function resolveProviderMatch(
providers: ProviderPlugin[],
rawProvider: string,
): ProviderPlugin | null {
const normalized = normalizeProviderId(rawProvider);
return (
providers.find((provider) => normalizeProviderId(provider.id) === normalized) ??
providers.find(
(provider) =>
provider.aliases?.some((alias) => normalizeProviderId(alias) === normalized) ?? false,
) ??
null
);
}
function pickAuthMethod(provider: ProviderPlugin, rawMethod?: string): ProviderAuthMethod | null {
const raw = rawMethod?.trim();
if (!raw) return null;
const normalized = raw.toLowerCase();
return (
provider.auth.find((method) => method.id.toLowerCase() === normalized) ??
provider.auth.find((method) => method.label.toLowerCase() === normalized) ??
null
);
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function mergeConfigPatch<T>(base: T, patch: unknown): T {
if (!isPlainRecord(base) || !isPlainRecord(patch)) {
return patch as T;
}
const next: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(patch)) {
const existing = next[key];
if (isPlainRecord(existing) && isPlainRecord(value)) {
next[key] = mergeConfigPatch(existing, value);
} else {
next[key] = value;
}
}
return next as T;
}
function applyDefaultModel(cfg: ClawdbotConfig, model: string): ClawdbotConfig {
const models = { ...cfg.agents?.defaults?.models };
models[model] = models[model] ?? {};
const existingModel = cfg.agents?.defaults?.model;
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
model: {
...(existingModel && typeof existingModel === "object" && "fallbacks" in existingModel
? { fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks }
: undefined),
primary: model,
},
},
},
};
}
export async function applyAuthChoiceQwenPortal( export async function applyAuthChoiceQwenPortal(
params: ApplyAuthChoiceParams, params: ApplyAuthChoiceParams,
): Promise<ApplyAuthChoiceResult | null> { ): Promise<ApplyAuthChoiceResult | null> {
return await applyAuthChoicePluginProvider(params, { if (params.authChoice !== "qwen-portal") return null;
authChoice: "qwen-portal",
pluginId: "qwen-portal-auth", let nextConfig = enableBundledPlugin(params.config);
providerId: "qwen-portal", const agentId = params.agentId ?? resolveDefaultAgentId(nextConfig);
methodId: "device", const defaultAgentId = resolveDefaultAgentId(nextConfig);
label: "Qwen", const agentDir =
params.agentDir ??
(agentId === defaultAgentId ? resolveClawdbotAgentDir() : resolveAgentDir(nextConfig, agentId));
const workspaceDir =
resolveAgentWorkspaceDir(nextConfig, agentId) ?? resolveDefaultAgentWorkspaceDir();
const providers = resolvePluginProviders({ config: nextConfig, workspaceDir });
const provider = resolveProviderMatch(providers, PROVIDER_ID);
if (!provider) {
await params.prompter.note(
"Qwen auth plugin is not available. Run `clawdbot plugins enable qwen-portal-auth` and re-run the wizard.",
"Qwen",
);
return { config: nextConfig };
}
const method = pickAuthMethod(provider, "device") ?? provider.auth[0];
if (!method) {
await params.prompter.note("Qwen auth method missing.", "Qwen");
return { config: nextConfig };
}
const isRemote = isRemoteEnvironment();
const result = await method.run({
config: nextConfig,
agentDir,
workspaceDir,
prompter: params.prompter,
runtime: params.runtime,
isRemote,
openUrl: async (url) => {
await openUrl(url);
},
oauth: {
createVpsAwareHandlers: (opts) => createVpsAwareOAuthHandlers(opts),
},
}); });
if (result.configPatch) {
nextConfig = mergeConfigPatch(nextConfig, result.configPatch);
}
for (const profile of result.profiles) {
upsertAuthProfile({
profileId: profile.profileId,
credential: profile.credential,
agentDir,
});
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: profile.profileId,
provider: profile.credential.provider,
mode: profile.credential.type === "token" ? "token" : profile.credential.type,
...("email" in profile.credential && profile.credential.email
? { email: profile.credential.email }
: {}),
});
}
let agentModelOverride: string | undefined;
if (result.defaultModel) {
if (params.setDefaultModel) {
nextConfig = applyDefaultModel(nextConfig, result.defaultModel);
await params.prompter.note(`Default model set to ${result.defaultModel}`, "Model configured");
} else if (params.agentId) {
agentModelOverride = result.defaultModel;
await params.prompter.note(
`Default model set to ${result.defaultModel} for agent "${params.agentId}".`,
"Model configured",
);
}
}
if (result.notes && result.notes.length > 0) {
await params.prompter.note(result.notes.join("\n"), "Provider notes");
}
return { config: nextConfig, agentModelOverride };
} }
-6
View File
@@ -3,10 +3,7 @@ import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js"; import type { WizardPrompter } from "../wizard/prompts.js";
import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js"; import { applyAuthChoiceAnthropic } from "./auth-choice.apply.anthropic.js";
import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js"; import { applyAuthChoiceApiProviders } from "./auth-choice.apply.api-providers.js";
import { applyAuthChoiceCopilotProxy } from "./auth-choice.apply.copilot-proxy.js";
import { applyAuthChoiceGitHubCopilot } from "./auth-choice.apply.github-copilot.js"; import { applyAuthChoiceGitHubCopilot } from "./auth-choice.apply.github-copilot.js";
import { applyAuthChoiceGoogleAntigravity } from "./auth-choice.apply.google-antigravity.js";
import { applyAuthChoiceGoogleGeminiCli } from "./auth-choice.apply.google-gemini-cli.js";
import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js"; import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js";
import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js"; import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js";
import { applyAuthChoiceOpenAI } from "./auth-choice.apply.openai.js"; import { applyAuthChoiceOpenAI } from "./auth-choice.apply.openai.js";
@@ -38,9 +35,6 @@ export async function applyAuthChoice(
applyAuthChoiceApiProviders, applyAuthChoiceApiProviders,
applyAuthChoiceMiniMax, applyAuthChoiceMiniMax,
applyAuthChoiceGitHubCopilot, applyAuthChoiceGitHubCopilot,
applyAuthChoiceGoogleAntigravity,
applyAuthChoiceGoogleGeminiCli,
applyAuthChoiceCopilotProxy,
applyAuthChoiceQwenPortal, applyAuthChoiceQwenPortal,
]; ];
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff. Show more