diff --git a/CHANGELOG.md b/CHANGELOG.md index e08ecccc7..02912989a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ ### Fixes - CLI/Daemon: add `clawdbot logs` tailing and improve restart/service hints across platforms. -- Gateway/CLI: tighten LAN bind auth checks, warn on mis-keyed gateway tokens, and surface last gateway error when daemon looks running but the port is closed. +- Gateway/CLI/Doctor: tighten LAN bind auth checks, warn/migrate mis-keyed gateway tokens, and surface last gateway error when daemon looks running but the port is closed. - Auto-reply: keep typing indicators alive during tool execution without changing typing-mode semantics. Thanks @thesash for PR #452. - macOS: harden Voice Wake tester/runtime (pause trigger, mic persistence, local-only tester) and keep transcript logs private. Thanks @xadenryan for PR #438. - macOS: preserve node bridge tunnel port override so remote nodes connect on the bridge port. Thanks @sircrumpet for PR #364. @@ -71,6 +71,7 @@ - Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect. - CLI: add `clawdbot docs` live docs search with pretty output. - CLI: add `clawdbot agents` (list/add/delete) with wizarded workspace/setup, provider login, and full prune on delete. +- CLI: add non-interactive flags for `agents add`, support `agents list --bindings`, and keep JSON output clean for scripting. - Discord/Slack: fork thread sessions (agent-scoped) and inject thread starters for context. Thanks @thewilloftheshadow for PR #400. - Agent: treat compaction retry AbortError as a fallback trigger without swallowing non-abort errors. Thanks @erikpr1994 for PR #341. - Agent: add opt-in session pruning for tool results to reduce context bloat. Thanks @maxsumrall for PR #381. diff --git a/docs/cli/index.md b/docs/cli/index.md index 3196b6ab2..d3b8571be 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -330,14 +330,21 @@ List configured agents. Options: - `--json` +- `--bindings` #### `agents add [name]` -Add a new isolated agent. Runs the guided wizard unless flags are passed; `--workspace` is required in non-interactive mode. +Add a new isolated agent. Runs the guided wizard unless flags (or `--non-interactive`) are passed; `--workspace` is required in non-interactive mode. Options: - `--workspace ` +- `--model ` +- `--agent-dir ` +- `--bind ` (repeatable) +- `--non-interactive` - `--json` +Binding specs use `provider[:accountId]`. When `accountId` is omitted for WhatsApp, the default account id is used. + #### `agents delete ` Delete an agent and prune its workspace + state. @@ -420,6 +427,7 @@ Notes: - `daemon status` uses the same URL/token defaults as `gateway status` unless you pass `--url/--token/--password`. - `daemon status` supports `--no-probe`, `--deep`, and `--json` for scripting. - `daemon status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). +- `daemon status` prints which config path the CLI uses vs which config the daemon likely uses (service env), plus the resolved probe target URL. - `daemon install` defaults to Node runtime; use `--runtime bun` only when WhatsApp is disabled. - `daemon install` options: `--port`, `--runtime`, `--token`. diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index c7556a6cc..9627932fa 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -19,6 +19,14 @@ An **agent** is a fully scoped brain with its own: The Gateway can host **one agent** (default) or **many agents** side-by-side. +## Paths (quick map) + +- Config: `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) +- State dir: `~/.clawdbot` (or `CLAWDBOT_STATE_DIR`) +- Workspace: `~/clawd` (or `~/clawd-`) +- Agent dir: `~/.clawdbot/agents//agent` (or `routing.agents..agentDir`) +- Sessions: `~/.clawdbot/agents//sessions` + ### Single-agent mode (default) If you do nothing, Clawdbot runs a single agent: @@ -38,6 +46,12 @@ clawdbot agents add work Then add `routing.bindings` (or let the wizard do it) to route inbound messages. +Verify with: + +```bash +clawdbot agents list --bindings +``` + ## Multiple agents = multiple people, multiple personalities With **multiple agents**, each `agentId` becomes a **fully isolated persona**: diff --git a/docs/multi-agent-sandbox-tools.md b/docs/multi-agent-sandbox-tools.md index f31b43d5f..4c06777a1 100644 --- a/docs/multi-agent-sandbox-tools.md +++ b/docs/multi-agent-sandbox-tools.md @@ -248,7 +248,7 @@ After configuring multi-agent sandbox and tools: 1. **Check agent resolution:** ```bash - clawdbot agents list + clawdbot agents list --bindings ``` 2. **Verify sandbox containers:** diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 233332e16..b20d01a13 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -44,6 +44,8 @@ To add more isolated agents (separate workspace + sessions + auth), use: clawdbot agents add ``` +Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (and `--workspace`) for scripts. + ## Flow details (local) 1) **Existing config detection** @@ -131,6 +133,7 @@ What it sets: Notes: - Default workspaces follow `~/clawd-`. - Add `routing.bindings` to route inbound messages (the wizard can do this). + - Non-interactive flags: `--model`, `--agent-dir`, `--bind`, `--non-interactive`. ## Non‑interactive mode @@ -153,7 +156,12 @@ Add `--json` for a machine‑readable summary. Add agent (non‑interactive) example: ```bash -clawdbot agents add work --workspace ~/clawd-work +clawdbot agents add work \ + --workspace ~/clawd-work \ + --model openai/gpt-5.2 \ + --bind whatsapp:biz \ + --non-interactive \ + --json ``` ## Gateway wizard RPC diff --git a/src/cli/program.ts b/src/cli/program.ts index 7012bcd5e..2aa74307d 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -48,6 +48,10 @@ import { registerTuiCli } from "./tui-cli.js"; export { forceFreePort }; +function collectOption(value: string, previous: string[] = []): string[] { + return [...previous, value]; +} + export function buildProgram() { const program = new Command(); const PROGRAM_VERSION = VERSION; @@ -553,9 +557,13 @@ Examples: .command("list") .description("List configured agents") .option("--json", "Output JSON instead of text", false) + .option("--bindings", "Include routing bindings", false) .action(async (opts) => { try { - await agentsListCommand({ json: Boolean(opts.json) }, defaultRuntime); + await agentsListCommand( + { json: Boolean(opts.json), bindings: Boolean(opts.bindings) }, + defaultRuntime, + ); } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); @@ -566,14 +574,28 @@ Examples: .command("add [name]") .description("Add a new isolated agent") .option("--workspace ", "Workspace directory for the new agent") + .option("--model ", "Model id for this agent") + .option("--agent-dir ", "Agent state directory for this agent") + .option("--bind ", "Route provider binding (repeatable)", collectOption, []) + .option("--non-interactive", "Disable prompts; requires --workspace", false) .option("--json", "Output JSON summary", false) .action(async (name, opts, command) => { try { - const hasFlags = hasExplicitOptions(command, ["workspace", "json"]); + const hasFlags = hasExplicitOptions(command, [ + "workspace", + "model", + "agentDir", + "bind", + "nonInteractive", + ]); await agentsAddCommand( { name: typeof name === "string" ? name : undefined, workspace: opts.workspace as string | undefined, + model: opts.model as string | undefined, + agentDir: opts.agentDir as string | undefined, + bind: Array.isArray(opts.bind) ? (opts.bind as string[]) : undefined, + nonInteractive: Boolean(opts.nonInteractive), json: Boolean(opts.json), }, defaultRuntime, diff --git a/src/commands/agents.add.test.ts b/src/commands/agents.add.test.ts index 8d4125528..aadef7258 100644 --- a/src/commands/agents.add.test.ts +++ b/src/commands/agents.add.test.ts @@ -55,4 +55,20 @@ describe("agents add command", () => { expect(runtime.exit).toHaveBeenCalledWith(1); expect(configMocks.writeConfigFile).not.toHaveBeenCalled(); }); + + it("requires --workspace in non-interactive mode", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot }); + + await agentsAddCommand( + { name: "Work", nonInteractive: true }, + runtime, + { hasFlags: false }, + ); + + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining("--workspace"), + ); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(configMocks.writeConfigFile).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/agents.ts b/src/commands/agents.ts index 0e7b2684a..304c2eed4 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -18,6 +18,7 @@ import { import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath } from "../utils.js"; +import { normalizeChatProviderId } from "../providers/registry.js"; import { resolveDefaultWhatsAppAccountId } from "../web/accounts.js"; import { createClackPrompter } from "../wizard/clack-prompter.js"; import { WizardCancelledError } from "../wizard/prompts.js"; @@ -29,11 +30,16 @@ import type { AuthChoice, ProviderChoice } from "./onboard-types.js"; type AgentsListOptions = { json?: boolean; + bindings?: boolean; }; type AgentsAddOptions = { name?: string; workspace?: string; + model?: string; + agentDir?: string; + bind?: string[]; + nonInteractive?: boolean; json?: boolean; }; @@ -50,6 +56,7 @@ export type AgentSummary = { agentDir: string; model?: string; bindings: number; + bindingDetails?: string[]; isDefault: boolean; }; @@ -64,6 +71,10 @@ type AgentBinding = { }; }; +function createQuietRuntime(runtime: RuntimeEnv): RuntimeEnv { + return { ...runtime, log: () => {} }; +} + function resolveAgentName(cfg: ClawdbotConfig, agentId: string) { return cfg.routing?.agents?.[agentId]?.name?.trim() || undefined; } @@ -270,7 +281,13 @@ function formatSummary(summary: AgentSummary) { summary.model ? `model: ${summary.model}` : null, `bindings: ${summary.bindings}`, ].filter(Boolean); - return `- ${parts.join(" | ")}`; + const lines = [`- ${parts.join(" | ")}`]; + if (summary.bindingDetails?.length) { + for (const binding of summary.bindingDetails) { + lines.push(` - ${binding}`); + } + } + return lines.join("\n"); } async function requireValidConfig( @@ -300,6 +317,22 @@ export async function agentsListCommand( if (!cfg) return; const summaries = buildAgentSummaries(cfg); + if (opts.bindings) { + const bindingMap = new Map(); + for (const binding of cfg.routing?.bindings ?? []) { + const agentId = normalizeAgentId(binding.agentId); + const list = bindingMap.get(agentId) ?? []; + list.push(describeBinding(binding as AgentBinding)); + bindingMap.set(agentId, list); + } + for (const summary of summaries) { + const details = bindingMap.get(summary.id); + if (details && details.length > 0) { + summary.bindingDetails = details; + } + } + } + if (opts.json) { runtime.log(JSON.stringify(summaries, null, 2)); return; @@ -340,6 +373,40 @@ function buildProviderBindings(params: { return bindings; } +function parseBindingSpecs(params: { + agentId: string; + specs?: string[]; + config: ClawdbotConfig; +}): { bindings: AgentBinding[]; errors: string[] } { + const bindings: AgentBinding[] = []; + const errors: string[] = []; + const specs = params.specs ?? []; + const agentId = normalizeAgentId(params.agentId); + for (const raw of specs) { + const trimmed = raw?.trim(); + if (!trimmed) continue; + const [providerRaw, accountRaw] = trimmed.split(":", 2); + const provider = normalizeChatProviderId(providerRaw); + if (!provider) { + errors.push(`Unknown provider "${providerRaw}".`); + continue; + } + let accountId = accountRaw?.trim(); + if (accountRaw !== undefined && !accountId) { + errors.push(`Invalid binding "${trimmed}" (empty account id).`); + continue; + } + if (!accountId && provider === "whatsapp") { + accountId = resolveDefaultWhatsAppAccountId(params.config); + if (!accountId) accountId = DEFAULT_ACCOUNT_ID; + } + const match: AgentBinding["match"] = { provider }; + if (accountId) match.accountId = accountId; + bindings.push({ agentId, match }); + } + return { bindings, errors }; +} + export async function agentsAddCommand( opts: AgentsAddOptions, runtime: RuntimeEnv = defaultRuntime, @@ -351,8 +418,9 @@ export async function agentsAddCommand( const workspaceFlag = opts.workspace?.trim(); const nameInput = opts.name?.trim(); const hasFlags = params?.hasFlags === true; + const nonInteractive = Boolean(opts.nonInteractive || hasFlags); - if (hasFlags && !workspaceFlag) { + if (nonInteractive && !workspaceFlag) { runtime.error( "Non-interactive mode requires --workspace. Re-run without flags to use the wizard.", ); @@ -360,9 +428,9 @@ export async function agentsAddCommand( return; } - if (workspaceFlag) { + if (nonInteractive) { if (!nameInput) { - runtime.error("Agent name is required when --workspace is provided."); + runtime.error("Agent name is required in non-interactive mode."); runtime.exit(1); return; } @@ -382,18 +450,38 @@ export async function agentsAddCommand( } const workspaceDir = resolveUserPath(workspaceFlag); - const agentDir = resolveAgentDir(cfg, agentId); + const agentDir = opts.agentDir?.trim() + ? resolveUserPath(opts.agentDir.trim()) + : resolveAgentDir(cfg, agentId); + const model = opts.model?.trim(); const nextConfig = applyAgentConfig(cfg, { agentId, name: nameInput, workspace: workspaceDir, agentDir, + ...(model ? { model } : {}), }); - await writeConfigFile(nextConfig); - runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); - await ensureWorkspaceAndSessions(workspaceDir, runtime, { - skipBootstrap: Boolean(nextConfig.agent?.skipBootstrap), + const bindingParse = parseBindingSpecs({ + agentId, + specs: opts.bind, + config: nextConfig, + }); + if (bindingParse.errors.length > 0) { + runtime.error(bindingParse.errors.join("\n")); + runtime.exit(1); + return; + } + const bindingResult = + bindingParse.bindings.length > 0 + ? applyAgentBindings(nextConfig, bindingParse.bindings) + : { config: nextConfig, added: [], skipped: [], conflicts: [] }; + + await writeConfigFile(bindingResult.config); + if (!opts.json) runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); + const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime; + await ensureWorkspaceAndSessions(workspaceDir, quietRuntime, { + skipBootstrap: Boolean(bindingResult.config.agent?.skipBootstrap), agentId, }); @@ -402,6 +490,15 @@ export async function agentsAddCommand( name: nameInput, workspace: workspaceDir, agentDir, + model, + bindings: { + added: bindingResult.added.map(describeBinding), + skipped: bindingResult.skipped.map(describeBinding), + conflicts: bindingResult.conflicts.map( + (conflict) => + `${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`, + ), + }, }; if (opts.json) { runtime.log(JSON.stringify(payload, null, 2)); @@ -409,6 +506,18 @@ export async function agentsAddCommand( runtime.log(`Agent: ${agentId}`); runtime.log(`Workspace: ${workspaceDir}`); runtime.log(`Agent dir: ${agentDir}`); + if (model) runtime.log(`Model: ${model}`); + if (bindingResult.conflicts.length > 0) { + runtime.error( + [ + "Skipped bindings already claimed by another agent:", + ...bindingResult.conflicts.map( + (conflict) => + `- ${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`, + ), + ].join("\n"), + ); + } } return; } @@ -633,11 +742,12 @@ export async function agentsDeleteCommand( const result = pruneAgentConfig(cfg, agentId); await writeConfigFile(result.config); - runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); + if (!opts.json) runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); - await moveToTrash(workspaceDir, runtime); - await moveToTrash(agentDir, runtime); - await moveToTrash(sessionsDir, runtime); + const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime; + await moveToTrash(workspaceDir, quietRuntime); + await moveToTrash(agentDir, quietRuntime); + await moveToTrash(sessionsDir, quietRuntime); if (opts.json) { runtime.log(