Compare commits

..

11 Commits

Author SHA1 Message Date
Peter Steinberger
f2795754e9 fix: normalize Windows argv handling (#1564) (thanks @Takhoffman) 2026-01-24 07:10:23 +00:00
Peter Steinberger
4ab6263762 fix: normalize Windows argv (#1564) (thanks @Takhoffman) 2026-01-24 07:05:56 +00:00
Tak hoffman
d3df691684 CLI: fix Windows node argv stripping 2026-01-24 07:05:12 +00:00
Peter Steinberger
ae48066d28 fix: track TUI agent events for external runs (#1567) (thanks @vignesh07) 2026-01-24 07:00:01 +00:00
Vignesh Natarajan
f56f799990 tui: filter agent events by active chat run id
Agent events are emitted per run; filter against activeChatRunId instead of session id. Adds unit tests for tool + lifecycle events.
2026-01-24 07:00:01 +00:00
Andrii
7e498ab94a anthropic-payload-log mvp
Added a dedicated Anthropic payload logger that writes exact request
JSON (as sent) plus per‑run usage stats (input/output/cache read/write)
to a
  standalone JSONL file, gated by an env flag.

  Changes

  - New logger: src/agents/anthropic-payload-log.ts (writes
logs/anthropic-payload.jsonl under the state dir, optional override via
env).
  - Hooked into embedded runs to wrap the stream function and record
usage: src/agents/pi-embedded-runner/run/attempt.ts.

  How to enable

  - CLAWDBOT_ANTHROPIC_PAYLOAD_LOG=1
  - Optional:
CLAWDBOT_ANTHROPIC_PAYLOAD_LOG_FILE=/path/to/anthropic-payload.jsonl

  What you’ll get (JSONL)

  - stage: "request" with payload (exact Anthropic params) +
payloadDigest
  - stage: "usage" with usage
(input/output/cacheRead/cacheWrite/totalTokens/etc.)

  Notes

  - Usage is taken from the last assistant message in the run; if the
run fails before usage is present, you’ll only see an error field.

  Files touched

  - src/agents/anthropic-payload-log.ts
  - src/agents/pi-embedded-runner/run/attempt.ts

  Tests not run.
2026-01-24 06:43:51 +00:00
Glucksberg
6bd6ae41b1 fix: address code review findings for plugin commands
- Add registry lock during command execution to prevent race conditions
- Add input sanitization for command arguments (defense in depth)
- Validate handler is a function during registration
- Remove redundant case-insensitive regex flag
- Add success logging for command execution
- Simplify handler return type (always returns result now)
- Remove dead code branch in commands-plugin.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:28:22 +00:00
Glucksberg
f648aae440 fix: clear plugin commands on reload to prevent duplicates
Add clearPluginCommands() call in loadClawdbotPlugins() to ensure
previously registered commands are cleaned up before reloading plugins.
This prevents command conflicts during hot-reload scenarios.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:28:22 +00:00
Glucksberg
b56587f26e fix: address code review findings for plugin command API
Blockers fixed:
- Fix documentation: requireAuth defaults to true (not false)
- Add command name validation (must start with letter, alphanumeric only)
- Add reserved commands list to prevent shadowing built-in commands
- Emit diagnostic errors for invalid/duplicate command registration

Other improvements:
- Return user-friendly message for unauthorized commands (instead of silence)
- Sanitize error messages to avoid leaking internal details
- Document acceptsArgs behavior when arguments are provided
- Add notes about reserved commands and validation rules to docs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:28:22 +00:00
Glucksberg
4ee808dbcb feat: add plugin command API for LLM-free auto-reply commands
This adds a new `api.registerCommand()` method to the plugin API, allowing
plugins to register slash commands that execute without invoking the AI agent.

Features:
- Plugin commands are processed before built-in commands and the agent
- Commands can optionally require authorization
- Commands can accept arguments
- Async handlers are supported

Use case: plugins can implement toggle commands (like /tts_on, /tts_off)
that respond immediately without consuming LLM API calls.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:28:22 +00:00
Peter Steinberger
66eec295b8 perf: stabilize system prompt time 2026-01-24 06:24:04 +00:00
25 changed files with 669 additions and 458 deletions

View File

@@ -5,9 +5,9 @@ Docs: https://docs.clawd.bot
## 2026.1.23 (Unreleased)
### Changes
- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits.
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
- Plugins: add LLM-free plugin slash commands and include them in `/commands`. (#1558) Thanks @Glucksberg.
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`.
@@ -20,6 +20,7 @@ Docs: https://docs.clawd.bot
### Fixes
- Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)
- CLI: normalize Windows argv to drop duplicate node.exe entries before commands. (#1564) Thanks @Takhoffman.
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
@@ -55,6 +56,7 @@ Docs: https://docs.clawd.bot
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)
- TUI: track active run ids from chat events so tool/lifecycle updates show for non-TUI runs. (#1567) Thanks @vignesh07.
## 2026.1.22

View File

@@ -66,12 +66,12 @@ To inspect how much each injected file contributes (raw vs injected, truncation,
## Time handling
The system prompt includes a dedicated **Current Date & Time** section when user
time or timezone is known. It is explicit about:
The system prompt includes a dedicated **Current Date & Time** section when the
user timezone is known. To keep the prompt cache-stable, it now only includes
the **time zone** (no dynamic clock or time format).
- The users **local time** (already converted).
- The **time zone** used for the conversion.
- The **time format** (12-hour / 24-hour).
Use `session_status` when the agent needs the current time; the status card
includes a timestamp line.
Configure with:

View File

@@ -7,8 +7,8 @@ read_when:
# Date & Time
Clawdbot defaults to **host-local time for transport timestamps** and **user-local time only in the system prompt**.
Provider timestamps are preserved so tools keep their native semantics.
Clawdbot defaults to **host-local time for transport timestamps** and **user timezone only in the system prompt**.
Provider timestamps are preserved so tools keep their native semantics (current time is available via `session_status`).
## Message envelopes (local by default)
@@ -63,16 +63,16 @@ You can override this behavior:
## System prompt: Current Date & Time
If the user timezone or local time is known, the system prompt includes a dedicated
**Current Date & Time** section:
If the user timezone is known, the system prompt includes a dedicated
**Current Date & Time** section with the **time zone only** (no clock/time format)
to keep prompt caching stable:
```
Thursday, January 15th, 2026 — 3:07 PM (America/Chicago)
Time format: 12-hour
Time zone: America/Chicago
```
If only the timezone is known, we still include the section and instruct the model
to assume UTC for unknown time references.
When the agent needs the current time, use the `session_status` tool; the status
card includes a timestamp line.
## System event lines (local by default)

View File

@@ -551,7 +551,6 @@ Notes:
- Commands are registered globally and work across all channels
- Command names are case-insensitive (`/MyStatus` matches `/mystatus`)
- Command names must start with a letter and contain only letters, numbers, hyphens, and underscores
- Telegram native commands only allow `a-z0-9_` (max 32 chars). Use underscores (not hyphens) if you want a plugin command to appear in Telegrams native command list.
- Reserved command names (like `help`, `status`, `reset`, etc.) cannot be overridden by plugins
- Duplicate command registration across plugins will fail with a diagnostic error

View File

@@ -0,0 +1,208 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { AgentMessage, StreamFn } from "@mariozechner/pi-agent-core";
import type { Api, Model } from "@mariozechner/pi-ai";
import { resolveStateDir } from "../config/paths.js";
import { parseBooleanValue } from "../utils/boolean.js";
import { resolveUserPath } from "../utils.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
type PayloadLogStage = "request" | "usage";
type PayloadLogEvent = {
ts: string;
stage: PayloadLogStage;
runId?: string;
sessionId?: string;
sessionKey?: string;
provider?: string;
modelId?: string;
modelApi?: string | null;
workspaceDir?: string;
payload?: unknown;
usage?: Record<string, unknown>;
error?: string;
payloadDigest?: string;
};
type PayloadLogConfig = {
enabled: boolean;
filePath: string;
};
type PayloadLogWriter = {
filePath: string;
write: (line: string) => void;
};
const writers = new Map<string, PayloadLogWriter>();
const log = createSubsystemLogger("agent/anthropic-payload");
function resolvePayloadLogConfig(env: NodeJS.ProcessEnv): PayloadLogConfig {
const enabled = parseBooleanValue(env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG) ?? false;
const fileOverride = env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG_FILE?.trim();
const filePath = fileOverride
? resolveUserPath(fileOverride)
: path.join(resolveStateDir(env), "logs", "anthropic-payload.jsonl");
return { enabled, filePath };
}
function getWriter(filePath: string): PayloadLogWriter {
const existing = writers.get(filePath);
if (existing) return existing;
const dir = path.dirname(filePath);
const ready = fs.mkdir(dir, { recursive: true }).catch(() => undefined);
let queue = Promise.resolve();
const writer: PayloadLogWriter = {
filePath,
write: (line: string) => {
queue = queue
.then(() => ready)
.then(() => fs.appendFile(filePath, line, "utf8"))
.catch(() => undefined);
},
};
writers.set(filePath, writer);
return writer;
}
function safeJsonStringify(value: unknown): string | null {
try {
return JSON.stringify(value, (_key, val) => {
if (typeof val === "bigint") return val.toString();
if (typeof val === "function") return "[Function]";
if (val instanceof Error) {
return { name: val.name, message: val.message, stack: val.stack };
}
if (val instanceof Uint8Array) {
return { type: "Uint8Array", data: Buffer.from(val).toString("base64") };
}
return val;
});
} catch {
return null;
}
}
function formatError(error: unknown): string {
if (typeof error === "string") return error;
if (error instanceof Error) return error.stack ?? error.message;
return safeJsonStringify(error) ?? "[unknown error]";
}
function digest(value: unknown): string | undefined {
const serialized = safeJsonStringify(value);
if (!serialized) return undefined;
return crypto.createHash("sha256").update(serialized).digest("hex");
}
function isAnthropicModel(model: Model<Api> | undefined | null): boolean {
return (model as { api?: unknown })?.api === "anthropic-messages";
}
function findLastAssistantUsage(messages: AgentMessage[]): Record<string, unknown> | null {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i] as { role?: unknown; usage?: unknown };
if (msg?.role === "assistant" && msg.usage && typeof msg.usage === "object") {
return msg.usage as Record<string, unknown>;
}
}
return null;
}
export type AnthropicPayloadLogger = {
enabled: true;
wrapStreamFn: (streamFn: StreamFn) => StreamFn;
recordUsage: (messages: AgentMessage[], error?: unknown) => void;
};
export function createAnthropicPayloadLogger(params: {
env?: NodeJS.ProcessEnv;
runId?: string;
sessionId?: string;
sessionKey?: string;
provider?: string;
modelId?: string;
modelApi?: string | null;
workspaceDir?: string;
}): AnthropicPayloadLogger | null {
const env = params.env ?? process.env;
const cfg = resolvePayloadLogConfig(env);
if (!cfg.enabled) return null;
const writer = getWriter(cfg.filePath);
const base: Omit<PayloadLogEvent, "ts" | "stage"> = {
runId: params.runId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
provider: params.provider,
modelId: params.modelId,
modelApi: params.modelApi,
workspaceDir: params.workspaceDir,
};
const record = (event: PayloadLogEvent) => {
const line = safeJsonStringify(event);
if (!line) return;
writer.write(`${line}\n`);
};
const wrapStreamFn: AnthropicPayloadLogger["wrapStreamFn"] = (streamFn) => {
const wrapped: StreamFn = (model, context, options) => {
if (!isAnthropicModel(model as Model<Api>)) {
return streamFn(model, context, options);
}
const nextOnPayload = (payload: unknown) => {
record({
...base,
ts: new Date().toISOString(),
stage: "request",
payload,
payloadDigest: digest(payload),
});
options?.onPayload?.(payload);
};
return streamFn(model, context, {
...options,
onPayload: nextOnPayload,
});
};
return wrapped;
};
const recordUsage: AnthropicPayloadLogger["recordUsage"] = (messages, error) => {
const usage = findLastAssistantUsage(messages);
if (!usage) {
if (error) {
record({
...base,
ts: new Date().toISOString(),
stage: "usage",
error: formatError(error),
});
}
return;
}
record({
...base,
ts: new Date().toISOString(),
stage: "usage",
usage,
error: error ? formatError(error) : undefined,
});
log.info("anthropic usage", {
runId: params.runId,
sessionId: params.sessionId,
usage,
});
};
log.info("anthropic payload logger enabled", { filePath: writer.filePath });
return { enabled: true, wrapStreamFn, recordUsage };
}

View File

@@ -20,6 +20,7 @@ import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
import { isSubagentSessionKey } from "../../../routing/session-key.js";
import { resolveUserPath } from "../../../utils.js";
import { createCacheTrace } from "../../cache-trace.js";
import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js";
import { resolveClawdbotAgentDir } from "../../agent-paths.js";
import { resolveSessionAgentIds } from "../../agent-scope.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
@@ -458,6 +459,16 @@ export async function runEmbeddedAttempt(
modelApi: params.model.api,
workspaceDir: params.workspaceDir,
});
const anthropicPayloadLogger = createAnthropicPayloadLogger({
env: process.env,
runId: params.runId,
sessionId: activeSession.sessionId,
sessionKey: params.sessionKey,
provider: params.provider,
modelId: params.modelId,
modelApi: params.model.api,
workspaceDir: params.workspaceDir,
});
// Force a stable streamFn reference so vitest can reliably mock @mariozechner/pi-ai.
activeSession.agent.streamFn = streamSimple;
@@ -478,6 +489,11 @@ export async function runEmbeddedAttempt(
});
activeSession.agent.streamFn = cacheTrace.wrapStreamFn(activeSession.agent.streamFn);
}
if (anthropicPayloadLogger) {
activeSession.agent.streamFn = anthropicPayloadLogger.wrapStreamFn(
activeSession.agent.streamFn,
);
}
try {
const prior = await sanitizeSessionHistory({
@@ -772,6 +788,7 @@ export async function runEmbeddedAttempt(
messages: messagesSnapshot,
note: promptError ? "prompt error" : undefined,
});
anthropicPayloadLogger?.recordUsage(messagesSnapshot, promptError);
// Run agent_end hooks to allow plugins to analyze the conversation
// This is fire-and-forget, so we don't await

View File

@@ -124,7 +124,7 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("Reminder: commit your changes in this workspace after edits.");
});
it("includes user time when provided (12-hour)", () => {
it("includes user timezone when provided (12-hour)", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
userTimezone: "America/Chicago",
@@ -133,11 +133,10 @@ describe("buildAgentSystemPrompt", () => {
});
expect(prompt).toContain("## Current Date & Time");
expect(prompt).toContain("Monday, January 5th, 2026 — 3:26 PM (America/Chicago)");
expect(prompt).toContain("Time format: 12-hour");
expect(prompt).toContain("Time zone: America/Chicago");
});
it("includes user time when provided (24-hour)", () => {
it("includes user timezone when provided (24-hour)", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
userTimezone: "America/Chicago",
@@ -146,11 +145,10 @@ describe("buildAgentSystemPrompt", () => {
});
expect(prompt).toContain("## Current Date & Time");
expect(prompt).toContain("Monday, January 5th, 2026 — 15:26 (America/Chicago)");
expect(prompt).toContain("Time format: 24-hour");
expect(prompt).toContain("Time zone: America/Chicago");
});
it("shows UTC fallback when only timezone is provided", () => {
it("shows timezone when only timezone is provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
userTimezone: "America/Chicago",
@@ -158,9 +156,7 @@ describe("buildAgentSystemPrompt", () => {
});
expect(prompt).toContain("## Current Date & Time");
expect(prompt).toContain(
"Time zone: America/Chicago. Current time unknown; assume UTC for date/time references.",
);
expect(prompt).toContain("Time zone: America/Chicago");
});
it("includes model alias guidance when aliases are provided", () => {

View File

@@ -49,22 +49,9 @@ function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: bool
return ["## User Identity", ownerLine, ""];
}
function buildTimeSection(params: {
userTimezone?: string;
userTime?: string;
userTimeFormat?: ResolvedTimeFormat;
}) {
if (!params.userTimezone && !params.userTime) return [];
return [
"## Current Date & Time",
params.userTime
? `${params.userTime} (${params.userTimezone ?? "unknown"})`
: `Time zone: ${params.userTimezone}. Current time unknown; assume UTC for date/time references.`,
params.userTimeFormat
? `Time format: ${params.userTimeFormat === "24" ? "24-hour" : "12-hour"}`
: "",
"",
];
function buildTimeSection(params: { userTimezone?: string }) {
if (!params.userTimezone) return [];
return ["## Current Date & Time", `Time zone: ${params.userTimezone}`, ""];
}
function buildReplyTagsSection(isMinimal: boolean) {
@@ -212,7 +199,7 @@ export function buildAgentSystemPrompt(params: {
sessions_send: "Send a message to another session/sub-agent",
sessions_spawn: "Spawn a sub-agent session",
session_status:
"Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
"Show a /status-equivalent status card (usage + time + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
image: "Analyze an image with the configured image model",
};
@@ -302,7 +289,6 @@ export function buildAgentSystemPrompt(params: {
: undefined;
const reasoningLevel = params.reasoningLevel ?? "off";
const userTimezone = params.userTimezone?.trim();
const userTime = params.userTime?.trim();
const skillsPrompt = params.skillsPrompt?.trim();
const heartbeatPrompt = params.heartbeatPrompt?.trim();
const heartbeatPromptLine = heartbeatPrompt
@@ -465,8 +451,6 @@ export function buildAgentSystemPrompt(params: {
...buildUserIdentitySection(ownerLine, isMinimal),
...buildTimeSection({
userTimezone,
userTime,
userTimeFormat: params.userTimeFormat,
}),
"## Workspace Files (injected)",
"These user-editable files are loaded by Clawdbot and included below in Project Context.",

View File

@@ -15,6 +15,7 @@ import {
resolveDefaultModelForAgent,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
import { normalizeGroupActivation } from "../../auto-reply/group-activation.js";
import { getFollowupQueueDepth, resolveQueueSettings } from "../../auto-reply/reply/queue.js";
import { buildStatusMessage } from "../../auto-reply/status.js";
@@ -215,7 +216,7 @@ export function createSessionStatusTool(opts?: {
label: "Session Status",
name: "session_status",
description:
"Show a /status-equivalent session status card (usage + cost when available). Use for model-use questions (📊 session_status). Optional: set per-session model override (model=default resets overrides).",
"Show a /status-equivalent session status card (usage + time + cost when available). Use for model-use questions (📊 session_status). Optional: set per-session model override (model=default resets overrides).",
parameters: SessionStatusToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
@@ -324,6 +325,13 @@ export function createSessionStatusTool(opts?: {
resolved.entry.queueDebounceMs ?? resolved.entry.queueCap ?? resolved.entry.queueDrop,
);
const userTimezone = resolveUserTimezone(cfg.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(cfg.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
const timeLine = userTime
? `🕒 Time: ${userTime} (${userTimezone})`
: `🕒 Time zone: ${userTimezone}`;
const agentDefaults = cfg.agents?.defaults ?? {};
const defaultLabel = `${configured.provider}/${configured.model}`;
const agentModel =
@@ -346,6 +354,7 @@ export function createSessionStatusTool(opts?: {
agentDir,
}),
usageLine,
timeLine,
queue: {
mode: queueSettings.mode,
depth: queueDepth,

View File

@@ -8,7 +8,6 @@ import {
listChatCommandsForConfig,
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
normalizeNativeCommandSpecsForSurface,
normalizeCommandBody,
parseCommandArgs,
resolveCommandArgMenu,
@@ -16,18 +15,15 @@ import {
shouldHandleTextCommands,
} from "./commands-registry.js";
import type { ChatCommandDefinition } from "./commands-registry.types.js";
import { clearPluginCommands, registerPluginCommand } from "../plugins/commands.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
beforeEach(() => {
setActivePluginRegistry(createTestRegistry([]));
clearPluginCommands();
});
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
clearPluginCommands();
});
describe("commands registry", () => {
@@ -46,20 +42,6 @@ describe("commands registry", () => {
expect(specs.find((spec) => spec.name === "compact")).toBeFalsy();
});
it("normalizes telegram native command specs", () => {
const specs = [
{ name: "OK", description: "Ok", acceptsArgs: false },
{ name: "bad-name", description: "Bad", acceptsArgs: false },
{ name: "fine_name", description: "Fine", acceptsArgs: false },
{ name: "ok", description: "Dup", acceptsArgs: false },
];
const normalized = normalizeNativeCommandSpecsForSurface({
surface: "telegram",
specs,
});
expect(normalized.map((spec) => spec.name)).toEqual(["ok", "fine_name"]);
});
it("filters commands based on config flags", () => {
const disabled = listChatCommandsForConfig({
commands: { config: false, debug: false },
@@ -103,19 +85,6 @@ describe("commands registry", () => {
expect(native.find((spec) => spec.name === "demo_skill")).toBeTruthy();
});
it("includes plugin commands in native specs", () => {
registerPluginCommand("plugin-core", {
name: "plugstatus",
description: "Plugin status",
handler: () => ({ text: "ok" }),
});
const native = listNativeCommandSpecsForConfig(
{ commands: { config: false, debug: false, native: true } },
{ skillCommands: [] },
);
expect(native.find((spec) => spec.name === "plugstatus")).toBeTruthy();
});
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/commands")).toBe(true);

View File

@@ -1,13 +1,8 @@
import type { ClawdbotConfig } from "../config/types.js";
import type { SkillCommandSpec } from "../agents/skills.js";
import { getChatCommands, getNativeCommandSurfaces } from "./commands-registry.data.js";
import { getPluginCommandSpecs } from "../plugins/commands.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import {
normalizeTelegramCommandName,
TELEGRAM_COMMAND_NAME_PATTERN,
} from "../config/telegram-custom-commands.js";
import type {
ChatCommandDefinition,
CommandArgChoiceContext,
@@ -113,7 +108,7 @@ export function listChatCommandsForConfig(
export function listNativeCommandSpecs(params?: {
skillCommands?: SkillCommandSpec[];
}): NativeCommandSpec[] {
const base = listChatCommands({ skillCommands: params?.skillCommands })
return listChatCommands({ skillCommands: params?.skillCommands })
.filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({
name: command.nativeName ?? command.key,
@@ -121,18 +116,13 @@ export function listNativeCommandSpecs(params?: {
acceptsArgs: Boolean(command.acceptsArgs),
args: command.args,
}));
const pluginSpecs = getPluginCommandSpecs();
if (pluginSpecs.length === 0) return base;
const seen = new Set(base.map((spec) => spec.name.toLowerCase()));
const extras = pluginSpecs.filter((spec) => !seen.has(spec.name.toLowerCase()));
return extras.length > 0 ? [...base, ...extras] : base;
}
export function listNativeCommandSpecsForConfig(
cfg: ClawdbotConfig,
params?: { skillCommands?: SkillCommandSpec[] },
): NativeCommandSpec[] {
const base = listChatCommandsForConfig(cfg, params)
return listChatCommandsForConfig(cfg, params)
.filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({
name: command.nativeName ?? command.key,
@@ -140,42 +130,6 @@ export function listNativeCommandSpecsForConfig(
acceptsArgs: Boolean(command.acceptsArgs),
args: command.args,
}));
const pluginSpecs = getPluginCommandSpecs();
if (pluginSpecs.length === 0) return base;
const seen = new Set(base.map((spec) => spec.name.toLowerCase()));
const extras = pluginSpecs.filter((spec) => !seen.has(spec.name.toLowerCase()));
return extras.length > 0 ? [...base, ...extras] : base;
}
function normalizeNativeCommandNameForSurface(name: string, surface: string): string | null {
const trimmed = name.trim();
if (!trimmed) return null;
if (surface === "telegram") {
const normalized = normalizeTelegramCommandName(trimmed);
if (!normalized) return null;
if (!TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) return null;
return normalized;
}
return trimmed;
}
export function normalizeNativeCommandSpecsForSurface(params: {
surface: string;
specs: NativeCommandSpec[];
}): NativeCommandSpec[] {
const surface = params.surface.toLowerCase();
if (!surface) return params.specs;
const normalized: NativeCommandSpec[] = [];
const seen = new Set<string>();
for (const spec of params.specs) {
const normalizedName = normalizeNativeCommandNameForSurface(spec.name, surface);
if (!normalizedName) continue;
const key = normalizedName.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
normalized.push(normalizedName === spec.name ? spec : { ...spec, name: normalizedName });
}
return normalized;
}
export function findCommandByNativeName(name: string): ChatCommandDefinition | undefined {

View File

@@ -1,53 +0,0 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
import type { HandleCommandsParams } from "./commands-types.js";
import { handlePluginCommand } from "./commands-plugin.js";
describe("handlePluginCommand", () => {
beforeEach(() => {
clearPluginCommands();
});
it("skips plugin commands when text commands are disabled", async () => {
registerPluginCommand("plugin-core", {
name: "ping",
description: "Ping",
handler: () => ({ text: "pong" }),
});
const params = {
command: {
commandBodyNormalized: "/ping",
senderId: "user-1",
channel: "test",
isAuthorizedSender: true,
},
cfg: {} as ClawdbotConfig,
} as HandleCommandsParams;
const result = await handlePluginCommand(params, false);
expect(result).toBeNull();
});
it("executes plugin commands when text commands are enabled", async () => {
registerPluginCommand("plugin-core", {
name: "ping",
description: "Ping",
handler: () => ({ text: "pong" }),
});
const params = {
command: {
commandBodyNormalized: "/ping",
senderId: "user-1",
channel: "test",
isAuthorizedSender: true,
},
cfg: {} as ClawdbotConfig,
} as HandleCommandsParams;
const result = await handlePluginCommand(params, true);
expect(result?.reply?.text).toBe("pong");
});
});

View File

@@ -15,9 +15,8 @@ import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
*/
export const handlePluginCommand: CommandHandler = async (
params,
allowTextCommands,
_allowTextCommands,
): Promise<CommandHandlerResult | null> => {
if (!allowTextCommands) return null;
const { command, cfg } = params;
// Try to match a plugin command

View File

@@ -4,11 +4,9 @@ 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 { clearPluginCommands, registerPluginCommand } from "../plugins/commands.js";
import { buildCommandsMessage, buildHelpMessage, buildStatusMessage } from "./status.js";
afterEach(() => {
clearPluginCommands();
vi.restoreAllMocks();
});
@@ -425,19 +423,6 @@ describe("buildCommandsMessage", () => {
);
expect(text).toContain("/demo_skill - Demo skill");
});
it("includes plugin commands when registered", () => {
registerPluginCommand("plugin-core", {
name: "plugstatus",
description: "Plugin status",
handler: () => ({ text: "ok" }),
});
const text = buildCommandsMessage({
commands: { config: false, debug: false },
} as ClawdbotConfig);
expect(text).toContain("🔌 Plugin commands");
expect(text).toContain("/plugstatus - Plugin status");
});
});
describe("buildHelpMessage", () => {

View File

@@ -22,7 +22,6 @@ import {
} from "../utils/usage-format.js";
import { VERSION } from "../version.js";
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
import { listPluginCommands } from "../plugins/commands.js";
import type { SkillCommandSpec } from "../agents/skills.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
import type { MediaUnderstandingDecision } from "../media-understanding/types.js";
@@ -53,6 +52,7 @@ type StatusArgs = {
resolvedElevated?: ElevatedLevel;
modelAuth?: string;
usageLine?: string;
timeLine?: string;
queue?: QueueStatus;
mediaDecisions?: MediaUnderstandingDecision[];
subagentsLine?: string;
@@ -382,6 +382,7 @@ export function buildStatusMessage(args: StatusArgs): string {
return [
versionLine,
args.timeLine,
modelLine,
usageCostLine,
`📚 ${contextLine}`,
@@ -443,12 +444,5 @@ export function buildCommandsMessage(
const scopeLabel = command.scope === "text" ? " (text-only)" : "";
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
}
const pluginCommands = listPluginCommands();
if (pluginCommands.length > 0) {
lines.push("🔌 Plugin commands");
for (const command of pluginCommands) {
lines.push(`/${command.name} - ${command.description}`);
}
}
return lines.join("\n");
}

View File

@@ -1,4 +1,3 @@
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
@@ -12,6 +11,7 @@ import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.
import { enableConsoleCapture } from "../logging.js";
import { getPrimaryCommand, hasHelpOrVersion } from "./argv.js";
import { tryRouteCli } from "./route.js";
import { normalizeWindowsArgv } from "./windows-argv.js";
export function rewriteUpdateFlagArgv(argv: string[]): string[] {
const index = argv.indexOf("--update");
@@ -23,7 +23,7 @@ export function rewriteUpdateFlagArgv(argv: string[]): string[] {
}
export async function runCli(argv: string[] = process.argv) {
const normalizedArgv = stripWindowsNodeExec(argv);
const normalizedArgv = normalizeWindowsArgv(argv);
loadDotEnv({ quiet: true });
normalizeEnv();
ensureClawdbotCliOnPath();
@@ -59,50 +59,6 @@ export async function runCli(argv: string[] = process.argv) {
await program.parseAsync(parseArgv);
}
function stripWindowsNodeExec(argv: string[]): string[] {
if (process.platform !== "win32") return argv;
const stripControlChars = (value: string): string => {
let out = "";
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
if (code >= 32 && code !== 127) {
out += value[i];
}
}
return out;
};
const normalizeArg = (value: string): string =>
stripControlChars(value)
.replace(/^['"]+|['"]+$/g, "")
.trim();
const normalizeCandidate = (value: string): string =>
normalizeArg(value).replace(/^\\\\\\?\\/, "");
const execPath = normalizeCandidate(process.execPath);
const execPathLower = execPath.toLowerCase();
const execBase = path.basename(execPath).toLowerCase();
const isExecPath = (value: string | undefined): boolean => {
if (!value) return false;
const lower = normalizeCandidate(value).toLowerCase();
return (
lower === execPathLower ||
path.basename(lower) === execBase ||
lower.endsWith("\\node.exe") ||
lower.endsWith("/node.exe") ||
lower.includes("node.exe")
);
};
const filtered = argv.filter((arg, index) => index === 0 || !isExecPath(arg));
if (filtered.length < 3) return filtered;
const cleaned = [...filtered];
if (isExecPath(cleaned[1])) {
cleaned.splice(1, 1);
}
if (isExecPath(cleaned[2])) {
cleaned.splice(2, 1);
}
return cleaned;
}
export function isCliMainModule(): boolean {
return isMainModule({ currentFile: fileURLToPath(import.meta.url) });
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { normalizeWindowsArgv } from "./windows-argv.js";
describe("normalizeWindowsArgv", () => {
const execPath = "C:\\Program Files\\nodejs\\node.exe";
const scriptPath = "C:\\clawdbot\\dist\\entry.js";
it("returns argv unchanged on non-windows platforms", () => {
const argv = [execPath, scriptPath, "status"];
expect(normalizeWindowsArgv(argv, { platform: "darwin", execPath })).toBe(argv);
});
it("removes duplicate node exec at argv[1]", () => {
const argv = [execPath, execPath, scriptPath, "status"];
expect(normalizeWindowsArgv(argv, { platform: "win32", execPath })).toEqual([
execPath,
scriptPath,
"status",
]);
});
it("removes duplicate node exec at argv[2]", () => {
const argv = [execPath, scriptPath, execPath, "gateway", "run"];
expect(normalizeWindowsArgv(argv, { platform: "win32", execPath })).toEqual([
execPath,
scriptPath,
"gateway",
"run",
]);
});
it("keeps url arguments that contain node.exe", () => {
const argv = [execPath, scriptPath, "send", "https://example.com/node.exe"];
expect(normalizeWindowsArgv(argv, { platform: "win32", execPath })).toEqual(argv);
});
it("keeps node.exe paths after the command", () => {
const argv = [execPath, scriptPath, "send", "C:\\Program Files\\nodejs\\node.exe"];
expect(normalizeWindowsArgv(argv, { platform: "win32", execPath })).toEqual(argv);
});
});

55
src/cli/windows-argv.ts Normal file
View File

@@ -0,0 +1,55 @@
import path from "node:path";
import process from "node:process";
type WindowsArgvOptions = {
platform?: NodeJS.Platform;
execPath?: string;
};
export function normalizeWindowsArgv(
argv: string[],
{ platform = process.platform, execPath = process.execPath }: WindowsArgvOptions = {},
): string[] {
if (platform !== "win32") return argv;
if (argv.length < 2) return argv;
const stripControlChars = (value: string): string => {
let out = "";
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
if (code >= 32 && code !== 127) {
out += value[i];
}
}
return out;
};
const normalizeArg = (value: string): string =>
stripControlChars(value)
.replace(/^['"]+|['"]+$/g, "")
.trim();
const normalizeCandidate = (value: string): string =>
normalizeArg(value).replace(/^\\\\\\?\\/, "");
const execPathNormalized = normalizeCandidate(execPath);
const execPathLower = execPathNormalized.toLowerCase();
const execBaseLower = path.basename(execPathLower);
const isNodeExecPath = (value: string | undefined): boolean => {
if (!value) return false;
const normalized = normalizeCandidate(value);
if (!normalized) return false;
if (normalized.includes("://")) return false;
const lower = normalized.toLowerCase();
if (lower === execPathLower || lower === execBaseLower) return true;
if (!lower.endsWith("node.exe")) return false;
return path.isAbsolute(normalized) || normalized.includes("\\") || normalized.includes("/");
};
const next = [...argv];
for (let i = 1; i <= 2 && i < next.length; ) {
if (isNodeExecPath(next[i])) {
next.splice(i, 1);
continue;
}
i += 1;
}
return next;
}

View File

@@ -1,9 +1,9 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import path from "node:path";
import process from "node:process";
import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js";
import { normalizeWindowsArgv } from "./cli/windows-argv.js";
import { isTruthyEnvValue } from "./infra/env.js";
import { attachChildProcessBridge } from "./process/child-process-bridge.js";
@@ -57,65 +57,6 @@ function ensureExperimentalWarningSuppressed(): boolean {
return true;
}
function normalizeWindowsArgv(argv: string[]): string[] {
if (process.platform !== "win32") return argv;
if (argv.length < 2) return argv;
const stripControlChars = (value: string): string => {
let out = "";
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
if (code >= 32 && code !== 127) {
out += value[i];
}
}
return out;
};
const normalizeArg = (value: string): string =>
stripControlChars(value)
.replace(/^['"]+|['"]+$/g, "")
.trim();
const normalizeCandidate = (value: string): string =>
normalizeArg(value).replace(/^\\\\\\?\\/, "");
const execPath = normalizeCandidate(process.execPath);
const execPathLower = execPath.toLowerCase();
const execBase = path.basename(execPath).toLowerCase();
const isExecPath = (value: string | undefined): boolean => {
if (!value) return false;
const lower = normalizeCandidate(value).toLowerCase();
return (
lower === execPathLower ||
path.basename(lower) === execBase ||
lower.endsWith("\\node.exe") ||
lower.endsWith("/node.exe") ||
lower.includes("node.exe")
);
};
const next = [...argv];
for (let i = 1; i <= 3 && i < next.length; ) {
if (isExecPath(next[i])) {
next.splice(i, 1);
continue;
}
i += 1;
}
const filtered = next.filter((arg, index) => index === 0 || !isExecPath(arg));
if (filtered.length < 3) return filtered;
const cleaned = [...filtered];
for (let i = 2; i < cleaned.length; ) {
const arg = cleaned[i];
if (!arg || arg.startsWith("-")) {
i += 1;
continue;
}
if (isExecPath(arg)) {
cleaned.splice(i, 1);
continue;
}
break;
}
return cleaned;
}
process.argv = normalizeWindowsArgv(process.argv);
if (!ensureExperimentalWarningSuppressed()) {

View File

@@ -1,63 +0,0 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import {
clearPluginCommands,
executePluginCommand,
matchPluginCommand,
registerPluginCommand,
validateCommandName,
} from "./commands.js";
describe("validateCommandName", () => {
it("rejects reserved aliases from built-in commands", () => {
const error = validateCommandName("id");
expect(error).toContain("reserved");
});
});
describe("plugin command registry", () => {
beforeEach(() => {
clearPluginCommands();
});
it("normalizes command names for registration and matching", () => {
const result = registerPluginCommand("plugin-core", {
name: " ping ",
description: "Ping",
handler: () => ({ text: "pong" }),
});
expect(result.ok).toBe(true);
const match = matchPluginCommand("/ping");
expect(match?.command.name).toBe("ping");
});
it("blocks registration while a command is executing", async () => {
let nestedResult: { ok: boolean; error?: string } | undefined;
registerPluginCommand("plugin-core", {
name: "outer",
description: "Outer",
handler: () => {
nestedResult = registerPluginCommand("plugin-inner", {
name: "inner",
description: "Inner",
handler: () => ({ text: "ok" }),
});
return { text: "done" };
},
});
await executePluginCommand({
command: matchPluginCommand("/outer")!.command,
senderId: "user-1",
channel: "test",
isAuthorizedSender: true,
commandBody: "/outer",
config: {} as ClawdbotConfig,
});
expect(nestedResult?.ok).toBe(false);
expect(nestedResult?.error).toContain("processing is in progress");
});
});

View File

@@ -6,7 +6,6 @@
*/
import type { ClawdbotConfig } from "../config/config.js";
import { listChatCommands } from "../auto-reply/commands-registry.js";
import type { ClawdbotPluginCommandDefinition, PluginCommandContext } from "./types.js";
import { logVerbose } from "../globals.js";
@@ -17,29 +16,53 @@ type RegisteredPluginCommand = ClawdbotPluginCommandDefinition & {
// Registry of plugin commands
const pluginCommands: Map<string, RegisteredPluginCommand> = new Map();
// Lock counter to prevent modifications during command execution
let registryLockCount = 0;
// Lock to prevent modifications during command execution
let registryLocked = false;
// Maximum allowed length for command arguments (defense in depth)
const MAX_ARGS_LENGTH = 4096;
function getReservedCommands(): Set<string> {
const reserved = new Set<string>();
for (const command of listChatCommands()) {
if (command.nativeName) {
const normalized = command.nativeName.trim().toLowerCase();
if (normalized) reserved.add(normalized);
}
for (const alias of command.textAliases ?? []) {
const trimmed = alias.trim();
if (!trimmed) continue;
const withoutSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed;
const normalized = withoutSlash.trim().toLowerCase();
if (normalized) reserved.add(normalized);
}
}
return reserved;
}
/**
* Reserved command names that plugins cannot override.
* These are built-in commands from commands-registry.data.ts.
*/
const RESERVED_COMMANDS = new Set([
// Core commands
"help",
"commands",
"status",
"whoami",
"context",
// Session management
"stop",
"restart",
"reset",
"new",
"compact",
// Configuration
"config",
"debug",
"allowlist",
"activation",
// Agent control
"skill",
"subagents",
"model",
"models",
"queue",
// Messaging
"send",
// Execution
"bash",
"exec",
// Mode toggles
"think",
"verbose",
"reasoning",
"elevated",
// Billing
"usage",
]);
/**
* Validate a command name.
@@ -59,7 +82,7 @@ export function validateCommandName(name: string): string | null {
}
// Check reserved commands
if (getReservedCommands().has(trimmed)) {
if (RESERVED_COMMANDS.has(trimmed)) {
return `Command name "${trimmed}" is reserved by a built-in command`;
}
@@ -80,7 +103,7 @@ export function registerPluginCommand(
command: ClawdbotPluginCommandDefinition,
): CommandRegistrationResult {
// Prevent registration while commands are being processed
if (registryLockCount > 0) {
if (registryLocked) {
return { ok: false, error: "Cannot register commands while processing is in progress" };
}
@@ -94,8 +117,7 @@ export function registerPluginCommand(
return { ok: false, error: validationError };
}
const normalizedName = command.name.trim();
const key = `/${normalizedName.toLowerCase()}`;
const key = `/${command.name.toLowerCase()}`;
// Check for duplicate registration
if (pluginCommands.has(key)) {
@@ -106,7 +128,7 @@ export function registerPluginCommand(
};
}
pluginCommands.set(key, { ...command, name: normalizedName, pluginId });
pluginCommands.set(key, { ...command, pluginId });
logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`);
return { ok: true };
}
@@ -117,7 +139,6 @@ export function registerPluginCommand(
*/
export function clearPluginCommands(): void {
pluginCommands.clear();
registryLockCount = 0;
}
/**
@@ -169,28 +190,26 @@ function sanitizeArgs(args: string | undefined): string | undefined {
if (!args) return undefined;
// Enforce length limit
const trimmed = args.length > MAX_ARGS_LENGTH ? args.slice(0, MAX_ARGS_LENGTH) : args;
if (args.length > MAX_ARGS_LENGTH) {
return args.slice(0, MAX_ARGS_LENGTH);
}
const stripControlChars = (value: string): string => {
let out = "";
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
if (code === 9 || code === 10) {
out += value[i];
continue;
}
if (code < 32 || code === 127) continue;
out += value[i];
}
return out;
};
// Remove control characters (except newlines and tabs which may be intentional)
let needsSanitize = false;
for (let i = 0; i < trimmed.length; i += 1) {
const code = trimmed.charCodeAt(i);
if (code === 0x09 || code === 0x0a) continue;
if (code < 0x20 || code === 0x7f) {
needsSanitize = true;
break;
}
}
if (!needsSanitize) return trimmed;
let sanitized = "";
for (let i = 0; i < trimmed.length; i += 1) {
const code = trimmed.charCodeAt(i);
if (code === 0x09 || code === 0x0a || (code >= 0x20 && code !== 0x7f)) {
sanitized += trimmed[i];
}
}
return sanitized;
return stripControlChars(args);
}
/**
@@ -232,7 +251,7 @@ export async function executePluginCommand(params: {
};
// Lock registry during execution to prevent concurrent modifications
registryLockCount += 1;
registryLocked = true;
try {
const result = await command.handler(ctx);
logVerbose(
@@ -245,7 +264,7 @@ export async function executePluginCommand(params: {
// Don't leak internal error details - return a safe generic message
return { text: "⚠️ Command failed. Please try again later." };
} finally {
registryLockCount = Math.max(0, registryLockCount - 1);
registryLocked = false;
}
}
@@ -271,11 +290,9 @@ export function listPluginCommands(): Array<{
export function getPluginCommandSpecs(): Array<{
name: string;
description: string;
acceptsArgs: boolean;
}> {
return Array.from(pluginCommands.values()).map((cmd) => ({
name: cmd.name,
description: cmd.description,
acceptsArgs: Boolean(cmd.acceptsArgs),
}));
}

View File

@@ -376,8 +376,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
}
// Register with the plugin command system (validates name and checks for duplicates)
const normalizedCommand = { ...command, name };
const result = registerPluginCommand(record.id, normalizedCommand);
const result = registerPluginCommand(record.id, command);
if (!result.ok) {
pushDiagnostic({
level: "error",
@@ -391,7 +390,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
record.commands.push(name);
registry.commands.push({
pluginId: record.id,
command: normalizedCommand,
command,
source: record.source,
});
};

View File

@@ -6,7 +6,6 @@ import {
findCommandByNativeName,
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
normalizeNativeCommandSpecsForSurface,
parseCommandArgs,
resolveCommandArgMenu,
} from "../auto-reply/commands-registry.js";
@@ -85,28 +84,13 @@ export const registerTelegramNativeCommands = ({
}: RegisterTelegramNativeCommandsParams) => {
const skillCommands =
nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : [];
const rawNativeCommands = nativeEnabled
const nativeCommands = nativeEnabled
? listNativeCommandSpecsForConfig(cfg, { skillCommands })
: [];
const nativeCommands = normalizeNativeCommandSpecsForSurface({
surface: "telegram",
specs: rawNativeCommands,
});
const reservedCommands = new Set(
normalizeNativeCommandSpecsForSurface({
surface: "telegram",
specs: listNativeCommandSpecs(),
}).map((command) => command.name.toLowerCase()),
listNativeCommandSpecs().map((command) => command.name.toLowerCase()),
);
const reservedSkillSpecs = normalizeNativeCommandSpecsForSurface({
surface: "telegram",
specs: skillCommands.map((command) => ({
name: command.name,
description: command.description,
acceptsArgs: true,
})),
});
for (const command of reservedSkillSpecs) {
for (const command of skillCommands) {
reservedCommands.add(command.name.toLowerCase());
}
const customResolution = resolveTelegramCustomCommands({

View File

@@ -0,0 +1,187 @@
import { describe, expect, it, vi } from "vitest";
import { createEventHandlers } from "./tui-event-handlers.js";
import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js";
type MockChatLog = {
startTool: ReturnType<typeof vi.fn>;
updateToolResult: ReturnType<typeof vi.fn>;
addSystem: ReturnType<typeof vi.fn>;
updateAssistant: ReturnType<typeof vi.fn>;
finalizeAssistant: ReturnType<typeof vi.fn>;
};
describe("tui-event-handlers: handleAgentEvent", () => {
const makeState = (overrides?: Partial<TuiStateAccess>): TuiStateAccess => ({
agentDefaultId: "main",
sessionMainKey: "agent:main:main",
sessionScope: "global",
agents: [],
currentAgentId: "main",
currentSessionKey: "agent:main:main",
currentSessionId: "session-1",
activeChatRunId: "run-1",
historyLoaded: true,
sessionInfo: {},
initialSessionApplied: true,
isConnected: true,
autoMessageSent: false,
toolsExpanded: false,
showThinking: false,
connectionStatus: "connected",
activityStatus: "idle",
statusTimeout: null,
lastCtrlCAt: 0,
...overrides,
});
const makeContext = (state: TuiStateAccess) => {
const chatLog: MockChatLog = {
startTool: vi.fn(),
updateToolResult: vi.fn(),
addSystem: vi.fn(),
updateAssistant: vi.fn(),
finalizeAssistant: vi.fn(),
};
const tui = { requestRender: vi.fn() };
const setActivityStatus = vi.fn();
return { chatLog, tui, state, setActivityStatus };
};
it("processes tool events when runId matches activeChatRunId (even if sessionId differs)", () => {
const state = makeState({ currentSessionId: "session-xyz", activeChatRunId: "run-123" });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
// Casts are fine here: TUI runtime shape is larger than we need in unit tests.
chatLog: chatLog as any,
tui: tui as any,
state,
setActivityStatus,
});
const evt: AgentEvent = {
runId: "run-123",
stream: "tool",
data: {
phase: "start",
toolCallId: "tc1",
name: "exec",
args: { command: "echo hi" },
},
};
handleAgentEvent(evt);
expect(chatLog.startTool).toHaveBeenCalledWith("tc1", "exec", { command: "echo hi" });
expect(tui.requestRender).toHaveBeenCalledTimes(1);
});
it("ignores tool events when runId does not match activeChatRunId", () => {
const state = makeState({ activeChatRunId: "run-1" });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
chatLog: chatLog as any,
tui: tui as any,
state,
setActivityStatus,
});
const evt: AgentEvent = {
runId: "run-2",
stream: "tool",
data: { phase: "start", toolCallId: "tc1", name: "exec" },
};
handleAgentEvent(evt);
expect(chatLog.startTool).not.toHaveBeenCalled();
expect(chatLog.updateToolResult).not.toHaveBeenCalled();
expect(tui.requestRender).not.toHaveBeenCalled();
});
it("processes lifecycle events when runId matches activeChatRunId", () => {
const state = makeState({ activeChatRunId: "run-9" });
const { tui, setActivityStatus } = makeContext(state);
const { handleAgentEvent } = createEventHandlers({
chatLog: { startTool: vi.fn(), updateToolResult: vi.fn() } as any,
tui: tui as any,
state,
setActivityStatus,
});
const evt: AgentEvent = {
runId: "run-9",
stream: "lifecycle",
data: { phase: "start" },
};
handleAgentEvent(evt);
expect(setActivityStatus).toHaveBeenCalledWith("running");
expect(tui.requestRender).toHaveBeenCalledTimes(1);
});
it("captures runId from chat events when activeChatRunId is unset", () => {
const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
chatLog: chatLog as any,
tui: tui as any,
state,
setActivityStatus,
});
const chatEvt: ChatEvent = {
runId: "run-42",
sessionKey: state.currentSessionKey,
state: "delta",
message: { content: "hello" },
};
handleChatEvent(chatEvt);
expect(state.activeChatRunId).toBe("run-42");
const agentEvt: AgentEvent = {
runId: "run-42",
stream: "tool",
data: { phase: "start", toolCallId: "tc1", name: "exec" },
};
handleAgentEvent(agentEvt);
expect(chatLog.startTool).toHaveBeenCalledWith("tc1", "exec", undefined);
});
it("clears run mapping when the session changes", () => {
const state = makeState({ activeChatRunId: null });
const { chatLog, tui, setActivityStatus } = makeContext(state);
const { handleChatEvent, handleAgentEvent } = createEventHandlers({
chatLog: chatLog as any,
tui: tui as any,
state,
setActivityStatus,
});
handleChatEvent({
runId: "run-old",
sessionKey: state.currentSessionKey,
state: "delta",
message: { content: "hello" },
});
state.currentSessionKey = "agent:main:other";
state.activeChatRunId = null;
tui.requestRender.mockClear();
handleAgentEvent({
runId: "run-old",
stream: "tool",
data: { phase: "start", toolCallId: "tc2", name: "exec" },
});
expect(chatLog.startTool).not.toHaveBeenCalled();
expect(tui.requestRender).not.toHaveBeenCalled();
});
});

View File

@@ -15,33 +15,58 @@ type EventHandlerContext = {
export function createEventHandlers(context: EventHandlerContext) {
const { chatLog, tui, state, setActivityStatus, refreshSessionInfo } = context;
const finalizedRuns = new Map<string, number>();
const streamAssembler = new TuiStreamAssembler();
const sessionRuns = new Map<string, number>();
let streamAssembler = new TuiStreamAssembler();
let lastSessionKey = state.currentSessionKey;
const pruneRunMap = (runs: Map<string, number>) => {
if (runs.size <= 200) return;
const keepUntil = Date.now() - 10 * 60 * 1000;
for (const [key, ts] of runs) {
if (runs.size <= 150) break;
if (ts < keepUntil) runs.delete(key);
}
if (runs.size > 200) {
for (const key of runs.keys()) {
runs.delete(key);
if (runs.size <= 150) break;
}
}
};
const syncSessionKey = () => {
if (state.currentSessionKey === lastSessionKey) return;
lastSessionKey = state.currentSessionKey;
finalizedRuns.clear();
sessionRuns.clear();
streamAssembler = new TuiStreamAssembler();
};
const noteSessionRun = (runId: string) => {
sessionRuns.set(runId, Date.now());
pruneRunMap(sessionRuns);
};
const noteFinalizedRun = (runId: string) => {
finalizedRuns.set(runId, Date.now());
sessionRuns.delete(runId);
streamAssembler.drop(runId);
if (finalizedRuns.size <= 200) return;
const keepUntil = Date.now() - 10 * 60 * 1000;
for (const [key, ts] of finalizedRuns) {
if (finalizedRuns.size <= 150) break;
if (ts < keepUntil) finalizedRuns.delete(key);
}
if (finalizedRuns.size > 200) {
for (const key of finalizedRuns.keys()) {
finalizedRuns.delete(key);
if (finalizedRuns.size <= 150) break;
}
}
pruneRunMap(finalizedRuns);
};
const handleChatEvent = (payload: unknown) => {
if (!payload || typeof payload !== "object") return;
const evt = payload as ChatEvent;
syncSessionKey();
if (evt.sessionKey !== state.currentSessionKey) return;
if (finalizedRuns.has(evt.runId)) {
if (evt.state === "delta") return;
if (evt.state === "final") return;
}
noteSessionRun(evt.runId);
if (!state.activeChatRunId) {
state.activeChatRunId = evt.runId;
}
if (evt.state === "delta") {
const displayText = streamAssembler.ingestDelta(evt.runId, evt.message, state.showThinking);
if (!displayText) return;
@@ -78,6 +103,7 @@ export function createEventHandlers(context: EventHandlerContext) {
if (evt.state === "aborted") {
chatLog.addSystem("run aborted");
streamAssembler.drop(evt.runId);
sessionRuns.delete(evt.runId);
state.activeChatRunId = null;
setActivityStatus("aborted");
void refreshSessionInfo?.();
@@ -85,6 +111,7 @@ export function createEventHandlers(context: EventHandlerContext) {
if (evt.state === "error") {
chatLog.addSystem(`run error: ${evt.errorMessage ?? "unknown"}`);
streamAssembler.drop(evt.runId);
sessionRuns.delete(evt.runId);
state.activeChatRunId = null;
setActivityStatus("error");
void refreshSessionInfo?.();
@@ -95,7 +122,10 @@ export function createEventHandlers(context: EventHandlerContext) {
const handleAgentEvent = (payload: unknown) => {
if (!payload || typeof payload !== "object") return;
const evt = payload as AgentEvent;
if (!state.currentSessionId || evt.runId !== state.currentSessionId) return;
syncSessionKey();
// Agent events (tool streaming, lifecycle) are emitted per-run. Filter against the
// active chat run id, not the session id.
if (evt.runId !== state.activeChatRunId && !sessionRuns.has(evt.runId)) return;
if (evt.stream === "tool") {
const data = evt.data ?? {};
const phase = asString(data.phase, "");