Merge remote-tracking branch 'origin/main' into feature/agent-avatar-support

This commit is contained in:
Peter Steinberger
2026-01-22 06:03:56 +00:00
84 changed files with 1323 additions and 381 deletions

View File

@@ -54,6 +54,7 @@ import { callGatewayTool } from "./tools/gateway.js";
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js";
import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
const DEFAULT_MAX_OUTPUT = clampNumber(
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
@@ -139,7 +140,7 @@ export type { BashSandboxConfig } from "./bash-tools.shared.js";
export type ExecElevatedDefaults = {
enabled: boolean;
allowed: boolean;
defaultLevel: "on" | "off";
defaultLevel: "on" | "off" | "ask" | "full";
};
const execSchema = Type.Object({
@@ -659,6 +660,11 @@ export function createExecTool(
const notifyOnExit = defaults?.notifyOnExit !== false;
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
const approvalRunningNoticeMs = resolveApprovalRunningNoticeMs(defaults?.approvalRunningNoticeMs);
// Derive agentId only when sessionKey is an agent session key.
const parsedAgentSession = parseAgentSessionKey(defaults?.sessionKey);
const agentId =
defaults?.agentId ??
(parsedAgentSession ? resolveAgentIdFromSessionKey(defaults?.sessionKey) : undefined);
return {
name: "exec",
@@ -700,12 +706,23 @@ export function createExecTool(
: clampNumber(params.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000)
: null;
const elevatedDefaults = defaults?.elevated;
const elevatedDefaultOn =
elevatedDefaults?.defaultLevel === "on" &&
elevatedDefaults.enabled &&
elevatedDefaults.allowed;
const elevatedRequested =
typeof params.elevated === "boolean" ? params.elevated : elevatedDefaultOn;
const elevatedDefaultMode =
elevatedDefaults?.defaultLevel === "full"
? "full"
: elevatedDefaults?.defaultLevel === "ask"
? "ask"
: elevatedDefaults?.defaultLevel === "on"
? "ask"
: "off";
const elevatedMode =
typeof params.elevated === "boolean"
? params.elevated
? elevatedDefaultMode === "full"
? "full"
: "ask"
: "off"
: elevatedDefaultMode;
const elevatedRequested = elevatedMode !== "off";
if (elevatedRequested) {
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
const runtime = defaults?.sandbox ? "sandboxed" : "direct";
@@ -761,6 +778,10 @@ export function createExecTool(
const configuredAsk = defaults?.ask ?? "on-miss";
const requestedAsk = normalizeExecAsk(params.ask);
let ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);
const bypassApprovals = elevatedRequested && elevatedMode === "full";
if (bypassApprovals) {
ask = "off";
}
const sandbox = host === "sandbox" ? defaults?.sandbox : undefined;
const rawWorkdir = params.workdir?.trim() || defaults?.cwd || process.cwd();
@@ -799,7 +820,7 @@ export function createExecTool(
if (host === "node") {
const approvals = resolveExecApprovals(
defaults?.agentId,
agentId,
host === "node" ? { security: "allowlist" } : undefined,
);
const hostSecurity = minSecurity(security, approvals.agent.security);
@@ -865,7 +886,7 @@ export function createExecTool(
cwd: workdir,
env: nodeEnv,
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
agentId: defaults?.agentId,
agentId,
sessionKey: defaults?.sessionKey,
approved: approvedByAsk,
approvalDecision: approvalDecision ?? undefined,
@@ -895,9 +916,9 @@ export function createExecTool(
host: "node",
security: hostSecurity,
ask: hostAsk,
agentId: defaults?.agentId,
resolvedPath: undefined,
sessionKey: defaults?.sessionKey,
agentId,
resolvedPath: null,
sessionKey: defaults?.sessionKey ?? null,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
},
)) as { decision?: string } | null;
@@ -1025,8 +1046,8 @@ export function createExecTool(
};
}
if (host === "gateway") {
const approvals = resolveExecApprovals(defaults?.agentId, { security: "allowlist" });
if (host === "gateway" && !bypassApprovals) {
const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
@@ -1060,7 +1081,7 @@ export function createExecTool(
const approvalSlug = createApprovalSlug(approvalId);
const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
const contextKey = `exec:${approvalId}`;
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath;
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath ?? null;
const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000));
const commandText = params.command;
const effectiveTimeout =
@@ -1080,9 +1101,9 @@ export function createExecTool(
host: "gateway",
security: hostSecurity,
ask: hostAsk,
agentId: defaults?.agentId,
agentId,
resolvedPath,
sessionKey: defaults?.sessionKey,
sessionKey: defaults?.sessionKey ?? null,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
},
)) as { decision?: string } | null;
@@ -1123,7 +1144,7 @@ export function createExecTool(
for (const segment of analysis.segments) {
const pattern = segment.resolution?.resolvedPath ?? "";
if (pattern) {
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
addAllowlistEntry(approvals.file, agentId, pattern);
}
}
}
@@ -1152,7 +1173,7 @@ export function createExecTool(
seen.add(match.pattern);
recordAllowlistUse(
approvals.file,
defaults?.agentId,
agentId,
match,
commandText,
resolvedPath ?? undefined,
@@ -1242,7 +1263,7 @@ export function createExecTool(
seen.add(match.pattern);
recordAllowlistUse(
approvals.file,
defaults?.agentId,
agentId,
match,
params.command,
analysis.segments[0]?.resolution?.resolvedPath,

View File

@@ -183,6 +183,8 @@ export function buildSystemPrompt(params: {
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
config: params.config,
agentId: params.agentId,
workspaceDir: params.workspaceDir,
cwd: process.cwd(),
runtime: {
host: "clawdbot",
os: `${os.type()} ${os.release()}`,

View File

@@ -68,6 +68,10 @@ function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
}
function isInstructionsRequiredError(raw: string): boolean {
return /instructions are required/i.test(raw);
}
function toInt(value: string | undefined, fallback: number): number {
const trimmed = value?.trim();
if (!trimmed) return fallback;
@@ -443,6 +447,15 @@ describeLive("live models (profile keys)", () => {
logProgress(`${progressLabel}: skip (chatgpt usage limit)`);
break;
}
if (
allowNotFoundSkip &&
model.provider === "openai-codex" &&
isInstructionsRequiredError(message)
) {
skipped.push({ model: id, reason: message });
logProgress(`${progressLabel}: skip (instructions required)`);
break;
}
logProgress(`${progressLabel}: failed`);
failures.push({ model: id, error: message });
break;

View File

@@ -44,6 +44,8 @@ describe("resolveOpencodeZenModelApi", () => {
expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages");
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses");
expect(resolveOpencodeZenModelApi("alpha-gd4")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("big-pickle")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions");
});

View File

@@ -87,19 +87,19 @@ export function resolveOpencodeZenAlias(modelIdOrAlias: string): string {
}
/**
* OpenCode Zen routes models to different APIs based on model family.
* OpenCode Zen routes models to specific API shapes by family.
*/
export function resolveOpencodeZenModelApi(modelId: string): ModelApi {
const lower = modelId.toLowerCase();
if (lower.startsWith("claude-") || lower.startsWith("minimax") || lower.startsWith("alpha-gd4")) {
if (lower.startsWith("gpt-")) {
return "openai-responses";
}
if (lower.startsWith("claude-") || lower.startsWith("minimax-")) {
return "anthropic-messages";
}
if (lower.startsWith("gemini-")) {
return "google-generative-ai";
}
if (lower.startsWith("gpt-")) {
return "openai-responses";
}
return "openai-completions";
}

View File

@@ -279,6 +279,8 @@ export async function runEmbeddedAttempt(
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
config: params.config,
agentId: sessionAgentId,
workspaceDir: effectiveWorkspace,
cwd: process.cwd(),
runtime: {
host: machineName,
os: `${os.type()} ${os.release()}`,

View File

@@ -76,6 +76,6 @@ export type EmbeddedSandboxInfo = {
allowedControlPorts?: number[];
elevated?: {
allowed: boolean;
defaultLevel: "on" | "off";
defaultLevel: "on" | "off" | "ask" | "full";
};
};

View File

@@ -0,0 +1,106 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { buildSystemPromptParams } from "./system-prompt-params.js";
async function makeTempDir(label: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), `clawdbot-${label}-`));
}
async function makeRepoRoot(root: string): Promise<void> {
await fs.mkdir(path.join(root, ".git"), { recursive: true });
}
function buildParams(params: { config?: ClawdbotConfig; workspaceDir?: string; cwd?: string }) {
return buildSystemPromptParams({
config: params.config,
workspaceDir: params.workspaceDir,
cwd: params.cwd,
runtime: {
host: "host",
os: "os",
arch: "arch",
node: "node",
model: "model",
},
});
}
describe("buildSystemPromptParams repo root", () => {
it("detects repo root from workspaceDir", async () => {
const temp = await makeTempDir("workspace");
const repoRoot = path.join(temp, "repo");
const workspaceDir = path.join(repoRoot, "nested", "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
await makeRepoRoot(repoRoot);
const { runtimeInfo } = buildParams({ workspaceDir });
expect(runtimeInfo.repoRoot).toBe(repoRoot);
});
it("falls back to cwd when workspaceDir has no repo", async () => {
const temp = await makeTempDir("cwd");
const repoRoot = path.join(temp, "repo");
const workspaceDir = path.join(temp, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
await makeRepoRoot(repoRoot);
const { runtimeInfo } = buildParams({ workspaceDir, cwd: repoRoot });
expect(runtimeInfo.repoRoot).toBe(repoRoot);
});
it("uses configured repoRoot when valid", async () => {
const temp = await makeTempDir("config");
const repoRoot = path.join(temp, "config-root");
const workspaceDir = path.join(temp, "workspace");
await fs.mkdir(repoRoot, { recursive: true });
await fs.mkdir(workspaceDir, { recursive: true });
await makeRepoRoot(workspaceDir);
const config: ClawdbotConfig = {
agents: {
defaults: {
repoRoot,
},
},
};
const { runtimeInfo } = buildParams({ config, workspaceDir });
expect(runtimeInfo.repoRoot).toBe(repoRoot);
});
it("ignores invalid repoRoot config and auto-detects", async () => {
const temp = await makeTempDir("invalid");
const repoRoot = path.join(temp, "repo");
const workspaceDir = path.join(repoRoot, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
await makeRepoRoot(repoRoot);
const config: ClawdbotConfig = {
agents: {
defaults: {
repoRoot: path.join(temp, "missing"),
},
},
};
const { runtimeInfo } = buildParams({ config, workspaceDir });
expect(runtimeInfo.repoRoot).toBe(repoRoot);
});
it("returns undefined when no repo is found", async () => {
const workspaceDir = await makeTempDir("norepo");
const { runtimeInfo } = buildParams({ workspaceDir });
expect(runtimeInfo.repoRoot).toBeUndefined();
});
});

View File

@@ -1,3 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import type { ClawdbotConfig } from "../config/config.js";
import {
formatUserTime,
@@ -18,6 +21,7 @@ export type RuntimeInfoInput = {
capabilities?: string[];
/** Supported message actions for the current channel (e.g., react, edit, unsend) */
channelActions?: string[];
repoRoot?: string;
};
export type SystemPromptRuntimeParams = {
@@ -31,7 +35,14 @@ export function buildSystemPromptParams(params: {
config?: ClawdbotConfig;
agentId?: string;
runtime: Omit<RuntimeInfoInput, "agentId">;
workspaceDir?: string;
cwd?: string;
}): SystemPromptRuntimeParams {
const repoRoot = resolveRepoRoot({
config: params.config,
workspaceDir: params.workspaceDir,
cwd: params.cwd,
});
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
@@ -39,9 +50,56 @@ export function buildSystemPromptParams(params: {
runtimeInfo: {
agentId: params.agentId,
...params.runtime,
repoRoot,
},
userTimezone,
userTime,
userTimeFormat,
};
}
function resolveRepoRoot(params: {
config?: ClawdbotConfig;
workspaceDir?: string;
cwd?: string;
}): string | undefined {
const configured = params.config?.agents?.defaults?.repoRoot?.trim();
if (configured) {
try {
const resolved = path.resolve(configured);
const stat = fs.statSync(resolved);
if (stat.isDirectory()) return resolved;
} catch {
// ignore invalid config path
}
}
const candidates = [params.workspaceDir, params.cwd]
.map((value) => value?.trim())
.filter(Boolean) as string[];
const seen = new Set<string>();
for (const candidate of candidates) {
const resolved = path.resolve(candidate);
if (seen.has(resolved)) continue;
seen.add(resolved);
const root = findGitRoot(resolved);
if (root) return root;
}
return undefined;
}
function findGitRoot(startDir: string): string | null {
let current = path.resolve(startDir);
for (let i = 0; i < 12; i += 1) {
const gitPath = path.join(current, ".git");
try {
const stat = fs.statSync(gitPath);
if (stat.isDirectory() || stat.isFile()) return current;
} catch {
// ignore missing .git at this level
}
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return null;
}

View File

@@ -284,6 +284,7 @@ describe("buildAgentSystemPrompt", () => {
{
agentId: "work",
host: "host",
repoRoot: "/repo",
os: "macOS",
arch: "arm64",
node: "v20",
@@ -297,6 +298,7 @@ describe("buildAgentSystemPrompt", () => {
expect(line).toContain("agent=work");
expect(line).toContain("host=host");
expect(line).toContain("repo=/repo");
expect(line).toContain("os=macOS (arm64)");
expect(line).toContain("node=v20");
expect(line).toContain("model=anthropic/claude");
@@ -320,7 +322,7 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("You are running in a sandboxed runtime");
expect(prompt).toContain("Sub-agents stay sandboxed");
expect(prompt).toContain("User can toggle with /elevated on|off.");
expect(prompt).toContain("User can toggle with /elevated on|off|ask|full.");
expect(prompt).toContain("Current elevated level: on");
});

View File

@@ -160,6 +160,7 @@ export function buildAgentSystemPrompt(params: {
defaultModel?: string;
channel?: string;
capabilities?: string[];
repoRoot?: string;
};
messageToolHints?: string[];
sandboxInfo?: {
@@ -175,7 +176,7 @@ export function buildAgentSystemPrompt(params: {
allowedControlPorts?: number[];
elevated?: {
allowed: boolean;
defaultLevel: "on" | "off";
defaultLevel: "on" | "off" | "ask" | "full";
};
};
/** Reaction guidance for the agent (for Telegram minimal/extensive modes). */
@@ -200,7 +201,7 @@ export function buildAgentSystemPrompt(params: {
browser: "Control web browser",
canvas: "Present/eval/snapshot the Canvas",
nodes: "List/describe/notify/camera/screen on paired nodes",
cron: "Manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)",
cron: "Manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
message: "Send messages and channel actions",
gateway: "Restart, apply config, or run updates on the running Clawdbot process",
agents_list: "List agent ids allowed for sessions_spawn",
@@ -351,7 +352,7 @@ export function buildAgentSystemPrompt(params: {
"- browser: control clawd's dedicated browser",
"- canvas: present/eval/snapshot the Canvas",
"- nodes: list/describe/notify/camera/screen on paired nodes",
"- cron: manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)",
"- cron: manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
"- sessions_list: list sessions",
"- sessions_history: fetch session history",
"- sessions_send: send to another session",
@@ -443,12 +444,14 @@ export function buildAgentSystemPrompt(params: {
params.sandboxInfo.elevated?.allowed
? "Elevated exec is available for this session."
: "",
params.sandboxInfo.elevated?.allowed ? "User can toggle with /elevated on|off." : "",
params.sandboxInfo.elevated?.allowed
? "You may also send /elevated on|off when needed."
? "User can toggle with /elevated on|off|ask|full."
: "",
params.sandboxInfo.elevated?.allowed
? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (on runs exec on host; off runs in sandbox).`
? "You may also send /elevated on|off|ask|full when needed."
: "",
params.sandboxInfo.elevated?.allowed
? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (ask runs exec on host with approvals; full auto-approves).`
: "",
]
.filter(Boolean)
@@ -570,6 +573,7 @@ export function buildRuntimeLine(
node?: string;
model?: string;
defaultModel?: string;
repoRoot?: string;
},
runtimeChannel?: string,
runtimeCapabilities: string[] = [],
@@ -578,6 +582,7 @@ export function buildRuntimeLine(
return `Runtime: ${[
runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "",
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
runtimeInfo?.repoRoot ? `repo=${runtimeInfo.repoRoot}` : "",
runtimeInfo?.os
? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}`
: runtimeInfo?.arch

View File

@@ -395,9 +395,9 @@ function buildChatCommands(): ChatCommandDefinition[] {
args: [
{
name: "mode",
description: "on or off",
description: "on, off, ask, or full",
type: "string",
choices: ["on", "off"],
choices: ["on", "off", "ask", "full"],
},
],
argsMenu: "auto",

View File

@@ -18,6 +18,7 @@ describe("formatAgentEnvelope", () => {
host: "mac-mini",
ip: "10.0.0.5",
timestamp: ts,
envelope: { timezone: "utc" },
body: "hello",
});
@@ -26,7 +27,7 @@ describe("formatAgentEnvelope", () => {
expect(body).toBe("[WebChat user1 mac-mini 10.0.0.5 2025-01-02T03:04Z] hello");
});
it("formats timestamps in UTC regardless of local timezone", () => {
it("formats timestamps in local timezone by default", () => {
const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
@@ -39,10 +40,10 @@ describe("formatAgentEnvelope", () => {
process.env.TZ = originalTz;
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello");
expect(body).toMatch(/\[WebChat 2025-01-01 19:04 [^\]]+\] hello/);
});
it("formats timestamps in local timezone when configured", () => {
it("formats timestamps in UTC when configured", () => {
const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
@@ -50,13 +51,13 @@ describe("formatAgentEnvelope", () => {
const body = formatAgentEnvelope({
channel: "WebChat",
timestamp: ts,
envelope: { timezone: "local" },
envelope: { timezone: "utc" },
body: "hello",
});
process.env.TZ = originalTz;
expect(body).toMatch(/\[WebChat 2025-01-01 19:04 [^\]]+\] hello/);
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello");
});
it("formats timestamps in user timezone when configured", () => {

View File

@@ -16,7 +16,7 @@ export type AgentEnvelopeParams = {
export type EnvelopeFormatOptions = {
/**
* "utc" (default), "local", "user", or an explicit IANA timezone string.
* "local" (default), "utc", "user", or an explicit IANA timezone string.
*/
timezone?: string;
/**
@@ -59,7 +59,7 @@ function normalizeEnvelopeOptions(options?: EnvelopeFormatOptions): NormalizedEn
const includeTimestamp = options?.includeTimestamp !== false;
const includeElapsed = options?.includeElapsed !== false;
return {
timezone: options?.timezone?.trim() || "utc",
timezone: options?.timezone?.trim() || "local",
includeTimestamp,
includeElapsed,
userTimezone: options?.userTimezone,
@@ -77,7 +77,7 @@ function resolveExplicitTimezone(value: string): string | undefined {
function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone {
const trimmed = options.timezone?.trim();
if (!trimmed) return { mode: "utc" };
if (!trimmed) return { mode: "local" };
const lowered = trimmed.toLowerCase();
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" };
if (lowered === "local" || lowered === "host") return { mode: "local" };

View File

@@ -219,7 +219,7 @@ describe("directive behavior", () => {
);
const events = drainSystemEvents(MAIN_SESSION_KEY);
expect(events.some((e) => e.includes("Elevated ON"))).toBe(true);
expect(events.some((e) => e.includes("Elevated ASK"))).toBe(true);
});
});
it("queues a system event when toggling reasoning", async () => {

View File

@@ -150,7 +150,7 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
expect(text).toContain("Elevated mode set to ask");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});

View File

@@ -143,7 +143,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current elevated level: on");
expect(text).toContain("Options: on, off.");
expect(text).toContain("Options: on, off, ask, full.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});

View File

@@ -55,6 +55,16 @@ describe("directive parsing", () => {
expect(res.hasDirective).toBe(true);
expect(res.elevatedLevel).toBe("on");
});
it("matches elevated ask", () => {
const res = extractElevatedDirective("/elevated ask please");
expect(res.hasDirective).toBe(true);
expect(res.elevatedLevel).toBe("ask");
});
it("matches elevated full", () => {
const res = extractElevatedDirective("/elevated full please");
expect(res.hasDirective).toBe(true);
expect(res.elevatedLevel).toBe("full");
});
it("matches think at start of line", () => {
const res = extractThinkDirective("/think:high run slow");

View File

@@ -129,7 +129,7 @@ describe("trigger handling", () => {
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
expect(text).toContain("Elevated mode set to ask");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
@@ -223,7 +223,7 @@ describe("trigger handling", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(text).not.toContain("Elevated mode enabled");
expect(text).not.toContain("Elevated mode set to ask");
});
});
});

View File

@@ -184,7 +184,7 @@ describe("trigger handling", () => {
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
expect(text).toContain("Elevated mode set to ask");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
@@ -226,7 +226,7 @@ describe("trigger handling", () => {
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
expect(text).toContain("Elevated mode set to ask");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;

View File

@@ -167,7 +167,7 @@ describe("trigger handling", () => {
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
expect(text).toContain("Elevated mode set to ask");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;

View File

@@ -102,6 +102,8 @@ async function resolveContextReport(
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
config: params.cfg,
agentId: sessionAgentId,
workspaceDir,
cwd: process.cwd(),
runtime: {
host: "unknown",
os: "unknown",
@@ -118,7 +120,7 @@ async function resolveContextReport(
workspaceAccess: "rw" as const,
elevated: {
allowed: params.elevated.allowed,
defaultLevel: params.resolvedElevatedLevel === "off" ? ("off" as const) : ("on" as const),
defaultLevel: (params.resolvedElevatedLevel ?? "off") as "on" | "off" | "ask" | "full",
},
}
: { enabled: false };

View File

@@ -45,7 +45,7 @@ function formatTimestampWithAge(valueMs?: number) {
}
function resolveRequesterSessionKey(params: Parameters<CommandHandler>[0]): string | undefined {
const raw = params.ctx.CommandTargetSessionKey?.trim() || params.sessionKey;
const raw = params.sessionKey?.trim() || params.ctx.CommandTargetSessionKey?.trim();
if (!raw) return undefined;
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
return resolveInternalSessionKey({ key: raw, alias, mainKey });

View File

@@ -215,6 +215,33 @@ describe("handleCommands subagents", () => {
expect(result.reply?.text).toContain("Subagents: none");
});
it("lists subagents for the current command session over the target session", async () => {
resetSubagentRegistryForTests();
addSubagentRunForTests({
runId: "run-1",
childSessionKey: "agent:main:subagent:abc",
requesterSessionKey: "agent:main:slack:slash:U1",
requesterDisplayKey: "agent:main:slack:slash:U1",
task: "do thing",
cleanup: "keep",
createdAt: 1000,
startedAt: 1000,
});
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/subagents list", cfg, {
CommandSource: "native",
CommandTargetSessionKey: "agent:main:main",
});
params.sessionKey = "agent:main:slack:slash:U1";
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Subagents (current session)");
expect(result.reply?.text).toContain("agent:main:subagent:abc");
});
it("omits subagent status line when none exist", async () => {
resetSubagentRegistryForTests();
const cfg = {

View File

@@ -205,7 +205,7 @@ export async function handleDirectiveOnly(params: {
const level = currentElevatedLevel ?? "off";
return {
text: [
withOptions(`Current elevated level: ${level}.`, "on, off"),
withOptions(`Current elevated level: ${level}.`, "on, off, ask, full"),
shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null,
]
.filter(Boolean)
@@ -213,7 +213,7 @@ export async function handleDirectiveOnly(params: {
};
}
return {
text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on.`,
text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on, ask, full.`,
};
}
if (directives.hasElevatedDirective && (!elevatedEnabled || !elevatedAllowed)) {
@@ -426,7 +426,9 @@ export async function handleDirectiveOnly(params: {
parts.push(
directives.elevatedLevel === "off"
? formatDirectiveAck("Elevated mode disabled.")
: formatDirectiveAck("Elevated mode enabled."),
: directives.elevatedLevel === "full"
? formatDirectiveAck("Elevated mode set to full (auto-approve).")
: formatDirectiveAck("Elevated mode set to ask (approvals may still apply)."),
);
if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint());
}

View File

@@ -16,10 +16,15 @@ export const withOptions = (line: string, options: string) =>
export const formatElevatedRuntimeHint = () =>
`${SYSTEM_MARK} Runtime is direct; sandboxing does not apply.`;
export const formatElevatedEvent = (level: ElevatedLevel) =>
level === "on"
? "Elevated ON — exec runs on host; set elevated:false to stay sandboxed."
: "Elevated OFF — exec stays in sandbox.";
export const formatElevatedEvent = (level: ElevatedLevel) => {
if (level === "full") {
return "Elevated FULL — exec runs on host with auto-approval.";
}
if (level === "ask" || level === "on") {
return "Elevated ASK — exec runs on host; approvals may still apply.";
}
return "Elevated OFF — exec stays in sandbox.";
};
export const formatReasoningEvent = (level: ReasoningLevel) => {
if (level === "stream") return "Reasoning STREAM — emit live <think>.";

View File

@@ -5,8 +5,10 @@ import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system
import { prependSystemEvents } from "./session-updates.js";
describe("prependSystemEvents", () => {
it("adds a UTC timestamp to queued system events", async () => {
it("adds a local timestamp to queued system events by default", async () => {
vi.useFakeTimers();
const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
const timestamp = new Date("2026-01-12T20:19:17Z");
vi.setSystemTime(timestamp);
@@ -20,11 +22,10 @@ describe("prependSystemEvents", () => {
prefixedBodyBase: "User: hi",
});
const expectedTimestamp = "2026-01-12T20:19:17Z";
expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`);
expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./);
resetSystemEventsForTest();
process.env.TZ = originalTz;
vi.useRealTimers();
});
});

View File

@@ -1,5 +1,6 @@
import crypto from "node:crypto";
import { resolveUserTimezone } from "../../agents/date-time.js";
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
import type { ClawdbotConfig } from "../../config/config.js";
@@ -27,9 +28,32 @@ export async function prependSystemEvents(params: {
return trimmed;
};
const formatSystemEventTimestamp = (ts: number) => {
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return "unknown-time";
const resolveExplicitTimezone = (value: string): string | undefined => {
try {
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
return value;
} catch {
return undefined;
}
};
const resolveSystemEventTimezone = (cfg: ClawdbotConfig) => {
const raw = cfg.agents?.defaults?.envelopeTimezone?.trim();
if (!raw) return { mode: "local" as const };
const lowered = raw.toLowerCase();
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" as const };
if (lowered === "local" || lowered === "host") return { mode: "local" as const };
if (lowered === "user") {
return {
mode: "iana" as const,
timeZone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone),
};
}
const explicit = resolveExplicitTimezone(raw);
return explicit ? { mode: "iana" as const, timeZone: explicit } : { mode: "local" as const };
};
const formatUtcTimestamp = (date: Date): string => {
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
@@ -39,6 +63,42 @@ export async function prependSystemEvents(params: {
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`;
};
const formatZonedTimestamp = (date: Date, timeZone?: string): string | undefined => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
timeZoneName: "short",
}).formatToParts(date);
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
const yyyy = pick("year");
const mm = pick("month");
const dd = pick("day");
const hh = pick("hour");
const min = pick("minute");
const sec = pick("second");
const tz = [...parts]
.reverse()
.find((part) => part.type === "timeZoneName")
?.value?.trim();
if (!yyyy || !mm || !dd || !hh || !min || !sec) return undefined;
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`;
};
const formatSystemEventTimestamp = (ts: number, cfg: ClawdbotConfig) => {
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return "unknown-time";
const zone = resolveSystemEventTimezone(cfg);
if (zone.mode === "utc") return formatUtcTimestamp(date);
if (zone.mode === "local") return formatZonedTimestamp(date) ?? "unknown-time";
return formatZonedTimestamp(date, zone.timeZone) ?? "unknown-time";
};
const systemLines: string[] = [];
const queued = drainSystemEventEntries(params.sessionKey);
systemLines.push(
@@ -46,7 +106,7 @@ export async function prependSystemEvents(params: {
.map((event) => {
const compacted = compactSystemEvent(event.text);
if (!compacted) return null;
return `[${formatSystemEventTimestamp(event.ts)}] ${compacted}`;
return `[${formatSystemEventTimestamp(event.ts, params.cfg)}] ${compacted}`;
})
.filter((v): v is string => Boolean(v)),
);

View File

@@ -324,7 +324,12 @@ export function buildStatusMessage(args: StatusArgs): string {
const queueDetails = formatQueueDetails(args.queue);
const verboseLabel =
verboseLevel === "full" ? "verbose:full" : verboseLevel === "on" ? "verbose" : null;
const elevatedLabel = elevatedLevel === "on" ? "elevated" : null;
const elevatedLabel =
elevatedLevel && elevatedLevel !== "off"
? elevatedLevel === "on"
? "elevated"
: `elevated:${elevatedLevel}`
: null;
const optionParts = [
`Runtime: ${runtime.label}`,
`Think: ${thinkLevel}`,
@@ -395,7 +400,7 @@ export function buildHelpMessage(cfg?: ClawdbotConfig): string {
"/think <level>",
"/verbose on|full|off",
"/reasoning on|off",
"/elevated on|off",
"/elevated on|off|ask|full",
"/model <id>",
"/usage off|tokens|full",
];

View File

@@ -1,6 +1,7 @@
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
export type VerboseLevel = "off" | "on" | "full";
export type ElevatedLevel = "off" | "on";
export type ElevatedLevel = "off" | "on" | "ask" | "full";
export type ElevatedMode = "off" | "ask" | "full";
export type ReasoningLevel = "off" | "on" | "stream";
export type UsageDisplayLevel = "off" | "tokens" | "full";
@@ -112,10 +113,18 @@ export function normalizeElevatedLevel(raw?: string | null): ElevatedLevel | und
if (!raw) return undefined;
const key = raw.toLowerCase();
if (["off", "false", "no", "0"].includes(key)) return "off";
if (["full", "auto", "auto-approve", "autoapprove"].includes(key)) return "full";
if (["ask", "prompt", "approval", "approve"].includes(key)) return "ask";
if (["on", "true", "yes", "1"].includes(key)) return "on";
return undefined;
}
export function resolveElevatedMode(level?: ElevatedLevel | null): ElevatedMode {
if (!level || level === "off") return "off";
if (level === "full") return "full";
return "ask";
}
// Normalize reasoning visibility flags used to toggle reasoning exposure.
export function normalizeReasoningLevel(raw?: string | null): ReasoningLevel | undefined {
if (!raw) return undefined;

View File

@@ -180,3 +180,11 @@ export function decorateClawdProfile(
// ignore
}
}
export function ensureProfileCleanExit(userDataDir: string) {
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
const prefs = safeReadJson(preferencesPath) ?? {};
setDeep(prefs, ["exit_type"], "Normal");
setDeep(prefs, ["exited_cleanly"], true);
safeWriteJson(preferencesPath, prefs);
}

View File

@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
decorateClawdProfile,
ensureProfileCleanExit,
findChromeExecutableMac,
findChromeExecutableWindows,
isChromeReachable,
@@ -103,6 +104,18 @@ describe("browser chrome profile decoration", () => {
}
});
it("writes clean exit prefs to avoid restore prompts", async () => {
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdbot-chrome-test-"));
try {
ensureProfileCleanExit(userDataDir);
const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
expect(prefs.exit_type).toBe("Normal");
expect(prefs.exited_cleanly).toBe(true);
} finally {
await fsp.rm(userDataDir, { recursive: true, force: true });
}
});
it("is idempotent when rerun on an existing profile", async () => {
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdbot-chrome-test-"));
try {

View File

@@ -13,7 +13,11 @@ import {
type BrowserExecutable,
resolveBrowserExecutableForPlatform,
} from "./chrome.executables.js";
import { decorateClawdProfile, isProfileDecorated } from "./chrome.profile-decoration.js";
import {
decorateClawdProfile,
ensureProfileCleanExit,
isProfileDecorated,
} from "./chrome.profile-decoration.js";
import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
import { DEFAULT_CLAWD_BROWSER_COLOR, DEFAULT_CLAWD_BROWSER_PROFILE_NAME } from "./constants.js";
@@ -26,7 +30,11 @@ export {
findChromeExecutableWindows,
resolveBrowserExecutableForPlatform,
} from "./chrome.executables.js";
export { decorateClawdProfile, isProfileDecorated } from "./chrome.profile-decoration.js";
export {
decorateClawdProfile,
ensureProfileCleanExit,
isProfileDecorated,
} from "./chrome.profile-decoration.js";
function exists(filePath: string) {
try {
@@ -178,6 +186,8 @@ export async function launchClawdChrome(
"--disable-background-networking",
"--disable-component-update",
"--disable-features=Translate,MediaRouter",
"--disable-session-crashed-bubble",
"--hide-crash-restore-bubble",
"--password-store=basic",
];
@@ -246,6 +256,12 @@ export async function launchClawdChrome(
}
}
try {
ensureProfileCleanExit(userDataDir);
} catch (err) {
log.warn(`clawd browser clean-exit prefs failed: ${String(err)}`);
}
const proc = spawnOnce();
// Wait for CDP to come up.
const readyDeadline = Date.now() + 15_000;

View File

@@ -1 +1 @@
27d5aed982d9f110b44e85254877597e49efae61141de480b4e9f254c04131ce
0ae29522de4c48c6b6407290be18b94d7244d4e0036738abd19d93148f2c8cd4

View File

@@ -171,7 +171,7 @@ describe("canvas host", () => {
const ws = new WebSocket(`ws://127.0.0.1:${server.port}${CANVAS_WS_PATH}`);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("ws open timeout")), 2000);
const timer = setTimeout(() => reject(new Error("ws open timeout")), 5000);
ws.on("open", () => {
clearTimeout(timer);
resolve();
@@ -183,13 +183,14 @@ describe("canvas host", () => {
});
const msg = new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("reload timeout")), 4000);
const timer = setTimeout(() => reject(new Error("reload timeout")), 10_000);
ws.on("message", (data) => {
clearTimeout(timer);
resolve(rawDataToString(data));
});
});
await new Promise((resolve) => setTimeout(resolve, 100));
await fs.writeFile(index, "<html><body>v2</body></html>", "utf8");
expect(await msg).toBe("reload");
ws.close();
@@ -197,7 +198,7 @@ describe("canvas host", () => {
await server.close();
await fs.rm(dir, { recursive: true, force: true });
}
}, 10_000);
}, 20_000);
it("serves the gateway-hosted A2UI scaffold", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-canvas-"));

View File

@@ -44,6 +44,14 @@ const entries: SubCliEntry[] = [
mod.registerGatewayCli(program);
},
},
{
name: "daemon",
description: "Gateway service (legacy alias)",
register: async (program) => {
const mod = await import("../daemon-cli.js");
mod.registerDaemonCli(program);
},
},
{
name: "logs",
description: "Gateway logs",

View File

@@ -68,8 +68,12 @@ const STEP_LABELS: Record<string, string> = {
"clean check": "Working directory is clean",
"upstream check": "Upstream branch exists",
"git fetch": "Fetching latest changes",
"git rebase": "Rebasing onto upstream",
"git rebase": "Rebasing onto target commit",
"git rev-parse @{upstream}": "Resolving upstream commit",
"git rev-list": "Enumerating candidate commits",
"git clone": "Cloning git checkout",
"preflight worktree": "Preparing preflight worktree",
"preflight cleanup": "Cleaning preflight worktree",
"deps install": "Installing dependencies",
build: "Building",
"ui:build": "Building UI",

View File

@@ -23,21 +23,33 @@ describe("legacy config detection", () => {
expect(res.issues[0]?.path).toBe("routing.groupChat.requireMention");
}
});
it("migrates routing.allowFrom to channels.whatsapp.allowFrom", async () => {
it("migrates routing.allowFrom to channels.whatsapp.allowFrom when whatsapp configured", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
routing: { allowFrom: ["+15555550123"] },
channels: { whatsapp: {} },
});
expect(res.changes).toContain("Moved routing.allowFrom → channels.whatsapp.allowFrom.");
expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
expect(res.config?.routing?.allowFrom).toBeUndefined();
});
it("migrates routing.groupChat.requireMention to channels whatsapp/telegram/imessage groups", async () => {
it("drops routing.allowFrom when whatsapp missing", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
routing: { allowFrom: ["+15555550123"] },
});
expect(res.changes).toContain("Removed routing.allowFrom (channels.whatsapp not configured).");
expect(res.config?.channels?.whatsapp).toBeUndefined();
expect(res.config?.routing?.allowFrom).toBeUndefined();
});
it("migrates routing.groupChat.requireMention to channels whatsapp/telegram/imessage groups when whatsapp configured", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
routing: { groupChat: { requireMention: false } },
channels: { whatsapp: {} },
});
expect(res.changes).toContain(
'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
@@ -53,6 +65,26 @@ describe("legacy config detection", () => {
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false);
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
});
it("migrates routing.groupChat.requireMention to telegram/imessage when whatsapp missing", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
routing: { groupChat: { requireMention: false } },
});
expect(res.changes).toContain(
'Moved routing.groupChat.requireMention → channels.telegram.groups."*".requireMention.',
);
expect(res.changes).toContain(
'Moved routing.groupChat.requireMention → channels.imessage.groups."*".requireMention.',
);
expect(res.changes).not.toContain(
'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
);
expect(res.config?.channels?.whatsapp).toBeUndefined();
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false);
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
});
it("migrates routing.groupChat.mentionPatterns to messages.groupChat.mentionPatterns", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");

View File

@@ -156,11 +156,16 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
const allowFrom = (routing as Record<string, unknown>).allowFrom;
if (allowFrom === undefined) return;
const channels = ensureRecord(raw, "channels");
const whatsapp =
channels.whatsapp && typeof channels.whatsapp === "object"
? (channels.whatsapp as Record<string, unknown>)
: {};
const channels = getRecord(raw.channels);
const whatsapp = channels ? getRecord(channels.whatsapp) : null;
if (!whatsapp) {
delete (routing as Record<string, unknown>).allowFrom;
if (Object.keys(routing as Record<string, unknown>).length === 0) {
delete raw.routing;
}
changes.push("Removed routing.allowFrom (channels.whatsapp not configured).");
return;
}
if (whatsapp.allowFrom === undefined) {
whatsapp.allowFrom = allowFrom;
@@ -173,8 +178,8 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
if (Object.keys(routing as Record<string, unknown>).length === 0) {
delete raw.routing;
}
channels.whatsapp = whatsapp;
raw.channels = channels;
channels!.whatsapp = whatsapp;
raw.channels = channels!;
},
},
{
@@ -193,7 +198,11 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
if (requireMention === undefined) return;
const channels = ensureRecord(raw, "channels");
const applyTo = (key: "whatsapp" | "telegram" | "imessage") => {
const applyTo = (
key: "whatsapp" | "telegram" | "imessage",
options?: { requireExisting?: boolean },
) => {
if (options?.requireExisting && !isRecord(channels[key])) return;
const section =
channels[key] && typeof channels[key] === "object"
? (channels[key] as Record<string, unknown>)
@@ -222,7 +231,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
}
};
applyTo("whatsapp");
applyTo("whatsapp", { requireExisting: true });
applyTo("telegram");
applyTo("imessage");

View File

@@ -198,6 +198,7 @@ const FIELD_LABELS: Record<string, string> = {
"skills.load.watch": "Watch Skills",
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
"agents.defaults.workspace": "Workspace",
"agents.defaults.repoRoot": "Repo Root",
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars",
"agents.defaults.envelopeTimezone": "Envelope Timezone",
"agents.defaults.envelopeTimestamp": "Envelope Timestamp",
@@ -436,6 +437,8 @@ const FIELD_HELP: Record<string, string> = {
"auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).",
"agents.defaults.bootstrapMaxChars":
"Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).",
"agents.defaults.repoRoot":
"Optional repository root shown in the system prompt runtime line (overrides auto-detect).",
"agents.defaults.envelopeTimezone":
'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).',
"agents.defaults.envelopeTimestamp":

View File

@@ -99,6 +99,8 @@ export type AgentDefaultsConfig = {
models?: Record<string, AgentModelEntryConfig>;
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
workspace?: string;
/** Optional repository root for system prompt runtime line (overrides auto-detect). */
repoRoot?: string;
/** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */
skipBootstrap?: boolean;
/** Max chars for injected bootstrap files before truncation (default: 20000). */
@@ -134,7 +136,7 @@ export type AgentDefaultsConfig = {
/** Default verbose level when no /verbose directive is present. */
verboseDefault?: "off" | "on" | "full";
/** Default elevated level when no /elevated directive is present. */
elevatedDefault?: "off" | "on";
elevatedDefault?: "off" | "on" | "ask" | "full";
/** Default block streaming level when no override is present. */
blockStreamingDefault?: "off" | "on";
/**

View File

@@ -42,6 +42,7 @@ export const AgentDefaultsSchema = z
)
.optional(),
workspace: z.string().optional(),
repoRoot: z.string().optional(),
skipBootstrap: z.boolean().optional(),
bootstrapMaxChars: z.number().int().positive().optional(),
userTimezone: z.string().optional(),
@@ -112,7 +113,9 @@ export const AgentDefaultsSchema = z
])
.optional(),
verboseDefault: z.union([z.literal("off"), z.literal("on"), z.literal("full")]).optional(),
elevatedDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
elevatedDefault: z
.union([z.literal("off"), z.literal("on"), z.literal("ask"), z.literal("full")])
.optional(),
blockStreamingDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
blockStreamingBreak: z.union([z.literal("text_end"), z.literal("message_end")]).optional(),
blockStreamingChunk: BlockStreamingChunkSchema.optional(),

View File

@@ -113,6 +113,30 @@ function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
}
function isInstructionsRequiredError(error: string): boolean {
return /instructions are required/i.test(error);
}
function isOpenAIReasoningSequenceError(error: string): boolean {
const msg = error.toLowerCase();
return msg.includes("required following item") && msg.includes("reasoning");
}
function isToolNonceRefusal(error: string): boolean {
const msg = error.toLowerCase();
if (!msg.includes("nonce")) return false;
return (
msg.includes("token") ||
msg.includes("secret") ||
msg.includes("local file") ||
msg.includes("disclose") ||
msg.includes("can't help") ||
msg.includes("cant help") ||
msg.includes("can't comply") ||
msg.includes("cant comply")
);
}
function isMissingProfileError(error: string): boolean {
return /no credentials found for profile/i.test(error);
}
@@ -856,6 +880,27 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
logProgress(`${progressLabel}: skip (chatgpt usage limit)`);
break;
}
if (model.provider === "openai-codex" && isInstructionsRequiredError(message)) {
skippedCount += 1;
logProgress(`${progressLabel}: skip (instructions required)`);
break;
}
if (
(model.provider === "openai" || model.provider === "openai-codex") &&
isOpenAIReasoningSequenceError(message)
) {
skippedCount += 1;
logProgress(`${progressLabel}: skip (openai reasoning sequence error)`);
break;
}
if (
(model.provider === "openai" || model.provider === "openai-codex") &&
isToolNonceRefusal(message)
) {
skippedCount += 1;
logProgress(`${progressLabel}: skip (tool probe refusal)`);
break;
}
if (isMissingProfileError(message)) {
skippedCount += 1;
logProgress(`${progressLabel}: skip (missing auth profile)`);

View File

@@ -169,7 +169,7 @@ export async function applySessionsPatchToStore(params: {
delete next.elevatedLevel;
} else if (raw !== undefined) {
const normalized = normalizeElevatedLevel(String(raw));
if (!normalized) return invalid('invalid elevatedLevel (use "on"|"off")');
if (!normalized) return invalid('invalid elevatedLevel (use "on"|"off"|"ask"|"full")');
// Persist "off" explicitly so patches can override defaults.
next.elevatedLevel = normalized;
}

View File

@@ -14,6 +14,7 @@ import {
normalizeSafeBins,
resolveCommandResolution,
resolveExecApprovals,
resolveExecApprovalsFromFile,
type ExecAllowlistEntry,
} from "./exec-approvals.js";
@@ -227,3 +228,32 @@ describe("exec approvals wildcard agent", () => {
}
});
});
describe("exec approvals default agent migration", () => {
it("migrates legacy default agent entries to main", () => {
const file = {
version: 1,
agents: {
default: { allowlist: [{ pattern: "/bin/legacy" }] },
},
};
const resolved = resolveExecApprovalsFromFile({ file });
expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual(["/bin/legacy"]);
expect(resolved.file.agents?.default).toBeUndefined();
expect(resolved.file.agents?.main?.allowlist?.[0]?.pattern).toBe("/bin/legacy");
});
it("prefers main agent settings when both main and default exist", () => {
const file = {
version: 1,
agents: {
main: { ask: "always", allowlist: [{ pattern: "/bin/main" }] },
default: { ask: "off", allowlist: [{ pattern: "/bin/legacy" }] },
},
};
const resolved = resolveExecApprovalsFromFile({ file });
expect(resolved.agent.ask).toBe("always");
expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual(["/bin/main", "/bin/legacy"]);
expect(resolved.file.agents?.default).toBeUndefined();
});
});

View File

@@ -4,6 +4,8 @@ import net from "node:net";
import os from "node:os";
import path from "node:path";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
export type ExecHost = "sandbox" | "gateway" | "node";
export type ExecSecurity = "deny" | "allowlist" | "full";
export type ExecAsk = "off" | "on-miss" | "always";
@@ -84,6 +86,35 @@ export function resolveExecApprovalsSocketPath(): string {
return expandHome(DEFAULT_SOCKET);
}
function normalizeAllowlistPattern(value: string | undefined): string | null {
const trimmed = value?.trim() ?? "";
return trimmed ? trimmed.toLowerCase() : null;
}
function mergeLegacyAgent(
current: ExecApprovalsAgent,
legacy: ExecApprovalsAgent,
): ExecApprovalsAgent {
const allowlist: ExecAllowlistEntry[] = [];
const seen = new Set<string>();
const pushEntry = (entry: ExecAllowlistEntry) => {
const key = normalizeAllowlistPattern(entry.pattern);
if (!key || seen.has(key)) return;
seen.add(key);
allowlist.push(entry);
};
for (const entry of current.allowlist ?? []) pushEntry(entry);
for (const entry of legacy.allowlist ?? []) pushEntry(entry);
return {
security: current.security ?? legacy.security,
ask: current.ask ?? legacy.ask,
askFallback: current.askFallback ?? legacy.askFallback,
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
allowlist: allowlist.length > 0 ? allowlist : undefined,
};
}
function ensureDir(filePath: string) {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
@@ -92,6 +123,13 @@ function ensureDir(filePath: string) {
export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
const socketPath = file.socket?.path?.trim();
const token = file.socket?.token?.trim();
const agents = { ...file.agents };
const legacyDefault = agents.default;
if (legacyDefault) {
const main = agents[DEFAULT_AGENT_ID];
agents[DEFAULT_AGENT_ID] = main ? mergeLegacyAgent(main, legacyDefault) : legacyDefault;
delete agents.default;
}
const normalized: ExecApprovalsFile = {
version: 1,
socket: {
@@ -104,7 +142,7 @@ export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFi
askFallback: file.defaults?.askFallback,
autoAllowSkills: file.defaults?.autoAllowSkills,
},
agents: file.agents ?? {},
agents,
};
return normalized;
}
@@ -231,7 +269,7 @@ export function resolveExecApprovalsFromFile(params: {
}): ExecApprovalsResolved {
const file = normalizeExecApprovals(params.file);
const defaults = file.defaults ?? {};
const agentKey = params.agentId ?? "default";
const agentKey = params.agentId ?? DEFAULT_AGENT_ID;
const agent = file.agents?.[agentKey] ?? {};
const wildcard = file.agents?.["*"] ?? {};
const fallbackSecurity = params.overrides?.security ?? DEFAULT_SECURITY;
@@ -696,7 +734,7 @@ export function recordAllowlistUse(
command: string,
resolvedPath?: string,
) {
const target = agentId ?? "default";
const target = agentId ?? DEFAULT_AGENT_ID;
const agents = approvals.agents ?? {};
const existing = agents[target] ?? {};
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
@@ -720,7 +758,7 @@ export function addAllowlistEntry(
agentId: string | undefined,
pattern: string,
) {
const target = agentId ?? "default";
const target = agentId ?? DEFAULT_AGENT_ID;
const agents = approvals.agents ?? {};
const existing = agents[target] ?? {};
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];

View File

@@ -74,7 +74,9 @@ describe("runGatewayUpdate", () => {
stdout: "origin/main",
},
[`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" },
[`git -C ${tempDir} rebase @{upstream}`]: { code: 1, stderr: "conflict" },
[`git -C ${tempDir} rev-parse @{upstream}`]: { stdout: "upstream123" },
[`git -C ${tempDir} rev-list --max-count=10 upstream123`]: { stdout: "upstream123\n" },
[`git -C ${tempDir} rebase upstream123`]: { code: 1, stderr: "conflict" },
[`git -C ${tempDir} rebase --abort`]: { stdout: "" },
});

View File

@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
@@ -63,6 +64,7 @@ type UpdateRunnerOptions = {
const DEFAULT_TIMEOUT_MS = 20 * 60_000;
const MAX_LOG_CHARS = 8000;
const PREFLIGHT_MAX_COMMITS = 10;
const START_DIRS = ["cwd", "argv1", "process"];
function normalizeDir(value?: string | null) {
@@ -420,8 +422,152 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
);
steps.push(fetchStep);
const upstreamShaStep = await runStep(
step(
"git rev-parse @{upstream}",
["git", "-C", gitRoot, "rev-parse", "@{upstream}"],
gitRoot,
),
);
steps.push(upstreamShaStep);
const upstreamSha = upstreamShaStep.stdoutTail?.trim();
if (!upstreamShaStep.stdoutTail || !upstreamSha) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "no-upstream-sha",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const revListStep = await runStep(
step(
"git rev-list",
["git", "-C", gitRoot, "rev-list", `--max-count=${PREFLIGHT_MAX_COMMITS}`, upstreamSha],
gitRoot,
),
);
steps.push(revListStep);
if (revListStep.exitCode !== 0) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "preflight-revlist-failed",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const candidates = (revListStep.stdoutTail ?? "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
if (candidates.length === 0) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "preflight-no-candidates",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const manager = await detectPackageManager(gitRoot);
const preflightRoot = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-preflight-"));
const worktreeDir = path.join(preflightRoot, "worktree");
const worktreeStep = await runStep(
step(
"preflight worktree",
["git", "-C", gitRoot, "worktree", "add", "--detach", worktreeDir, upstreamSha],
gitRoot,
),
);
steps.push(worktreeStep);
if (worktreeStep.exitCode !== 0) {
await fs.rm(preflightRoot, { recursive: true, force: true }).catch(() => {});
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "preflight-worktree-failed",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
let selectedSha: string | null = null;
try {
for (const sha of candidates) {
const shortSha = sha.slice(0, 8);
const checkoutStep = await runStep(
step(
`preflight checkout (${shortSha})`,
["git", "-C", worktreeDir, "checkout", "--detach", sha],
worktreeDir,
),
);
steps.push(checkoutStep);
if (checkoutStep.exitCode !== 0) continue;
const depsStep = await runStep(
step(`preflight deps install (${shortSha})`, managerInstallArgs(manager), worktreeDir),
);
steps.push(depsStep);
if (depsStep.exitCode !== 0) continue;
const lintStep = await runStep(
step(`preflight lint (${shortSha})`, managerScriptArgs(manager, "lint"), worktreeDir),
);
steps.push(lintStep);
if (lintStep.exitCode !== 0) continue;
const buildStep = await runStep(
step(`preflight build (${shortSha})`, managerScriptArgs(manager, "build"), worktreeDir),
);
steps.push(buildStep);
if (buildStep.exitCode !== 0) continue;
selectedSha = sha;
break;
}
} finally {
const removeStep = await runStep(
step(
"preflight cleanup",
["git", "-C", gitRoot, "worktree", "remove", "--force", worktreeDir],
gitRoot,
),
);
steps.push(removeStep);
await runCommand(["git", "-C", gitRoot, "worktree", "prune"], {
cwd: gitRoot,
timeoutMs,
}).catch(() => null);
await fs.rm(preflightRoot, { recursive: true, force: true }).catch(() => {});
}
if (!selectedSha) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "preflight-no-good-commit",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const rebaseStep = await runStep(
step("git rebase", ["git", "-C", gitRoot, "rebase", "@{upstream}"], gitRoot),
step("git rebase", ["git", "-C", gitRoot, "rebase", selectedSha], gitRoot),
);
steps.push(rebaseStep);
if (rebaseStep.exitCode !== 0) {

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import { createTelegramBot } from "./bot.js";
@@ -119,8 +119,11 @@ const getOnHandler = (event: string) => {
return handler as (ctx: Record<string, unknown>) => Promise<void>;
};
const ORIGINAL_TZ = process.env.TZ;
describe("createTelegramBot", () => {
beforeEach(() => {
process.env.TZ = "UTC";
resetInboundDedupe();
loadConfig.mockReturnValue({
channels: {
@@ -138,6 +141,9 @@ describe("createTelegramBot", () => {
botCtorSpy.mockReset();
_sequentializeKey = undefined;
});
afterEach(() => {
process.env.TZ = ORIGINAL_TZ;
});
// groupPolicy tests

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import { createTelegramBot, getTelegramSequentialKey } from "./bot.js";
@@ -122,8 +122,11 @@ const getOnHandler = (event: string) => {
return handler as (ctx: Record<string, unknown>) => Promise<void>;
};
const ORIGINAL_TZ = process.env.TZ;
describe("createTelegramBot", () => {
beforeEach(() => {
process.env.TZ = "UTC";
resetInboundDedupe();
loadConfig.mockReturnValue({
channels: {
@@ -141,6 +144,9 @@ describe("createTelegramBot", () => {
botCtorSpy.mockReset();
sequentializeKey = undefined;
});
afterEach(() => {
process.env.TZ = ORIGINAL_TZ;
});
// groupPolicy tests

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
@@ -148,8 +148,11 @@ const getOnHandler = (event: string) => {
return handler as (ctx: Record<string, unknown>) => Promise<void>;
};
const ORIGINAL_TZ = process.env.TZ;
describe("createTelegramBot", () => {
beforeEach(() => {
process.env.TZ = "UTC";
resetInboundDedupe();
loadConfig.mockReturnValue({
channels: {
@@ -168,6 +171,9 @@ describe("createTelegramBot", () => {
botCtorSpy.mockReset();
sequentializeKey = undefined;
});
afterEach(() => {
process.env.TZ = ORIGINAL_TZ;
});
it("installs grammY throttler", () => {
createTelegramBot({ token: "tok" });
@@ -556,94 +562,106 @@ describe("createTelegramBot", () => {
});
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
const originalTz = process.env.TZ;
process.env.TZ = "UTC";
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
loadConfig.mockReturnValue({
identity: { name: "Bert" },
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
try {
loadConfig.mockReturnValue({
identity: { name: "Bert" },
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
},
});
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "group", title: "Test Group" },
text: "bert: introduce yourself",
date: 1736380800,
message_id: 1,
from: { id: 9, first_name: "Ada" },
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
await handler({
message: {
chat: { id: 7, type: "group", title: "Test Group" },
text: "bert: introduce yourself",
date: 1736380800,
message_id: 1,
from: { id: 9, first_name: "Ada" },
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload);
expect(payload.WasMentioned).toBe(true);
const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
const timestampPattern = escapeRegExp(expectedTimestamp);
expect(payload.Body).toMatch(
new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`),
);
expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9");
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload);
expect(payload.WasMentioned).toBe(true);
const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
const timestampPattern = escapeRegExp(expectedTimestamp);
expect(payload.Body).toMatch(
new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`),
);
expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9");
} finally {
process.env.TZ = originalTz;
}
});
it("includes sender identity in group envelope headers", async () => {
const originalTz = process.env.TZ;
process.env.TZ = "UTC";
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
try {
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
},
});
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "hello",
date: 1736380800,
message_id: 2,
from: {
id: 99,
first_name: "Ada",
last_name: "Lovelace",
username: "ada",
await handler({
message: {
chat: { id: 42, type: "group", title: "Ops" },
text: "hello",
date: 1736380800,
message_id: 2,
from: {
id: 99,
first_name: "Ada",
last_name: "Lovelace",
username: "ada",
},
},
},
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload);
const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
const timestampPattern = escapeRegExp(expectedTimestamp);
expect(payload.Body).toMatch(
new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`),
);
expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada");
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload);
const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
const timestampPattern = escapeRegExp(expectedTimestamp);
expect(payload.Body).toMatch(
new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`),
);
expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada");
} finally {
process.env.TZ = originalTz;
}
});
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {

View File

@@ -3,7 +3,7 @@ import { formatThinkingLevels, listThinkingLevelLabels } from "../auto-reply/thi
const VERBOSE_LEVELS = ["on", "off"];
const REASONING_LEVELS = ["on", "off"];
const ELEVATED_LEVELS = ["on", "off"];
const ELEVATED_LEVELS = ["on", "off", "ask", "full"];
const ACTIVATION_LEVELS = ["mention", "always"];
const USAGE_FOOTER_LEVELS = ["off", "tokens", "full"];
@@ -83,7 +83,7 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman
},
{
name: "elevated",
description: "Set elevated on/off",
description: "Set elevated on/off/ask/full",
getArgumentCompletions: (prefix) =>
ELEVATED_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map((value) => ({
value,
@@ -130,8 +130,8 @@ export function helpText(options: SlashCommandOptions = {}): string {
"/verbose <on|off>",
"/reasoning <on|off>",
"/usage <off|tokens|full>",
"/elevated <on|off>",
"/elev <on|off>",
"/elevated <on|off|ask|full>",
"/elev <on|off|ask|full>",
"/activation <mention|always>",
"/new or /reset",
"/abort",

View File

@@ -371,7 +371,11 @@ export function createCommandHandlers(context: CommandHandlerContext) {
}
case "elevated":
if (!args) {
chatLog.addSystem("usage: /elevated <on|off>");
chatLog.addSystem("usage: /elevated <on|off|ask|full>");
break;
}
if (!["on", "off", "ask", "full"].includes(args)) {
chatLog.addSystem("usage: /elevated <on|off|ask|full>");
break;
}
try {