Compare commits
11 Commits
feat/plugi
...
pr/windows
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2795754e9 | ||
|
|
4ab6263762 | ||
|
|
d3df691684 | ||
|
|
ae48066d28 | ||
|
|
f56f799990 | ||
|
|
7e498ab94a | ||
|
|
6bd6ae41b1 | ||
|
|
f648aae440 | ||
|
|
b56587f26e | ||
|
|
4ee808dbcb | ||
|
|
66eec295b8 |
@@ -5,6 +5,7 @@ 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.
|
||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
||||
@@ -19,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.
|
||||
@@ -54,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
|
||||
|
||||
|
||||
@@ -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 user’s **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:
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ 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).
|
||||
@@ -494,6 +495,65 @@ 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
|
||||
|
||||
208
src/agents/anthropic-payload-log.ts
Normal file
208
src/agents/anthropic-payload-log.ts
Normal 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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
handleStopCommand,
|
||||
handleUsageCommand,
|
||||
} from "./commands-session.js";
|
||||
import { handlePluginCommand } from "./commands-plugin.js";
|
||||
import type {
|
||||
CommandHandler,
|
||||
CommandHandlerResult,
|
||||
@@ -31,6 +32,8 @@ import type {
|
||||
} from "./commands-types.js";
|
||||
|
||||
const HANDLERS: CommandHandler[] = [
|
||||
// Plugin commands are processed first, before built-in commands
|
||||
handlePluginCommand,
|
||||
handleBashCommand,
|
||||
handleActivationCommand,
|
||||
handleSendPolicyCommand,
|
||||
|
||||
41
src/auto-reply/reply/commands-plugin.ts
Normal file
41
src/auto-reply/reply/commands-plugin.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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 },
|
||||
};
|
||||
};
|
||||
@@ -52,6 +52,7 @@ type StatusArgs = {
|
||||
resolvedElevated?: ElevatedLevel;
|
||||
modelAuth?: string;
|
||||
usageLine?: string;
|
||||
timeLine?: string;
|
||||
queue?: QueueStatus;
|
||||
mediaDecisions?: MediaUnderstandingDecision[];
|
||||
subagentsLine?: string;
|
||||
@@ -381,6 +382,7 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
|
||||
return [
|
||||
versionLine,
|
||||
args.timeLine,
|
||||
modelLine,
|
||||
usageCostLine,
|
||||
`📚 ${contextLine}`,
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
42
src/cli/windows-argv.test.ts
Normal file
42
src/cli/windows-argv.test.ts
Normal 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
55
src/cli/windows-argv.ts
Normal 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;
|
||||
}
|
||||
61
src/entry.ts
61
src/entry.ts
@@ -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()) {
|
||||
|
||||
@@ -12,6 +12,7 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
const merged = { ...base, ...overrides };
|
||||
|
||||
@@ -140,6 +140,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
|
||||
298
src/plugins/commands.ts
Normal file
298
src/plugins/commands.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* 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,
|
||||
}));
|
||||
}
|
||||
@@ -16,6 +16,7 @@ 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";
|
||||
@@ -147,6 +148,7 @@ function createPluginRecord(params: {
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
httpHandlers: 0,
|
||||
hookCount: 0,
|
||||
configSchema: params.configSchema,
|
||||
@@ -177,6 +179,9 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
}
|
||||
}
|
||||
|
||||
// Clear previously registered plugin commands before reloading
|
||||
clearPluginCommands();
|
||||
|
||||
const runtime = createPluginRuntime();
|
||||
const { registry, createApi } = createPluginRegistry({
|
||||
logger,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ClawdbotPluginApi,
|
||||
ClawdbotPluginChannelRegistration,
|
||||
ClawdbotPluginCliRegistrar,
|
||||
ClawdbotPluginCommandDefinition,
|
||||
ClawdbotPluginHttpHandler,
|
||||
ClawdbotPluginHookOptions,
|
||||
ProviderPlugin,
|
||||
@@ -26,6 +27,7 @@ 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";
|
||||
@@ -77,6 +79,12 @@ export type PluginServiceRegistration = {
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type PluginCommandRegistration = {
|
||||
pluginId: string;
|
||||
command: ClawdbotPluginCommandDefinition;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type PluginRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -96,6 +104,7 @@ export type PluginRecord = {
|
||||
gatewayMethods: string[];
|
||||
cliCommands: string[];
|
||||
services: string[];
|
||||
commands: string[];
|
||||
httpHandlers: number;
|
||||
hookCount: number;
|
||||
configSchema: boolean;
|
||||
@@ -114,6 +123,7 @@ export type PluginRegistry = {
|
||||
httpHandlers: PluginHttpRegistration[];
|
||||
cliRegistrars: PluginCliRegistration[];
|
||||
services: PluginServiceRegistration[];
|
||||
commands: PluginCommandRegistration[];
|
||||
diagnostics: PluginDiagnostic[];
|
||||
};
|
||||
|
||||
@@ -135,6 +145,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {}));
|
||||
@@ -352,6 +363,38 @@ 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,
|
||||
@@ -401,6 +444,7 @@ 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),
|
||||
};
|
||||
@@ -416,6 +460,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
registerGatewayMethod,
|
||||
registerCli,
|
||||
registerService,
|
||||
registerCommand,
|
||||
registerHook,
|
||||
registerTypedHook,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ const createEmptyRegistry = (): PluginRegistry => ({
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -129,6 +129,59 @@ 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,
|
||||
@@ -201,6 +254,12 @@ 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>(
|
||||
|
||||
@@ -19,6 +19,7 @@ export const createTestRegistry = (channels: PluginRegistry["channels"] = []): P
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
|
||||
187
src/tui/tui-event-handlers.test.ts
Normal file
187
src/tui/tui-event-handlers.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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, "");
|
||||
|
||||
Reference in New Issue
Block a user