Merge branch 'main' into commands-list-clean

This commit is contained in:
Luke
2026-01-09 11:46:08 -05:00
committed by GitHub
26 changed files with 913 additions and 364 deletions

View File

@@ -8,13 +8,16 @@
- CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott - CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
- Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc - Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc
- Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro - Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro
- Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow
- Commands: accept /models as an alias for /model. - Commands: accept /models as an alias for /model.
- Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp
- Models/Auth: show per-agent auth candidates in `/model status`, and add `clawdbot models auth order {get,set,clear}` (per-agent auth rotation overrides). — thanks @steipete - Models/Auth: show per-agent auth candidates in `/model status`, and add `clawdbot models auth order {get,set,clear}` (per-agent auth rotation overrides). — thanks @steipete
- Debugging: add raw model stream logging flags and document gateway watch mode. - Debugging: add raw model stream logging flags and document gateway watch mode.
- Gateway: decode dns-sd escaped UTF-8 in discovery output and show scan progress immediately. — thanks @steipete - Gateway: decode dns-sd escaped UTF-8 in discovery output and show scan progress immediately. — thanks @steipete
- Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled). - Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled).
- CLI: move `clawdbot message` to subcommands (`message send|poll|…`), fold Discord/Slack/Telegram/WhatsApp tools into `message`, and require `--provider` unless only one provider is configured. - CLI: move `clawdbot message` to subcommands (`message send|poll|…`), fold Discord/Slack/Telegram/WhatsApp tools into `message`, and require `--provider` unless only one provider is configured.
- CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging. - CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging.
- Hooks: default hook agent delivery to true. (#533) — thanks @mcinteerj
- WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj - WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj
- Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223 - Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223
- Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj - Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj
@@ -89,7 +92,7 @@
- Status: show Verbose/Elevated only when enabled. - Status: show Verbose/Elevated only when enabled.
- Status: filter usage summary to the active model provider. - Status: filter usage summary to the active model provider.
- Status: map model providers to usage sources so unrelated usage doesnt appear. - Status: map model providers to usage sources so unrelated usage doesnt appear.
- Status: allow Claude usage snapshot fallback via claude.ai session cookie (`CLAUDE_AI_SESSION_KEY` / `CLAUDE_WEB_COOKIE`) when OAuth token lacks `user:profile`. - Status: fix Claude usage snapshots when `anthropic:default` is a setup-token lacking `user:profile` by preferring `anthropic:claude-cli`; optional claude.ai fallback via `CLAUDE_AI_SESSION_KEY` / `CLAUDE_WEB_COOKIE`.
- Commands: allow /elevated off in groups without a mention; keep /elevated on mention-gated. - Commands: allow /elevated off in groups without a mention; keep /elevated on mention-gated.
- Commands: keep multi-directive messages from clearing directive handling. - Commands: keep multi-directive messages from clearing directive handling.
- Commands: warn when /elevated runs in direct (unsandboxed) runtime. - Commands: warn when /elevated runs in direct (unsandboxed) runtime.

View File

@@ -5,6 +5,9 @@ services:
HOME: /home/node HOME: /home/node
TERM: xterm-256color TERM: xterm-256color
CLAWDBOT_GATEWAY_TOKEN: ${CLAWDBOT_GATEWAY_TOKEN} CLAWDBOT_GATEWAY_TOKEN: ${CLAWDBOT_GATEWAY_TOKEN}
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY}
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY}
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE}
volumes: volumes:
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot - ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd - ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
@@ -30,6 +33,9 @@ services:
HOME: /home/node HOME: /home/node
TERM: xterm-256color TERM: xterm-256color
BROWSER: echo BROWSER: echo
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY}
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY}
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE}
volumes: volumes:
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot - ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd - ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd

View File

@@ -57,7 +57,7 @@ Payload:
"name": "Email", "name": "Email",
"sessionKey": "hook:email:msg-123", "sessionKey": "hook:email:msg-123",
"wakeMode": "now", "wakeMode": "now",
"deliver": false, "deliver": true,
"provider": "last", "provider": "last",
"to": "+15551234567", "to": "+15551234567",
"model": "openai/gpt-5.2-mini", "model": "openai/gpt-5.2-mini",
@@ -70,7 +70,7 @@ Payload:
- `name` optional (string): Human-readable name for the hook (e.g., "GitHub"), used as a prefix in session summaries. - `name` optional (string): Human-readable name for the hook (e.g., "GitHub"), used as a prefix in session summaries.
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context. - `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context.
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check. - `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging provider. Defaults to `false`. Responses that are only heartbeat acknowledgments are automatically skipped. - `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging provider. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
- `provider` optional (string): The messaging service for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`. Defaults to `last`. - `provider` optional (string): The messaging service for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`. Defaults to `last`.
- `to` optional (string): The recipient identifier for the provider (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack). Defaults to the last recipient in the main session. - `to` optional (string): The recipient identifier for the provider (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack). Defaults to the last recipient in the main session.
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted. - `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.

View File

@@ -377,7 +377,7 @@ Options:
Clawdbot can surface provider usage/quota when OAuth/API creds are available. Clawdbot can surface provider usage/quota when OAuth/API creds are available.
Surfaces: Surfaces:
- `/status` (adds a short usage line when available) - `/status` (alias: `/usage`; adds a short usage line when available)
- `clawdbot status --usage` (prints full provider breakdown) - `clawdbot status --usage` (prints full provider breakdown)
- macOS menu bar (Usage section under Context) - macOS menu bar (Usage section under Context)

View File

