Compare commits

..
Author SHA1 Message Date
Peter Steinberger 012a83cdb2 fix: avoid logging stale anthropic usage (#1501) (thanks @parubets) 2026-01-24 06:24:46 +00:00
Peter Steinberger e85abaca2b fix: wire anthropic payload log diagnostics (#1501) (thanks @parubets) 2026-01-24 06:24:46 +00:00
Andrii 07bc85b7fb 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:24:46 +00:00
25 changed files with 385 additions and 868 deletions

No files matched your search

+1 -2
View File
@@ -12,6 +12,7 @@ Docs: https://docs.clawd.bot
- 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`.
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.
- Agents: add diagnostics-configured Anthropic payload logging. (#1501) Thanks @parubets.
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
@@ -20,7 +21,6 @@ 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.
@@ -56,7 +56,6 @@ 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
+23
View File
@@ -95,6 +95,29 @@ Console logs are **TTY-aware** and formatted for readability:
Console formatting is controlled by `logging.consoleStyle`.
## Anthropic payload log (debugging)
For Anthropic-only runs, you can enable a dedicated JSONL log that captures the
exact request payload (as sent) plus per-run usage stats. This log includes full
prompt/message data; treat it as sensitive.
```json
{
"diagnostics": {
"anthropicPayloadLog": {
"enabled": true,
"filePath": "/path/to/anthropic-payload.jsonl"
}
}
}
```
Defaults + overrides:
- Default path: `$CLAWDBOT_STATE_DIR/logs/anthropic-payload.jsonl`
- Env enable: `CLAWDBOT_ANTHROPIC_PAYLOAD_LOG=1`
- Env path: `CLAWDBOT_ANTHROPIC_PAYLOAD_LOG_FILE=/path/to/anthropic-payload.jsonl`
## Configuring logging
All logging configuration lives under `logging` in `~/.clawdbot/clawdbot.json`.
-60
View File
@@ -62,7 +62,6 @@ Plugins can register:
- Background services
- Optional config validation
- **Skills** (by listing `skills` directories in the plugin manifest)
- **Auto-reply commands** (execute without invoking the AI agent)
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
@@ -495,65 +494,6 @@ export default function (api) {
}
```
### Register auto-reply commands
Plugins can register custom slash commands that execute **without invoking the
AI agent**. This is useful for toggle commands, status checks, or quick actions
that don't need LLM processing.
```ts
export default function (api) {
api.registerCommand({
name: "mystatus",
description: "Show plugin status",
handler: (ctx) => ({
text: `Plugin is running! Channel: ${ctx.channel}`,
}),
});
}
```
Command handler context:
- `senderId`: The sender's ID (if available)
- `channel`: The channel where the command was sent
- `isAuthorizedSender`: Whether the sender is an authorized user
- `args`: Arguments passed after the command (if `acceptsArgs: true`)
- `commandBody`: The full command text
- `config`: The current Clawdbot config
Command options:
- `name`: Command name (without the leading `/`)
- `description`: Help text shown in command lists
- `acceptsArgs`: Whether the command accepts arguments (default: false). If false and arguments are provided, the command won't match and the message falls through to other handlers
- `requireAuth`: Whether to require authorized sender (default: true)
- `handler`: Function that returns `{ text: string }` (can be async)
Example with authorization and arguments:
```ts
api.registerCommand({
name: "setmode",
description: "Set plugin mode",
acceptsArgs: true,
requireAuth: true,
handler: async (ctx) => {
const mode = ctx.args?.trim() || "default";
await saveMode(mode);
return { text: `Mode set to: ${mode}` };
},
});
```
Notes:
- Plugin commands are processed **before** built-in commands and the AI agent
- 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
- Reserved command names (like `help`, `status`, `reset`, etc.) cannot be overridden by plugins
- Duplicate command registration across plugins will fail with a diagnostic error
### Register background services
```ts
+182
View File
@@ -0,0 +1,182 @@
import { describe, expect, it } from "vitest";
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveUserPath } from "../utils.js";
import { createAnthropicPayloadLogger } from "./anthropic-payload-log.js";
describe("createAnthropicPayloadLogger", () => {
it("returns null when diagnostics payload logging is disabled", () => {
const logger = createAnthropicPayloadLogger({
cfg: {} as ClawdbotConfig,
env: {},
modelApi: "anthropic-messages",
});
expect(logger).toBeNull();
});
it("returns null when model api is not anthropic", () => {
const logger = createAnthropicPayloadLogger({
cfg: {
diagnostics: {
anthropicPayloadLog: {
enabled: true,
},
},
},
env: {},
modelApi: "openai",
writer: {
filePath: "memory",
write: () => undefined,
},
});
expect(logger).toBeNull();
});
it("honors diagnostics config and expands file paths", () => {
const lines: string[] = [];
const logger = createAnthropicPayloadLogger({
cfg: {
diagnostics: {
anthropicPayloadLog: {
enabled: true,
filePath: "~/.clawdbot/logs/anthropic-payload.jsonl",
},
},
},
env: {},
modelApi: "anthropic-messages",
writer: {
filePath: "memory",
write: (line) => lines.push(line),
},
});
expect(logger).not.toBeNull();
expect(logger?.filePath).toBe(resolveUserPath("~/.clawdbot/logs/anthropic-payload.jsonl"));
logger?.recordUsage([
{
role: "assistant",
usage: {
input: 12,
},
} as unknown as {
role: string;
usage: { input: number };
},
]);
const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record<string, unknown>;
expect(event.stage).toBe("usage");
expect(event.usage).toEqual({ input: 12 });
});
it("skips usage when no new assistant message was added", () => {
const lines: string[] = [];
const logger = createAnthropicPayloadLogger({
cfg: {
diagnostics: {
anthropicPayloadLog: {
enabled: true,
},
},
},
env: {},
modelApi: "anthropic-messages",
writer: {
filePath: "memory",
write: (line) => lines.push(line),
},
});
logger?.recordUsage(
[
{
role: "assistant",
usage: {
input: 1,
},
} as unknown as {
role: string;
usage: { input: number };
},
],
undefined,
1,
);
expect(lines.length).toBe(0);
});
it("records request payloads and forwards onPayload", async () => {
const lines: string[] = [];
let forwarded: unknown;
const logger = createAnthropicPayloadLogger({
cfg: {
diagnostics: {
anthropicPayloadLog: {
enabled: true,
},
},
},
env: {},
modelApi: "anthropic-messages",
writer: {
filePath: "memory",
write: (line) => lines.push(line),
},
});
const streamFn = ((_, __, options) => {
options?.onPayload?.({ hello: "world" });
return Promise.resolve(undefined);
}) as StreamFn;
const wrapped = logger?.wrapStreamFn(streamFn);
await wrapped?.(
{ api: "anthropic-messages" } as unknown as { api: string },
{},
{
onPayload: (payload) => {
forwarded = payload;
},
},
);
const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record<string, unknown>;
expect(event.stage).toBe("request");
expect(event.payload).toEqual({ hello: "world" });
expect(event.payloadDigest).toBeTruthy();
expect(forwarded).toEqual({ hello: "world" });
});
it("records errors when usage is missing", () => {
const lines: string[] = [];
const logger = createAnthropicPayloadLogger({
cfg: {
diagnostics: {
anthropicPayloadLog: {
enabled: true,
},
},
},
env: {},
modelApi: "anthropic-messages",
writer: {
filePath: "memory",
write: (line) => lines.push(line),
},
});
logger?.recordUsage([], new Error("boom"));
const event = JSON.parse(lines[0]?.trim() ?? "{}") as Record<string, unknown>;
expect(event.stage).toBe("usage");
expect(event.error).toContain("boom");
});
});
+35 -19
View File
@@ -5,7 +5,9 @@ import path from "node:path";
import type { AgentMessage, StreamFn } from "@mariozechner/pi-agent-core";
import type { Api, Model } from "@mariozechner/pi-ai";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { formatErrorMessage } from "../infra/errors.js";
import { parseBooleanValue } from "../utils/boolean.js";
import { resolveUserPath } from "../utils.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
@@ -33,6 +35,11 @@ type PayloadLogConfig = {
filePath: string;
};
type PayloadLogInit = {
cfg?: ClawdbotConfig;
env?: NodeJS.ProcessEnv;
};
type PayloadLogWriter = {
filePath: string;
write: (line: string) => void;
@@ -41,9 +48,12 @@ type PayloadLogWriter = {
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();
function resolvePayloadLogConfig(params: PayloadLogInit): PayloadLogConfig {
const env = params.env ?? process.env;
const config = params.cfg?.diagnostics?.anthropicPayloadLog;
const envEnabled = parseBooleanValue(env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG);
const enabled = envEnabled ?? config?.enabled ?? false;
const fileOverride = config?.filePath?.trim() || env.CLAWDBOT_ANTHROPIC_PAYLOAD_LOG_FILE?.trim();
const filePath = fileOverride
? resolveUserPath(fileOverride)
: path.join(resolveStateDir(env), "logs", "anthropic-payload.jsonl");
@@ -90,12 +100,6 @@ function safeJsonStringify(value: unknown): string | 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;
@@ -106,8 +110,12 @@ function isAnthropicModel(model: Model<Api> | undefined | null): boolean {
return (model as { api?: unknown })?.api === "anthropic-messages";
}
function findLastAssistantUsage(messages: AgentMessage[]): Record<string, unknown> | null {
function findLastAssistantUsage(
messages: AgentMessage[],
minIndex = 0,
): Record<string, unknown> | null {
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (i < minIndex) break;
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>;
@@ -118,11 +126,13 @@ function findLastAssistantUsage(messages: AgentMessage[]): Record<string, unknow
export type AnthropicPayloadLogger = {
enabled: true;
filePath: string;
wrapStreamFn: (streamFn: StreamFn) => StreamFn;
recordUsage: (messages: AgentMessage[], error?: unknown) => void;
recordUsage: (messages: AgentMessage[], error?: unknown, baselineMessageCount?: number) => void;
};
export function createAnthropicPayloadLogger(params: {
cfg?: ClawdbotConfig;
env?: NodeJS.ProcessEnv;
runId?: string;
sessionId?: string;
@@ -131,12 +141,14 @@ export function createAnthropicPayloadLogger(params: {
modelId?: string;
modelApi?: string | null;
workspaceDir?: string;
writer?: PayloadLogWriter;
}): AnthropicPayloadLogger | null {
const env = params.env ?? process.env;
const cfg = resolvePayloadLogConfig(env);
const cfg = resolvePayloadLogConfig({ env, cfg: params.cfg });
if (!cfg.enabled) return null;
if (params.modelApi !== "anthropic-messages") return null;
const writer = getWriter(cfg.filePath);
const writer = params.writer ?? getWriter(cfg.filePath);
const base: Omit<PayloadLogEvent, "ts" | "stage"> = {
runId: params.runId,
sessionId: params.sessionId,
@@ -176,15 +188,19 @@ export function createAnthropicPayloadLogger(params: {
return wrapped;
};
const recordUsage: AnthropicPayloadLogger["recordUsage"] = (messages, error) => {
const usage = findLastAssistantUsage(messages);
const recordUsage: AnthropicPayloadLogger["recordUsage"] = (
messages,
error,
baselineMessageCount,
) => {
const usage = findLastAssistantUsage(messages, baselineMessageCount ?? 0);
if (!usage) {
if (error) {
record({
...base,
ts: new Date().toISOString(),
stage: "usage",
error: formatError(error),
error: formatErrorMessage(error),
});
}
return;
@@ -194,7 +210,7 @@ export function createAnthropicPayloadLogger(params: {
ts: new Date().toISOString(),
stage: "usage",
usage,
error: error ? formatError(error) : undefined,
error: error ? formatErrorMessage(error) : undefined,
});
log.info("anthropic usage", {
runId: params.runId,
@@ -203,6 +219,6 @@ export function createAnthropicPayloadLogger(params: {
});
};
log.info("anthropic payload logger enabled", { filePath: writer.filePath });
return { enabled: true, wrapStreamFn, recordUsage };
log.info("anthropic payload logger enabled", { filePath: cfg.filePath });
return { enabled: true, filePath: cfg.filePath, wrapStreamFn, recordUsage };
}
+5 -1
View File
@@ -460,6 +460,7 @@ export async function runEmbeddedAttempt(
workspaceDir: params.workspaceDir,
});
const anthropicPayloadLogger = createAnthropicPayloadLogger({
cfg: params.config,
env: process.env,
runId: params.runId,
sessionId: activeSession.sessionId,
@@ -642,6 +643,7 @@ export async function runEmbeddedAttempt(
let messagesSnapshot: AgentMessage[] = [];
let sessionIdUsed = activeSession.sessionId;
let promptStartMessageCount = activeSession.messages.length;
const onAbort = () => {
const reason = params.abortSignal ? getAbortReason(params.abortSignal) : undefined;
const timeout = reason ? isTimeoutError(reason) : false;
@@ -713,6 +715,8 @@ export async function runEmbeddedAttempt(
);
}
promptStartMessageCount = activeSession.messages.length;
try {
// Detect and load images referenced in the prompt for vision-capable models.
// This eliminates the need for an explicit "view" tool call by injecting
@@ -788,7 +792,7 @@ export async function runEmbeddedAttempt(
messages: messagesSnapshot,
note: promptError ? "prompt error" : undefined,
});
anthropicPayloadLogger?.recordUsage(messagesSnapshot, promptError);
anthropicPayloadLogger?.recordUsage(messagesSnapshot, promptError, promptStartMessageCount);
// Run agent_end hooks to allow plugins to analyze the conversation
// This is fire-and-forget, so we don't await
-3
View File
@@ -24,7 +24,6 @@ import {
handleStopCommand,
handleUsageCommand,
} from "./commands-session.js";
import { handlePluginCommand } from "./commands-plugin.js";
import type {
CommandHandler,
CommandHandlerResult,
@@ -32,8 +31,6 @@ import type {
} from "./commands-types.js";
const HANDLERS: CommandHandler[] = [
// Plugin commands are processed first, before built-in commands
handlePluginCommand,
handleBashCommand,
handleActivationCommand,
handleSendPolicyCommand,
-41
View File
@@ -1,41 +0,0 @@
/**
* Plugin Command Handler
*
* Handles commands registered by plugins, bypassing the LLM agent.
* This handler is called before built-in command handlers.
*/
import { matchPluginCommand, executePluginCommand } from "../../plugins/commands.js";
import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
/**
* Handle plugin-registered commands.
* Returns a result if a plugin command was matched and executed,
* or null to continue to the next handler.
*/
export const handlePluginCommand: CommandHandler = async (
params,
_allowTextCommands,
): Promise<CommandHandlerResult | null> => {
const { command, cfg } = params;
// Try to match a plugin command
const match = matchPluginCommand(command.commandBodyNormalized);
if (!match) return null;
// Execute the plugin command (always returns a result)
const result = await executePluginCommand({
command: match.command,
args: match.args,
senderId: command.senderId,
channel: command.channel,
isAuthorizedSender: command.isAuthorizedSender,
commandBody: command.commandBodyNormalized,
config: cfg,
});
return {
shouldContinue: false,
reply: { text: result.text },
};
};
+46 -2
View File
@@ -1,3 +1,4 @@
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
@@ -11,7 +12,6 @@ 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 = normalizeWindowsArgv(argv);
const normalizedArgv = stripWindowsNodeExec(argv);
loadDotEnv({ quiet: true });
normalizeEnv();
ensureClawdbotCliOnPath();
@@ -59,6 +59,50 @@ 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) });
}
-42
View File
@@ -1,42 +0,0 @@
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
View File
@@ -1,55 +0,0 @@
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;
}
+6
View File
@@ -121,6 +121,8 @@ const FIELD_LABELS: Record<string, string> = {
"diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages",
"diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt",
"diagnostics.cacheTrace.includeSystem": "Cache Trace Include System",
"diagnostics.anthropicPayloadLog.enabled": "Anthropic Payload Log Enabled",
"diagnostics.anthropicPayloadLog.filePath": "Anthropic Payload Log File Path",
"agents.list.*.identity.avatar": "Identity Avatar",
"gateway.remote.url": "Remote Gateway URL",
"gateway.remote.sshTarget": "Remote Gateway SSH Target",
@@ -390,6 +392,10 @@ const FIELD_HELP: Record<string, string> = {
"Include full message payloads in trace output (default: true).",
"diagnostics.cacheTrace.includePrompt": "Include prompt text in trace output (default: true).",
"diagnostics.cacheTrace.includeSystem": "Include system prompt in trace output (default: true).",
"diagnostics.anthropicPayloadLog.enabled":
"Log Anthropic request payloads + usage for embedded runs (default: false).",
"diagnostics.anthropicPayloadLog.filePath":
"JSONL output path for Anthropic payload logs (default: $CLAWDBOT_STATE_DIR/logs/anthropic-payload.jsonl).",
"tools.exec.applyPatch.enabled":
"Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.",
"tools.exec.applyPatch.allowModels":
+6
View File
@@ -133,10 +133,16 @@ export type DiagnosticsCacheTraceConfig = {
includeSystem?: boolean;
};
export type DiagnosticsAnthropicPayloadLogConfig = {
enabled?: boolean;
filePath?: string;
};
export type DiagnosticsConfig = {
enabled?: boolean;
otel?: DiagnosticsOtelConfig;
cacheTrace?: DiagnosticsCacheTraceConfig;
anthropicPayloadLog?: DiagnosticsAnthropicPayloadLogConfig;
};
export type WebReconnectConfig = {
+7
View File
@@ -86,6 +86,13 @@ export const ClawdbotSchema = z
})
.strict()
.optional(),
anthropicPayloadLog: z
.object({
enabled: z.boolean().optional(),
filePath: z.string().optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),
+60 -1
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,6 +57,65 @@ 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()) {
@@ -12,7 +12,6 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
httpHandlers: [],
cliRegistrars: [],
services: [],
commands: [],
diagnostics: [],
};
const merged = { ...base, ...overrides };
-1
View File
@@ -140,7 +140,6 @@ const createStubPluginRegistry = (): PluginRegistry => ({
httpHandlers: [],
cliRegistrars: [],
services: [],
commands: [],
diagnostics: [],
});
-298
View File
@@ -1,298 +0,0 @@
/**
* Plugin Command Registry
*
* Manages commands registered by plugins that bypass the LLM agent.
* These commands are processed before built-in commands and before agent invocation.
*/
import type { ClawdbotConfig } from "../config/config.js";
import type { ClawdbotPluginCommandDefinition, PluginCommandContext } from "./types.js";
import { logVerbose } from "../globals.js";
type RegisteredPluginCommand = ClawdbotPluginCommandDefinition & {
pluginId: string;
};
// Registry of plugin commands
const pluginCommands: Map<string, RegisteredPluginCommand> = new Map();
// Lock to prevent modifications during command execution
let registryLocked = false;
// Maximum allowed length for command arguments (defense in depth)
const MAX_ARGS_LENGTH = 4096;
/**
* 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.
* Returns an error message if invalid, or null if valid.
*/
export function validateCommandName(name: string): string | null {
const trimmed = name.trim().toLowerCase();
if (!trimmed) {
return "Command name cannot be empty";
}
// Must start with a letter, contain only letters, numbers, hyphens, underscores
// Note: trimmed is already lowercased, so no need for /i flag
if (!/^[a-z][a-z0-9_-]*$/.test(trimmed)) {
return "Command name must start with a letter and contain only letters, numbers, hyphens, and underscores";
}
// Check reserved commands
if (RESERVED_COMMANDS.has(trimmed)) {
return `Command name "${trimmed}" is reserved by a built-in command`;
}
return null;
}
export type CommandRegistrationResult = {
ok: boolean;
error?: string;
};
/**
* Register a plugin command.
* Returns an error if the command name is invalid or reserved.
*/
export function registerPluginCommand(
pluginId: string,
command: ClawdbotPluginCommandDefinition,
): CommandRegistrationResult {
// Prevent registration while commands are being processed
if (registryLocked) {
return { ok: false, error: "Cannot register commands while processing is in progress" };
}
// Validate handler is a function
if (typeof command.handler !== "function") {
return { ok: false, error: "Command handler must be a function" };
}
const validationError = validateCommandName(command.name);
if (validationError) {
return { ok: false, error: validationError };
}
const key = `/${command.name.toLowerCase()}`;
// Check for duplicate registration
if (pluginCommands.has(key)) {
const existing = pluginCommands.get(key)!;
return {
ok: false,
error: `Command "${command.name}" already registered by plugin "${existing.pluginId}"`,
};
}
pluginCommands.set(key, { ...command, pluginId });
logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`);
return { ok: true };
}
/**
* Clear all registered plugin commands.
* Called during plugin reload.
*/
export function clearPluginCommands(): void {
pluginCommands.clear();
}
/**
* Clear plugin commands for a specific plugin.
*/
export function clearPluginCommandsForPlugin(pluginId: string): void {
for (const [key, cmd] of pluginCommands.entries()) {
if (cmd.pluginId === pluginId) {
pluginCommands.delete(key);
}
}
}
/**
* Check if a command body matches a registered plugin command.
* Returns the command definition and parsed args if matched.
*
* Note: If a command has `acceptsArgs: false` and the user provides arguments,
* the command will not match. This allows the message to fall through to
* built-in handlers or the agent. Document this behavior to plugin authors.
*/
export function matchPluginCommand(
commandBody: string,
): { command: RegisteredPluginCommand; args?: string } | null {
const trimmed = commandBody.trim();
if (!trimmed.startsWith("/")) return null;
// Extract command name and args
const spaceIndex = trimmed.indexOf(" ");
const commandName = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex);
const args = spaceIndex === -1 ? undefined : trimmed.slice(spaceIndex + 1).trim();
const key = commandName.toLowerCase();
const command = pluginCommands.get(key);
if (!command) return null;
// If command doesn't accept args but args were provided, don't match
if (args && !command.acceptsArgs) return null;
return { command, args: args || undefined };
}
/**
* Sanitize command arguments to prevent injection attacks.
* Removes control characters and enforces length limits.
*/
function sanitizeArgs(args: string | undefined): string | undefined {
if (!args) return undefined;
// Enforce length limit
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)
return stripControlChars(args);
}
/**
* Execute a plugin command handler.
*
* Note: Plugin authors should still validate and sanitize ctx.args for their
* specific use case. This function provides basic defense-in-depth sanitization.
*/
export async function executePluginCommand(params: {
command: RegisteredPluginCommand;
args?: string;
senderId?: string;
channel: string;
isAuthorizedSender: boolean;
commandBody: string;
config: ClawdbotConfig;
}): Promise<{ text: string }> {
const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params;
// Check authorization
const requireAuth = command.requireAuth !== false; // Default to true
if (requireAuth && !isAuthorizedSender) {
logVerbose(
`Plugin command /${command.name} blocked: unauthorized sender ${senderId || "<unknown>"}`,
);
return { text: "⚠️ This command requires authorization." };
}
// Sanitize args before passing to handler
const sanitizedArgs = sanitizeArgs(args);
const ctx: PluginCommandContext = {
senderId,
channel,
isAuthorizedSender,
args: sanitizedArgs,
commandBody,
config,
};
// Lock registry during execution to prevent concurrent modifications
registryLocked = true;
try {
const result = await command.handler(ctx);
logVerbose(
`Plugin command /${command.name} executed successfully for ${senderId || "unknown"}`,
);
return { text: result.text };
} catch (err) {
const error = err as Error;
logVerbose(`Plugin command /${command.name} error: ${error.message}`);
// Don't leak internal error details - return a safe generic message
return { text: "⚠️ Command failed. Please try again later." };
} finally {
registryLocked = false;
}
}
/**
* List all registered plugin commands.
* Used for /help and /commands output.
*/
export function listPluginCommands(): Array<{
name: string;
description: string;
pluginId: string;
}> {
return Array.from(pluginCommands.values()).map((cmd) => ({
name: cmd.name,
description: cmd.description,
pluginId: cmd.pluginId,
}));
}
/**
* Get plugin command specs for native command registration (e.g., Telegram).
*/
export function getPluginCommandSpecs(): Array<{
name: string;
description: string;
}> {
return Array.from(pluginCommands.values()).map((cmd) => ({
name: cmd.name,
description: cmd.description,
}));
}
-5
View File
@@ -16,7 +16,6 @@ import {
type NormalizedPluginsConfig,
} from "./config-state.js";
import { initializeGlobalHookRunner } from "./hook-runner-global.js";
import { clearPluginCommands } from "./commands.js";
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
import { createPluginRuntime } from "./runtime/index.js";
import { setActivePluginRegistry } from "./runtime.js";
@@ -148,7 +147,6 @@ function createPluginRecord(params: {
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpHandlers: 0,
hookCount: 0,
configSchema: params.configSchema,
@@ -179,9 +177,6 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
}
}
// Clear previously registered plugin commands before reloading
clearPluginCommands();
const runtime = createPluginRuntime();
const { registry, createApi } = createPluginRegistry({
logger,
-45
View File
@@ -11,7 +11,6 @@ import type {
ClawdbotPluginApi,
ClawdbotPluginChannelRegistration,
ClawdbotPluginCliRegistrar,
ClawdbotPluginCommandDefinition,
ClawdbotPluginHttpHandler,
ClawdbotPluginHookOptions,
ProviderPlugin,
@@ -27,7 +26,6 @@ import type {
PluginHookHandlerMap,
PluginHookRegistration as TypedPluginHookRegistration,
} from "./types.js";
import { registerPluginCommand } from "./commands.js";
import type { PluginRuntime } from "./runtime/types.js";
import type { HookEntry } from "../hooks/types.js";
import path from "node:path";
@@ -79,12 +77,6 @@ export type PluginServiceRegistration = {
source: string;
};
export type PluginCommandRegistration = {
pluginId: string;
command: ClawdbotPluginCommandDefinition;
source: string;
};
export type PluginRecord = {
id: string;
name: string;
@@ -104,7 +96,6 @@ export type PluginRecord = {
gatewayMethods: string[];
cliCommands: string[];
services: string[];
commands: string[];
httpHandlers: number;
hookCount: number;
configSchema: boolean;
@@ -123,7 +114,6 @@ export type PluginRegistry = {
httpHandlers: PluginHttpRegistration[];
cliRegistrars: PluginCliRegistration[];
services: PluginServiceRegistration[];
commands: PluginCommandRegistration[];
diagnostics: PluginDiagnostic[];
};
@@ -145,7 +135,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
httpHandlers: [],
cliRegistrars: [],
services: [],
commands: [],
diagnostics: [],
};
const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {}));
@@ -363,38 +352,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
});
};
const registerCommand = (record: PluginRecord, command: ClawdbotPluginCommandDefinition) => {
const name = command.name.trim();
if (!name) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "command registration missing name",
});
return;
}
// Register with the plugin command system (validates name and checks for duplicates)
const result = registerPluginCommand(record.id, command);
if (!result.ok) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `command registration failed: ${result.error}`,
});
return;
}
record.commands.push(name);
registry.commands.push({
pluginId: record.id,
command,
source: record.source,
});
};
const registerTypedHook = <K extends PluginHookName>(
record: PluginRecord,
hookName: K,
@@ -444,7 +401,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler),
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
registerService: (service) => registerService(record, service),
registerCommand: (command) => registerCommand(record, command),
resolvePath: (input: string) => resolveUserPath(input),
on: (hookName, handler, opts) => registerTypedHook(record, hookName, handler, opts),
};
@@ -460,7 +416,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerGatewayMethod,
registerCli,
registerService,
registerCommand,
registerHook,
registerTypedHook,
};
-1
View File
@@ -11,7 +11,6 @@ const createEmptyRegistry = (): PluginRegistry => ({
httpHandlers: [],
cliRegistrars: [],
services: [],
commands: [],
diagnostics: [],
});
-59
View File
@@ -129,59 +129,6 @@ export type ClawdbotPluginGatewayMethod = {
handler: GatewayRequestHandler;
};
// =============================================================================
// Plugin Commands
// =============================================================================
/**
* Context passed to plugin command handlers.
*/
export type PluginCommandContext = {
/** The sender's identifier (e.g., Telegram user ID) */
senderId?: string;
/** The channel/surface (e.g., "telegram", "discord") */
channel: string;
/** Whether the sender is on the allowlist */
isAuthorizedSender: boolean;
/** Raw command arguments after the command name */
args?: string;
/** The full normalized command body */
commandBody: string;
/** Current clawdbot configuration */
config: ClawdbotConfig;
};
/**
* Result returned by a plugin command handler.
*/
export type PluginCommandResult = {
/** Text response to send back to the user */
text: string;
};
/**
* Handler function for plugin commands.
*/
export type PluginCommandHandler = (
ctx: PluginCommandContext,
) => PluginCommandResult | Promise<PluginCommandResult>;
/**
* Definition for a plugin-registered command.
*/
export type ClawdbotPluginCommandDefinition = {
/** Command name without leading slash (e.g., "tts_on") */
name: string;
/** Description shown in /help and command menus */
description: string;
/** Whether this command accepts arguments */
acceptsArgs?: boolean;
/** Whether only authorized senders can use this command (default: true) */
requireAuth?: boolean;
/** The handler function */
handler: PluginCommandHandler;
};
export type ClawdbotPluginHttpHandler = (
req: IncomingMessage,
res: ServerResponse,
@@ -254,12 +201,6 @@ export type ClawdbotPluginApi = {
registerCli: (registrar: ClawdbotPluginCliRegistrar, opts?: { commands?: string[] }) => void;
registerService: (service: ClawdbotPluginService) => void;
registerProvider: (provider: ProviderPlugin) => void;
/**
* Register a custom command that bypasses the LLM agent.
* Plugin commands are processed before built-in commands and before agent invocation.
* Use this for simple state-toggling or status commands that don't need AI reasoning.
*/
registerCommand: (command: ClawdbotPluginCommandDefinition) => void;
resolvePath: (input: string) => string;
/** Register a lifecycle hook handler */
on: <K extends PluginHookName>(
-1
View File
@@ -19,7 +19,6 @@ export const createTestRegistry = (channels: PluginRegistry["channels"] = []): P
httpHandlers: [],
cliRegistrars: [],
services: [],
commands: [],
diagnostics: [],
});
-187
View File
@@ -1,187 +0,0 @@
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();
});
});
+14 -44
View File
@@ -15,58 +15,33 @@ type EventHandlerContext = {
export function createEventHandlers(context: EventHandlerContext) {
const { chatLog, tui, state, setActivityStatus, refreshSessionInfo } = context;
const finalizedRuns = new Map<string, number>();
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 streamAssembler = new TuiStreamAssembler();
const noteFinalizedRun = (runId: string) => {
finalizedRuns.set(runId, Date.now());
sessionRuns.delete(runId);
streamAssembler.drop(runId);
pruneRunMap(finalizedRuns);
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;
}
}
};
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;
@@ -103,7 +78,6 @@ 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?.();
@@ -111,7 +85,6 @@ 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?.();
@@ -122,10 +95,7 @@ export function createEventHandlers(context: EventHandlerContext) {
const handleAgentEvent = (payload: unknown) => {
if (!payload || typeof payload !== "object") return;
const evt = payload as AgentEvent;
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 (!state.currentSessionId || evt.runId !== state.currentSessionId) return;
if (evt.stream === "tool") {
const data = evt.data ?? {};
const phase = asString(data.phase, "");