Compare commits

..
Author SHA1 Message Date
Peter Steinberger adbc4d093e fix: handle repeated compaction failures (#1187) (thanks @fayrose) 2026-01-18 22:02:22 +00:00
Lauren Rosenberg 0c1ac41085 fix: add reserveTokensFloor suggestion to compaction error messages
When context limit is exceeded, the error message now suggests
setting agents.defaults.compaction.reserveTokensFloor to 4000
or higher to prevent future occurrences.
2026-01-18 21:54:04 +00:00
Lauren Rosenberg a8c204e9fa style: apply prettier formatting 2026-01-18 21:54:04 +00:00
Lauren Rosenberg 1ac6f3ce92 fix: return user-facing error when session reset after compaction failure
Previously, when auto-compaction failed due to context overflow, the system
would reset the session and silently continue the execution loop without
sending any response to the user. This made it appear as if messages were
being ignored.

This change ensures users receive a clear error message explaining that
the context limit was exceeded and the conversation has been reset,
consistent with how role ordering conflicts are already handled.

Fixes the silent failure case where message + compaction exceeds context limits.
2026-01-18 21:54:04 +00:00
132 changed files with 18274 additions and 18766 deletions

No files matched your search

-1
View File
@@ -1,5 +1,4 @@
node_modules
**/node_modules/
.env
docker-compose.extra.yml
dist
-4
View File
@@ -8,10 +8,6 @@
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
- Core channel docs: `docs/channels/`
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
## Docs Linking (Mintlify)
- Docs are hosted on Mintlify (docs.clawd.bot).
+2 -8
View File
@@ -6,19 +6,13 @@ Docs: https://docs.clawd.bot
### Changes
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels.
- TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07.
- Docs: clarify allowlist input types and onboarding behavior for messaging channels.
- Agents: make inbound message envelopes configurable (timezone/timestamp/elapsed) and surface elapsed gaps. (#1150) — thanks @shiv19.
### Fixes
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
- Docs: make docs:list fail fast with a clear error if the docs directory is missing.
- Agents: retry compaction reset once, then surface a user-facing error on repeat failure. (#1187) — thanks @fayrose.
- macOS: load menu session previews asynchronously so items populate while the menu is open.
- macOS: use label colors for session preview text so previews render in menu subviews.
- macOS: suppress usage error text in the menubar cost view.
- macOS: fix onboarding action link labels for channels vs skills. (#1197) — thanks @chriseidhof.
- Telegram: honor pairing allowlists for native slash commands.
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195) — thanks @gumadeiras.
## 2026.1.18-4
@@ -12,9 +12,6 @@ final class ConnectionModeCoordinator {
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
switch mode {
case .unconfigured:
if let error = await NodeServiceManager.stop() {
NodesStore.shared.lastError = "Node service stop failed: \(error)"
}
await RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels()
GatewayProcessManager.shared.stop()
@@ -23,9 +20,6 @@ final class ConnectionModeCoordinator {
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
case .local:
if let error = await NodeServiceManager.stop() {
NodesStore.shared.lastError = "Node service stop failed: \(error)"
}
await RemoteTunnelManager.shared.stopAll()
WebChatManager.shared.resetTunnels()
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
@@ -56,9 +50,6 @@ final class ConnectionModeCoordinator {
WebChatManager.shared.resetTunnels()
do {
if let error = await NodeServiceManager.start() {
NodesStore.shared.lastError = "Node service start failed: \(error)"
}
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
let settings = CommandResolver.connectionSettings()
try await ControlChannel.shared.configure(mode: .remote(
@@ -443,6 +443,7 @@ actor MacNodeRuntime {
let approvals = ExecApprovalsStore.resolve(agentId: agentId)
let security = approvals.agent.security
let ask = approvals.agent.ask
let askFallback = approvals.agent.askFallback
let autoAllowSkills = approvals.agent.autoAllowSkills
let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -488,7 +489,7 @@ actor MacNodeRuntime {
var approvedByAsk = false
if requiresAsk {
let decision = await ExecApprovalsPromptPresenter.prompt(
let decision: ExecApprovalDecision? = await ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: displayCommand,
cwd: params.cwd,
@@ -499,7 +500,7 @@ actor MacNodeRuntime {
resolvedPath: resolution?.resolvedPath))
switch decision {
case .deny:
case .deny?:
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
@@ -512,7 +513,41 @@ actor MacNodeRuntime {
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: user denied")
case .allowAlways:
case nil:
if askFallback == .full {
approvedByAsk = true
} else if askFallback == .allowlist {
if allowlistMatch != nil || skillAllow {
approvedByAsk = true
} else {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
reason: "approval-required"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: approval required")
}
} else {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
reason: "approval-required"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: approval required")
}
case .allowAlways?:
approvedByAsk = true
if security == .allowlist {
let pattern = resolution?.resolvedPath ??
@@ -523,7 +558,7 @@ actor MacNodeRuntime {
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
}
}
case .allowOnce:
case .allowOnce?:
approvedByAsk = true
}
}
@@ -1,150 +0,0 @@
import Foundation
import OSLog
enum NodeServiceManager {
private static let logger = Logger(subsystem: "com.clawdbot", category: "node.service")
static func start() async -> String? {
let result = await self.runServiceCommandResult(
["node", "start"],
timeout: 20,
quiet: false)
if let error = self.errorMessage(from: result, treatNotLoadedAsError: true) {
self.logger.error("node service start failed: \(error, privacy: .public)")
return error
}
return nil
}
static func stop() async -> String? {
let result = await self.runServiceCommandResult(
["node", "stop"],
timeout: 15,
quiet: false)
if let error = self.errorMessage(from: result, treatNotLoadedAsError: false) {
self.logger.error("node service stop failed: \(error, privacy: .public)")
return error
}
return nil
}
}
extension NodeServiceManager {
private struct CommandResult {
let success: Bool
let payload: Data?
let message: String?
let parsed: ParsedServiceJson?
}
private struct ParsedServiceJson {
let text: String
let object: [String: Any]
let ok: Bool?
let result: String?
let message: String?
let error: String?
let hints: [String]
}
private static func runServiceCommandResult(
_ args: [String],
timeout: Double,
quiet: Bool) async -> CommandResult
{
let command = CommandResolver.clawdbotCommand(
subcommand: "service",
extraArgs: self.withJsonFlag(args),
// Service management must always run locally, even if remote mode is configured.
configRoot: ["gateway": ["mode": "local"]])
var env = ProcessInfo.processInfo.environment
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
let parsed = self.parseServiceJson(from: response.stdout) ?? self.parseServiceJson(from: response.stderr)
let ok = parsed?.ok
let message = parsed?.error ?? parsed?.message
let payload = parsed?.text.data(using: .utf8)
?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8)
let success = ok ?? response.success
if success {
return CommandResult(success: true, payload: payload, message: nil, parsed: parsed)
}
if quiet {
return CommandResult(success: false, payload: payload, message: message, parsed: parsed)
}
let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout)
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
let fullMessage = detail.map { "Node service command failed (\(exit)): \($0)" }
?? "Node service command failed (\(exit))"
self.logger.error("\(fullMessage, privacy: .public)")
return CommandResult(success: false, payload: payload, message: detail, parsed: parsed)
}
private static func errorMessage(from result: CommandResult, treatNotLoadedAsError: Bool) -> String? {
if !result.success {
return result.message ?? "Node service command failed"
}
guard let parsed = result.parsed else { return nil }
if parsed.ok == false {
return self.mergeHints(message: parsed.error ?? parsed.message, hints: parsed.hints)
}
if treatNotLoadedAsError, parsed.result == "not-loaded" {
let base = parsed.message ?? "Node service not loaded."
return self.mergeHints(message: base, hints: parsed.hints)
}
return nil
}
private static func withJsonFlag(_ args: [String]) -> [String] {
if args.contains("--json") { return args }
return args + ["--json"]
}
private static func parseServiceJson(from raw: String) -> ParsedServiceJson? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard let start = trimmed.firstIndex(of: "{"),
let end = trimmed.lastIndex(of: "}")
else {
return nil
}
let jsonText = String(trimmed[start...end])
guard let data = jsonText.data(using: .utf8) else { return nil }
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
let ok = object["ok"] as? Bool
let result = object["result"] as? String
let message = object["message"] as? String
let error = object["error"] as? String
let hints = (object["hints"] as? [String]) ?? []
return ParsedServiceJson(
text: jsonText,
object: object,
ok: ok,
result: result,
message: message,
error: error,
hints: hints)
}
private static func mergeHints(message: String?, hints: [String]) -> String? {
let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines)
let nonEmpty = trimmed?.isEmpty == false ? trimmed : nil
guard !hints.isEmpty else { return nonEmpty }
let hintText = hints.prefix(2).joined(separator: " · ")
if let nonEmpty {
return "\(nonEmpty) (\(hintText))"
}
return hintText
}
private static func summarize(_ text: String) -> String? {
let lines = text
.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard let last = lines.last else { return nil }
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
}
}
@@ -210,7 +210,6 @@ extension OnboardingView {
title: String,
subtitle: String,
systemImage: String,
buttonTitle: String,
action: @escaping () -> Void) -> some View
{
HStack(alignment: .top, spacing: 12) {
@@ -223,7 +222,7 @@ extension OnboardingView {
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
Button(buttonTitle, action: action)
Button("Open Settings → Skills", action: action)
.buttonStyle(.link)
.padding(.top, 2)
}
@@ -695,8 +695,7 @@ extension OnboardingView {
self.featureActionRow(
title: "Connect WhatsApp or Telegram",
subtitle: "Open Settings → Channels to link channels and monitor status.",
systemImage: "link",
buttonTitle: "Open Settings → Channels")
systemImage: "link")
{
self.openSettings(tab: .channels)
}
@@ -712,8 +711,7 @@ extension OnboardingView {
self.featureActionRow(
title: "Give your agent more powers",
subtitle: "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.",
systemImage: "sparkles",
buttonTitle: "Open Settings → Skills")
systemImage: "sparkles")
{
self.openSettings(tab: .skills)
}
+13 -1
View File
@@ -75,6 +75,18 @@ struct UsageRow: Identifiable {
extension GatewayUsageSummary {
func primaryRows() -> [UsageRow] {
self.providers.compactMap { provider in
if let error = provider.error, provider.windows.isEmpty {
return UsageRow(
id: provider.provider,
providerId: provider.provider,
displayName: provider.displayName,
plan: provider.plan,
windowLabel: nil,
usedPercent: nil,
resetAt: nil,
error: error)
}
guard let window = provider.windows.max(by: { $0.usedPercent < $1.usedPercent }) else {
return nil
}
@@ -87,7 +99,7 @@ extension GatewayUsageSummary {
windowLabel: window.label,
usedPercent: window.usedPercent,
resetAt: window.resetAt.map { Date(timeIntervalSince1970: $0 / 1000) },
error: nil)
error: provider.error)
}
}
}
@@ -37,12 +37,14 @@ struct UsageMenuLabelView: View {
Spacer(minLength: 4)
Text(self.row.detailText())
.font(.caption.monospacedDigit())
.foregroundStyle(self.secondaryTextColor)
.lineLimit(1)
.truncationMode(.tail)
.layoutPriority(2)
if !self.row.hasError {
Text(self.row.detailText())
.font(.caption.monospacedDigit())
.foregroundStyle(self.secondaryTextColor)
.lineLimit(1)
.truncationMode(.tail)
.layoutPriority(2)
}
if self.showsChevron {
Image(systemName: "chevron.right")
@@ -52,6 +54,15 @@ struct UsageMenuLabelView: View {
}
}
if let error = self.row.error?.nonEmpty {
Text(error)
.font(.caption)
.foregroundStyle(self.secondaryTextColor)
.multilineTextAlignment(.leading)
.lineLimit(2)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.vertical, 10)
.padding(.leading, self.paddingLeading)
+1 -1
View File
@@ -287,7 +287,7 @@ ack reaction after the bot replies.
- `dm.enabled`: set `false` to ignore all DMs (default `true`).
- `dm.policy`: DM access control (`pairing` recommended). `"open"` requires `dm.allowFrom=["*"]`.
- `dm.allowFrom`: DM allowlist (user ids or names). Used by `dm.policy="allowlist"` and for `dm.policy="open"` validation. The wizard accepts usernames and resolves them to ids when the bot can search members.
- `dm.allowFrom`: DM allowlist (user ids or names). Used by `dm.policy="allowlist"` and for `dm.policy="open"` validation.
- `dm.groupEnabled`: enable group DMs (default `false`).
- `dm.groupChannels`: optional allowlist for group DM channel ids or slugs.
- `groupPolicy`: controls guild channel handling (`open|disabled|allowlist`); `allowlist` requires channel allowlists.
+1 -1
View File
@@ -244,7 +244,7 @@ Provider options:
- `channels.imessage.service`: `imessage | sms | auto`.
- `channels.imessage.region`: SMS region.
- `channels.imessage.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.imessage.allowFrom`: DM allowlist (handles, emails, E.164 numbers, or `chat_id:*`). `open` requires `"*"`. iMessage has no usernames; use handles or chat targets.
- `channels.imessage.allowFrom`: DM allowlist (handles or `chat_id:*`). `open` requires `"*"`.
- `channels.imessage.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
- `channels.imessage.groupAllowFrom`: group sender allowlist.
- `channels.imessage.historyLimit` / `channels.imessage.accounts.*.historyLimit`: max group messages to include as context (0 disables).
+2 -2
View File
@@ -70,7 +70,7 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis
- `clawdbot pairing list matrix`
- `clawdbot pairing approve matrix <CODE>`
- Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`.
- `channels.matrix.dm.allowFrom` accepts user IDs or display names. The wizard resolves display names to user IDs when directory search is available.
- `channels.matrix.dm.allowFrom` accepts user IDs or display names (resolved at startup when directory search is available).
## Rooms (groups)
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset.
@@ -121,7 +121,7 @@ Provider options:
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible.
- `channels.matrix.dm.allowFrom`: DM allowlist. `open` requires `"*"`.
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
- `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms.
- `channels.matrix.rooms`: per-room settings and allowlist.
+2 -2
View File
@@ -76,7 +76,7 @@ Disable with:
**DM access**
- Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved.
- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names. The wizard resolves names to IDs via Microsoft Graph when credentials allow.
- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names (resolved at startup when Graph allows).
**Group access**
- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). Use `channels.defaults.groupPolicy` to override the default when unset.
@@ -413,7 +413,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
- `channels.msteams.webhook.port` (default `3978`)
- `channels.msteams.webhook.path` (default `/api/messages`)
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available.
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs or UPNs).
- `channels.msteams.textChunkLimit`: outbound text chunk size.
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
- `channels.msteams.requireMention`: require @mention in channels/groups (default true).
+1 -1
View File
@@ -120,7 +120,7 @@ Provider options:
- `channels.signal.ignoreStories`: ignore stories from the daemon.
- `channels.signal.sendReadReceipts`: forward read receipts.
- `channels.signal.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.signal.allowFrom`: DM allowlist (E.164 or `uuid:<id>`). `open` requires `"*"`. Signal has no usernames; use phone/UUID ids.
- `channels.signal.allowFrom`: DM allowlist (E.164 or `uuid:<id>`). `open` requires `"*"`.
- `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
- `channels.signal.groupAllowFrom`: group sender allowlist.
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
+1 -1
View File
@@ -378,7 +378,7 @@ For fine-grained control, use these tags in agent responses:
- Default: `channels.slack.dm.policy="pairing"` — unknown DM senders get a pairing code (expires after 1 hour).
- Approve via: `clawdbot pairing approve slack <code>`.
- To allow anyone: set `channels.slack.dm.policy="open"` and `channels.slack.dm.allowFrom=["*"]`.
- `channels.slack.dm.allowFrom` accepts user IDs, @handles, or emails (resolved at startup when tokens allow). The wizard accepts usernames and resolves them to ids during setup when tokens allow.
- `channels.slack.dm.allowFrom` accepts user IDs, @handles, or emails (resolved at startup when tokens allow).
## Group policy
- `channels.slack.groupPolicy` controls channel handling (`open|disabled|allowlist`).
+1 -1
View File
@@ -305,7 +305,7 @@ Use the global setting when all Telegram bots/accounts should behave the same. U
- `clawdbot pairing list telegram`
- `clawdbot pairing approve telegram <CODE>`
- Pairing is the default token exchange used for Telegram DMs. Details: [Pairing](/start/pairing)
- `channels.telegram.allowFrom` accepts numeric user IDs (recommended) or `@username` entries. It is **not** the bot username; use the human sender’s ID. The wizard accepts `@username` and resolves it to the numeric ID when possible.
- `channels.telegram.allowFrom` accepts numeric user IDs (recommended) or `@username` entries. It is **not** the bot username; use the human sender’s ID.
#### Finding your Telegram user ID
Safer (no third-party bot):
+1 -1
View File
@@ -308,7 +308,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
## Config quick map
- `channels.whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled).
- `channels.whatsapp.selfChatMode` (same-phone setup; bot uses your personal WhatsApp number).
- `channels.whatsapp.allowFrom` (DM allowlist). WhatsApp uses E.164 phone numbers (no usernames).
- `channels.whatsapp.allowFrom` (DM allowlist).
- `channels.whatsapp.mediaMaxMb` (inbound media save cap).
- `channels.whatsapp.ackReaction` (auto-reaction on message receipt: `{emoji, direct, group}`).
- `channels.whatsapp.accounts.<accountId>.*` (per-account settings + optional `authDir`).
+2 -2
View File
@@ -92,7 +92,7 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
- `clawdbot pairing list zalo`
- `clawdbot pairing approve zalo <CODE>`
- Pairing is the default token exchange. Details: [Pairing](/start/pairing)
- `channels.zalo.allowFrom` accepts numeric user IDs (no username lookup available).
- `channels.zalo.allowFrom` accepts numeric user IDs.
## Long-polling vs webhook
- Default: long-polling (no public URL required).
@@ -147,7 +147,7 @@ Provider options:
- `channels.zalo.botToken`: bot token from Zalo Bot Platform.
- `channels.zalo.tokenFile`: read token from file path.
- `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs.
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`.
- `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5).
- `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required).
- `channels.zalo.webhookSecret`: webhook secret (8-256 chars).
+1 -1
View File
@@ -66,7 +66,7 @@ clawdbot directory groups list --channel zalouser --query "work"
## Access control (DMs)
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
`channels.zalouser.allowFrom` accepts user IDs or names. The wizard resolves names to IDs via `zca friend find` when available.
`channels.zalouser.allowFrom` accepts user IDs or names (resolved at startup when available).
Approve via:
- `clawdbot pairing list zalouser`
+1 -3
View File
@@ -7,7 +7,6 @@ read_when:
# `clawdbot agent`
Run an agent turn via the Gateway (use `--local` for embedded).
Use `--agent <id>` to target a configured agent directly.
Related:
- Agent send tool: [Agent send](/tools/agent-send)
@@ -16,7 +15,6 @@ Related:
```bash
clawdbot agent --to +15555550123 --message "status update" --deliver
clawdbot agent --agent ops --message "Summarize logs"
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
```
+7 -7
View File
@@ -5,6 +5,13 @@ read_when:
---
# Security 🔒
Running an AI agent with shell access on your machine is... *spicy*. Here’s how to not get pwned.
Clawdbot is both a product and an experiment: you’re wiring frontier-model behavior into real messaging surfaces and real tools. **There is no “perfectly secure” setup.** The goal is to be deliberate about:
- who can talk to your bot
- where the bot is allowed to act
- what the bot can touch
## Quick check: `clawdbot security audit`
Run this regularly (especially after changing config or exposing network surfaces):
@@ -22,13 +29,6 @@ It flags common footguns (Gateway auth exposure, browser control exposure, eleva
- Turn `logging.redactSensitive="off"` back to `"tools"`.
- Tighten local perms (`~/.clawdbot` → `700`, config file → `600`, plus common state files like `credentials/*.json`, `agents/*/agent/auth-profiles.json`, and `agents/*/sessions/sessions.json`).
Running an AI agent with shell access on your machine is... *spicy*. Here’s how to not get pwned.
Clawdbot is both a product and an experiment: you’re wiring frontier-model behavior into real messaging surfaces and real tools. **There is no “perfectly secure” setup.** The goal is to be deliberate about:
- who can talk to your bot
- where the bot is allowed to act
- what the bot can touch
### What the audit checks (high level)
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?
+10 -4
View File
@@ -5,7 +5,9 @@ read_when:
---
# Clawdbot macOS IPC architecture
**Current model:** a local Unix socket connects the **node service** to the **macOS app** for exec approvals + `system.run`. There is no `clawdbot-mac` CLI; agent actions still flow through the Gateway WebSocket and `node.invoke`. UI automation 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
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
@@ -13,11 +15,11 @@ read_when:
- Predictable permissions: always the same signed bundle ID, launched by launchd, so TCC grants stick.
## How it works
### Gateway + node bridge
### Gateway + node bridge (current)
- 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.*`).
### Node service + app IPC
### 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.
@@ -36,6 +38,10 @@ Agent -> Gateway -> Bridge -> Node Service (TS)
- Security: bridge hosts require an allowed TeamID; DEBUG-only same-UID escape hatch is guarded by `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (Peekaboo convention).
- See: [PeekabooBridge usage](/platforms/mac/peekaboo) for details.
### Mach/XPC
- Not required for automation; `node.invoke` + PeekabooBridge cover current needs.
- Planned IPC keeps Unix sockets (no XPC helper).
## Operational flows
- Restart/rebuild: `SIGN_IDENTITY="Apple Development: <Developer Name> (<TEAMID>)" scripts/restart-mac.sh`
- Kills existing instances
@@ -48,4 +54,4 @@ Agent -> Gateway -> Bridge -> Node Service (TS)
- 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.
- TCC prompts originate only from the GUI app bundle; keep the signed bundle ID stable across rebuilds.
- IPC hardening: socket mode `0600`, token, peer-UID checks, HMAC challenge/response, short TTL.
- Planned IPC hardening: socket mode `0600`, token, peer-UID checks, HMAC challenge/response, short TTL.
+7 -5
View File
@@ -17,17 +17,19 @@ capabilities to the agent as a node.
Speech Recognition, Automation/AppleScript).
- Runs or connects to the Gateway (local or remote).
- Exposes macOS‑only tools (Canvas, Camera, Screen Recording, `system.run`).
- Starts the local node host service in **remote** mode (launchd), and stops it in **local** mode.
- Optionally hosts **PeekabooBridge** for UI automation.
- 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** (default): the app attaches to a running local Gateway if present;
otherwise it enables the launchd service via `clawdbot daemon`.
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
a local process.
The app starts the local **node host service** so the remote Gateway can reach this Mac.
The app does not spawn the Gateway as a child process.
## Launchd control
@@ -56,9 +58,9 @@ The macOS app presents itself as a node. Common commands:
The node reports a `permissions` map so agents can decide what’s allowed.
Node service + app IPC:
- When the headless node service is running (remote mode), it connects to the Gateway bridge.
- `system.run` executes in the macOS app (UI/TCC context) over a local Unix socket; prompts + output stay in-app.
Planned split:
- Node service advertises the node surface to the Gateway.
- macOS app performs `system.run` in UI context over IPC.
Diagram (SCI):
```
+3 -10
View File
@@ -14,15 +14,13 @@ runtime on the current machine.
- Required: `--message <text>`
- Session selection:
- `--to <dest>` derives the session key (group/channel targets preserve isolation; direct chats collapse to `main`), **or**
- `--session-id <id>` reuses an existing session by id, **or**
- `--agent <id>` targets a configured agent directly (uses that agent's `main` session key)
- `--session-id <id>` reuses an existing session by id
- Runs the same embedded agent runtime as normal inbound replies.
- Thinking/verbose flags persist into the session store.
- Output:
- default: prints reply text (plus `MEDIA:<url>` lines)
- `--json`: prints structured payload + metadata
- Optional delivery back to a channel with `--deliver` + `--channel` (target formats match `clawdbot message --target`).
- Use `--reply-channel`/`--reply-to`/`--reply-account` to override delivery without changing the session.
If the Gateway is unreachable, the CLI **falls back** to the embedded local run.
@@ -30,21 +28,16 @@ If the Gateway is unreachable, the CLI **falls back** to the embedded local run.
```bash
clawdbot agent --to +15555550123 --message "status update"
clawdbot agent --agent ops --message "Summarize logs"
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
clawdbot agent --to +15555550123 --message "Trace logs" --verbose on --json
clawdbot agent --to +15555550123 --message "Summon reply" --deliver
clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
```
## Flags
- `--local`: run locally (requires model provider API keys in your shell)
- `--deliver`: send the reply to the chosen channel
- `--channel`: delivery channel (`whatsapp|telegram|discord|slack|signal|imessage`, default: `whatsapp`)
- `--reply-to`: delivery target override
- `--reply-channel`: delivery channel override
- `--reply-account`: delivery account id override
- `--deliver`: send the reply to the chosen channel (requires `--to`)
- `--channel`: `whatsapp|telegram|discord|slack|signal|imessage` (default: `whatsapp`)
- `--thinking <off|minimal|low|medium|high|xhigh>`: persist thinking level (GPT-5.2 + Codex models only)
- `--verbose <on|full|off>`: persist verbose level
- `--timeout <seconds>`: override agent timeout
+31 -79
View File
@@ -7,7 +7,6 @@ import {
type WizardPrompter,
} from "clawdbot/plugin-sdk";
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
import { listMatrixDirectoryPeersLive } from "./directory-live.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
import type { CoreConfig, DmPolicy } from "./types.js";
@@ -50,86 +49,40 @@ async function promptMatrixAllowFrom(params: {
}): Promise<CoreConfig> {
const { cfg, prompter } = params;
const existingAllowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? [];
const account = resolveMatrixAccount({ cfg });
const canResolve = Boolean(account.configured);
const entry = await prompter.text({
message: "Matrix allowFrom (user id)",
placeholder: "@user:server",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
if (!raw.startsWith("@")) return "Matrix user IDs should start with @";
if (!raw.includes(":")) return "Matrix user IDs should include a server (:@server)";
return undefined;
},
});
const normalized = String(entry).trim();
const merged = [
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
normalized,
];
const unique = [...new Set(merged)];
const parseInput = (raw: string) =>
raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
const isFullUserId = (value: string) => value.startsWith("@") && value.includes(":");
while (true) {
const entry = await prompter.text({
message: "Matrix allowFrom (username or user id)",
placeholder: "@user:server",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = parseInput(String(entry));
const resolvedIds: string[] = [];
let unresolved: string[] = [];
for (const part of parts) {
if (isFullUserId(part)) {
resolvedIds.push(part);
continue;
}
if (!canResolve) {
unresolved.push(part);
continue;
}
const results = await listMatrixDirectoryPeersLive({
cfg,
query: part,
limit: 5,
}).catch(() => []);
const match = results.find((result) => result.id);
if (match?.id) {
resolvedIds.push(match.id);
if (results.length > 1) {
await prompter.note(
`Multiple matches for "${part}", using ${match.id}.`,
"Matrix allowlist",
);
}
} else {
unresolved.push(part);
}
}
if (unresolved.length > 0) {
await prompter.note(
`Could not resolve: ${unresolved.join(", ")}. Use full @user:server IDs.`,
"Matrix allowlist",
);
continue;
}
const unique = [
...new Set([
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
...resolvedIds,
]),
];
return {
...cfg,
channels: {
...cfg.channels,
matrix: {
...cfg.channels?.matrix,
enabled: true,
dm: {
...cfg.channels?.matrix?.dm,
policy: "allowlist",
allowFrom: unique,
},
return {
...cfg,
channels: {
...cfg.channels,
matrix: {
...cfg.channels?.matrix,
enabled: true,
dm: {
...cfg.channels?.matrix?.dm,
policy: "allowlist",
allowFrom: unique,
},
},
};
}
},
};
}
function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") {
@@ -168,7 +121,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
allowFromKey: "channels.matrix.dm.allowFrom",
getCurrent: (cfg) => (cfg as CoreConfig).channels?.matrix?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy),
promptAllowFrom: promptMatrixAllowFrom,
};
export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
-93
View File
@@ -16,7 +16,6 @@ import { resolveMSTeamsCredentials } from "./token.js";
import {
parseMSTeamsTeamEntry,
resolveMSTeamsChannelAllowlist,
resolveMSTeamsUserAllowlist,
} from "./resolve-allowlist.js";
const channel = "msteams" as const;
@@ -39,97 +38,6 @@ function setMSTeamsDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
};
}
function setMSTeamsAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
return {
...cfg,
channels: {
...cfg.channels,
msteams: {
...cfg.channels?.msteams,
allowFrom,
},
},
};
}
function parseAllowFromInput(raw: string): string[] {
return raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
function looksLikeGuid(value: string): boolean {
return /^[0-9a-fA-F-]{16,}$/.test(value);
}
async function promptMSTeamsAllowFrom(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
}): Promise<ClawdbotConfig> {
const existing = params.cfg.channels?.msteams?.allowFrom ?? [];
await params.prompter.note(
[
"Allowlist MS Teams DMs by display name, UPN/email, or user id.",
"We resolve names to user IDs via Microsoft Graph when credentials allow.",
"Examples:",
"- alex@example.com",
"- Alex Johnson",
"- 00000000-0000-0000-0000-000000000000",
].join("\n"),
"MS Teams allowlist",
);
while (true) {
const entry = await params.prompter.text({
message: "MS Teams allowFrom (usernames or ids)",
placeholder: "alex@example.com, Alex Johnson",
initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = parseAllowFromInput(String(entry));
if (parts.length === 0) {
await params.prompter.note("Enter at least one user.", "MS Teams allowlist");
continue;
}
const resolved = await resolveMSTeamsUserAllowlist({
cfg: params.cfg,
entries: parts,
}).catch(() => null);
if (!resolved) {
const ids = parts.filter((part) => looksLikeGuid(part));
if (ids.length !== parts.length) {
await params.prompter.note(
"Graph lookup unavailable. Use user IDs only.",
"MS Teams allowlist",
);
continue;
}
const unique = [
...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]),
];
return setMSTeamsAllowFrom(params.cfg, unique);
}
const unresolved = resolved.filter((item) => !item.resolved || !item.id);
if (unresolved.length > 0) {
await params.prompter.note(
`Could not resolve: ${unresolved.map((item) => item.input).join(", ")}`,
"MS Teams allowlist",
);
continue;
}
const ids = resolved.map((item) => item.id as string);
const unique = [
...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]),
];
return setMSTeamsAllowFrom(params.cfg, unique);
}
}
async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
@@ -198,7 +106,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
allowFromKey: "channels.msteams.allowFrom",
getCurrent: (cfg) => cfg.channels?.msteams?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setMSTeamsDmPolicy(cfg, policy),
promptAllowFrom: promptMSTeamsAllowFrom,
};
export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = {
-11
View File
@@ -207,17 +207,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
allowFromKey: "channels.zalo.allowFrom",
getCurrent: (cfg) => (cfg.channels?.zalo?.dmPolicy ?? "pairing") as "pairing",
setPolicy: (cfg, policy) => setZaloDmPolicy(cfg as ClawdbotConfig, policy),
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
const id =
accountId && normalizeAccountId(accountId)
? normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID
: resolveDefaultZaloAccountId(cfg as ClawdbotConfig);
return promptZaloAllowFrom({
cfg: cfg as ClawdbotConfig,
prompter,
accountId: id,
});
},
};
export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
+41 -87
View File
@@ -19,7 +19,7 @@ import {
checkZcaAuthenticated,
} from "./accounts.js";
import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from "./zca.js";
import type { ZcaFriend, ZcaGroup } from "./types.js";
import type { ZcaGroup } from "./types.js";
const channel = "zalouser" as const;
@@ -67,73 +67,25 @@ async function promptZalouserAllowFrom(params: {
const { cfg, prompter, accountId } = params;
const resolved = resolveZalouserAccountSync({ cfg, accountId });
const existingAllowFrom = resolved.config.allowFrom ?? [];
const parseInput = (raw: string) =>
raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
const resolveUserId = async (input: string): Promise<string | null> => {
const trimmed = input.trim();
if (!trimmed) return null;
if (/^\d+$/.test(trimmed)) return trimmed;
const ok = await checkZcaInstalled();
if (!ok) return null;
const result = await runZca(["friend", "find", trimmed], {
profile: resolved.profile,
timeout: 15000,
});
if (!result.ok) return null;
const parsed = parseJsonOutput<ZcaFriend[]>(result.stdout);
const rows = Array.isArray(parsed) ? parsed : [];
const match = rows[0];
if (!match?.userId) return null;
if (rows.length > 1) {
await prompter.note(
`Multiple matches for "${trimmed}", using ${match.displayName ?? match.userId}.`,
"Zalo Personal allowlist",
);
}
return String(match.userId);
};
while (true) {
const entry = await prompter.text({
message: "Zalouser allowFrom (username or user id)",
placeholder: "Alice, 123456789",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = parseInput(String(entry));
const results = await Promise.all(parts.map((part) => resolveUserId(part)));
const unresolved = parts.filter((_, idx) => !results[idx]);
if (unresolved.length > 0) {
await prompter.note(
`Could not resolve: ${unresolved.join(", ")}. Use numeric user ids or ensure zca is available.`,
"Zalo Personal allowlist",
);
continue;
}
const merged = [
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
...(results.filter(Boolean) as string[]),
];
const unique = [...new Set(merged)];
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
} as ClawdbotConfig;
}
const entry = await prompter.text({
message: "Zalouser allowFrom (user id)",
placeholder: "123456789",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
if (!/^\d+$/.test(raw)) return "Use a numeric Zalo user id";
return undefined;
},
});
const normalized = String(entry).trim();
const merged = [
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
normalized,
];
const unique = [...new Set(merged)];
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
@@ -141,19 +93,32 @@ async function promptZalouserAllowFrom(params: {
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
accounts: {
...(cfg.channels?.zalouser?.accounts ?? {}),
[accountId]: {
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
dmPolicy: "allowlist",
allowFrom: unique,
},
},
} as ClawdbotConfig;
}
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
accounts: {
...(cfg.channels?.zalouser?.accounts ?? {}),
[accountId]: {
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
},
},
} as ClawdbotConfig;
}
function setZalouserGroupPolicy(
@@ -272,17 +237,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
allowFromKey: "channels.zalouser.allowFrom",
getCurrent: (cfg) => ((cfg as ClawdbotConfig).channels?.zalouser?.dmPolicy ?? "pairing") as "pairing",
setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as ClawdbotConfig, policy),
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
const id =
accountId && normalizeAccountId(accountId)
? normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID
: resolveDefaultZalouserAccountId(cfg as ClawdbotConfig);
return promptZalouserAllowFrom({
cfg: cfg as ClawdbotConfig,
prompter,
accountId: id,
});
},
};
export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
+8 -8
View File
@@ -265,13 +265,7 @@ importers:
specifier: 40.0.0
version: 40.0.0
extensions/memory-core:
dependencies:
clawdbot:
specifier: workspace:*
version: link:../..
extensions/memory-lancedb:
extensions/memory:
dependencies:
'@lancedb/lancedb':
specifier: ^0.15.0
@@ -283,6 +277,12 @@ importers:
specifier: ^4.104.0
version: 4.104.0(ws@8.19.0)(zod@3.25.76)
extensions/memory-core:
dependencies:
clawdbot:
specifier: workspace:*
version: link:../..
extensions/msteams:
dependencies:
'@microsoft/agents-hosting':
@@ -6625,7 +6625,7 @@ snapshots:
'@types/node-fetch@2.6.13':
dependencies:
'@types/node': 25.0.9
'@types/node': 18.19.130
form-data: 4.0.5
'@types/node@10.17.60': {}
-10
View File
@@ -49,16 +49,6 @@ for file in "${files[@]}"; do
fi
done
# Prevent staging node_modules even if a path is forced.
for file in "${files[@]}"; do
case "$file" in
*node_modules* | */node_modules | */node_modules/* | node_modules)
printf 'Error: node_modules paths are not allowed: %s\n' "$file" >&2
exit 1
;;
esac
done
last_commit_error=''
run_git_commit() {
+1 -9
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path';
process.stdout.on('error', (error) => {
@@ -11,14 +11,6 @@ process.stdout.on('error', (error) => {
});
const DOCS_DIR = join(process.cwd(), 'docs');
if (!existsSync(DOCS_DIR)) {
console.error('docs:list: missing docs directory. Run from repo root.');
process.exit(1);
}
if (!statSync(DOCS_DIR).isDirectory()) {
console.error('docs:list: docs path is not a directory.');
process.exit(1);
}
const EXCLUDED_DIRS = new Set(['archive', 'research']);
@@ -6,7 +6,6 @@ import path from "node:path";
import { type Api, completeSimple, type Model } from "@mariozechner/pi-ai";
import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
import { describe, expect, it } from "vitest";
import { isTruthyEnvValue } from "../infra/env.js";
import {
ANTHROPIC_SETUP_TOKEN_PREFIX,
validateAnthropicSetupToken,
@@ -22,8 +21,7 @@ import { getApiKeyForModel } from "./model-auth.js";
import { normalizeProviderId, parseModelRef } from "./model-selection.js";
import { ensureClawdbotModelsJson } from "./models-config.js";
const LIVE =
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
const SETUP_TOKEN_RAW = process.env.CLAWDBOT_LIVE_SETUP_TOKEN?.trim() ?? "";
const SETUP_TOKEN_VALUE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_VALUE?.trim() ?? "";
const SETUP_TOKEN_PROFILE = process.env.CLAWDBOT_LIVE_SETUP_TOKEN_PROFILE?.trim() ?? "";
+1 -2
View File
@@ -2,7 +2,6 @@ import type { ImageContent } from "@mariozechner/pi-ai";
import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js";
import type { ThinkLevel } from "../auto-reply/thinking.js";
import type { ClawdbotConfig } from "../config/config.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { shouldLogVerbose } from "../globals.js";
import { createSubsystemLogger } from "../logging.js";
import { runCommandWithTimeout } from "../process/exec.js";
@@ -165,7 +164,7 @@ export async function runCliAgent(params: {
log.info(
`cli exec: provider=${params.provider} model=${normalizedModel} promptChars=${params.prompt.length}`,
);
const logOutputText = isTruthyEnvValue(process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT);
const logOutputText = process.env.CLAWDBOT_CLAUDE_CLI_LOG_OUTPUT === "1";
if (logOutputText) {
const logArgs: string[] = [];
for (let i = 0; i < args.length; i += 1) {
+1 -3
View File
@@ -1,10 +1,8 @@
import { completeSimple, getModel } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { isTruthyEnvValue } from "../infra/env.js";
const GEMINI_KEY = process.env.GEMINI_API_KEY ?? "";
const LIVE =
isTruthyEnvValue(process.env.GEMINI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const LIVE = process.env.GEMINI_LIVE_TEST === "1" || process.env.LIVE === "1";
const describeLive = LIVE && GEMINI_KEY ? describe : describe.skip;
+1 -3
View File
@@ -1,12 +1,10 @@
import { completeSimple, type Model } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { isTruthyEnvValue } from "../infra/env.js";
const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? "";
const MINIMAX_BASE_URL = process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/anthropic";
const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1";
const LIVE =
isTruthyEnvValue(process.env.MINIMAX_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const LIVE = process.env.MINIMAX_LIVE_TEST === "1" || process.env.LIVE === "1";
const describeLive = LIVE && MINIMAX_KEY ? describe : describe.skip;
+2 -4
View File
@@ -3,7 +3,6 @@ import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-age
import { Type } from "@sinclair/typebox";
import { describe, expect, it } from "vitest";
import { loadConfig } from "../config/config.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { resolveClawdbotAgentDir } from "./agent-paths.js";
import {
collectAnthropicApiKeys,
@@ -15,10 +14,9 @@ import { getApiKeyForModel } from "./model-auth.js";
import { ensureClawdbotModelsJson } from "./models-config.js";
import { isRateLimitErrorMessage } from "./pi-embedded-helpers/errors.js";
const LIVE =
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
const DIRECT_ENABLED = Boolean(process.env.CLAWDBOT_LIVE_MODELS?.trim());
const REQUIRE_PROFILE_KEYS = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS);
const REQUIRE_PROFILE_KEYS = process.env.CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS === "1";
const describeLive = LIVE ? describe : describe.skip;
@@ -1,13 +1,11 @@
import type { Model } from "@mariozechner/pi-ai";
import { getModel, streamSimple } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { isTruthyEnvValue } from "../infra/env.js";
import type { ClawdbotConfig } from "../config/config.js";
import { applyExtraParamsToAgent } from "./pi-embedded-runner.js";
const OPENAI_KEY = process.env.OPENAI_API_KEY ?? "";
const LIVE =
isTruthyEnvValue(process.env.OPENAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const LIVE = process.env.OPENAI_LIVE_TEST === "1" || process.env.LIVE === "1";
const describeLive = LIVE && OPENAI_KEY ? describe : describe.skip;
@@ -2,9 +2,8 @@ import fs from "node:fs";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import { isTruthyEnvValue } from "../infra/env.js";
const RAW_STREAM_ENABLED = isTruthyEnvValue(process.env.CLAWDBOT_RAW_STREAM);
const RAW_STREAM_ENABLED = process.env.CLAWDBOT_RAW_STREAM === "1";
const RAW_STREAM_PATH =
process.env.CLAWDBOT_RAW_STREAM_PATH?.trim() ||
path.join(resolveStateDir(), "logs", "raw-stream.jsonl");
-20
View File
@@ -1,20 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveSkillInvocationPolicy } from "./frontmatter.js";
describe("resolveSkillInvocationPolicy", () => {
it("defaults to enabled behaviors", () => {
const policy = resolveSkillInvocationPolicy({});
expect(policy.userInvocable).toBe(true);
expect(policy.disableModelInvocation).toBe(false);
});
it("parses frontmatter boolean strings", () => {
const policy = resolveSkillInvocationPolicy({
"user-invocable": "no",
"disable-model-invocation": "yes",
});
expect(policy.userInvocable).toBe(false);
expect(policy.disableModelInvocation).toBe(true);
});
});
+10 -3
View File
@@ -2,7 +2,6 @@ import JSON5 from "json5";
import type { Skill } from "@mariozechner/pi-coding-agent";
import { parseFrontmatterBlock } from "../../markdown/frontmatter.js";
import { parseBooleanValue } from "../../utils/boolean.js";
import type {
ClawdbotSkillMetadata,
ParsedSkillFrontmatter,
@@ -60,8 +59,16 @@ function getFrontmatterValue(frontmatter: ParsedSkillFrontmatter, key: string):
}
function parseFrontmatterBool(value: string | undefined, fallback: boolean): boolean {
const parsed = parseBooleanValue(value);
return parsed === undefined ? fallback : parsed;
if (!value) return fallback;
const normalized = value.trim().toLowerCase();
if (!normalized) return fallback;
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") {
return true;
}
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") {
return false;
}
return fallback;
}
export function resolveClawdbotMetadata(
+1 -2
View File
@@ -1,9 +1,8 @@
import { completeSimple, getModel } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { isTruthyEnvValue } from "../infra/env.js";
const ZAI_KEY = process.env.ZAI_API_KEY ?? process.env.Z_AI_API_KEY ?? "";
const LIVE = isTruthyEnvValue(process.env.ZAI_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
const LIVE = process.env.ZAI_LIVE_TEST === "1" || process.env.LIVE === "1";
const describeLive = LIVE && ZAI_KEY ? describe : describe.skip;
+20 -14
View File
@@ -441,19 +441,22 @@ export async function runAgentTurnWithFallback(params: {
// Some embedded runs surface context overflow as an error payload instead of throwing.
// Treat those as a session-level failure and auto-recover by starting a fresh session.
const embeddedError = runResult.meta?.error;
if (
embeddedError &&
isContextOverflowError(embeddedError.message) &&
!didResetAfterCompactionFailure &&
(await params.resetSessionAfterCompactionFailure(embeddedError.message))
) {
didResetAfterCompactionFailure = true;
return {
kind: "final",
payload: {
text: "⚠️ Context limit exceeded. I've reset our conversation to start fresh - please try again.\n\nTo prevent this, increase your compaction buffer by setting `agents.defaults.compaction.reserveTokensFloor` to 4000 or higher in your config.",
},
};
if (embeddedError && isContextOverflowError(embeddedError.message)) {
if (
!didResetAfterCompactionFailure &&
(await params.resetSessionAfterCompactionFailure(embeddedError.message))
) {
didResetAfterCompactionFailure = true;
continue;
}
if (didResetAfterCompactionFailure) {
return {
kind: "final",
payload: {
text: "⚠️ Context limit exceeded. I've reset our conversation to start fresh - please try again.\n\nTo prevent this, increase your compaction buffer by setting `agents.defaults.compaction.reserveTokensFloor` to 20000 or higher in your config.",
},
};
}
}
if (embeddedError?.kind === "role_ordering") {
const didReset = await params.resetSessionAfterRoleOrderingConflict(
@@ -487,10 +490,13 @@ export async function runAgentTurnWithFallback(params: {
(await params.resetSessionAfterCompactionFailure(message))
) {
didResetAfterCompactionFailure = true;
continue;
}
if (isCompactionFailure && didResetAfterCompactionFailure) {
return {
kind: "final",
payload: {
text: "⚠️ Context limit exceeded during compaction. I've reset our conversation to start fresh - please try again.\n\nTo prevent this, increase your compaction buffer by setting `agents.defaults.compaction.reserveTokensFloor` to 4000 or higher in your config.",
text: "⚠️ Context limit exceeded during compaction. I've reset our conversation to start fresh - please try again.\n\nTo prevent this, increase your compaction buffer by setting `agents.defaults.compaction.reserveTokensFloor` to 20000 or higher in your config.",
},
};
}
@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../../config/sessions.js";
import * as sessions from "../../config/sessions.js";
import type { TypingMode } from "../../config/types.js";
@@ -121,10 +121,6 @@ function createMinimalRun(params?: {
}
describe("runReplyAgent typing (heartbeat)", () => {
beforeEach(() => {
runEmbeddedPiAgentMock.mockReset();
});
it("retries after compaction failure by resetting the session", async () => {
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-compaction-reset-"));
@@ -141,12 +137,18 @@ describe("runReplyAgent typing (heartbeat)", () => {
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
await fs.writeFile(transcriptPath, "ok", "utf-8");
runEmbeddedPiAgentMock.mockImplementationOnce(async () => {
throw new Error(
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
);
});
runEmbeddedPiAgentMock
.mockImplementationOnce(async () => {
throw new Error(
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
);
})
.mockImplementationOnce(async () => ({
payloads: [{ text: "ok" }],
meta: {},
}));
const callsBefore = runEmbeddedPiAgentMock.mock.calls.length;
const { run } = createMinimalRun({
sessionEntry,
sessionStore,
@@ -155,11 +157,55 @@ describe("runReplyAgent typing (heartbeat)", () => {
});
const res = await run();
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
expect(runEmbeddedPiAgentMock.mock.calls.length - callsBefore).toBe(2);
const payload = Array.isArray(res) ? res[0] : res;
expect(payload).toMatchObject({
text: expect.stringContaining("Context limit exceeded during compaction"),
expect(payload).toMatchObject({ text: "ok" });
expect(sessionStore.main.sessionId).not.toBe(sessionId);
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId);
} finally {
if (prevStateDir) {
process.env.CLAWDBOT_STATE_DIR = prevStateDir;
} else {
delete process.env.CLAWDBOT_STATE_DIR;
}
}
});
it("returns a user-facing error after repeated compaction failure", async () => {
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-compaction-repeat-"));
process.env.CLAWDBOT_STATE_DIR = stateDir;
try {
const sessionId = "session";
const storePath = path.join(stateDir, "sessions", "sessions.json");
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath };
const sessionStore = { main: sessionEntry };
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
await fs.writeFile(transcriptPath, "ok", "utf-8");
runEmbeddedPiAgentMock.mockImplementation(async () => {
throw new Error(
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
);
});
const callsBefore = runEmbeddedPiAgentMock.mock.calls.length;
const { run } = createMinimalRun({
sessionEntry,
sessionStore,
sessionKey: "main",
storePath,
});
const res = await run();
expect(runEmbeddedPiAgentMock.mock.calls.length - callsBefore).toBe(2);
const payload = Array.isArray(res) ? res[0] : res;
expect(payload.text).toContain("Context limit exceeded");
expect(payload.text?.toLowerCase()).toContain("reset");
expect(sessionStore.main.sessionId).not.toBe(sessionId);
@@ -173,7 +219,6 @@ describe("runReplyAgent typing (heartbeat)", () => {
}
}
});
it("retries after context overflow payload by resetting the session", async () => {
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-overflow-reset-"));
@@ -190,18 +235,24 @@ describe("runReplyAgent typing (heartbeat)", () => {
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
await fs.writeFile(transcriptPath, "ok", "utf-8");
runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({
payloads: [{ text: "Context overflow: prompt too large", isError: true }],
meta: {
durationMs: 1,
error: {
kind: "context_overflow",
message:
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
runEmbeddedPiAgentMock
.mockImplementationOnce(async () => ({
payloads: [{ text: "Context overflow: prompt too large", isError: true }],
meta: {
durationMs: 1,
error: {
kind: "context_overflow",
message:
'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}',
},
},
},
}));
}))
.mockImplementationOnce(async () => ({
payloads: [{ text: "ok" }],
meta: { durationMs: 1 },
}));
const callsBefore = runEmbeddedPiAgentMock.mock.calls.length;
const { run } = createMinimalRun({
sessionEntry,
sessionStore,
@@ -210,12 +261,9 @@ describe("runReplyAgent typing (heartbeat)", () => {
});
const res = await run();
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1);
expect(runEmbeddedPiAgentMock.mock.calls.length - callsBefore).toBe(2);
const payload = Array.isArray(res) ? res[0] : res;
expect(payload).toMatchObject({
text: expect.stringContaining("Context limit exceeded"),
});
expect(payload.text?.toLowerCase()).toContain("reset");
expect(payload).toMatchObject({ text: "ok" });
expect(sessionStore.main.sessionId).not.toBe(sessionId);
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
@@ -228,7 +276,6 @@ describe("runReplyAgent typing (heartbeat)", () => {
}
}
});
it("resets the session after role ordering payloads", async () => {
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-role-ordering-"));
+9 -21
View File
@@ -161,14 +161,11 @@ export async function runReplyAgent(params: {
const steered = queueEmbeddedPiMessage(followupRun.run.sessionId, followupRun.prompt);
if (steered && !shouldFollowup) {
if (activeSessionEntry && activeSessionStore && sessionKey) {
const updatedAt = Date.now();
activeSessionEntry.updatedAt = updatedAt;
activeSessionEntry.updatedAt = Date.now();
activeSessionStore[sessionKey] = activeSessionEntry;
if (storePath) {
await updateSessionStoreEntry({
storePath,
sessionKey,
update: async () => ({ updatedAt }),
await updateSessionStore(storePath, (store) => {
store[sessionKey] = activeSessionEntry as SessionEntry;
});
}
}
@@ -180,14 +177,11 @@ export async function runReplyAgent(params: {
if (isActive && (shouldFollowup || resolvedQueue.mode === "steer")) {
enqueueFollowupRun(queueKey, followupRun, resolvedQueue);
if (activeSessionEntry && activeSessionStore && sessionKey) {
const updatedAt = Date.now();
activeSessionEntry.updatedAt = updatedAt;
activeSessionEntry.updatedAt = Date.now();
activeSessionStore[sessionKey] = activeSessionEntry;
if (storePath) {
await updateSessionStoreEntry({
storePath,
sessionKey,
update: async () => ({ updatedAt }),
await updateSessionStore(storePath, (store) => {
store[sessionKey] = activeSessionEntry as SessionEntry;
});
}
}
@@ -334,18 +328,12 @@ export async function runReplyAgent(params: {
sessionKey &&
activeSessionEntry.groupActivationNeedsSystemIntro
) {
const updatedAt = Date.now();
activeSessionEntry.groupActivationNeedsSystemIntro = false;
activeSessionEntry.updatedAt = updatedAt;
activeSessionEntry.updatedAt = Date.now();
activeSessionStore[sessionKey] = activeSessionEntry;
if (storePath) {
await updateSessionStoreEntry({
storePath,
sessionKey,
update: async () => ({
groupActivationNeedsSystemIntro: false,
updatedAt,
}),
await updateSessionStore(storePath, (store) => {
store[sessionKey] = activeSessionEntry as SessionEntry;
});
}
}
@@ -1,8 +1,6 @@
import { describe, it } from "vitest";
import { isTruthyEnvValue } from "../infra/env.js";
const LIVE =
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
const CDP_URL = process.env.CLAWDBOT_LIVE_BROWSER_CDP_URL?.trim() || "";
const describeLive = LIVE && CDP_URL ? describe : describe.skip;
-22
View File
@@ -1,22 +0,0 @@
import { describe, expect, it } from "vitest";
import { toBoolean } from "./utils.js";
describe("toBoolean", () => {
it("parses yes/no and 1/0", () => {
expect(toBoolean("yes")).toBe(true);
expect(toBoolean("1")).toBe(true);
expect(toBoolean("no")).toBe(false);
expect(toBoolean("0")).toBe(false);
});
it("returns undefined for on/off strings", () => {
expect(toBoolean("on")).toBeUndefined();
expect(toBoolean("off")).toBeUndefined();
});
it("passes through boolean values", () => {
expect(toBoolean(true)).toBe(true);
expect(toBoolean(false)).toBe(false);
});
});
+7 -5
View File
@@ -1,7 +1,6 @@
import type express from "express";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import { parseBooleanValue } from "../../utils/boolean.js";
/**
* Extract profile name from query string or body and get profile context.
@@ -55,10 +54,13 @@ export function toNumber(value: unknown) {
}
export function toBoolean(value: unknown) {
return parseBooleanValue(value, {
truthy: ["true", "1", "yes"],
falsy: ["false", "0", "no"],
});
if (typeof value === "boolean") return value;
if (typeof value === "string") {
const v = value.trim().toLowerCase();
if (v === "true" || v === "1" || v === "yes") return true;
if (v === "false" || v === "0" || v === "no") return false;
}
return undefined;
}
export function toStringArray(value: unknown): string[] | undefined {
+1 -1
View File
@@ -1 +1 @@
c51e9080b032ccb0dd153452854f2ffdaca8e1db14d7b98ed56cca8f0f1a5257
e8286176aaf2c0daf5f4b30dd221c3d7aa1aec8329d6865cac3d9636fd7f08f6
File diff suppressed because it is too large. Load diff
+1 -2
View File
@@ -7,7 +7,6 @@ import type { Duplex } from "node:stream";
import chokidar from "chokidar";
import { type WebSocket, WebSocketServer } from "ws";
import { isTruthyEnvValue } from "../infra/env.js";
import { detectMime } from "../media/mime.js";
import type { RuntimeEnv } from "../runtime.js";
import { ensureDir, resolveUserPath } from "../utils.js";
@@ -172,7 +171,7 @@ async function resolveFilePath(rootReal: string, urlPath: string) {
}
function isDisabledByEnv() {
if (isTruthyEnvValue(process.env.CLAWDBOT_SKIP_CANVAS_HOST)) return true;
if (process.env.CLAWDBOT_SKIP_CANVAS_HOST === "1") return true;
if (process.env.NODE_ENV === "test") return true;
if (process.env.VITEST) return true;
return false;
-5
View File
@@ -69,11 +69,6 @@ export type ChannelOnboardingDmPolicy = {
allowFromKey: string;
getCurrent: (cfg: ClawdbotConfig) => DmPolicy;
setPolicy: (cfg: ClawdbotConfig, policy: DmPolicy) => ClawdbotConfig;
promptAllowFrom?: (params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
accountId?: string;
}) => Promise<ClawdbotConfig>;
};
export type ChannelOnboardingAdapter = {
-109
View File
@@ -7,7 +7,6 @@ import {
resolveDiscordAccount,
} from "../../../discord/accounts.js";
import { normalizeDiscordSlug } from "../../../discord/monitor/allow-list.js";
import { resolveDiscordUserAllowlist } from "../../../discord/resolve-users.js";
import {
resolveDiscordChannelAllowlist,
type DiscordChannelResolution,
@@ -149,113 +148,6 @@ function setDiscordGuildChannelAllowlist(
};
}
function setDiscordAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
return {
...cfg,
channels: {
...cfg.channels,
discord: {
...cfg.channels?.discord,
dm: {
...cfg.channels?.discord?.dm,
enabled: cfg.channels?.discord?.dm?.enabled ?? true,
allowFrom,
},
},
},
};
}
function parseDiscordAllowFromInput(raw: string): string[] {
return raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
async function promptDiscordAllowFrom(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<ClawdbotConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID
: resolveDefaultDiscordAccountId(params.cfg);
const resolved = resolveDiscordAccount({ cfg: params.cfg, accountId });
const token = resolved.token;
const existing = params.cfg.channels?.discord?.dm?.allowFrom ?? [];
await params.prompter.note(
[
"Allowlist Discord DMs by username (we resolve to user ids).",
"Examples:",
"- 123456789012345678",
"- @alice",
"- alice#1234",
"Multiple entries: comma-separated.",
`Docs: ${formatDocsLink("/discord", "discord")}`,
].join("\n"),
"Discord allowlist",
);
const parseInputs = (value: string) => parseDiscordAllowFromInput(value);
const parseId = (value: string) => {
const trimmed = value.trim();
if (!trimmed) return null;
const mention = trimmed.match(/^<@!?(\d+)>$/);
if (mention) return mention[1];
const prefixed = trimmed.replace(/^(user:|discord:)/i, "");
if (/^\d+$/.test(prefixed)) return prefixed;
return null;
};
while (true) {
const entry = await params.prompter.text({
message: "Discord allowFrom (usernames or ids)",
placeholder: "@alice, 123456789012345678",
initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = parseInputs(String(entry));
if (!token) {
const ids = parts.map(parseId).filter(Boolean) as string[];
if (ids.length !== parts.length) {
await params.prompter.note(
"Bot token missing; use numeric user ids (or mention form) only.",
"Discord allowlist",
);
continue;
}
const unique = [...new Set([...existing.map((v) => String(v).trim()), ...ids])].filter(
Boolean,
);
return setDiscordAllowFrom(params.cfg, unique);
}
const results = await resolveDiscordUserAllowlist({
token,
entries: parts,
}).catch(() => null);
if (!results) {
await params.prompter.note("Failed to resolve usernames. Try again.", "Discord allowlist");
continue;
}
const unresolved = results.filter((res) => !res.resolved || !res.id);
if (unresolved.length > 0) {
await params.prompter.note(
`Could not resolve: ${unresolved.map((res) => res.input).join(", ")}`,
"Discord allowlist",
);
continue;
}
const ids = results.map((res) => res.id as string);
const unique = [
...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]),
];
return setDiscordAllowFrom(params.cfg, unique);
}
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Discord",
channel,
@@ -263,7 +155,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
allowFromKey: "channels.discord.dm.allowFrom",
getCurrent: (cfg) => cfg.channels?.discord?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) => setDiscordDmPolicy(cfg, policy),
promptAllowFrom: promptDiscordAllowFrom,
};
export const discordOnboardingAdapter: ChannelOnboardingAdapter = {
-102
View File
@@ -6,10 +6,8 @@ import {
resolveDefaultIMessageAccountId,
resolveIMessageAccount,
} from "../../../imessage/accounts.js";
import { normalizeIMessageHandle } from "../../../imessage/targets.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
@@ -31,105 +29,6 @@ function setIMessageDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
};
}
function setIMessageAllowFrom(
cfg: ClawdbotConfig,
accountId: string,
allowFrom: string[],
): ClawdbotConfig {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
imessage: {
...cfg.channels?.imessage,
allowFrom,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
imessage: {
...cfg.channels?.imessage,
accounts: {
...cfg.channels?.imessage?.accounts,
[accountId]: {
...cfg.channels?.imessage?.accounts?.[accountId],
allowFrom,
},
},
},
},
};
}
function parseIMessageAllowFromInput(raw: string): string[] {
return raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
async function promptIMessageAllowFrom(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<ClawdbotConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID
: resolveDefaultIMessageAccountId(params.cfg);
const resolved = resolveIMessageAccount({ cfg: params.cfg, accountId });
const existing = resolved.config.allowFrom ?? [];
await params.prompter.note(
[
"Allowlist iMessage DMs by handle or chat target.",
"Examples:",
"- +15555550123",
"- user@example.com",
"- chat_id:123",
"- chat_guid:... or chat_identifier:...",
"Multiple entries: comma-separated.",
`Docs: ${formatDocsLink("/imessage", "imessage")}`,
].join("\n"),
"iMessage allowlist",
);
const entry = await params.prompter.text({
message: "iMessage allowFrom (handle or chat_id)",
placeholder: "+15555550123, user@example.com, chat_id:123",
initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
const parts = parseIMessageAllowFromInput(raw);
for (const part of parts) {
if (part === "*") continue;
if (part.toLowerCase().startsWith("chat_id:")) {
const id = part.slice("chat_id:".length).trim();
if (!/^\d+$/.test(id)) return `Invalid chat_id: ${part}`;
continue;
}
if (part.toLowerCase().startsWith("chat_guid:")) {
if (!part.slice("chat_guid:".length).trim()) return "Invalid chat_guid entry";
continue;
}
if (part.toLowerCase().startsWith("chat_identifier:")) {
if (!part.slice("chat_identifier:".length).trim()) return "Invalid chat_identifier entry";
continue;
}
if (!normalizeIMessageHandle(part)) return `Invalid handle: ${part}`;
}
return undefined;
},
});
const parts = parseIMessageAllowFromInput(String(entry));
const unique = [...new Set(parts)];
return setIMessageAllowFrom(params.cfg, accountId, unique);
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "iMessage",
channel,
@@ -137,7 +36,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
allowFromKey: "channels.imessage.allowFrom",
getCurrent: (cfg) => cfg.channels?.imessage?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setIMessageDmPolicy(cfg, policy),
promptAllowFrom: promptIMessageAllowFrom,
};
export const imessageOnboardingAdapter: ChannelOnboardingAdapter = {
-104
View File
@@ -9,8 +9,6 @@ import {
resolveSignalAccount,
} from "../../../signal/accounts.js";
import { formatDocsLink } from "../../../terminal/links.js";
import { normalizeE164 } from "../../../utils.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
import { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
@@ -32,107 +30,6 @@ function setSignalDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
};
}
function setSignalAllowFrom(
cfg: ClawdbotConfig,
accountId: string,
allowFrom: string[],
): ClawdbotConfig {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
signal: {
...cfg.channels?.signal,
allowFrom,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
signal: {
...cfg.channels?.signal,
accounts: {
...cfg.channels?.signal?.accounts,
[accountId]: {
...cfg.channels?.signal?.accounts?.[accountId],
allowFrom,
},
},
},
},
};
}
function parseSignalAllowFromInput(raw: string): string[] {
return raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
function isUuidLike(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
}
async function promptSignalAllowFrom(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<ClawdbotConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID
: resolveDefaultSignalAccountId(params.cfg);
const resolved = resolveSignalAccount({ cfg: params.cfg, accountId });
const existing = resolved.config.allowFrom ?? [];
await params.prompter.note(
[
"Allowlist Signal DMs by sender id.",
"Examples:",
"- +15555550123",
"- uuid:123e4567-e89b-12d3-a456-426614174000",
"Multiple entries: comma-separated.",
`Docs: ${formatDocsLink("/signal", "signal")}`,
].join("\n"),
"Signal allowlist",
);
const entry = await params.prompter.text({
message: "Signal allowFrom (E.164 or uuid)",
placeholder: "+15555550123, uuid:123e4567-e89b-12d3-a456-426614174000",
initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
const parts = parseSignalAllowFromInput(raw);
for (const part of parts) {
if (part === "*") continue;
if (part.toLowerCase().startsWith("uuid:")) {
if (!part.slice("uuid:".length).trim()) return "Invalid uuid entry";
continue;
}
if (isUuidLike(part)) continue;
if (!normalizeE164(part)) return `Invalid entry: ${part}`;
}
return undefined;
},
});
const parts = parseSignalAllowFromInput(String(entry));
const normalized = parts
.map((part) => {
if (part === "*") return "*";
if (part.toLowerCase().startsWith("uuid:")) return `uuid:${part.slice(5).trim()}`;
if (isUuidLike(part)) return `uuid:${part}`;
return normalizeE164(part);
})
.filter(Boolean);
const unique = [...new Set(normalized)];
return setSignalAllowFrom(params.cfg, accountId, unique);
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Signal",
channel,
@@ -140,7 +37,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
allowFromKey: "channels.signal.allowFrom",
getCurrent: (cfg) => cfg.channels?.signal?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setSignalDmPolicy(cfg, policy),
promptAllowFrom: promptSignalAllowFrom,
};
export const signalOnboardingAdapter: ChannelOnboardingAdapter = {
-107
View File
@@ -7,7 +7,6 @@ import {
resolveSlackAccount,
} from "../../../slack/accounts.js";
import { resolveSlackChannelAllowlist } from "../../../slack/resolve-channels.js";
import { resolveSlackUserAllowlist } from "../../../slack/resolve-users.js";
import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
@@ -201,111 +200,6 @@ function setSlackChannelAllowlist(
};
}
function setSlackAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
return {
...cfg,
channels: {
...cfg.channels,
slack: {
...cfg.channels?.slack,
dm: {
...cfg.channels?.slack?.dm,
enabled: cfg.channels?.slack?.dm?.enabled ?? true,
allowFrom,
},
},
},
};
}
function parseSlackAllowFromInput(raw: string): string[] {
return raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
async function promptSlackAllowFrom(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<ClawdbotConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID
: resolveDefaultSlackAccountId(params.cfg);
const resolved = resolveSlackAccount({ cfg: params.cfg, accountId });
const token = resolved.config.userToken ?? resolved.config.botToken ?? "";
const existing = params.cfg.channels?.slack?.dm?.allowFrom ?? [];
await params.prompter.note(
[
"Allowlist Slack DMs by username (we resolve to user ids).",
"Examples:",
"- U12345678",
"- @alice",
"Multiple entries: comma-separated.",
`Docs: ${formatDocsLink("/slack", "slack")}`,
].join("\n"),
"Slack allowlist",
);
const parseInputs = (value: string) => parseSlackAllowFromInput(value);
const parseId = (value: string) => {
const trimmed = value.trim();
if (!trimmed) return null;
const mention = trimmed.match(/^<@([A-Z0-9]+)>$/i);
if (mention) return mention[1]?.toUpperCase();
const prefixed = trimmed.replace(/^(slack:|user:)/i, "");
if (/^[A-Z][A-Z0-9]+$/i.test(prefixed)) return prefixed.toUpperCase();
return null;
};
while (true) {
const entry = await params.prompter.text({
message: "Slack allowFrom (usernames or ids)",
placeholder: "@alice, U12345678",
initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = parseInputs(String(entry));
if (!token) {
const ids = parts.map(parseId).filter(Boolean) as string[];
if (ids.length !== parts.length) {
await params.prompter.note(
"Slack token missing; use user ids (or mention form) only.",
"Slack allowlist",
);
continue;
}
const unique = [...new Set([...existing.map((v) => String(v).trim()), ...ids])].filter(
Boolean,
);
return setSlackAllowFrom(params.cfg, unique);
}
const results = await resolveSlackUserAllowlist({
token,
entries: parts,
}).catch(() => null);
if (!results) {
await params.prompter.note("Failed to resolve usernames. Try again.", "Slack allowlist");
continue;
}
const unresolved = results.filter((res) => !res.resolved || !res.id);
if (unresolved.length > 0) {
await params.prompter.note(
`Could not resolve: ${unresolved.map((res) => res.input).join(", ")}`,
"Slack allowlist",
);
continue;
}
const ids = results.map((res) => res.id as string);
const unique = [
...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]),
];
return setSlackAllowFrom(params.cfg, unique);
}
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Slack",
channel,
@@ -313,7 +207,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
allowFromKey: "channels.slack.dm.allowFrom",
getCurrent: (cfg) => cfg.channels?.slack?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) => setSlackDmPolicy(cfg, policy),
promptAllowFrom: promptSlackAllowFrom,
};
export const slackOnboardingAdapter: ChannelOnboardingAdapter = {
+13 -68
View File
@@ -65,59 +65,21 @@ async function promptTelegramAllowFrom(params: {
const resolved = resolveTelegramAccount({ cfg, accountId });
const existingAllowFrom = resolved.config.allowFrom ?? [];
await noteTelegramUserIdHelp(prompter);
const token = resolved.token;
if (!token) {
await prompter.note("Telegram token missing; username lookup is unavailable.", "Telegram");
}
const resolveTelegramUserId = async (raw: string): Promise<string | null> => {
const trimmed = raw.trim();
if (!trimmed) return null;
const stripped = trimmed.replace(/^(telegram|tg):/i, "").trim();
if (/^\d+$/.test(stripped)) return stripped;
if (!token) return null;
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
const url = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(username)}`;
const res = await fetch(url);
const data = (await res.json().catch(() => null)) as
| { ok?: boolean; result?: { id?: number | string } }
| null;
const id = data?.ok ? data?.result?.id : undefined;
if (typeof id === "number" || typeof id === "string") return String(id);
return null;
};
const parseInput = (value: string) =>
value
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
let resolvedIds: string[] = [];
while (resolvedIds.length === 0) {
const entry = await prompter.text({
message: "Telegram allowFrom (username or user id)",
placeholder: "@username",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = parseInput(String(entry));
const results = await Promise.all(parts.map((part) => resolveTelegramUserId(part)));
const unresolved = parts.filter((_, idx) => !results[idx]);
if (unresolved.length > 0) {
await prompter.note(
`Could not resolve: ${unresolved.join(", ")}. Use @username or numeric id.`,
"Telegram allowlist",
);
continue;
}
resolvedIds = results.filter(Boolean) as string[];
}
const entry = await prompter.text({
message: "Telegram allowFrom (user id)",
placeholder: "123456789",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
if (!/^\d+$/.test(raw)) return "Use a numeric Telegram user id";
return undefined;
},
});
const normalized = String(entry).trim();
const merged = [
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
...resolvedIds,
normalized,
];
const unique = [...new Set(merged)];
@@ -157,22 +119,6 @@ async function promptTelegramAllowFrom(params: {
};
}
async function promptTelegramAllowFromForAccount(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<ClawdbotConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID
: resolveDefaultTelegramAccountId(params.cfg);
return promptTelegramAllowFrom({
cfg: params.cfg,
prompter: params.prompter,
accountId,
});
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Telegram",
channel,
@@ -180,7 +126,6 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
allowFromKey: "channels.telegram.allowFrom",
getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setTelegramDmPolicy(cfg, policy),
promptAllowFrom: promptTelegramAllowFromForAccount,
};
export const telegramOnboardingAdapter: ChannelOnboardingAdapter = {
-83
View File
@@ -1,83 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildParseArgv,
getFlagValue,
getCommandPath,
getPrimaryCommand,
hasHelpOrVersion,
hasFlag,
} from "./argv.js";
describe("argv helpers", () => {
it("detects help/version flags", () => {
expect(hasHelpOrVersion(["node", "clawdbot", "--help"])).toBe(true);
expect(hasHelpOrVersion(["node", "clawdbot", "-V"])).toBe(true);
expect(hasHelpOrVersion(["node", "clawdbot", "status"])).toBe(false);
});
it("extracts command path ignoring flags and terminator", () => {
expect(getCommandPath(["node", "clawdbot", "status", "--json"], 2)).toEqual([
"status",
]);
expect(getCommandPath(["node", "clawdbot", "agents", "list"], 2)).toEqual([
"agents",
"list",
]);
expect(getCommandPath(["node", "clawdbot", "status", "--", "ignored"], 2)).toEqual([
"status",
]);
});
it("returns primary command", () => {
expect(getPrimaryCommand(["node", "clawdbot", "agents", "list"])).toBe("agents");
expect(getPrimaryCommand(["node", "clawdbot"])).toBeNull();
});
it("parses boolean flags and ignores terminator", () => {
expect(hasFlag(["node", "clawdbot", "status", "--json"], "--json")).toBe(true);
expect(hasFlag(["node", "clawdbot", "--", "--json"], "--json")).toBe(false);
});
it("extracts flag values with equals and missing values", () => {
expect(getFlagValue(["node", "clawdbot", "status", "--timeout", "5000"], "--timeout")).toBe(
"5000",
);
expect(getFlagValue(["node", "clawdbot", "status", "--timeout=2500"], "--timeout")).toBe(
"2500",
);
expect(getFlagValue(["node", "clawdbot", "status", "--timeout"], "--timeout")).toBeNull();
expect(getFlagValue(["node", "clawdbot", "status", "--timeout", "--json"], "--timeout")).toBe(
null,
);
expect(getFlagValue(["node", "clawdbot", "--", "--timeout=99"], "--timeout")).toBeUndefined();
});
it("builds parse argv from raw args", () => {
const nodeArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["node", "clawdbot", "status"],
});
expect(nodeArgv).toEqual(["node", "clawdbot", "status"]);
const directArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["clawdbot", "status"],
});
expect(directArgv).toEqual(["node", "clawdbot", "status"]);
const bunArgv = buildParseArgv({
programName: "clawdbot",
rawArgs: ["bun", "src/entry.ts", "status"],
});
expect(bunArgv).toEqual(["bun", "src/entry.ts", "status"]);
});
it("builds parse argv from fallback args", () => {
const fallbackArgv = buildParseArgv({
programName: "clawdbot",
fallbackArgv: ["status"],
});
expect(fallbackArgv).toEqual(["node", "clawdbot", "status"]);
});
});
-94
View File
@@ -1,94 +0,0 @@
const HELP_FLAGS = new Set(["-h", "--help"]);
const VERSION_FLAGS = new Set(["-v", "-V", "--version"]);
const FLAG_TERMINATOR = "--";
export function hasHelpOrVersion(argv: string[]): boolean {
return argv.some((arg) => HELP_FLAGS.has(arg) || VERSION_FLAGS.has(arg));
}
function isValueToken(arg: string | undefined): boolean {
if (!arg) return false;
if (arg === FLAG_TERMINATOR) return false;
if (!arg.startsWith("-")) return true;
return /^-\d+(?:\.\d+)?$/.test(arg);
}
export function hasFlag(argv: string[], name: string): boolean {
const args = argv.slice(2);
for (const arg of args) {
if (arg === FLAG_TERMINATOR) break;
if (arg === name) return true;
}
return false;
}
export function getFlagValue(argv: string[], name: string): string | null | undefined {
const args = argv.slice(2);
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === FLAG_TERMINATOR) break;
if (arg === name) {
const next = args[i + 1];
return isValueToken(next) ? next : null;
}
if (arg.startsWith(`${name}=`)) {
const value = arg.slice(name.length + 1);
return value ? value : null;
}
}
return undefined;
}
export function getCommandPath(argv: string[], depth = 2): string[] {
const args = argv.slice(2);
const path: string[] = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (!arg) continue;
if (arg === "--") break;
if (arg.startsWith("-")) continue;
path.push(arg);
if (path.length >= depth) break;
}
return path;
}
export function getPrimaryCommand(argv: string[]): string | null {
const [primary] = getCommandPath(argv, 1);
return primary ?? null;
}
export function buildParseArgv(params: {
programName?: string;
rawArgs?: string[];
fallbackArgv?: string[];
}): string[] {
const baseArgv =
params.rawArgs && params.rawArgs.length > 0
? params.rawArgs
: params.fallbackArgv && params.fallbackArgv.length > 0
? params.fallbackArgv
: process.argv;
const programName = params.programName ?? "";
const normalizedArgv =
programName && baseArgv[0] === programName
? baseArgv.slice(1)
: baseArgv[0]?.endsWith("clawdbot")
? baseArgv.slice(1)
: baseArgv;
const executable = normalizedArgv[0]?.split(/[/\\]/).pop() ?? "";
const looksLikeNode =
normalizedArgv.length >= 2 && (executable === "node" || executable === "bun");
if (looksLikeNode) return normalizedArgv;
return ["node", programName || "clawdbot", ...normalizedArgv];
}
export function isReadOnlyCommand(argv: string[]): boolean {
const path = getCommandPath(argv, 2);
if (path.length === 0) return false;
const [primary, secondary] = path;
if (primary === "health" || primary === "status" || primary === "sessions") return true;
if (primary === "memory" && secondary === "status") return true;
if (primary === "agents" && secondary === "list") return true;
return false;
}
+4 -3
View File
@@ -14,13 +14,14 @@ import {
import { browserAct } from "../browser/client-actions-core.js";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { parseBooleanValue } from "../utils/boolean.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
import { registerBrowserCookiesAndStorageCommands } from "./browser-cli-state.cookies-storage.js";
function parseOnOff(raw: string): boolean | null {
const parsed = parseBooleanValue(raw);
return parsed === undefined ? null : parsed;
const v = raw.trim().toLowerCase();
if (v === "on" || v === "true" || v === "1") return true;
if (v === "off" || v === "false" || v === "0") return false;
return null;
}
export function registerBrowserStateCommands(
-16
View File
@@ -1,16 +0,0 @@
import { CHAT_CHANNEL_ORDER } from "../channels/registry.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { ensurePluginRegistryLoaded } from "./plugin-registry.js";
export function resolveCliChannelOptions(): string[] {
if (isTruthyEnvValue(process.env.CLAWDBOT_EAGER_CHANNEL_OPTIONS)) {
ensurePluginRegistryLoaded();
return listChannelPlugins().map((plugin) => plugin.id);
}
return [...CHAT_CHANNEL_ORDER];
}
export function formatCliChannelOptions(extra: string[] = []): string {
return [...extra, ...resolveCliChannelOptions()].join("|");
}
+2 -10
View File
@@ -17,10 +17,6 @@ vi.mock("../agents/agent-scope.js", () => ({
resolveDefaultAgentId,
}));
vi.mock("./program/config-guard.js", () => ({
ensureConfigReady: vi.fn(async () => {}),
}));
afterEach(async () => {
vi.restoreAllMocks();
getMemorySearchManager.mockReset();
@@ -270,9 +266,7 @@ describe("memory cli", () => {
registerMemoryCli(program);
await program.parseAsync(["memory", "index"], { from: "user" });
expect(sync).toHaveBeenCalledWith(
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
);
expect(sync).toHaveBeenCalledWith({ reason: "cli", force: false });
expect(close).toHaveBeenCalled();
expect(log).toHaveBeenCalledWith("Memory index updated (main).");
});
@@ -297,9 +291,7 @@ describe("memory cli", () => {
registerMemoryCli(program);
await program.parseAsync(["memory", "index"], { from: "user" });
expect(sync).toHaveBeenCalledWith(
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
);
expect(sync).toHaveBeenCalledWith({ reason: "cli", force: false });
expect(close).toHaveBeenCalled();
expect(error).toHaveBeenCalledWith(
expect.stringContaining("Memory manager close failed: close boom"),
+185 -252
View File
@@ -13,7 +13,6 @@ import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import { resolveStateDir } from "../config/paths.js";
import { ensureConfigReady } from "./program/config-guard.js";
type MemoryCommandOptions = {
agent?: string;
@@ -52,194 +51,6 @@ function resolveAgentIds(cfg: ReturnType<typeof loadConfig>, agent?: string): st
return [resolveDefaultAgentId(cfg)];
}
export async function runMemoryStatus(opts: MemoryCommandOptions) {
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
setVerbose(Boolean(opts.verbose));
const cfg = loadConfig();
const agentIds = resolveAgentIds(cfg, opts.agent);
const allResults: Array<{
agentId: string;
status: ReturnType<MemoryManager["status"]>;
embeddingProbe?: Awaited<ReturnType<MemoryManager["probeEmbeddingAvailability"]>>;
indexError?: string;
}> = [];
for (const agentId of agentIds) {
await withManager<MemoryManager>({
getManager: () => getMemorySearchManager({ cfg, agentId }),
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
onCloseError: (err) =>
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
close: (manager) => manager.close(),
run: async (manager) => {
const deep = Boolean(opts.deep || opts.index);
let embeddingProbe:
| Awaited<ReturnType<typeof manager.probeEmbeddingAvailability>>
| undefined;
let indexError: string | undefined;
if (deep) {
await withProgress({ label: "Checking memory…", total: 2 }, async (progress) => {
progress.setLabel("Probing vector…");
await manager.probeVectorAvailability();
progress.tick();
progress.setLabel("Probing embeddings…");
embeddingProbe = await manager.probeEmbeddingAvailability();
progress.tick();
});
if (opts.index) {
await withProgressTotals(
{
label: "Indexing memory…",
total: 0,
fallback: opts.verbose ? "line" : undefined,
},
async (update, progress) => {
try {
await manager.sync({
reason: "cli",
progress: (syncUpdate) => {
update({
completed: syncUpdate.completed,
total: syncUpdate.total,
label: syncUpdate.label,
});
if (syncUpdate.label) progress.setLabel(syncUpdate.label);
},
});
} catch (err) {
indexError = formatErrorMessage(err);
defaultRuntime.error(`Memory index failed: ${indexError}`);
process.exitCode = 1;
}
},
);
}
} else {
await manager.probeVectorAvailability();
}
const status = manager.status();
allResults.push({ agentId, status, embeddingProbe, indexError });
},
});
}
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)}`);
}
}
if (status.sourceCounts?.length) {
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) {
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
}
if (status.vector) {
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) {
lines.push(`${label("Vector path")} ${info(status.vector.extensionPath)}`);
}
if (status.vector.loadError) {
lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`);
}
}
if (status.fts) {
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.cache) {
const cacheState = status.cache.enabled ? "enabled" : "disabled";
const cacheColor = status.cache.enabled ? theme.success : theme.muted;
const suffix =
status.cache.enabled && typeof status.cache.entries === "number"
? ` (${status.cache.entries} entries)`
: "";
lines.push(
`${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState)}${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"));
defaultRuntime.log("");
}
}
export function registerMemoryCli(program: Command) {
const memory = program
.command("memory")
@@ -259,7 +70,188 @@ export function registerMemoryCli(program: Command) {
.option("--index", "Reindex if dirty (implies --deep)")
.option("--verbose", "Verbose logging", false)
.action(async (opts: MemoryCommandOptions) => {
await runMemoryStatus(opts);
setVerbose(Boolean(opts.verbose));
const cfg = loadConfig();
const agentIds = resolveAgentIds(cfg, opts.agent);
const allResults: Array<{
agentId: string;
status: ReturnType<MemoryManager["status"]>;
embeddingProbe?: Awaited<ReturnType<MemoryManager["probeEmbeddingAvailability"]>>;
indexError?: string;
}> = [];
for (const agentId of agentIds) {
await withManager<MemoryManager>({
getManager: () => getMemorySearchManager({ cfg, agentId }),
onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."),
onCloseError: (err) =>
defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`),
close: (manager) => manager.close(),
run: async (manager) => {
const deep = Boolean(opts.deep || opts.index);
let embeddingProbe:
| Awaited<ReturnType<typeof manager.probeEmbeddingAvailability>>
| undefined;
let indexError: string | undefined;
if (deep) {
await withProgress({ label: "Checking memory…", total: 2 }, async (progress) => {
progress.setLabel("Probing vector…");
await manager.probeVectorAvailability();
progress.tick();
progress.setLabel("Probing embeddings…");
embeddingProbe = await manager.probeEmbeddingAvailability();
progress.tick();
});
if (opts.index) {
await withProgressTotals(
{
label: "Indexing memory…",
total: 0,
fallback: opts.verbose ? "line" : undefined,
},
async (update, progress) => {
try {
await manager.sync({
reason: "cli",
progress: (syncUpdate) => {
update({
completed: syncUpdate.completed,
total: syncUpdate.total,
label: syncUpdate.label,
});
if (syncUpdate.label) progress.setLabel(syncUpdate.label);
},
});
} catch (err) {
indexError = formatErrorMessage(err);
defaultRuntime.error(`Memory index failed: ${indexError}`);
process.exitCode = 1;
}
},
);
}
} else {
await manager.probeVectorAvailability();
}
const status = manager.status();
allResults.push({ agentId, status, embeddingProbe, indexError });
},
});
}
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)}`);
}
}
if (status.sourceCounts?.length) {
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) {
lines.push(`${label("Fallback")} ${warn(status.fallback.from)}`);
}
if (status.vector) {
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) {
lines.push(`${label("Vector path")} ${info(status.vector.extensionPath)}`);
}
if (status.vector.loadError) {
lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`);
}
}
if (status.fts) {
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.cache) {
const cacheState = status.cache.enabled ? "enabled" : "disabled";
const cacheColor = status.cache.enabled ? theme.success : theme.muted;
const suffix =
status.cache.enabled && typeof status.cache.entries === "number"
? ` (${status.cache.entries} entries)`
: "";
lines.push(
`${label("Embedding cache")} ${colorize(rich, cacheColor, cacheState)}${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"));
if (agentIds.length > 1) defaultRuntime.log("");
}
});
memory
@@ -308,66 +300,7 @@ export function registerMemoryCli(program: Command) {
defaultRuntime.log(lines.join("\n"));
defaultRuntime.log("");
}
const startedAt = Date.now();
let lastLabel = "Indexing memory…";
let lastCompleted = 0;
let lastTotal = 0;
const formatElapsed = () => {
const elapsedMs = Math.max(0, Date.now() - startedAt);
const seconds = Math.floor(elapsedMs / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
};
const formatEta = () => {
if (lastTotal <= 0 || lastCompleted <= 0) return null;
const elapsedMs = Math.max(1, Date.now() - startedAt);
const rate = lastCompleted / elapsedMs;
if (!Number.isFinite(rate) || rate <= 0) return null;
const remainingMs = Math.max(0, (lastTotal - lastCompleted) / rate);
const seconds = Math.floor(remainingMs / 1000);
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
};
const buildLabel = () => {
const elapsed = formatElapsed();
const eta = formatEta();
return eta
? `${lastLabel} · elapsed ${elapsed} · eta ${eta}`
: `${lastLabel} · elapsed ${elapsed}`;
};
await withProgressTotals(
{
label: "Indexing memory…",
total: 0,
fallback: opts.verbose ? "line" : undefined,
},
async (update, progress) => {
const interval = setInterval(() => {
progress.setLabel(buildLabel());
}, 1000);
try {
await manager.sync({
reason: "cli",
force: opts.force,
progress: (syncUpdate) => {
if (syncUpdate.label) lastLabel = syncUpdate.label;
lastCompleted = syncUpdate.completed;
lastTotal = syncUpdate.total;
update({
completed: syncUpdate.completed,
total: syncUpdate.total,
label: buildLabel(),
});
progress.setLabel(buildLabel());
},
});
} finally {
clearInterval(interval);
}
},
);
await manager.sync({ reason: "cli", force: opts.force });
defaultRuntime.log(`Memory index updated (${agentId}).`);
} catch (err) {
const message = formatErrorMessage(err);
@@ -384,8 +317,8 @@ export function registerMemoryCli(program: Command) {
.description("Search memory files")
.argument("<query>", "Search query")
.option("--agent <id>", "Agent id (default: default agent)")
.option("--max-results <n>", "Max results", (value: string) => Number(value))
.option("--min-score <n>", "Minimum score", (value: string) => Number(value))
.option("--max-results <n>", "Max results", (v) => Number(v))
.option("--min-score <n>", "Minimum score", (v) => Number(v))
.option("--json", "Print JSON")
.action(
async (
-26
View File
@@ -1,26 +0,0 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging.js";
import { loadClawdbotPlugins } from "../plugins/loader.js";
import type { PluginLogger } from "../plugins/types.js";
const log = createSubsystemLogger("plugins");
let pluginRegistryLoaded = false;
export function ensurePluginRegistryLoaded(): void {
if (pluginRegistryLoaded) return;
const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const logger: PluginLogger = {
info: (msg) => log.info(msg),
warn: (msg) => log.warn(msg),
error: (msg) => log.error(msg),
debug: (msg) => log.debug(msg),
};
loadClawdbotPlugins({
config,
workspaceDir,
logger,
});
pluginRegistryLoaded = true;
}
+1 -2
View File
@@ -16,7 +16,6 @@ import { registerSubCliCommands } from "./register.subclis.js";
export function buildProgram() {
const program = new Command();
const ctx = createProgramContext();
const argv = process.argv;
configureProgramHelp(program, ctx);
registerPreActionHooks(program, ctx.programVersion);
@@ -30,7 +29,7 @@ export function buildProgram() {
registerAgentCommands(program, {
agentChannelOptions: ctx.agentChannelOptions,
});
registerSubCliCommands(program, argv);
registerSubCliCommands(program);
registerStatusHealthSessionsCommands(program);
registerBrowserCli(program);
-65
View File
@@ -1,65 +0,0 @@
import {
isNixMode,
loadConfig,
migrateLegacyConfig,
readConfigFileSnapshot,
writeConfigFile,
} from "../../config/config.js";
import { danger } from "../../globals.js";
import { autoMigrateLegacyState } from "../../infra/state-migrations.js";
import type { RuntimeEnv } from "../../runtime.js";
export async function ensureConfigReady(params: {
runtime: RuntimeEnv;
migrateState?: boolean;
}): Promise<void> {
const snapshot = await readConfigFileSnapshot();
if (snapshot.legacyIssues.length > 0) {
if (isNixMode) {
params.runtime.error(
danger(
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and retry.",
),
);
params.runtime.exit(1);
return;
}
const migrated = migrateLegacyConfig(snapshot.parsed);
if (migrated.config) {
await writeConfigFile(migrated.config);
if (migrated.changes.length > 0) {
params.runtime.log(
`Migrated legacy config entries:\n${migrated.changes
.map((entry) => `- ${entry}`)
.join("\n")}`,
);
}
} else {
const issues = snapshot.legacyIssues
.map((issue) => `- ${issue.path}: ${issue.message}`)
.join("\n");
params.runtime.error(
danger(
`Legacy config entries detected. Run "clawdbot doctor" (or ask your agent) to migrate.\n${issues}`,
),
);
params.runtime.exit(1);
return;
}
}
if (snapshot.exists && !snapshot.valid) {
params.runtime.error(`Config invalid at ${snapshot.path}.`);
for (const issue of snapshot.issues) {
params.runtime.error(`- ${issue.path || "<root>"}: ${issue.message}`);
}
params.runtime.error("Run `clawdbot doctor` to repair, then retry.");
params.runtime.exit(1);
return;
}
if (params.migrateState !== false) {
const cfg = loadConfig();
await autoMigrateLegacyState({ cfg });
}
}
+24 -2
View File
@@ -1,5 +1,9 @@
import { listChannelPlugins } from "../../channels/plugins/index.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { loadConfig } from "../../config/config.js";
import { createSubsystemLogger } from "../../logging.js";
import { loadClawdbotPlugins } from "../../plugins/loader.js";
import { VERSION } from "../../version.js";
import { resolveCliChannelOptions } from "../channel-options.js";
export type ProgramContext = {
programVersion: string;
@@ -8,8 +12,26 @@ export type ProgramContext = {
agentChannelOptions: string;
};
const log = createSubsystemLogger("plugins");
function primePluginRegistry() {
const config = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
loadClawdbotPlugins({
config,
workspaceDir,
logger: {
info: (msg) => log.info(msg),
warn: (msg) => log.warn(msg),
error: (msg) => log.error(msg),
debug: (msg) => log.debug(msg),
},
});
}
export function createProgramContext(): ProgramContext {
const channelOptions = resolveCliChannelOptions();
primePluginRegistry();
const channelOptions = listChannelPlugins().map((plugin) => plugin.id);
return {
programVersion: VERSION,
channelOptions,
-6
View File
@@ -16,9 +16,3 @@ export function parsePositiveIntOrUndefined(value: unknown): number | undefined
}
return undefined;
}
export function resolveActionArgs(actionCommand?: import("commander").Command): string[] {
if (!actionCommand) return [];
const args = (actionCommand as import("commander").Command & { args?: string[] }).args;
return Array.isArray(args) ? args : [];
}
-2
View File
@@ -4,7 +4,6 @@ import { danger, setVerbose } from "../../../globals.js";
import { CHANNEL_TARGET_DESCRIPTION } from "../../../infra/outbound/channel-target.js";
import { defaultRuntime } from "../../../runtime.js";
import { createDefaultDeps } from "../../deps.js";
import { ensureConfigReady } from "../config-guard.js";
export type MessageCliHelpers = {
withMessageBase: (command: Command) => Command;
@@ -31,7 +30,6 @@ export function createMessageCliHelpers(
command.requiredOption("-t, --target <dest>", CHANNEL_TARGET_DESCRIPTION);
const runMessageAction = async (action: string, opts: Record<string, unknown>) => {
await ensureConfigReady({ runtime: defaultRuntime, migrateState: true });
setVerbose(Boolean(opts.verbose));
const deps = createDefaultDeps();
try {
+56 -8
View File
@@ -1,8 +1,15 @@
import type { Command } from "commander";
import {
isNixMode,
loadConfig,
migrateLegacyConfig,
readConfigFileSnapshot,
writeConfigFile,
} from "../../config/config.js";
import { danger } from "../../globals.js";
import { autoMigrateLegacyState } from "../../infra/state-migrations.js";
import { defaultRuntime } from "../../runtime.js";
import { emitCliBanner } from "../banner.js";
import { getCommandPath, hasHelpOrVersion, isReadOnlyCommand } from "../argv.js";
import { ensureConfigReady } from "./config-guard.js";
function setProcessTitleForCommand(actionCommand: Command) {
let current: Command = actionCommand;
@@ -18,11 +25,52 @@ export function registerPreActionHooks(program: Command, programVersion: string)
program.hook("preAction", async (_thisCommand, actionCommand) => {
setProcessTitleForCommand(actionCommand);
emitCliBanner(programVersion);
const argv = process.argv;
if (hasHelpOrVersion(argv)) return;
const [primary] = getCommandPath(argv, 1);
if (primary === "doctor") return;
const migrateState = !isReadOnlyCommand(argv);
await ensureConfigReady({ runtime: defaultRuntime, migrateState });
if (actionCommand.name() === "doctor") return;
const snapshot = await readConfigFileSnapshot();
if (snapshot.legacyIssues.length === 0) return;
if (isNixMode) {
defaultRuntime.error(
danger(
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and retry.",
),
);
process.exit(1);
}
const migrated = migrateLegacyConfig(snapshot.parsed);
if (migrated.config) {
await writeConfigFile(migrated.config);
if (migrated.changes.length > 0) {
defaultRuntime.log(
`Migrated legacy config entries:\n${migrated.changes
.map((entry) => `- ${entry}`)
.join("\n")}`,
);
}
return;
}
const issues = snapshot.legacyIssues
.map((issue) => `- ${issue.path}: ${issue.message}`)
.join("\n");
defaultRuntime.error(
danger(
`Legacy config entries detected. Run "clawdbot doctor" (or ask your agent) to migrate.\n${issues}`,
),
);
process.exit(1);
});
program.hook("preAction", async (_thisCommand, actionCommand) => {
if (actionCommand.name() === "doctor") return;
const snapshot = await readConfigFileSnapshot();
if (snapshot.exists && !snapshot.valid) {
defaultRuntime.error(`Config invalid at ${snapshot.path}.`);
for (const issue of snapshot.issues) {
defaultRuntime.error(`- ${issue.path || "<root>"}: ${issue.message}`);
}
defaultRuntime.error("Run `clawdbot doctor` to repair, then retry.");
process.exit(1);
}
const cfg = loadConfig();
await autoMigrateLegacyState({ cfg });
});
}
+1 -13
View File
@@ -9,7 +9,6 @@ import { theme } from "../../terminal/theme.js";
import { hasExplicitOptions } from "../command-options.js";
import { createDefaultDeps } from "../deps.js";
import { collectOption } from "./helpers.js";
import { ensureConfigReady } from "./config-guard.js";
export function registerAgentCommands(program: Command, args: { agentChannelOptions: string }) {
program
@@ -18,16 +17,12 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
.requiredOption("-m, --message <text>", "Message body for the agent")
.option("-t, --to <number>", "Recipient number in E.164 used to derive the session key")
.option("--session-id <id>", "Use an explicit session id")
.option("--agent <id>", "Agent id (overrides routing bindings)")
.option("--thinking <level>", "Thinking level: off | minimal | low | medium | high")
.option("--verbose <on|off>", "Persist agent verbose level for the session")
.option(
"--channel <channel>",
`Delivery channel: ${args.agentChannelOptions} (default: ${DEFAULT_CHAT_CHANNEL})`,
)
.option("--reply-to <target>", "Delivery target override (separate from session routing)")
.option("--reply-channel <channel>", "Delivery channel override (separate from routing)")
.option("--reply-account <id>", "Delivery account id override")
.option(
"--local",
"Run the embedded agent locally (requires model provider API keys in your shell)",
@@ -35,7 +30,7 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
)
.option(
"--deliver",
"Send the agent's reply back to the selected channel",
"Send the agent's reply back to the selected channel (requires --to)",
false,
)
.option("--json", "Output result as JSON", false)
@@ -49,16 +44,13 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
`
Examples:
clawdbot agent --to +15555550123 --message "status update"
clawdbot agent --agent ops --message "Summarize logs"
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
clawdbot agent --to +15555550123 --message "Trace logs" --verbose on --json
clawdbot agent --to +15555550123 --message "Summon reply" --deliver
clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent")}`,
)
.action(async (opts) => {
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
const verboseLevel = typeof opts.verbose === "string" ? opts.verbose.toLowerCase() : "";
setVerbose(verboseLevel === "on");
// Build default deps (keeps parity with other commands; future-proofing).
@@ -86,7 +78,6 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
.option("--json", "Output JSON instead of text", false)
.option("--bindings", "Include routing bindings", false)
.action(async (opts) => {
await ensureConfigReady({ runtime: defaultRuntime, migrateState: true });
try {
await agentsListCommand(
{ json: Boolean(opts.json), bindings: Boolean(opts.bindings) },
@@ -108,7 +99,6 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
.option("--non-interactive", "Disable prompts; requires --workspace", false)
.option("--json", "Output JSON summary", false)
.action(async (name, opts, command) => {
await ensureConfigReady({ runtime: defaultRuntime, migrateState: true });
try {
const hasFlags = hasExplicitOptions(command, [
"workspace",
@@ -142,7 +132,6 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
.option("--force", "Skip confirmation", false)
.option("--json", "Output JSON summary", false)
.action(async (id, opts) => {
await ensureConfigReady({ runtime: defaultRuntime, migrateState: true });
try {
await agentsDeleteCommand(
{
@@ -159,7 +148,6 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
});
agents.action(async () => {
await ensureConfigReady({ runtime: defaultRuntime, migrateState: true });
try {
await agentsListCommand({}, defaultRuntime);
} catch (err) {
@@ -7,7 +7,6 @@ import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { parsePositiveIntOrUndefined } from "./helpers.js";
import { ensureConfigReady } from "./config-guard.js";
export function registerStatusHealthSessionsCommands(program: Command) {
program
@@ -38,7 +37,6 @@ Examples:
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/status", "docs.clawd.bot/cli/status")}\n`,
)
.action(async (opts) => {
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
const verbose = Boolean(opts.verbose || opts.debug);
setVerbose(verbose);
const timeout = parsePositiveIntOrUndefined(opts.timeout);
@@ -78,7 +76,6 @@ Examples:
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/health", "docs.clawd.bot/cli/health")}\n`,
)
.action(async (opts) => {
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
const verbose = Boolean(opts.verbose || opts.debug);
setVerbose(verbose);
const timeout = parsePositiveIntOrUndefined(opts.timeout);
@@ -126,7 +123,6 @@ Shows token usage per session when the agent reports it; set agents.defaults.con
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/sessions", "docs.clawd.bot/cli/sessions")}\n`,
)
.action(async (opts) => {
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
setVerbose(Boolean(opts.verbose));
await sessionsCommand(
{
-81
View File
@@ -1,81 +0,0 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { acpAction, registerAcpCli } = vi.hoisted(() => {
const action = vi.fn();
const register = vi.fn((program: Command) => {
program.command("acp").action(action);
});
return { acpAction: action, registerAcpCli: register };
});
const { nodesAction, registerNodesCli } = vi.hoisted(() => {
const action = vi.fn();
const register = vi.fn((program: Command) => {
const nodes = program.command("nodes");
nodes.command("list").action(action);
});
return { nodesAction: action, registerNodesCli: register };
});
vi.mock("../acp-cli.js", () => ({ registerAcpCli }));
vi.mock("../nodes-cli.js", () => ({ registerNodesCli }));
const { registerSubCliCommands } = await import("./register.subclis.js");
describe("registerSubCliCommands", () => {
const originalArgv = process.argv;
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = { ...originalEnv };
delete process.env.CLAWDBOT_DISABLE_LAZY_SUBCOMMANDS;
registerAcpCli.mockClear();
acpAction.mockClear();
registerNodesCli.mockClear();
nodesAction.mockClear();
});
afterEach(() => {
process.argv = originalArgv;
process.env = { ...originalEnv };
});
it("registers only the primary placeholder and dispatches", async () => {
process.argv = ["node", "clawdbot", "acp"];
const program = new Command();
registerSubCliCommands(program, process.argv);
expect(program.commands.map((cmd) => cmd.name())).toEqual(["acp"]);
await program.parseAsync(process.argv);
expect(registerAcpCli).toHaveBeenCalledTimes(1);
expect(acpAction).toHaveBeenCalledTimes(1);
});
it("registers placeholders for all subcommands when no primary", () => {
process.argv = ["node", "clawdbot"];
const program = new Command();
registerSubCliCommands(program, process.argv);
const names = program.commands.map((cmd) => cmd.name());
expect(names).toContain("acp");
expect(names).toContain("gateway");
expect(registerAcpCli).not.toHaveBeenCalled();
});
it("re-parses argv for lazy subcommands", async () => {
process.argv = ["node", "clawdbot", "nodes", "list"];
const program = new Command();
program.name("clawdbot");
registerSubCliCommands(program, process.argv);
expect(program.commands.map((cmd) => cmd.name())).toEqual(["nodes"]);
await program.parseAsync(["nodes", "list"], { from: "user" });
expect(registerNodesCli).toHaveBeenCalledTimes(1);
expect(nodesAction).toHaveBeenCalledTimes(1);
});
});
+50 -267
View File
@@ -1,270 +1,53 @@
import type { Command } from "commander";
import type { ClawdbotConfig } from "../../config/config.js";
import { isTruthyEnvValue } from "../../infra/env.js";
import { buildParseArgv, getPrimaryCommand, hasHelpOrVersion } from "../argv.js";
import { resolveActionArgs } from "./helpers.js";
import { loadConfig } from "../../config/config.js";
import { registerPluginCliCommands } from "../../plugins/cli.js";
import { registerAcpCli } from "../acp-cli.js";
import { registerChannelsCli } from "../channels-cli.js";
import { registerCronCli } from "../cron-cli.js";
import { registerDaemonCli } from "../daemon-cli.js";
import { registerDnsCli } from "../dns-cli.js";
import { registerDirectoryCli } from "../directory-cli.js";
import { registerDocsCli } from "../docs-cli.js";
import { registerExecApprovalsCli } from "../exec-approvals-cli.js";
import { registerGatewayCli } from "../gateway-cli.js";
import { registerHooksCli } from "../hooks-cli.js";
import { registerWebhooksCli } from "../webhooks-cli.js";
import { registerLogsCli } from "../logs-cli.js";
import { registerModelsCli } from "../models-cli.js";
import { registerNodesCli } from "../nodes-cli.js";
import { registerNodeCli } from "../node-cli.js";
import { registerPairingCli } from "../pairing-cli.js";
import { registerPluginsCli } from "../plugins-cli.js";
import { registerSandboxCli } from "../sandbox-cli.js";
import { registerSecurityCli } from "../security-cli.js";
import { registerServiceCli } from "../service-cli.js";
import { registerSkillsCli } from "../skills-cli.js";
import { registerTuiCli } from "../tui-cli.js";
import { registerUpdateCli } from "../update-cli.js";
type SubCliRegistrar = (program: Command) => Promise<void> | void;
type SubCliEntry = {
name: string;
description: string;
register: SubCliRegistrar;
};
const shouldRegisterPrimaryOnly = (argv: string[]) => {
if (isTruthyEnvValue(process.env.CLAWDBOT_DISABLE_LAZY_SUBCOMMANDS)) return false;
if (hasHelpOrVersion(argv)) return false;
return true;
};
const shouldEagerRegisterSubcommands = (argv: string[]) => {
if (isTruthyEnvValue(process.env.CLAWDBOT_DISABLE_LAZY_SUBCOMMANDS)) return true;
if (hasHelpOrVersion(argv)) return true;
return false;
};
const loadConfig = async (): Promise<ClawdbotConfig> => {
const mod = await import("../../config/config.js");
return mod.loadConfig();
};
const entries: SubCliEntry[] = [
{
name: "acp",
description: "Agent Control Protocol tools",
register: async (program) => {
const mod = await import("../acp-cli.js");
mod.registerAcpCli(program);
},
},
{
name: "daemon",
description: "Manage the gateway daemon",
register: async (program) => {
const mod = await import("../daemon-cli.js");
mod.registerDaemonCli(program);
},
},
{
name: "gateway",
description: "Gateway control",
register: async (program) => {
const mod = await import("../gateway-cli.js");
mod.registerGatewayCli(program);
},
},
{
name: "service",
description: "Service helpers",
register: async (program) => {
const mod = await import("../service-cli.js");
mod.registerServiceCli(program);
},
},
{
name: "logs",
description: "Gateway logs",
register: async (program) => {
const mod = await import("../logs-cli.js");
mod.registerLogsCli(program);
},
},
{
name: "models",
description: "Model configuration",
register: async (program) => {
const mod = await import("../models-cli.js");
mod.registerModelsCli(program);
},
},
{
name: "approvals",
description: "Exec approvals",
register: async (program) => {
const mod = await import("../exec-approvals-cli.js");
mod.registerExecApprovalsCli(program);
},
},
{
name: "nodes",
description: "Node commands",
register: async (program) => {
const mod = await import("../nodes-cli.js");
mod.registerNodesCli(program);
},
},
{
name: "node",
description: "Node control",
register: async (program) => {
const mod = await import("../node-cli.js");
mod.registerNodeCli(program);
},
},
{
name: "sandbox",
description: "Sandbox tools",
register: async (program) => {
const mod = await import("../sandbox-cli.js");
mod.registerSandboxCli(program);
},
},
{
name: "tui",
description: "Terminal UI",
register: async (program) => {
const mod = await import("../tui-cli.js");
mod.registerTuiCli(program);
},
},
{
name: "cron",
description: "Cron scheduler",
register: async (program) => {
const mod = await import("../cron-cli.js");
mod.registerCronCli(program);
},
},
{
name: "dns",
description: "DNS helpers",
register: async (program) => {
const mod = await import("../dns-cli.js");
mod.registerDnsCli(program);
},
},
{
name: "docs",
description: "Docs helpers",
register: async (program) => {
const mod = await import("../docs-cli.js");
mod.registerDocsCli(program);
},
},
{
name: "hooks",
description: "Hooks tooling",
register: async (program) => {
const mod = await import("../hooks-cli.js");
mod.registerHooksCli(program);
},
},
{
name: "webhooks",
description: "Webhook helpers",
register: async (program) => {
const mod = await import("../webhooks-cli.js");
mod.registerWebhooksCli(program);
},
},
{
name: "pairing",
description: "Pairing helpers",
register: async (program) => {
const mod = await import("../pairing-cli.js");
mod.registerPairingCli(program);
},
},
{
name: "plugins",
description: "Plugin management",
register: async (program) => {
const mod = await import("../plugins-cli.js");
mod.registerPluginsCli(program);
const { registerPluginCliCommands } = await import("../../plugins/cli.js");
registerPluginCliCommands(program, await loadConfig());
},
},
{
name: "channels",
description: "Channel management",
register: async (program) => {
const mod = await import("../channels-cli.js");
mod.registerChannelsCli(program);
},
},
{
name: "directory",
description: "Directory commands",
register: async (program) => {
const mod = await import("../directory-cli.js");
mod.registerDirectoryCli(program);
},
},
{
name: "security",
description: "Security helpers",
register: async (program) => {
const mod = await import("../security-cli.js");
mod.registerSecurityCli(program);
},
},
{
name: "skills",
description: "Skills management",
register: async (program) => {
const mod = await import("../skills-cli.js");
mod.registerSkillsCli(program);
},
},
{
name: "update",
description: "CLI update helpers",
register: async (program) => {
const mod = await import("../update-cli.js");
mod.registerUpdateCli(program);
},
},
];
function removeCommand(program: Command, command: Command) {
const commands = program.commands as Command[];
const index = commands.indexOf(command);
if (index >= 0) {
commands.splice(index, 1);
}
}
function registerLazyCommand(program: Command, entry: SubCliEntry) {
const placeholder = program.command(entry.name).description(entry.description);
placeholder.allowUnknownOption(true);
placeholder.allowExcessArguments(true);
placeholder.action(async (...actionArgs) => {
removeCommand(program, placeholder);
await entry.register(program);
const actionCommand = actionArgs.at(-1) as Command | undefined;
const root = actionCommand?.parent ?? program;
const rawArgs = (root as Command & { rawArgs?: string[] }).rawArgs;
const actionArgsList = resolveActionArgs(actionCommand);
const fallbackArgv = actionCommand?.name()
? [actionCommand.name(), ...actionArgsList]
: actionArgsList;
const parseArgv = buildParseArgv({
programName: program.name(),
rawArgs,
fallbackArgv,
});
await program.parseAsync(parseArgv);
});
}
export function registerSubCliCommands(program: Command, argv: string[] = process.argv) {
if (shouldEagerRegisterSubcommands(argv)) {
for (const entry of entries) {
void entry.register(program);
}
return;
}
const primary = getPrimaryCommand(argv);
if (primary && shouldRegisterPrimaryOnly(argv)) {
const entry = entries.find((candidate) => candidate.name === primary);
if (entry) {
registerLazyCommand(program, entry);
return;
}
}
for (const candidate of entries) {
registerLazyCommand(program, candidate);
}
export function registerSubCliCommands(program: Command) {
registerAcpCli(program);
registerDaemonCli(program);
registerGatewayCli(program);
registerServiceCli(program);
registerLogsCli(program);
registerModelsCli(program);
registerExecApprovalsCli(program);
registerNodesCli(program);
registerNodeCli(program);
registerSandboxCli(program);
registerTuiCli(program);
registerCronCli(program);
registerDnsCli(program);
registerDocsCli(program);
registerHooksCli(program);
registerWebhooksCli(program);
registerPairingCli(program);
registerPluginsCli(program);
registerChannelsCli(program);
registerDirectoryCli(program);
registerSecurityCli(program);
registerSkillsCli(program);
registerUpdateCli(program);
registerPluginCliCommands(program, loadConfig());
}
-91
View File
@@ -1,91 +0,0 @@
import { defaultRuntime } from "../runtime.js";
import { setVerbose } from "../globals.js";
import { healthCommand } from "../commands/health.js";
import { statusCommand } from "../commands/status.js";
import { sessionsCommand } from "../commands/sessions.js";
import { agentsListCommand } from "../commands/agents.js";
import { ensurePluginRegistryLoaded } from "./plugin-registry.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { emitCliBanner } from "./banner.js";
import { VERSION } from "../version.js";
import { getCommandPath, getFlagValue, hasFlag, hasHelpOrVersion } from "./argv.js";
import { parsePositiveIntOrUndefined } from "./program/helpers.js";
import { ensureConfigReady } from "./program/config-guard.js";
import { runMemoryStatus } from "./memory-cli.js";
export async function tryRouteCli(argv: string[]): Promise<boolean> {
if (isTruthyEnvValue(process.env.CLAWDBOT_DISABLE_ROUTE_FIRST)) return false;
if (hasHelpOrVersion(argv)) return false;
const path = getCommandPath(argv, 2);
const [primary, secondary] = path;
if (!primary) return false;
if (primary === "health") {
emitCliBanner(VERSION, { argv });
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
ensurePluginRegistryLoaded();
const json = hasFlag(argv, "--json");
const verbose = hasFlag(argv, "--verbose") || hasFlag(argv, "--debug");
const timeout = getFlagValue(argv, "--timeout");
if (timeout === null) return false;
const timeoutMs = parsePositiveIntOrUndefined(timeout);
setVerbose(verbose);
await healthCommand({ json, timeoutMs, verbose }, defaultRuntime);
return true;
}
if (primary === "status") {
emitCliBanner(VERSION, { argv });
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
ensurePluginRegistryLoaded();
const json = hasFlag(argv, "--json");
const deep = hasFlag(argv, "--deep");
const all = hasFlag(argv, "--all");
const usage = hasFlag(argv, "--usage");
const verbose = hasFlag(argv, "--verbose") || hasFlag(argv, "--debug");
const timeout = getFlagValue(argv, "--timeout");
if (timeout === null) return false;
const timeoutMs = parsePositiveIntOrUndefined(timeout);
setVerbose(verbose);
await statusCommand({ json, deep, all, usage, timeoutMs, verbose }, defaultRuntime);
return true;
}
if (primary === "sessions") {
emitCliBanner(VERSION, { argv });
await ensureConfigReady({ runtime: defaultRuntime, migrateState: false });
const json = hasFlag(argv, "--json");
const verbose = hasFlag(argv, "--verbose");
const store = getFlagValue(argv, "--store");
if (store === null) return false;
const active = getFlagValue(argv, "--active");
if (active === null) return false;
setVerbose(verbose);
await sessionsCommand({ json, store, active }, defaultRuntime);
return true;
}
if (primary === "agents" && secondary === "list") {
emitCliBanner(VERSION, { argv });
await ensureConfigReady({ runtime: defaultRuntime, migrateState: true });
const json = hasFlag(argv, "--json");
const bindings = hasFlag(argv, "--bindings");
await agentsListCommand({ json, bindings }, defaultRuntime);
return true;
}
if (primary === "memory" && secondary === "status") {
emitCliBanner(VERSION, { argv });
const agent = getFlagValue(argv, "--agent");
if (agent === null) return false;
const json = hasFlag(argv, "--json");
const deep = hasFlag(argv, "--deep");
const index = hasFlag(argv, "--index");
const verbose = hasFlag(argv, "--verbose");
await runMemoryStatus({ agent, json, deep, index, verbose });
return true;
}
return false;
}
+3 -6
View File
@@ -8,7 +8,6 @@ import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { enableConsoleCapture } from "../logging.js";
import { tryRouteCli } from "./route.js";
export function rewriteUpdateFlagArgv(argv: string[]): string[] {
const index = argv.indexOf("--update");
@@ -24,14 +23,12 @@ export async function runCli(argv: string[] = process.argv) {
normalizeEnv();
ensureClawdbotCliOnPath();
// Enforce the minimum supported runtime before doing any work.
assertSupportedRuntime();
if (await tryRouteCli(argv)) return;
// Capture all console output into structured logs while keeping stdout/stderr behavior.
enableConsoleCapture();
// Enforce the minimum supported runtime before doing any work.
assertSupportedRuntime();
const { buildProgram } = await import("./program.js");
const program = buildProgram();
+29 -33
View File
@@ -2,10 +2,9 @@ import { DEFAULT_CHAT_CHANNEL } from "../channels/registry.js";
import type { CliDeps } from "../cli/deps.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig } from "../config/config.js";
import { resolveSessionKeyForRequest } from "./agent/session.js";
import { loadSessionStore, resolveSessionKey, resolveStorePath } from "../config/sessions.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { listAgentIds } from "../agents/agent-scope.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { normalizeMainKey } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import {
GATEWAY_CLIENT_MODES,
@@ -32,7 +31,6 @@ type GatewayAgentResponse = {
export type AgentCliOpts = {
message: string;
agent?: string;
to?: string;
sessionId?: string;
thinking?: string;
@@ -41,9 +39,6 @@ export type AgentCliOpts = {
timeout?: string;
deliver?: boolean;
channel?: string;
replyTo?: string;
replyChannel?: string;
replyAccount?: string;
bestEffortDeliver?: boolean;
lane?: string;
runId?: string;
@@ -51,6 +46,27 @@ export type AgentCliOpts = {
local?: boolean;
};
function resolveGatewaySessionKey(opts: {
cfg: ReturnType<typeof loadConfig>;
to?: string;
sessionId?: string;
}): string | undefined {
const sessionCfg = opts.cfg.session;
const scope = sessionCfg?.scope ?? "per-sender";
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
const storePath = resolveStorePath(sessionCfg?.store);
const store = loadSessionStore(storePath);
const ctx = opts.to?.trim() ? ({ From: opts.to } as { From: string }) : null;
let sessionKey: string | undefined = ctx ? resolveSessionKey(scope, ctx, mainKey) : undefined;
if (opts.sessionId && (!sessionKey || store[sessionKey]?.sessionId !== opts.sessionId)) {
const foundKey = Object.keys(store).find((key) => store[key]?.sessionId === opts.sessionId);
if (foundKey) sessionKey = foundKey;
}
return sessionKey;
}
function parseTimeoutSeconds(opts: { cfg: ReturnType<typeof loadConfig>; timeout?: string }) {
const raw =
@@ -82,30 +98,19 @@ function formatPayloadForLog(payload: {
export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: RuntimeEnv) {
const body = (opts.message ?? "").trim();
if (!body) throw new Error("Message (--message) is required");
if (!opts.to && !opts.sessionId && !opts.agent) {
throw new Error("Pass --to <E.164>, --session-id, or --agent to choose a session");
if (!opts.to && !opts.sessionId) {
throw new Error("Pass --to <E.164> or --session-id to choose a session");
}
const cfg = loadConfig();
const agentIdRaw = opts.agent?.trim();
const agentId = agentIdRaw ? normalizeAgentId(agentIdRaw) : undefined;
if (agentId) {
const knownAgents = listAgentIds(cfg);
if (!knownAgents.includes(agentId)) {
throw new Error(
`Unknown agent id "${agentIdRaw}". Use "clawdbot agents list" to see configured agents.`,
);
}
}
const timeoutSeconds = parseTimeoutSeconds({ cfg, timeout: opts.timeout });
const gatewayTimeoutMs = Math.max(10_000, (timeoutSeconds + 30) * 1000);
const sessionKey = resolveSessionKeyForRequest({
const sessionKey = resolveGatewaySessionKey({
cfg,
agentId,
to: opts.to,
sessionId: opts.sessionId,
}).sessionKey;
});
const channel = normalizeMessageChannel(opts.channel) ?? DEFAULT_CHAT_CHANNEL;
const idempotencyKey = opts.runId?.trim() || randomIdempotencyKey();
@@ -121,16 +126,12 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim
method: "agent",
params: {
message: body,
agentId,
to: opts.to,
replyTo: opts.replyTo,
sessionId: opts.sessionId,
sessionKey,
thinking: opts.thinking,
deliver: Boolean(opts.deliver),
channel,
replyChannel: opts.replyChannel,
replyAccountId: opts.replyAccount,
timeout: timeoutSeconds,
lane: opts.lane,
extraSystemPrompt: opts.extraSystemPrompt,
@@ -165,19 +166,14 @@ export async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: Runtim
}
export async function agentCliCommand(opts: AgentCliOpts, runtime: RuntimeEnv, deps?: CliDeps) {
const localOpts = {
...opts,
agentId: opts.agent,
replyAccountId: opts.replyAccount,
};
if (opts.local === true) {
return await agentCommand(localOpts, runtime, deps);
return await agentCommand(opts, runtime, deps);
}
try {
return await agentViaGatewayCommand(opts, runtime);
} catch (err) {
runtime.error?.(`Gateway agent failed; falling back to embedded: ${String(err)}`);
return await agentCommand(localOpts, runtime, deps);
return await agentCommand(opts, runtime, deps);
}
}
-40
View File
@@ -220,46 +220,6 @@ describe("deliverAgentCommandResult", () => {
);
});
it("uses reply overrides for delivery routing", async () => {
const cfg = {} as ClawdbotConfig;
const deps = {} as CliDeps;
const runtime = {
log: vi.fn(),
error: vi.fn(),
} as unknown as RuntimeEnv;
const sessionEntry = {
lastChannel: "telegram",
lastTo: "123",
lastAccountId: "legacy",
} as SessionEntry;
const result = {
payloads: [{ text: "hi" }],
meta: {},
};
const { deliverAgentCommandResult } = await import("./agent/delivery.js");
await deliverAgentCommandResult({
cfg,
deps,
runtime,
opts: {
message: "hello",
deliver: true,
to: "+15551234567",
replyTo: "#reports",
replyChannel: "slack",
replyAccountId: "ops",
},
sessionEntry,
result,
payloads: result.payloads,
});
expect(mocks.resolveOutboundTarget).toHaveBeenCalledWith(
expect.objectContaining({ channel: "slack", to: "#reports", accountId: "ops" }),
);
});
it("prefixes nested agent outputs with context", async () => {
const cfg = {} as ClawdbotConfig;
const deps = {} as CliDeps;
-49
View File
@@ -45,7 +45,6 @@ function mockConfig(
storePath: string,
agentOverrides?: Partial<NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>>,
telegramOverrides?: Partial<NonNullable<ClawdbotConfig["telegram"]>>,
agentsList?: Array<{ id: string; default?: boolean }>,
) {
configSpy.mockReturnValue({
agents: {
@@ -55,7 +54,6 @@ function mockConfig(
workspace: path.join(home, "clawd"),
...agentOverrides,
},
list: agentsList,
},
session: { store: storePath, mainKey: "main" },
telegram: telegramOverrides ? { ...telegramOverrides } : undefined,
@@ -197,30 +195,6 @@ describe("agentCommand", () => {
});
});
it("derives session key from --agent when no routing target is provided", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, undefined, undefined, [{ id: "ops" }]);
await agentCommand({ message: "hi", agentId: "ops" }, runtime);
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.sessionKey).toBe("agent:ops:main");
expect(callArgs?.sessionFile).toContain(`${path.sep}agents${path.sep}ops${path.sep}sessions`);
});
});
it("rejects unknown agent overrides", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store);
await expect(agentCommand({ message: "hi", agentId: "ghost" }, runtime)).rejects.toThrow(
'Unknown agent id "ghost"',
);
});
});
it("defaults thinking to low for reasoning-capable models", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
@@ -322,27 +296,4 @@ describe("agentCommand", () => {
}
});
});
it("uses reply channel as the message channel context", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, undefined, undefined, [{ id: "ops" }]);
await agentCommand({ message: "hi", agentId: "ops", replyChannel: "slack" }, runtime);
const callArgs = vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0];
expect(callArgs?.messageChannel).toBe("slack");
});
});
it("logs output when delivery is disabled", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, undefined, undefined, [{ id: "ops" }]);
await agentCommand({ message: "hi", agentId: "ops" }, runtime);
expect(runtime.log).toHaveBeenCalledWith("ok");
});
});
});
+4 -28
View File
@@ -1,5 +1,4 @@
import {
listAgentIds,
resolveAgentDir,
resolveAgentModelFallbacksOverride,
resolveAgentModelPrimary,
@@ -54,7 +53,6 @@ import { deliverAgentCommandResult } from "./agent/delivery.js";
import { resolveSession } from "./agent/session.js";
import { updateSessionStoreAfterAgentRun } from "./agent/session-store.js";
import type { AgentCommandOpts } from "./agent/types.js";
import { normalizeAgentId } from "../routing/session-key.js";
export async function agentCommand(
opts: AgentCommandOpts,
@@ -63,31 +61,13 @@ export async function agentCommand(
) {
const body = (opts.message ?? "").trim();
if (!body) throw new Error("Message (--message) is required");
if (!opts.to && !opts.sessionId && !opts.sessionKey && !opts.agentId) {
throw new Error("Pass --to <E.164>, --session-id, or --agent to choose a session");
if (!opts.to && !opts.sessionId && !opts.sessionKey) {
throw new Error("Pass --to <E.164> or --session-id to choose a session");
}
const cfg = loadConfig();
const agentIdOverrideRaw = opts.agentId?.trim();
const agentIdOverride = agentIdOverrideRaw ? normalizeAgentId(agentIdOverrideRaw) : undefined;
if (agentIdOverride) {
const knownAgents = listAgentIds(cfg);
if (!knownAgents.includes(agentIdOverride)) {
throw new Error(
`Unknown agent id "${agentIdOverrideRaw}". Use "clawdbot agents list" to see configured agents.`,
);
}
}
if (agentIdOverride && opts.sessionKey) {
const sessionAgentId = resolveAgentIdFromSessionKey(opts.sessionKey);
if (sessionAgentId !== agentIdOverride) {
throw new Error(
`Agent id "${agentIdOverrideRaw}" does not match session key agent "${sessionAgentId}".`,
);
}
}
const agentCfg = cfg.agents?.defaults;
const sessionAgentId = agentIdOverride ?? resolveAgentIdFromSessionKey(opts.sessionKey?.trim());
const sessionAgentId = resolveAgentIdFromSessionKey(opts.sessionKey?.trim());
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, sessionAgentId);
const agentDir = resolveAgentDir(cfg, sessionAgentId);
const workspace = await ensureAgentWorkspace({
@@ -134,7 +114,6 @@ export async function agentCommand(
to: opts.to,
sessionId: opts.sessionId,
sessionKey: opts.sessionKey,
agentId: agentIdOverride,
});
const {
@@ -367,10 +346,7 @@ export async function agentCommand(
let fallbackProvider = provider;
let fallbackModel = model;
try {
const messageChannel = resolveMessageChannel(
opts.messageChannel,
opts.replyChannel ?? opts.channel,
);
const messageChannel = resolveMessageChannel(opts.messageChannel, opts.channel);
const fallbackResult = await runWithModelFallback({
cfg,
provider,
+3 -3
View File
@@ -59,9 +59,9 @@ export async function deliverAgentCommandResult(params: {
const bestEffortDeliver = opts.bestEffortDeliver === true;
const deliveryPlan = resolveAgentDeliveryPlan({
sessionEntry,
requestedChannel: opts.replyChannel ?? opts.channel,
explicitTo: opts.replyTo ?? opts.to,
accountId: opts.replyAccountId ?? opts.accountId,
requestedChannel: opts.channel,
explicitTo: opts.to,
accountId: opts.accountId,
wantsDelivery: deliver,
});
const deliveryChannel = deliveryPlan.resolvedChannel;
+26 -56
View File
@@ -12,7 +12,6 @@ import {
evaluateSessionFreshness,
loadSessionStore,
resolveAgentIdFromSessionKey,
resolveExplicitAgentSessionKey,
resolveSessionResetPolicy,
resolveSessionResetType,
resolveSessionKey,
@@ -32,71 +31,42 @@ export type SessionResolution = {
persistedVerbose?: VerboseLevel;
};
type SessionKeyResolution = {
sessionKey?: string;
sessionStore: Record<string, SessionEntry>;
storePath: string;
};
export function resolveSessionKeyForRequest(opts: {
cfg: ClawdbotConfig;
to?: string;
sessionId?: string;
sessionKey?: string;
agentId?: string;
}): SessionKeyResolution {
const sessionCfg = opts.cfg.session;
const scope = sessionCfg?.scope ?? "per-sender";
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
const explicitSessionKey =
opts.sessionKey?.trim() ||
resolveExplicitAgentSessionKey({
cfg: opts.cfg,
agentId: opts.agentId,
});
const storeAgentId = resolveAgentIdFromSessionKey(explicitSessionKey);
const storePath = resolveStorePath(sessionCfg?.store, {
agentId: storeAgentId,
});
const sessionStore = loadSessionStore(storePath);
const ctx: MsgContext | undefined = opts.to?.trim() ? { From: opts.to } : undefined;
let sessionKey: string | undefined =
explicitSessionKey ?? (ctx ? resolveSessionKey(scope, ctx, mainKey) : undefined);
// If a session id was provided, prefer to re-use its entry (by id) even when no key was derived.
if (
!explicitSessionKey &&
opts.sessionId &&
(!sessionKey || sessionStore[sessionKey]?.sessionId !== opts.sessionId)
) {
const foundKey = Object.keys(sessionStore).find(
(key) => sessionStore[key]?.sessionId === opts.sessionId,
);
if (foundKey) sessionKey = foundKey;
}
return { sessionKey, sessionStore, storePath };
}
export function resolveSession(opts: {
cfg: ClawdbotConfig;
to?: string;
sessionId?: string;
sessionKey?: string;
agentId?: string;
}): SessionResolution {
const sessionCfg = opts.cfg.session;
const { sessionKey, sessionStore, storePath } = resolveSessionKeyForRequest({
cfg: opts.cfg,
to: opts.to,
sessionId: opts.sessionId,
sessionKey: opts.sessionKey,
agentId: opts.agentId,
const scope = sessionCfg?.scope ?? "per-sender";
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
const explicitSessionKey = opts.sessionKey?.trim();
const storeAgentId = resolveAgentIdFromSessionKey(explicitSessionKey);
const storePath = resolveStorePath(sessionCfg?.store, {
agentId: storeAgentId,
});
const sessionStore = loadSessionStore(storePath);
const now = Date.now();
const sessionEntry = sessionKey ? sessionStore[sessionKey] : undefined;
const ctx: MsgContext | undefined = opts.to?.trim() ? { From: opts.to } : undefined;
let sessionKey: string | undefined =
explicitSessionKey ?? (ctx ? resolveSessionKey(scope, ctx, mainKey) : undefined);
let sessionEntry = sessionKey ? sessionStore[sessionKey] : undefined;
// If a session id was provided, prefer to re-use its entry (by id) even when no key was derived.
if (
!explicitSessionKey &&
opts.sessionId &&
(!sessionEntry || sessionEntry.sessionId !== opts.sessionId)
) {
const foundKey = Object.keys(sessionStore).find(
(key) => sessionStore[key]?.sessionId === opts.sessionId,
);
if (foundKey) {
sessionKey = sessionKey ?? foundKey;
sessionEntry = sessionStore[foundKey];
}
}
const resetType = resolveSessionResetType({ sessionKey });
const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType });
-8
View File
@@ -11,8 +11,6 @@ export type AgentCommandOpts = {
message: string;
/** Optional image attachments for multimodal messages. */
images?: ImageContent[];
/** Agent id override (must exist in config). */
agentId?: string;
to?: string;
sessionId?: string;
sessionKey?: string;
@@ -22,12 +20,6 @@ export type AgentCommandOpts = {
json?: boolean;
timeout?: string;
deliver?: boolean;
/** Override delivery target (separate from session routing). */
replyTo?: string;
/** Override delivery channel (separate from session routing). */
replyChannel?: string;
/** Override delivery account id (separate from session routing). */
replyAccountId?: string;
/** Message channel context (webchat|voicewake|whatsapp|...). */
messageChannel?: string;
channel?: string; // delivery channel (whatsapp|telegram|...)
+1 -1
View File
@@ -26,7 +26,7 @@ describe("buildAuthChoiceOptions", () => {
const claudeCli = options.find((opt) => opt.value === "claude-cli");
expect(claudeCli).toBeDefined();
expect(claudeCli?.hint).toBe("reuses existing Claude Code auth · requires Keychain access");
expect(claudeCli?.hint).toBe("requires Keychain access");
});
it("skips missing Claude Code CLI option off macOS", () => {
+4 -4
View File
@@ -160,26 +160,26 @@ export function buildAuthChoiceOptions(params: {
options.push({
value: "claude-cli",
label: "Anthropic token (Claude Code CLI)",
hint: `reuses existing Claude Code auth · ${formatOAuthHint(claudeCli.expires)}`,
hint: formatOAuthHint(claudeCli.expires),
});
} else if (params.includeClaudeCliIfMissing && platform === "darwin") {
options.push({
value: "claude-cli",
label: "Anthropic token (Claude Code CLI)",
hint: "reuses existing Claude Code auth · requires Keychain access",
hint: "requires Keychain access",
});
}
options.push({
value: "setup-token",
label: "Anthropic token (run setup-token)",
hint: "runs `claude setup-token` · opens browser for fresh OAuth login",
hint: "Runs `claude setup-token`",
});
options.push({
value: "token",
label: "Anthropic token (paste setup-token)",
hint: "run `claude setup-token` elsewhere, then paste the token here",
hint: "Run `claude setup-token`, then paste the token",
});
options.push({
+1 -2
View File
@@ -1,5 +1,4 @@
import { runGatewayUpdate } from "../infra/update-runner.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { runCommandWithTimeout } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { note } from "../terminal/note.js";
@@ -28,7 +27,7 @@ export async function maybeOfferUpdateBeforeDoctor(params: {
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
outro: (message: string) => void;
}) {
const updateInProgress = isTruthyEnvValue(process.env.CLAWDBOT_UPDATE_IN_PROGRESS);
const updateInProgress = process.env.CLAWDBOT_UPDATE_IN_PROGRESS === "1";
const canOfferUpdate =
!updateInProgress &&
params.options.nonInteractive !== true &&
+2 -3
View File
@@ -8,7 +8,6 @@ import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { info } from "../globals.js";
import { formatErrorMessage } from "../infra/errors.js";
import { isTruthyEnvValue } from "../infra/env.js";
import {
type HeartbeatSummary,
resolveHeartbeatSummaryForAgent,
@@ -72,7 +71,7 @@ export type HealthSummary = {
const DEFAULT_TIMEOUT_MS = 10_000;
const debugHealth = (...args: unknown[]) => {
if (isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_HEALTH)) {
if (process.env.CLAWDBOT_DEBUG_HEALTH === "1") {
console.warn("[health:debug]", ...args);
}
};
@@ -524,7 +523,7 @@ export async function healthCommand(
if (opts.json) {
runtime.log(JSON.stringify(summary, null, 2));
} else {
const debugEnabled = isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_HEALTH);
const debugEnabled = process.env.CLAWDBOT_DEBUG_HEALTH === "1";
if (opts.verbose) {
const details = buildGatewayConnectionDetails();
runtime.log(info("Gateway connection:"));
+2 -19
View File
@@ -214,9 +214,8 @@ async function maybeConfigureDmPolicies(params: {
cfg: ClawdbotConfig;
selection: ChannelChoice[];
prompter: WizardPrompter;
accountIdsByChannel?: Map<ChannelChoice, string>;
}): Promise<ClawdbotConfig> {
const { selection, prompter, accountIdsByChannel } = params;
const { selection, prompter } = params;
const dmPolicies = selection
.map((channel) => getChannelOnboardingAdapter(channel)?.dmPolicy)
.filter(Boolean) as ChannelOnboardingDmPolicy[];
@@ -234,7 +233,6 @@ async function maybeConfigureDmPolicies(params: {
[
"Default: pairing (unknown DMs get a pairing code).",
`Approve: clawdbot pairing approve ${policy.channel} <code>`,
`Allowlist DMs: ${policy.policyKey}="allowlist" + ${policy.allowFromKey} entries.`,
`Public DMs: ${policy.policyKey}="open" + ${policy.allowFromKey} includes "*".`,
'Multi-user DMs: set session.dmScope="per-channel-peer" to isolate sessions.',
`Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`,
@@ -245,7 +243,6 @@ async function maybeConfigureDmPolicies(params: {
message: `${policy.label} DM policy`,
options: [
{ value: "pairing", label: "Pairing (recommended)" },
{ value: "allowlist", label: "Allowlist (specific users only)" },
{ value: "open", label: "Open (public inbound DMs)" },
{ value: "disabled", label: "Disabled (ignore DMs)" },
],
@@ -258,13 +255,6 @@ async function maybeConfigureDmPolicies(params: {
if (nextPolicy !== current) {
cfg = policy.setPolicy(cfg, nextPolicy);
}
if (nextPolicy === "allowlist" && policy.promptAllowFrom) {
cfg = await policy.promptAllowFrom({
cfg,
prompter,
accountId: accountIdsByChannel?.get(policy.channel),
});
}
}
return cfg;
@@ -330,12 +320,10 @@ export async function setupChannels(
options?.initialSelection?.[0] ?? resolveQuickstartDefault(statusByChannel);
const shouldPromptAccountIds = options?.promptAccountIds === true;
const accountIdsByChannel = new Map<ChannelChoice, string>();
const recordAccount = (channel: ChannelChoice, accountId: string) => {
options?.onAccountId?.(channel, accountId);
const adapter = getChannelOnboardingAdapter(channel);
adapter?.onAccountRecorded?.(accountId, options);
accountIdsByChannel.set(channel, accountId);
};
const selection: ChannelChoice[] = [];
@@ -626,12 +614,7 @@ export async function setupChannels(
}
if (!options?.skipDmPolicyPrompt) {
next = await maybeConfigureDmPolicies({
cfg: next,
selection,
prompter,
accountIdsByChannel,
});
next = await maybeConfigureDmPolicies({ cfg: next, selection, prompter });
}
return next;
+3 -52
View File
@@ -8,7 +8,6 @@ import JSON5 from "json5";
import {
loadShellEnvFallback,
resolveShellEnvFallbackTimeoutMs,
shouldDeferShellEnvFallback,
shouldEnableShellEnvFallback,
} from "../infra/shell-env.js";
import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js";
@@ -283,7 +282,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
function loadConfig(): ClawdbotConfig {
try {
if (!deps.fs.existsSync(configPath)) {
if (shouldEnableShellEnvFallback(deps.env) && !shouldDeferShellEnvFallback(deps.env)) {
if (shouldEnableShellEnvFallback(deps.env)) {
loadShellEnvFallback({
enabled: true,
env: deps.env,
@@ -353,7 +352,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
applyConfigEnv(cfg, deps.env);
const enabled = shouldEnableShellEnvFallback(deps.env) || cfg.env?.shellEnv?.enabled === true;
if (enabled && !shouldDeferShellEnvFallback(deps.env)) {
if (enabled) {
loadShellEnvFallback({
enabled: true,
env: deps.env,
@@ -528,7 +527,6 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
}
async function writeConfigFile(cfg: ClawdbotConfig) {
clearConfigCache();
const dir = path.dirname(configPath);
await deps.fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
const json = JSON.stringify(applyModelDefaults(stampConfigVersion(cfg)), null, 2)
@@ -585,54 +583,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
// NOTE: These wrappers intentionally do *not* cache the resolved config path at
// module scope. `CLAWDBOT_CONFIG_PATH` (and friends) are expected to work even
// when set after the module has been imported (tests, one-off scripts, etc.).
const DEFAULT_CONFIG_CACHE_MS = 200;
let configCache:
| {
configPath: string;
expiresAt: number;
config: ClawdbotConfig;
}
| null = null;
function resolveConfigCacheMs(env: NodeJS.ProcessEnv): number {
const raw = env.CLAWDBOT_CONFIG_CACHE_MS?.trim();
if (raw === "" || raw === "0") return 0;
if (!raw) return DEFAULT_CONFIG_CACHE_MS;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed)) return DEFAULT_CONFIG_CACHE_MS;
return Math.max(0, parsed);
}
function shouldUseConfigCache(env: NodeJS.ProcessEnv): boolean {
if (env.CLAWDBOT_DISABLE_CONFIG_CACHE?.trim()) return false;
return resolveConfigCacheMs(env) > 0;
}
function clearConfigCache(): void {
configCache = null;
}
export function loadConfig(): ClawdbotConfig {
const configPath = resolveConfigPath();
const now = Date.now();
if (shouldUseConfigCache(process.env)) {
const cached = configCache;
if (cached && cached.configPath === configPath && cached.expiresAt > now) {
return cached.config;
}
}
const config = createConfigIO({ configPath }).loadConfig();
if (shouldUseConfigCache(process.env)) {
const cacheMs = resolveConfigCacheMs(process.env);
if (cacheMs > 0) {
configCache = {
configPath,
expiresAt: now + cacheMs,
config,
};
}
}
return config;
return createConfigIO({ configPath: resolveConfigPath() }).loadConfig();
}
export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
@@ -642,6 +594,5 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
}
export async function writeConfigFile(cfg: ClawdbotConfig): Promise<void> {
clearConfigCache();
await createConfigIO({ configPath: resolveConfigPath() }).writeConfigFile(cfg);
}
-31
View File
@@ -206,37 +206,6 @@ describe("sessions", () => {
expect(store[sessionKey]?.origin?.chatType).toBe("group");
});
it("updateSessionStoreEntry preserves existing fields when patching", async () => {
const sessionKey = "agent:main:main";
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(
storePath,
JSON.stringify(
{
[sessionKey]: {
sessionId: "sess-1",
updatedAt: 100,
reasoningLevel: "on",
},
},
null,
2,
),
"utf-8",
);
await updateSessionStoreEntry({
storePath,
sessionKey,
update: async () => ({ updatedAt: 200 }),
});
const store = loadSessionStore(storePath);
expect(store[sessionKey]?.updatedAt).toBeGreaterThanOrEqual(200);
expect(store[sessionKey]?.reasoningLevel).toBe("on");
});
it("updateSessionStore preserves concurrent additions", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
const storePath = path.join(dir, "sessions.json");
-9
View File
@@ -35,15 +35,6 @@ export function resolveAgentMainSessionKey(params: {
return buildAgentMainSessionKey({ agentId: params.agentId, mainKey });
}
export function resolveExplicitAgentSessionKey(params: {
cfg?: { session?: { scope?: SessionScope; mainKey?: string } };
agentId?: string | null;
}): string | undefined {
const agentId = params.agentId?.trim();
if (!agentId) return undefined;
return resolveAgentMainSessionKey({ cfg: params.cfg, agentId });
}
export function canonicalizeMainSessionAlias(params: {
cfg?: { session?: { scope?: SessionScope; mainKey?: string } };
agentId: string;
+1 -3
View File
@@ -3,7 +3,6 @@ import { spawn } from "node:child_process";
import process from "node:process";
import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js";
import { isTruthyEnvValue } from "./infra/env.js";
import { attachChildProcessBridge } from "./process/child-process-bridge.js";
process.title = "clawdbot";
@@ -21,8 +20,7 @@ function hasExperimentalWarningSuppressed(nodeOptions: string): boolean {
}
function ensureExperimentalWarningSuppressed(): boolean {
if (isTruthyEnvValue(process.env.CLAWDBOT_NO_RESPAWN)) return false;
if (isTruthyEnvValue(process.env.CLAWDBOT_NODE_OPTIONS_READY)) return false;
if (process.env.CLAWDBOT_NODE_OPTIONS_READY === "1") return false;
const nodeOptions = process.env.NODE_OPTIONS ?? "";
if (hasExperimentalWarningSuppressed(nodeOptions)) return false;
+4 -6
View File
@@ -7,16 +7,14 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import { parseModelRef } from "../agents/model-selection.js";
import { loadConfig } from "../config/config.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { GatewayClient } from "./client.js";
import { renderCatNoncePngBase64 } from "./live-image-probe.js";
import { startGatewayServer } from "./server.js";
const LIVE =
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const CLI_LIVE = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_CLI_BACKEND);
const CLI_IMAGE = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_PROBE);
const CLI_RESUME = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_CLI_BACKEND_RESUME_PROBE);
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
const CLI_LIVE = process.env.CLAWDBOT_LIVE_CLI_BACKEND === "1";
const CLI_IMAGE = process.env.CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_PROBE === "1";
const CLI_RESUME = process.env.CLAWDBOT_LIVE_CLI_BACKEND_RESUME_PROBE === "1";
const describeLive = LIVE && CLI_LIVE ? describe : describe.skip;
const DEFAULT_MODEL = "claude-cli/claude-sonnet-4-5";
@@ -24,17 +24,15 @@ import { getApiKeyForModel } from "../agents/model-auth.js";
import { ensureClawdbotModelsJson } from "../agents/models-config.js";
import { loadConfig } from "../config/config.js";
import type { ClawdbotConfig, ModelProviderConfig } from "../config/types.js";
import { isTruthyEnvValue } from "../infra/env.js";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { GatewayClient } from "./client.js";
import { renderCatNoncePngBase64 } from "./live-image-probe.js";
import { startGatewayServer } from "./server.js";
const LIVE =
isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.CLAWDBOT_LIVE_TEST);
const GATEWAY_LIVE = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_GATEWAY);
const ZAI_FALLBACK = isTruthyEnvValue(process.env.CLAWDBOT_LIVE_GATEWAY_ZAI_FALLBACK);
const LIVE = process.env.LIVE === "1" || process.env.CLAWDBOT_LIVE_TEST === "1";
const GATEWAY_LIVE = process.env.CLAWDBOT_LIVE_GATEWAY === "1";
const ZAI_FALLBACK = process.env.CLAWDBOT_LIVE_GATEWAY_ZAI_FALLBACK === "1";
const PROVIDERS = parseFilter(process.env.CLAWDBOT_LIVE_GATEWAY_PROVIDERS);
const THINKING_LEVEL = "high";
const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking)\s*>/i;
-4
View File
@@ -45,18 +45,14 @@ export const PollParamsSchema = Type.Object(
export const AgentParamsSchema = Type.Object(
{
message: NonEmptyString,
agentId: Type.Optional(NonEmptyString),
to: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()),
sessionId: Type.Optional(Type.String()),
sessionKey: Type.Optional(Type.String()),
thinking: Type.Optional(Type.String()),
deliver: Type.Optional(Type.Boolean()),
attachments: Type.Optional(Type.Array(Type.Unknown())),
channel: Type.Optional(Type.String()),
replyChannel: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
replyAccountId: Type.Optional(Type.String()),
timeout: Type.Optional(Type.Integer({ minimum: 0 })),
lane: Type.Optional(Type.String()),
extraSystemPrompt: Type.Optional(Type.String()),
+1 -3
View File
@@ -1,11 +1,9 @@
import { isTruthyEnvValue } from "../infra/env.js";
export type BrowserControlServer = {
stop: () => Promise<void>;
};
export async function startBrowserControlServerIfEnabled(): Promise<BrowserControlServer | null> {
if (isTruthyEnvValue(process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER)) return null;
if (process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER === "1") return null;
// Lazy import: keeps startup fast, but still bundles for the embedded
// gateway (bun --compile) via the static specifier path.
const override = process.env.CLAWDBOT_BROWSER_CONTROL_MODULE?.trim();
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more