Merge branch 'main' into fix/imessage-groupish-threads

This commit is contained in:
Peter Steinberger
2026-01-09 17:16:44 +00:00
committed by GitHub
428 changed files with 26439 additions and 6160 deletions
+27 -32
View File
@@ -11,10 +11,8 @@ describe("resolveAgentConfig", () => {
it("should return undefined when agent id does not exist", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
main: { workspace: "~/clawd" },
},
agents: {
list: [{ id: "main", workspace: "~/clawd" }],
},
};
const result = resolveAgentConfig(cfg, "nonexistent");
@@ -23,15 +21,16 @@ describe("resolveAgentConfig", () => {
it("should return basic agent config", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
name: "Main Agent",
workspace: "~/clawd",
agentDir: "~/.clawdbot/agents/main",
model: "anthropic/claude-opus-4",
},
},
],
},
};
const result = resolveAgentConfig(cfg, "main");
@@ -40,6 +39,9 @@ describe("resolveAgentConfig", () => {
workspace: "~/clawd",
agentDir: "~/.clawdbot/agents/main",
model: "anthropic/claude-opus-4",
identity: undefined,
groupChat: undefined,
subagents: undefined,
sandbox: undefined,
tools: undefined,
});
@@ -47,9 +49,10 @@ describe("resolveAgentConfig", () => {
it("should return agent-specific sandbox config", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
work: {
agents: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
@@ -57,13 +60,9 @@ describe("resolveAgentConfig", () => {
perSession: false,
workspaceAccess: "ro",
workspaceRoot: "~/sandboxes",
tools: {
allow: ["read"],
deny: ["bash"],
},
},
},
},
],
},
};
const result = resolveAgentConfig(cfg, "work");
@@ -73,25 +72,22 @@ describe("resolveAgentConfig", () => {
perSession: false,
workspaceAccess: "ro",
workspaceRoot: "~/sandboxes",
tools: {
allow: ["read"],
deny: ["bash"],
},
});
});
it("should return agent-specific tools config", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
restricted: {
agents: {
list: [
{
id: "restricted",
workspace: "~/clawd-restricted",
tools: {
allow: ["read"],
deny: ["bash", "write", "edit"],
},
},
},
],
},
};
const result = resolveAgentConfig(cfg, "restricted");
@@ -103,9 +99,10 @@ describe("resolveAgentConfig", () => {
it("should return both sandbox and tools config", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
family: {
agents: {
list: [
{
id: "family",
workspace: "~/clawd-family",
sandbox: {
mode: "all",
@@ -116,7 +113,7 @@ describe("resolveAgentConfig", () => {
deny: ["bash"],
},
},
},
],
},
};
const result = resolveAgentConfig(cfg, "family");
@@ -126,10 +123,8 @@ describe("resolveAgentConfig", () => {
it("should normalize agent id", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
main: { workspace: "~/clawd" },
},
agents: {
list: [{ id: "main", workspace: "~/clawd" }],
},
};
// Should normalize to "main" (default)
+58 -43
View File
@@ -3,61 +3,75 @@ import path from "node:path";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import {
DEFAULT_AGENT_ID,
normalizeAgentId,
parseAgentSessionKey,
} from "../routing/session-key.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
import { resolveUserPath } from "../utils.js";
import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js";
export function resolveAgentIdFromSessionKey(
sessionKey?: string | null,
): string {
const parsed = parseAgentSessionKey(sessionKey);
return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID);
export { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
type AgentEntry = NonNullable<
NonNullable<ClawdbotConfig["agents"]>["list"]
>[number];
type ResolvedAgentConfig = {
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
identity?: AgentEntry["identity"];
groupChat?: AgentEntry["groupChat"];
subagents?: AgentEntry["subagents"];
sandbox?: AgentEntry["sandbox"];
tools?: AgentEntry["tools"];
};
let defaultAgentWarned = false;
function listAgents(cfg: ClawdbotConfig): AgentEntry[] {
const list = cfg.agents?.list;
if (!Array.isArray(list)) return [];
return list.filter((entry): entry is AgentEntry =>
Boolean(entry && typeof entry === "object"),
);
}
export function resolveDefaultAgentId(cfg: ClawdbotConfig): string {
const agents = listAgents(cfg);
if (agents.length === 0) return DEFAULT_AGENT_ID;
const defaults = agents.filter((agent) => agent?.default);
if (defaults.length > 1 && !defaultAgentWarned) {
defaultAgentWarned = true;
console.warn(
"Multiple agents marked default=true; using the first entry as default.",
);
}
const chosen = (defaults[0] ?? agents[0])?.id?.trim();
return normalizeAgentId(chosen || DEFAULT_AGENT_ID);
}
function resolveAgentEntry(
cfg: ClawdbotConfig,
agentId: string,
): AgentEntry | undefined {
const id = normalizeAgentId(agentId);
return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
}
export function resolveAgentConfig(
cfg: ClawdbotConfig,
agentId: string,
):
| {
name?: string;
workspace?: string;
agentDir?: string;
model?: string;
subagents?: {
allowAgents?: string[];
};
sandbox?: {
mode?: "off" | "non-main" | "all";
workspaceAccess?: "none" | "ro" | "rw";
scope?: "session" | "agent" | "shared";
perSession?: boolean;
workspaceRoot?: string;
tools?: {
allow?: string[];
deny?: string[];
};
};
tools?: {
allow?: string[];
deny?: string[];
};
}
| undefined {
): ResolvedAgentConfig | undefined {
const id = normalizeAgentId(agentId);
const agents = cfg.routing?.agents;
if (!agents || typeof agents !== "object") return undefined;
const entry = agents[id];
if (!entry || typeof entry !== "object") return undefined;
const entry = resolveAgentEntry(cfg, id);
if (!entry) return undefined;
return {
name: typeof entry.name === "string" ? entry.name : undefined,
workspace:
typeof entry.workspace === "string" ? entry.workspace : undefined,
agentDir: typeof entry.agentDir === "string" ? entry.agentDir : undefined,
model: typeof entry.model === "string" ? entry.model : undefined,
identity: entry.identity,
groupChat: entry.groupChat,
subagents:
typeof entry.subagents === "object" && entry.subagents
? entry.subagents
@@ -71,9 +85,10 @@ export function resolveAgentWorkspaceDir(cfg: ClawdbotConfig, agentId: string) {
const id = normalizeAgentId(agentId);
const configured = resolveAgentConfig(cfg, id)?.workspace?.trim();
if (configured) return resolveUserPath(configured);
if (id === DEFAULT_AGENT_ID) {
const legacy = cfg.agent?.workspace?.trim();
if (legacy) return resolveUserPath(legacy);
const defaultAgentId = resolveDefaultAgentId(cfg);
if (id === defaultAgentId) {
const fallback = cfg.agents?.defaults?.workspace?.trim();
if (fallback) return resolveUserPath(fallback);
return DEFAULT_AGENT_WORKSPACE_DIR;
}
return path.join(os.homedir(), `clawd-${id}`);
+39 -4
View File
@@ -19,7 +19,7 @@ export type AuthProfileHealthStatus =
export type AuthProfileHealth = {
profileId: string;
provider: string;
type: "oauth" | "api_key";
type: "oauth" | "token" | "api_key";
status: AuthProfileHealthStatus;
expiresAt?: number;
remainingMs?: number;
@@ -109,6 +109,39 @@ function buildProfileHealth(params: {
};
}
if (credential.type === "token") {
const expiresAt =
typeof credential.expires === "number" &&
Number.isFinite(credential.expires)
? credential.expires
: undefined;
if (!expiresAt || expiresAt <= 0) {
return {
profileId,
provider: credential.provider,
type: "token",
status: "static",
source,
label,
};
}
const { status, remainingMs } = resolveOAuthStatus(
expiresAt,
now,
warnAfterMs,
);
return {
profileId,
provider: credential.provider,
type: "token",
status,
expiresAt,
remainingMs,
source,
label,
};
}
const { status, remainingMs } = resolveOAuthStatus(
credential.expires,
now,
@@ -192,16 +225,18 @@ export function buildAuthHealthSummary(params: {
}
const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth");
const tokenProfiles = provider.profiles.filter((p) => p.type === "token");
const apiKeyProfiles = provider.profiles.filter(
(p) => p.type === "api_key",
);
if (oauthProfiles.length === 0) {
const expirable = [...oauthProfiles, ...tokenProfiles];
if (expirable.length === 0) {
provider.status = apiKeyProfiles.length > 0 ? "static" : "missing";
continue;
}
const expiryCandidates = oauthProfiles
const expiryCandidates = expirable
.map((p) => p.expiresAt)
.filter((v): v is number => typeof v === "number" && Number.isFinite(v));
if (expiryCandidates.length > 0) {
@@ -209,7 +244,7 @@ export function buildAuthHealthSummary(params: {
provider.remainingMs = provider.expiresAt - now;
}
const statuses = oauthProfiles.map((p) => p.status);
const statuses = expirable.map((p) => p.status);
if (statuses.includes("expired") || statuses.includes("missing")) {
provider.status = "expired";
} else if (statuses.includes("expiring")) {
+251 -231
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import {
type AuthProfileStore,
CLAUDE_CLI_PROFILE_ID,
@@ -13,40 +14,6 @@ import {
resolveAuthProfileOrder,
} from "./auth-profiles.js";
const HOME_ENV_KEYS = ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"] as const;
type HomeEnvSnapshot = Record<
(typeof HOME_ENV_KEYS)[number],
string | undefined
>;
const snapshotHomeEnv = (): HomeEnvSnapshot => ({
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
HOMEDRIVE: process.env.HOMEDRIVE,
HOMEPATH: process.env.HOMEPATH,
});
const restoreHomeEnv = (snapshot: HomeEnvSnapshot) => {
for (const key of HOME_ENV_KEYS) {
const value = snapshot[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
};
const setTempHome = (tempHome: string) => {
process.env.HOME = tempHome;
if (process.platform === "win32") {
process.env.USERPROFILE = tempHome;
const root = path.parse(tempHome).root;
process.env.HOMEDRIVE = root.replace(/\\$/, "");
process.env.HOMEPATH = tempHome.slice(root.length - 1);
}
};
describe("resolveAuthProfileOrder", () => {
const store: AuthProfileStore = {
version: 1,
@@ -130,6 +97,60 @@ describe("resolveAuthProfileOrder", () => {
expect(order).toEqual(["anthropic:work", "anthropic:default"]);
});
it("prefers store order over config order", () => {
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: { anthropic: ["anthropic:default", "anthropic:work"] },
profiles: cfg.auth.profiles,
},
},
store: {
...store,
order: { anthropic: ["anthropic:work", "anthropic:default"] },
},
provider: "anthropic",
});
expect(order).toEqual(["anthropic:work", "anthropic:default"]);
});
it("pushes cooldown profiles to the end even with store order", () => {
const now = Date.now();
const order = resolveAuthProfileOrder({
store: {
...store,
order: { anthropic: ["anthropic:default", "anthropic:work"] },
usageStats: {
"anthropic:default": { cooldownUntil: now + 60_000 },
"anthropic:work": { lastUsed: 1 },
},
},
provider: "anthropic",
});
expect(order).toEqual(["anthropic:work", "anthropic:default"]);
});
it("pushes cooldown profiles to the end even with configured order", () => {
const now = Date.now();
const order = resolveAuthProfileOrder({
cfg: {
auth: {
order: { anthropic: ["anthropic:default", "anthropic:work"] },
profiles: cfg.auth.profiles,
},
},
store: {
...store,
usageStats: {
"anthropic:default": { cooldownUntil: now + 60_000 },
"anthropic:work": { lastUsed: 1 },
},
},
provider: "anthropic",
});
expect(order).toEqual(["anthropic:work", "anthropic:default"]);
});
it("normalizes z.ai aliases in auth.order", () => {
const order = resolveAuthProfileOrder({
cfg: {
@@ -377,260 +398,259 @@ describe("auth profile cooldowns", () => {
});
describe("external CLI credential sync", () => {
it("syncs Claude CLI credentials into anthropic:claude-cli", () => {
it("syncs Claude CLI credentials into anthropic:claude-cli", async () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-cli-sync-"),
);
const originalHome = snapshotHomeEnv();
try {
// Create a temp home with Claude CLI credentials
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-"));
setTempHome(tempHome);
// Create Claude CLI credentials
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
const claudeCreds = {
claudeAiOauth: {
accessToken: "fresh-access-token",
refreshToken: "fresh-refresh-token",
expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour from now
},
};
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify(claudeCreds),
);
// Create empty auth-profiles.json
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
await withTempHome(
async (tempHome) => {
// Create Claude CLI credentials
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
const claudeCreds = {
claudeAiOauth: {
accessToken: "fresh-access-token",
refreshToken: "fresh-refresh-token",
expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour from now
},
},
}),
);
};
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify(claudeCreds),
);
// Load the store - should sync from CLI
const store = ensureAuthProfileStore(agentDir);
// Create empty auth-profiles.json
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-default",
},
},
}),
);
expect(store.profiles["anthropic:default"]).toBeDefined();
expect((store.profiles["anthropic:default"] as { key: string }).key).toBe(
"sk-default",
// Load the store - should sync from CLI
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles["anthropic:default"]).toBeDefined();
expect(
(store.profiles["anthropic:default"] as { key: string }).key,
).toBe("sk-default");
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
expect(
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token,
).toBe("fresh-access-token");
expect(
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { expires: number })
.expires,
).toBeGreaterThan(Date.now());
},
{ prefix: "clawdbot-home-" },
);
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
expect(
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access,
).toBe("fresh-access-token");
expect(
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { expires: number }).expires,
).toBeGreaterThan(Date.now());
} finally {
restoreHomeEnv(originalHome);
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("syncs Codex CLI credentials into openai-codex:codex-cli", () => {
it("syncs Codex CLI credentials into openai-codex:codex-cli", async () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-codex-sync-"),
);
const originalHome = snapshotHomeEnv();
try {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-"));
setTempHome(tempHome);
await withTempHome(
async (tempHome) => {
// Create Codex CLI credentials
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexCreds = {
tokens: {
access_token: "codex-access-token",
refresh_token: "codex-refresh-token",
},
};
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(codexAuthPath, JSON.stringify(codexCreds));
// Create Codex CLI credentials
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexCreds = {
tokens: {
access_token: "codex-access-token",
refresh_token: "codex-refresh-token",
// Create empty auth-profiles.json
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeDefined();
expect(
(store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access,
).toBe("codex-access-token");
},
};
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(codexAuthPath, JSON.stringify(codexCreds));
// Create empty auth-profiles.json
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {},
}),
{ prefix: "clawdbot-home-" },
);
const store = ensureAuthProfileStore(agentDir);
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeDefined();
expect(
(store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access,
).toBe("codex-access-token");
} finally {
restoreHomeEnv(originalHome);
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("does not overwrite API keys when syncing external CLI creds", () => {
it("does not overwrite API keys when syncing external CLI creds", async () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-no-overwrite-"),
);
const originalHome = snapshotHomeEnv();
try {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-"));
setTempHome(tempHome);
// Create Claude CLI credentials
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
const claudeCreds = {
claudeAiOauth: {
accessToken: "cli-access",
refreshToken: "cli-refresh",
expiresAt: Date.now() + 30 * 60 * 1000,
},
};
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify(claudeCreds),
);
// Create auth-profiles.json with an API key
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-store",
await withTempHome(
async (tempHome) => {
// Create Claude CLI credentials
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
const claudeCreds = {
claudeAiOauth: {
accessToken: "cli-access",
refreshToken: "cli-refresh",
expiresAt: Date.now() + 30 * 60 * 1000,
},
},
}),
);
};
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify(claudeCreds),
);
const store = ensureAuthProfileStore(agentDir);
// Create auth-profiles.json with an API key
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
"anthropic:default": {
type: "api_key",
provider: "anthropic",
key: "sk-store",
},
},
}),
);
// Should keep the store's API key and still add the CLI profile.
expect((store.profiles["anthropic:default"] as { key: string }).key).toBe(
"sk-store",
const store = ensureAuthProfileStore(agentDir);
// Should keep the store's API key and still add the CLI profile.
expect(
(store.profiles["anthropic:default"] as { key: string }).key,
).toBe("sk-store");
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
},
{ prefix: "clawdbot-home-" },
);
expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined();
} finally {
restoreHomeEnv(originalHome);
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("does not overwrite fresher store OAuth with older Claude CLI credentials", () => {
it("does not overwrite fresher store token with older Claude CLI credentials", async () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-"),
);
const originalHome = snapshotHomeEnv();
try {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-"));
setTempHome(tempHome);
await withTempHome(
async (tempHome) => {
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify({
claudeAiOauth: {
accessToken: "cli-access",
refreshToken: "cli-refresh",
expiresAt: Date.now() + 30 * 60 * 1000,
},
}),
);
const claudeDir = path.join(tempHome, ".claude");
fs.mkdirSync(claudeDir, { recursive: true });
fs.writeFileSync(
path.join(claudeDir, ".credentials.json"),
JSON.stringify({
claudeAiOauth: {
accessToken: "cli-access",
refreshToken: "cli-refresh",
expiresAt: Date.now() + 30 * 60 * 1000,
},
}),
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CLAUDE_CLI_PROFILE_ID]: {
type: "token",
provider: "anthropic",
token: "store-access",
expires: Date.now() + 60 * 60 * 1000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { token: string }).token,
).toBe("store-access");
},
{ prefix: "clawdbot-home-" },
);
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CLAUDE_CLI_PROFILE_ID]: {
type: "oauth",
provider: "anthropic",
access: "store-access",
refresh: "store-refresh",
expires: Date.now() + 60 * 60 * 1000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(
(store.profiles[CLAUDE_CLI_PROFILE_ID] as { access: string }).access,
).toBe("store-access");
} finally {
restoreHomeEnv(originalHome);
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
it("updates codex-cli profile when Codex CLI refresh token changes", () => {
it("updates codex-cli profile when Codex CLI refresh token changes", async () => {
const agentDir = fs.mkdtempSync(
path.join(os.tmpdir(), "clawdbot-codex-refresh-sync-"),
);
const originalHome = snapshotHomeEnv();
try {
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-home-"));
setTempHome(tempHome);
await withTempHome(
async (tempHome) => {
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(
codexAuthPath,
JSON.stringify({
tokens: {
access_token: "same-access",
refresh_token: "new-refresh",
},
}),
);
fs.utimesSync(codexAuthPath, new Date(), new Date());
const codexDir = path.join(tempHome, ".codex");
fs.mkdirSync(codexDir, { recursive: true });
const codexAuthPath = path.join(codexDir, "auth.json");
fs.writeFileSync(
codexAuthPath,
JSON.stringify({
tokens: { access_token: "same-access", refresh_token: "new-refresh" },
}),
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CODEX_CLI_PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
access: "same-access",
refresh: "old-refresh",
expires: Date.now() - 1000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(
(store.profiles[CODEX_CLI_PROFILE_ID] as { refresh: string })
.refresh,
).toBe("new-refresh");
},
{ prefix: "clawdbot-home-" },
);
fs.utimesSync(codexAuthPath, new Date(), new Date());
const authPath = path.join(agentDir, "auth-profiles.json");
fs.writeFileSync(
authPath,
JSON.stringify({
version: 1,
profiles: {
[CODEX_CLI_PROFILE_ID]: {
type: "oauth",
provider: "openai-codex",
access: "same-access",
refresh: "old-refresh",
expires: Date.now() - 1000,
},
},
}),
);
const store = ensureAuthProfileStore(agentDir);
expect(
(store.profiles[CODEX_CLI_PROFILE_ID] as { refresh: string }).refresh,
).toBe("new-refresh");
} finally {
restoreHomeEnv(originalHome);
fs.rmSync(agentDir, { recursive: true, force: true });
}
});
+206 -35
View File
@@ -48,13 +48,29 @@ export type ApiKeyCredential = {
email?: string;
};
export type TokenCredential = {
/**
* Static bearer-style token (often OAuth access token / PAT).
* Not refreshable by clawdbot (unlike `type: "oauth"`).
*/
type: "token";
provider: string;
token: string;
/** Optional expiry timestamp (ms since epoch). */
expires?: number;
email?: string;
};
export type OAuthCredential = OAuthCredentials & {
type: "oauth";
provider: OAuthProvider;
email?: string;
};
export type AuthProfileCredential = ApiKeyCredential | OAuthCredential;
export type AuthProfileCredential =
| ApiKeyCredential
| TokenCredential
| OAuthCredential;
/** Per-profile usage statistics for round-robin and cooldown tracking */
export type ProfileUsageStats = {
@@ -66,6 +82,12 @@ export type ProfileUsageStats = {
export type AuthProfileStore = {
version: number;
profiles: Record<string, AuthProfileCredential>;
/**
* Optional per-agent preferred profile order overrides.
* This lets you lock/override auth rotation for a specific agent without
* changing the global config.
*/
order?: Record<string, string[]>;
lastGood?: Record<string, string>;
/** Usage statistics per profile for round-robin rotation */
usageStats?: Record<string, ProfileUsageStats>;
@@ -117,6 +139,7 @@ function syncAuthProfileStore(
): void {
target.version = source.version;
target.profiles = source.profiles;
target.order = source.order;
target.lastGood = source.lastGood;
target.usageStats = source.usageStats;
}
@@ -220,7 +243,13 @@ function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
for (const [key, value] of Object.entries(record)) {
if (!value || typeof value !== "object") continue;
const typed = value as Partial<AuthProfileCredential>;
if (typed.type !== "api_key" && typed.type !== "oauth") continue;
if (
typed.type !== "api_key" &&
typed.type !== "oauth" &&
typed.type !== "token"
) {
continue;
}
entries[key] = {
...typed,
provider: typed.provider ?? (key as OAuthProvider),
@@ -238,13 +267,35 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null {
for (const [key, value] of Object.entries(profiles)) {
if (!value || typeof value !== "object") continue;
const typed = value as Partial<AuthProfileCredential>;
if (typed.type !== "api_key" && typed.type !== "oauth") continue;
if (
typed.type !== "api_key" &&
typed.type !== "oauth" &&
typed.type !== "token"
) {
continue;
}
if (!typed.provider) continue;
normalized[key] = typed as AuthProfileCredential;
}
const order =
record.order && typeof record.order === "object"
? Object.entries(record.order as Record<string, unknown>).reduce(
(acc, [provider, value]) => {
if (!Array.isArray(value)) return acc;
const list = value
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter(Boolean);
if (list.length === 0) return acc;
acc[provider] = list;
return acc;
},
{} as Record<string, string[]>,
)
: undefined;
return {
version: Number(record.version ?? AUTH_STORE_VERSION),
profiles: normalized,
order,
lastGood:
record.lastGood && typeof record.lastGood === "object"
? (record.lastGood as Record<string, string>)
@@ -285,7 +336,7 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
*/
function readClaudeCliCredentials(options?: {
allowKeychainPrompt?: boolean;
}): OAuthCredential | null {
}): TokenCredential | null {
if (process.platform === "darwin" && options?.allowKeychainPrompt !== false) {
const keychainCreds = readClaudeCliKeychainCredentials();
if (keychainCreds) {
@@ -306,18 +357,15 @@ function readClaudeCliCredentials(options?: {
if (!claudeOauth || typeof claudeOauth !== "object") return null;
const accessToken = claudeOauth.accessToken;
const refreshToken = claudeOauth.refreshToken;
const expiresAt = claudeOauth.expiresAt;
if (typeof accessToken !== "string" || !accessToken) return null;
if (typeof refreshToken !== "string" || !refreshToken) return null;
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
return {
type: "oauth",
type: "token",
provider: "anthropic",
access: accessToken,
refresh: refreshToken,
token: accessToken,
expires: expiresAt,
};
}
@@ -326,7 +374,7 @@ function readClaudeCliCredentials(options?: {
* Read Claude Code credentials from macOS keychain.
* Uses the `security` CLI to access keychain without native dependencies.
*/
function readClaudeCliKeychainCredentials(): OAuthCredential | null {
function readClaudeCliKeychainCredentials(): TokenCredential | null {
try {
const result = execSync(
'security find-generic-password -s "Claude Code-credentials" -w',
@@ -338,18 +386,15 @@ function readClaudeCliKeychainCredentials(): OAuthCredential | null {
if (!claudeOauth || typeof claudeOauth !== "object") return null;
const accessToken = claudeOauth.accessToken;
const refreshToken = claudeOauth.refreshToken;
const expiresAt = claudeOauth.expiresAt;
if (typeof accessToken !== "string" || !accessToken) return null;
if (typeof refreshToken !== "string" || !refreshToken) return null;
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
return {
type: "oauth",
type: "token",
provider: "anthropic",
access: accessToken,
refresh: refreshToken,
token: accessToken,
expires: expiresAt,
};
} catch {
@@ -416,6 +461,20 @@ function shallowEqualOAuthCredentials(
);
}
function shallowEqualTokenCredentials(
a: TokenCredential | undefined,
b: TokenCredential,
): boolean {
if (!a) return false;
if (a.type !== "token") return false;
return (
a.provider === b.provider &&
a.token === b.token &&
a.expires === b.expires &&
a.email === b.email
);
}
/**
* Sync OAuth credentials from external CLI tools (Claude CLI, Codex CLI) into the store.
* This allows clawdbot to use the same credentials as these tools without requiring
@@ -434,25 +493,28 @@ function syncExternalCliCredentials(
const claudeCreds = readClaudeCliCredentials(options);
if (claudeCreds) {
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
const existingToken = existing?.type === "token" ? existing : undefined;
// Update if: no existing profile, existing is not oauth, or CLI has newer/valid token
const shouldUpdate =
!existingOAuth ||
existingOAuth.provider !== "anthropic" ||
existingOAuth.expires <= now ||
(claudeCreds.expires > now &&
claudeCreds.expires > existingOAuth.expires);
!existingToken ||
existingToken.provider !== "anthropic" ||
(existingToken.expires ?? 0) <= now ||
((claudeCreds.expires ?? 0) > now &&
(claudeCreds.expires ?? 0) > (existingToken.expires ?? 0));
if (
shouldUpdate &&
!shallowEqualOAuthCredentials(existingOAuth, claudeCreds)
!shallowEqualTokenCredentials(existingToken, claudeCreds)
) {
store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds;
mutated = true;
log.info("synced anthropic credentials from claude cli", {
profileId: CLAUDE_CLI_PROFILE_ID,
expires: new Date(claudeCreds.expires).toISOString(),
expires:
typeof claudeCreds.expires === "number"
? new Date(claudeCreds.expires).toISOString()
: "unknown",
});
}
}
@@ -515,6 +577,16 @@ export function loadAuthProfileStore(): AuthProfileStore {
key: cred.key,
...(cred.email ? { email: cred.email } : {}),
};
} else if (cred.type === "token") {
store.profiles[profileId] = {
type: "token",
provider: cred.provider ?? (provider as OAuthProvider),
token: cred.token,
...(typeof cred.expires === "number"
? { expires: cred.expires }
: {}),
...(cred.email ? { email: cred.email } : {}),
};
} else {
store.profiles[profileId] = {
type: "oauth",
@@ -570,6 +642,16 @@ export function ensureAuthProfileStore(
key: cred.key,
...(cred.email ? { email: cred.email } : {}),
};
} else if (cred.type === "token") {
store.profiles[profileId] = {
type: "token",
provider: cred.provider ?? (provider as OAuthProvider),
token: cred.token,
...(typeof cred.expires === "number"
? { expires: cred.expires }
: {}),
...(cred.email ? { email: cred.email } : {}),
};
} else {
store.profiles[profileId] = {
type: "oauth",
@@ -621,12 +703,47 @@ export function saveAuthProfileStore(
const payload = {
version: AUTH_STORE_VERSION,
profiles: store.profiles,
order: store.order ?? undefined,
lastGood: store.lastGood ?? undefined,
usageStats: store.usageStats ?? undefined,
} satisfies AuthProfileStore;
saveJsonFile(authPath, payload);
}
export async function setAuthProfileOrder(params: {
agentDir?: string;
provider: string;
order?: string[] | null;
}): Promise<AuthProfileStore | null> {
const providerKey = normalizeProviderId(params.provider);
const sanitized =
params.order && Array.isArray(params.order)
? params.order.map((entry) => String(entry).trim()).filter(Boolean)
: [];
const deduped: string[] = [];
for (const entry of sanitized) {
if (!deduped.includes(entry)) deduped.push(entry);
}
return await updateAuthProfileStoreWithLock({
agentDir: params.agentDir,
updater: (store) => {
store.order = store.order ?? {};
if (deduped.length === 0) {
if (!store.order[providerKey]) return false;
delete store.order[providerKey];
if (Object.keys(store.order).length === 0) {
store.order = undefined;
}
return true;
}
store.order[providerKey] = deduped;
return true;
},
});
}
export function upsertAuthProfile(params: {
profileId: string;
credential: AuthProfileCredential;
@@ -804,6 +921,14 @@ export function resolveAuthProfileOrder(params: {
}): string[] {
const { cfg, store, provider, preferredProfile } = params;
const providerKey = normalizeProviderId(provider);
const storedOrder = (() => {
const order = store.order;
if (!order) return undefined;
for (const [key, value] of Object.entries(order)) {
if (normalizeProviderId(key) === providerKey) return value;
}
return undefined;
})();
const configuredOrder = (() => {
const order = cfg?.auth?.order;
if (!order) return undefined;
@@ -812,6 +937,7 @@ export function resolveAuthProfileOrder(params: {
}
return undefined;
})();
const explicitOrder = storedOrder ?? configuredOrder;
const explicitProfiles = cfg?.auth?.profiles
? Object.entries(cfg.auth.profiles)
.filter(
@@ -821,7 +947,7 @@ export function resolveAuthProfileOrder(params: {
.map(([profileId]) => profileId)
: [];
const baseOrder =
configuredOrder ??
explicitOrder ??
(explicitProfiles.length > 0
? explicitProfiles
: listProfilesForProvider(store, providerKey));
@@ -836,16 +962,44 @@ export function resolveAuthProfileOrder(params: {
if (!deduped.includes(entry)) deduped.push(entry);
}
// If user specified explicit order in config, respect it exactly
if (configuredOrder && configuredOrder.length > 0) {
// If user specified explicit order (store override or config), respect it
// exactly, but still apply cooldown sorting to avoid repeatedly selecting
// known-bad/rate-limited keys as the first candidate.
if (explicitOrder && explicitOrder.length > 0) {
// ...but still respect cooldown tracking to avoid repeatedly selecting a
// known-bad/rate-limited key as the first candidate.
const now = Date.now();
const available: string[] = [];
const inCooldown: Array<{ profileId: string; cooldownUntil: number }> = [];
for (const profileId of deduped) {
const cooldownUntil = store.usageStats?.[profileId]?.cooldownUntil;
if (
typeof cooldownUntil === "number" &&
Number.isFinite(cooldownUntil) &&
cooldownUntil > 0 &&
now < cooldownUntil
) {
inCooldown.push({ profileId, cooldownUntil });
} else {
available.push(profileId);
}
}
const cooldownSorted = inCooldown
.sort((a, b) => a.cooldownUntil - b.cooldownUntil)
.map((entry) => entry.profileId);
const ordered = [...available, ...cooldownSorted];
// Still put preferredProfile first if specified
if (preferredProfile && deduped.includes(preferredProfile)) {
if (preferredProfile && ordered.includes(preferredProfile)) {
return [
preferredProfile,
...deduped.filter((e) => e !== preferredProfile),
...ordered.filter((e) => e !== preferredProfile),
];
}
return deduped;
return ordered;
}
// Otherwise, use round-robin: sort by lastUsed (oldest first)
@@ -882,16 +1036,17 @@ function orderProfilesByMode(
// Then by lastUsed (oldest first = round-robin within type)
const scored = available.map((profileId) => {
const type = store.profiles[profileId]?.type;
const typeScore = type === "oauth" ? 0 : type === "api_key" ? 1 : 2;
const typeScore =
type === "oauth" ? 0 : type === "token" ? 1 : type === "api_key" ? 2 : 3;
const lastUsed = store.usageStats?.[profileId]?.lastUsed ?? 0;
return { profileId, typeScore, lastUsed };
});
// Primary sort: type preference (oauth > api_key).
// Primary sort: type preference (oauth > token > api_key).
// Secondary sort: lastUsed (oldest first for round-robin within type).
const sorted = scored
.sort((a, b) => {
// First by type (oauth > api_key)
// First by type (oauth > token > api_key)
if (a.typeScore !== b.typeScore) return a.typeScore - b.typeScore;
// Then by lastUsed (oldest first)
return a.lastUsed - b.lastUsed;
@@ -921,11 +1076,27 @@ export async function resolveApiKeyForProfile(params: {
if (!cred) return null;
const profileConfig = cfg?.auth?.profiles?.[profileId];
if (profileConfig && profileConfig.provider !== cred.provider) return null;
if (profileConfig && profileConfig.mode !== cred.type) return null;
if (profileConfig && profileConfig.mode !== cred.type) {
// Compatibility: treat "oauth" config as compatible with stored token profiles.
if (!(profileConfig.mode === "oauth" && cred.type === "token")) return null;
}
if (cred.type === "api_key") {
return { apiKey: cred.key, provider: cred.provider, email: cred.email };
}
if (cred.type === "token") {
const token = cred.token?.trim();
if (!token) return null;
if (
typeof cred.expires === "number" &&
Number.isFinite(cred.expires) &&
cred.expires > 0 &&
Date.now() >= cred.expires
) {
return null;
}
return { apiKey: token, provider: cred.provider, email: cred.email };
}
if (Date.now() < cred.expires) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),
@@ -1016,8 +1187,8 @@ export async function markAuthProfileGood(params: {
saveAuthProfileStore(store, agentDir);
}
export function resolveAuthStorePathForDisplay(): string {
const pathname = resolveAuthStorePath();
export function resolveAuthStorePathForDisplay(agentDir?: string): string {
const pathname = resolveAuthStorePath(agentDir);
return pathname.startsWith("~") ? pathname : resolveUserPath(pathname);
}
+32 -27
View File
@@ -50,35 +50,40 @@ beforeEach(() => {
});
describe("bash tool backgrounding", () => {
it("backgrounds after yield and can be polled", async () => {
const result = await bashTool.execute("call1", {
command: joinCommands([yieldDelayCmd, "echo done"]),
yieldMs: 10,
});
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
let status = "running";
let output = "";
const deadline = Date.now() + (process.platform === "win32" ? 8000 : 2000);
while (Date.now() < deadline && status === "running") {
const poll = await processTool.execute("call2", {
action: "poll",
sessionId,
it(
"backgrounds after yield and can be polled",
async () => {
const result = await bashTool.execute("call1", {
command: joinCommands([yieldDelayCmd, "echo done"]),
yieldMs: 10,
});
status = (poll.details as { status: string }).status;
const textBlock = poll.content.find((c) => c.type === "text");
output = textBlock?.text ?? "";
if (status === "running") {
await sleep(20);
}
}
expect(status).toBe("completed");
expect(output).toContain("done");
});
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
let status = "running";
let output = "";
const deadline =
Date.now() + (process.platform === "win32" ? 8000 : 2000);
while (Date.now() < deadline && status === "running") {
const poll = await processTool.execute("call2", {
action: "poll",
sessionId,
});
status = (poll.details as { status: string }).status;
const textBlock = poll.content.find((c) => c.type === "text");
output = textBlock?.text ?? "";
if (status === "running") {
await sleep(20);
}
}
expect(status).toBe("completed");
expect(output).toContain("done");
},
isWin ? 15_000 : 5_000,
);
it("supports explicit background", async () => {
const result = await bashTool.execute("call1", {
+2 -1
View File
@@ -8,6 +8,7 @@ import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { logInfo } from "../logger.js";
import { sliceUtf16Safe } from "../utils.js";
import {
addSession,
appendOutput,
@@ -1041,7 +1042,7 @@ function chunkString(input: string, limit = CHUNK_LIMIT) {
function truncateMiddle(str: string, max: number) {
if (str.length <= max) return str;
const half = Math.floor((max - 3) / 2);
return `${str.slice(0, half)}...${str.slice(str.length - half)}`;
return `${sliceUtf16Safe(str, 0, half)}...${sliceUtf16Safe(str, -half)}`;
}
function sliceLogLines(
+5 -3
View File
@@ -108,7 +108,7 @@ function formatUserTime(date: Date, timeZone: string): string | undefined {
}
function buildModelAliasLines(cfg?: ClawdbotConfig) {
const models = cfg?.agent?.models ?? {};
const models = cfg?.agents?.defaults?.models ?? {};
const entries: Array<{ alias: string; model: string }> = [];
for (const [keyRaw, entryRaw] of Object.entries(models)) {
const model = String(keyRaw ?? "").trim();
@@ -134,7 +134,9 @@ function buildSystemPrompt(params: {
contextFiles?: EmbeddedContextFile[];
modelDisplay: string;
}) {
const userTimezone = resolveUserTimezone(params.config?.agent?.userTimezone);
const userTimezone = resolveUserTimezone(
params.config?.agents?.defaults?.userTimezone,
);
const userTime = formatUserTime(new Date(), userTimezone);
return buildAgentSystemPrompt({
workspaceDir: params.workspaceDir,
@@ -143,7 +145,7 @@ function buildSystemPrompt(params: {
ownerNumbers: params.ownerNumbers,
reasoningTagHint: false,
heartbeatPrompt: resolveHeartbeatPrompt(
params.config?.agent?.heartbeat?.prompt,
params.config?.agents?.defaults?.heartbeat?.prompt,
),
runtimeInfo: {
host: "clawdbot",
+1 -1
View File
@@ -46,7 +46,7 @@ describe("gateway tool", () => {
expect(tool).toBeDefined();
if (!tool) throw new Error("missing gateway tool");
const raw = '{\n agent: { workspace: "~/clawd" }\n}\n';
const raw = '{\n agents: { defaults: { workspace: "~/clawd" } }\n}\n';
await tool.execute("call2", {
action: "config.apply",
raw,
+21 -15
View File
@@ -52,18 +52,20 @@ describe("agents_list", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
name: "Main",
subagents: {
allowAgents: ["research"],
},
},
research: {
{
id: "research",
name: "Research",
},
},
],
},
};
@@ -87,20 +89,23 @@ describe("agents_list", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["*"],
},
},
research: {
{
id: "research",
name: "Research",
},
coder: {
{
id: "coder",
name: "Coder",
},
},
],
},
};
@@ -131,14 +136,15 @@ describe("agents_list", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["research"],
},
},
},
],
},
};
+20 -16
View File
@@ -314,14 +314,15 @@ describe("subagents", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["beta"],
},
},
},
],
},
};
@@ -365,14 +366,15 @@ describe("subagents", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["*"],
},
},
},
],
},
};
@@ -416,14 +418,15 @@ describe("subagents", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["Research"],
},
},
},
],
},
};
@@ -467,14 +470,15 @@ describe("subagents", () => {
mainKey: "main",
scope: "per-sender",
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
subagents: {
allowAgents: ["alpha"],
},
},
},
],
},
};
+1 -9
View File
@@ -4,7 +4,6 @@ import { createBrowserTool } from "./tools/browser-tool.js";
import { createCanvasTool } from "./tools/canvas-tool.js";
import type { AnyAgentTool } from "./tools/common.js";
import { createCronTool } from "./tools/cron-tool.js";
import { createDiscordTool } from "./tools/discord-tool.js";
import { createGatewayTool } from "./tools/gateway-tool.js";
import { createImageTool } from "./tools/image-tool.js";
import { createMessageTool } from "./tools/message-tool.js";
@@ -13,9 +12,6 @@ import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
import { createSlackTool } from "./tools/slack-tool.js";
import { createTelegramTool } from "./tools/telegram-tool.js";
import { createWhatsAppTool } from "./tools/whatsapp-tool.js";
export function createClawdbotTools(options?: {
browserControlUrl?: string;
@@ -35,14 +31,10 @@ export function createClawdbotTools(options?: {
createCanvasTool(),
createNodesTool(),
createCronTool(),
createDiscordTool(),
createMessageTool(),
createSlackTool({
createMessageTool({
agentAccountId: options?.agentAccountId,
config: options?.config,
}),
createTelegramTool(),
createWhatsAppTool(),
createGatewayTool({
agentSessionKey: options?.agentSessionKey,
config: options?.config,
+69
View File
@@ -0,0 +1,69 @@
import type { ClawdbotConfig, IdentityConfig } from "../config/config.js";
import { resolveAgentConfig } from "./agent-scope.js";
const DEFAULT_ACK_REACTION = "👀";
export function resolveAgentIdentity(
cfg: ClawdbotConfig,
agentId: string,
): IdentityConfig | undefined {
return resolveAgentConfig(cfg, agentId)?.identity;
}
export function resolveAckReaction(
cfg: ClawdbotConfig,
agentId: string,
): string {
const configured = cfg.messages?.ackReaction;
if (configured !== undefined) return configured.trim();
const emoji = resolveAgentIdentity(cfg, agentId)?.emoji?.trim();
return emoji || DEFAULT_ACK_REACTION;
}
export function resolveIdentityNamePrefix(
cfg: ClawdbotConfig,
agentId: string,
): string | undefined {
const name = resolveAgentIdentity(cfg, agentId)?.name?.trim();
if (!name) return undefined;
return `[${name}]`;
}
export function resolveMessagePrefix(
cfg: ClawdbotConfig,
agentId: string,
opts?: { hasAllowFrom?: boolean; fallback?: string },
): string {
const configured = cfg.messages?.messagePrefix;
if (configured !== undefined) return configured;
const hasAllowFrom = opts?.hasAllowFrom === true;
if (hasAllowFrom) return "";
return (
resolveIdentityNamePrefix(cfg, agentId) ?? opts?.fallback ?? "[clawdbot]"
);
}
export function resolveResponsePrefix(
cfg: ClawdbotConfig,
agentId: string,
): string | undefined {
const configured = cfg.messages?.responsePrefix;
if (configured !== undefined) return configured;
return resolveIdentityNamePrefix(cfg, agentId);
}
export function resolveEffectiveMessagesConfig(
cfg: ClawdbotConfig,
agentId: string,
opts?: { hasAllowFrom?: boolean; fallbackMessagePrefix?: string },
): { messagePrefix: string; responsePrefix?: string } {
return {
messagePrefix: resolveMessagePrefix(cfg, agentId, {
hasAllowFrom: opts?.hasAllowFrom,
fallback: opts?.fallbackMessagePrefix,
}),
responsePrefix: resolveResponsePrefix(cfg, agentId),
};
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? "";
const MINIMAX_BASE_URL =
process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/v1";
const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "minimax-m2.1";
const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1";
const LIVE = process.env.MINIMAX_LIVE_TEST === "1" || process.env.LIVE === "1";
const describeLive = LIVE && MINIMAX_KEY ? describe : describe.skip;
+8 -3
View File
@@ -100,7 +100,7 @@ export async function resolveApiKeyForProvider(params: {
}
export type EnvApiKeyResult = { apiKey: string; source: string };
export type ModelAuthMode = "api-key" | "oauth" | "mixed" | "unknown";
export type ModelAuthMode = "api-key" | "oauth" | "token" | "mixed" | "unknown";
export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
const applied = new Set(getShellEnvAppliedKeys());
@@ -136,6 +136,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
cerebras: "CEREBRAS_API_KEY",
xai: "XAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
minimax: "MINIMAX_API_KEY",
zai: "ZAI_API_KEY",
mistral: "MISTRAL_API_KEY",
};
@@ -158,10 +159,14 @@ export function resolveModelAuthMode(
const modes = new Set(
profiles
.map((id) => authStore.profiles[id]?.type)
.filter((mode): mode is "api_key" | "oauth" => Boolean(mode)),
.filter((mode): mode is "api_key" | "oauth" | "token" => Boolean(mode)),
);
if (modes.has("oauth") && modes.has("api_key")) return "mixed";
const distinct = ["oauth", "token", "api_key"].filter((k) =>
modes.has(k as "oauth" | "token" | "api_key"),
);
if (distinct.length >= 2) return "mixed";
if (modes.has("oauth")) return "oauth";
if (modes.has("token")) return "token";
if (modes.has("api_key")) return "api-key";
}
+5 -5
View File
@@ -34,7 +34,7 @@ function buildAllowedModelKeys(
defaultProvider: string,
): Set<string> | null {
const rawAllowlist = (() => {
const modelMap = cfg?.agent?.models ?? {};
const modelMap = cfg?.agents?.defaults?.models ?? {};
return Object.keys(modelMap);
})();
if (rawAllowlist.length === 0) return null;
@@ -85,7 +85,7 @@ function resolveImageFallbackCandidates(params: {
if (params.modelOverride?.trim()) {
addRaw(params.modelOverride, false);
} else {
const imageModel = params.cfg?.agent?.imageModel as
const imageModel = params.cfg?.agents?.defaults?.imageModel as
| { primary?: string }
| string
| undefined;
@@ -95,7 +95,7 @@ function resolveImageFallbackCandidates(params: {
}
const imageFallbacks = (() => {
const imageModel = params.cfg?.agent?.imageModel as
const imageModel = params.cfg?.agents?.defaults?.imageModel as
| { fallbacks?: string[] }
| string
| undefined;
@@ -142,7 +142,7 @@ function resolveFallbackCandidates(params: {
addCandidate({ provider, model }, false);
const modelFallbacks = (() => {
const model = params.cfg?.agent?.model as
const model = params.cfg?.agents?.defaults?.model as
| { fallbacks?: string[] }
| string
| undefined;
@@ -253,7 +253,7 @@ export async function runWithImageModelFallback<T>(params: {
});
if (candidates.length === 0) {
throw new Error(
"No image model configured. Set agent.imageModel.primary or agent.imageModel.fallbacks.",
"No image model configured. Set agents.defaults.imageModel.primary or agents.defaults.imageModel.fallbacks.",
);
}
+6 -4
View File
@@ -18,9 +18,11 @@ const catalog = [
describe("buildAllowedModelSet", () => {
it("always allows the configured default model", () => {
const cfg = {
agent: {
models: {
"openai/gpt-4": { alias: "gpt4" },
agents: {
defaults: {
models: {
"openai/gpt-4": { alias: "gpt4" },
},
},
},
} as ClawdbotConfig;
@@ -41,7 +43,7 @@ describe("buildAllowedModelSet", () => {
it("includes the default model when no allowlist is set", () => {
const cfg = {
agent: {},
agents: { defaults: {} },
} as ClawdbotConfig;
const allowed = buildAllowedModelSet({
+5 -5
View File
@@ -65,7 +65,7 @@ export function buildModelAliasIndex(params: {
const byAlias = new Map<string, { alias: string; ref: ModelRef }>();
const byKey = new Map<string, string[]>();
const rawModels = params.cfg.agent?.models ?? {};
const rawModels = params.cfg.agents?.defaults?.models ?? {};
for (const [keyRaw, entryRaw] of Object.entries(rawModels)) {
const parsed = parseModelRef(String(keyRaw ?? ""), params.defaultProvider);
if (!parsed) continue;
@@ -109,7 +109,7 @@ export function resolveConfiguredModelRef(params: {
defaultModel: string;
}): ModelRef {
const rawModel = (() => {
const raw = params.cfg.agent?.model as
const raw = params.cfg.agents?.defaults?.model as
| { primary?: string }
| string
| undefined;
@@ -128,7 +128,7 @@ export function resolveConfiguredModelRef(params: {
aliasIndex,
});
if (resolved) return resolved.ref;
// TODO(steipete): drop this fallback once provider-less agent.model is fully deprecated.
// TODO(steipete): drop this fallback once provider-less agents.defaults.model is fully deprecated.
return { provider: "anthropic", model: trimmed };
}
return { provider: params.defaultProvider, model: params.defaultModel };
@@ -145,7 +145,7 @@ export function buildAllowedModelSet(params: {
allowedKeys: Set<string>;
} {
const rawAllowlist = (() => {
const modelMap = params.cfg.agent?.models ?? {};
const modelMap = params.cfg.agents?.defaults?.models ?? {};
return Object.keys(modelMap);
})();
const allowAny = rawAllowlist.length === 0;
@@ -203,7 +203,7 @@ export function resolveThinkingDefault(params: {
model: string;
catalog?: ModelCatalogEntry[];
}): ThinkLevel {
const configured = params.cfg.agent?.thinkingDefault;
const configured = params.cfg.agents?.defaults?.thinkingDefault;
if (configured) return configured;
const candidate = params.catalog?.find(
(entry) => entry.provider === params.provider && entry.id === params.model,
+2 -10
View File
@@ -1,20 +1,12 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-models-"));
const previousHome = process.env.HOME;
process.env.HOME = base;
try {
return await fn(base);
} finally {
process.env.HOME = previousHome;
await fs.rm(base, { recursive: true, force: true });
}
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
}
const MODELS_CONFIG: ClawdbotConfig = {
@@ -110,12 +110,14 @@ describe("resolveExtraParams", () => {
it("respects explicit thinking config from user (disable thinking)", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
params: {
thinking: {
type: "disabled",
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
thinking: {
type: "disabled",
},
},
},
},
@@ -136,12 +138,14 @@ describe("resolveExtraParams", () => {
it("preserves other params while adding thinking config", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
params: {
temperature: 0.7,
max_tokens: 4096,
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
temperature: 0.7,
max_tokens: 4096,
},
},
},
},
@@ -164,13 +168,15 @@ describe("resolveExtraParams", () => {
it("does not override explicit thinking config even if partial", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
params: {
thinking: {
type: "enabled",
// User explicitly omitted clear_thinking
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
thinking: {
type: "enabled",
// User explicitly omitted clear_thinking
},
},
},
},
@@ -214,12 +220,14 @@ describe("resolveExtraParams", () => {
it("passes through params for non-GLM models without modification", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"openai/gpt-4": {
params: {
logprobs: true,
top_logprobs: 5,
agents: {
defaults: {
models: {
"openai/gpt-4": {
params: {
logprobs: true,
top_logprobs: 5,
},
},
},
},
@@ -264,7 +272,7 @@ describe("resolveExtraParams", () => {
it("handles config with empty models gracefully", () => {
const result = resolveExtraParams({
cfg: { agent: { models: {} } },
cfg: { agents: { defaults: { models: {} } } },
provider: "zai",
modelId: "glm-4.7",
});
@@ -280,12 +288,14 @@ describe("resolveExtraParams", () => {
it("model alias lookup uses exact provider/model key", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
alias: "smart",
params: {
custom_param: "value",
agents: {
defaults: {
models: {
"zai/glm-4.7": {
alias: "smart",
params: {
custom_param: "value",
},
},
},
},
@@ -307,11 +317,13 @@ describe("resolveExtraParams", () => {
it("treats thinking: null as explicit config (no auto-enable)", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
params: {
thinking: null,
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
thinking: null,
},
},
},
},
@@ -374,11 +386,13 @@ describe("resolveExtraParams", () => {
it("thinkLevel: 'off' still passes through explicit config", () => {
const result = resolveExtraParams({
cfg: {
agent: {
models: {
"zai/glm-4.7": {
params: {
custom_param: "value",
agents: {
defaults: {
models: {
"zai/glm-4.7": {
params: {
custom_param: "value",
},
},
},
},
+16 -15
View File
@@ -105,7 +105,7 @@ import { loadWorkspaceBootstrapFiles } from "./workspace.js";
* - GLM-4.5/4.6: Interleaved thinking (clear_thinking: true) - reasoning cleared each turn
*
* Users can override via config:
* agent.models["zai/glm-4.7"].params.thinking = { type: "disabled" }
* agents.defaults.models["zai/glm-4.7"].params.thinking = { type: "disabled" }
*
* Or disable via runtime flag: --thinking off
*
@@ -119,7 +119,7 @@ export function resolveExtraParams(params: {
thinkLevel?: string;
}): Record<string, unknown> | undefined {
const modelKey = `${params.provider}/${params.modelId}`;
const modelConfig = params.cfg?.agent?.models?.[modelKey];
const modelConfig = params.cfg?.agents?.defaults?.models?.[modelKey];
let extraParams = modelConfig?.params ? { ...modelConfig.params } : undefined;
// Auto-enable thinking for ZAI GLM-4.x models when not explicitly configured
@@ -200,10 +200,10 @@ function resolveContextWindowTokens(params: {
if (fromModelsConfig) return fromModelsConfig;
const fromAgentConfig =
typeof params.cfg?.agent?.contextTokens === "number" &&
Number.isFinite(params.cfg.agent.contextTokens) &&
params.cfg.agent.contextTokens > 0
? Math.floor(params.cfg.agent.contextTokens)
typeof params.cfg?.agents?.defaults?.contextTokens === "number" &&
Number.isFinite(params.cfg.agents.defaults.contextTokens) &&
params.cfg.agents.defaults.contextTokens > 0
? Math.floor(params.cfg.agents.defaults.contextTokens)
: undefined;
if (fromAgentConfig) return fromAgentConfig;
@@ -217,7 +217,7 @@ function buildContextPruningExtension(params: {
modelId: string;
model: Model<Api> | undefined;
}): { additionalExtensionPaths?: string[] } {
const raw = params.cfg?.agent?.contextPruning;
const raw = params.cfg?.agents?.defaults?.contextPruning;
if (raw?.mode !== "adaptive" && raw?.mode !== "aggressive") return {};
const settings = computeEffectiveSettings(raw);
@@ -254,7 +254,7 @@ export type EmbeddedPiRunMeta = {
};
function buildModelAliasLines(cfg?: ClawdbotConfig) {
const models = cfg?.agent?.models ?? {};
const models = cfg?.agents?.defaults?.models ?? {};
const entries: Array<{ alias: string; model: string }> = [];
for (const [keyRaw, entryRaw] of Object.entries(models)) {
const model = String(keyRaw ?? "").trim();
@@ -844,7 +844,7 @@ export async function compactEmbeddedPiSession(params: {
const contextFiles = buildBootstrapContextFiles(bootstrapFiles);
const tools = createClawdbotCodingTools({
bash: {
...params.config?.agent?.bash,
...params.config?.tools?.bash,
elevated: params.bashElevated,
},
sandbox,
@@ -865,7 +865,7 @@ export async function compactEmbeddedPiSession(params: {
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
const reasoningTagHint = provider === "ollama";
const userTimezone = resolveUserTimezone(
params.config?.agent?.userTimezone,
params.config?.agents?.defaults?.userTimezone,
);
const userTime = formatUserTime(new Date(), userTimezone);
const appendPrompt = buildEmbeddedSystemPrompt({
@@ -875,7 +875,7 @@ export async function compactEmbeddedPiSession(params: {
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
heartbeatPrompt: resolveHeartbeatPrompt(
params.config?.agent?.heartbeat?.prompt,
params.config?.agents?.defaults?.heartbeat?.prompt,
),
runtimeInfo,
sandboxInfo,
@@ -1157,7 +1157,7 @@ export async function runEmbeddedPiAgent(params: {
// `createClawdbotCodingTools()` normalizes schemas so the session can pass them through unchanged.
const tools = createClawdbotCodingTools({
bash: {
...params.config?.agent?.bash,
...params.config?.tools?.bash,
elevated: params.bashElevated,
},
sandbox,
@@ -1178,7 +1178,7 @@ export async function runEmbeddedPiAgent(params: {
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox);
const reasoningTagHint = provider === "ollama";
const userTimezone = resolveUserTimezone(
params.config?.agent?.userTimezone,
params.config?.agents?.defaults?.userTimezone,
);
const userTime = formatUserTime(new Date(), userTimezone);
const appendPrompt = buildEmbeddedSystemPrompt({
@@ -1188,7 +1188,7 @@ export async function runEmbeddedPiAgent(params: {
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
heartbeatPrompt: resolveHeartbeatPrompt(
params.config?.agent?.heartbeat?.prompt,
params.config?.agents?.defaults?.heartbeat?.prompt,
),
runtimeInfo,
sandboxInfo,
@@ -1444,7 +1444,8 @@ export async function runEmbeddedPiAgent(params: {
}
const fallbackConfigured =
(params.config?.agent?.model?.fallbacks?.length ?? 0) > 0;
(params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) >
0;
const authFailure = isAuthAssistantError(lastAssistant);
const rateLimitFailure = isRateLimitAssistantError(lastAssistant);
+111
View File
@@ -167,6 +167,117 @@ describe("subscribeEmbeddedPiSession", () => {
);
});
it("promotes <think> tags to thinking blocks at write-time", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<
typeof subscribeEmbeddedPiSession
>[0]["session"],
runId: "run",
onBlockReply,
blockReplyBreak: "message_end",
reasoningMode: "on",
});
const assistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: "<think>\nBecause it helps\n</think>\n\nFinal answer",
},
],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
expect(onBlockReply).toHaveBeenCalledTimes(1);
expect(onBlockReply.mock.calls[0][0].text).toBe(
"_Reasoning:_\n_Because it helps_\n\nFinal answer",
);
expect(assistantMessage.content).toEqual([
{ type: "thinking", thinking: "Because it helps" },
{ type: "text", text: "Final answer" },
]);
});
it("streams <think> reasoning via onReasoningStream without leaking into final text", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
subscribe: (fn) => {
handler = fn;
return () => {};
},
};
const onReasoningStream = vi.fn();
const onBlockReply = vi.fn();
subscribeEmbeddedPiSession({
session: session as unknown as Parameters<
typeof subscribeEmbeddedPiSession
>[0]["session"],
runId: "run",
onReasoningStream,
onBlockReply,
blockReplyBreak: "message_end",
reasoningMode: "stream",
});
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
type: "text_delta",
delta: "<think>\nBecause",
},
});
handler?.({
type: "message_update",
message: { role: "assistant" },
assistantMessageEvent: {
type: "text_delta",
delta: " it helps\n</think>\n\nFinal answer",
},
});
const assistantMessage = {
role: "assistant",
content: [
{
type: "text",
text: "<think>\nBecause it helps\n</think>\n\nFinal answer",
},
],
} as AssistantMessage;
handler?.({ type: "message_end", message: assistantMessage });
expect(onBlockReply).toHaveBeenCalledTimes(1);
expect(onBlockReply.mock.calls[0][0].text).toBe("Final answer");
const streamTexts = onReasoningStream.mock.calls
.map((call) => call[0]?.text)
.filter((value): value is string => typeof value === "string");
expect(streamTexts.at(-1)).toBe("Reasoning:\nBecause it helps");
expect(assistantMessage.content).toEqual([
{ type: "thinking", thinking: "Because it helps" },
{ type: "text", text: "Final answer" },
]);
});
it("emits block replies on text_end and does not duplicate on message_end", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
+94 -1
View File
@@ -9,6 +9,7 @@ import { resolveStateDir } from "../config/paths.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { createSubsystemLogger } from "../logging.js";
import { splitMediaFromOutput } from "../media/parse.js";
import { truncateUtf16Safe } from "../utils.js";
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
import { isMessagingToolDuplicate } from "./pi-embedded-helpers.js";
@@ -24,6 +25,7 @@ const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i;
const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i;
const THINKING_OPEN_GLOBAL_RE = /<\s*think(?:ing)?\s*>/gi;
const THINKING_CLOSE_GLOBAL_RE = /<\s*\/\s*think(?:ing)?\s*>/gi;
const THINKING_TAG_SCAN_RE = /<\s*(\/?)\s*think(?:ing)?\s*>/gi;
const TOOL_RESULT_MAX_CHARS = 8000;
const log = createSubsystemLogger("agent/embedded");
const RAW_STREAM_ENABLED = process.env.CLAWDBOT_RAW_STREAM === "1";
@@ -63,7 +65,7 @@ type MessagingToolSend = {
function truncateToolText(text: string): string {
if (text.length <= TOOL_RESULT_MAX_CHARS) return text;
return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`;
return `${truncateUtf16Safe(text, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`;
}
function sanitizeToolResult(result: unknown): unknown {
@@ -121,6 +123,96 @@ function stripUnpairedThinkingTags(text: string): string {
return text;
}
type ThinkTaggedSplitBlock =
| { type: "thinking"; thinking: string }
| { type: "text"; text: string };
function splitThinkingTaggedText(text: string): ThinkTaggedSplitBlock[] | null {
const trimmedStart = text.trimStart();
// Avoid false positives: only treat it as structured thinking when it begins
// with a think tag (common for local/OpenAI-compat providers that emulate
// reasoning blocks via tags).
if (!trimmedStart.startsWith("<")) return null;
if (!THINKING_OPEN_RE.test(trimmedStart)) return null;
if (!THINKING_CLOSE_RE.test(text)) return null;
THINKING_TAG_SCAN_RE.lastIndex = 0;
let inThinking = false;
let cursor = 0;
let thinkingStart = 0;
const blocks: ThinkTaggedSplitBlock[] = [];
const pushText = (value: string) => {
if (!value) return;
blocks.push({ type: "text", text: value });
};
const pushThinking = (value: string) => {
const cleaned = value.trim();
if (!cleaned) return;
blocks.push({ type: "thinking", thinking: cleaned });
};
for (const match of text.matchAll(THINKING_TAG_SCAN_RE)) {
const index = match.index ?? 0;
const isClose = Boolean(match[1]?.includes("/"));
if (!inThinking && !isClose) {
pushText(text.slice(cursor, index));
thinkingStart = index + match[0].length;
inThinking = true;
continue;
}
if (inThinking && isClose) {
pushThinking(text.slice(thinkingStart, index));
cursor = index + match[0].length;
inThinking = false;
}
}
if (inThinking) return null;
pushText(text.slice(cursor));
const hasThinking = blocks.some((b) => b.type === "thinking");
if (!hasThinking) return null;
return blocks;
}
function promoteThinkingTagsToBlocks(message: AssistantMessage): void {
if (!Array.isArray(message.content)) return;
const hasThinkingBlock = message.content.some(
(block) => block.type === "thinking",
);
if (hasThinkingBlock) return;
const next: AssistantMessage["content"] = [];
let changed = false;
for (const block of message.content) {
if (block.type !== "text") {
next.push(block);
continue;
}
const split = splitThinkingTaggedText(block.text);
if (!split) {
next.push(block);
continue;
}
changed = true;
for (const part of split) {
if (part.type === "thinking") {
next.push({ type: "thinking", thinking: part.thinking });
} else if (part.type === "text") {
const cleaned = part.text.trimStart();
if (cleaned) next.push({ type: "text", text: cleaned });
}
}
}
if (!changed) return;
message.content = next;
}
function normalizeSlackTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
@@ -792,6 +884,7 @@ export function subscribeEmbeddedPiSession(params: {
const msg = (evt as AgentEvent & { message: AgentMessage }).message;
if (msg?.role === "assistant") {
const assistantMessage = msg as AssistantMessage;
promoteThinkingTagsToBlocks(assistantMessage);
const rawText = extractAssistantText(assistantMessage);
appendRawStream({
ts: Date.now(),
+48 -46
View File
@@ -6,18 +6,17 @@ import type { SandboxDockerConfig } from "./sandbox.js";
describe("Agent-specific tool filtering", () => {
it("should apply global tool policy when no agent-specific policy exists", () => {
const cfg: ClawdbotConfig = {
agent: {
tools: {
allow: ["read", "write"],
deny: ["bash"],
},
tools: {
allow: ["read", "write"],
deny: ["bash"],
},
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
workspace: "~/clawd",
},
},
],
},
};
@@ -36,22 +35,21 @@ describe("Agent-specific tool filtering", () => {
it("should apply agent-specific tool policy", () => {
const cfg: ClawdbotConfig = {
agent: {
tools: {
allow: ["read", "write", "bash"],
deny: [],
},
tools: {
allow: ["read", "write", "bash"],
deny: [],
},
routing: {
agents: {
restricted: {
agents: {
list: [
{
id: "restricted",
workspace: "~/clawd-restricted",
tools: {
allow: ["read"], // Agent override: only read
deny: ["bash", "write", "edit"],
},
},
},
],
},
};
@@ -71,20 +69,22 @@ describe("Agent-specific tool filtering", () => {
it("should allow different tool policies for different agents", () => {
const cfg: ClawdbotConfig = {
routing: {
agents: {
main: {
agents: {
list: [
{
id: "main",
workspace: "~/clawd",
// No tools restriction - all tools available
},
family: {
{
id: "family",
workspace: "~/clawd-family",
tools: {
allow: ["read"],
deny: ["bash", "write", "edit", "process"],
},
},
},
],
},
};
@@ -116,20 +116,19 @@ describe("Agent-specific tool filtering", () => {
it("should prefer agent-specific tool policy over global", () => {
const cfg: ClawdbotConfig = {
agent: {
tools: {
deny: ["browser"], // Global deny
},
tools: {
deny: ["browser"], // Global deny
},
routing: {
agents: {
work: {
agents: {
list: [
{
id: "work",
workspace: "~/clawd-work",
tools: {
deny: ["bash", "process"], // Agent deny (override)
},
},
},
],
},
};
@@ -149,19 +148,16 @@ describe("Agent-specific tool filtering", () => {
it("should work with sandbox tools filtering", () => {
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
tools: {
allow: ["read", "write", "bash"], // Sandbox allows these
deny: [],
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
},
},
},
routing: {
agents: {
restricted: {
list: [
{
id: "restricted",
workspace: "~/clawd-restricted",
sandbox: {
mode: "all",
@@ -172,6 +168,14 @@ describe("Agent-specific tool filtering", () => {
deny: ["bash", "write"],
},
},
],
},
tools: {
sandbox: {
tools: {
allow: ["read", "write", "bash"], // Sandbox allows these
deny: [],
},
},
},
};
@@ -216,10 +220,8 @@ describe("Agent-specific tool filtering", () => {
it("should run bash synchronously when process is denied", async () => {
const cfg: ClawdbotConfig = {
agent: {
tools: {
deny: ["process"],
},
tools: {
deny: ["process"],
},
};
+88 -34
View File
@@ -4,7 +4,7 @@ import path from "node:path";
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { createClawdbotCodingTools } from "./pi-tools.js";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
import { createBrowserTool } from "./tools/browser-tool.js";
describe("createClawdbotCodingTools", () => {
@@ -64,9 +64,38 @@ describe("createClawdbotCodingTools", () => {
expect(format?.enum).toEqual(["aria", "ai"]);
});
it("inlines local $ref before removing unsupported keywords", () => {
const cleaned = __testing.cleanToolSchemaForGemini({
type: "object",
properties: {
foo: { $ref: "#/$defs/Foo" },
},
$defs: {
Foo: { type: "string", enum: ["a", "b"] },
},
}) as {
$defs?: unknown;
properties?: Record<string, unknown>;
};
expect(cleaned.$defs).toBeUndefined();
expect(cleaned.properties).toBeDefined();
expect(cleaned.properties?.foo).toMatchObject({
type: "string",
enum: ["a", "b"],
});
});
it("preserves action enums in normalized schemas", () => {
const tools = createClawdbotCodingTools();
const toolNames = ["browser", "canvas", "nodes", "cron", "gateway", "message"];
const toolNames = [
"browser",
"canvas",
"nodes",
"cron",
"gateway",
"message",
];
const collectActionValues = (
schema: unknown,
@@ -134,36 +163,13 @@ describe("createClawdbotCodingTools", () => {
expect(offenders).toEqual([]);
});
it("scopes discord tool to discord provider", () => {
const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
expect(other.some((tool) => tool.name === "discord")).toBe(false);
const discord = createClawdbotCodingTools({ messageProvider: "discord" });
expect(discord.some((tool) => tool.name === "discord")).toBe(true);
});
it("scopes slack tool to slack provider", () => {
const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
expect(other.some((tool) => tool.name === "slack")).toBe(false);
const slack = createClawdbotCodingTools({ messageProvider: "slack" });
expect(slack.some((tool) => tool.name === "slack")).toBe(true);
});
it("scopes telegram tool to telegram provider", () => {
const other = createClawdbotCodingTools({ messageProvider: "whatsapp" });
expect(other.some((tool) => tool.name === "telegram")).toBe(false);
const telegram = createClawdbotCodingTools({ messageProvider: "telegram" });
expect(telegram.some((tool) => tool.name === "telegram")).toBe(true);
});
it("scopes whatsapp tool to whatsapp provider", () => {
const other = createClawdbotCodingTools({ messageProvider: "slack" });
expect(other.some((tool) => tool.name === "whatsapp")).toBe(false);
const whatsapp = createClawdbotCodingTools({ messageProvider: "whatsapp" });
expect(whatsapp.some((tool) => tool.name === "whatsapp")).toBe(true);
it("does not expose provider-specific message tools", () => {
const tools = createClawdbotCodingTools({ messageProvider: "discord" });
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("discord")).toBe(false);
expect(names.has("slack")).toBe(false);
expect(names.has("telegram")).toBe(false);
expect(names.has("whatsapp")).toBe(false);
});
it("filters session tools for sub-agent sessions by default", () => {
@@ -187,7 +193,7 @@ describe("createClawdbotCodingTools", () => {
sessionKey: "agent:main:subagent:test",
// Intentionally partial config; only fields used by pi-tools are provided.
config: {
agent: {
tools: {
subagents: {
tools: {
// Policy matching is case-insensitive
@@ -341,10 +347,58 @@ describe("createClawdbotCodingTools", () => {
it("filters tools by agent tool policy even without sandbox", () => {
const tools = createClawdbotCodingTools({
config: { agent: { tools: { deny: ["browser"] } } },
config: { tools: { deny: ["browser"] } },
});
// NOTE: bash is capitalized to bypass Anthropic OAuth blocking
expect(tools.some((tool) => tool.name === "Bash")).toBe(true);
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
});
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
const tools = createClawdbotCodingTools();
// Helper to recursively check schema for unsupported keywords
const unsupportedKeywords = new Set([
"patternProperties",
"additionalProperties",
"$schema",
"$id",
"$ref",
"$defs",
"definitions",
]);
const findUnsupportedKeywords = (
schema: unknown,
path: string,
): string[] => {
const found: string[] = [];
if (!schema || typeof schema !== "object") return found;
if (Array.isArray(schema)) {
schema.forEach((item, i) => {
found.push(...findUnsupportedKeywords(item, `${path}[${i}]`));
});
return found;
}
for (const [key, value] of Object.entries(
schema as Record<string, unknown>,
)) {
if (unsupportedKeywords.has(key)) {
found.push(`${path}.${key}`);
}
if (value && typeof value === "object") {
found.push(...findUnsupportedKeywords(value, `${path}.${key}`));
}
}
return found;
};
for (const tool of tools) {
const violations = findUnsupportedKeywords(
tool.parameters,
`${tool.name}.parameters`,
);
expect(violations).toEqual([]);
}
});
});
+12 -167
View File
@@ -24,6 +24,7 @@ import {
import { createClawdbotTools } from "./clawdbot-tools.js";
import type { SandboxContext, SandboxToolPolicy } from "./sandbox.js";
import { assertSandboxPath } from "./sandbox-paths.js";
import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js";
import { sanitizeToolResultImages } from "./tool-images.js";
// NOTE(steipete): Upstream read now does file-magic MIME detection; we keep the wrapper
@@ -154,128 +155,6 @@ function mergePropertySchemas(existing: unknown, incoming: unknown): unknown {
return existing;
}
// Check if an anyOf array contains only literal values that can be flattened
// TypeBox Type.Literal generates { const: "value", type: "string" }
// Some schemas may use { enum: ["value"], type: "string" }
// Both patterns are flattened to { type: "string", enum: ["a", "b", ...] }
function tryFlattenLiteralAnyOf(
anyOf: unknown[],
): { type: string; enum: unknown[] } | null {
if (anyOf.length === 0) return null;
const allValues: unknown[] = [];
let commonType: string | null = null;
for (const variant of anyOf) {
if (!variant || typeof variant !== "object") return null;
const v = variant as Record<string, unknown>;
// Extract the literal value - either from const or single-element enum
let literalValue: unknown;
if ("const" in v) {
literalValue = v.const;
} else if (Array.isArray(v.enum) && v.enum.length === 1) {
literalValue = v.enum[0];
} else {
return null; // Not a literal pattern
}
// Must have consistent type (usually "string")
const variantType = typeof v.type === "string" ? v.type : null;
if (!variantType) return null;
if (commonType === null) commonType = variantType;
else if (commonType !== variantType) return null;
allValues.push(literalValue);
}
if (commonType && allValues.length > 0) {
return { type: commonType, enum: allValues };
}
return null;
}
function cleanSchemaForGemini(schema: unknown): unknown {
if (!schema || typeof schema !== "object") return schema;
if (Array.isArray(schema)) return schema.map(cleanSchemaForGemini);
const obj = schema as Record<string, unknown>;
const hasAnyOf = "anyOf" in obj && Array.isArray(obj.anyOf);
// Try to flatten anyOf of literals to a single enum BEFORE processing
// This handles Type.Union([Type.Literal("a"), Type.Literal("b")]) patterns
if (hasAnyOf) {
const flattened = tryFlattenLiteralAnyOf(obj.anyOf as unknown[]);
if (flattened) {
// Return flattened enum, preserving metadata (description, title, default, examples)
const result: Record<string, unknown> = {
type: flattened.type,
enum: flattened.enum,
};
for (const key of ["description", "title", "default", "examples"]) {
if (key in obj && obj[key] !== undefined) {
result[key] = obj[key];
}
}
return result;
}
}
const cleaned: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
// Skip unsupported schema features for Gemini:
// - patternProperties: not in OpenAPI 3.0 subset
// - const: convert to enum with single value instead
if (key === "patternProperties") {
// Gemini doesn't support patternProperties - skip it
continue;
}
// Convert const to enum (Gemini doesn't support const)
if (key === "const") {
cleaned.enum = [value];
continue;
}
// Skip 'type' if we have 'anyOf' — Gemini doesn't allow both
if (key === "type" && hasAnyOf) {
continue;
}
if (key === "properties" && value && typeof value === "object") {
// Recursively clean nested properties
const props = value as Record<string, unknown>;
cleaned[key] = Object.fromEntries(
Object.entries(props).map(([k, v]) => [k, cleanSchemaForGemini(v)]),
);
} else if (key === "items" && value && typeof value === "object") {
// Recursively clean array items schema
cleaned[key] = cleanSchemaForGemini(value);
} else if (key === "anyOf" && Array.isArray(value)) {
// Clean each anyOf variant
cleaned[key] = value.map((variant) => cleanSchemaForGemini(variant));
} else if (key === "oneOf" && Array.isArray(value)) {
// Clean each oneOf variant
cleaned[key] = value.map((variant) => cleanSchemaForGemini(variant));
} else if (key === "allOf" && Array.isArray(value)) {
// Clean each allOf variant
cleaned[key] = value.map((variant) => cleanSchemaForGemini(variant));
} else if (
key === "additionalProperties" &&
value &&
typeof value === "object"
) {
// Recursively clean additionalProperties schema
cleaned[key] = cleanSchemaForGemini(value);
} else {
cleaned[key] = value;
}
}
return cleaned;
}
function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool {
const schema =
tool.parameters && typeof tool.parameters === "object"
@@ -394,6 +273,10 @@ function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool {
};
}
function cleanToolSchemaForGemini(schema: Record<string, unknown>): unknown {
return cleanSchemaForGemini(schema);
}
function normalizeToolNames(list?: string[]) {
if (!list) return [];
return list.map((entry) => entry.trim().toLowerCase()).filter(Boolean);
@@ -429,7 +312,7 @@ const DEFAULT_SUBAGENT_TOOL_DENY = [
];
function resolveSubagentToolPolicy(cfg?: ClawdbotConfig): SandboxToolPolicy {
const configured = cfg?.agent?.subagents?.tools;
const configured = cfg?.tools?.subagents?.tools;
const deny = [
...DEFAULT_SUBAGENT_TOOL_DENY,
...(Array.isArray(configured?.deny) ? configured.deny : []),
@@ -466,7 +349,7 @@ function resolveEffectiveToolPolicy(params: {
? resolveAgentConfig(params.config, agentId)
: undefined;
const hasAgentTools = agentConfig?.tools !== undefined;
const globalTools = params.config?.agent?.tools;
const globalTools = params.config?.tools;
return {
agentId,
policy: hasAgentTools ? agentConfig?.tools : globalTools,
@@ -613,36 +496,9 @@ function createClawdbotReadTool(base: AnyAgentTool): AnyAgentTool {
};
}
function normalizeMessageProvider(
messageProvider?: string,
): string | undefined {
const trimmed = messageProvider?.trim().toLowerCase();
return trimmed ? trimmed : undefined;
}
function shouldIncludeDiscordTool(messageProvider?: string): boolean {
const normalized = normalizeMessageProvider(messageProvider);
if (!normalized) return false;
return normalized === "discord" || normalized.startsWith("discord:");
}
function shouldIncludeSlackTool(messageProvider?: string): boolean {
const normalized = normalizeMessageProvider(messageProvider);
if (!normalized) return false;
return normalized === "slack" || normalized.startsWith("slack:");
}
function shouldIncludeTelegramTool(messageProvider?: string): boolean {
const normalized = normalizeMessageProvider(messageProvider);
if (!normalized) return false;
return normalized === "telegram" || normalized.startsWith("telegram:");
}
function shouldIncludeWhatsAppTool(messageProvider?: string): boolean {
const normalized = normalizeMessageProvider(messageProvider);
if (!normalized) return false;
return normalized === "whatsapp" || normalized.startsWith("whatsapp:");
}
export const __testing = {
cleanToolSchemaForGemini,
} as const;
export function createClawdbotCodingTools(options?: {
bash?: BashToolDefaults & ProcessToolDefaults;
@@ -724,20 +580,9 @@ export function createClawdbotCodingTools(options?: {
config: options?.config,
}),
];
const allowDiscord = shouldIncludeDiscordTool(options?.messageProvider);
const allowSlack = shouldIncludeSlackTool(options?.messageProvider);
const allowTelegram = shouldIncludeTelegramTool(options?.messageProvider);
const allowWhatsApp = shouldIncludeWhatsAppTool(options?.messageProvider);
const filtered = tools.filter((tool) => {
if (tool.name === "discord") return allowDiscord;
if (tool.name === "slack") return allowSlack;
if (tool.name === "telegram") return allowTelegram;
if (tool.name === "whatsapp") return allowWhatsApp;
return true;
});
const toolsFiltered = effectiveToolsPolicy
? filterToolsByPolicy(filtered, effectiveToolsPolicy)
: filtered;
? filterToolsByPolicy(tools, effectiveToolsPolicy)
: tools;
const sandboxed = sandbox
? filterToolsByPolicy(toolsFiltered, sandbox.tools)
: toolsFiltered;
+139 -116
View File
@@ -52,51 +52,57 @@ describe("Agent-specific sandbox config", () => {
spawnCalls.length = 0;
});
it("should use global sandbox config when no agent-specific config exists", async () => {
const { resolveSandboxContext } = await import("./sandbox.js");
it(
"should use global sandbox config when no agent-specific config exists",
{ timeout: 15_000 },
async () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
},
},
routing: {
const cfg: ClawdbotConfig = {
agents: {
main: {
workspace: "~/clawd",
defaults: {
sandbox: {
mode: "all",
scope: "agent",
},
},
list: [
{
id: "main",
workspace: "~/clawd",
},
],
},
},
};
};
const context = await resolveSandboxContext({
config: cfg,
sessionKey: "agent:main:main",
workspaceDir: "/tmp/test",
});
const context = await resolveSandboxContext({
config: cfg,
sessionKey: "agent:main:main",
workspaceDir: "/tmp/test",
});
expect(context).toBeDefined();
expect(context?.enabled).toBe(true);
});
expect(context).toBeDefined();
expect(context?.enabled).toBe(true);
},
);
it("should allow agent-specific docker setupCommand overrides", async () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
docker: {
setupCommand: "echo global",
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
docker: {
setupCommand: "echo global",
},
},
},
},
routing: {
agents: {
work: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
@@ -106,7 +112,7 @@ describe("Agent-specific sandbox config", () => {
},
},
},
},
],
},
};
@@ -133,18 +139,19 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "shared",
docker: {
setupCommand: "echo global",
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "shared",
docker: {
setupCommand: "echo global",
},
},
},
},
routing: {
agents: {
work: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
@@ -154,7 +161,7 @@ describe("Agent-specific sandbox config", () => {
},
},
},
},
],
},
};
@@ -182,19 +189,20 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
docker: {
image: "global-image",
network: "none",
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
docker: {
image: "global-image",
network: "none",
},
},
},
},
routing: {
agents: {
work: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
@@ -205,7 +213,7 @@ describe("Agent-specific sandbox config", () => {
},
},
},
},
],
},
};
@@ -224,21 +232,22 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all", // Global default
scope: "agent",
agents: {
defaults: {
sandbox: {
mode: "all", // Global default
scope: "agent",
},
},
},
routing: {
agents: {
main: {
list: [
{
id: "main",
workspace: "~/clawd",
sandbox: {
mode: "off", // Agent override
},
},
},
],
},
};
@@ -256,21 +265,22 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "off", // Global default
agents: {
defaults: {
sandbox: {
mode: "off", // Global default
},
},
},
routing: {
agents: {
family: {
list: [
{
id: "family",
workspace: "~/clawd-family",
sandbox: {
mode: "all", // Agent override
scope: "agent",
},
},
},
],
},
};
@@ -288,22 +298,23 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "session", // Global default
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "session", // Global default
},
},
},
routing: {
agents: {
work: {
list: [
{
id: "work",
workspace: "~/clawd-work",
sandbox: {
mode: "all",
scope: "agent", // Agent override
},
},
},
],
},
};
@@ -322,16 +333,17 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
workspaceRoot: "~/.clawdbot/sandboxes", // Global default
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
workspaceRoot: "~/.clawdbot/sandboxes", // Global default
},
},
},
routing: {
agents: {
isolated: {
list: [
{
id: "isolated",
workspace: "~/clawd-isolated",
sandbox: {
mode: "all",
@@ -339,7 +351,7 @@ describe("Agent-specific sandbox config", () => {
workspaceRoot: "/tmp/isolated-sandboxes", // Agent override
},
},
},
],
},
};
@@ -359,28 +371,30 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "non-main",
scope: "session",
agents: {
defaults: {
sandbox: {
mode: "non-main",
scope: "session",
},
},
},
routing: {
agents: {
main: {
list: [
{
id: "main",
workspace: "~/clawd",
sandbox: {
mode: "off", // main: no sandbox
},
},
family: {
{
id: "family",
workspace: "~/clawd-family",
sandbox: {
mode: "all", // family: always sandbox
scope: "agent",
},
},
},
],
},
};
@@ -406,29 +420,38 @@ describe("Agent-specific sandbox config", () => {
const { resolveSandboxContext } = await import("./sandbox.js");
const cfg: ClawdbotConfig = {
agent: {
sandbox: {
mode: "all",
scope: "agent",
tools: {
allow: ["read"],
deny: ["bash"],
agents: {
defaults: {
sandbox: {
mode: "all",
scope: "agent",
},
},
},
routing: {
agents: {
restricted: {
list: [
{
id: "restricted",
workspace: "~/clawd-restricted",
sandbox: {
mode: "all",
scope: "agent",
tools: {
allow: ["read", "write"],
deny: ["edit"],
},
tools: {
sandbox: {
tools: {
allow: ["read", "write"],
deny: ["edit"],
},
},
},
},
],
},
tools: {
sandbox: {
tools: {
allow: ["read"],
deny: ["bash"],
},
},
},
};
+13 -9
View File
@@ -1,16 +1,20 @@
import { describe, expect, it } from "vitest";
describe("sandbox config merges", () => {
it("resolves sandbox scope deterministically", async () => {
const { resolveSandboxScope } = await import("./sandbox.js");
it(
"resolves sandbox scope deterministically",
{ timeout: 15_000 },
async () => {
const { resolveSandboxScope } = await import("./sandbox.js");
expect(resolveSandboxScope({})).toBe("agent");
expect(resolveSandboxScope({ perSession: true })).toBe("session");
expect(resolveSandboxScope({ perSession: false })).toBe("shared");
expect(resolveSandboxScope({ perSession: true, scope: "agent" })).toBe(
"agent",
);
});
expect(resolveSandboxScope({})).toBe("agent");
expect(resolveSandboxScope({ perSession: true })).toBe("session");
expect(resolveSandboxScope({ perSession: false })).toBe("shared");
expect(resolveSandboxScope({ perSession: true, scope: "agent" })).toBe(
"agent",
);
},
);
it("merges sandbox docker env and ulimits (agent wins)", async () => {
const { resolveSandboxDockerConfig } = await import("./sandbox.js");
+146 -13
View File
@@ -14,11 +14,18 @@ import {
resolveProfile,
} from "../browser/config.js";
import { DEFAULT_CLAWD_BROWSER_COLOR } from "../browser/constants.js";
import type { ClawdbotConfig } from "../config/config.js";
import { STATE_DIR_CLAWDBOT } from "../config/config.js";
import {
type ClawdbotConfig,
loadConfig,
STATE_DIR_CLAWDBOT,
} from "../config/config.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import { resolveAgentIdFromSessionKey } from "./agent-scope.js";
import {
resolveAgentConfig,
resolveAgentIdFromSessionKey,
} from "./agent-scope.js";
import { syncSkillsToWorkspace } from "./skills.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
@@ -329,19 +336,26 @@ function resolveSandboxScopeKey(scope: SandboxScope, sessionKey: string) {
return `agent:${agentId}`;
}
function resolveSandboxAgentId(scopeKey: string): string | undefined {
const trimmed = scopeKey.trim();
if (!trimmed || trimmed === "shared") return undefined;
const parts = trimmed.split(":").filter(Boolean);
if (parts[0] === "agent" && parts[1]) return normalizeAgentId(parts[1]);
return resolveAgentIdFromSessionKey(trimmed);
}
export function resolveSandboxConfigForAgent(
cfg?: ClawdbotConfig,
agentId?: string,
): SandboxConfig {
const agent = cfg?.agent?.sandbox;
const agent = cfg?.agents?.defaults?.sandbox;
// Agent-specific sandbox config overrides global
let agentSandbox: typeof agent | undefined;
if (agentId && cfg?.routing?.agents) {
const agentConfig = cfg.routing.agents[agentId];
if (agentConfig && typeof agentConfig === "object") {
agentSandbox = agentConfig.sandbox;
}
const agentConfig =
cfg && agentId ? resolveAgentConfig(cfg, agentId) : undefined;
if (agentConfig?.sandbox) {
agentSandbox = agentConfig.sandbox;
}
const scope = resolveSandboxScope({
@@ -370,9 +384,13 @@ export function resolveSandboxConfigForAgent(
}),
tools: {
allow:
agentSandbox?.tools?.allow ?? agent?.tools?.allow ?? DEFAULT_TOOL_ALLOW,
agentConfig?.tools?.sandbox?.tools?.allow ??
cfg?.tools?.sandbox?.tools?.allow ??
DEFAULT_TOOL_ALLOW,
deny:
agentSandbox?.tools?.deny ?? agent?.tools?.deny ?? DEFAULT_TOOL_DENY,
agentConfig?.tools?.sandbox?.tools?.deny ??
cfg?.tools?.sandbox?.tools?.deny ??
DEFAULT_TOOL_DENY,
},
prune: resolveSandboxPruneConfig({
scope,
@@ -1047,7 +1065,7 @@ export async function resolveSandboxContext(params: {
await ensureSandboxWorkspace(
sandboxWorkspaceDir,
agentWorkspaceDir,
params.config?.agent?.skipBootstrap,
params.config?.agents?.defaults?.skipBootstrap,
);
if (cfg.workspaceAccess === "none") {
try {
@@ -1121,7 +1139,7 @@ export async function ensureSandboxWorkspaceForSession(params: {
await ensureSandboxWorkspace(
sandboxWorkspaceDir,
agentWorkspaceDir,
params.config?.agent?.skipBootstrap,
params.config?.agents?.defaults?.skipBootstrap,
);
if (cfg.workspaceAccess === "none") {
try {
@@ -1145,3 +1163,118 @@ export async function ensureSandboxWorkspaceForSession(params: {
containerWorkdir: cfg.docker.workdir,
};
}
// --- Public API for sandbox management ---
export type SandboxContainerInfo = SandboxRegistryEntry & {
running: boolean;
imageMatch: boolean;
};
export type SandboxBrowserInfo = SandboxBrowserRegistryEntry & {
running: boolean;
imageMatch: boolean;
};
export async function listSandboxContainers(): Promise<SandboxContainerInfo[]> {
const config = loadConfig();
const registry = await readRegistry();
const results: SandboxContainerInfo[] = [];
for (const entry of registry.entries) {
const state = await dockerContainerState(entry.containerName);
// Get actual image from container
let actualImage = entry.image;
if (state.exists) {
try {
const result = await execDocker(
["inspect", "-f", "{{.Config.Image}}", entry.containerName],
{ allowFailure: true },
);
if (result.code === 0) {
actualImage = result.stdout.trim();
}
} catch {
// ignore
}
}
const agentId = resolveSandboxAgentId(entry.sessionKey);
const configuredImage = resolveSandboxConfigForAgent(config, agentId).docker
.image;
results.push({
...entry,
image: actualImage,
running: state.running,
imageMatch: actualImage === configuredImage,
});
}
return results;
}
export async function listSandboxBrowsers(): Promise<SandboxBrowserInfo[]> {
const config = loadConfig();
const registry = await readBrowserRegistry();
const results: SandboxBrowserInfo[] = [];
for (const entry of registry.entries) {
const state = await dockerContainerState(entry.containerName);
let actualImage = entry.image;
if (state.exists) {
try {
const result = await execDocker(
["inspect", "-f", "{{.Config.Image}}", entry.containerName],
{ allowFailure: true },
);
if (result.code === 0) {
actualImage = result.stdout.trim();
}
} catch {
// ignore
}
}
const agentId = resolveSandboxAgentId(entry.sessionKey);
const configuredImage = resolveSandboxConfigForAgent(config, agentId)
.browser.image;
results.push({
...entry,
image: actualImage,
running: state.running,
imageMatch: actualImage === configuredImage,
});
}
return results;
}
export async function removeSandboxContainer(
containerName: string,
): Promise<void> {
try {
await execDocker(["rm", "-f", containerName], { allowFailure: true });
} catch {
// ignore removal failures
}
await removeRegistryEntry(containerName);
}
export async function removeSandboxBrowserContainer(
containerName: string,
): Promise<void> {
try {
await execDocker(["rm", "-f", containerName], { allowFailure: true });
} catch {
// ignore removal failures
}
await removeBrowserRegistryEntry(containerName);
// Stop browser bridge if active
for (const [sessionKey, bridge] of BROWSER_BRIDGES.entries()) {
if (bridge.containerName === containerName) {
await stopBrowserBridgeServer(bridge.bridge.server).catch(
() => undefined,
);
BROWSER_BRIDGES.delete(sessionKey);
}
}
}
+229
View File
@@ -0,0 +1,229 @@
// Cloud Code Assist API rejects a subset of JSON Schema keywords.
// This module scrubs/normalizes tool schemas to keep Gemini happy.
// Keywords that Cloud Code Assist API rejects (not compliant with their JSON Schema subset)
const UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
"patternProperties",
"additionalProperties",
"$schema",
"$id",
"$ref",
"$defs",
"definitions",
]);
// Check if an anyOf/oneOf array contains only literal values that can be flattened.
// TypeBox Type.Literal generates { const: "value", type: "string" }.
// Some schemas may use { enum: ["value"], type: "string" }.
// Both patterns are flattened to { type: "string", enum: ["a", "b", ...] }.
function tryFlattenLiteralAnyOf(
variants: unknown[],
): { type: string; enum: unknown[] } | null {
if (variants.length === 0) return null;
const allValues: unknown[] = [];
let commonType: string | null = null;
for (const variant of variants) {
if (!variant || typeof variant !== "object") return null;
const v = variant as Record<string, unknown>;
let literalValue: unknown;
if ("const" in v) {
literalValue = v.const;
} else if (Array.isArray(v.enum) && v.enum.length === 1) {
literalValue = v.enum[0];
} else {
return null;
}
const variantType = typeof v.type === "string" ? v.type : null;
if (!variantType) return null;
if (commonType === null) commonType = variantType;
else if (commonType !== variantType) return null;
allValues.push(literalValue);
}
if (commonType && allValues.length > 0)
return { type: commonType, enum: allValues };
return null;
}
type SchemaDefs = Map<string, unknown>;
function extendSchemaDefs(
defs: SchemaDefs | undefined,
schema: Record<string, unknown>,
): SchemaDefs | undefined {
const defsEntry =
schema.$defs &&
typeof schema.$defs === "object" &&
!Array.isArray(schema.$defs)
? (schema.$defs as Record<string, unknown>)
: undefined;
const legacyDefsEntry =
schema.definitions &&
typeof schema.definitions === "object" &&
!Array.isArray(schema.definitions)
? (schema.definitions as Record<string, unknown>)
: undefined;
if (!defsEntry && !legacyDefsEntry) return defs;
const next = defs ? new Map(defs) : new Map<string, unknown>();
if (defsEntry) {
for (const [key, value] of Object.entries(defsEntry)) next.set(key, value);
}
if (legacyDefsEntry) {
for (const [key, value] of Object.entries(legacyDefsEntry))
next.set(key, value);
}
return next;
}
function decodeJsonPointerSegment(segment: string): string {
return segment.replaceAll("~1", "/").replaceAll("~0", "~");
}
function tryResolveLocalRef(
ref: string,
defs: SchemaDefs | undefined,
): unknown {
if (!defs) return undefined;
const match = ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/);
if (!match) return undefined;
const name = decodeJsonPointerSegment(match[1] ?? "");
if (!name) return undefined;
return defs.get(name);
}
function cleanSchemaForGeminiWithDefs(
schema: unknown,
defs: SchemaDefs | undefined,
refStack: Set<string> | undefined,
): unknown {
if (!schema || typeof schema !== "object") return schema;
if (Array.isArray(schema)) {
return schema.map((item) =>
cleanSchemaForGeminiWithDefs(item, defs, refStack),
);
}
const obj = schema as Record<string, unknown>;
const nextDefs = extendSchemaDefs(defs, obj);
const refValue = typeof obj.$ref === "string" ? obj.$ref : undefined;
if (refValue) {
if (refStack?.has(refValue)) return {};
const resolved = tryResolveLocalRef(refValue, nextDefs);
if (resolved) {
const nextRefStack = refStack ? new Set(refStack) : new Set<string>();
nextRefStack.add(refValue);
const cleaned = cleanSchemaForGeminiWithDefs(
resolved,
nextDefs,
nextRefStack,
);
if (!cleaned || typeof cleaned !== "object" || Array.isArray(cleaned)) {
return cleaned;
}
const result: Record<string, unknown> = {
...(cleaned as Record<string, unknown>),
};
for (const key of ["description", "title", "default", "examples"]) {
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
}
return result;
}
const result: Record<string, unknown> = {};
for (const key of ["description", "title", "default", "examples"]) {
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
}
return result;
}
const hasAnyOf = "anyOf" in obj && Array.isArray(obj.anyOf);
const hasOneOf = "oneOf" in obj && Array.isArray(obj.oneOf);
if (hasAnyOf) {
const flattened = tryFlattenLiteralAnyOf(obj.anyOf as unknown[]);
if (flattened) {
const result: Record<string, unknown> = {
type: flattened.type,
enum: flattened.enum,
};
for (const key of ["description", "title", "default", "examples"]) {
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
}
return result;
}
}
if (hasOneOf) {
const flattened = tryFlattenLiteralAnyOf(obj.oneOf as unknown[]);
if (flattened) {
const result: Record<string, unknown> = {
type: flattened.type,
enum: flattened.enum,
};
for (const key of ["description", "title", "default", "examples"]) {
if (key in obj && obj[key] !== undefined) result[key] = obj[key];
}
return result;
}
}
const cleaned: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (UNSUPPORTED_SCHEMA_KEYWORDS.has(key)) continue;
if (key === "const") {
cleaned.enum = [value];
continue;
}
if (key === "type" && (hasAnyOf || hasOneOf)) continue;
if (key === "properties" && value && typeof value === "object") {
const props = value as Record<string, unknown>;
cleaned[key] = Object.fromEntries(
Object.entries(props).map(([k, v]) => [
k,
cleanSchemaForGeminiWithDefs(v, nextDefs, refStack),
]),
);
} else if (key === "items" && value && typeof value === "object") {
cleaned[key] = cleanSchemaForGeminiWithDefs(value, nextDefs, refStack);
} else if (key === "anyOf" && Array.isArray(value)) {
cleaned[key] = value.map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
);
} else if (key === "oneOf" && Array.isArray(value)) {
cleaned[key] = value.map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
);
} else if (key === "allOf" && Array.isArray(value)) {
cleaned[key] = value.map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
);
} else {
cleaned[key] = value;
}
}
return cleaned;
}
export function cleanSchemaForGemini(schema: unknown): unknown {
if (!schema || typeof schema !== "object") return schema;
if (Array.isArray(schema)) return schema.map(cleanSchemaForGemini);
const defs = extendSchemaDefs(undefined, schema as Record<string, unknown>);
return cleanSchemaForGeminiWithDefs(schema, defs, undefined);
}
+13
View File
@@ -196,6 +196,7 @@ export async function runSubagentAnnounceFlow(params: {
waitForCompletion?: boolean;
startedAt?: number;
endedAt?: number;
label?: string;
}) {
try {
let reply = params.roundOneReply;
@@ -273,6 +274,18 @@ export async function runSubagentAnnounceFlow(params: {
} catch {
// Best-effort follow-ups; ignore failures to avoid breaking the caller response.
} finally {
// Patch label after all writes complete
if (params.label) {
try {
await callGateway({
method: "sessions.patch",
params: { key: params.childSessionKey, label: params.label },
timeoutMs: 10_000,
});
} catch {
// Best-effort
}
}
if (params.cleanup === "delete") {
try {
await callGateway({
+7 -1
View File
@@ -11,6 +11,7 @@ export type SubagentRunRecord = {
requesterDisplayKey: string;
task: string;
cleanup: "delete" | "keep";
label?: string;
createdAt: number;
startedAt?: number;
endedAt?: number;
@@ -24,7 +25,7 @@ let listenerStarted = false;
function resolveArchiveAfterMs() {
const cfg = loadConfig();
const minutes = cfg.agent?.subagents?.archiveAfterMinutes ?? 60;
const minutes = cfg.agents?.defaults?.subagents?.archiveAfterMinutes ?? 60;
if (!Number.isFinite(minutes) || minutes <= 0) return undefined;
return Math.max(1, Math.floor(minutes)) * 60_000;
}
@@ -83,6 +84,7 @@ function ensureListener() {
? (evt.data.endedAt as number)
: Date.now();
entry.endedAt = endedAt;
if (!beginSubagentAnnounce(evt.runId)) {
if (entry.cleanup === "delete") {
subagentRuns.delete(evt.runId);
@@ -101,6 +103,7 @@ function ensureListener() {
waitForCompletion: false,
startedAt: entry.startedAt,
endedAt: entry.endedAt,
label: entry.label,
});
if (entry.cleanup === "delete") {
subagentRuns.delete(evt.runId);
@@ -124,6 +127,7 @@ export function registerSubagentRun(params: {
requesterDisplayKey: string;
task: string;
cleanup: "delete" | "keep";
label?: string;
}) {
const now = Date.now();
const archiveAfterMs = resolveArchiveAfterMs();
@@ -136,6 +140,7 @@ export function registerSubagentRun(params: {
requesterDisplayKey: params.requesterDisplayKey,
task: params.task,
cleanup: params.cleanup,
label: params.label,
createdAt: now,
startedAt: now,
archiveAtMs,
@@ -175,6 +180,7 @@ async function probeImmediateCompletion(runId: string) {
waitForCompletion: false,
startedAt: entry.startedAt,
endedAt: entry.endedAt,
label: entry.label,
});
if (entry.cleanup === "delete") {
subagentRuns.delete(runId);
+1
View File
@@ -222,6 +222,7 @@ export function buildAgentSystemPrompt(params: {
"To request a native reply/quote on supported surfaces, include one tag in your reply:",
"- [[reply_to_current]] replies to the triggering message.",
"- [[reply_to:<id>]] replies to a specific message id when you have it.",
"Whitespace inside the tag is allowed (e.g. [[ reply_to_current ]] / [[ reply_to: 123 ]]).",
"Tags are stripped before sending; support depends on the current provider config.",
"",
"## Messaging",
+1 -1
View File
@@ -8,7 +8,7 @@ const normalizeNumber = (value: unknown): number | undefined =>
: undefined;
export function resolveAgentTimeoutSeconds(cfg?: ClawdbotConfig): number {
const raw = normalizeNumber(cfg?.agent?.timeoutSeconds);
const raw = normalizeNumber(cfg?.agents?.defaults?.timeoutSeconds);
const seconds = raw ?? DEFAULT_AGENT_TIMEOUT_SECONDS;
return Math.max(seconds, 1);
}
+31 -73
View File
@@ -154,8 +154,37 @@
"emoji": "✉️",
"title": "Message",
"actions": {
"send": { "label": "send", "detailKeys": ["to", "provider", "mediaUrl"] },
"poll": { "label": "poll", "detailKeys": ["to", "provider", "question"] }
"send": { "label": "send", "detailKeys": ["provider", "to", "media", "replyTo", "threadId"] },
"poll": { "label": "poll", "detailKeys": ["provider", "to", "pollQuestion"] },
"react": { "label": "react", "detailKeys": ["provider", "to", "messageId", "emoji", "remove"] },
"reactions": { "label": "reactions", "detailKeys": ["provider", "to", "messageId", "limit"] },
"read": { "label": "read", "detailKeys": ["provider", "to", "limit"] },
"edit": { "label": "edit", "detailKeys": ["provider", "to", "messageId"] },
"delete": { "label": "delete", "detailKeys": ["provider", "to", "messageId"] },
"pin": { "label": "pin", "detailKeys": ["provider", "to", "messageId"] },
"unpin": { "label": "unpin", "detailKeys": ["provider", "to", "messageId"] },
"list-pins": { "label": "list pins", "detailKeys": ["provider", "to"] },
"permissions": { "label": "permissions", "detailKeys": ["provider", "channelId", "to"] },
"thread-create": { "label": "thread create", "detailKeys": ["provider", "channelId", "threadName"] },
"thread-list": { "label": "thread list", "detailKeys": ["provider", "guildId", "channelId"] },
"thread-reply": { "label": "thread reply", "detailKeys": ["provider", "channelId", "messageId"] },
"search": { "label": "search", "detailKeys": ["provider", "guildId", "query"] },
"sticker": { "label": "sticker", "detailKeys": ["provider", "to", "stickerId"] },
"member-info": { "label": "member", "detailKeys": ["provider", "guildId", "userId"] },
"role-info": { "label": "roles", "detailKeys": ["provider", "guildId"] },
"emoji-list": { "label": "emoji list", "detailKeys": ["provider", "guildId"] },
"emoji-upload": { "label": "emoji upload", "detailKeys": ["provider", "guildId", "emojiName"] },
"sticker-upload": { "label": "sticker upload", "detailKeys": ["provider", "guildId", "stickerName"] },
"role-add": { "label": "role add", "detailKeys": ["provider", "guildId", "userId", "roleId"] },
"role-remove": { "label": "role remove", "detailKeys": ["provider", "guildId", "userId", "roleId"] },
"channel-info": { "label": "channel", "detailKeys": ["provider", "channelId"] },
"channel-list": { "label": "channels", "detailKeys": ["provider", "guildId"] },
"voice-status": { "label": "voice", "detailKeys": ["provider", "guildId", "userId"] },
"event-list": { "label": "events", "detailKeys": ["provider", "guildId"] },
"event-create": { "label": "event create", "detailKeys": ["provider", "guildId", "eventName"] },
"timeout": { "label": "timeout", "detailKeys": ["provider", "guildId", "userId"] },
"kick": { "label": "kick", "detailKeys": ["provider", "guildId", "userId"] },
"ban": { "label": "ban", "detailKeys": ["provider", "guildId", "userId"] }
}
},
"agents_list": {
@@ -190,77 +219,6 @@
"start": { "label": "start" },
"wait": { "label": "wait" }
}
},
"discord": {
"emoji": "💬",
"title": "Discord",
"actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji", "remove"] },
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
"sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] },
"poll": { "label": "poll", "detailKeys": ["question", "to"] },
"permissions": { "label": "permissions", "detailKeys": ["channelId"] },
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
"threadCreate": { "label": "thread create", "detailKeys": ["channelId", "name"] },
"threadList": { "label": "thread list", "detailKeys": ["guildId", "channelId"] },
"threadReply": { "label": "thread reply", "detailKeys": ["channelId", "content"] },
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
"searchMessages": { "label": "search", "detailKeys": ["guildId", "content"] },
"memberInfo": { "label": "member", "detailKeys": ["guildId", "userId"] },
"roleInfo": { "label": "roles", "detailKeys": ["guildId"] },
"emojiList": { "label": "emoji list", "detailKeys": ["guildId"] },
"emojiUpload": { "label": "emoji upload", "detailKeys": ["guildId", "name"] },
"stickerUpload": { "label": "sticker upload", "detailKeys": ["guildId", "name"] },
"roleAdd": { "label": "role add", "detailKeys": ["guildId", "userId", "roleId"] },
"roleRemove": { "label": "role remove", "detailKeys": ["guildId", "userId", "roleId"] },
"channelInfo": { "label": "channel", "detailKeys": ["channelId"] },
"channelList": { "label": "channels", "detailKeys": ["guildId"] },
"voiceStatus": { "label": "voice", "detailKeys": ["guildId", "userId"] },
"eventList": { "label": "events", "detailKeys": ["guildId"] },
"eventCreate": { "label": "event create", "detailKeys": ["guildId", "name"] },
"timeout": { "label": "timeout", "detailKeys": ["guildId", "userId"] },
"kick": { "label": "kick", "detailKeys": ["guildId", "userId"] },
"ban": { "label": "ban", "detailKeys": ["guildId", "userId"] }
}
},
"slack": {
"emoji": "💬",
"title": "Slack",
"actions": {
"react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji", "remove"] },
"reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] },
"sendMessage": { "label": "send", "detailKeys": ["to", "content"] },
"editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] },
"deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] },
"readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] },
"pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] },
"unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] },
"listPins": { "label": "list pins", "detailKeys": ["channelId"] },
"memberInfo": { "label": "member", "detailKeys": ["userId"] },
"emojiList": { "label": "emoji list" }
}
},
"telegram": {
"emoji": "✈️",
"title": "Telegram",
"actions": {
"react": { "label": "react", "detailKeys": ["chatId", "messageId", "emoji", "remove"] }
}
},
"whatsapp": {
"emoji": "💬",
"title": "WhatsApp",
"actions": {
"react": {
"label": "react",
"detailKeys": ["chatJid", "messageId", "emoji", "remove", "participant", "accountId", "fromMe"]
}
}
}
}
}
+8 -10
View File
@@ -55,19 +55,17 @@ export function createAgentsListTool(opts?: {
.map((value) => normalizeAgentId(value)),
);
const configuredAgents = cfg.routing?.agents ?? {};
const configuredIds = Object.keys(configuredAgents).map((key) =>
normalizeAgentId(key),
const configuredAgents = Array.isArray(cfg.agents?.list)
? cfg.agents?.list
: [];
const configuredIds = configuredAgents.map((entry) =>
normalizeAgentId(entry.id),
);
const configuredNameMap = new Map<string, string>();
for (const [key, value] of Object.entries(configuredAgents)) {
if (!value || typeof value !== "object") continue;
const name =
typeof (value as { name?: unknown }).name === "string"
? ((value as { name?: string }).name?.trim() ?? "")
: "";
for (const entry of configuredAgents) {
const name = entry?.name?.trim() ?? "";
if (!name) continue;
configuredNameMap.set(normalizeAgentId(key), name);
configuredNameMap.set(normalizeAgentId(entry.id), name);
}
const allowed = new Set<string>();
+165
View File
@@ -2,7 +2,10 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js";
import {
addRoleDiscord,
createChannelDiscord,
createScheduledEventDiscord,
deleteChannelDiscord,
editChannelDiscord,
fetchChannelInfoDiscord,
fetchMemberInfoDiscord,
fetchRoleInfoDiscord,
@@ -10,17 +13,28 @@ import {
listGuildChannelsDiscord,
listGuildEmojisDiscord,
listScheduledEventsDiscord,
moveChannelDiscord,
removeChannelPermissionDiscord,
removeRoleDiscord,
setChannelPermissionDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
} from "../../discord/send.js";
import {
type ActionGate,
jsonResult,
readNumberParam,
readStringArrayParam,
readStringParam,
} from "./common.js";
function readParentIdParam(
params: Record<string, unknown>,
): string | null | undefined {
if (params.parentId === null) return null;
return readStringParam(params, "parentId");
}
export async function handleDiscordGuildAction(
action: string,
params: Record<string, unknown>,
@@ -207,6 +221,157 @@ export async function handleDiscordGuildAction(
const event = await createScheduledEventDiscord(guildId, payload);
return jsonResult({ ok: true, event });
}
case "channelCreate": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "name", { required: true });
const type = readNumberParam(params, "type", { integer: true });
const parentId = readParentIdParam(params);
const topic = readStringParam(params, "topic");
const position = readNumberParam(params, "position", { integer: true });
const nsfw = params.nsfw as boolean | undefined;
const channel = await createChannelDiscord({
guildId,
name,
type: type ?? undefined,
parentId: parentId ?? undefined,
topic: topic ?? undefined,
position: position ?? undefined,
nsfw,
});
return jsonResult({ ok: true, channel });
}
case "channelEdit": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const name = readStringParam(params, "name");
const topic = readStringParam(params, "topic");
const position = readNumberParam(params, "position", { integer: true });
const parentId = readParentIdParam(params);
const nsfw = params.nsfw as boolean | undefined;
const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", {
integer: true,
});
const channel = await editChannelDiscord({
channelId,
name: name ?? undefined,
topic: topic ?? undefined,
position: position ?? undefined,
parentId: parentId === undefined ? undefined : parentId,
nsfw,
rateLimitPerUser: rateLimitPerUser ?? undefined,
});
return jsonResult({ ok: true, channel });
}
case "channelDelete": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const result = await deleteChannelDiscord(channelId);
return jsonResult(result);
}
case "channelMove": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const guildId = readStringParam(params, "guildId", { required: true });
const channelId = readStringParam(params, "channelId", {
required: true,
});
const parentId = readParentIdParam(params);
const position = readNumberParam(params, "position", { integer: true });
await moveChannelDiscord({
guildId,
channelId,
parentId: parentId === undefined ? undefined : parentId,
position: position ?? undefined,
});
return jsonResult({ ok: true });
}
case "categoryCreate": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "name", { required: true });
const position = readNumberParam(params, "position", { integer: true });
const channel = await createChannelDiscord({
guildId,
name,
type: 4,
position: position ?? undefined,
});
return jsonResult({ ok: true, category: channel });
}
case "categoryEdit": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const categoryId = readStringParam(params, "categoryId", {
required: true,
});
const name = readStringParam(params, "name");
const position = readNumberParam(params, "position", { integer: true });
const channel = await editChannelDiscord({
channelId: categoryId,
name: name ?? undefined,
position: position ?? undefined,
});
return jsonResult({ ok: true, category: channel });
}
case "categoryDelete": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const categoryId = readStringParam(params, "categoryId", {
required: true,
});
const result = await deleteChannelDiscord(categoryId);
return jsonResult(result);
}
case "channelPermissionSet": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const targetId = readStringParam(params, "targetId", { required: true });
const targetTypeRaw = readStringParam(params, "targetType", {
required: true,
});
const targetType = targetTypeRaw === "member" ? 1 : 0;
const allow = readStringParam(params, "allow");
const deny = readStringParam(params, "deny");
await setChannelPermissionDiscord({
channelId,
targetId,
targetType,
allow: allow ?? undefined,
deny: deny ?? undefined,
});
return jsonResult({ ok: true });
}
case "channelPermissionRemove": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const targetId = readStringParam(params, "targetId", { required: true });
await removeChannelPermissionDiscord(channelId, targetId);
return jsonResult({ ok: true });
}
default:
throw new Error(`Unknown action: ${action}`);
}
+233
View File
@@ -1,38 +1,58 @@
import { describe, expect, it, vi } from "vitest";
import type { DiscordActionConfig } from "../../config/config.js";
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
const createChannelDiscord = vi.fn(async () => ({
id: "new-channel",
name: "test",
type: 0,
}));
const createThreadDiscord = vi.fn(async () => ({}));
const deleteChannelDiscord = vi.fn(async () => ({ ok: true, channelId: "C1" }));
const deleteMessageDiscord = vi.fn(async () => ({}));
const editChannelDiscord = vi.fn(async () => ({
id: "C1",
name: "edited",
}));
const editMessageDiscord = vi.fn(async () => ({}));
const fetchChannelPermissionsDiscord = vi.fn(async () => ({}));
const fetchReactionsDiscord = vi.fn(async () => ({}));
const listPinsDiscord = vi.fn(async () => ({}));
const listThreadsDiscord = vi.fn(async () => ({}));
const moveChannelDiscord = vi.fn(async () => ({ ok: true }));
const pinMessageDiscord = vi.fn(async () => ({}));
const reactMessageDiscord = vi.fn(async () => ({}));
const readMessagesDiscord = vi.fn(async () => []);
const removeChannelPermissionDiscord = vi.fn(async () => ({ ok: true }));
const removeOwnReactionsDiscord = vi.fn(async () => ({ removed: ["👍"] }));
const removeReactionDiscord = vi.fn(async () => ({}));
const searchMessagesDiscord = vi.fn(async () => ({}));
const sendMessageDiscord = vi.fn(async () => ({}));
const sendPollDiscord = vi.fn(async () => ({}));
const sendStickerDiscord = vi.fn(async () => ({}));
const setChannelPermissionDiscord = vi.fn(async () => ({ ok: true }));
const unpinMessageDiscord = vi.fn(async () => ({}));
vi.mock("../../discord/send.js", () => ({
createChannelDiscord: (...args: unknown[]) => createChannelDiscord(...args),
createThreadDiscord: (...args: unknown[]) => createThreadDiscord(...args),
deleteChannelDiscord: (...args: unknown[]) => deleteChannelDiscord(...args),
deleteMessageDiscord: (...args: unknown[]) => deleteMessageDiscord(...args),
editChannelDiscord: (...args: unknown[]) => editChannelDiscord(...args),
editMessageDiscord: (...args: unknown[]) => editMessageDiscord(...args),
fetchChannelPermissionsDiscord: (...args: unknown[]) =>
fetchChannelPermissionsDiscord(...args),
fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args),
listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args),
listThreadsDiscord: (...args: unknown[]) => listThreadsDiscord(...args),
moveChannelDiscord: (...args: unknown[]) => moveChannelDiscord(...args),
pinMessageDiscord: (...args: unknown[]) => pinMessageDiscord(...args),
reactMessageDiscord: (...args: unknown[]) => reactMessageDiscord(...args),
readMessagesDiscord: (...args: unknown[]) => readMessagesDiscord(...args),
removeChannelPermissionDiscord: (...args: unknown[]) =>
removeChannelPermissionDiscord(...args),
removeOwnReactionsDiscord: (...args: unknown[]) =>
removeOwnReactionsDiscord(...args),
removeReactionDiscord: (...args: unknown[]) => removeReactionDiscord(...args),
@@ -40,6 +60,8 @@ vi.mock("../../discord/send.js", () => ({
sendMessageDiscord: (...args: unknown[]) => sendMessageDiscord(...args),
sendPollDiscord: (...args: unknown[]) => sendPollDiscord(...args),
sendStickerDiscord: (...args: unknown[]) => sendStickerDiscord(...args),
setChannelPermissionDiscord: (...args: unknown[]) =>
setChannelPermissionDiscord(...args),
unpinMessageDiscord: (...args: unknown[]) => unpinMessageDiscord(...args),
}));
@@ -117,3 +139,214 @@ describe("handleDiscordMessagingAction", () => {
).rejects.toThrow(/Discord reactions are disabled/);
});
});
const channelsEnabled = (key: keyof DiscordActionConfig) => key === "channels";
const channelsDisabled = () => false;
describe("handleDiscordGuildAction - channel management", () => {
it("creates a channel", async () => {
const result = await handleDiscordGuildAction(
"channelCreate",
{
guildId: "G1",
name: "test-channel",
type: 0,
topic: "Test topic",
},
channelsEnabled,
);
expect(createChannelDiscord).toHaveBeenCalledWith({
guildId: "G1",
name: "test-channel",
type: 0,
parentId: undefined,
topic: "Test topic",
position: undefined,
nsfw: undefined,
});
expect(result.details).toMatchObject({ ok: true });
});
it("respects channel gating for channelCreate", async () => {
await expect(
handleDiscordGuildAction(
"channelCreate",
{ guildId: "G1", name: "test" },
channelsDisabled,
),
).rejects.toThrow(/Discord channel management is disabled/);
});
it("edits a channel", async () => {
await handleDiscordGuildAction(
"channelEdit",
{
channelId: "C1",
name: "new-name",
topic: "new topic",
},
channelsEnabled,
);
expect(editChannelDiscord).toHaveBeenCalledWith({
channelId: "C1",
name: "new-name",
topic: "new topic",
position: undefined,
parentId: undefined,
nsfw: undefined,
rateLimitPerUser: undefined,
});
});
it("clears the channel parent when parentId is null", async () => {
await handleDiscordGuildAction(
"channelEdit",
{
channelId: "C1",
parentId: null,
},
channelsEnabled,
);
expect(editChannelDiscord).toHaveBeenCalledWith({
channelId: "C1",
name: undefined,
topic: undefined,
position: undefined,
parentId: null,
nsfw: undefined,
rateLimitPerUser: undefined,
});
});
it("deletes a channel", async () => {
await handleDiscordGuildAction(
"channelDelete",
{ channelId: "C1" },
channelsEnabled,
);
expect(deleteChannelDiscord).toHaveBeenCalledWith("C1");
});
it("moves a channel", async () => {
await handleDiscordGuildAction(
"channelMove",
{
guildId: "G1",
channelId: "C1",
parentId: "P1",
position: 5,
},
channelsEnabled,
);
expect(moveChannelDiscord).toHaveBeenCalledWith({
guildId: "G1",
channelId: "C1",
parentId: "P1",
position: 5,
});
});
it("clears the channel parent on move when parentId is null", async () => {
await handleDiscordGuildAction(
"channelMove",
{
guildId: "G1",
channelId: "C1",
parentId: null,
},
channelsEnabled,
);
expect(moveChannelDiscord).toHaveBeenCalledWith({
guildId: "G1",
channelId: "C1",
parentId: null,
position: undefined,
});
});
it("creates a category with type=4", async () => {
await handleDiscordGuildAction(
"categoryCreate",
{ guildId: "G1", name: "My Category" },
channelsEnabled,
);
expect(createChannelDiscord).toHaveBeenCalledWith({
guildId: "G1",
name: "My Category",
type: 4,
position: undefined,
});
});
it("edits a category", async () => {
await handleDiscordGuildAction(
"categoryEdit",
{ categoryId: "CAT1", name: "Renamed Category" },
channelsEnabled,
);
expect(editChannelDiscord).toHaveBeenCalledWith({
channelId: "CAT1",
name: "Renamed Category",
position: undefined,
});
});
it("deletes a category", async () => {
await handleDiscordGuildAction(
"categoryDelete",
{ categoryId: "CAT1" },
channelsEnabled,
);
expect(deleteChannelDiscord).toHaveBeenCalledWith("CAT1");
});
it("sets channel permissions for role", async () => {
await handleDiscordGuildAction(
"channelPermissionSet",
{
channelId: "C1",
targetId: "R1",
targetType: "role",
allow: "1024",
deny: "2048",
},
channelsEnabled,
);
expect(setChannelPermissionDiscord).toHaveBeenCalledWith({
channelId: "C1",
targetId: "R1",
targetType: 0,
allow: "1024",
deny: "2048",
});
});
it("sets channel permissions for member", async () => {
await handleDiscordGuildAction(
"channelPermissionSet",
{
channelId: "C1",
targetId: "U1",
targetType: "member",
allow: "1024",
},
channelsEnabled,
);
expect(setChannelPermissionDiscord).toHaveBeenCalledWith({
channelId: "C1",
targetId: "U1",
targetType: 1,
allow: "1024",
deny: undefined,
});
});
it("removes channel permissions", async () => {
await handleDiscordGuildAction(
"channelPermissionRemove",
{ channelId: "C1", targetId: "R1" },
channelsEnabled,
);
expect(removeChannelPermissionDiscord).toHaveBeenCalledWith("C1", "R1");
});
});
+9
View File
@@ -37,6 +37,15 @@ const guildActions = new Set([
"voiceStatus",
"eventList",
"eventCreate",
"channelCreate",
"channelEdit",
"channelDelete",
"channelMove",
"categoryCreate",
"categoryEdit",
"categoryDelete",
"channelPermissionSet",
"channelPermissionRemove",
]);
const moderationActions = new Set(["timeout", "kick", "ban"]);
+63
View File
@@ -202,4 +202,67 @@ export const DiscordToolSchema = Type.Union([
reason: Type.Optional(Type.String()),
deleteMessageDays: Type.Optional(Type.Number()),
}),
// Channel management actions
Type.Object({
action: Type.Literal("channelCreate"),
guildId: Type.String(),
name: Type.String(),
type: Type.Optional(Type.Number()),
parentId: Type.Optional(Type.String()),
topic: Type.Optional(Type.String()),
position: Type.Optional(Type.Number()),
nsfw: Type.Optional(Type.Boolean()),
}),
Type.Object({
action: Type.Literal("channelEdit"),
channelId: Type.String(),
name: Type.Optional(Type.String()),
topic: Type.Optional(Type.String()),
position: Type.Optional(Type.Number()),
parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
nsfw: Type.Optional(Type.Boolean()),
rateLimitPerUser: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("channelDelete"),
channelId: Type.String(),
}),
Type.Object({
action: Type.Literal("channelMove"),
guildId: Type.String(),
channelId: Type.String(),
parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
position: Type.Optional(Type.Number()),
}),
// Category management actions (convenience aliases)
Type.Object({
action: Type.Literal("categoryCreate"),
guildId: Type.String(),
name: Type.String(),
position: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("categoryEdit"),
categoryId: Type.String(),
name: Type.Optional(Type.String()),
position: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("categoryDelete"),
categoryId: Type.String(),
}),
// Permission overwrite actions
Type.Object({
action: Type.Literal("channelPermissionSet"),
channelId: Type.String(),
targetId: Type.String(),
targetType: Type.Union([Type.Literal("role"), Type.Literal("member")]),
allow: Type.Optional(Type.String()),
deny: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("channelPermissionRemove"),
channelId: Type.String(),
targetId: Type.String(),
}),
]);
+3
View File
@@ -71,6 +71,9 @@ export function createGatewayTool(opts?: {
typeof params.reason === "string" && params.reason.trim()
? params.reason.trim().slice(0, 200)
: undefined;
console.info(
`gateway tool: restart requested (delayMs=${delayMs ?? "default"}, reason=${reason ?? "none"})`,
);
const scheduled = scheduleGatewaySigusr1Restart({
delayMs,
reason,
+3 -3
View File
@@ -23,7 +23,7 @@ import type { AnyAgentTool } from "./common.js";
const DEFAULT_PROMPT = "Describe the image.";
function ensureImageToolConfigured(cfg?: ClawdbotConfig): boolean {
const imageModel = cfg?.agent?.imageModel as
const imageModel = cfg?.agents?.defaults?.imageModel as
| { primary?: string; fallbacks?: string[] }
| string
| undefined;
@@ -45,7 +45,7 @@ function pickMaxBytes(
) {
return Math.floor(maxBytesMb * 1024 * 1024);
}
const configured = cfg?.agent?.mediaMaxMb;
const configured = cfg?.agents?.defaults?.mediaMaxMb;
if (
typeof configured === "number" &&
Number.isFinite(configured) &&
@@ -141,7 +141,7 @@ export function createImageTool(options?: {
label: "Image",
name: "image",
description:
"Analyze an image with the configured image model (agent.imageModel). Provide a prompt and image path or URL.",
"Analyze an image with the configured image model (agents.defaults.imageModel). Provide a prompt and image path or URL.",
parameters: Type.Object({
prompt: Type.Optional(Type.String()),
image: Type.String(),
+833 -29
View File
@@ -1,11 +1,15 @@
import { Type } from "@sinclair/typebox";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import {
sendMessage,
sendPoll,
type MessagePollResult,
type MessageSendResult,
sendMessage,
sendPoll,
} from "../../infra/outbound/message.js";
import { resolveMessageProviderSelection } from "../../infra/outbound/provider-selection.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import type { AnyAgentTool } from "./common.js";
import {
jsonResult,
@@ -13,36 +17,131 @@ import {
readStringArrayParam,
readStringParam,
} from "./common.js";
import { handleDiscordAction } from "./discord-actions.js";
import { handleSlackAction } from "./slack-actions.js";
import { handleTelegramAction } from "./telegram-actions.js";
import { handleWhatsAppAction } from "./whatsapp-actions.js";
const MessageActionSchema = Type.Union([
Type.Literal("send"),
Type.Literal("poll"),
Type.Literal("react"),
Type.Literal("reactions"),
Type.Literal("read"),
Type.Literal("edit"),
Type.Literal("delete"),
Type.Literal("pin"),
Type.Literal("unpin"),
Type.Literal("list-pins"),
Type.Literal("permissions"),
Type.Literal("thread-create"),
Type.Literal("thread-list"),
Type.Literal("thread-reply"),
Type.Literal("search"),
Type.Literal("sticker"),
Type.Literal("member-info"),
Type.Literal("role-info"),
Type.Literal("emoji-list"),
Type.Literal("emoji-upload"),
Type.Literal("sticker-upload"),
Type.Literal("role-add"),
Type.Literal("role-remove"),
Type.Literal("channel-info"),
Type.Literal("channel-list"),
Type.Literal("voice-status"),
Type.Literal("event-list"),
Type.Literal("event-create"),
Type.Literal("timeout"),
Type.Literal("kick"),
Type.Literal("ban"),
]);
const MessageToolSchema = Type.Object({
action: Type.Union([Type.Literal("send"), Type.Literal("poll")]),
to: Type.Optional(Type.String()),
content: Type.Optional(Type.String()),
mediaUrl: Type.Optional(Type.String()),
gifPlayback: Type.Optional(Type.Boolean()),
action: MessageActionSchema,
provider: Type.Optional(Type.String()),
to: Type.Optional(Type.String()),
message: Type.Optional(Type.String()),
media: Type.Optional(Type.String()),
messageId: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()),
threadId: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
dryRun: Type.Optional(Type.Boolean()),
bestEffort: Type.Optional(Type.Boolean()),
question: Type.Optional(Type.String()),
options: Type.Optional(Type.Array(Type.String())),
maxSelections: Type.Optional(Type.Number()),
durationHours: Type.Optional(Type.Number()),
gifPlayback: Type.Optional(Type.Boolean()),
emoji: Type.Optional(Type.String()),
remove: Type.Optional(Type.Boolean()),
limit: Type.Optional(Type.Number()),
before: Type.Optional(Type.String()),
after: Type.Optional(Type.String()),
around: Type.Optional(Type.String()),
pollQuestion: Type.Optional(Type.String()),
pollOption: Type.Optional(Type.Array(Type.String())),
pollDurationHours: Type.Optional(Type.Number()),
pollMulti: Type.Optional(Type.Boolean()),
channelId: Type.Optional(Type.String()),
channelIds: Type.Optional(Type.Array(Type.String())),
guildId: Type.Optional(Type.String()),
userId: Type.Optional(Type.String()),
authorId: Type.Optional(Type.String()),
authorIds: Type.Optional(Type.Array(Type.String())),
roleId: Type.Optional(Type.String()),
roleIds: Type.Optional(Type.Array(Type.String())),
emojiName: Type.Optional(Type.String()),
stickerId: Type.Optional(Type.Array(Type.String())),
stickerName: Type.Optional(Type.String()),
stickerDesc: Type.Optional(Type.String()),
stickerTags: Type.Optional(Type.String()),
threadName: Type.Optional(Type.String()),
autoArchiveMin: Type.Optional(Type.Number()),
query: Type.Optional(Type.String()),
eventName: Type.Optional(Type.String()),
eventType: Type.Optional(Type.String()),
startTime: Type.Optional(Type.String()),
endTime: Type.Optional(Type.String()),
desc: Type.Optional(Type.String()),
location: Type.Optional(Type.String()),
durationMin: Type.Optional(Type.Number()),
until: Type.Optional(Type.String()),
reason: Type.Optional(Type.String()),
deleteDays: Type.Optional(Type.Number()),
includeArchived: Type.Optional(Type.Boolean()),
participant: Type.Optional(Type.String()),
fromMe: Type.Optional(Type.Boolean()),
gatewayUrl: Type.Optional(Type.String()),
gatewayToken: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Number()),
});
export function createMessageTool(): AnyAgentTool {
type MessageToolOptions = {
agentAccountId?: string;
config?: ClawdbotConfig;
};
function resolveAgentAccountId(value?: string): string | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
return normalizeAccountId(trimmed);
}
export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
const agentAccountId = resolveAgentAccountId(options?.agentAccountId);
return {
label: "Message",
name: "message",
description:
"Send messages and polls across providers (send/poll). Prefer this for general outbound messaging.",
"Send messages and provider-specific actions (Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams).",
parameters: MessageToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const cfg = options?.config ?? loadConfig();
const action = readStringParam(params, "action", { required: true });
const providerSelection = await resolveMessageProviderSelection({
cfg,
provider: readStringParam(params, "provider"),
});
const provider = providerSelection.provider;
const accountId = readStringParam(params, "accountId") ?? agentAccountId;
const gateway = {
url: readStringParam(params, "gatewayUrl", { trim: false }),
token: readStringParam(params, "gatewayToken", { trim: false }),
@@ -54,24 +153,80 @@ export function createMessageTool(): AnyAgentTool {
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content", {
const message = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "mediaUrl", { trim: false });
const provider = readStringParam(params, "provider");
const accountId = readStringParam(params, "accountId");
const mediaUrl = readStringParam(params, "media", { trim: false });
const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
const gifPlayback =
typeof params.gifPlayback === "boolean" ? params.gifPlayback : false;
const bestEffort =
typeof params.bestEffort === "boolean" ? params.bestEffort : undefined;
typeof params.bestEffort === "boolean"
? params.bestEffort
: undefined;
if (dryRun) {
const result: MessageSendResult = await sendMessage({
to,
content: message,
mediaUrl: mediaUrl || undefined,
provider: provider || undefined,
accountId: accountId ?? undefined,
gifPlayback,
dryRun,
bestEffort,
gateway,
});
return jsonResult(result);
}
if (provider === "discord") {
return await handleDiscordAction(
{
action: "sendMessage",
to,
content: message,
mediaUrl: mediaUrl ?? undefined,
replyTo: replyTo ?? undefined,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "sendMessage",
to,
content: message,
mediaUrl: mediaUrl ?? undefined,
accountId: accountId ?? undefined,
threadTs: threadId ?? replyTo ?? undefined,
},
cfg,
);
}
if (provider === "telegram") {
return await handleTelegramAction(
{
action: "sendMessage",
to,
content: message,
mediaUrl: mediaUrl ?? undefined,
replyToMessageId: replyTo ?? undefined,
messageThreadId: threadId ?? undefined,
},
cfg,
);
}
const result: MessageSendResult = await sendMessage({
to,
content,
content: message,
mediaUrl: mediaUrl || undefined,
provider: provider || undefined,
accountId: accountId || undefined,
accountId: accountId ?? undefined,
gifPlayback,
dryRun,
bestEffort,
@@ -82,30 +237,679 @@ export function createMessageTool(): AnyAgentTool {
if (action === "poll") {
const to = readStringParam(params, "to", { required: true });
const question = readStringParam(params, "question", { required: true });
const question = readStringParam(params, "pollQuestion", {
required: true,
});
const options =
readStringArrayParam(params, "options", { required: true }) ?? [];
const maxSelections = readNumberParam(params, "maxSelections", {
readStringArrayParam(params, "pollOption", { required: true }) ?? [];
const allowMultiselect =
typeof params.pollMulti === "boolean" ? params.pollMulti : undefined;
const durationHours = readNumberParam(params, "pollDurationHours", {
integer: true,
});
const durationHours = readNumberParam(params, "durationHours", {
integer: true,
});
const provider = readStringParam(params, "provider");
if (dryRun) {
const maxSelections = allowMultiselect
? Math.max(2, options.length)
: 1;
const result: MessagePollResult = await sendPoll({
to,
question,
options,
maxSelections,
durationHours: durationHours ?? undefined,
provider,
dryRun,
gateway,
});
return jsonResult(result);
}
if (provider === "discord") {
return await handleDiscordAction(
{
action: "poll",
to,
question,
answers: options,
allowMultiselect,
durationHours: durationHours ?? undefined,
content: readStringParam(params, "message"),
},
cfg,
);
}
const maxSelections = allowMultiselect
? Math.max(2, options.length)
: 1;
const result: MessagePollResult = await sendPoll({
to,
question,
options,
maxSelections,
durationHours,
provider: provider || undefined,
durationHours: durationHours ?? undefined,
provider,
dryRun,
gateway,
});
return jsonResult(result);
}
const resolveChannelId = (label: string) =>
readStringParam(params, label) ??
readStringParam(params, "to", { required: true });
const resolveChatId = (label: string) =>
readStringParam(params, label) ??
readStringParam(params, "to", { required: true });
if (action === "react") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove =
typeof params.remove === "boolean" ? params.remove : undefined;
if (provider === "discord") {
return await handleDiscordAction(
{
action: "react",
channelId: resolveChannelId("channelId"),
messageId,
emoji,
remove,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "react",
channelId: resolveChannelId("channelId"),
messageId,
emoji,
remove,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (provider === "telegram") {
return await handleTelegramAction(
{
action: "react",
chatId: resolveChatId("chatId"),
messageId,
emoji,
remove,
},
cfg,
);
}
if (provider === "whatsapp") {
return await handleWhatsAppAction(
{
action: "react",
chatJid: resolveChatId("chatJid"),
messageId,
emoji,
remove,
participant: readStringParam(params, "participant"),
accountId: accountId ?? undefined,
fromMe:
typeof params.fromMe === "boolean" ? params.fromMe : undefined,
},
cfg,
);
}
throw new Error(`React is not supported for provider ${provider}.`);
}
if (action === "reactions") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const limit = readNumberParam(params, "limit", { integer: true });
if (provider === "discord") {
return await handleDiscordAction(
{
action: "reactions",
channelId: resolveChannelId("channelId"),
messageId,
limit,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "reactions",
channelId: resolveChannelId("channelId"),
messageId,
limit,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(
`Reactions are not supported for provider ${provider}.`,
);
}
if (action === "read") {
const limit = readNumberParam(params, "limit", { integer: true });
const before = readStringParam(params, "before");
const after = readStringParam(params, "after");
const around = readStringParam(params, "around");
if (provider === "discord") {
return await handleDiscordAction(
{
action: "readMessages",
channelId: resolveChannelId("channelId"),
limit,
before,
after,
around,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "readMessages",
channelId: resolveChannelId("channelId"),
limit,
before,
after,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(`Read is not supported for provider ${provider}.`);
}
if (action === "edit") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const message = readStringParam(params, "message", { required: true });
if (provider === "discord") {
return await handleDiscordAction(
{
action: "editMessage",
channelId: resolveChannelId("channelId"),
messageId,
content: message,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "editMessage",
channelId: resolveChannelId("channelId"),
messageId,
content: message,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(`Edit is not supported for provider ${provider}.`);
}
if (action === "delete") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
if (provider === "discord") {
return await handleDiscordAction(
{
action: "deleteMessage",
channelId: resolveChannelId("channelId"),
messageId,
},
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{
action: "deleteMessage",
channelId: resolveChannelId("channelId"),
messageId,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(`Delete is not supported for provider ${provider}.`);
}
if (action === "pin" || action === "unpin" || action === "list-pins") {
const messageId =
action === "list-pins"
? undefined
: readStringParam(params, "messageId", { required: true });
const channelId = resolveChannelId("channelId");
if (provider === "discord") {
const discordAction =
action === "pin"
? "pinMessage"
: action === "unpin"
? "unpinMessage"
: "listPins";
return await handleDiscordAction(
{
action: discordAction,
channelId,
messageId,
},
cfg,
);
}
if (provider === "slack") {
const slackAction =
action === "pin"
? "pinMessage"
: action === "unpin"
? "unpinMessage"
: "listPins";
return await handleSlackAction(
{
action: slackAction,
channelId,
messageId,
accountId: accountId ?? undefined,
},
cfg,
);
}
throw new Error(`Pins are not supported for provider ${provider}.`);
}
if (action === "permissions") {
if (provider !== "discord") {
throw new Error(
`Permissions are only supported for Discord (provider=${provider}).`,
);
}
return await handleDiscordAction(
{
action: "permissions",
channelId: resolveChannelId("channelId"),
},
cfg,
);
}
if (action === "thread-create") {
if (provider !== "discord") {
throw new Error(
`Thread create is only supported for Discord (provider=${provider}).`,
);
}
const name = readStringParam(params, "threadName", { required: true });
const messageId = readStringParam(params, "messageId");
const autoArchiveMinutes = readNumberParam(params, "autoArchiveMin", {
integer: true,
});
return await handleDiscordAction(
{
action: "threadCreate",
channelId: resolveChannelId("channelId"),
name,
messageId,
autoArchiveMinutes,
},
cfg,
);
}
if (action === "thread-list") {
if (provider !== "discord") {
throw new Error(
`Thread list is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const channelId = readStringParam(params, "channelId");
const includeArchived =
typeof params.includeArchived === "boolean"
? params.includeArchived
: undefined;
const before = readStringParam(params, "before");
const limit = readNumberParam(params, "limit", { integer: true });
return await handleDiscordAction(
{
action: "threadList",
guildId,
channelId,
includeArchived,
before,
limit,
},
cfg,
);
}
if (action === "thread-reply") {
if (provider !== "discord") {
throw new Error(
`Thread reply is only supported for Discord (provider=${provider}).`,
);
}
const content = readStringParam(params, "message", { required: true });
const mediaUrl = readStringParam(params, "media", { trim: false });
const replyTo = readStringParam(params, "replyTo");
return await handleDiscordAction(
{
action: "threadReply",
channelId: resolveChannelId("channelId"),
content,
mediaUrl: mediaUrl ?? undefined,
replyTo: replyTo ?? undefined,
},
cfg,
);
}
if (action === "search") {
if (provider !== "discord") {
throw new Error(
`Search is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const query = readStringParam(params, "query", { required: true });
const channelId = readStringParam(params, "channelId");
const channelIds = readStringArrayParam(params, "channelIds");
const authorId = readStringParam(params, "authorId");
const authorIds = readStringArrayParam(params, "authorIds");
const limit = readNumberParam(params, "limit", { integer: true });
return await handleDiscordAction(
{
action: "searchMessages",
guildId,
content: query,
channelId,
channelIds,
authorId,
authorIds,
limit,
},
cfg,
);
}
if (action === "sticker") {
if (provider !== "discord") {
throw new Error(
`Sticker send is only supported for Discord (provider=${provider}).`,
);
}
const stickerIds =
readStringArrayParam(params, "stickerId", {
required: true,
label: "sticker-id",
}) ?? [];
const content = readStringParam(params, "message");
return await handleDiscordAction(
{
action: "sticker",
to: readStringParam(params, "to", { required: true }),
stickerIds,
content,
},
cfg,
);
}
if (action === "member-info") {
const userId = readStringParam(params, "userId", { required: true });
if (provider === "discord") {
const guildId = readStringParam(params, "guildId", {
required: true,
});
return await handleDiscordAction(
{ action: "memberInfo", guildId, userId },
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{ action: "memberInfo", userId, accountId: accountId ?? undefined },
cfg,
);
}
throw new Error(
`Member info is not supported for provider ${provider}.`,
);
}
if (action === "role-info") {
if (provider !== "discord") {
throw new Error(
`Role info is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
return await handleDiscordAction({ action: "roleInfo", guildId }, cfg);
}
if (action === "emoji-list") {
if (provider === "discord") {
const guildId = readStringParam(params, "guildId", {
required: true,
});
return await handleDiscordAction(
{ action: "emojiList", guildId },
cfg,
);
}
if (provider === "slack") {
return await handleSlackAction(
{ action: "emojiList", accountId: accountId ?? undefined },
cfg,
);
}
throw new Error(
`Emoji list is not supported for provider ${provider}.`,
);
}
if (action === "emoji-upload") {
if (provider !== "discord") {
throw new Error(
`Emoji upload is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "emojiName", { required: true });
const mediaUrl = readStringParam(params, "media", {
required: true,
trim: false,
});
const roleIds = readStringArrayParam(params, "roleIds");
return await handleDiscordAction(
{
action: "emojiUpload",
guildId,
name,
mediaUrl,
roleIds,
},
cfg,
);
}
if (action === "sticker-upload") {
if (provider !== "discord") {
throw new Error(
`Sticker upload is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "stickerName", { required: true });
const description = readStringParam(params, "stickerDesc", {
required: true,
});
const tags = readStringParam(params, "stickerTags", { required: true });
const mediaUrl = readStringParam(params, "media", {
required: true,
trim: false,
});
return await handleDiscordAction(
{
action: "stickerUpload",
guildId,
name,
description,
tags,
mediaUrl,
},
cfg,
);
}
if (action === "role-add" || action === "role-remove") {
if (provider !== "discord") {
throw new Error(
`Role changes are only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const userId = readStringParam(params, "userId", { required: true });
const roleId = readStringParam(params, "roleId", { required: true });
const discordAction = action === "role-add" ? "roleAdd" : "roleRemove";
return await handleDiscordAction(
{ action: discordAction, guildId, userId, roleId },
cfg,
);
}
if (action === "channel-info") {
if (provider !== "discord") {
throw new Error(
`Channel info is only supported for Discord (provider=${provider}).`,
);
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
return await handleDiscordAction(
{ action: "channelInfo", channelId },
cfg,
);
}
if (action === "channel-list") {
if (provider !== "discord") {
throw new Error(
`Channel list is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
return await handleDiscordAction(
{ action: "channelList", guildId },
cfg,
);
}
if (action === "voice-status") {
if (provider !== "discord") {
throw new Error(
`Voice status is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const userId = readStringParam(params, "userId", { required: true });
return await handleDiscordAction(
{ action: "voiceStatus", guildId, userId },
cfg,
);
}
if (action === "event-list") {
if (provider !== "discord") {
throw new Error(
`Event list is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
return await handleDiscordAction({ action: "eventList", guildId }, cfg);
}
if (action === "event-create") {
if (provider !== "discord") {
throw new Error(
`Event create is only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "eventName", { required: true });
const startTime = readStringParam(params, "startTime", {
required: true,
});
const endTime = readStringParam(params, "endTime");
const description = readStringParam(params, "desc");
const channelId = readStringParam(params, "channelId");
const location = readStringParam(params, "location");
const entityType = readStringParam(params, "eventType");
return await handleDiscordAction(
{
action: "eventCreate",
guildId,
name,
startTime,
endTime,
description,
channelId,
location,
entityType,
},
cfg,
);
}
if (action === "timeout" || action === "kick" || action === "ban") {
if (provider !== "discord") {
throw new Error(
`Moderation actions are only supported for Discord (provider=${provider}).`,
);
}
const guildId = readStringParam(params, "guildId", { required: true });
const userId = readStringParam(params, "userId", { required: true });
const durationMinutes = readNumberParam(params, "durationMin", {
integer: true,
});
const until = readStringParam(params, "until");
const reason = readStringParam(params, "reason");
const deleteMessageDays = readNumberParam(params, "deleteDays", {
integer: true,
});
const discordAction = action as "timeout" | "kick" | "ban";
return await handleDiscordAction(
{
action: discordAction,
guildId,
userId,
durationMinutes,
until,
reason,
deleteMessageDays,
},
cfg,
);
}
throw new Error(`Unknown action: ${action}`);
},
};
+4 -5
View File
@@ -25,7 +25,7 @@ const SessionsHistoryToolSchema = Type.Object({
function resolveSandboxSessionToolsVisibility(
cfg: ReturnType<typeof loadConfig>,
) {
return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned";
return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
}
async function isSpawnedSessionAllowed(params: {
@@ -97,7 +97,7 @@ export function createSessionsHistoryTool(opts?: {
}
}
const routingA2A = cfg.routing?.agentToAgent;
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
@@ -126,14 +126,13 @@ export function createSessionsHistoryTool(opts?: {
return jsonResult({
status: "forbidden",
error:
"Agent-to-agent history is disabled. Set routing.agentToAgent.enabled=true to allow cross-agent access.",
"Agent-to-agent history is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.",
});
}
if (!matchesAllow(requesterAgentId) || !matchesAllow(targetAgentId)) {
return jsonResult({
status: "forbidden",
error:
"Agent-to-agent history denied by routing.agentToAgent.allow.",
error: "Agent-to-agent history denied by tools.agentToAgent.allow.",
});
}
}
@@ -13,7 +13,7 @@ vi.mock("../../config/config.js", async (importOriginal) => {
loadConfig: () =>
({
session: { scope: "per-sender", mainKey: "main" },
routing: { agentToAgent: { enabled: false } },
tools: { agentToAgent: { enabled: false } },
}) as never,
};
});
@@ -32,7 +32,7 @@ describe("sessions_list gating", () => {
});
});
it("filters out other agents when routing.agentToAgent.enabled is false", async () => {
it("filters out other agents when tools.agentToAgent.enabled is false", async () => {
const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" });
const result = await tool.execute("call1", {});
expect(result.details).toMatchObject({
+4 -2
View File
@@ -25,6 +25,7 @@ type SessionListRow = {
key: string;
kind: SessionKind;
provider: string;
label?: string;
displayName?: string;
updatedAt?: number | null;
sessionId?: string;
@@ -53,7 +54,7 @@ const SessionsListToolSchema = Type.Object({
function resolveSandboxSessionToolsVisibility(
cfg: ReturnType<typeof loadConfig>,
) {
return cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned";
return cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
}
export function createSessionsListTool(opts?: {
@@ -126,7 +127,7 @@ export function createSessionsListTool(opts?: {
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
const storePath = typeof list?.path === "string" ? list.path : undefined;
const routingA2A = cfg.routing?.agentToAgent;
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
@@ -205,6 +206,7 @@ export function createSessionsListTool(opts?: {
key: displayKey,
kind,
provider: derivedProvider,
label: typeof entry.label === "string" ? entry.label : undefined,
displayName:
typeof entry.displayName === "string"
? entry.displayName
@@ -13,7 +13,7 @@ vi.mock("../../config/config.js", async (importOriginal) => {
loadConfig: () =>
({
session: { scope: "per-sender", mainKey: "main" },
routing: { agentToAgent: { enabled: false } },
tools: { agentToAgent: { enabled: false } },
}) as never,
};
});
@@ -25,7 +25,7 @@ describe("sessions_send gating", () => {
callGatewayMock.mockReset();
});
it("blocks cross-agent sends when routing.agentToAgent.enabled is false", async () => {
it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => {
const tool = createSessionsSendTool({
agentSessionKey: "agent:main:main",
agentProvider: "whatsapp",
+181 -56
View File
@@ -9,6 +9,7 @@ import {
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { SESSION_LABEL_MAX_LENGTH } from "../../sessions/session-label.js";
import { readLatestAssistantReply, runAgentStep } from "./agent-step.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam } from "./common.js";
@@ -29,11 +30,25 @@ import {
resolvePingPongTurns,
} from "./sessions-send-helpers.js";
const SessionsSendToolSchema = Type.Object({
sessionKey: Type.String(),
message: Type.String(),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
});
const SessionsSendToolSchema = Type.Union([
Type.Object(
{
sessionKey: Type.String(),
message: Type.String(),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
),
Type.Object(
{
label: Type.String({ minLength: 1, maxLength: SESSION_LABEL_MAX_LENGTH }),
agentId: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
message: Type.String(),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
},
{ additionalProperties: false },
),
]);
export function createSessionsSendTool(opts?: {
agentSessionKey?: string;
@@ -43,18 +58,16 @@ export function createSessionsSendTool(opts?: {
return {
label: "Session Send",
name: "sessions_send",
description: "Send a message into another session.",
description:
"Send a message into another session. Use sessionKey or label to identify the target.",
parameters: SessionsSendToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const sessionKey = readStringParam(params, "sessionKey", {
required: true,
});
const message = readStringParam(params, "message", { required: true });
const cfg = loadConfig();
const { mainKey, alias } = resolveMainSessionAlias(cfg);
const visibility =
cfg.agent?.sandbox?.sessionToolsVisibility ?? "spawned";
cfg.agents?.defaults?.sandbox?.sessionToolsVisibility ?? "spawned";
const requesterInternalKey =
typeof opts?.agentSessionKey === "string" && opts.agentSessionKey.trim()
? resolveInternalSessionKey({
@@ -63,42 +76,172 @@ export function createSessionsSendTool(opts?: {
mainKey,
})
: undefined;
const resolvedKey = resolveInternalSessionKey({
key: sessionKey,
alias,
mainKey,
});
const restrictToSpawned =
opts?.sandboxed === true &&
visibility === "spawned" &&
requesterInternalKey &&
!isSubagentSessionKey(requesterInternalKey);
if (restrictToSpawned) {
try {
const list = (await callGateway({
method: "sessions.list",
params: {
includeGlobal: false,
includeUnknown: false,
limit: 500,
spawnedBy: requesterInternalKey,
},
})) as { sessions?: Array<Record<string, unknown>> };
const sessions = Array.isArray(list?.sessions) ? list.sessions : [];
const ok = sessions.some((entry) => entry?.key === resolvedKey);
if (!ok) {
const routingA2A = cfg.tools?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
: [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
const raw = String(pattern ?? "").trim();
if (!raw) return false;
if (raw === "*") return true;
if (!raw.includes("*")) return raw === agentId;
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
return re.test(agentId);
});
};
const sessionKeyParam = readStringParam(params, "sessionKey");
const labelParam = readStringParam(params, "label")?.trim() || undefined;
const labelAgentIdParam =
readStringParam(params, "agentId")?.trim() || undefined;
if (sessionKeyParam && labelParam) {
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: "Provide either sessionKey or label (not both).",
});
}
const listSessions = async (listParams: Record<string, unknown>) => {
const result = (await callGateway({
method: "sessions.list",
params: listParams,
timeoutMs: 10_000,
})) as { sessions?: Array<Record<string, unknown>> };
return Array.isArray(result?.sessions) ? result.sessions : [];
};
let sessionKey = sessionKeyParam;
if (!sessionKey && labelParam) {
const requesterAgentId = requesterInternalKey
? normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
)
: undefined;
const requestedAgentId = labelAgentIdParam
? normalizeAgentId(labelAgentIdParam)
: undefined;
if (
restrictToSpawned &&
requestedAgentId &&
requesterAgentId &&
requestedAgentId !== requesterAgentId
) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Sandboxed sessions_send label lookup is limited to this agent",
});
}
if (
requesterAgentId &&
requestedAgentId &&
requestedAgentId !== requesterAgentId
) {
if (!a2aEnabled) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error: `Session not visible from this sandboxed agent session: ${sessionKey}`,
sessionKey: resolveDisplaySessionKey({
key: sessionKey,
alias,
mainKey,
}),
error:
"Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.",
});
}
} catch {
if (
!matchesAllow(requesterAgentId) ||
!matchesAllow(requestedAgentId)
) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Agent-to-agent messaging denied by tools.agentToAgent.allow.",
});
}
}
const resolveParams: Record<string, unknown> = {
label: labelParam,
...(requestedAgentId ? { agentId: requestedAgentId } : {}),
...(restrictToSpawned ? { spawnedBy: requesterInternalKey } : {}),
};
let resolvedKey = "";
try {
const resolved = (await callGateway({
method: "sessions.resolve",
params: resolveParams,
timeoutMs: 10_000,
})) as { key?: unknown };
resolvedKey =
typeof resolved?.key === "string" ? resolved.key.trim() : "";
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (restrictToSpawned) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error: `Session not visible from this sandboxed agent session: label=${labelParam}`,
});
}
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: msg || `No session found with label: ${labelParam}`,
});
}
if (!resolvedKey) {
if (restrictToSpawned) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
error: `Session not visible from this sandboxed agent session: label=${labelParam}`,
});
}
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: `No session found with label: ${labelParam}`,
});
}
sessionKey = resolvedKey;
}
if (!sessionKey) {
return jsonResult({
runId: crypto.randomUUID(),
status: "error",
error: "Either sessionKey or label is required",
});
}
const resolvedKey = resolveInternalSessionKey({
key: sessionKey,
alias,
mainKey,
});
if (restrictToSpawned) {
const sessions = await listSessions({
includeGlobal: false,
includeUnknown: false,
limit: 500,
spawnedBy: requesterInternalKey,
});
const ok = sessions.some((entry) => entry?.key === resolvedKey);
if (!ok) {
return jsonResult({
runId: crypto.randomUUID(),
status: "forbidden",
@@ -125,24 +268,6 @@ export function createSessionsSendTool(opts?: {
alias,
mainKey,
});
const routingA2A = cfg.routing?.agentToAgent;
const a2aEnabled = routingA2A?.enabled === true;
const allowPatterns = Array.isArray(routingA2A?.allow)
? routingA2A.allow
: [];
const matchesAllow = (agentId: string) => {
if (allowPatterns.length === 0) return true;
return allowPatterns.some((pattern) => {
const raw = String(pattern ?? "").trim();
if (!raw) return false;
if (raw === "*") return true;
if (!raw.includes("*")) return raw === agentId;
const escaped = raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^${escaped.replaceAll("\\*", ".*")}$`, "i");
return re.test(agentId);
});
};
const requesterAgentId = normalizeAgentId(
parseAgentSessionKey(requesterInternalKey)?.agentId,
);
@@ -156,7 +281,7 @@ export function createSessionsSendTool(opts?: {
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Agent-to-agent messaging is disabled. Set routing.agentToAgent.enabled=true to allow cross-agent sends.",
"Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.",
sessionKey: displayKey,
});
}
@@ -165,7 +290,7 @@ export function createSessionsSendTool(opts?: {
runId: crypto.randomUUID(),
status: "forbidden",
error:
"Agent-to-agent messaging denied by routing.agentToAgent.allow.",
"Agent-to-agent messaging denied by tools.agentToAgent.allow.",
sessionKey: displayKey,
});
}
+4 -11
View File
@@ -126,17 +126,7 @@ export function createSessionsSpawnTool(opts?: {
}
}
const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`;
if (opts?.sandboxed === true) {
try {
await callGateway({
method: "sessions.patch",
params: { key: childSessionKey, spawnedBy: requesterInternalKey },
timeoutMs: 10_000,
});
} catch {
// best-effort; scoping relies on this metadata but spawning still works without it
}
}
const shouldPatchSpawnedBy = opts?.sandboxed === true;
if (model) {
try {
await callGateway({
@@ -185,6 +175,8 @@ export function createSessionsSpawnTool(opts?: {
lane: "subagent",
extraSystemPrompt: childSystemPrompt,
timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined,
label: label || undefined,
spawnedBy: shouldPatchSpawnedBy ? requesterInternalKey : undefined,
},
timeoutMs: 10_000,
})) as { runId?: string };
@@ -214,6 +206,7 @@ export function createSessionsSpawnTool(opts?: {
requesterDisplayKey,
task,
cleanup,
label: label || undefined,
});
return jsonResult({
+2
View File
@@ -91,9 +91,11 @@ export async function handleSlackAction(
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content", { required: true });
const mediaUrl = readStringParam(params, "mediaUrl");
const threadTs = readStringParam(params, "threadTs");
const result = await sendSlackMessage(to, content, {
accountId: accountId ?? undefined,
mediaUrl: mediaUrl ?? undefined,
threadTs: threadTs ?? undefined,
});
return jsonResult({ ok: true, result });
}
+1
View File
@@ -24,6 +24,7 @@ export const SlackToolSchema = Type.Union([
to: Type.String(),
content: Type.String(),
mediaUrl: Type.Optional(Type.String()),
threadTs: Type.Optional(Type.String()),
accountId: Type.Optional(Type.String()),
}),
Type.Object({
+6 -1
View File
@@ -17,7 +17,8 @@ export type TextChunkProvider =
| "slack"
| "signal"
| "imessage"
| "webchat";
| "webchat"
| "msteams";
const DEFAULT_CHUNK_LIMIT_BY_PROVIDER: Record<TextChunkProvider, number> = {
whatsapp: 4000,
@@ -27,6 +28,7 @@ const DEFAULT_CHUNK_LIMIT_BY_PROVIDER: Record<TextChunkProvider, number> = {
signal: 4000,
imessage: 4000,
webchat: 4000,
msteams: 4000,
};
export function resolveTextChunkLimit(
@@ -70,6 +72,9 @@ export function resolveTextChunkLimit(
cfg?.imessage?.textChunkLimit
);
}
if (provider === "msteams") {
return cfg?.msteams?.textChunkLimit;
}
return undefined;
})();
if (typeof providerOverride === "number" && providerOverride > 0) {
+15 -5
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { hasControlCommand } from "./command-detection.js";
import { listChatCommands } from "./commands-registry.js";
import { parseActivationCommand } from "./group-activation.js";
import { parseSendPolicyCommand } from "./send-policy.js";
@@ -37,14 +38,23 @@ describe("control command parsing", () => {
});
it("treats bare commands as non-control", () => {
expect(hasControlCommand("/send")).toBe(true);
expect(hasControlCommand("send")).toBe(false);
expect(hasControlCommand("/help")).toBe(true);
expect(hasControlCommand("/help:")).toBe(true);
expect(hasControlCommand("help")).toBe(false);
expect(hasControlCommand("/status")).toBe(true);
expect(hasControlCommand("/status:")).toBe(true);
expect(hasControlCommand("/commands")).toBe(true);
expect(hasControlCommand("/commands:")).toBe(true);
expect(hasControlCommand("commands")).toBe(false);
expect(hasControlCommand("/compact")).toBe(true);
expect(hasControlCommand("/compact:")).toBe(true);
expect(hasControlCommand("compact")).toBe(false);
expect(hasControlCommand("status")).toBe(false);
expect(hasControlCommand("usage")).toBe(false);
for (const command of listChatCommands()) {
for (const alias of command.textAliases) {
expect(hasControlCommand(alias)).toBe(true);
expect(hasControlCommand(`${alias}:`)).toBe(true);
}
}
});
it("requires commands to be the full message", () => {
+19 -7
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
buildCommandText,
getCommandDetection,
listChatCommands,
listNativeCommandSpecs,
shouldHandleTextCommands,
} from "./commands-registry.js";
@@ -17,17 +18,28 @@ describe("commands registry", () => {
const specs = listNativeCommandSpecs();
expect(specs.find((spec) => spec.name === "help")).toBeTruthy();
expect(specs.find((spec) => spec.name === "stop")).toBeTruthy();
expect(specs.find((spec) => spec.name === "compact")).toBeFalsy();
});
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/help")).toBe(true);
expect(detection.regex.test("/status")).toBe(true);
expect(detection.regex.test("/status:")).toBe(true);
expect(detection.regex.test("/stop")).toBe(true);
expect(detection.regex.test("/send:")).toBe(true);
expect(detection.regex.test("/models")).toBe(true);
expect(detection.regex.test("/models list")).toBe(true);
expect(detection.exact.has("/commands")).toBe(true);
expect(detection.exact.has("/compact")).toBe(true);
for (const command of listChatCommands()) {
for (const alias of command.textAliases) {
expect(detection.exact.has(alias.toLowerCase())).toBe(true);
expect(detection.regex.test(alias)).toBe(true);
expect(detection.regex.test(`${alias}:`)).toBe(true);
if (command.acceptsArgs) {
expect(detection.regex.test(`${alias} list`)).toBe(true);
expect(detection.regex.test(`${alias}: list`)).toBe(true);
} else {
expect(detection.regex.test(`${alias} list`)).toBe(false);
expect(detection.regex.test(`${alias}: list`)).toBe(false);
}
}
}
expect(detection.regex.test("try /status")).toBe(false);
});
+284 -110
View File
@@ -1,11 +1,14 @@
import type { ClawdbotConfig } from "../config/types.js";
export type CommandScope = "text" | "native" | "both";
export type ChatCommandDefinition = {
key: string;
nativeName: string;
nativeName?: string;
description: string;
textAliases: string[];
acceptsArgs?: boolean;
scope: CommandScope;
};
export type NativeCommandSpec = {
@@ -14,110 +17,257 @@ export type NativeCommandSpec = {
acceptsArgs: boolean;
};
const CHAT_COMMANDS: ChatCommandDefinition[] = [
{
key: "help",
nativeName: "help",
description: "Show available commands.",
textAliases: ["/help"],
},
{
key: "status",
nativeName: "status",
description: "Show current status.",
textAliases: ["/status"],
},
{
key: "cost",
nativeName: "cost",
description: "Toggle per-response usage line.",
textAliases: ["/cost"],
acceptsArgs: true,
},
{
key: "stop",
nativeName: "stop",
description: "Stop the current run.",
textAliases: ["/stop"],
},
{
key: "restart",
nativeName: "restart",
description: "Restart Clawdbot.",
textAliases: ["/restart"],
},
{
key: "activation",
nativeName: "activation",
description: "Set group activation mode.",
textAliases: ["/activation"],
acceptsArgs: true,
},
{
key: "send",
nativeName: "send",
description: "Set send policy.",
textAliases: ["/send"],
acceptsArgs: true,
},
{
key: "reset",
nativeName: "reset",
description: "Reset the current session.",
textAliases: ["/reset"],
},
{
key: "new",
nativeName: "new",
description: "Start a new session.",
textAliases: ["/new"],
},
{
key: "think",
nativeName: "think",
description: "Set thinking level.",
textAliases: ["/thinking", "/think", "/t"],
acceptsArgs: true,
},
{
key: "verbose",
nativeName: "verbose",
description: "Toggle verbose mode.",
textAliases: ["/verbose", "/v"],
acceptsArgs: true,
},
{
key: "reasoning",
nativeName: "reasoning",
description: "Toggle reasoning visibility.",
textAliases: ["/reasoning", "/reason"],
acceptsArgs: true,
},
{
key: "elevated",
nativeName: "elevated",
description: "Toggle elevated mode.",
textAliases: ["/elevated", "/elev"],
acceptsArgs: true,
},
{
key: "model",
nativeName: "model",
description: "Show or set the model.",
textAliases: ["/model", "/models"],
acceptsArgs: true,
},
{
key: "queue",
nativeName: "queue",
description: "Adjust queue settings.",
textAliases: ["/queue"],
acceptsArgs: true,
},
];
type TextAliasSpec = {
canonical: string;
acceptsArgs: boolean;
};
function defineChatCommand(command: {
key: string;
nativeName?: string;
description: string;
acceptsArgs?: boolean;
textAlias?: string;
textAliases?: string[];
scope?: CommandScope;
}): ChatCommandDefinition {
const aliases = (
command.textAliases ?? (command.textAlias ? [command.textAlias] : [])
)
.map((alias) => alias.trim())
.filter(Boolean);
const scope =
command.scope ??
(command.nativeName ? (aliases.length ? "both" : "native") : "text");
return {
key: command.key,
nativeName: command.nativeName,
description: command.description,
acceptsArgs: command.acceptsArgs,
textAliases: aliases,
scope,
};
}
function registerAlias(
commands: ChatCommandDefinition[],
key: string,
...aliases: string[]
): void {
const command = commands.find((entry) => entry.key === key);
if (!command) {
throw new Error(`registerAlias: unknown command key: ${key}`);
}
const existing = new Set(
command.textAliases.map((alias) => alias.trim().toLowerCase()),
);
for (const alias of aliases) {
const trimmed = alias.trim();
if (!trimmed) continue;
const lowered = trimmed.toLowerCase();
if (existing.has(lowered)) continue;
existing.add(lowered);
command.textAliases.push(trimmed);
}
}
function assertCommandRegistry(commands: ChatCommandDefinition[]): void {
const keys = new Set<string>();
const nativeNames = new Set<string>();
const textAliases = new Set<string>();
for (const command of commands) {
if (keys.has(command.key)) {
throw new Error(`Duplicate command key: ${command.key}`);
}
keys.add(command.key);
const nativeName = command.nativeName?.trim();
if (command.scope === "text") {
if (nativeName) {
throw new Error(`Text-only command has native name: ${command.key}`);
}
if (command.textAliases.length === 0) {
throw new Error(`Text-only command missing text alias: ${command.key}`);
}
} else if (!nativeName) {
throw new Error(`Native command missing native name: ${command.key}`);
} else {
const nativeKey = nativeName.toLowerCase();
if (nativeNames.has(nativeKey)) {
throw new Error(`Duplicate native command: ${nativeName}`);
}
nativeNames.add(nativeKey);
}
if (command.scope === "native" && command.textAliases.length > 0) {
throw new Error(`Native-only command has text aliases: ${command.key}`);
}
for (const alias of command.textAliases) {
if (!alias.startsWith("/")) {
throw new Error(`Command alias missing leading '/': ${alias}`);
}
const aliasKey = alias.toLowerCase();
if (textAliases.has(aliasKey)) {
throw new Error(`Duplicate command alias: ${alias}`);
}
textAliases.add(aliasKey);
}
}
}
export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
const commands: ChatCommandDefinition[] = [
defineChatCommand({
key: "help",
nativeName: "help",
description: "Show available commands.",
textAlias: "/help",
}),
defineChatCommand({
key: "commands",
nativeName: "commands",
description: "List all slash commands.",
textAlias: "/commands",
}),
defineChatCommand({
key: "status",
nativeName: "status",
description: "Show current status.",
textAlias: "/status",
}),
defineChatCommand({
key: "debug",
nativeName: "debug",
description: "Set runtime debug overrides.",
textAlias: "/debug",
acceptsArgs: true,
}),
defineChatCommand({
key: "cost",
nativeName: "cost",
description: "Toggle per-response usage line.",
textAlias: "/cost",
acceptsArgs: true,
}),
defineChatCommand({
key: "stop",
nativeName: "stop",
description: "Stop the current run.",
textAlias: "/stop",
}),
defineChatCommand({
key: "restart",
nativeName: "restart",
description: "Restart Clawdbot.",
textAlias: "/restart",
}),
defineChatCommand({
key: "activation",
nativeName: "activation",
description: "Set group activation mode.",
textAlias: "/activation",
acceptsArgs: true,
}),
defineChatCommand({
key: "send",
nativeName: "send",
description: "Set send policy.",
textAlias: "/send",
acceptsArgs: true,
}),
defineChatCommand({
key: "reset",
nativeName: "reset",
description: "Reset the current session.",
textAlias: "/reset",
}),
defineChatCommand({
key: "new",
nativeName: "new",
description: "Start a new session.",
textAlias: "/new",
}),
defineChatCommand({
key: "compact",
description: "Compact the session context.",
textAlias: "/compact",
scope: "text",
acceptsArgs: true,
}),
defineChatCommand({
key: "think",
nativeName: "think",
description: "Set thinking level.",
textAlias: "/think",
acceptsArgs: true,
}),
defineChatCommand({
key: "verbose",
nativeName: "verbose",
description: "Toggle verbose mode.",
textAlias: "/verbose",
acceptsArgs: true,
}),
defineChatCommand({
key: "reasoning",
nativeName: "reasoning",
description: "Toggle reasoning visibility.",
textAlias: "/reasoning",
acceptsArgs: true,
}),
defineChatCommand({
key: "elevated",
nativeName: "elevated",
description: "Toggle elevated mode.",
textAlias: "/elevated",
acceptsArgs: true,
}),
defineChatCommand({
key: "model",
nativeName: "model",
description: "Show or set the model.",
textAlias: "/model",
acceptsArgs: true,
}),
defineChatCommand({
key: "queue",
nativeName: "queue",
description: "Adjust queue settings.",
textAlias: "/queue",
acceptsArgs: true,
}),
];
registerAlias(commands, "status", "/usage");
registerAlias(commands, "think", "/thinking", "/t");
registerAlias(commands, "verbose", "/v");
registerAlias(commands, "reasoning", "/reason");
registerAlias(commands, "elevated", "/elev");
registerAlias(commands, "model", "/models");
assertCommandRegistry(commands);
return commands;
})();
const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]);
const TEXT_ALIAS_MAP: Map<string, TextAliasSpec> = (() => {
const map = new Map<string, TextAliasSpec>();
for (const command of CHAT_COMMANDS) {
const canonical = `/${command.key}`;
const acceptsArgs = Boolean(command.acceptsArgs);
for (const alias of command.textAliases) {
const normalized = alias.trim().toLowerCase();
if (!normalized) continue;
if (!map.has(normalized)) {
map.set(normalized, { canonical, acceptsArgs });
}
}
}
return map;
})();
let cachedDetection:
| {
exact: Set<string>;
@@ -134,8 +284,10 @@ export function listChatCommands(): ChatCommandDefinition[] {
}
export function listNativeCommandSpecs(): NativeCommandSpec[] {
return CHAT_COMMANDS.map((command) => ({
name: command.nativeName,
return CHAT_COMMANDS.filter(
(command) => command.scope !== "text" && command.nativeName,
).map((command) => ({
name: command.nativeName ?? command.key,
description: command.description,
acceptsArgs: Boolean(command.acceptsArgs),
}));
@@ -146,7 +298,9 @@ export function findCommandByNativeName(
): ChatCommandDefinition | undefined {
const normalized = name.trim().toLowerCase();
return CHAT_COMMANDS.find(
(command) => command.nativeName.toLowerCase() === normalized,
(command) =>
command.scope !== "text" &&
command.nativeName?.toLowerCase() === normalized,
);
}
@@ -158,11 +312,31 @@ export function buildCommandText(commandName: string, args?: string): string {
export function normalizeCommandBody(raw: string): string {
const trimmed = raw.trim();
if (!trimmed.startsWith("/")) return trimmed;
const match = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
if (!match) return trimmed;
const [, command, rest] = match;
const normalizedRest = rest.trimStart();
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
const colonMatch = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
const normalized = colonMatch
? (() => {
const [, command, rest] = colonMatch;
const normalizedRest = rest.trimStart();
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
})()
: trimmed;
const lowered = normalized.toLowerCase();
const exact = TEXT_ALIAS_MAP.get(lowered);
if (exact) return exact.canonical;
const tokenMatch = normalized.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
if (!tokenMatch) return normalized;
const [, token, rest] = tokenMatch;
const tokenKey = `/${token.toLowerCase()}`;
const tokenSpec = TEXT_ALIAS_MAP.get(tokenKey);
if (!tokenSpec) return normalized;
if (rest && !tokenSpec.acceptsArgs) return normalized;
const normalizedRest = rest?.trimStart();
return normalizedRest
? `${tokenSpec.canonical} ${normalizedRest}`
: tokenSpec.canonical;
}
export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {
+22 -23
View File
@@ -1,9 +1,8 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "./reply.js";
@@ -22,15 +21,7 @@ vi.mock("../agents/model-catalog.js", () => ({
}));
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-stream-"));
const previousHome = process.env.HOME;
process.env.HOME = base;
try {
return await fn(base);
} finally {
process.env.HOME = previousHome;
await fs.rm(base, { recursive: true, force: true });
}
return withTempHomeBase(fn, { prefix: "clawdbot-stream-" });
}
describe("block streaming", () => {
@@ -85,9 +76,11 @@ describe("block streaming", () => {
onBlockReply,
},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
@@ -140,9 +133,11 @@ describe("block streaming", () => {
onBlockReply,
},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
telegram: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
@@ -185,9 +180,11 @@ describe("block streaming", () => {
onBlockReply,
},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
@@ -239,9 +236,11 @@ describe("block streaming", () => {
blockReplyTimeoutMs: 10,
},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
telegram: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
@@ -144,6 +144,12 @@ describe("directive parsing", () => {
expect(res.cleaned).toBe("thats not /tmp/hello");
});
it("preserves spacing when stripping usage directives before paths", () => {
const res = extractStatusDirective("thats not /usage:/tmp/hello");
expect(res.hasDirective).toBe(true);
expect(res.cleaned).toBe("thats not /tmp/hello");
});
it("parses queue options and modes", () => {
const res = extractQueueDirective(
"please /queue steer+backlog debounce:2s cap:5 drop:summarize now",
@@ -162,12 +168,24 @@ describe("directive parsing", () => {
expect(res.cleaned).toBe("ok");
});
it("extracts reply_to_current tag with whitespace", () => {
const res = extractReplyToTag("ok [[ reply_to_current ]]", "msg-1");
expect(res.replyToId).toBe("msg-1");
expect(res.cleaned).toBe("ok");
});
it("extracts reply_to id tag", () => {
const res = extractReplyToTag("see [[reply_to:12345]] now", "msg-1");
expect(res.replyToId).toBe("12345");
expect(res.cleaned).toBe("see now");
});
it("extracts reply_to id tag with whitespace", () => {
const res = extractReplyToTag("see [[ reply_to : 12345 ]] now", "msg-1");
expect(res.replyToId).toBe("12345");
expect(res.cleaned).toBe("see now");
});
it("preserves newlines when stripping reply tags", () => {
const res = extractReplyToTag(
"line 1\nline 2 [[reply_to_current]]\n\nline 3",
+284 -172
View File
@@ -1,9 +1,9 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import {
@@ -28,28 +28,18 @@ vi.mock("../agents/model-catalog.js", () => ({
}));
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-reply-"));
const previousHome = process.env.HOME;
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
process.env.HOME = base;
process.env.CLAWDBOT_STATE_DIR = path.join(base, ".clawdbot");
process.env.CLAWDBOT_AGENT_DIR = path.join(base, ".clawdbot", "agent");
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
try {
return await fn(base);
} finally {
process.env.HOME = previousHome;
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
if (previousPiAgentDir === undefined)
delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
await fs.rm(base, { recursive: true, force: true });
}
return withTempHomeBase(
async (home) => {
return await fn(home);
},
{
env: {
CLAWDBOT_AGENT_DIR: (home) => path.join(home, ".clawdbot", "agent"),
PI_CODING_AGENT_DIR: (home) => path.join(home, ".clawdbot", "agent"),
},
prefix: "clawdbot-reply-",
},
);
}
describe("directive behavior", () => {
@@ -78,11 +68,13 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": { alias: " help " },
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": { alias: " help " },
},
},
},
whatsapp: { allowFrom: ["*"] },
@@ -108,9 +100,11 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
@@ -138,11 +132,13 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
routing: {
messages: {
queue: {
mode: "collect",
debounceMs: 1500,
@@ -174,10 +170,12 @@ describe("directive behavior", () => {
{ Body: "/think", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
thinkingDefault: "high",
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
thinkingDefault: "high",
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -198,9 +196,11 @@ describe("directive behavior", () => {
{ Body: "/think", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -232,9 +232,47 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
},
);
const payload = Array.isArray(res) ? res[0] : res;
expect(payload?.text).toBe("hello");
expect(payload?.replyToId).toBe("msg-123");
});
});
it("strips reply tags with whitespace and maps reply_to_current to MessageSid", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "hello [[ reply_to_current ]]" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await getReplyFromConfig(
{
Body: "ping",
From: "+1004",
To: "+2000",
MessageSid: "msg-123",
},
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
@@ -270,9 +308,11 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
@@ -303,9 +343,11 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -330,9 +372,11 @@ describe("directive behavior", () => {
{ Body: "/verbose on", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -352,10 +396,12 @@ describe("directive behavior", () => {
{ Body: "/think", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
thinkingDefault: "high",
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
thinkingDefault: "high",
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -376,9 +422,11 @@ describe("directive behavior", () => {
{ Body: "/think", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -399,10 +447,12 @@ describe("directive behavior", () => {
{ Body: "/verbose", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
verboseDefault: "on",
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
verboseDefault: "on",
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -423,9 +473,11 @@ describe("directive behavior", () => {
{ Body: "/reasoning", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
session: { store: path.join(home, "sessions.json") },
},
@@ -452,10 +504,14 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
elevatedDefault: "on",
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
elevatedDefault: "on",
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
@@ -486,13 +542,17 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
sandbox: { mode: "off" },
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
sandbox: { mode: "off" },
},
whatsapp: { allowFrom: ["+1222"] },
session: { store: path.join(home, "sessions.json") },
@@ -520,9 +580,13 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
@@ -552,9 +616,13 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
@@ -585,9 +653,13 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1222"] },
},
@@ -613,9 +685,11 @@ describe("directive behavior", () => {
{ Body: "/queue interrupt", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
@@ -644,9 +718,11 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
@@ -677,9 +753,11 @@ describe("directive behavior", () => {
{ Body: "/queue interrupt", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
@@ -690,9 +768,11 @@ describe("directive behavior", () => {
{ Body: "/queue reset", From: "+1222", To: "+1222" },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
@@ -749,9 +829,11 @@ describe("directive behavior", () => {
ctx,
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -810,9 +892,11 @@ describe("directive behavior", () => {
{ Body: "/verbose on", From: ctx.From, To: ctx.To },
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -825,9 +909,11 @@ describe("directive behavior", () => {
ctx,
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -853,12 +939,14 @@ describe("directive behavior", () => {
{ Body: "/model", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
session: { store: storePath },
@@ -883,12 +971,14 @@ describe("directive behavior", () => {
{ Body: "/model status", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
session: { store: storePath },
@@ -913,12 +1003,14 @@ describe("directive behavior", () => {
{ Body: "/model list", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
session: { store: storePath },
@@ -943,12 +1035,14 @@ describe("directive behavior", () => {
{ Body: "/model", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
session: { store: storePath },
@@ -972,11 +1066,13 @@ describe("directive behavior", () => {
{ Body: "/model list", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
},
},
},
session: { store: storePath },
@@ -999,12 +1095,14 @@ describe("directive behavior", () => {
{ Body: "/model openai/gpt-4.1-mini", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
session: { store: storePath },
@@ -1030,12 +1128,14 @@ describe("directive behavior", () => {
{ Body: "/model Opus", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"),
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
agents: {
defaults: {
model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"),
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
},
},
},
session: { store: storePath },
@@ -1057,7 +1157,7 @@ describe("directive behavior", () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
const authDir = path.join(home, ".clawdbot", "agent");
const authDir = path.join(home, ".clawdbot", "agents", "main", "agent");
await fs.mkdir(authDir, { recursive: true, mode: 0o700 });
await fs.writeFile(
path.join(authDir, "auth-profiles.json"),
@@ -1081,12 +1181,14 @@ describe("directive behavior", () => {
{ Body: "/model Opus@anthropic:work", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"),
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
agents: {
defaults: {
model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"),
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
},
},
},
session: { store: storePath },
@@ -1112,12 +1214,14 @@ describe("directive behavior", () => {
{ Body: "/model Opus", From: "+1222", To: "+1222" },
{},
{
agent: {
model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"),
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
agents: {
defaults: {
model: { primary: "openai/gpt-4.1-mini" },
workspace: path.join(home, "clawd"),
models: {
"openai/gpt-4.1-mini": {},
"anthropic/claude-opus-4-5": { alias: "Opus" },
},
},
},
session: { store: storePath },
@@ -1151,12 +1255,14 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
whatsapp: {
@@ -1204,9 +1310,11 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -1242,9 +1350,13 @@ describe("directive behavior", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1004"] },
},
+14 -15
View File
@@ -1,9 +1,9 @@
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
const runEmbeddedPiAgentMock = vi.fn();
vi.mock("../agents/model-fallback.js", () => ({
@@ -43,23 +43,22 @@ vi.mock("../web/session.js", () => webMocks);
import { getReplyFromConfig } from "./reply.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(join(tmpdir(), "clawdbot-typing-"));
const previousHome = process.env.HOME;
process.env.HOME = base;
try {
runEmbeddedPiAgentMock.mockClear();
return await fn(base);
} finally {
process.env.HOME = previousHome;
await fs.rm(base, { recursive: true, force: true });
}
return withTempHomeBase(
async (home) => {
runEmbeddedPiAgentMock.mockClear();
return await fn(home);
},
{ prefix: "clawdbot-typing-" },
);
}
function makeCfg(home: string) {
return {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
+19 -19
View File
@@ -1,9 +1,8 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "./reply.js";
@@ -28,27 +27,28 @@ function makeResult(text: string) {
}
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-media-note-"));
const previousHome = process.env.HOME;
process.env.HOME = base;
try {
vi.mocked(runEmbeddedPiAgent).mockReset();
return await fn(base);
} finally {
process.env.HOME = previousHome;
try {
await fs.rm(base, { recursive: true, force: true });
} catch {
// ignore cleanup failures in tests
}
}
return withTempHomeBase(
async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
return await fn(home);
},
{
env: {
CLAWDBOT_BUNDLED_SKILLS_DIR: (home) =>
path.join(home, "bundled-skills"),
},
prefix: "clawdbot-media-note-",
},
);
}
function makeCfg(home: string) {
return {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
+14 -20
View File
@@ -1,9 +1,8 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import {
isEmbeddedPiRunActive,
isEmbeddedPiRunStreaming,
@@ -32,31 +31,26 @@ function makeResult(text: string) {
}
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-queue-"));
const previousHome = process.env.HOME;
process.env.HOME = base;
try {
vi.mocked(runEmbeddedPiAgent).mockReset();
return await fn(base);
} finally {
process.env.HOME = previousHome;
try {
await fs.rm(base, { recursive: true, force: true });
} catch {
// ignore cleanup failures in tests
}
}
return withTempHomeBase(
async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
return await fn(home);
},
{ prefix: "clawdbot-queue-" },
);
}
function makeCfg(home: string, queue?: Record<string, unknown>) {
return {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
},
whatsapp: { allowFrom: ["*"] },
session: { store: path.join(home, "sessions.json") },
routing: queue ? { queue } : undefined,
messages: queue ? { queue } : undefined,
};
}
+154 -76
View File
@@ -2,6 +2,8 @@ import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
@@ -25,13 +27,18 @@ const usageMocks = vi.hoisted(() => ({
vi.mock("../infra/provider-usage.js", () => usageMocks);
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import {
abortEmbeddedPiRun,
compactEmbeddedPiSession,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
import { loadSessionStore, resolveSessionKey } from "../config/sessions.js";
import {
loadSessionStore,
resolveAgentIdFromSessionKey,
resolveSessionKey,
} from "../config/sessions.js";
import { getReplyFromConfig } from "./reply.js";
import { HEARTBEAT_TOKEN } from "./tokens.js";
@@ -46,24 +53,23 @@ const webMocks = vi.hoisted(() => ({
vi.mock("../web/session.js", () => webMocks);
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(join(tmpdir(), "clawdbot-triggers-"));
const previousHome = process.env.HOME;
process.env.HOME = base;
try {
vi.mocked(runEmbeddedPiAgent).mockClear();
vi.mocked(abortEmbeddedPiRun).mockClear();
return await fn(base);
} finally {
process.env.HOME = previousHome;
await fs.rm(base, { recursive: true, force: true });
}
return withTempHomeBase(
async (home) => {
vi.mocked(runEmbeddedPiAgent).mockClear();
vi.mocked(abortEmbeddedPiRun).mockClear();
return await fn(home);
},
{ prefix: "clawdbot-triggers-" },
);
}
function makeCfg(home: string) {
return {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -94,7 +100,7 @@ describe("trigger handling", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("📊 Usage: Claude 80% left");
expect(normalizeTestText(text ?? "")).toContain("Usage: Claude 80% left");
expect(usageMocks.loadProviderUsageSummary).toHaveBeenCalledWith(
expect.objectContaining({ providers: ["anthropic"] }),
);
@@ -236,6 +242,23 @@ describe("trigger handling", () => {
});
});
it("reports status via /usage without invoking the agent", async () => {
await withTempHome(async (home) => {
const res = await getReplyFromConfig(
{
Body: "/usage",
From: "+1002",
To: "+2000",
},
{},
makeCfg(home),
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("ClawdBot");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("reports active auth profile and key snippet in status", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
@@ -293,7 +316,7 @@ describe("trigger handling", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("api-key");
expect(text).toContain("…");
expect(text).toMatch(/…|\.{3}/);
expect(text).toContain("(anthropic:work)");
expect(text).not.toContain("mixed");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
@@ -345,9 +368,11 @@ describe("trigger handling", () => {
it("allows owner to set send policy", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["+1000"],
@@ -381,9 +406,13 @@ describe("trigger handling", () => {
it("allows approved sender to toggle elevated mode", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
@@ -420,9 +449,13 @@ describe("trigger handling", () => {
it("rejects elevated toggles when disabled", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
enabled: false,
allowFrom: { whatsapp: ["+1000"] },
@@ -467,9 +500,13 @@ describe("trigger handling", () => {
},
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
@@ -510,9 +547,13 @@ describe("trigger handling", () => {
},
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
@@ -545,9 +586,13 @@ describe("trigger handling", () => {
it("allows elevated directive in groups when mentioned", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
@@ -589,9 +634,13 @@ describe("trigger handling", () => {
it("allows elevated directive in direct chats without mentions", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
@@ -635,9 +684,13 @@ describe("trigger handling", () => {
},
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { whatsapp: ["+1000"] },
},
@@ -668,9 +721,11 @@ describe("trigger handling", () => {
it("falls back to discord dm allowFrom for elevated approval", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
discord: {
dm: {
@@ -708,9 +763,13 @@ describe("trigger handling", () => {
it("treats explicit discord elevated allowlist as override", async () => {
await withTempHome(async (home) => {
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
tools: {
elevated: {
allowFrom: { discord: [] },
},
@@ -799,9 +858,12 @@ describe("trigger handling", () => {
});
const cfg = makeCfg(home);
cfg.agent = {
...cfg.agent,
heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" },
cfg.agents = {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
heartbeat: { model: "anthropic/claude-haiku-4-5-20251001" },
},
};
await getReplyFromConfig(
@@ -941,15 +1003,17 @@ describe("trigger handling", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
groups: { "*": { requireMention: false } },
},
routing: {
messages: {
groupChat: {},
},
session: { store: join(home, "sessions.json") },
@@ -985,9 +1049,11 @@ describe("trigger handling", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -1024,9 +1090,11 @@ describe("trigger handling", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -1056,9 +1124,11 @@ describe("trigger handling", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["+1999"],
@@ -1083,9 +1153,11 @@ describe("trigger handling", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["+1999"],
@@ -1124,9 +1196,11 @@ describe("trigger handling", () => {
},
{},
{
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
whatsapp: {
allowFrom: ["*"],
@@ -1183,6 +1257,7 @@ describe("trigger handling", () => {
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
expect(prompt).toContain("Give me the status");
expect(prompt).not.toContain("/thinking high");
expect(prompt).not.toContain("/think high");
});
});
@@ -1229,12 +1304,14 @@ describe("trigger handling", () => {
});
const cfg = {
agent: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
sandbox: {
mode: "non-main" as const,
workspaceRoot: join(home, "sandboxes"),
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
sandbox: {
mode: "non-main" as const,
workspaceRoot: join(home, "sandboxes"),
},
},
},
whatsapp: {
@@ -1272,10 +1349,11 @@ describe("trigger handling", () => {
ctx,
cfg.session?.mainKey,
);
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const sandbox = await ensureSandboxWorkspaceForSession({
config: cfg,
sessionKey,
workspaceDir: cfg.agent.workspace,
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
});
expect(sandbox).not.toBeNull();
if (!sandbox) {
+6 -5
View File
@@ -212,7 +212,7 @@ export async function getReplyFromConfig(
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
const cfg = configOverride ?? loadConfig();
const agentId = resolveAgentIdFromSessionKey(ctx.SessionKey);
const agentCfg = cfg.agent;
const agentCfg = cfg.agents?.defaults;
const sessionCfg = cfg.session;
const { defaultProvider, defaultModel, aliasIndex } = resolveDefaultModel({
cfg,
@@ -239,7 +239,7 @@ export async function getReplyFromConfig(
resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
const workspace = await ensureAgentWorkspace({
dir: workspaceDirRaw,
ensureBootstrapFiles: !cfg.agent?.skipBootstrap,
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
});
const workspaceDir = workspace.dir;
const agentDir = resolveAgentDir(cfg, agentId);
@@ -257,7 +257,7 @@ export async function getReplyFromConfig(
opts?.onTypingController?.(typing);
let transcribedText: string | undefined;
if (cfg.routing?.transcribeAudio && isAudio(ctx.MediaType)) {
if (cfg.audio?.transcription && isAudio(ctx.MediaType)) {
const transcribed = await transcribeInboundAudio(cfg, ctx, defaultRuntime);
if (transcribed?.text) {
transcribedText = transcribed.text;
@@ -329,7 +329,7 @@ export async function getReplyFromConfig(
cmd.textAliases.map((a) => a.replace(/^\//, "").toLowerCase()),
),
);
const configuredAliases = Object.values(cfg.agent?.models ?? {})
const configuredAliases = Object.values(cfg.agents?.defaults?.models ?? {})
.map((entry) => entry.alias?.trim())
.filter((alias): alias is string => Boolean(alias))
.filter((alias) => !reservedCommands.has(alias.toLowerCase()));
@@ -391,7 +391,7 @@ export async function getReplyFromConfig(
sessionCtx.Provider?.trim().toLowerCase() ??
ctx.Provider?.trim().toLowerCase() ??
"";
const elevatedConfig = agentCfg?.elevated;
const elevatedConfig = cfg.tools?.elevated;
const discordElevatedFallback =
messageProviderKey === "discord" ? cfg.discord?.dm?.allowFrom : undefined;
const elevatedEnabled = elevatedConfig?.enabled !== false;
@@ -582,6 +582,7 @@ export async function getReplyFromConfig(
directives,
effectiveModelDirective,
cfg,
agentDir,
sessionEntry,
sessionStore,
sessionKey,
+7 -3
View File
@@ -50,7 +50,7 @@ import {
shouldSuppressMessagingToolReplies,
} from "./reply-payloads.js";
import {
createReplyToModeFilter,
createReplyToModeFilterForChannel,
resolveReplyToMode,
} from "./reply-threading.js";
import { incrementCompactionCount } from "./session-updates.js";
@@ -260,7 +260,10 @@ export async function runReplyAgent(params: {
followupRun.run.config,
replyToChannel,
);
const applyReplyToMode = createReplyToModeFilter(replyToMode);
const applyReplyToMode = createReplyToModeFilterForChannel(
replyToMode,
replyToChannel,
);
const cfg = followupRun.run.config;
if (shouldSteer && isStreaming) {
@@ -716,7 +719,8 @@ export async function runReplyAgent(params: {
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
payloads: sanitizedPayloads,
applyReplyToMode,
replyToMode,
replyToChannel,
currentMessageId: sessionCtx.MessageSid,
})
.map((payload) => {
+1 -1
View File
@@ -34,7 +34,7 @@ export function resolveBlockStreamingChunking(
} {
const providerKey = normalizeChunkProvider(provider);
const textLimit = resolveTextChunkLimit(cfg, providerKey);
const chunkCfg = cfg?.agent?.blockStreamingChunk;
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
const maxRequested = Math.max(
1,
Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX),
+140 -7
View File
@@ -1,3 +1,7 @@
import {
resolveAgentDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import {
ensureAuthProfileStore,
resolveAuthProfileDisplayLabel,
@@ -16,6 +20,13 @@ import {
} from "../../agents/pi-embedded.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
getConfigOverrides,
resetConfigOverrides,
setConfigOverride,
unsetConfigOverride,
} from "../../config/runtime-overrides.js";
import {
resolveAgentIdFromSessionKey,
resolveSessionFilePath,
type SessionEntry,
type SessionScope,
@@ -46,6 +57,7 @@ import {
} from "../group-activation.js";
import { parseSendPolicyCommand } from "../send-policy.js";
import {
buildCommandsMessage,
buildHelpMessage,
buildStatusMessage,
formatContextUsageShort,
@@ -60,6 +72,7 @@ import type {
} from "../thinking.js";
import type { ReplyPayload } from "../types.js";
import { isAbortTrigger, setAbortMemory } from "./abort.js";
import { parseDebugCommand } from "./debug-commands.js";
import type { InlineDirectives } from "./directive-handling.js";
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js";
@@ -134,6 +147,10 @@ export async function buildStatusReply(params: {
);
return undefined;
}
const statusAgentId = sessionKey
? resolveAgentIdFromSessionKey(sessionKey)
: resolveDefaultAgentId(cfg);
const statusAgentDir = resolveAgentDir(cfg, statusAgentId);
let usageLine: string | null = null;
try {
const usageProvider = resolveUsageProviderId(provider);
@@ -141,8 +158,18 @@ export async function buildStatusReply(params: {
const usageSummary = await loadProviderUsageSummary({
timeoutMs: 3500,
providers: [usageProvider],
agentDir: statusAgentDir,
});
usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() });
if (
!usageLine &&
(resolvedVerboseLevel === "on" || resolvedElevatedLevel === "on")
) {
const entry = usageSummary.providers[0];
if (entry?.error) {
usageLine = `📊 Usage: ${entry.displayName} (${entry.error})`;
}
}
}
} catch {
usageLine = null;
@@ -163,18 +190,19 @@ export async function buildStatusReply(params: {
? (normalizeGroupActivation(sessionEntry?.groupActivation) ??
defaultGroupActivation())
: undefined;
const agentDefaults = cfg.agents?.defaults ?? {};
const statusText = buildStatusMessage({
config: cfg,
agent: {
...cfg.agent,
...agentDefaults,
model: {
...cfg.agent?.model,
...agentDefaults.model,
primary: `${provider}/${model}`,
},
contextTokens,
thinkingDefault: cfg.agent?.thinkingDefault,
verboseDefault: cfg.agent?.verboseDefault,
elevatedDefault: cfg.agent?.elevatedDefault,
thinkingDefault: agentDefaults.thinkingDefault,
verboseDefault: agentDefaults.verboseDefault,
elevatedDefault: agentDefaults.elevatedDefault,
},
sessionEntry,
sessionKey,
@@ -184,7 +212,12 @@ export async function buildStatusReply(params: {
resolvedVerbose: resolvedVerboseLevel,
resolvedReasoning: resolvedReasoningLevel,
resolvedElevated: resolvedElevatedLevel,
modelAuth: resolveModelAuthLabel(provider, cfg, sessionEntry),
modelAuth: resolveModelAuthLabel(
provider,
cfg,
sessionEntry,
statusAgentDir,
),
usageLine: usageLine ?? undefined,
queue: {
mode: queueSettings.mode,
@@ -212,12 +245,15 @@ function resolveModelAuthLabel(
provider?: string,
cfg?: ClawdbotConfig,
sessionEntry?: SessionEntry,
agentDir?: string,
): string | undefined {
const resolved = provider?.trim();
if (!resolved) return undefined;
const providerKey = normalizeProviderId(resolved);
const store = ensureAuthProfileStore();
const store = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
const profileOverride = sessionEntry?.authProfileOverride?.trim();
const order = resolveAuthProfileOrder({
cfg,
@@ -236,6 +272,10 @@ function resolveModelAuthLabel(
if (profile.type === "oauth") {
return `oauth${label ? ` (${label})` : ""}`;
}
if (profile.type === "token") {
const snippet = formatApiKeySnippet(profile.token);
return `token ${snippet}${label ? ` (${label})` : ""}`;
}
const snippet = formatApiKeySnippet(profile.key);
return `api-key ${snippet}${label ? ` (${label})` : ""}`;
}
@@ -553,6 +593,17 @@ export async function handleCommands(params: {
return { shouldContinue: false, reply: { text: buildHelpMessage() } };
}
const commandsRequested = command.commandBodyNormalized === "/commands";
if (allowTextCommands && commandsRequested) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /commands from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
return { shouldContinue: false, reply: { text: buildCommandsMessage() } };
}
const statusRequested =
directives.hasStatusDirective ||
command.commandBodyNormalized === "/status";
@@ -577,6 +628,88 @@ export async function handleCommands(params: {
return { shouldContinue: false, reply };
}
const debugCommand = allowTextCommands
? parseDebugCommand(command.commandBodyNormalized)
: null;
if (debugCommand) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /debug from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
);
return { shouldContinue: false };
}
if (debugCommand.action === "error") {
return {
shouldContinue: false,
reply: { text: `⚠️ ${debugCommand.message}` },
};
}
if (debugCommand.action === "show") {
const overrides = getConfigOverrides();
const hasOverrides = Object.keys(overrides).length > 0;
if (!hasOverrides) {
return {
shouldContinue: false,
reply: { text: "⚙️ Debug overrides: (none)" },
};
}
const json = JSON.stringify(overrides, null, 2);
return {
shouldContinue: false,
reply: {
text: `⚙️ Debug overrides (memory-only):\n\`\`\`json\n${json}\n\`\`\``,
},
};
}
if (debugCommand.action === "reset") {
resetConfigOverrides();
return {
shouldContinue: false,
reply: { text: "⚙️ Debug overrides cleared; using config on disk." },
};
}
if (debugCommand.action === "unset") {
const result = unsetConfigOverride(debugCommand.path);
if (!result.ok) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${result.error ?? "Invalid path."}` },
};
}
if (!result.removed) {
return {
shouldContinue: false,
reply: {
text: `⚙️ No debug override found for ${debugCommand.path}.`,
},
};
}
return {
shouldContinue: false,
reply: { text: `⚙️ Debug override removed for ${debugCommand.path}.` },
};
}
if (debugCommand.action === "set") {
const result = setConfigOverride(debugCommand.path, debugCommand.value);
if (!result.ok) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${result.error ?? "Invalid override."}` },
};
}
const valueLabel =
typeof debugCommand.value === "string"
? `"${debugCommand.value}"`
: JSON.stringify(debugCommand.value);
return {
shouldContinue: false,
reply: {
text: `⚙️ Debug override set: ${debugCommand.path}=${valueLabel ?? "null"}`,
},
};
}
}
const stopRequested = command.commandBodyNormalized === "/stop";
if (allowTextCommands && stopRequested) {
if (!command.isAuthorizedSender) {
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { parseDebugCommand } from "./debug-commands.js";
describe("parseDebugCommand", () => {
it("parses show/reset", () => {
expect(parseDebugCommand("/debug")).toEqual({ action: "show" });
expect(parseDebugCommand("/debug show")).toEqual({ action: "show" });
expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" });
});
it("parses set with JSON", () => {
const cmd = parseDebugCommand('/debug set foo={"a":1}');
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
});
it("parses unset", () => {
const cmd = parseDebugCommand("/debug unset foo.bar");
expect(cmd).toEqual({ action: "unset", path: "foo.bar" });
});
});
+99
View File
@@ -0,0 +1,99 @@
export type DebugCommand =
| { action: "show" }
| { action: "reset" }
| { action: "set"; path: string; value: unknown }
| { action: "unset"; path: string }
| { action: "error"; message: string };
function parseDebugValue(raw: string): { value?: unknown; error?: string } {
const trimmed = raw.trim();
if (!trimmed) return { error: "Missing value." };
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
return { value: JSON.parse(trimmed) };
} catch (err) {
return { error: `Invalid JSON: ${String(err)}` };
}
}
if (trimmed === "true") return { value: true };
if (trimmed === "false") return { value: false };
if (trimmed === "null") return { value: null };
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
const num = Number(trimmed);
if (Number.isFinite(num)) return { value: num };
}
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
try {
return { value: JSON.parse(trimmed) };
} catch {
const unquoted = trimmed.slice(1, -1);
return { value: unquoted };
}
}
return { value: trimmed };
}
export function parseDebugCommand(raw: string): DebugCommand | null {
const trimmed = raw.trim();
if (!trimmed.toLowerCase().startsWith("/debug")) return null;
const rest = trimmed.slice("/debug".length).trim();
if (!rest) return { action: "show" };
const match = rest.match(/^(\S+)(?:\s+([\s\S]+))?$/);
if (!match) return { action: "error", message: "Invalid /debug syntax." };
const action = match[1].toLowerCase();
const args = (match[2] ?? "").trim();
switch (action) {
case "show":
return { action: "show" };
case "reset":
return { action: "reset" };
case "unset": {
if (!args)
return { action: "error", message: "Usage: /debug unset path" };
return { action: "unset", path: args };
}
case "set": {
if (!args) {
return {
action: "error",
message: "Usage: /debug set path=value",
};
}
const eqIndex = args.indexOf("=");
if (eqIndex <= 0) {
return {
action: "error",
message: "Usage: /debug set path=value",
};
}
const path = args.slice(0, eqIndex).trim();
const rawValue = args.slice(eqIndex + 1);
if (!path) {
return {
action: "error",
message: "Usage: /debug set path=value",
};
}
const parsed = parseDebugValue(rawValue);
if (parsed.error) {
return { action: "error", message: parsed.error };
}
return { action: "set", path, value: parsed.value };
}
default:
return {
action: "error",
message: "Usage: /debug show|set|unset|reset",
};
}
}
+186 -33
View File
@@ -1,6 +1,10 @@
import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js";
import { resolveAgentConfig } from "../../agents/agent-scope.js";
import {
resolveAgentConfig,
resolveAgentDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import {
isProfileInCooldown,
resolveAuthProfileDisplayLabel,
resolveAuthStorePathForDisplay,
} from "../../agents/auth-profiles.js";
@@ -20,9 +24,11 @@ import {
buildModelAliasIndex,
type ModelAliasIndex,
modelKey,
normalizeProviderId,
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { resolveSandboxConfigForAgent } from "../../agents/sandbox.js";
import type { ClawdbotConfig } from "../../config/config.js";
import {
resolveAgentIdFromSessionKey,
@@ -72,28 +78,140 @@ const maskApiKey = (value: string): string => {
return `${trimmed.slice(0, 8)}...${trimmed.slice(-8)}`;
};
type ModelAuthDetailMode = "compact" | "verbose";
const resolveAuthLabel = async (
provider: string,
cfg: ClawdbotConfig,
modelsPath: string,
agentDir?: string,
mode: ModelAuthDetailMode = "compact",
): Promise<{ label: string; source: string }> => {
const formatPath = (value: string) => shortenHomePath(value);
const store = ensureAuthProfileStore();
const store = ensureAuthProfileStore(agentDir, {
allowKeychainPrompt: false,
});
const order = resolveAuthProfileOrder({ cfg, store, provider });
const providerKey = normalizeProviderId(provider);
const lastGood = (() => {
const map = store.lastGood;
if (!map) return undefined;
for (const [key, value] of Object.entries(map)) {
if (normalizeProviderId(key) === providerKey) return value;
}
return undefined;
})();
const nextProfileId = order[0];
const now = Date.now();
const formatUntil = (timestampMs: number) => {
const remainingMs = Math.max(0, timestampMs - now);
const minutes = Math.round(remainingMs / 60_000);
if (minutes < 1) return "soon";
if (minutes < 60) return `${minutes}m`;
const hours = Math.round(minutes / 60);
if (hours < 48) return `${hours}h`;
const days = Math.round(hours / 24);
return `${days}d`;
};
if (order.length > 0) {
if (mode === "compact") {
const profileId = nextProfileId;
if (!profileId) return { label: "missing", source: "missing" };
const profile = store.profiles[profileId];
const configProfile = cfg.auth?.profiles?.[profileId];
const missing =
!profile ||
(configProfile?.provider &&
configProfile.provider !== profile.provider) ||
(configProfile?.mode &&
configProfile.mode !== profile.type &&
!(configProfile.mode === "oauth" && profile.type === "token"));
const more = order.length > 1 ? ` (+${order.length - 1})` : "";
if (missing) return { label: `${profileId} missing${more}`, source: "" };
if (profile.type === "api_key") {
return {
label: `${profileId} api-key ${maskApiKey(profile.key)}${more}`,
source: "",
};
}
if (profile.type === "token") {
const exp =
typeof profile.expires === "number" &&
Number.isFinite(profile.expires) &&
profile.expires > 0
? profile.expires <= now
? " expired"
: ` exp ${formatUntil(profile.expires)}`
: "";
return {
label: `${profileId} token ${maskApiKey(profile.token)}${exp}${more}`,
source: "",
};
}
const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
const label = display === profileId ? profileId : display;
const exp =
typeof profile.expires === "number" &&
Number.isFinite(profile.expires) &&
profile.expires > 0
? profile.expires <= now
? " expired"
: ` exp ${formatUntil(profile.expires)}`
: "";
return { label: `${label} oauth${exp}${more}`, source: "" };
}
const labels = order.map((profileId) => {
const profile = store.profiles[profileId];
const configProfile = cfg.auth?.profiles?.[profileId];
const flags: string[] = [];
if (profileId === nextProfileId) flags.push("next");
if (lastGood && profileId === lastGood) flags.push("lastGood");
if (isProfileInCooldown(store, profileId)) {
const until = store.usageStats?.[profileId]?.cooldownUntil;
if (
typeof until === "number" &&
Number.isFinite(until) &&
until > now
) {
flags.push(`cooldown ${formatUntil(until)}`);
} else {
flags.push("cooldown");
}
}
if (
!profile ||
(configProfile?.provider &&
configProfile.provider !== profile.provider) ||
(configProfile?.mode && configProfile.mode !== profile.type)
(configProfile?.mode &&
configProfile.mode !== profile.type &&
!(configProfile.mode === "oauth" && profile.type === "token"))
) {
return `${profileId}=missing`;
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
return `${profileId}=missing${suffix}`;
}
if (profile.type === "api_key") {
return `${profileId}=${maskApiKey(profile.key)}`;
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
return `${profileId}=${maskApiKey(profile.key)}${suffix}`;
}
if (profile.type === "token") {
if (
typeof profile.expires === "number" &&
Number.isFinite(profile.expires) &&
profile.expires > 0
) {
flags.push(
profile.expires <= now
? "expired"
: `exp ${formatUntil(profile.expires)}`,
);
}
const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : "";
return `${profileId}=token:${maskApiKey(profile.token)}${suffix}`;
}
const display = resolveAuthProfileDisplayLabel({
cfg,
@@ -106,13 +224,24 @@ const resolveAuthLabel = async (
: display.startsWith(profileId)
? display.slice(profileId.length).trim()
: `(${display})`;
return `${profileId}=OAuth${suffix ? ` ${suffix}` : ""}`;
if (
typeof profile.expires === "number" &&
Number.isFinite(profile.expires) &&
profile.expires > 0
) {
flags.push(
profile.expires <= now
? "expired"
: `exp ${formatUntil(profile.expires)}`,
);
}
const suffixLabel = suffix ? ` ${suffix}` : "";
const suffixFlags = flags.length > 0 ? ` (${flags.join(", ")})` : "";
return `${profileId}=OAuth${suffixLabel}${suffixFlags}`;
});
return {
label: labels.join(", "),
source: `auth-profiles.json: ${formatPath(
resolveAuthStorePathForDisplay(),
)}`,
source: `auth-profiles.json: ${formatPath(resolveAuthStorePathForDisplay(agentDir))}`,
};
}
@@ -122,13 +251,14 @@ const resolveAuthLabel = async (
envKey.source.includes("ANTHROPIC_OAUTH_TOKEN") ||
envKey.source.toLowerCase().includes("oauth");
const label = isOAuthEnv ? "OAuth (env)" : maskApiKey(envKey.apiKey);
return { label, source: envKey.source };
return { label, source: mode === "verbose" ? envKey.source : "" };
}
const customKey = getCustomProviderApiKey(cfg, provider);
if (customKey) {
return {
label: maskApiKey(customKey),
source: `models.json: ${formatPath(modelsPath)}`,
source:
mode === "verbose" ? `models.json: ${formatPath(modelsPath)}` : "",
};
}
return { label: "missing", source: "missing" };
@@ -145,10 +275,13 @@ const resolveProfileOverride = (params: {
rawProfile?: string;
provider: string;
cfg: ClawdbotConfig;
agentDir?: string;
}): { profileId?: string; error?: string } => {
const raw = params.rawProfile?.trim();
if (!raw) return {};
const store = ensureAuthProfileStore();
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const profile = store.profiles[raw];
if (!profile) {
return { error: `Auth profile "${raw}" not found.` };
@@ -357,17 +490,21 @@ export async function handleDirectiveOnly(params: {
currentReasoningLevel,
currentElevatedLevel,
} = params;
const activeAgentId = params.sessionKey
? resolveAgentIdFromSessionKey(params.sessionKey)
: resolveDefaultAgentId(params.cfg);
const agentDir = resolveAgentDir(params.cfg, activeAgentId);
const runtimeIsSandboxed = (() => {
const sandboxMode = params.cfg.agent?.sandbox?.mode ?? "off";
if (sandboxMode === "off") return false;
const sessionKey = params.sessionKey?.trim();
if (!sessionKey) return false;
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const sandboxCfg = resolveSandboxConfigForAgent(params.cfg, agentId);
if (sandboxCfg.mode === "off") return false;
const mainKey = resolveAgentMainSessionKey({
cfg: params.cfg,
agentId,
});
if (sandboxMode === "all") return true;
if (sandboxCfg.mode === "all") return true;
return sessionKey !== mainKey;
})();
const shouldHintDirectRuntime =
@@ -378,6 +515,10 @@ export async function handleDirectiveOnly(params: {
const isModelListAlias =
modelDirective === "status" || modelDirective === "list";
if (!directives.rawModelDirective || isModelListAlias) {
const modelsPath = `${agentDir}/models.json`;
const formatPath = (value: string) => shortenHomePath(value);
const authMode: ModelAuthDetailMode =
modelDirective === "status" ? "verbose" : "compact";
if (allowedModelCatalog.length === 0) {
const resolvedDefault = resolveConfiguredModelRef({
cfg: params.cfg,
@@ -389,7 +530,9 @@ export async function handleDirectiveOnly(params: {
provider: string;
id: string;
}> = [];
for (const raw of Object.keys(params.cfg.agent?.models ?? {})) {
for (const raw of Object.keys(
params.cfg.agents?.defaults?.models ?? {},
)) {
const resolved = resolveModelRefFromString({
raw: String(raw),
defaultProvider,
@@ -415,9 +558,6 @@ export async function handleDirectiveOnly(params: {
if (fallbackCatalog.length === 0) {
return { text: "No models available." };
}
const agentDir = resolveClawdbotAgentDir();
const modelsPath = `${agentDir}/models.json`;
const formatPath = (value: string) => shortenHomePath(value);
const authByProvider = new Map<string, string>();
for (const entry of fallbackCatalog) {
if (authByProvider.has(entry.provider)) continue;
@@ -425,6 +565,8 @@ export async function handleDirectiveOnly(params: {
entry.provider,
params.cfg,
modelsPath,
agentDir,
authMode,
);
authByProvider.set(entry.provider, formatAuthLabel(auth));
}
@@ -433,7 +575,8 @@ export async function handleDirectiveOnly(params: {
const lines = [
`Current: ${current}`,
`Default: ${defaultLabel}`,
`Auth file: ${formatPath(resolveAuthStorePathForDisplay())}`,
`Agent: ${activeAgentId}`,
`Auth file: ${formatPath(resolveAuthStorePathForDisplay(agentDir))}`,
`⚠️ Model catalog unavailable; showing configured models only.`,
];
const byProvider = new Map<string, typeof fallbackCatalog>();
@@ -461,9 +604,6 @@ export async function handleDirectiveOnly(params: {
}
return { text: lines.join("\n") };
}
const agentDir = resolveClawdbotAgentDir();
const modelsPath = `${agentDir}/models.json`;
const formatPath = (value: string) => shortenHomePath(value);
const authByProvider = new Map<string, string>();
for (const entry of allowedModelCatalog) {
if (authByProvider.has(entry.provider)) continue;
@@ -471,6 +611,8 @@ export async function handleDirectiveOnly(params: {
entry.provider,
params.cfg,
modelsPath,
agentDir,
authMode,
);
authByProvider.set(entry.provider, formatAuthLabel(auth));
}
@@ -479,7 +621,8 @@ export async function handleDirectiveOnly(params: {
const lines = [
`Current: ${current}`,
`Default: ${defaultLabel}`,
`Auth file: ${formatPath(resolveAuthStorePathForDisplay())}`,
`Agent: ${activeAgentId}`,
`Auth file: ${formatPath(resolveAuthStorePathForDisplay(agentDir))}`,
];
if (resetModelOverride) {
lines.push(`(previous selection reset to default)`);
@@ -681,6 +824,7 @@ export async function handleDirectiveOnly(params: {
rawProfile: directives.rawModelProfile,
provider: modelSelection.provider,
cfg: params.cfg,
agentDir,
});
if (profileResolved.error) {
return { text: profileResolved.error };
@@ -832,6 +976,7 @@ export async function persistInlineDirectives(params: {
directives: InlineDirectives;
effectiveModelDirective?: string;
cfg: ClawdbotConfig;
agentDir?: string;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
@@ -846,7 +991,7 @@ export async function persistInlineDirectives(params: {
model: string;
initialModelLabel: string;
formatModelSwitchEvent: (label: string, alias?: string) => string;
agentCfg: ClawdbotConfig["agent"] | undefined;
agentCfg: NonNullable<ClawdbotConfig["agents"]>["defaults"] | undefined;
}): Promise<{ provider: string; model: string; contextTokens: number }> {
const {
directives,
@@ -866,6 +1011,10 @@ export async function persistInlineDirectives(params: {
agentCfg,
} = params;
let { provider, model } = params;
const activeAgentId = sessionKey
? resolveAgentIdFromSessionKey(sessionKey)
: resolveDefaultAgentId(cfg);
const agentDir = resolveAgentDir(cfg, activeAgentId);
if (sessionEntry && sessionStore && sessionKey) {
let updated = false;
@@ -925,6 +1074,7 @@ export async function persistInlineDirectives(params: {
rawProfile: directives.rawModelProfile,
provider: resolved.ref.provider,
cfg,
agentDir,
});
if (profileResolved.error) {
throw new Error(profileResolved.error);
@@ -1002,13 +1152,16 @@ export function resolveDefaultModel(params: {
agentModelOverride && agentModelOverride.length > 0
? {
...params.cfg,
agent: {
...params.cfg.agent,
model: {
...(typeof params.cfg.agent?.model === "object"
? params.cfg.agent.model
: undefined),
primary: agentModelOverride,
agents: {
...params.cfg.agents,
defaults: {
...params.cfg.agents?.defaults,
model: {
...(typeof params.cfg.agents?.defaults?.model === "object"
? params.cfg.agents.defaults.model
: undefined),
primary: agentModelOverride,
},
},
},
}
+1 -1
View File
@@ -170,7 +170,7 @@ export function extractStatusDirective(body?: string): {
hasDirective: boolean;
} {
if (!body) return { cleaned: "", hasDirective: false };
return extractSimpleDirective(body, ["status"]);
return extractSimpleDirective(body, ["status", "usage"]);
}
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
@@ -53,6 +53,7 @@ export async function dispatchReplyFromConfig(params: {
payload,
channel: originatingChannel,
to: originatingTo,
sessionKey: ctx.SessionKey,
accountId: ctx.AccountId,
threadId: ctx.MessageThreadId,
cfg,
@@ -106,6 +107,7 @@ export async function dispatchReplyFromConfig(params: {
payload: reply,
channel: originatingChannel,
to: originatingTo,
sessionKey: ctx.SessionKey,
accountId: ctx.AccountId,
threadId: ctx.MessageThreadId,
cfg,
+5 -8
View File
@@ -19,10 +19,7 @@ import {
filterMessagingToolDuplicates,
shouldSuppressMessagingToolReplies,
} from "./reply-payloads.js";
import {
createReplyToModeFilter,
resolveReplyToMode,
} from "./reply-threading.js";
import { resolveReplyToMode } from "./reply-threading.js";
import { isRoutableChannel, routeReply } from "./route-reply.js";
import { incrementCompactionCount } from "./session-updates.js";
import type { TypingController } from "./typing.js";
@@ -97,6 +94,7 @@ export function createFollowupRunner(params: {
payload,
channel: originatingChannel,
to: originatingTo,
sessionKey: queued.run.sessionKey,
accountId: queued.originatingAccountId,
threadId: queued.originatingThreadId,
cfg: queued.run.config,
@@ -194,13 +192,12 @@ export function createFollowupRunner(params: {
(queued.run.messageProvider?.toLowerCase() as
| OriginatingChannelType
| undefined);
const applyReplyToMode = createReplyToModeFilter(
resolveReplyToMode(queued.run.config, replyToChannel),
);
const replyToMode = resolveReplyToMode(queued.run.config, replyToChannel);
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
payloads: sanitizedPayloads,
applyReplyToMode,
replyToMode,
replyToChannel,
});
const dedupedPayloads = filterMessagingToolDuplicates({
+11 -6
View File
@@ -9,7 +9,7 @@ import {
describe("mention helpers", () => {
it("builds regexes and skips invalid patterns", () => {
const regexes = buildMentionRegexes({
routing: {
messages: {
groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] },
},
});
@@ -23,7 +23,7 @@ describe("mention helpers", () => {
it("matches patterns case-insensitively", () => {
const regexes = buildMentionRegexes({
routing: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } },
messages: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } },
});
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
});
@@ -31,11 +31,16 @@ describe("mention helpers", () => {
it("uses per-agent mention patterns when configured", () => {
const regexes = buildMentionRegexes(
{
routing: {
messages: {
groupChat: { mentionPatterns: ["\\bglobal\\b"] },
agents: {
work: { mentionPatterns: ["\\bworkbot\\b"] },
},
},
agents: {
list: [
{
id: "work",
groupChat: { mentionPatterns: ["\\bworkbot\\b"] },
},
],
},
},
"work",
+47 -6
View File
@@ -1,23 +1,62 @@
import { resolveAgentConfig } from "../../agents/agent-scope.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function deriveMentionPatterns(identity?: { name?: string; emoji?: string }) {
const patterns: string[] = [];
const name = identity?.name?.trim();
if (name) {
const parts = name.split(/\s+/).filter(Boolean).map(escapeRegExp);
const re = parts.length ? parts.join(String.raw`\s+`) : escapeRegExp(name);
patterns.push(String.raw`\b@?${re}\b`);
}
const emoji = identity?.emoji?.trim();
if (emoji) {
patterns.push(escapeRegExp(emoji));
}
return patterns;
}
const BACKSPACE_CHAR = "\u0008";
function normalizeMentionPattern(pattern: string): string {
if (!pattern.includes(BACKSPACE_CHAR)) return pattern;
return pattern.split(BACKSPACE_CHAR).join("\\b");
}
function normalizeMentionPatterns(patterns: string[]): string[] {
return patterns.map(normalizeMentionPattern);
}
function resolveMentionPatterns(
cfg: ClawdbotConfig | undefined,
agentId?: string,
): string[] {
if (!cfg) return [];
const agentConfig = agentId ? cfg.routing?.agents?.[agentId] : undefined;
if (agentConfig && Object.hasOwn(agentConfig, "mentionPatterns")) {
return agentConfig.mentionPatterns ?? [];
const agentConfig = agentId ? resolveAgentConfig(cfg, agentId) : undefined;
const agentGroupChat = agentConfig?.groupChat;
if (agentGroupChat && Object.hasOwn(agentGroupChat, "mentionPatterns")) {
return agentGroupChat.mentionPatterns ?? [];
}
return cfg.routing?.groupChat?.mentionPatterns ?? [];
const globalGroupChat = cfg.messages?.groupChat;
if (globalGroupChat && Object.hasOwn(globalGroupChat, "mentionPatterns")) {
return globalGroupChat.mentionPatterns ?? [];
}
const derived = deriveMentionPatterns(agentConfig?.identity);
return derived.length > 0 ? derived : [];
}
export function buildMentionRegexes(
cfg: ClawdbotConfig | undefined,
agentId?: string,
): RegExp[] {
const patterns = resolveMentionPatterns(cfg, agentId);
const patterns = normalizeMentionPatterns(
resolveMentionPatterns(cfg, agentId),
);
return patterns
.map((pattern) => {
try {
@@ -66,7 +105,9 @@ export function stripMentions(
agentId?: string,
): string {
let result = text;
const patterns = resolveMentionPatterns(cfg, agentId);
const patterns = normalizeMentionPatterns(
resolveMentionPatterns(cfg, agentId),
);
for (const p of patterns) {
try {
const re = new RegExp(p, "gi");
+6 -2
View File
@@ -33,7 +33,9 @@ type ModelSelectionState = {
export async function createModelSelectionState(params: {
cfg: ClawdbotConfig;
agentCfg: ClawdbotConfig["agent"] | undefined;
agentCfg:
| NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
| undefined;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
@@ -201,7 +203,9 @@ export function resolveModelDirectiveSelection(params: {
}
export function resolveContextTokens(params: {
agentCfg: ClawdbotConfig["agent"] | undefined;
agentCfg:
| NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
| undefined;
model: string;
}): number {
return (
+49
View File
@@ -0,0 +1,49 @@
import { stripHeartbeatToken } from "../heartbeat.js";
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
import type { ReplyPayload } from "../types.js";
export type NormalizeReplyOptions = {
responsePrefix?: string;
onHeartbeatStrip?: () => void;
stripHeartbeat?: boolean;
silentToken?: string;
};
export function normalizeReplyPayload(
payload: ReplyPayload,
opts: NormalizeReplyOptions = {},
): ReplyPayload | null {
const hasMedia = Boolean(
payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0,
);
const trimmed = payload.text?.trim() ?? "";
if (!trimmed && !hasMedia) return null;
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
if (trimmed === silentToken && !hasMedia) return null;
let text = payload.text ?? undefined;
if (text && !trimmed) {
// Keep empty text when media exists so media-only replies still send.
text = "";
}
const shouldStripHeartbeat = opts.stripHeartbeat ?? true;
if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) {
const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip) opts.onHeartbeatStrip?.();
if (stripped.shouldSkip && !hasMedia) return null;
text = stripped.text;
}
if (
opts.responsePrefix &&
text &&
text.trim() !== HEARTBEAT_TOKEN &&
!text.startsWith(opts.responsePrefix)
) {
text = `${opts.responsePrefix} ${text}`;
}
return { ...payload, text };
}
+1 -1
View File
@@ -553,7 +553,7 @@ export function resolveQueueSettings(params: {
inlineOptions?: Partial<QueueSettings>;
}): QueueSettings {
const providerKey = params.provider?.trim().toLowerCase();
const queueCfg = params.cfg.routing?.queue;
const queueCfg = params.cfg.messages?.queue;
const providerModeRaw =
providerKey && queueCfg?.byProvider
? (queueCfg.byProvider as Record<string, string | undefined>)[providerKey]
+7 -35
View File
@@ -1,6 +1,5 @@
import { stripHeartbeatToken } from "../heartbeat.js";
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { normalizeReplyPayload } from "./normalize-reply.js";
import type { TypingController } from "./typing.js";
export type ReplyDispatchKind = "tool" | "block" | "final";
@@ -45,41 +44,14 @@ export type ReplyDispatcher = {
getQueuedCounts: () => Record<ReplyDispatchKind, number>;
};
function normalizeReplyPayload(
function normalizeReplyPayloadInternal(
payload: ReplyPayload,
opts: Pick<ReplyDispatcherOptions, "responsePrefix" | "onHeartbeatStrip">,
): ReplyPayload | null {
const hasMedia = Boolean(
payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0,
);
const trimmed = payload.text?.trim() ?? "";
if (!trimmed && !hasMedia) return null;
// Avoid sending the explicit silent token when no media is attached.
if (trimmed === SILENT_REPLY_TOKEN && !hasMedia) return null;
let text = payload.text ?? undefined;
if (text && !trimmed) {
// Keep empty text when media exists so media-only replies still send.
text = "";
}
if (text?.includes(HEARTBEAT_TOKEN)) {
const stripped = stripHeartbeatToken(text, { mode: "message" });
if (stripped.didStrip) opts.onHeartbeatStrip?.();
if (stripped.shouldSkip && !hasMedia) return null;
text = stripped.text;
}
if (
opts.responsePrefix &&
text &&
text.trim() !== HEARTBEAT_TOKEN &&
!text.startsWith(opts.responsePrefix)
) {
text = `${opts.responsePrefix} ${text}`;
}
return { ...payload, text };
return normalizeReplyPayload(payload, {
responsePrefix: opts.responsePrefix,
onHeartbeatStrip: opts.onHeartbeatStrip,
});
}
export function createReplyDispatcher(
@@ -96,7 +68,7 @@ export function createReplyDispatcher(
};
const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => {
const normalized = normalizeReplyPayload(payload, options);
const normalized = normalizeReplyPayloadInternal(payload, options);
if (!normalized) return false;
queuedCounts[kind] += 1;
pending += 1;
+12 -5
View File
@@ -1,16 +1,17 @@
import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js";
import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js";
import type { ReplyToMode } from "../../config/types.js";
import type { OriginatingChannelType } from "../templating.js";
import type { ReplyPayload } from "../types.js";
import { extractReplyToTag } from "./reply-tags.js";
export type ReplyToModeFilter = (payload: ReplyPayload) => ReplyPayload;
import { createReplyToModeFilterForChannel } from "./reply-threading.js";
export function applyReplyTagsToPayload(
payload: ReplyPayload,
currentMessageId?: string,
): ReplyPayload {
if (typeof payload.text !== "string") return payload;
const { cleaned, replyToId } = extractReplyToTag(
const { cleaned, replyToId, hasTag } = extractReplyToTag(
payload.text,
currentMessageId,
);
@@ -18,6 +19,7 @@ export function applyReplyTagsToPayload(
...payload,
text: cleaned ? cleaned : undefined,
replyToId: replyToId ?? payload.replyToId,
replyToTag: hasTag || payload.replyToTag,
};
}
@@ -31,10 +33,15 @@ export function isRenderablePayload(payload: ReplyPayload): boolean {
export function applyReplyThreading(params: {
payloads: ReplyPayload[];
applyReplyToMode: ReplyToModeFilter;
replyToMode: ReplyToMode;
replyToChannel?: OriginatingChannelType;
currentMessageId?: string;
}): ReplyPayload[] {
const { payloads, applyReplyToMode, currentMessageId } = params;
const { payloads, replyToMode, replyToChannel, currentMessageId } = params;
const applyReplyToMode = createReplyToModeFilterForChannel(
replyToMode,
replyToChannel,
);
return payloads
.map((payload) => applyReplyTagsToPayload(payload, currentMessageId))
.filter(isRenderablePayload)
+29 -20
View File
@@ -1,3 +1,13 @@
const REPLY_TAG_RE =
/\[\[\s*(?:reply_to_current|reply_to\s*:\s*([^\]\n]+))\s*\]\]/gi;
function normalizeReplyText(text: string) {
return text
.replace(/[ \t]+/g, " ")
.replace(/[ \t]*\n[ \t]*/g, "\n")
.trim();
}
export function extractReplyToTag(
text?: string,
currentMessageId?: string,
@@ -7,29 +17,28 @@ export function extractReplyToTag(
hasTag: boolean;
} {
if (!text) return { cleaned: "", hasTag: false };
let cleaned = text;
let replyToId: string | undefined;
let sawCurrent = false;
let lastExplicitId: string | undefined;
let hasTag = false;
const currentMatch = cleaned.match(/\[\[reply_to_current\]\]/i);
if (currentMatch) {
cleaned = cleaned.replace(/\[\[reply_to_current\]\]/gi, " ");
hasTag = true;
if (currentMessageId?.trim()) {
replyToId = currentMessageId.trim();
}
}
const cleaned = normalizeReplyText(
text.replace(REPLY_TAG_RE, (_full, idRaw: string | undefined) => {
hasTag = true;
if (idRaw === undefined) {
sawCurrent = true;
return " ";
}
const idMatch = cleaned.match(/\[\[reply_to:([^\]\n]+)\]\]/i);
if (idMatch?.[1]) {
cleaned = cleaned.replace(/\[\[reply_to:[^\]\n]+\]\]/gi, " ");
replyToId = idMatch[1].trim();
hasTag = true;
}
const id = idRaw.trim();
if (id) lastExplicitId = id;
return " ";
}),
);
const replyToId =
lastExplicitId ??
(sawCurrent ? currentMessageId?.trim() || undefined : undefined);
cleaned = cleaned
.replace(/[ \t]+/g, " ")
.replace(/[ \t]*\n[ \t]*/g, "\n")
.trim();
return { cleaned, replyToId, hasTag };
}
@@ -40,6 +40,13 @@ describe("createReplyToModeFilter", () => {
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBeUndefined();
});
it("keeps replyToId when mode is off and reply tags are allowed", () => {
const filter = createReplyToModeFilter("off", { allowTagsWhenOff: true });
expect(
filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId,
).toBe("1");
});
it("keeps replyToId when mode is all", () => {
const filter = createReplyToModeFilter("all");
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1");
+14 -1
View File
@@ -19,11 +19,15 @@ export function resolveReplyToMode(
}
}
export function createReplyToModeFilter(mode: ReplyToMode) {
export function createReplyToModeFilter(
mode: ReplyToMode,
opts: { allowTagsWhenOff?: boolean } = {},
) {
let hasThreaded = false;
return (payload: ReplyPayload): ReplyPayload => {
if (!payload.replyToId) return payload;
if (mode === "off") {
if (opts.allowTagsWhenOff && payload.replyToTag) return payload;
return { ...payload, replyToId: undefined };
}
if (mode === "all") return payload;
@@ -34,3 +38,12 @@ export function createReplyToModeFilter(mode: ReplyToMode) {
return payload;
};
}
export function createReplyToModeFilterForChannel(
mode: ReplyToMode,
channel?: OriginatingChannelType,
) {
return createReplyToModeFilter(mode, {
allowTagsWhenOff: channel === "slack",
});
}
+88
View File
@@ -1,8 +1,15 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
const mocks = vi.hoisted(() => ({
sendMessageDiscord: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })),
sendMessageIMessage: vi.fn(async () => ({ messageId: "ok" })),
sendMessageMSTeams: vi.fn(async () => ({
messageId: "m1",
conversationId: "c1",
})),
sendMessageSignal: vi.fn(async () => ({ messageId: "t1" })),
sendMessageSlack: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })),
sendMessageTelegram: vi.fn(async () => ({ messageId: "m1", chatId: "c1" })),
@@ -15,6 +22,9 @@ vi.mock("../../discord/send.js", () => ({
vi.mock("../../imessage/send.js", () => ({
sendMessageIMessage: mocks.sendMessageIMessage,
}));
vi.mock("../../msteams/send.js", () => ({
sendMessageMSTeams: mocks.sendMessageMSTeams,
}));
vi.mock("../../signal/send.js", () => ({
sendMessageSignal: mocks.sendMessageSignal,
}));
@@ -59,6 +69,63 @@ describe("routeReply", () => {
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
});
it("drops silent token payloads", async () => {
mocks.sendMessageSlack.mockClear();
const res = await routeReply({
payload: { text: SILENT_REPLY_TOKEN },
channel: "slack",
to: "channel:C123",
cfg: {} as never,
});
expect(res.ok).toBe(true);
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
});
it("applies responsePrefix when routing", async () => {
mocks.sendMessageSlack.mockClear();
const cfg = {
messages: { responsePrefix: "[clawdbot]" },
} as unknown as ClawdbotConfig;
await routeReply({
payload: { text: "hi" },
channel: "slack",
to: "channel:C123",
cfg,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"[clawdbot] hi",
expect.any(Object),
);
});
it("derives responsePrefix from agent identity when routing", async () => {
mocks.sendMessageSlack.mockClear();
const cfg = {
agents: {
list: [
{
id: "rich",
identity: { name: "Richbot", theme: "lion bot", emoji: "🦁" },
},
],
},
messages: {},
} as unknown as ClawdbotConfig;
await routeReply({
payload: { text: "hi" },
channel: "slack",
to: "channel:C123",
sessionKey: "agent:rich:main",
cfg,
});
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
"channel:C123",
"[Richbot] hi",
expect.any(Object),
);
});
it("passes thread id to Telegram sends", async () => {
mocks.sendMessageTelegram.mockClear();
await routeReply({
@@ -143,4 +210,25 @@ describe("routeReply", () => {
expect.objectContaining({ accountId: "acc-1", verbose: false }),
);
});
it("routes MS Teams via proactive sender", async () => {
mocks.sendMessageMSTeams.mockClear();
const cfg = {
msteams: {
enabled: true,
},
} as unknown as ClawdbotConfig;
await routeReply({
payload: { text: "hi" },
channel: "msteams",
to: "conversation:19:abc@thread.tacv2",
cfg,
});
expect(mocks.sendMessageMSTeams).toHaveBeenCalledWith({
cfg,
to: "conversation:19:abc@thread.tacv2",
text: "hi",
mediaUrl: undefined,
});
});
});
+38 -8
View File
@@ -7,15 +7,19 @@
* across multiple providers.
*/
import { resolveEffectiveMessagesConfig } from "../../agents/identity.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { sendMessageDiscord } from "../../discord/send.js";
import { sendMessageIMessage } from "../../imessage/send.js";
import { sendMessageMSTeams } from "../../msteams/send.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { sendMessageSignal } from "../../signal/send.js";
import { sendMessageSlack } from "../../slack/send.js";
import { sendMessageTelegram } from "../../telegram/send.js";
import { sendMessageWhatsApp } from "../../web/outbound.js";
import type { OriginatingChannelType } from "../templating.js";
import type { ReplyPayload } from "../types.js";
import { normalizeReplyPayload } from "./normalize-reply.js";
export type RouteReplyParams = {
/** The reply payload to send. */
@@ -24,6 +28,8 @@ export type RouteReplyParams = {
channel: OriginatingChannelType;
/** The destination chat/channel/user ID. */
to: string;
/** Session key for deriving agent identity defaults (multi-agent). */
sessionKey?: string;
/** Provider account id (multi-account). */
accountId?: string;
/** Telegram message thread id (forum topics). */
@@ -54,16 +60,28 @@ export type RouteReplyResult = {
export async function routeReply(
params: RouteReplyParams,
): Promise<RouteReplyResult> {
const { payload, channel, to, accountId, threadId, abortSignal } = params;
const { payload, channel, to, accountId, threadId, cfg, abortSignal } =
params;
// Debug: `pnpm test src/auto-reply/reply/route-reply.test.ts`
const text = payload.text ?? "";
const mediaUrls = (payload.mediaUrls?.filter(Boolean) ?? []).length
? (payload.mediaUrls?.filter(Boolean) as string[])
: payload.mediaUrl
? [payload.mediaUrl]
const responsePrefix = params.sessionKey
? resolveEffectiveMessagesConfig(
cfg,
resolveAgentIdFromSessionKey(params.sessionKey),
).responsePrefix
: cfg.messages?.responsePrefix;
const normalized = normalizeReplyPayload(payload, {
responsePrefix,
});
if (!normalized) return { ok: true };
const text = normalized.text ?? "";
const mediaUrls = (normalized.mediaUrls?.filter(Boolean) ?? []).length
? (normalized.mediaUrls?.filter(Boolean) as string[])
: normalized.mediaUrl
? [normalized.mediaUrl]
: [];
const replyToId = payload.replyToId;
const replyToId = normalized.replyToId;
// Skip empty replies.
if (!text.trim() && mediaUrls.length === 0) {
@@ -145,6 +163,16 @@ export async function routeReply(
};
}
case "msteams": {
const result = await sendMessageMSTeams({
cfg,
to,
text,
mediaUrl,
});
return { ok: true, messageId: result.messageId };
}
default: {
const _exhaustive: never = channel;
return { ok: false, error: `Unknown channel: ${String(_exhaustive)}` };
@@ -195,7 +223,8 @@ export function isRoutableChannel(
| "discord"
| "signal"
| "imessage"
| "whatsapp" {
| "whatsapp"
| "msteams" {
if (!channel) return false;
return [
"telegram",
@@ -204,5 +233,6 @@ export function isRoutableChannel(
"signal",
"imessage",
"whatsapp",
"msteams",
].includes(channel);
}
+2 -2
View File
@@ -91,7 +91,7 @@ export async function ensureSkillSnapshot(params: {
systemSent: true,
skillsSnapshot: skillSnapshot,
};
sessionStore[sessionKey] = nextEntry;
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry };
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
@@ -123,7 +123,7 @@ export async function ensureSkillSnapshot(params: {
updatedAt: Date.now(),
skillsSnapshot,
};
sessionStore[sessionKey] = nextEntry;
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry };
if (storePath) {
await saveSessionStore(storePath, sessionStore);
}
+1 -1
View File
@@ -264,7 +264,7 @@ export async function initSessionState(params: {
ctx.MessageThreadId,
);
}
sessionStore[sessionKey] = sessionEntry;
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...sessionEntry };
await saveSessionStore(storePath, sessionStore);
const sessionCtx: TemplateContext = {
+98 -115
View File
@@ -1,43 +1,10 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js";
import { buildStatusMessage } from "./status.js";
const HOME_ENV_KEYS = ["HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH"] as const;
type HomeEnvSnapshot = Record<
(typeof HOME_ENV_KEYS)[number],
string | undefined
>;
const snapshotHomeEnv = (): HomeEnvSnapshot => ({
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
HOMEDRIVE: process.env.HOMEDRIVE,
HOMEPATH: process.env.HOMEPATH,
});
const restoreHomeEnv = (snapshot: HomeEnvSnapshot) => {
for (const key of HOME_ENV_KEYS) {
const value = snapshot[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
};
const setTempHome = (tempHome: string) => {
process.env.HOME = tempHome;
if (process.platform === "win32") {
process.env.USERPROFILE = tempHome;
const root = path.parse(tempHome).root;
process.env.HOMEDRIVE = root.replace(/\\$/, "");
process.env.HOMEPATH = tempHome.slice(root.length - 1);
}
};
import { buildCommandsMessage, buildStatusMessage } from "./status.js";
afterEach(() => {
vi.restoreAllMocks();
@@ -89,19 +56,22 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key",
now: 10 * 60_000, // 10 minutes later
});
const normalized = normalizeTestText(text);
expect(text).toContain("🦞 ClawdBot");
expect(text).toContain("🧠 Model: anthropic/pi:opus · 🔑 api-key");
expect(text).toContain("🧮 Tokens: 1.2k in / 800 out · 💵 Cost: $0.0020");
expect(text).toContain("Context: 16k/32k (50%)");
expect(text).toContain("🧹 Compactions: 2");
expect(text).toContain("Session: agent:main:main");
expect(text).toContain("updated 10m ago");
expect(text).toContain("Runtime: direct");
expect(text).toContain("Think: medium");
expect(text).toContain("Verbose: off");
expect(text).toContain("Elevated: on");
expect(text).toContain("Queue: collect");
expect(normalized).toContain("ClawdBot");
expect(normalized).toContain("Model: anthropic/pi:opus");
expect(normalized).toContain("api-key");
expect(normalized).toContain("Tokens: 1.2k in / 800 out");
expect(normalized).toContain("Cost: $0.0020");
expect(normalized).toContain("Context: 16k/32k (50%)");
expect(normalized).toContain("Compactions: 2");
expect(normalized).toContain("Session: agent:main:main");
expect(normalized).toContain("updated 10m ago");
expect(normalized).toContain("Runtime: direct");
expect(normalized).toContain("Think: medium");
expect(normalized).toContain("Verbose: off");
expect(normalized).toContain("Elevated: on");
expect(normalized).toContain("Queue: collect");
});
it("shows verbose/elevated labels only when enabled", () => {
@@ -141,7 +111,7 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key",
});
expect(text).toContain("🧠 Model: openai/gpt-4.1-mini");
expect(normalizeTestText(text)).toContain("Model: openai/gpt-4.1-mini");
});
it("keeps provider prefix from configured model", () => {
@@ -154,7 +124,9 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key",
});
expect(text).toContain("🧠 Model: google-antigravity/claude-sonnet-4-5");
expect(normalizeTestText(text)).toContain(
"Model: google-antigravity/claude-sonnet-4-5",
);
});
it("handles missing agent config gracefully", () => {
@@ -165,9 +137,10 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key",
});
expect(text).toContain("🧠 Model:");
expect(text).toContain("Context:");
expect(text).toContain("Queue: collect");
const normalized = normalizeTestText(text);
expect(normalized).toContain("Model:");
expect(normalized).toContain("Context:");
expect(normalized).toContain("Queue: collect");
});
it("includes group activation for group sessions", () => {
@@ -221,10 +194,10 @@ describe("buildStatusMessage", () => {
modelAuth: "api-key",
});
const lines = text.split("\n");
const contextIndex = lines.findIndex((line) => line.startsWith("📚 "));
const lines = normalizeTestText(text).split("\n");
const contextIndex = lines.findIndex((line) => line.includes("Context:"));
expect(contextIndex).toBeGreaterThan(-1);
expect(lines[contextIndex + 1]).toBe("📊 Usage: Claude 80% left (5h)");
expect(lines[contextIndex + 1]).toContain("Usage: Claude 80% left (5h)");
});
it("hides cost when not using an API key", () => {
@@ -260,69 +233,79 @@ describe("buildStatusMessage", () => {
});
it("prefers cached prompt tokens from the session log", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-status-"));
const previousHome = snapshotHomeEnv();
setTempHome(dir);
try {
vi.resetModules();
const { buildStatusMessage: buildStatusMessageDynamic } = await import(
"./status.js"
);
await withTempHome(
async (dir) => {
vi.resetModules();
const { buildStatusMessage: buildStatusMessageDynamic } = await import(
"./status.js"
);
const sessionId = "sess-1";
const logPath = path.join(
dir,
".clawdbot",
"agents",
"main",
"sessions",
`${sessionId}.jsonl`,
);
fs.mkdirSync(path.dirname(logPath), { recursive: true });
const sessionId = "sess-1";
const logPath = path.join(
dir,
".clawdbot",
"agents",
"main",
"sessions",
`${sessionId}.jsonl`,
);
fs.mkdirSync(path.dirname(logPath), { recursive: true });
fs.writeFileSync(
logPath,
[
JSON.stringify({
type: "message",
message: {
role: "assistant",
model: "claude-opus-4-5",
usage: {
input: 1,
output: 2,
cacheRead: 1000,
cacheWrite: 0,
totalTokens: 1003,
fs.writeFileSync(
logPath,
[
JSON.stringify({
type: "message",
message: {
role: "assistant",
model: "claude-opus-4-5",
usage: {
input: 1,
output: 2,
cacheRead: 1000,
cacheWrite: 0,
totalTokens: 1003,
},
},
},
}),
].join("\n"),
"utf-8",
);
}),
].join("\n"),
"utf-8",
);
const text = buildStatusMessageDynamic({
agent: {
model: "anthropic/claude-opus-4-5",
contextTokens: 32_000,
},
sessionEntry: {
sessionId,
updatedAt: 0,
totalTokens: 3, // would be wrong if cached prompt tokens exist
contextTokens: 32_000,
},
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
includeTranscriptUsage: true,
modelAuth: "api-key",
});
const text = buildStatusMessageDynamic({
agent: {
model: "anthropic/claude-opus-4-5",
contextTokens: 32_000,
},
sessionEntry: {
sessionId,
updatedAt: 0,
totalTokens: 3, // would be wrong if cached prompt tokens exist
contextTokens: 32_000,
},
sessionKey: "agent:main:main",
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
includeTranscriptUsage: true,
modelAuth: "api-key",
});
expect(text).toContain("Context: 1.0k/32k");
} finally {
restoreHomeEnv(previousHome);
fs.rmSync(dir, { recursive: true, force: true });
}
expect(normalizeTestText(text)).toContain("Context: 1.0k/32k");
},
{ prefix: "clawdbot-status-" },
);
});
});
describe("buildCommandsMessage", () => {
it("lists commands with aliases and text-only hints", () => {
const text = buildCommandsMessage();
expect(text).toContain("/commands - List all slash commands.");
expect(text).toContain(
"/think (aliases: /thinking, /t) - Set thinking level.",
);
expect(text).toContain(
"/compact (text-only) - Compact the session context.",
);
});
});
+37 -3
View File
@@ -28,6 +28,7 @@ import {
resolveModelCostConfig,
} from "../utils/usage-format.js";
import { VERSION } from "../version.js";
import { listChatCommands } from "./commands-registry.js";
import type {
ElevatedLevel,
ReasoningLevel,
@@ -35,7 +36,9 @@ import type {
VerboseLevel,
} from "./thinking.js";
type AgentConfig = NonNullable<ClawdbotConfig["agent"]>;
type AgentConfig = Partial<
NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>
>;
export const formatTokenCount = formatTokenCountShared;
@@ -188,7 +191,11 @@ export function buildStatusMessage(args: StatusArgs): string {
const now = args.now ?? Date.now();
const entry = args.sessionEntry;
const resolved = resolveConfiguredModelRef({
cfg: { agent: args.agent ?? {} },
cfg: {
agents: {
defaults: args.agent ?? {},
},
} as ClawdbotConfig,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
@@ -351,6 +358,33 @@ export function buildHelpMessage(): string {
return [
"️ Help",
"Shortcuts: /new reset | /compact [instructions] | /restart relink (if enabled)",
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off",
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off | /debug show",
"More: /commands for all slash commands",
].join("\n");
}
export function buildCommandsMessage(): string {
const lines = ["️ Slash commands"];
for (const command of listChatCommands()) {
const primary = command.nativeName
? `/${command.nativeName}`
: command.textAliases[0]?.trim() || `/${command.key}`;
const seen = new Set<string>();
const aliases = command.textAliases
.map((alias) => alias.trim())
.filter(Boolean)
.filter((alias) => alias.toLowerCase() !== primary.toLowerCase())
.filter((alias) => {
const key = alias.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const aliasLabel = aliases.length
? ` (aliases: ${aliases.join(", ")})`
: "";
const scopeLabel = command.scope === "text" ? " (text-only)" : "";
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
}
return lines.join("\n");
}
+2 -1
View File
@@ -6,7 +6,8 @@ export type OriginatingChannelType =
| "signal"
| "imessage"
| "whatsapp"
| "webchat";
| "webchat"
| "msteams";
export type MsgContext = {
Body?: string;
+3 -3
View File
@@ -37,8 +37,8 @@ describe("transcribeInboundAudio", () => {
vi.stubGlobal("fetch", fetchMock);
const cfg = {
routing: {
transcribeAudio: {
audio: {
transcription: {
command: ["echo", "{{MediaPath}}"],
timeoutSeconds: 5,
},
@@ -64,7 +64,7 @@ describe("transcribeInboundAudio", () => {
it("returns undefined when no transcription command", async () => {
const { transcribeInboundAudio } = await import("./transcription.js");
const res = await transcribeInboundAudio(
{ routing: {} } as never,
{ audio: {} } as never,
{} as never,
runtime as never,
);
+1 -1
View File
@@ -18,7 +18,7 @@ export async function transcribeInboundAudio(
ctx: MsgContext,
runtime: RuntimeEnv,
): Promise<{ text: string } | undefined> {
const transcriber = cfg.routing?.transcribeAudio;
const transcriber = cfg.audio?.transcription;
if (!transcriber?.command?.length) return undefined;
const timeoutMs = Math.max((transcriber.timeoutSeconds ?? 45) * 1000, 1_000);
+1
View File
@@ -28,6 +28,7 @@ export type ReplyPayload = {
mediaUrl?: string;
mediaUrls?: string[];
replyToId?: string;
replyToTag?: boolean;
/** Send audio as voice message (bubble) instead of audio file. Defaults to false. */
audioAsVoice?: boolean;
isError?: boolean;
+1 -1
View File
@@ -231,7 +231,7 @@ describe("canvas host", () => {
await server.close();
await fs.rm(dir, { recursive: true, force: true });
}
});
}, 10_000);
it("serves the gateway-hosted A2UI scaffold", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-canvas-"));
+1
View File
@@ -271,6 +271,7 @@ export async function createCanvasHostHandler(
? chokidar.watch(rootReal, {
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 75, pollInterval: 10 },
usePolling: opts.allowInTests === true,
ignored: [
/(^|[\\/])\../, // dotfiles
/(^|[\\/])node_modules([\\/]|$)/,
+49
View File
@@ -10,6 +10,20 @@ type BannerOptions = TaglineOptions & {
let bannerEmitted = false;
const graphemeSegmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
function splitGraphemes(value: string): string[] {
if (!graphemeSegmenter) return Array.from(value);
try {
return Array.from(graphemeSegmenter.segment(value), (seg) => seg.segment);
} catch {
return Array.from(value);
}
}
const hasJsonFlag = (argv: string[]) =>
argv.some((arg) => arg === "--json" || arg.startsWith("--json="));
@@ -33,6 +47,41 @@ export function formatCliBannerLine(
return `${title} ${version} (${commitLabel}) — ${tagline}`;
}
const LOBSTER_ASCII = [
"░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀",
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░",
"█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░",
"█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░",
"░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░",
" 🦞 FRESH DAILY 🦞",
];
export function formatCliBannerArt(options: BannerOptions = {}): string {
const rich = options.richTty ?? isRich();
if (!rich) return LOBSTER_ASCII.join("\n");
const colorChar = (ch: string) => {
if (ch === "█") return theme.accentBright(ch);
if (ch === "░") return theme.accentDim(ch);
if (ch === "▀") return theme.accent(ch);
return theme.muted(ch);
};
const colored = LOBSTER_ASCII.map((line) => {
if (line.includes("FRESH DAILY")) {
return (
theme.muted(" ") +
theme.accent("🦞") +
theme.info(" FRESH DAILY ") +
theme.accent("🦞")
);
}
return splitGraphemes(line).map(colorChar).join("");
});
return colored.join("\n");
}
export function emitCliBanner(version: string, options: BannerOptions = {}) {
if (bannerEmitted) return;
const argv = options.argv ?? process.argv;
+3
View File
@@ -1,5 +1,6 @@
import { sendMessageDiscord } from "../discord/send.js";
import { sendMessageIMessage } from "../imessage/send.js";
import { sendMessageMSTeams } from "../msteams/send.js";
import { logWebSelfId, sendMessageWhatsApp } from "../providers/web/index.js";
import { sendMessageSignal } from "../signal/send.js";
import { sendMessageSlack } from "../slack/send.js";
@@ -12,6 +13,7 @@ export type CliDeps = {
sendMessageSlack: typeof sendMessageSlack;
sendMessageSignal: typeof sendMessageSignal;
sendMessageIMessage: typeof sendMessageIMessage;
sendMessageMSTeams: typeof sendMessageMSTeams;
};
export function createDefaultDeps(): CliDeps {
@@ -22,6 +24,7 @@ export function createDefaultDeps(): CliDeps {
sendMessageSlack,
sendMessageSignal,
sendMessageIMessage,
sendMessageMSTeams,
};
}
+115 -1
View File
@@ -12,6 +12,8 @@ const forceFreePortAndWait = vi.fn(async () => ({
escalatedToSigkill: false,
}));
const serviceIsLoaded = vi.fn().mockResolvedValue(true);
const discoverGatewayBeacons = vi.fn(async () => []);
const gatewayStatusCommand = vi.fn(async () => {});
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
@@ -90,8 +92,16 @@ vi.mock("../daemon/program-args.js", () => ({
}),
}));
vi.mock("../infra/bonjour-discovery.js", () => ({
discoverGatewayBeacons: (opts: unknown) => discoverGatewayBeacons(opts),
}));
vi.mock("../commands/gateway-status.js", () => ({
gatewayStatusCommand: (opts: unknown) => gatewayStatusCommand(opts),
}));
describe("gateway-cli coverage", () => {
it("registers call/health/status commands and routes to callGateway", async () => {
it("registers call/health commands and routes to callGateway", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGateway.mockClear();
@@ -110,6 +120,110 @@ describe("gateway-cli coverage", () => {
expect(runtimeLogs.join("\n")).toContain('"ok": true');
});
it("registers gateway status and routes to gatewayStatusCommand", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
gatewayStatusCommand.mockClear();
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await program.parseAsync(["gateway", "status", "--json"], { from: "user" });
expect(gatewayStatusCommand).toHaveBeenCalledTimes(1);
});
it("registers gateway discover and prints JSON", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
discoverGatewayBeacons.mockReset();
discoverGatewayBeacons.mockResolvedValueOnce([
{
instanceName: "Studio (Clawdbot)",
displayName: "Studio",
domain: "local.",
host: "studio.local",
lanHost: "studio.local",
tailnetDns: "studio.tailnet.ts.net",
gatewayPort: 18789,
bridgePort: 18790,
sshPort: 22,
},
]);
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await program.parseAsync(["gateway", "discover", "--json"], {
from: "user",
});
expect(discoverGatewayBeacons).toHaveBeenCalledTimes(1);
expect(runtimeLogs.join("\n")).toContain('"beacons"');
expect(runtimeLogs.join("\n")).toContain('"wsUrl"');
expect(runtimeLogs.join("\n")).toContain("ws://");
});
it("registers gateway discover and prints human output with details on new lines", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
discoverGatewayBeacons.mockReset();
discoverGatewayBeacons.mockResolvedValueOnce([
{
instanceName: "Studio (Clawdbot)",
displayName: "Studio",
domain: "clawdbot.internal.",
host: "studio.clawdbot.internal",
lanHost: "studio.local",
tailnetDns: "studio.tailnet.ts.net",
gatewayPort: 18789,
bridgePort: 18790,
sshPort: 22,
},
]);
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await program.parseAsync(["gateway", "discover", "--timeout", "1"], {
from: "user",
});
const out = runtimeLogs.join("\n");
expect(out).toContain("Gateway Discovery");
expect(out).toContain("Found 1 gateway(s)");
expect(out).toContain("- Studio clawdbot.internal.");
expect(out).toContain(" tailnet: studio.tailnet.ts.net");
expect(out).toContain(" host: studio.clawdbot.internal");
expect(out).toContain(" ws: ws://studio.tailnet.ts.net:18789");
});
it("validates gateway discover timeout", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
discoverGatewayBeacons.mockReset();
const { registerGatewayCli } = await import("./gateway-cli.js");
const program = new Command();
program.exitOverride();
registerGatewayCli(program);
await expect(
program.parseAsync(["gateway", "discover", "--timeout", "0"], {
from: "user",
}),
).rejects.toThrow("__exit__:1");
expect(runtimeErrors.join("\n")).toContain("gateway discover failed:");
expect(discoverGatewayBeacons).not.toHaveBeenCalled();
});
it("fails gateway call on invalid params JSON", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
+428 -20
View File
@@ -1,12 +1,18 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { Command } from "commander";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import { gatewayStatusCommand } from "../commands/gateway-status.js";
import { handleReset } from "../commands/onboard-helpers.js";
import {
CONFIG_PATH_CLAWDBOT,
type GatewayAuthMode,
loadConfig,
readConfigFileSnapshot,
resolveGatewayPort,
writeConfigFile,
} from "../config/config.js";
import {
GATEWAY_LAUNCH_AGENT_LABEL,
@@ -22,13 +28,18 @@ import {
setGatewayWsLogStyle,
} from "../gateway/ws-logging.js";
import { setVerbose } from "../globals.js";
import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
import { WIDE_AREA_DISCOVERY_DOMAIN } from "../infra/widearea-dns.js";
import {
createSubsystemLogger,
setConsoleSubsystemFilter,
} from "../logging.js";
import { defaultRuntime } from "../runtime.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import { resolveUserPath } from "../utils.js";
import { forceFreePortAndWait } from "./ports.js";
import { withProgress } from "./progress.js";
@@ -38,6 +49,7 @@ type GatewayRpcOpts = {
password?: string;
timeout?: string;
expectFinal?: boolean;
json?: boolean;
};
type GatewayRunOpts = {
@@ -56,6 +68,8 @@ type GatewayRunOpts = {
compact?: boolean;
rawStream?: boolean;
rawStreamPath?: unknown;
dev?: boolean;
reset?: boolean;
};
type GatewayRunParams = {
@@ -63,6 +77,32 @@ type GatewayRunParams = {
};
const gatewayLog = createSubsystemLogger("gateway");
const DEV_IDENTITY_NAME = "C3-PO";
const DEV_IDENTITY_THEME = "protocol droid";
const DEV_IDENTITY_EMOJI = "🤖";
const DEV_AGENT_WORKSPACE_SUFFIX = "dev";
const DEV_TEMPLATE_DIR = path.resolve(
path.dirname(new URL(import.meta.url).pathname),
"../../docs/reference/templates",
);
async function loadDevTemplate(
name: string,
fallback: string,
): Promise<string> {
try {
const raw = await fs.promises.readFile(
path.join(DEV_TEMPLATE_DIR, name),
"utf-8",
);
if (!raw.startsWith("---")) return raw;
const endIndex = raw.indexOf("\n---", 3);
if (endIndex === -1) return raw;
return raw.slice(endIndex + "\n---".length).replace(/^\s+/, "");
} catch {
return fallback;
}
}
type GatewayRunSignalAction = "stop" | "restart";
@@ -87,6 +127,202 @@ const toOptionString = (value: unknown): string | undefined => {
return undefined;
};
const resolveDevWorkspaceDir = (
env: NodeJS.ProcessEnv = process.env,
): string => {
const baseDir = resolveDefaultAgentWorkspaceDir(env, os.homedir);
const profile = env.CLAWDBOT_PROFILE?.trim().toLowerCase();
if (profile === "dev") return baseDir;
return `${baseDir}-${DEV_AGENT_WORKSPACE_SUFFIX}`;
};
async function writeFileIfMissing(filePath: string, content: string) {
try {
await fs.promises.writeFile(filePath, content, {
encoding: "utf-8",
flag: "wx",
});
} catch (err) {
const anyErr = err as { code?: string };
if (anyErr.code !== "EEXIST") throw err;
}
}
async function ensureDevWorkspace(dir: string) {
const resolvedDir = resolveUserPath(dir);
await fs.promises.mkdir(resolvedDir, { recursive: true });
const [agents, soul, tools, identity, user] = await Promise.all([
loadDevTemplate(
"AGENTS.dev.md",
`# AGENTS.md - Clawdbot Dev Workspace\n\nDefault dev workspace for clawdbot gateway --dev.\n`,
),
loadDevTemplate(
"SOUL.dev.md",
`# SOUL.md - Dev Persona\n\nProtocol droid for debugging and operations.\n`,
),
loadDevTemplate(
"TOOLS.dev.md",
`# TOOLS.md - User Tool Notes (editable)\n\nAdd your local tool notes here.\n`,
),
loadDevTemplate(
"IDENTITY.dev.md",
`# IDENTITY.md - Agent Identity\n\n- Name: ${DEV_IDENTITY_NAME}\n- Creature: protocol droid\n- Vibe: ${DEV_IDENTITY_THEME}\n- Emoji: ${DEV_IDENTITY_EMOJI}\n`,
),
loadDevTemplate(
"USER.dev.md",
`# USER.md - User Profile\n\n- Name:\n- Preferred address:\n- Notes:\n`,
),
]);
await writeFileIfMissing(path.join(resolvedDir, "AGENTS.md"), agents);
await writeFileIfMissing(path.join(resolvedDir, "SOUL.md"), soul);
await writeFileIfMissing(path.join(resolvedDir, "TOOLS.md"), tools);
await writeFileIfMissing(path.join(resolvedDir, "IDENTITY.md"), identity);
await writeFileIfMissing(path.join(resolvedDir, "USER.md"), user);
}
async function ensureDevGatewayConfig(opts: { reset?: boolean }) {
const workspace = resolveDevWorkspaceDir();
if (opts.reset) {
await handleReset("full", workspace, defaultRuntime);
}
const configExists = fs.existsSync(CONFIG_PATH_CLAWDBOT);
if (!opts.reset && configExists) return;
await writeConfigFile({
gateway: {
mode: "local",
bind: "loopback",
},
agents: {
defaults: {
workspace,
skipBootstrap: true,
},
list: [
{
id: "dev",
default: true,
workspace,
identity: {
name: DEV_IDENTITY_NAME,
theme: DEV_IDENTITY_THEME,
emoji: DEV_IDENTITY_EMOJI,
},
},
],
},
});
await ensureDevWorkspace(workspace);
defaultRuntime.log(`Dev config ready: ${CONFIG_PATH_CLAWDBOT}`);
defaultRuntime.log(`Dev workspace ready: ${resolveUserPath(workspace)}`);
}
type GatewayDiscoverOpts = {
timeout?: string;
json?: boolean;
};
function parseDiscoverTimeoutMs(raw: unknown, fallbackMs: number): number {
if (raw === undefined || raw === null) return fallbackMs;
const value =
typeof raw === "string"
? raw.trim()
: typeof raw === "number" || typeof raw === "bigint"
? String(raw)
: null;
if (value === null) {
throw new Error("invalid --timeout");
}
if (!value) return fallbackMs;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error(`invalid --timeout: ${value}`);
}
return parsed;
}
function pickBeaconHost(beacon: GatewayBonjourBeacon): string | null {
const host = beacon.tailnetDns || beacon.lanHost || beacon.host;
return host?.trim() ? host.trim() : null;
}
function pickGatewayPort(beacon: GatewayBonjourBeacon): number {
const port = beacon.gatewayPort ?? 18789;
return port > 0 ? port : 18789;
}
function dedupeBeacons(
beacons: GatewayBonjourBeacon[],
): GatewayBonjourBeacon[] {
const out: GatewayBonjourBeacon[] = [];
const seen = new Set<string>();
for (const b of beacons) {
const host = pickBeaconHost(b) ?? "";
const key = [
b.domain ?? "",
b.instanceName ?? "",
b.displayName ?? "",
host,
String(b.port ?? ""),
String(b.bridgePort ?? ""),
String(b.gatewayPort ?? ""),
].join("|");
if (seen.has(key)) continue;
seen.add(key);
out.push(b);
}
return out;
}
function renderBeaconLines(
beacon: GatewayBonjourBeacon,
rich: boolean,
): string[] {
const nameRaw = (
beacon.displayName ||
beacon.instanceName ||
"Gateway"
).trim();
const domainRaw = (beacon.domain || "local.").trim();
const title = colorize(rich, theme.accentBright, nameRaw);
const domain = colorize(rich, theme.muted, domainRaw);
const host = pickBeaconHost(beacon);
const gatewayPort = pickGatewayPort(beacon);
const wsUrl = host ? `ws://${host}:${gatewayPort}` : null;
const lines = [`- ${title} ${domain}`];
if (beacon.tailnetDns) {
lines.push(
` ${colorize(rich, theme.info, "tailnet")}: ${beacon.tailnetDns}`,
);
}
if (beacon.lanHost) {
lines.push(` ${colorize(rich, theme.info, "lan")}: ${beacon.lanHost}`);
}
if (beacon.host) {
lines.push(` ${colorize(rich, theme.info, "host")}: ${beacon.host}`);
}
if (wsUrl) {
lines.push(
` ${colorize(rich, theme.muted, "ws")}: ${colorize(rich, theme.command, wsUrl)}`,
);
}
if (typeof beacon.sshPort === "number" && beacon.sshPort > 0 && host) {
const ssh = `ssh -N -L 18789:127.0.0.1:18789 <user>@${host} -p ${beacon.sshPort}`;
lines.push(
` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, ssh)}`,
);
}
return lines;
}
function describeUnknownError(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === "string") return err;
@@ -219,9 +455,18 @@ async function runGatewayLoop(params: {
})();
};
const onSigterm = () => request("stop", "SIGTERM");
const onSigint = () => request("stop", "SIGINT");
const onSigusr1 = () => request("restart", "SIGUSR1");
const onSigterm = () => {
gatewayLog.info("signal SIGTERM received");
request("stop", "SIGTERM");
};
const onSigint = () => {
gatewayLog.info("signal SIGINT received");
request("stop", "SIGINT");
};
const onSigusr1 = () => {
gatewayLog.info("signal SIGUSR1 received");
request("restart", "SIGUSR1");
};
process.on("SIGTERM", onSigterm);
process.on("SIGINT", onSigint);
@@ -251,7 +496,8 @@ const gatewayCallOpts = (cmd: Command) =>
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (password auth)")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--expect-final", "Wait for final response (agent)", false);
.option("--expect-final", "Wait for final response (agent)", false)
.option("--json", "Output JSON", false);
const callGatewayCli = async (
method: string,
@@ -262,7 +508,7 @@ const callGatewayCli = async (
{
label: `Gateway ${method}`,
indeterminate: true,
enabled: true,
enabled: opts.json !== true,
},
async () =>
await callGateway({
@@ -282,6 +528,14 @@ async function runGatewayCommand(
opts: GatewayRunOpts,
params: GatewayRunParams = {},
) {
const isDevProfile =
process.env.CLAWDBOT_PROFILE?.trim().toLowerCase() === "dev";
const devMode = Boolean(opts.dev) || isDevProfile;
if (opts.reset && !devMode) {
defaultRuntime.error("Use --reset with --dev.");
defaultRuntime.exit(1);
return;
}
if (params.legacyTokenEnv) {
const legacyToken = process.env.CLAWDIS_GATEWAY_TOKEN;
if (legacyToken && !process.env.CLAWDBOT_GATEWAY_TOKEN) {
@@ -318,6 +572,10 @@ async function runGatewayCommand(
process.env.CLAWDBOT_RAW_STREAM_PATH = rawStreamPath;
}
if (devMode) {
await ensureDevGatewayConfig({ reset: Boolean(opts.reset) });
}
const cfg = loadConfig();
const portOverride = parsePort(opts.port);
if (opts.port !== undefined && portOverride === null) {
@@ -571,6 +829,16 @@ function addGatewayRunCommand(
"Allow gateway start without gateway.mode=local in config",
false,
)
.option(
"--dev",
"Create a dev config + workspace if missing (no BOOTSTRAP.md)",
false,
)
.option(
"--reset",
"Reset dev config + credentials + sessions + workspace (requires --dev)",
false,
)
.option(
"--force",
"Kill any existing listener on the target port before starting",
@@ -611,7 +879,7 @@ export function registerGatewayCli(program: Command) {
gatewayCallOpts(
gateway
.command("call")
.description("Call a Gateway method and print JSON")
.description("Call a Gateway method")
.argument(
"<method>",
"Method name (health/status/system-presence/cron.*)",
@@ -621,6 +889,18 @@ export function registerGatewayCli(program: Command) {
try {
const params = JSON.parse(String(opts.params ?? "{}"));
const result = await callGatewayCli(method, opts, params);
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const rich = isRich();
defaultRuntime.log(
`${colorize(rich, theme.heading, "Gateway call")}: ${colorize(
rich,
theme.muted,
String(method),
)}`,
);
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(`Gateway call failed: ${String(err)}`);
@@ -636,7 +916,46 @@ export function registerGatewayCli(program: Command) {
.action(async (opts) => {
try {
const result = await callGatewayCli("health", opts);
defaultRuntime.log(JSON.stringify(result, null, 2));
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const rich = isRich();
const obj =
result && typeof result === "object"
? (result as Record<string, unknown>)
: {};
const durationMs =
typeof obj.durationMs === "number" ? obj.durationMs : null;
defaultRuntime.log(colorize(rich, theme.heading, "Gateway Health"));
defaultRuntime.log(
`${colorize(rich, theme.success, "OK")}${
durationMs != null ? ` (${durationMs}ms)` : ""
}`,
);
if (obj.web && typeof obj.web === "object") {
const web = obj.web as Record<string, unknown>;
const linked = web.linked === true;
defaultRuntime.log(
`Web: ${linked ? "linked" : "not linked"}${
typeof web.authAgeMs === "number" && linked
? ` (${Math.round(web.authAgeMs / 60_000)}m)`
: ""
}`,
);
}
if (obj.telegram && typeof obj.telegram === "object") {
const tg = obj.telegram as Record<string, unknown>;
defaultRuntime.log(
`Telegram: ${tg.configured === true ? "configured" : "not configured"}`,
);
}
if (obj.discord && typeof obj.discord === "object") {
const dc = obj.discord as Record<string, unknown>;
defaultRuntime.log(
`Discord: ${dc.configured === true ? "configured" : "not configured"}`,
);
}
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
@@ -644,18 +963,107 @@ export function registerGatewayCli(program: Command) {
}),
);
gatewayCallOpts(
gateway
.command("status")
.description("Fetch Gateway status")
.action(async (opts) => {
try {
const result = await callGatewayCli("status", opts);
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
gateway
.command("status")
.description(
"Show gateway reachability + discovery + health + status summary (local + remote)",
)
.option(
"--url <url>",
"Explicit Gateway WebSocket URL (still probes localhost)",
)
.option(
"--ssh <target>",
"SSH target for remote gateway tunnel (user@host or user@host:port)",
)
.option("--ssh-identity <path>", "SSH identity file path")
.option(
"--ssh-auto",
"Try to derive an SSH target from Bonjour discovery",
false,
)
.option("--token <token>", "Gateway token (applies to all probes)")
.option("--password <password>", "Gateway password (applies to all probes)")
.option("--timeout <ms>", "Overall probe budget in ms", "3000")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
await gatewayStatusCommand(opts, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
gateway
.command("discover")
.description(
`Discover gateways via Bonjour (multicast local. + unicast ${WIDE_AREA_DISCOVERY_DOMAIN})`,
)
.option("--timeout <ms>", "Per-command timeout in ms", "2000")
.option("--json", "Output JSON", false)
.action(async (opts: GatewayDiscoverOpts) => {
try {
const timeoutMs = parseDiscoverTimeoutMs(opts.timeout, 2000);
const beacons = await withProgress(
{
label: "Scanning for gateways…",
indeterminate: true,
enabled: opts.json !== true,
delayMs: 0,
},
async () => await discoverGatewayBeacons({ timeoutMs }),
);
const deduped = dedupeBeacons(beacons).sort((a, b) =>
String(a.displayName || a.instanceName).localeCompare(
String(b.displayName || b.instanceName),
),
);
if (opts.json) {
const enriched = deduped.map((b) => {
const host = pickBeaconHost(b);
const port = pickGatewayPort(b);
return {
...b,
wsUrl: host ? `ws://${host}:${port}` : null,
};
});
defaultRuntime.log(
JSON.stringify(
{
timeoutMs,
domains: ["local.", WIDE_AREA_DISCOVERY_DOMAIN],
count: enriched.length,
beacons: enriched,
},
null,
2,
),
);
return;
}
}),
);
const rich = isRich();
defaultRuntime.log(colorize(rich, theme.heading, "Gateway Discovery"));
defaultRuntime.log(
colorize(
rich,
theme.muted,
`Found ${deduped.length} gateway(s) · domains: local., ${WIDE_AREA_DISCOVERY_DOMAIN}`,
),
);
if (deduped.length === 0) return;
for (const beacon of deduped) {
for (const line of renderBeaconLines(beacon, rich)) {
defaultRuntime.log(line);
}
}
} catch (err) {
defaultRuntime.error(`gateway discover failed: ${String(err)}`);
defaultRuntime.exit(1);
}
});
}
+143 -2
View File
@@ -4,6 +4,12 @@ import {
modelsAliasesAddCommand,
modelsAliasesListCommand,
modelsAliasesRemoveCommand,
modelsAuthAddCommand,
modelsAuthOrderClearCommand,
modelsAuthOrderGetCommand,
modelsAuthOrderSetCommand,
modelsAuthPasteTokenCommand,
modelsAuthSetupTokenCommand,
modelsFallbacksAddCommand,
modelsFallbacksClearCommand,
modelsFallbacksListCommand,
@@ -264,10 +270,14 @@ export function registerModelsCli(program: Command) {
.option("--no-probe", "Skip live probes; list free candidates only")
.option("--yes", "Accept defaults without prompting", false)
.option("--no-input", "Disable prompts (use defaults)")
.option("--set-default", "Set agent.model to the first selection", false)
.option(
"--set-default",
"Set agents.defaults.model to the first selection",
false,
)
.option(
"--set-image",
"Set agent.imageModel to the first image selection",
"Set agents.defaults.imageModel to the first image selection",
false,
)
.option("--json", "Output JSON", false)
@@ -294,4 +304,135 @@ export function registerModelsCli(program: Command) {
defaultRuntime.exit(1);
}
});
const auth = models.command("auth").description("Manage model auth profiles");
auth
.command("add")
.description("Interactive auth helper (setup-token or paste token)")
.action(async () => {
try {
await modelsAuthAddCommand({}, defaultRuntime);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
auth
.command("setup-token")
.description("Run a provider CLI to create/sync a token (TTY required)")
.option("--provider <name>", "Provider id (default: anthropic)")
.option("--yes", "Skip confirmation", false)
.action(async (opts) => {
try {
await modelsAuthSetupTokenCommand(
{
provider: opts.provider as string | undefined,
yes: Boolean(opts.yes),
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
auth
.command("paste-token")
.description("Paste a token into auth-profiles.json and update config")
.requiredOption("--provider <name>", "Provider id (e.g. anthropic)")
.option("--profile-id <id>", "Auth profile id (default: <provider>:manual)")
.option(
"--expires-in <duration>",
"Optional expiry duration (e.g. 365d, 12h). Stored as absolute expiresAt.",
)
.action(async (opts) => {
try {
await modelsAuthPasteTokenCommand(
{
provider: opts.provider as string | undefined,
profileId: opts.profileId as string | undefined,
expiresIn: opts.expiresIn as string | undefined,
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
const order = auth
.command("order")
.description("Manage per-agent auth profile order overrides");
order
.command("get")
.description("Show per-agent auth order override (from auth-profiles.json)")
.requiredOption("--provider <name>", "Provider id (e.g. anthropic)")
.option("--agent <id>", "Agent id (default: configured default agent)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
try {
await modelsAuthOrderGetCommand(
{
provider: opts.provider as string,
agent: opts.agent as string | undefined,
json: Boolean(opts.json),
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
order
.command("set")
.description(
"Set per-agent auth order override (locks rotation to this list)",
)
.requiredOption("--provider <name>", "Provider id (e.g. anthropic)")
.option("--agent <id>", "Agent id (default: configured default agent)")
.argument("<profileIds...>", "Auth profile ids (e.g. anthropic:claude-cli)")
.action(async (profileIds: string[], opts) => {
try {
await modelsAuthOrderSetCommand(
{
provider: opts.provider as string,
agent: opts.agent as string | undefined,
order: profileIds,
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
order
.command("clear")
.description(
"Clear per-agent auth order override (fall back to config/round-robin)",
)
.requiredOption("--provider <name>", "Provider id (e.g. anthropic)")
.option("--agent <id>", "Agent id (default: configured default agent)")
.action(async (opts) => {
try {
await modelsAuthOrderClearCommand(
{
provider: opts.provider as string,
agent: opts.agent as string | undefined,
},
defaultRuntime,
);
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
}
+7
View File
@@ -3,6 +3,7 @@ import type { Command } from "commander";
import { loadConfig } from "../config/config.js";
import { sendMessageDiscord } from "../discord/send.js";
import { sendMessageIMessage } from "../imessage/send.js";
import { sendMessageMSTeams } from "../msteams/send.js";
import { PROVIDER_ID_LABELS } from "../pairing/pairing-labels.js";
import {
approveProviderPairingCode,
@@ -21,6 +22,7 @@ const PROVIDERS: PairingProvider[] = [
"discord",
"slack",
"whatsapp",
"msteams",
];
function parseProvider(raw: unknown): PairingProvider {
@@ -65,6 +67,11 @@ async function notifyApproved(provider: PairingProvider, id: string) {
await sendMessageIMessage(id, message);
return;
}
if (provider === "msteams") {
const cfg = loadConfig();
await sendMessageMSTeams({ cfg, to: id, text: message });
return;
}
// WhatsApp: approval still works (store); notifying requires an active web session.
}

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