@@ -37,6 +37,8 @@ Text + native (when enabled):
- `/help` - `/help`
- `/commands` - `/commands`
- `/status` - `/status`
- `/status` (show current status; includes a short usage line when available)
- `/usage` (alias: `/status`)
- `/debug show|set|unset|reset` (runtime overrides, owner-only) - `/debug show|set|unset|reset` (runtime overrides, owner-only)
- `/cost on|off` (toggle per-response usage line) - `/cost on|off` (toggle per-response usage line)
- `/stop` - `/stop`
@@ -48,7 +50,7 @@ Text + native (when enabled):
- `/verbose on|off` (alias: `/v`) - `/verbose on|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; `stream` = Telegram draft only) - `/reasoning on|off|stream` (alias: `/reason`; `stream` = Telegram draft only)
- `/elevated on|off` (alias: `/elev`) - `/elevated on|off` (alias: `/elev`)
- `/model <name>` (or `/<alias>` from `agents.defaults.models.*.alias`) - `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings) - `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)
Text-only: Text-only:
@@ -56,6 +58,7 @@ Text-only:
Notes: Notes:
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`). - Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
- `/status` and `/usage` show the same status output; for full provider usage breakdown, use `clawdbot status --usage`.
- `/cost` appends per-response token usage; it only shows dollar cost when the model uses an API key (OAuth hides cost). - `/cost` appends per-response token usage; it only shows dollar cost when the model uses an API key (OAuth hides cost).
- `/restart` is disabled by default; set `commands.restart: true` to enable it. - `/restart` is disabled by default; set `commands.restart: true` to enable it.
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.

View File

@@ -53,16 +53,17 @@ const loadAuthProfiles = (agentId: string) => {
return { authPath, store }; return { authPath, store };
}; };
const pickAnthropicToken = (store: { const pickAnthropicTokens = (store: {
profiles?: Record<string, { provider?: string; type?: string; token?: string; key?: string }>; profiles?: Record<string, { provider?: string; type?: string; token?: string; key?: string }>;
}): { profileId: string; token: string } | null => { }): Array<{ profileId: string; token: string }> => {
const profiles = store.profiles ?? {}; const profiles = store.profiles ?? {};
const found: Array<{ profileId: string; token: string }> = [];
for (const [id, cred] of Object.entries(profiles)) { for (const [id, cred] of Object.entries(profiles)) {
if (cred?.provider !== "anthropic") continue; if (cred?.provider !== "anthropic") continue;
const token = cred.type === "token" ? cred.token?.trim() : undefined; const token = cred.type === "token" ? cred.token?.trim() : undefined;
if (token) return { profileId: id, token }; if (token) found.push({ profileId: id, token });
} }
return null; return found;
}; };
const fetchAnthropicOAuthUsage = async (token: string) => { const fetchAnthropicOAuthUsage = async (token: string) => {
@@ -79,6 +80,34 @@ const fetchAnthropicOAuthUsage = async (token: string) => {
return { status: res.status, contentType: res.headers.get("content-type"), text }; return { status: res.status, contentType: res.headers.get("content-type"), text };
}; };
const readClaudeCliKeychain = (): {
accessToken: string;
expiresAt?: number;
scopes?: string[];
} | null => {
if (process.platform !== "darwin") return null;
try {
const raw = execFileSync(
"security",
["find-generic-password", "-s", "Claude Code-credentials", "-w"],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 },
);
const parsed = JSON.parse(raw.trim()) as Record<string, unknown>;
const oauth = parsed?.claudeAiOauth as Record<string, unknown> | undefined;
if (!oauth || typeof oauth !== "object") return null;
const accessToken = oauth.accessToken;
if (typeof accessToken !== "string" || !accessToken.trim()) return null;
const expiresAt =
typeof oauth.expiresAt === "number" ? oauth.expiresAt : undefined;
const scopes = Array.isArray(oauth.scopes)
? oauth.scopes.filter((v): v is string => typeof v === "string")
: undefined;
return { accessToken, expiresAt, scopes };
} catch {
return null;
}
};
const chromeServiceNameForPath = (cookiePath: string): string => { const chromeServiceNameForPath = (cookiePath: string): string => {
if (cookiePath.includes("/Arc/")) return "Arc Safe Storage"; if (cookiePath.includes("/Arc/")) return "Arc Safe Storage";
if (cookiePath.includes("/BraveSoftware/")) return "Brave Safe Storage"; if (cookiePath.includes("/BraveSoftware/")) return "Brave Safe Storage";
@@ -251,19 +280,34 @@ const main = async () => {
const { authPath, store } = loadAuthProfiles(opts.agentId); const { authPath, store } = loadAuthProfiles(opts.agentId);
console.log(`Auth file: ${authPath}`); console.log(`Auth file: ${authPath}`);
const anthropic = pickAnthropicToken(store); const keychain = readClaudeCliKeychain();
if (!anthropic) { if (keychain) {
console.log("Anthropic: no token profiles found in auth-profiles.json"); console.log(
`Claude CLI keychain: accessToken=${opts.reveal ? keychain.accessToken : mask(keychain.accessToken)} scopes=${keychain.scopes?.join(",") ?? "(unknown)"}`,
);
const oauth = await fetchAnthropicOAuthUsage(keychain.accessToken);
console.log(
`OAuth usage (keychain): HTTP ${oauth.status} (${oauth.contentType ?? "no content-type"})`,
);
console.log(oauth.text.slice(0, 200).replace(/\s+/g, " ").trim());
} else { } else {
console.log( console.log("Claude CLI keychain: missing/unreadable");
`Anthropic: ${anthropic.profileId} token=${opts.reveal ? anthropic.token : mask(anthropic.token)}`, }
);
const oauth = await fetchAnthropicOAuthUsage(anthropic.token); const anthropic = pickAnthropicTokens(store);
console.log( if (anthropic.length === 0) {
`OAuth usage: HTTP ${oauth.status} (${oauth.contentType ?? "no content-type"})`, console.log("Auth profiles: no Anthropic token profiles found");
); } else {
console.log(oauth.text.slice(0, 400).replace(/\s+/g, " ").trim()); for (const entry of anthropic) {
console.log(""); console.log(
`Auth profiles: ${entry.profileId} token=${opts.reveal ? entry.token : mask(entry.token)}`,
);
const oauth = await fetchAnthropicOAuthUsage(entry.token);
console.log(
`OAuth usage (${entry.profileId}): HTTP ${oauth.status} (${oauth.contentType ?? "no content-type"})`,
);
console.log(oauth.text.slice(0, 200).replace(/\s+/g, " ").trim());
}
} }
const sessionKey = const sessionKey =

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:", "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_current]] replies to the triggering message.",
"- [[reply_to:<id>]] replies to a specific message id when you have it.", "- [[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.", "Tags are stripped before sending; support depends on the current provider config.",
"", "",
"## Messaging", "## Messaging",

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { hasControlCommand } from "./command-detection.js"; import { hasControlCommand } from "./command-detection.js";
import { listChatCommands } from "./commands-registry.js";
import { parseActivationCommand } from "./group-activation.js"; import { parseActivationCommand } from "./group-activation.js";
import { parseSendPolicyCommand } from "./send-policy.js"; import { parseSendPolicyCommand } from "./send-policy.js";
@@ -37,17 +38,20 @@ describe("control command parsing", () => {
}); });
it("treats bare commands as non-control", () => { it("treats bare commands as non-control", () => {
expect(hasControlCommand("/send")).toBe(true);
expect(hasControlCommand("send")).toBe(false); expect(hasControlCommand("send")).toBe(false);
expect(hasControlCommand("/help")).toBe(true);
expect(hasControlCommand("/help:")).toBe(true);
expect(hasControlCommand("help")).toBe(false); expect(hasControlCommand("help")).toBe(false);
expect(hasControlCommand("/commands")).toBe(true); expect(hasControlCommand("/commands")).toBe(true);
expect(hasControlCommand("/commands:")).toBe(true); expect(hasControlCommand("/commands:")).toBe(true);
expect(hasControlCommand("commands")).toBe(false); expect(hasControlCommand("commands")).toBe(false);
expect(hasControlCommand("/status")).toBe(true);
expect(hasControlCommand("/status:")).toBe(true);
expect(hasControlCommand("status")).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", () => { it("requires commands to be the full message", () => {

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { import {
buildCommandText, buildCommandText,
getCommandDetection, getCommandDetection,
listChatCommands,
listNativeCommandSpecs, listNativeCommandSpecs,
shouldHandleTextCommands, shouldHandleTextCommands,
} from "./commands-registry.js"; } from "./commands-registry.js";
@@ -21,15 +22,21 @@ describe("commands registry", () => {
it("detects known text commands", () => { it("detects known text commands", () => {
const detection = getCommandDetection(); const detection = getCommandDetection();
expect(detection.exact.has("/help")).toBe(true); for (const command of listChatCommands()) {
expect(detection.exact.has("/commands")).toBe(true); for (const alias of command.textAliases) {
expect(detection.regex.test("/status")).toBe(true); expect(detection.exact.has(alias.toLowerCase())).toBe(true);
expect(detection.regex.test("/status:")).toBe(true); expect(detection.regex.test(alias)).toBe(true);
expect(detection.regex.test("/stop")).toBe(true); expect(detection.regex.test(`${alias}:`)).toBe(true);
expect(detection.regex.test("/send:")).toBe(true);
expect(detection.regex.test("/debug set foo=bar")).toBe(true); if (command.acceptsArgs) {
expect(detection.regex.test("/models")).toBe(true); expect(detection.regex.test(`${alias} list`)).toBe(true);
expect(detection.regex.test("/models 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); expect(detection.regex.test("try /status")).toBe(false);
}); });

View File

@@ -14,123 +14,186 @@ export type NativeCommandSpec = {
acceptsArgs: boolean; acceptsArgs: boolean;
}; };
const CHAT_COMMANDS: ChatCommandDefinition[] = [ function defineChatCommand(
{ command: Omit<ChatCommandDefinition, "textAliases"> & { textAlias: string },
key: "help", ): ChatCommandDefinition {
nativeName: "help", return {
description: "Show available commands.", key: command.key,
textAliases: ["/help"], nativeName: command.nativeName,
}, description: command.description,
{ acceptsArgs: command.acceptsArgs,
key: "commands", textAliases: [command.textAlias],
nativeName: "commands", };
description: "List all slash commands.", }
textAliases: ["/commands"],
}, function registerAlias(
{ commands: ChatCommandDefinition[],
key: "status", key: string,
nativeName: "status", ...aliases: string[]
description: "Show current status.", ): void {
textAliases: ["/status"], const command = commands.find((entry) => entry.key === key);
}, if (!command) {
{ throw new Error(`registerAlias: unknown command key: ${key}`);
key: "debug", }
nativeName: "debug", const existing = new Set(command.textAliases.map((alias) => alias.trim()));
description: "Set runtime debug overrides.", for (const alias of aliases) {
textAliases: ["/debug"], const trimmed = alias.trim();
acceptsArgs: true, if (!trimmed) continue;
}, if (existing.has(trimmed)) continue;
{ existing.add(trimmed);
key: "cost", command.textAliases.push(trimmed);
nativeName: "cost", }
description: "Toggle per-response usage line.", }
textAliases: ["/cost"],
acceptsArgs: true, export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
}, const commands: ChatCommandDefinition[] = [
{ defineChatCommand({
key: "stop", key: "help",
nativeName: "stop", nativeName: "help",
description: "Stop the current run.", description: "Show available commands.",
textAliases: ["/stop"], textAlias: "/help",
}, }),
{ defineChatCommand({
key: "restart", key: "commands",
nativeName: "restart", nativeName: "commands",
description: "Restart Clawdbot.", description: "List all slash commands.",
textAliases: ["/restart"], textAlias: "/commands",
}, }),
{ defineChatCommand({
key: "activation", key: "status",
nativeName: "activation", nativeName: "status",
description: "Set group activation mode.", description: "Show current status.",
textAliases: ["/activation"], textAlias: "/status",
acceptsArgs: true, }),
}, defineChatCommand({
{ key: "debug",
key: "send", nativeName: "debug",
nativeName: "send", description: "Set runtime debug overrides.",
description: "Set send policy.", textAlias: "/debug",
textAliases: ["/send"], acceptsArgs: true,
acceptsArgs: true, }),
}, defineChatCommand({
{ key: "cost",
key: "reset", nativeName: "cost",
nativeName: "reset", description: "Toggle per-response usage line.",
description: "Reset the current session.", textAlias: "/cost",
textAliases: ["/reset"], acceptsArgs: true,
}, }),
{ defineChatCommand({
key: "new", key: "stop",
nativeName: "new", nativeName: "stop",
description: "Start a new session.", description: "Stop the current run.",
textAliases: ["/new"], textAlias: "/stop",
}, }),
{ defineChatCommand({
key: "think", key: "restart",
nativeName: "think", nativeName: "restart",
description: "Set thinking level.", description: "Restart Clawdbot.",
textAliases: ["/thinking", "/think", "/t"], textAlias: "/restart",
acceptsArgs: true, }),
}, defineChatCommand({
{ key: "activation",
key: "verbose", nativeName: "activation",
nativeName: "verbose", description: "Set group activation mode.",
description: "Toggle verbose mode.", textAlias: "/activation",
textAliases: ["/verbose", "/v"], acceptsArgs: true,
acceptsArgs: true, }),
}, defineChatCommand({
{ key: "send",
key: "reasoning", nativeName: "send",
nativeName: "reasoning", description: "Set send policy.",
description: "Toggle reasoning visibility.", textAlias: "/send",
textAliases: ["/reasoning", "/reason"], acceptsArgs: true,
acceptsArgs: true, }),
}, defineChatCommand({
{ key: "reset",
key: "elevated", nativeName: "reset",
nativeName: "elevated", description: "Reset the current session.",
description: "Toggle elevated mode.", textAlias: "/reset",
textAliases: ["/elevated", "/elev"], }),
acceptsArgs: true, defineChatCommand({
}, key: "new",
{ nativeName: "new",
key: "model", description: "Start a new session.",
nativeName: "model", textAlias: "/new",
description: "Show or set the model.", }),
textAliases: ["/model", "/models"], defineChatCommand({
acceptsArgs: true, key: "think",
}, nativeName: "think",
{ description: "Set thinking level.",
key: "queue", textAlias: "/think",
nativeName: "queue", acceptsArgs: true,
description: "Adjust queue settings.", }),
textAliases: ["/queue"], defineChatCommand({
acceptsArgs: true, 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");
return commands;
})();
const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]); const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]);
type TextAliasSpec = {
canonical: string;
acceptsArgs: boolean;
};
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: let cachedDetection:
| { | {
exact: Set<string>; exact: Set<string>;
@@ -171,11 +234,31 @@ export function buildCommandText(commandName: string, args?: string): string {
export function normalizeCommandBody(raw: string): string { export function normalizeCommandBody(raw: string): string {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed.startsWith("/")) return trimmed; if (!trimmed.startsWith("/")) return trimmed;
const match = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
if (!match) return trimmed; const colonMatch = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
const [, command, rest] = match; const normalized = colonMatch
const normalizedRest = rest.trimStart(); ? (() => {
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`; 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 } { export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {

View File

@@ -144,6 +144,12 @@ describe("directive parsing", () => {
expect(res.cleaned).toBe("thats not /tmp/hello"); 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", () => { it("parses queue options and modes", () => {
const res = extractQueueDirective( const res = extractQueueDirective(
"please /queue steer+backlog debounce:2s cap:5 drop:summarize now", "please /queue steer+backlog debounce:2s cap:5 drop:summarize now",

View File

@@ -249,6 +249,42 @@ describe("directive behavior", () => {
}); });
}); });
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") },
},
);
const payload = Array.isArray(res) ? res[0] : res;
expect(payload?.text).toBe("hello");
expect(payload?.replyToId).toBe("msg-123");
});
});
it("prefers explicit reply_to id over reply_to_current", async () => { it("prefers explicit reply_to id over reply_to_current", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ vi.mocked(runEmbeddedPiAgent).mockResolvedValue({

View File

@@ -242,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 () => { it("reports active auth profile and key snippet in status", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const cfg = makeCfg(home); const cfg = makeCfg(home);
@@ -1240,6 +1257,7 @@ describe("trigger handling", () => {
vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? ""; vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
expect(prompt).toContain("Give me the status"); expect(prompt).toContain("Give me the status");
expect(prompt).not.toContain("/thinking high"); expect(prompt).not.toContain("/thinking high");
expect(prompt).not.toContain("/think high");
}); });
}); });

View File

@@ -170,7 +170,7 @@ export function extractStatusDirective(body?: string): {
hasDirective: boolean; hasDirective: boolean;
} { } {
if (!body) return { cleaned: "", hasDirective: false }; if (!body) return { cleaned: "", hasDirective: false };
return extractSimpleDirective(body, ["status"]); return extractSimpleDirective(body, ["status", "usage"]);
} }
export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel }; export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };

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( export function extractReplyToTag(
text?: string, text?: string,
currentMessageId?: string, currentMessageId?: string,
@@ -7,29 +17,28 @@ export function extractReplyToTag(
hasTag: boolean; hasTag: boolean;
} { } {
if (!text) return { cleaned: "", hasTag: false }; if (!text) return { cleaned: "", hasTag: false };
let cleaned = text;
let replyToId: string | undefined; let sawCurrent = false;
let lastExplicitId: string | undefined;
let hasTag = false; let hasTag = false;
const currentMatch = cleaned.match(/\[\[\s*reply_to_current\s*\]\]/i); const cleaned = normalizeReplyText(
if (currentMatch) { text.replace(REPLY_TAG_RE, (_full, idRaw: string | undefined) => {
cleaned = cleaned.replace(/\[\[\s*reply_to_current\s*\]\]/gi, " "); hasTag = true;
hasTag = true; if (idRaw === undefined) {
if (currentMessageId?.trim()) { sawCurrent = true;
replyToId = currentMessageId.trim(); return " ";
} }
}
const idMatch = cleaned.match(/\[\[\s*reply_to\s*:\s*([^\]\n]+)\s*\]\]/i); const id = idRaw.trim();
if (idMatch?.[1]) { if (id) lastExplicitId = id;
cleaned = cleaned.replace(/\[\[\s*reply_to\s*:\s*[^\]\n]+\s*\]\]/gi, " "); return " ";
replyToId = idMatch[1].trim(); }),
hasTag = true; );
}
const replyToId =
lastExplicitId ??
(sawCurrent ? currentMessageId?.trim() || undefined : undefined);
cleaned = cleaned
.replace(/[ \t]+/g, " ")
.replace(/[ \t]*\n[ \t]*/g, "\n")
.trim();
return { cleaned, replyToId, hasTag }; return { cleaned, replyToId, hasTag };
} }

View File

@@ -1,5 +1,6 @@
import type { Client } from "@buape/carbon"; import type { Client } from "@buape/carbon";
import { ChannelType, MessageType } from "@buape/carbon"; import { ChannelType, MessageType } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMock = vi.fn(); const sendMock = vi.fn();
@@ -119,6 +120,79 @@ describe("discord tool result dispatch", () => {
expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /); expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /);
}, 10000); }, 10000);
it("caches channel info lookups between messages", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: "/tmp/clawd",
},
},
session: { store: "/tmp/clawdbot-sessions.json" },
discord: { dm: { enabled: true, policy: "open" } },
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const handler = createDiscordMessageHandler({
cfg,
discordConfig: cfg.discord,
accountId: "default",
token: "token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
botUserId: "bot-id",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
});
const fetchChannel = vi.fn().mockResolvedValue({
type: ChannelType.DM,
name: "dm",
});
const client = { fetchChannel } as unknown as Client;
const baseMessage = {
content: "hello",
channelId: "cache-channel-1",
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [],
mentionedRoles: [],
author: { id: "u-cache", bot: false, username: "Ada" },
};
await handler(
{
message: { ...baseMessage, id: "m-cache-1" },
author: baseMessage.author,
guild_id: null,
},
client,
);
await handler(
{
message: { ...baseMessage, id: "m-cache-2" },
author: baseMessage.author,
guild_id: null,
},
client,
);
expect(fetchChannel).toHaveBeenCalledTimes(1);
});
it("replies with pairing code and sender id when dmPolicy is pairing", async () => { it("replies with pairing code and sender id when dmPolicy is pairing", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js"); const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = { const cfg = {
@@ -381,6 +455,110 @@ describe("discord tool result dispatch", () => {
expect(capturedCtx?.ThreadLabel).toContain("Discord thread #general"); expect(capturedCtx?.ThreadLabel).toContain("Discord thread #general");
}); });
it("treats forum threads as distinct sessions without channel payloads", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
let capturedCtx:
| {
SessionKey?: string;
ParentSessionKey?: string;
ThreadStarterBody?: string;
ThreadLabel?: string;
}
| undefined;
dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
capturedCtx = ctx;
dispatcher.sendFinalReply({ text: "hi" });
return { queuedFinal: true, counts: { final: 1 } };
});
const cfg = {
agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" },
session: { store: "/tmp/clawdbot-sessions.json" },
discord: {
dm: { enabled: true, policy: "open" },
guilds: { "*": { requireMention: false } },
},
routing: { allowFrom: [] },
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const handler = createDiscordMessageHandler({
cfg,
discordConfig: cfg.discord,
accountId: "default",
token: "token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
botUserId: "bot-id",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
guildEntries: { "*": { requireMention: false } },
});
const fetchChannel = vi
.fn()
.mockResolvedValueOnce({
type: ChannelType.PublicThread,
name: "topic-1",
parentId: "forum-1",
})
.mockResolvedValueOnce({
type: ChannelType.GuildForum,
name: "support",
});
const restGet = vi.fn().mockResolvedValue({
content: "starter message",
author: { id: "u1", username: "Alice", discriminator: "0001" },
timestamp: new Date().toISOString(),
});
const client = {
fetchChannel,
rest: {
get: restGet,
},
} as unknown as Client;
await handler(
{
message: {
id: "m6",
content: "thread reply",
channelId: "t1",
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [],
mentionedRoles: [],
author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" },
},
author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" },
member: { displayName: "Bob" },
guild: { id: "g1", name: "Guild" },
guild_id: "g1",
},
client,
);
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1");
expect(capturedCtx?.ParentSessionKey).toBe(
"agent:main:discord:channel:forum-1",
);
expect(capturedCtx?.ThreadStarterBody).toContain("starter message");
expect(capturedCtx?.ThreadLabel).toContain("Discord thread #support");
expect(restGet).toHaveBeenCalledWith(Routes.channelMessage("t1", "t1"));
});
it("scopes thread sessions to the routed agent", async () => { it("scopes thread sessions to the routed agent", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js"); const { createDiscordMessageHandler } = await import("./monitor.js");

View File

@@ -113,7 +113,20 @@ type DiscordThreadStarter = {
timestamp?: number; timestamp?: number;
}; };
type DiscordChannelInfo = {
type: ChannelType;
name?: string;
topic?: string;
parentId?: string;
};
const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarter>(); const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarter>();
const DISCORD_CHANNEL_INFO_CACHE_TTL_MS = 5 * 60 * 1000;
const DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS = 30 * 1000;
const DISCORD_CHANNEL_INFO_CACHE = new Map<
string,
{ value: DiscordChannelInfo | null; expiresAt: number }
>();
const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 1000; const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 1000;
function logSlowDiscordListener(params: { function logSlowDiscordListener(params: {
@@ -139,14 +152,22 @@ async function resolveDiscordThreadStarter(params: {
channel: DiscordThreadChannel; channel: DiscordThreadChannel;
client: Client; client: Client;
parentId?: string; parentId?: string;
parentType?: ChannelType;
}): Promise<DiscordThreadStarter | null> { }): Promise<DiscordThreadStarter | null> {
const cacheKey = params.channel.id; const cacheKey = params.channel.id;
const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey); const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey);
if (cached) return cached; if (cached) return cached;
try { try {
if (!params.parentId) return null; const parentType = params.parentType;
const isForumParent =
parentType === ChannelType.GuildForum ||
parentType === ChannelType.GuildMedia;
const messageChannelId = isForumParent
? params.channel.id
: params.parentId;
if (!messageChannelId) return null;
const starter = (await params.client.rest.get( const starter = (await params.client.rest.get(
Routes.channelMessage(params.parentId, params.channel.id), Routes.channelMessage(messageChannelId, params.channel.id),
)) as { )) as {
content?: string | null; content?: string | null;
embeds?: Array<{ description?: string | null }>; embeds?: Array<{ description?: string | null }>;
@@ -226,6 +247,14 @@ export type DiscordMessageHandler = (
client: Client, client: Client,
) => Promise<void>; ) => Promise<void>;
function isDiscordThreadType(type: ChannelType | undefined): boolean {
return (
type === ChannelType.PublicThread ||
type === ChannelType.PrivateThread ||
type === ChannelType.AnnouncementThread
);
}
export function resolveDiscordReplyTarget(opts: { export function resolveDiscordReplyTarget(opts: {
replyToMode: ReplyToMode; replyToMode: ReplyToMode;
replyToId?: string; replyToId?: string;
@@ -666,12 +695,33 @@ export function createDiscordMessageHandler(params: {
message.channel && message.channel &&
"isThread" in message.channel && "isThread" in message.channel &&
message.channel.isThread(); message.channel.isThread();
const threadChannel = isThreadChannel const isThreadByType =
isGuildMessage && isDiscordThreadType(channelInfo?.type);
const threadChannel: DiscordThreadChannel | null = isThreadChannel
? (message.channel as DiscordThreadChannel) ? (message.channel as DiscordThreadChannel)
: null; : isThreadByType
? {
id: message.channelId,
name: channelInfo?.name ?? undefined,
parentId: channelInfo?.parentId ?? undefined,
parent: undefined,
}
: null;
const threadParentId = const threadParentId =
threadChannel?.parentId ?? threadChannel?.parent?.id ?? undefined; threadChannel?.parentId ??
const threadParentName = threadChannel?.parent?.name; threadChannel?.parent?.id ??
channelInfo?.parentId ??
undefined;
let threadParentName = threadChannel?.parent?.name;
let threadParentType: ChannelType | undefined;
if (threadChannel && threadParentId) {
const parentInfo = await resolveDiscordChannelInfo(
client,
threadParentId,
);
threadParentName = threadParentName ?? parentInfo?.name;
threadParentType = parentInfo?.type;
}
const threadName = threadChannel?.name; const threadName = threadChannel?.name;
const configChannelName = threadParentName ?? channelName; const configChannelName = threadParentName ?? channelName;
const configChannelSlug = configChannelName const configChannelSlug = configChannelName
@@ -935,6 +985,7 @@ export function createDiscordMessageHandler(params: {
channel: threadChannel, channel: threadChannel,
client, client,
parentId: threadParentId, parentId: threadParentId,
parentType: threadParentType,
}); });
if (starter?.text) { if (starter?.text) {
const starterEnvelope = formatThreadStarterEnvelope({ const starterEnvelope = formatThreadStarterEnvelope({
@@ -1684,15 +1735,42 @@ async function deliverDiscordReply(params: {
async function resolveDiscordChannelInfo( async function resolveDiscordChannelInfo(
client: Client, client: Client,
channelId: string, channelId: string,
): Promise<{ type: ChannelType; name?: string; topic?: string } | null> { ): Promise<DiscordChannelInfo | null> {
const cached = DISCORD_CHANNEL_INFO_CACHE.get(channelId);
if (cached) {
if (cached.expiresAt > Date.now()) return cached.value;
DISCORD_CHANNEL_INFO_CACHE.delete(channelId);
}
try { try {
const channel = await client.fetchChannel(channelId); const channel = await client.fetchChannel(channelId);
if (!channel) return null; if (!channel) {
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: null,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
});
return null;
}
const name = "name" in channel ? (channel.name ?? undefined) : undefined; const name = "name" in channel ? (channel.name ?? undefined) : undefined;
const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined; const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined;
return { type: channel.type, name, topic }; const parentId =
"parentId" in channel ? (channel.parentId ?? undefined) : undefined;
const payload: DiscordChannelInfo = {
type: channel.type,
name,
topic,
parentId,
};
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: payload,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_CACHE_TTL_MS,
});
return payload;
} catch (err) { } catch (err) {
logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`); logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`);
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: null,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
});
return null; return null;
} }
} }

View File

@@ -0,0 +1,32 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { listChatCommands } from "../auto-reply/commands-registry.js";
function extractDocumentedSlashCommands(markdown: string): Set<string> {
const documented = new Set<string>();
for (const match of markdown.matchAll(/`\/(?!<)([a-z0-9_-]+)/gi)) {
documented.add(`/${match[1]}`);
}
return documented;
}
describe("slash commands docs", () => {
it("documents all built-in chat command aliases", async () => {
const docPath = path.join(
process.cwd(),
"docs",
"tools",
"slash-commands.md",
);
const markdown = await fs.readFile(docPath, "utf8");
const documented = extractDocumentedSlashCommands(markdown);
for (const command of listChatCommands()) {
for (const alias of command.textAliases) {
expect(documented.has(alias)).toBe(true);
}
}
});
});

View File

@@ -66,6 +66,16 @@ describe("gateway hooks helpers", () => {
expect(ok.value.sessionKey).toBe("hook:fixed"); expect(ok.value.sessionKey).toBe("hook:fixed");
expect(ok.value.provider).toBe("last"); expect(ok.value.provider).toBe("last");
expect(ok.value.name).toBe("Hook"); expect(ok.value.name).toBe("Hook");
expect(ok.value.deliver).toBe(true);
}
const explicitNoDeliver = normalizeAgentPayload(
{ message: "hello", deliver: false },
{ idFactory: () => "fixed" },
);
expect(explicitNoDeliver.ok).toBe(true);
if (explicitNoDeliver.ok) {
expect(explicitNoDeliver.value.deliver).toBe(false);
} }
const imsg = normalizeAgentPayload( const imsg = normalizeAgentPayload(

View File

@@ -210,7 +210,7 @@ export function normalizeAgentPayload(
if (modelRaw !== undefined && !model) { if (modelRaw !== undefined && !model) {
return { ok: false, error: "model required" }; return { ok: false, error: "model required" };
} }
const deliver = payload.deliver === true; const deliver = payload.deliver !== false;
const thinkingRaw = payload.thinking; const thinkingRaw = payload.thinking;
const thinking = const thinking =
typeof thinkingRaw === "string" && thinkingRaw.trim() typeof thinkingRaw === "string" && thinkingRaw.trim()

View File

@@ -80,6 +80,7 @@ import {
type SessionsPatchResult, type SessionsPatchResult,
} from "./session-utils.js"; } from "./session-utils.js";
import { applySessionsPatchToStore } from "./sessions-patch.js"; import { applySessionsPatchToStore } from "./sessions-patch.js";
import { resolveSessionKeyFromResolveParams } from "./sessions-resolve.js";
import { formatForLog } from "./ws-log.js"; import { formatForLog } from "./ws-log.js";
export type BridgeHandlersContext = { export type BridgeHandlersContext = {
@@ -314,93 +315,20 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
const p = params as SessionsResolveParams; const p = params as SessionsResolveParams;
const cfg = loadConfig(); const cfg = loadConfig();
const resolved = resolveSessionKeyFromResolveParams({ cfg, p });
const key = typeof p.key === "string" ? p.key.trim() : ""; if (!resolved.ok) {
const label = typeof p.label === "string" ? p.label.trim() : "";
const hasKey = key.length > 0;
const hasLabel = label.length > 0;
if (hasKey && hasLabel) {
return { return {
ok: false, ok: false,
error: { error: {
code: ErrorCodes.INVALID_REQUEST, code: resolved.error.code,
message: "Provide either key or label (not both)", message: resolved.error.message,
}, details: resolved.error.details,
};
}
if (!hasKey && !hasLabel) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "Either key or label is required",
},
};
}
if (hasKey) {
const target = resolveGatewaySessionStoreTarget({ cfg, key });
const store = loadSessionStore(target.storePath);
const existingKey = target.storeKeys.find(
(candidate) => store[candidate],
);
if (!existingKey) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `No session found: ${key}`,
},
};
}
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
key: target.canonicalKey,
}),
};
}
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
const list = listSessionsFromStore({
cfg,
storePath,
store,
opts: {
includeGlobal: p.includeGlobal === true,
includeUnknown: p.includeUnknown === true,
label,
agentId: p.agentId,
spawnedBy: p.spawnedBy,
limit: 2,
},
});
if (list.sessions.length === 0) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `No session found with label: ${label}`,
},
};
}
if (list.sessions.length > 1) {
const keys = list.sessions.map((s) => s.key).join(", ");
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `Multiple sessions found with label: ${label} (${keys})`,
}, },
}; };
} }
return { return {
ok: true, ok: true,
payloadJSON: JSON.stringify({ payloadJSON: JSON.stringify({ ok: true, key: resolved.key }),
ok: true,
key: list.sessions[0]?.key,
}),
}; };
} }
case "sessions.patch": { case "sessions.patch": {

View File

@@ -176,7 +176,7 @@ export function createHooksRequestHandler(
name: mapped.action.name ?? "Hook", name: mapped.action.name ?? "Hook",
wakeMode: mapped.action.wakeMode, wakeMode: mapped.action.wakeMode,
sessionKey: mapped.action.sessionKey ?? "", sessionKey: mapped.action.sessionKey ?? "",
deliver: mapped.action.deliver === true, deliver: mapped.action.deliver !== false,
provider: mapped.action.provider ?? "last", provider: mapped.action.provider ?? "last",
to: mapped.action.to, to: mapped.action.to,
model: mapped.action.model, model: mapped.action.model,

View File

@@ -35,6 +35,7 @@ import {
type SessionsPatchResult, type SessionsPatchResult,
} from "../session-utils.js"; } from "../session-utils.js";
import { applySessionsPatchToStore } from "../sessions-patch.js"; import { applySessionsPatchToStore } from "../sessions-patch.js";
import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js";
import type { GatewayRequestHandlers } from "./types.js"; import type { GatewayRequestHandlers } from "./types.js";
export const sessionsHandlers: GatewayRequestHandlers = { export const sessionsHandlers: GatewayRequestHandlers = {
@@ -76,106 +77,12 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const p = params as import("../protocol/index.js").SessionsResolveParams; const p = params as import("../protocol/index.js").SessionsResolveParams;
const cfg = loadConfig(); const cfg = loadConfig();
const key = typeof p.key === "string" ? p.key.trim() : ""; const resolved = resolveSessionKeyFromResolveParams({ cfg, p });
const label = typeof p.label === "string" ? p.label.trim() : ""; if (!resolved.ok) {
const hasKey = key.length > 0; respond(false, undefined, resolved.error);
const hasLabel = label.length > 0;
if (hasKey && hasLabel) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"Provide either key or label (not both)",
),
);
return; return;
} }
if (!hasKey && !hasLabel) { respond(true, { ok: true, key: resolved.key }, undefined);
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"Either key or label is required",
),
);
return;
}
if (hasKey) {
if (!key) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "key required"),
);
return;
}
const target = resolveGatewaySessionStoreTarget({ cfg, key });
const store = loadSessionStore(target.storePath);
const existingKey = target.storeKeys.find(
(candidate) => store[candidate],
);
if (!existingKey) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `No session found: ${key}`),
);
return;
}
respond(true, { ok: true, key: target.canonicalKey }, undefined);
return;
}
if (!label) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "label required"),
);
return;
}
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
const list = listSessionsFromStore({
cfg,
storePath,
store,
opts: {
includeGlobal: p.includeGlobal === true,
includeUnknown: p.includeUnknown === true,
label,
agentId: p.agentId,
spawnedBy: p.spawnedBy,
limit: 2,
},
});
if (list.sessions.length === 0) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`No session found with label: ${label}`,
),
);
return;
}
if (list.sessions.length > 1) {
const keys = list.sessions.map((s) => s.key).join(", ");
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`Multiple sessions found with label: ${label} (${keys})`,
),
);
return;
}
respond(true, { ok: true, key: list.sessions[0]?.key }, undefined);
}, },
"sessions.patch": async ({ params, respond, context }) => { "sessions.patch": async ({ params, respond, context }) => {
if (!validateSessionsPatchParams(params)) { if (!validateSessionsPatchParams(params)) {

View File

@@ -0,0 +1,107 @@
import type { ClawdbotConfig } from "../config/config.js";
import { loadSessionStore } from "../config/sessions.js";
import { parseSessionLabel } from "../sessions/session-label.js";
import {
ErrorCodes,
type ErrorShape,
errorShape,
type SessionsResolveParams,
} from "./protocol/index.js";
import {
listSessionsFromStore,
loadCombinedSessionStoreForGateway,
resolveGatewaySessionStoreTarget,
} from "./session-utils.js";
export type SessionsResolveResult =
| { ok: true; key: string }
| { ok: false; error: ErrorShape };
export function resolveSessionKeyFromResolveParams(params: {
cfg: ClawdbotConfig;
p: SessionsResolveParams;
}): SessionsResolveResult {
const { cfg, p } = params;
const key = typeof p.key === "string" ? p.key.trim() : "";
const hasKey = key.length > 0;
const hasLabel = typeof p.label === "string" && p.label.trim().length > 0;
if (hasKey && hasLabel) {
return {
ok: false,
error: errorShape(
ErrorCodes.INVALID_REQUEST,
"Provide either key or label (not both)",
),
};
}
if (!hasKey && !hasLabel) {
return {
ok: false,
error: errorShape(
ErrorCodes.INVALID_REQUEST,
"Either key or label is required",
),
};
}
if (hasKey) {
const target = resolveGatewaySessionStoreTarget({ cfg, key });
const store = loadSessionStore(target.storePath);
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
if (!existingKey) {
return {
ok: false,
error: errorShape(
ErrorCodes.INVALID_REQUEST,
`No session found: ${key}`,
),
};
}
return { ok: true, key: target.canonicalKey };
}
const parsedLabel = parseSessionLabel(p.label);
if (!parsedLabel.ok) {
return {
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, parsedLabel.error),
};
}
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
const list = listSessionsFromStore({
cfg,
storePath,
store,
opts: {
includeGlobal: p.includeGlobal === true,
includeUnknown: p.includeUnknown === true,
label: parsedLabel.label,
agentId: p.agentId,
spawnedBy: p.spawnedBy,
limit: 2,
},
});
if (list.sessions.length === 0) {
return {
ok: false,
error: errorShape(
ErrorCodes.INVALID_REQUEST,
`No session found with label: ${parsedLabel.label}`,
),
};
}
if (list.sessions.length > 1) {
const keys = list.sessions.map((s) => s.key).join(", ");
return {
ok: false,
error: errorShape(
ErrorCodes.INVALID_REQUEST,
`Multiple sessions found with label: ${parsedLabel.label} (${keys})`,
),
};
}
return { ok: true, key: String(list.sessions[0]?.key ?? "") };
}

View File

@@ -227,6 +227,85 @@ describe("provider usage loading", () => {
); );
}); });
it("prefers claude-cli token for Anthropic usage snapshots", async () => {
await withTempHome(
async () => {
const stateDir = process.env.CLAWDBOT_STATE_DIR;
if (!stateDir) throw new Error("Missing CLAWDBOT_STATE_DIR");
const agentDir = path.join(stateDir, "agents", "main", "agent");
fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 });
fs.writeFileSync(
path.join(agentDir, "auth-profiles.json"),
`${JSON.stringify(
{
version: 1,
profiles: {
"anthropic:default": {
type: "token",
provider: "anthropic",
token: "token-default",
expires: Date.UTC(2100, 0, 1, 0, 0, 0),
},
"anthropic:claude-cli": {
type: "token",
provider: "anthropic",
token: "token-cli",
expires: Date.UTC(2100, 0, 1, 0, 0, 0),
},
},
},
null,
2,
)}\n`,
"utf8",
);
const makeResponse = (status: number, body: unknown): Response => {
const payload =
typeof body === "string" ? body : JSON.stringify(body);
const headers =
typeof body === "string"
? undefined
: { "Content-Type": "application/json" };
return new Response(payload, { status, headers });
};
const mockFetch = vi.fn<
Parameters<typeof fetch>,
ReturnType<typeof fetch>
>(async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
if (url.includes("api.anthropic.com/api/oauth/usage")) {
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers.Authorization).toBe("Bearer token-cli");
return makeResponse(200, {
five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" },
});
}
return makeResponse(404, "not found");
});
const summary = await loadProviderUsageSummary({
now: Date.UTC(2026, 0, 7, 0, 0, 0),
providers: ["anthropic"],
agentDir,
fetch: mockFetch,
});
expect(summary.providers).toHaveLength(1);
expect(summary.providers[0]?.provider).toBe("anthropic");
expect(summary.providers[0]?.windows[0]?.label).toBe("5h");
expect(mockFetch).toHaveBeenCalled();
},
{ prefix: "clawdbot-provider-usage-" },
);
});
it("falls back to claude.ai web usage when OAuth scope is missing", async () => { it("falls back to claude.ai web usage when OAuth scope is missing", async () => {
const cookieSnapshot = process.env.CLAUDE_AI_SESSION_KEY; const cookieSnapshot = process.env.CLAUDE_AI_SESSION_KEY;
process.env.CLAUDE_AI_SESSION_KEY = "sk-ant-web-1"; process.env.CLAUDE_AI_SESSION_KEY = "sk-ant-web-1";

View File

@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { import {
CLAUDE_CLI_PROFILE_ID,
ensureAuthProfileStore, ensureAuthProfileStore,
listProfilesForProvider, listProfilesForProvider,
resolveApiKeyForProfile, resolveApiKeyForProfile,
@@ -802,7 +803,16 @@ async function resolveOAuthToken(params: {
provider: params.provider, provider: params.provider,
}); });
for (const profileId of order) { // Claude CLI creds are the only Anthropic tokens that reliably include the
// `user:profile` scope required for the OAuth usage endpoint.
const candidates =
params.provider === "anthropic" ? [CLAUDE_CLI_PROFILE_ID, ...order] : order;
const deduped: string[] = [];
for (const entry of candidates) {
if (!deduped.includes(entry)) deduped.push(entry);
}
for (const profileId of deduped) {
const cred = store.profiles[profileId]; const cred = store.profiles[profileId];
if (!cred || (cred.type !== "oauth" && cred.type !== "token")) continue; if (!cred || (cred.type !== "oauth" && cred.type !== "token")) continue;
try { try {