Merge branch 'main' into feat/mattermost-channel

This commit is contained in:
Dominic Damoah
2026-01-22 18:17:40 -05:00
committed by GitHub
149 changed files with 5513 additions and 672 deletions

View File

@@ -80,58 +80,44 @@ describe("exec approvals", () => {
if (process.platform !== "win32") {
await fs.chmod(exePath, 0o755);
}
const prevPath = process.env.PATH;
const prevPathExt = process.env.PATHEXT;
process.env.PATH = binDir;
if (process.platform === "win32") {
process.env.PATHEXT = ".CMD";
}
try {
const approvalsFile = {
version: 1,
defaults: { security: "allowlist", ask: "on-miss", askFallback: "deny" },
agents: {
main: {
allowlist: [{ pattern: exePath }],
},
const approvalsFile = {
version: 1,
defaults: { security: "allowlist", ask: "on-miss", askFallback: "deny" },
agents: {
main: {
allowlist: [{ pattern: exePath }],
},
};
},
};
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
calls.push(method);
if (method === "exec.approvals.node.get") {
return { file: approvalsFile };
}
if (method === "node.invoke") {
return { payload: { success: true, stdout: "ok" } };
}
if (method === "exec.approval.request") {
return { decision: "allow-once" };
}
return { ok: true };
});
const { createExecTool } = await import("./bash-tools.exec.js");
const tool = createExecTool({
host: "node",
ask: "on-miss",
approvalRunningNoticeMs: 0,
});
const result = await tool.execute("call2", { command: `${exeName} --help` });
expect(result.details.status).toBe("completed");
expect(calls).toContain("exec.approvals.node.get");
expect(calls).toContain("node.invoke");
expect(calls).not.toContain("exec.approval.request");
} finally {
process.env.PATH = prevPath;
if (prevPathExt === undefined) {
delete process.env.PATHEXT;
} else {
process.env.PATHEXT = prevPathExt;
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
calls.push(method);
if (method === "exec.approvals.node.get") {
return { file: approvalsFile };
}
}
if (method === "node.invoke") {
return { payload: { success: true, stdout: "ok" } };
}
if (method === "exec.approval.request") {
return { decision: "allow-once" };
}
return { ok: true };
});
const { createExecTool } = await import("./bash-tools.exec.js");
const tool = createExecTool({
host: "node",
ask: "on-miss",
approvalRunningNoticeMs: 0,
});
const result = await tool.execute("call2", {
command: `"${exePath}" --help`,
});
expect(result.details.status).toBe("completed");
expect(calls).toContain("exec.approvals.node.get");
expect(calls).toContain("node.invoke");
expect(calls).not.toContain("exec.approval.request");
});
});

View File

@@ -43,8 +43,6 @@ describe("formatAssistantErrorText", () => {
const msg = makeAssistantError(
'{"type":"error","error":{"message":"Something exploded","type":"server_error"}}',
);
expect(formatAssistantErrorText(msg)).toBe(
"The AI service returned an error. Please try again.",
);
expect(formatAssistantErrorText(msg)).toBe("LLM error server_error: Something exploded");
});
});

View File

@@ -17,4 +17,10 @@ describe("formatRawAssistantErrorForUi", () => {
it("renders a generic unknown error message when raw is empty", () => {
expect(formatRawAssistantErrorForUi("")).toContain("unknown error");
});
it("formats plain HTTP status lines", () => {
expect(formatRawAssistantErrorForUi("500 Internal Server Error")).toBe(
"HTTP 500: Internal Server Error",
);
});
});

View File

@@ -19,12 +19,12 @@ describe("sanitizeUserFacingText", () => {
it("sanitizes HTTP status errors with error hints", () => {
expect(sanitizeUserFacingText("500 Internal Server Error")).toBe(
"The AI service returned an error. Please try again.",
"HTTP 500: Internal Server Error",
);
});
it("sanitizes raw API error payloads", () => {
const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}';
expect(sanitizeUserFacingText(raw)).toBe("The AI service returned an error. Please try again.");
expect(sanitizeUserFacingText(raw)).toBe("LLM error server_error: Something exploded");
});
});

View File

@@ -201,6 +201,14 @@ export function formatRawAssistantErrorForUi(raw?: string): string {
const trimmed = (raw ?? "").trim();
if (!trimmed) return "LLM request failed with an unknown error.";
const httpMatch = trimmed.match(HTTP_STATUS_PREFIX_RE);
if (httpMatch) {
const rest = httpMatch[2].trim();
if (!rest.startsWith("{")) {
return `HTTP ${httpMatch[1]}: ${rest}`;
}
}
const info = parseApiErrorInfo(trimmed);
if (info?.message) {
const prefix = info.httpCode ? `HTTP ${info.httpCode}` : "LLM error";
@@ -261,8 +269,8 @@ export function formatAssistantErrorText(
return "The AI service is temporarily overloaded. Please try again in a moment.";
}
if (isRawApiErrorPayload(raw)) {
return "The AI service returned an error. Please try again.";
if (isLikelyHttpErrorText(raw) || isRawApiErrorPayload(raw)) {
return formatRawAssistantErrorForUi(raw);
}
// Never return raw unhandled errors - log for debugging but return safe message
@@ -293,7 +301,7 @@ export function sanitizeUserFacingText(text: string): string {
}
if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) {
return "The AI service returned an error. Please try again.";
return formatRawAssistantErrorForUi(trimmed);
}
if (ERROR_PREFIX_RE.test(trimmed)) {
@@ -303,7 +311,7 @@ export function sanitizeUserFacingText(text: string): string {
if (isTimeoutErrorMessage(trimmed)) {
return "LLM request timed out.";
}
return "The AI service returned an error. Please try again.";
return formatRawAssistantErrorForUi(trimmed);
}
return stripped;

View File

@@ -6,9 +6,15 @@ export function isGoogleModelApi(api?: string | null): boolean {
);
}
export function isAntigravityClaude(api?: string | null, modelId?: string): boolean {
if (api !== "google-antigravity") return false;
return modelId?.toLowerCase().includes("claude") ?? false;
export function isAntigravityClaude(params: {
api?: string | null;
provider?: string | null;
modelId?: string;
}): boolean {
const provider = params.provider?.toLowerCase();
const api = params.api?.toLowerCase();
if (provider !== "google-antigravity" && api !== "google-antigravity") return false;
return params.modelId?.toLowerCase().includes("claude") ?? false;
}
export { sanitizeGoogleTurnOrdering };

View File

@@ -86,7 +86,7 @@ describe("sanitizeSessionHistory (google thinking)", () => {
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
});
it("keeps unsigned thinking blocks for Antigravity Claude", async () => {
it("drops unsigned thinking blocks for Antigravity Claude", async () => {
const sessionManager = SessionManager.inMemory();
const input = [
{
@@ -107,11 +107,37 @@ describe("sanitizeSessionHistory (google thinking)", () => {
sessionId: "session:antigravity-claude",
});
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant");
expect(assistant).toBeUndefined();
});
it("maps base64 signatures to thinkingSignature for Antigravity Claude", async () => {
const sessionManager = SessionManager.inMemory();
const input = [
{
role: "user",
content: "hi",
},
{
role: "assistant",
content: [{ type: "thinking", thinking: "reasoning", signature: "c2ln" }],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionHistory({
messages: input,
modelApi: "google-antigravity",
modelId: "anthropic/claude-3.5-sonnet",
sessionManager,
sessionId: "session:antigravity-claude",
});
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
content?: Array<{ type?: string; thinking?: string }>;
content?: Array<{ type?: string; thinking?: string; thinkingSignature?: string }>;
};
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
expect(assistant.content?.[0]?.thinkingSignature).toBe("c2ln");
});
it("preserves order for mixed assistant content", async () => {

View File

@@ -55,6 +55,15 @@ const MISTRAL_MODEL_HINTS = [
"ministral",
"mistralai",
];
const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/;
function isValidAntigravitySignature(value: unknown): value is string {
if (typeof value !== "string") return false;
const trimmed = value.trim();
if (!trimmed) return false;
if (trimmed.length % 4 !== 0) return false;
return ANTIGRAVITY_SIGNATURE_RE.test(trimmed);
}
function shouldSanitizeToolCallIds(modelApi?: string | null): boolean {
if (!modelApi) return false;
@@ -69,6 +78,66 @@ function isMistralModel(params: { provider?: string | null; modelId?: string | n
return MISTRAL_MODEL_HINTS.some((hint) => modelId.includes(hint));
}
function sanitizeAntigravityThinkingBlocks(messages: AgentMessage[]): AgentMessage[] {
let touched = false;
const out: AgentMessage[] = [];
for (const msg of messages) {
if (!msg || typeof msg !== "object" || msg.role !== "assistant") {
out.push(msg);
continue;
}
const assistant = msg as Extract<AgentMessage, { role: "assistant" }>;
if (!Array.isArray(assistant.content)) {
out.push(msg);
continue;
}
type AssistantContentBlock = Extract<AgentMessage, { role: "assistant" }>["content"][number];
const nextContent: AssistantContentBlock[] = [];
let contentChanged = false;
for (const block of assistant.content) {
if (
!block ||
typeof block !== "object" ||
(block as { type?: unknown }).type !== "thinking"
) {
nextContent.push(block);
continue;
}
const rec = block as {
thinkingSignature?: unknown;
signature?: unknown;
thought_signature?: unknown;
thoughtSignature?: unknown;
};
const candidate =
rec.thinkingSignature ?? rec.signature ?? rec.thought_signature ?? rec.thoughtSignature;
if (!isValidAntigravitySignature(candidate)) {
contentChanged = true;
continue;
}
if (rec.thinkingSignature !== candidate) {
const nextBlock = {
...(block as unknown as Record<string, unknown>),
thinkingSignature: candidate,
} as AssistantContentBlock;
nextContent.push(nextBlock);
contentChanged = true;
} else {
nextContent.push(block);
}
}
if (contentChanged) {
touched = true;
}
if (nextContent.length === 0) {
touched = true;
continue;
}
out.push(contentChanged ? { ...assistant, content: nextContent } : msg);
}
return touched ? out : messages;
}
function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] {
if (!schema || typeof schema !== "object") return [];
if (Array.isArray(schema)) {
@@ -209,7 +278,11 @@ export async function sanitizeSessionHistory(params: {
sessionManager: SessionManager;
sessionId: string;
}): Promise<AgentMessage[]> {
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
const isAntigravityClaudeModel = isAntigravityClaude({
api: params.modelApi,
provider: params.provider,
modelId: params.modelId,
});
const provider = normalizeProviderId(params.provider ?? "");
const modelId = (params.modelId ?? "").toLowerCase();
const isOpenRouterGemini =
@@ -221,12 +294,15 @@ export async function sanitizeSessionHistory(params: {
sanitizeToolCallIds,
toolCallIdMode,
enforceToolCallLast: params.modelApi === "anthropic-messages",
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
preserveSignatures: isAntigravityClaudeModel,
sanitizeThoughtSignatures: isOpenRouterGemini
? { allowBase64Only: true, includeCamelCase: true }
: undefined,
});
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
const sanitizedThinking = isAntigravityClaudeModel
? sanitizeAntigravityThinkingBlocks(sanitizedImages)
: sanitizedImages;
const repairedTools = sanitizeToolUseResultPairing(sanitizedThinking);
return applyGoogleTurnOrderingFix({
messages: repairedTools,

View File

@@ -6,6 +6,7 @@ import { formatToolAggregate } from "../../../auto-reply/tool-meta.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import {
formatAssistantErrorText,
formatRawAssistantErrorForUi,
getApiErrorPayloadFingerprint,
isRawApiErrorPayload,
normalizeTextForComparison,
@@ -64,6 +65,12 @@ export function buildEmbeddedRunPayloads(params: {
const rawErrorFingerprint = rawErrorMessage
? getApiErrorPayloadFingerprint(rawErrorMessage)
: null;
const formattedRawErrorMessage = rawErrorMessage
? formatRawAssistantErrorForUi(rawErrorMessage)
: null;
const normalizedFormattedRawErrorMessage = formattedRawErrorMessage
? normalizeTextForComparison(formattedRawErrorMessage)
: null;
const normalizedRawErrorText = rawErrorMessage
? normalizeTextForComparison(rawErrorMessage)
: null;
@@ -116,10 +123,15 @@ export function buildEmbeddedRunPayloads(params: {
if (trimmed === genericErrorText) return true;
}
if (rawErrorMessage && trimmed === rawErrorMessage) return true;
if (formattedRawErrorMessage && trimmed === formattedRawErrorMessage) return true;
if (normalizedRawErrorText) {
const normalized = normalizeTextForComparison(trimmed);
if (normalized && normalized === normalizedRawErrorText) return true;
}
if (normalizedFormattedRawErrorMessage) {
const normalized = normalizeTextForComparison(trimmed);
if (normalized && normalized === normalizedFormattedRawErrorMessage) return true;
}
if (rawErrorFingerprint) {
const fingerprint = getApiErrorPayloadFingerprint(trimmed);
if (fingerprint && fingerprint === rawErrorFingerprint) return true;

View File

@@ -17,8 +17,9 @@ function buildSkillsSection(params: {
isMinimal: boolean;
readToolName: string;
}) {
if (params.isMinimal) return [];
const trimmed = params.skillsPrompt?.trim();
if (!trimmed || params.isMinimal) return [];
if (!trimmed) return [];
return [
"## Skills (mandatory)",
"Before replying: scan <available_skills> <description> entries.",
@@ -211,7 +212,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); optional per-session model override",
"Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
image: "Analyze an image with the configured image model",
};

View File

@@ -215,7 +215,7 @@ export function createSessionStatusTool(opts?: {
label: "Session Status",
name: "session_status",
description:
"Show a /status-equivalent session status card. Optional: set per-session model override (model=default resets overrides). Includes usage + cost when available.",
"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).",
parameters: SessionStatusToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;

View File

@@ -15,8 +15,8 @@ export async function applyInlineDirectivesFastLane(params: {
cfg: ClawdbotConfig;
agentId?: string;
isGroup: boolean;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionEntry: SessionEntry;
sessionStore: Record<string, SessionEntry>;
sessionKey: string;
storePath?: string;
elevatedEnabled: boolean;

View File

@@ -0,0 +1,113 @@
import { describe, expect, it, vi } from "vitest";
import type { ModelAliasIndex } from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { parseInlineDirectives } from "./directive-handling.js";
import { handleDirectiveOnly } from "./directive-handling.impl.js";
// Mock dependencies
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentConfig: vi.fn(() => ({})),
resolveAgentDir: vi.fn(() => "/tmp/agent"),
resolveSessionAgentId: vi.fn(() => "main"),
}));
vi.mock("../../agents/sandbox.js", () => ({
resolveSandboxRuntimeStatus: vi.fn(() => ({ sandboxed: false })),
}));
vi.mock("../../config/sessions.js", () => ({
updateSessionStore: vi.fn(async () => {}),
}));
vi.mock("../../infra/system-events.js", () => ({
enqueueSystemEvent: vi.fn(),
}));
function baseAliasIndex(): ModelAliasIndex {
return { byAlias: new Map(), byKey: new Map() };
}
function baseConfig(): ClawdbotConfig {
return {
commands: { text: true },
agents: { defaults: {} },
} as unknown as ClawdbotConfig;
}
describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
const allowedModelKeys = new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]);
const allowedModelCatalog = [
{ provider: "anthropic", id: "claude-opus-4-5" },
{ provider: "openai", id: "gpt-4o" },
];
it("shows success message when session state is available", async () => {
const directives = parseInlineDirectives("/model openai/gpt-4o");
const sessionEntry: SessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
};
const sessionStore = { "agent:main:dm:1": sessionEntry };
const result = await handleDirectiveOnly({
cfg: baseConfig(),
directives,
sessionEntry,
sessionStore,
sessionKey: "agent:main:dm:1",
storePath: "/tmp/sessions.json",
elevatedEnabled: false,
elevatedAllowed: false,
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelKeys,
allowedModelCatalog,
resetModelOverride: false,
provider: "anthropic",
model: "claude-opus-4-5",
initialModelLabel: "anthropic/claude-opus-4-5",
formatModelSwitchEvent: (label) => `Switched to ${label}`,
});
expect(result?.text).toContain("Model set to");
expect(result?.text).toContain("openai/gpt-4o");
expect(result?.text).not.toContain("failed");
});
it("shows no model message when no /model directive", async () => {
const directives = parseInlineDirectives("hello world");
const sessionEntry: SessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
};
const sessionStore = { "agent:main:dm:1": sessionEntry };
const result = await handleDirectiveOnly({
cfg: baseConfig(),
directives,
sessionEntry,
sessionStore,
sessionKey: "agent:main:dm:1",
storePath: "/tmp/sessions.json",
elevatedEnabled: false,
elevatedAllowed: false,
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelKeys,
allowedModelCatalog,
resetModelOverride: false,
provider: "anthropic",
model: "claude-opus-4-5",
initialModelLabel: "anthropic/claude-opus-4-5",
formatModelSwitchEvent: (label) => `Switched to ${label}`,
});
// No model directive = no model message
expect(result?.text ?? "").not.toContain("Model set to");
expect(result?.text ?? "").not.toContain("failed");
});
});

View File

@@ -62,8 +62,8 @@ function resolveExecDefaults(params: {
export async function handleDirectiveOnly(params: {
cfg: ClawdbotConfig;
directives: InlineDirectives;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionEntry: SessionEntry;
sessionStore: Record<string, SessionEntry>;
sessionKey: string;
storePath?: string;
elevatedEnabled: boolean;
@@ -288,113 +288,111 @@ export async function handleDirectiveOnly(params: {
nextThinkLevel === "xhigh" &&
!supportsXHighThinking(resolvedProvider, resolvedModel);
if (sessionEntry && sessionStore && sessionKey) {
const prevElevatedLevel =
currentElevatedLevel ??
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
const prevReasoningLevel =
currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
let elevatedChanged =
directives.hasElevatedDirective &&
directives.elevatedLevel !== undefined &&
elevatedEnabled &&
elevatedAllowed;
let reasoningChanged =
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
if (directives.hasThinkDirective && directives.thinkLevel) {
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
else sessionEntry.thinkingLevel = directives.thinkLevel;
const prevElevatedLevel =
currentElevatedLevel ??
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
(elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
const prevReasoningLevel =
currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
let elevatedChanged =
directives.hasElevatedDirective &&
directives.elevatedLevel !== undefined &&
elevatedEnabled &&
elevatedAllowed;
let reasoningChanged =
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
if (directives.hasThinkDirective && directives.thinkLevel) {
if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
else sessionEntry.thinkingLevel = directives.thinkLevel;
}
if (shouldDowngradeXHigh) {
sessionEntry.thinkingLevel = "high";
}
if (directives.hasVerboseDirective && directives.verboseLevel) {
applyVerboseOverride(sessionEntry, directives.verboseLevel);
}
if (directives.hasReasoningDirective && directives.reasoningLevel) {
if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel;
else sessionEntry.reasoningLevel = directives.reasoningLevel;
reasoningChanged =
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
}
if (directives.hasElevatedDirective && directives.elevatedLevel) {
// Unlike other toggles, elevated defaults can be "on".
// Persist "off" explicitly so `/elevated off` actually overrides defaults.
sessionEntry.elevatedLevel = directives.elevatedLevel;
elevatedChanged =
elevatedChanged ||
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
}
if (directives.hasExecDirective && directives.hasExecOptions) {
if (directives.execHost) {
sessionEntry.execHost = directives.execHost;
}
if (shouldDowngradeXHigh) {
sessionEntry.thinkingLevel = "high";
if (directives.execSecurity) {
sessionEntry.execSecurity = directives.execSecurity;
}
if (directives.hasVerboseDirective && directives.verboseLevel) {
applyVerboseOverride(sessionEntry, directives.verboseLevel);
if (directives.execAsk) {
sessionEntry.execAsk = directives.execAsk;
}
if (directives.hasReasoningDirective && directives.reasoningLevel) {
if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel;
else sessionEntry.reasoningLevel = directives.reasoningLevel;
reasoningChanged =
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
if (directives.execNode) {
sessionEntry.execNode = directives.execNode;
}
if (directives.hasElevatedDirective && directives.elevatedLevel) {
// Unlike other toggles, elevated defaults can be "on".
// Persist "off" explicitly so `/elevated off` actually overrides defaults.
sessionEntry.elevatedLevel = directives.elevatedLevel;
elevatedChanged =
elevatedChanged ||
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
}
if (modelSelection) {
applyModelOverrideToSessionEntry({
entry: sessionEntry,
selection: modelSelection,
profileOverride,
});
}
if (directives.hasQueueDirective && directives.queueReset) {
delete sessionEntry.queueMode;
delete sessionEntry.queueDebounceMs;
delete sessionEntry.queueCap;
delete sessionEntry.queueDrop;
} else if (directives.hasQueueDirective) {
if (directives.queueMode) sessionEntry.queueMode = directives.queueMode;
if (typeof directives.debounceMs === "number") {
sessionEntry.queueDebounceMs = directives.debounceMs;
}
if (directives.hasExecDirective && directives.hasExecOptions) {
if (directives.execHost) {
sessionEntry.execHost = directives.execHost;
}
if (directives.execSecurity) {
sessionEntry.execSecurity = directives.execSecurity;
}
if (directives.execAsk) {
sessionEntry.execAsk = directives.execAsk;
}
if (directives.execNode) {
sessionEntry.execNode = directives.execNode;
}
if (typeof directives.cap === "number") {
sessionEntry.queueCap = directives.cap;
}
if (modelSelection) {
applyModelOverrideToSessionEntry({
entry: sessionEntry,
selection: modelSelection,
profileOverride,
});
if (directives.dropPolicy) {
sessionEntry.queueDrop = directives.dropPolicy;
}
if (directives.hasQueueDirective && directives.queueReset) {
delete sessionEntry.queueMode;
delete sessionEntry.queueDebounceMs;
delete sessionEntry.queueCap;
delete sessionEntry.queueDrop;
} else if (directives.hasQueueDirective) {
if (directives.queueMode) sessionEntry.queueMode = directives.queueMode;
if (typeof directives.debounceMs === "number") {
sessionEntry.queueDebounceMs = directives.debounceMs;
}
if (typeof directives.cap === "number") {
sessionEntry.queueCap = directives.cap;
}
if (directives.dropPolicy) {
sessionEntry.queueDrop = directives.dropPolicy;
}
}
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
if (modelSelection) {
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), {
sessionKey,
contextKey: `model:${nextLabel}`,
});
}
}
if (elevatedChanged) {
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
enqueueSystemEvent(formatElevatedEvent(nextElevated), {
}
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = sessionEntry;
});
}
if (modelSelection) {
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), {
sessionKey,
contextKey: "mode:elevated",
});
}
if (reasoningChanged) {
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
sessionKey,
contextKey: "mode:reasoning",
contextKey: `model:${nextLabel}`,
});
}
}
if (elevatedChanged) {
const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
enqueueSystemEvent(formatElevatedEvent(nextElevated), {
sessionKey,
contextKey: "mode:elevated",
});
}
if (reasoningChanged) {
const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
sessionKey,
contextKey: "mode:reasoning",
});
}
const parts: string[] = [];
if (directives.hasThinkDirective && directives.thinkLevel) {

View File

@@ -39,8 +39,8 @@ export async function applyInlineDirectiveOverrides(params: {
agentId: string;
agentDir: string;
agentCfg: AgentDefaults;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionEntry: SessionEntry;
sessionStore: Record<string, SessionEntry>;
sessionKey: string;
storePath?: string;
sessionScope: Parameters<typeof buildStatusReply>[0]["sessionScope"];

View File

@@ -89,8 +89,8 @@ export async function resolveReplyDirectives(params: {
workspaceDir: string;
agentCfg: AgentDefaults;
sessionCtx: TemplateContext;
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionEntry: SessionEntry;
sessionStore: Record<string, SessionEntry>;
sessionKey: string;
storePath?: string;
sessionScope: Parameters<typeof applyInlineDirectiveOverrides>[0]["sessionScope"];

View File

@@ -1 +1 @@
70ce2f8889599d5d76bccea69516e3136fd25fd32e43fe055d05faca822b47c7
a99455ba0c4d0aad0a110bf25440c208b798198d5524b269f0f2d3f984262ae4

View File

@@ -1,10 +1,12 @@
import { resolveCommitHash } from "../infra/git-commit.js";
import { visibleWidth } from "../terminal/ansi.js";
import { isRich, theme } from "../terminal/theme.js";
import { pickTagline, type TaglineOptions } from "./tagline.js";
type BannerOptions = TaglineOptions & {
argv?: string[];
commit?: string | null;
columns?: number;
richTty?: boolean;
};
@@ -36,12 +38,28 @@ export function formatCliBannerLine(version: string, options: BannerOptions = {}
const tagline = pickTagline(options);
const rich = options.richTty ?? isRich();
const title = "🦞 Clawdbot";
const prefix = "🦞 ";
const columns = options.columns ?? process.stdout.columns ?? 120;
const plainFullLine = `${title} ${version} (${commitLabel}) — ${tagline}`;
const fitsOnOneLine = visibleWidth(plainFullLine) <= columns;
if (rich) {
return `${theme.heading(title)} ${theme.info(version)} ${theme.muted(
if (fitsOnOneLine) {
return `${theme.heading(title)} ${theme.info(version)} ${theme.muted(
`(${commitLabel})`,
)} ${theme.muted("—")} ${theme.accentDim(tagline)}`;
}
const line1 = `${theme.heading(title)} ${theme.info(version)} ${theme.muted(
`(${commitLabel})`,
)} ${theme.muted("—")} ${theme.accentDim(tagline)}`;
)}`;
const line2 = `${" ".repeat(prefix.length)}${theme.muted("—")} ${theme.accentDim(tagline)}`;
return `${line1}\n${line2}`;
}
return `${title} ${version} (${commitLabel}) — ${tagline}`;
if (fitsOnOneLine) {
return plainFullLine;
}
const line1 = `${title} ${version} (${commitLabel})`;
const line2 = `${" ".repeat(prefix.length)}${tagline}`;
return `${line1}\n${line2}`;
}
const LOBSTER_ASCII = [

View File

@@ -2,10 +2,12 @@ import type { Command } from "commander";
import { gatewayStatusCommand } from "../../commands/gateway-status.js";
import { formatHealthChannelLines, type HealthSummary } from "../../commands/health.js";
import { discoverGatewayBeacons } from "../../infra/bonjour-discovery.js";
import type { CostUsageSummary } from "../../infra/session-cost-usage.js";
import { WIDE_AREA_DISCOVERY_DOMAIN } from "../../infra/widearea-dns.js";
import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { colorize, isRich, theme } from "../../terminal/theme.js";
import { formatTokenCount, formatUsd } from "../../utils/usage-format.js";
import { withProgress } from "../progress.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import {
@@ -58,6 +60,41 @@ function runGatewayCommand(action: () => Promise<void>, label?: string) {
});
}
function parseDaysOption(raw: unknown, fallback = 30): number {
if (typeof raw === "number" && Number.isFinite(raw)) return Math.max(1, Math.floor(raw));
if (typeof raw === "string" && raw.trim() !== "") {
const parsed = Number(raw);
if (Number.isFinite(parsed)) return Math.max(1, Math.floor(parsed));
}
return fallback;
}
function renderCostUsageSummary(summary: CostUsageSummary, days: number, rich: boolean): string[] {
const totalCost = formatUsd(summary.totals.totalCost) ?? "$0.00";
const totalTokens = formatTokenCount(summary.totals.totalTokens) ?? "0";
const lines = [
colorize(rich, theme.heading, `Usage cost (${days} days)`),
`${colorize(rich, theme.muted, "Total:")} ${totalCost} · ${totalTokens} tokens`,
];
if (summary.totals.missingCostEntries > 0) {
lines.push(
`${colorize(rich, theme.muted, "Missing entries:")} ${summary.totals.missingCostEntries}`,
);
}
const latest = summary.daily.at(-1);
if (latest) {
const latestCost = formatUsd(latest.totalCost) ?? "$0.00";
const latestTokens = formatTokenCount(latest.totalTokens) ?? "0";
lines.push(
`${colorize(rich, theme.muted, "Latest day:")} ${latest.date} · ${latestCost} · ${latestTokens} tokens`,
);
}
return lines;
}
export function registerGatewayCli(program: Command) {
const gateway = addGatewayRunCommand(
program
@@ -160,6 +197,28 @@ export function registerGatewayCli(program: Command) {
}),
);
gatewayCallOpts(
gateway
.command("usage-cost")
.description("Fetch usage cost summary from session logs")
.option("--days <days>", "Number of days to include", "30")
.action(async (opts) => {
await runGatewayCommand(async () => {
const days = parseDaysOption(opts.days);
const result = await callGatewayCli("usage.cost", opts, { days });
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const rich = isRich();
const summary = result as CostUsageSummary;
for (const line of renderCostUsageSummary(summary, days, rich)) {
defaultRuntime.log(line);
}
}, "Gateway usage cost failed");
}),
);
gatewayCallOpts(
gateway
.command("health")

View File

@@ -48,7 +48,7 @@ export function registerOnboardCommand(program: Command) {
"Acknowledge that agents are powerful and full system access is risky (required for --non-interactive)",
false,
)
.option("--flow <flow>", "Wizard flow: quickstart|advanced")
.option("--flow <flow>", "Wizard flow: quickstart|advanced|manual")
.option("--mode <mode>", "Wizard mode: local|remote")
.option(
"--auth-choice <choice>",
@@ -106,7 +106,7 @@ export function registerOnboardCommand(program: Command) {
workspace: opts.workspace as string | undefined,
nonInteractive: Boolean(opts.nonInteractive),
acceptRisk: Boolean(opts.acceptRisk),
flow: opts.flow as "quickstart" | "advanced" | undefined,
flow: opts.flow as "quickstart" | "advanced" | "manual" | undefined,
mode: opts.mode as "local" | "remote" | undefined,
authChoice: opts.authChoice as AuthChoice | undefined,
tokenProvider: opts.tokenProvider as string | undefined,

View File

@@ -42,7 +42,8 @@ export type ProviderChoice = ChannelChoice;
export type OnboardOptions = {
mode?: OnboardMode;
flow?: "quickstart" | "advanced";
/** "manual" is an alias for "advanced". */
flow?: "quickstart" | "advanced" | "manual";
workspace?: string;
nonInteractive?: boolean;
/** Required for non-interactive onboarding; skips the interactive risk prompt when true. */

View File

@@ -12,7 +12,9 @@ import type { OnboardOptions } from "./onboard-types.js";
export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv = defaultRuntime) {
assertSupportedRuntime(runtime);
const authChoice = opts.authChoice === "oauth" ? ("setup-token" as const) : opts.authChoice;
const normalizedOpts = authChoice === opts.authChoice ? opts : { ...opts, authChoice };
const flow = opts.flow === "manual" ? ("advanced" as const) : opts.flow;
const normalizedOpts =
authChoice === opts.authChoice && flow === opts.flow ? opts : { ...opts, authChoice, flow };
if (normalizedOpts.nonInteractive && normalizedOpts.acceptRisk !== true) {
runtime.error(

View File

@@ -237,7 +237,7 @@ export async function resolveNodeProgramArguments(params: {
runtime?: GatewayRuntimePreference;
nodePath?: string;
}): Promise<GatewayProgramArgs> {
const args = ["node", "start", "--host", params.host, "--port", String(params.port)];
const args = ["node", "run", "--host", params.host, "--port", String(params.port)];
if (params.tls || params.tlsFingerprint) args.push("--tls");
if (params.tlsFingerprint) args.push("--tls-fingerprint", params.tlsFingerprint);
if (params.nodeId) args.push("--node-id", params.nodeId);

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
describe("resolveAssistantIdentity avatar normalization", () => {
it("drops sentence-like avatar placeholders", () => {
const cfg: ClawdbotConfig = {
ui: {
assistant: {
avatar: "workspace-relative path, http(s) URL, or data URI",
},
},
};
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe(
DEFAULT_ASSISTANT_IDENTITY.avatar,
);
});
it("keeps short text avatars", () => {
const cfg: ClawdbotConfig = {
ui: {
assistant: {
avatar: "PS",
},
},
};
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("PS");
});
it("keeps path avatars", () => {
const cfg: ClawdbotConfig = {
ui: {
assistant: {
avatar: "avatars/clawd.png",
},
},
};
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("avatars/clawd.png");
});
});

View File

@@ -26,6 +26,25 @@ function coerceIdentityValue(value: string | undefined, maxLength: number): stri
return trimmed.slice(0, maxLength);
}
function isAvatarUrl(value: string): boolean {
return /^https?:\/\//i.test(value) || /^data:image\//i.test(value);
}
function looksLikeAvatarPath(value: string): boolean {
if (/[\\/]/.test(value)) return true;
return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value);
}
function normalizeAvatarValue(value: string | undefined): string | undefined {
if (!value) return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (isAvatarUrl(trimmed)) return trimmed;
if (looksLikeAvatarPath(trimmed)) return trimmed;
if (!/\s/.test(trimmed) && trimmed.length <= 4) return trimmed;
return undefined;
}
export function resolveAssistantIdentity(params: {
cfg: ClawdbotConfig;
agentId?: string | null;
@@ -43,12 +62,15 @@ export function resolveAssistantIdentity(params: {
coerceIdentityValue(fileIdentity?.name, MAX_ASSISTANT_NAME) ??
DEFAULT_ASSISTANT_IDENTITY.name;
const avatarCandidates = [
coerceIdentityValue(configAssistant?.avatar, MAX_ASSISTANT_AVATAR),
coerceIdentityValue(agentIdentity?.avatar, MAX_ASSISTANT_AVATAR),
coerceIdentityValue(agentIdentity?.emoji, MAX_ASSISTANT_AVATAR),
coerceIdentityValue(fileIdentity?.avatar, MAX_ASSISTANT_AVATAR),
coerceIdentityValue(fileIdentity?.emoji, MAX_ASSISTANT_AVATAR),
];
const avatar =
coerceIdentityValue(configAssistant?.avatar, MAX_ASSISTANT_AVATAR) ??
coerceIdentityValue(agentIdentity?.avatar, MAX_ASSISTANT_AVATAR) ??
coerceIdentityValue(agentIdentity?.emoji, MAX_ASSISTANT_AVATAR) ??
coerceIdentityValue(fileIdentity?.avatar, MAX_ASSISTANT_AVATAR) ??
coerceIdentityValue(fileIdentity?.emoji, MAX_ASSISTANT_AVATAR) ??
avatarCandidates.map((candidate) => normalizeAvatarValue(candidate)).find(Boolean) ??
DEFAULT_ASSISTANT_IDENTITY.avatar;
return { agentId, name, avatar };

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { resolveAssistantAvatarUrl } from "./control-ui.js";
describe("resolveAssistantAvatarUrl", () => {
it("keeps remote and data URLs", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "https://example.com/avatar.png",
agentId: "main",
basePath: "/ui",
}),
).toBe("https://example.com/avatar.png");
expect(
resolveAssistantAvatarUrl({
avatar: "data:image/png;base64,abc",
agentId: "main",
basePath: "/ui",
}),
).toBe("data:image/png;base64,abc");
});
it("prefixes basePath for /avatar endpoints", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "/avatar/main",
agentId: "main",
basePath: "/ui",
}),
).toBe("/ui/avatar/main");
expect(
resolveAssistantAvatarUrl({
avatar: "/ui/avatar/main",
agentId: "main",
basePath: "/ui",
}),
).toBe("/ui/avatar/main");
});
it("maps local avatar paths to the avatar endpoint", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "avatars/me.png",
agentId: "main",
basePath: "/ui",
}),
).toBe("/ui/avatar/main");
expect(
resolveAssistantAvatarUrl({
avatar: "avatars/profile",
agentId: "main",
basePath: "/ui",
}),
).toBe("/ui/avatar/main");
});
it("keeps short text avatars", () => {
expect(
resolveAssistantAvatarUrl({
avatar: "PS",
agentId: "main",
basePath: "/ui",
}),
).toBe("PS");
});
});

View File

@@ -98,7 +98,7 @@ function sendJson(res: ServerResponse, status: number, body: unknown) {
res.end(JSON.stringify(body));
}
function buildAvatarUrl(basePath: string, agentId: string): string {
export function buildAvatarUrl(basePath: string, agentId: string): string {
return basePath ? `${basePath}${AVATAR_PREFIX}/${agentId}` : `${AVATAR_PREFIX}/${agentId}`;
}
@@ -206,11 +206,49 @@ interface ServeIndexHtmlOpts {
agentId?: string;
}
function looksLikeLocalAvatarPath(value: string): boolean {
if (/[\\/]/.test(value)) return true;
return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value);
}
export function resolveAssistantAvatarUrl(params: {
avatar?: string | null;
agentId?: string | null;
basePath?: string;
}): string | undefined {
const avatar = params.avatar?.trim();
if (!avatar) return undefined;
if (/^https?:\/\//i.test(avatar) || /^data:image\//i.test(avatar)) return avatar;
const basePath = normalizeControlUiBasePath(params.basePath);
const baseAvatarPrefix = basePath ? `${basePath}${AVATAR_PREFIX}/` : `${AVATAR_PREFIX}/`;
if (basePath && avatar.startsWith(`${AVATAR_PREFIX}/`)) {
return `${basePath}${avatar}`;
}
if (avatar.startsWith(baseAvatarPrefix)) return avatar;
if (!params.agentId) return avatar;
if (looksLikeLocalAvatarPath(avatar)) {
return buildAvatarUrl(basePath, params.agentId);
}
return avatar;
}
function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) {
const { basePath, config, agentId } = opts;
const identity = config
? resolveAssistantIdentity({ cfg: config, agentId })
: DEFAULT_ASSISTANT_IDENTITY;
const resolvedAgentId =
typeof (identity as { agentId?: string }).agentId === "string"
? (identity as { agentId?: string }).agentId
: agentId;
const avatarValue =
resolveAssistantAvatarUrl({
avatar: identity.avatar,
agentId: resolvedAgentId,
basePath,
}) ?? identity.avatar;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
const raw = fs.readFileSync(indexPath, "utf8");
@@ -218,7 +256,7 @@ function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndex
injectControlUiConfig(raw, {
basePath,
assistantName: identity.name,
assistantAvatar: identity.avatar,
assistantAvatar: avatarValue,
}),
);
}

View File

@@ -140,6 +140,8 @@ import {
SessionsListParamsSchema,
type SessionsPatchParams,
SessionsPatchParamsSchema,
type SessionsPreviewParams,
SessionsPreviewParamsSchema,
type SessionsResetParams,
SessionsResetParamsSchema,
type SessionsResolveParams,
@@ -229,6 +231,9 @@ export const validateNodeInvokeResultParams = ajv.compile<NodeInvokeResultParams
);
export const validateNodeEventParams = ajv.compile<NodeEventParams>(NodeEventParamsSchema);
export const validateSessionsListParams = ajv.compile<SessionsListParams>(SessionsListParamsSchema);
export const validateSessionsPreviewParams = ajv.compile<SessionsPreviewParams>(
SessionsPreviewParamsSchema,
);
export const validateSessionsResolveParams = ajv.compile<SessionsResolveParams>(
SessionsResolveParamsSchema,
);
@@ -376,6 +381,7 @@ export {
NodeListParamsSchema,
NodeInvokeParamsSchema,
SessionsListParamsSchema,
SessionsPreviewParamsSchema,
SessionsPatchParamsSchema,
SessionsResetParamsSchema,
SessionsDeleteParamsSchema,
@@ -488,6 +494,7 @@ export type {
NodeInvokeResultParams,
NodeEventParams,
SessionsListParams,
SessionsPreviewParams,
SessionsResolveParams,
SessionsPatchParams,
SessionsResetParams,

View File

@@ -108,6 +108,7 @@ import {
SessionsDeleteParamsSchema,
SessionsListParamsSchema,
SessionsPatchParamsSchema,
SessionsPreviewParamsSchema,
SessionsResetParamsSchema,
SessionsResolveParamsSchema,
} from "./sessions.js";
@@ -155,6 +156,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
NodeEventParams: NodeEventParamsSchema,
NodeInvokeRequestEvent: NodeInvokeRequestEventSchema,
SessionsListParams: SessionsListParamsSchema,
SessionsPreviewParams: SessionsPreviewParamsSchema,
SessionsResolveParams: SessionsResolveParamsSchema,
SessionsPatchParams: SessionsPatchParamsSchema,
SessionsResetParams: SessionsResetParamsSchema,

View File

@@ -26,6 +26,15 @@ export const SessionsListParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const SessionsPreviewParamsSchema = Type.Object(
{
keys: Type.Array(NonEmptyString, { minItems: 1 }),
limit: Type.Optional(Type.Integer({ minimum: 1 })),
maxChars: Type.Optional(Type.Integer({ minimum: 20 })),
},
{ additionalProperties: false },
);
export const SessionsResolveParamsSchema = Type.Object(
{
key: Type.Optional(NonEmptyString),

View File

@@ -101,6 +101,7 @@ import type {
SessionsDeleteParamsSchema,
SessionsListParamsSchema,
SessionsPatchParamsSchema,
SessionsPreviewParamsSchema,
SessionsResetParamsSchema,
SessionsResolveParamsSchema,
} from "./sessions.js";
@@ -144,6 +145,7 @@ export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
export type NodeInvokeResultParams = Static<typeof NodeInvokeResultParamsSchema>;
export type NodeEventParams = Static<typeof NodeEventParamsSchema>;
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
export type SessionsPreviewParams = Static<typeof SessionsPreviewParamsSchema>;
export type SessionsResolveParams = Static<typeof SessionsResolveParamsSchema>;
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;

View File

@@ -34,6 +34,7 @@ const BASE_METHODS = [
"voicewake.get",
"voicewake.set",
"sessions.list",
"sessions.preview",
"sessions.patch",
"sessions.reset",
"sessions.delete",

View File

@@ -59,6 +59,7 @@ const READ_METHODS = new Set([
"skills.status",
"voicewake.get",
"sessions.list",
"sessions.preview",
"cron.list",
"cron.status",
"cron.runs",

View File

@@ -38,6 +38,7 @@ import {
import { loadSessionEntry } from "../session-utils.js";
import { formatForLog } from "../ws-log.js";
import { resolveAssistantIdentity } from "../assistant-identity.js";
import { resolveAssistantAvatarUrl } from "../control-ui.js";
import { waitForAgentJob } from "./agent-job.js";
import type { GatewayRequestHandlers } from "./types.js";
@@ -407,7 +408,13 @@ export const agentHandlers: GatewayRequestHandlers = {
}
const cfg = loadConfig();
const identity = resolveAssistantIdentity({ cfg, agentId });
respond(true, identity, undefined);
const avatarValue =
resolveAssistantAvatarUrl({
avatar: identity.avatar,
agentId: identity.agentId,
basePath: cfg.gateway?.controlUi?.basePath,
}) ?? identity.avatar;
respond(true, { ...identity, avatar: avatarValue }, undefined);
},
"agent.wait": async ({ params, respond }) => {
if (!validateAgentWaitParams(params)) {

View File

@@ -6,6 +6,7 @@ import { stopSubagentsForRequester } from "../../auto-reply/reply/abort.js";
import { clearSessionQueues } from "../../auto-reply/reply/queue.js";
import { loadConfig } from "../../config/config.js";
import {
loadSessionStore,
snapshotSessionOrigin,
resolveMainSessionKey,
type SessionEntry,
@@ -19,6 +20,7 @@ import {
validateSessionsDeleteParams,
validateSessionsListParams,
validateSessionsPatchParams,
validateSessionsPreviewParams,
validateSessionsResetParams,
validateSessionsResolveParams,
} from "../protocol/index.js";
@@ -27,9 +29,12 @@ import {
listSessionsFromStore,
loadCombinedSessionStoreForGateway,
loadSessionEntry,
readSessionPreviewItemsFromTranscript,
resolveGatewaySessionStoreTarget,
resolveSessionTranscriptCandidates,
type SessionsPatchResult,
type SessionsPreviewEntry,
type SessionsPreviewResult,
} from "../session-utils.js";
import { applySessionsPatchToStore } from "../sessions-patch.js";
import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js";
@@ -59,6 +64,74 @@ export const sessionsHandlers: GatewayRequestHandlers = {
});
respond(true, result, undefined);
},
"sessions.preview": ({ params, respond }) => {
if (!validateSessionsPreviewParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid sessions.preview params: ${formatValidationErrors(
validateSessionsPreviewParams.errors,
)}`,
),
);
return;
}
const p = params as import("../protocol/index.js").SessionsPreviewParams;
const keysRaw = Array.isArray(p.keys) ? p.keys : [];
const keys = keysRaw
.map((key) => String(key ?? "").trim())
.filter(Boolean)
.slice(0, 64);
const limit =
typeof p.limit === "number" && Number.isFinite(p.limit) ? Math.max(1, p.limit) : 12;
const maxChars =
typeof p.maxChars === "number" && Number.isFinite(p.maxChars)
? Math.max(20, p.maxChars)
: 240;
if (keys.length === 0) {
respond(true, { ts: Date.now(), previews: [] } satisfies SessionsPreviewResult, undefined);
return;
}
const cfg = loadConfig();
const storeCache = new Map<string, Record<string, SessionEntry>>();
const previews: SessionsPreviewEntry[] = [];
for (const key of keys) {
try {
const target = resolveGatewaySessionStoreTarget({ cfg, key });
const store = storeCache.get(target.storePath) ?? loadSessionStore(target.storePath);
storeCache.set(target.storePath, store);
const entry =
target.storeKeys.map((candidate) => store[candidate]).find(Boolean) ??
store[target.canonicalKey];
if (!entry?.sessionId) {
previews.push({ key, status: "missing", items: [] });
continue;
}
const items = readSessionPreviewItemsFromTranscript(
entry.sessionId,
target.storePath,
entry.sessionFile,
target.agentId,
limit,
maxChars,
);
previews.push({
key,
status: items.length > 0 ? "ok" : "empty",
items,
});
} catch {
previews.push({ key, status: "error", items: [] });
}
}
respond(true, { ts: Date.now(), previews } satisfies SessionsPreviewResult, undefined);
},
"sessions.resolve": ({ params, respond }) => {
if (!validateSessionsResolveParams(params)) {
respond(

View File

@@ -1,16 +1,78 @@
import { loadConfig } from "../../config/config.js";
import type { CostUsageSummary } from "../../infra/session-cost-usage.js";
import { loadCostUsageSummary } from "../../infra/session-cost-usage.js";
import { loadProviderUsageSummary } from "../../infra/provider-usage.js";
import type { GatewayRequestHandlers } from "./types.js";
const COST_USAGE_CACHE_TTL_MS = 30_000;
type CostUsageCacheEntry = {
summary?: CostUsageSummary;
updatedAt?: number;
inFlight?: Promise<CostUsageSummary>;
};
const costUsageCache = new Map<number, CostUsageCacheEntry>();
const parseDays = (raw: unknown): number => {
if (typeof raw === "number" && Number.isFinite(raw)) return Math.floor(raw);
if (typeof raw === "string" && raw.trim() !== "") {
const parsed = Number(raw);
if (Number.isFinite(parsed)) return Math.floor(parsed);
}
return 30;
};
async function loadCostUsageSummaryCached(params: {
days: number;
config: ReturnType<typeof loadConfig>;
}): Promise<CostUsageSummary> {
const days = Math.max(1, params.days);
const now = Date.now();
const cached = costUsageCache.get(days);
if (cached?.summary && cached.updatedAt && now - cached.updatedAt < COST_USAGE_CACHE_TTL_MS) {
return cached.summary;
}
if (cached?.inFlight) {
if (cached.summary) return cached.summary;
return await cached.inFlight;
}
const entry: CostUsageCacheEntry = cached ?? {};
const inFlight = loadCostUsageSummary({ days, config: params.config })
.then((summary) => {
costUsageCache.set(days, { summary, updatedAt: Date.now() });
return summary;
})
.catch((err) => {
if (entry.summary) return entry.summary;
throw err;
})
.finally(() => {
const current = costUsageCache.get(days);
if (current?.inFlight === inFlight) {
current.inFlight = undefined;
costUsageCache.set(days, current);
}
});
entry.inFlight = inFlight;
costUsageCache.set(days, entry);
if (entry.summary) return entry.summary;
return await inFlight;
}
export const usageHandlers: GatewayRequestHandlers = {
"usage.status": async ({ respond }) => {
const summary = await loadProviderUsageSummary();
respond(true, summary, undefined);
},
"usage.cost": async ({ respond }) => {
"usage.cost": async ({ respond, params }) => {
const config = loadConfig();
const summary = await loadCostUsageSummary({ days: 30, config });
const days = parseDays(params?.days);
const summary = await loadCostUsageSummaryCached({ days, config });
respond(true, summary, undefined);
},
};

View File

@@ -103,6 +103,7 @@ describe("gateway server sessions", () => {
expect((hello as unknown as { features?: { methods?: string[] } }).features?.methods).toEqual(
expect.arrayContaining([
"sessions.list",
"sessions.preview",
"sessions.patch",
"sessions.reset",
"sessions.delete",
@@ -338,6 +339,53 @@ describe("gateway server sessions", () => {
await server.close();
});
test("sessions.preview returns transcript previews", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-preview-"));
const storePath = path.join(dir, "sessions.json");
testState.sessionStorePath = storePath;
const sessionId = "sess-preview";
const transcriptPath = path.join(dir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "user", content: "Hello" } }),
JSON.stringify({ message: { role: "assistant", content: "Hi" } }),
JSON.stringify({
message: { role: "assistant", content: [{ type: "toolcall", name: "weather" }] },
}),
JSON.stringify({ message: { role: "assistant", content: "Forecast ready" } }),
];
await fs.writeFile(transcriptPath, lines.join("\n"), "utf-8");
await writeSessionStore({
entries: {
main: {
sessionId,
updatedAt: Date.now(),
},
},
});
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const preview = await rpcReq<{
previews: Array<{
key: string;
status: string;
items: Array<{ role: string; text: string }>;
}>;
}>(ws, "sessions.preview", { keys: ["main"], limit: 3, maxChars: 120 });
expect(preview.ok).toBe(true);
const entry = preview.payload?.previews[0];
expect(entry?.key).toBe("main");
expect(entry?.status).toBe("ok");
expect(entry?.items.map((item) => item.role)).toEqual(["assistant", "tool", "assistant"]);
expect(entry?.items[1]?.text).toContain("call weather");
ws.close();
await server.close();
});
test("sessions.delete rejects main and aborts active runs", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
const storePath = path.join(dir, "sessions.json");

View File

@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
import {
readFirstUserMessageFromTranscript,
readLastMessagePreviewFromTranscript,
readSessionPreviewItemsFromTranscript,
} from "./session-utils.fs.js";
describe("readFirstUserMessageFromTranscript", () => {
@@ -341,3 +342,65 @@ describe("readLastMessagePreviewFromTranscript", () => {
expect(result).toBe("Valid UTF-8: 你好世界 🌍");
});
});
describe("readSessionPreviewItemsFromTranscript", () => {
let tmpDir: string;
let storePath: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-preview-test-"));
storePath = path.join(tmpDir, "sessions.json");
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test("returns recent preview items with tool summary", () => {
const sessionId = "preview-session";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "user", content: "Hello" } }),
JSON.stringify({ message: { role: "assistant", content: "Hi" } }),
JSON.stringify({
message: { role: "assistant", content: [{ type: "toolcall", name: "weather" }] },
}),
JSON.stringify({ message: { role: "assistant", content: "Forecast ready" } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readSessionPreviewItemsFromTranscript(
sessionId,
storePath,
undefined,
undefined,
3,
120,
);
expect(result.map((item) => item.role)).toEqual(["assistant", "tool", "assistant"]);
expect(result[1]?.text).toContain("call weather");
});
test("truncates preview text to max chars", () => {
const sessionId = "preview-truncate";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const longText = "a".repeat(60);
const lines = [JSON.stringify({ message: { role: "assistant", content: longText } })];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readSessionPreviewItemsFromTranscript(
sessionId,
storePath,
undefined,
undefined,
1,
24,
);
expect(result).toHaveLength(1);
expect(result[0]?.text.length).toBe(24);
expect(result[0]?.text.endsWith("...")).toBe(true);
});
});

View File

@@ -3,6 +3,8 @@ import os from "node:os";
import path from "node:path";
import { resolveSessionTranscriptPath } from "../config/sessions.js";
import { stripEnvelope } from "./chat-sanitize.js";
import type { SessionPreviewItem } from "./session-utils.types.js";
export function readSessionMessages(
sessionId: string,
@@ -189,3 +191,202 @@ export function readLastMessagePreviewFromTranscript(
}
return null;
}
const PREVIEW_READ_SIZES = [64 * 1024, 256 * 1024, 1024 * 1024];
const PREVIEW_MAX_LINES = 200;
type TranscriptContentEntry = {
type?: string;
text?: string;
name?: string;
};
type TranscriptPreviewMessage = {
role?: string;
content?: string | TranscriptContentEntry[];
text?: string;
toolName?: string;
tool_name?: string;
};
function normalizeRole(role: string | undefined, isTool: boolean): SessionPreviewItem["role"] {
if (isTool) return "tool";
switch ((role ?? "").toLowerCase()) {
case "user":
return "user";
case "assistant":
return "assistant";
case "system":
return "system";
case "tool":
return "tool";
default:
return "other";
}
}
function truncatePreviewText(text: string, maxChars: number): string {
if (maxChars <= 0 || text.length <= maxChars) return text;
if (maxChars <= 3) return text.slice(0, maxChars);
return `${text.slice(0, maxChars - 3)}...`;
}
function extractPreviewText(message: TranscriptPreviewMessage): string | null {
if (typeof message.content === "string") {
const trimmed = message.content.trim();
return trimmed ? trimmed : null;
}
if (Array.isArray(message.content)) {
const parts = message.content
.map((entry) => (typeof entry?.text === "string" ? entry.text : ""))
.filter((text) => text.trim().length > 0);
if (parts.length > 0) {
return parts.join("\n").trim();
}
}
if (typeof message.text === "string") {
const trimmed = message.text.trim();
return trimmed ? trimmed : null;
}
return null;
}
function isToolCall(message: TranscriptPreviewMessage): boolean {
if (message.toolName || message.tool_name) return true;
if (!Array.isArray(message.content)) return false;
return message.content.some((entry) => {
if (entry?.name) return true;
const raw = typeof entry?.type === "string" ? entry.type.toLowerCase() : "";
return raw === "toolcall" || raw === "tool_call";
});
}
function extractToolNames(message: TranscriptPreviewMessage): string[] {
const names: string[] = [];
if (Array.isArray(message.content)) {
for (const entry of message.content) {
if (typeof entry?.name === "string" && entry.name.trim()) {
names.push(entry.name.trim());
}
}
}
const toolName = typeof message.toolName === "string" ? message.toolName : message.tool_name;
if (typeof toolName === "string" && toolName.trim()) {
names.push(toolName.trim());
}
return names;
}
function extractMediaSummary(message: TranscriptPreviewMessage): string | null {
if (!Array.isArray(message.content)) return null;
for (const entry of message.content) {
const raw = typeof entry?.type === "string" ? entry.type.trim().toLowerCase() : "";
if (!raw || raw === "text" || raw === "toolcall" || raw === "tool_call") continue;
return `[${raw}]`;
}
return null;
}
function buildPreviewItems(
messages: TranscriptPreviewMessage[],
maxItems: number,
maxChars: number,
): SessionPreviewItem[] {
const items: SessionPreviewItem[] = [];
for (const message of messages) {
const toolCall = isToolCall(message);
const role = normalizeRole(message.role, toolCall);
let text = extractPreviewText(message);
if (!text) {
const toolNames = extractToolNames(message);
if (toolNames.length > 0) {
const shown = toolNames.slice(0, 2);
const overflow = toolNames.length - shown.length;
text = `call ${shown.join(", ")}`;
if (overflow > 0) text += ` +${overflow}`;
}
}
if (!text) {
text = extractMediaSummary(message);
}
if (!text) continue;
let trimmed = text.trim();
if (!trimmed) continue;
if (role === "user") {
trimmed = stripEnvelope(trimmed);
}
trimmed = truncatePreviewText(trimmed, maxChars);
items.push({ role, text: trimmed });
}
if (items.length <= maxItems) return items;
return items.slice(-maxItems);
}
function readRecentMessagesFromTranscript(
filePath: string,
maxMessages: number,
readBytes: number,
): TranscriptPreviewMessage[] {
let fd: number | null = null;
try {
fd = fs.openSync(filePath, "r");
const stat = fs.fstatSync(fd);
const size = stat.size;
if (size === 0) return [];
const readStart = Math.max(0, size - readBytes);
const readLen = Math.min(size, readBytes);
const buf = Buffer.alloc(readLen);
fs.readSync(fd, buf, 0, readLen, readStart);
const chunk = buf.toString("utf-8");
const lines = chunk.split(/\r?\n/).filter((l) => l.trim());
const tailLines = lines.slice(-PREVIEW_MAX_LINES);
const collected: TranscriptPreviewMessage[] = [];
for (let i = tailLines.length - 1; i >= 0; i--) {
const line = tailLines[i];
try {
const parsed = JSON.parse(line);
const msg = parsed?.message as TranscriptPreviewMessage | undefined;
if (msg && typeof msg === "object") {
collected.push(msg);
if (collected.length >= maxMessages) break;
}
} catch {
// skip malformed lines
}
}
return collected.reverse();
} catch {
return [];
} finally {
if (fd !== null) fs.closeSync(fd);
}
}
export function readSessionPreviewItemsFromTranscript(
sessionId: string,
storePath: string | undefined,
sessionFile: string | undefined,
agentId: string | undefined,
maxItems: number,
maxChars: number,
): SessionPreviewItem[] {
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId);
const filePath = candidates.find((p) => fs.existsSync(p));
if (!filePath) return [];
const boundedItems = Math.max(1, Math.min(maxItems, 50));
const boundedChars = Math.max(20, Math.min(maxChars, 2000));
for (const readSize of PREVIEW_READ_SIZES) {
const messages = readRecentMessagesFromTranscript(filePath, boundedItems, readSize);
if (messages.length > 0 || readSize === PREVIEW_READ_SIZES[PREVIEW_READ_SIZES.length - 1]) {
return buildPreviewItems(messages, boundedItems, boundedChars);
}
}
return [];
}

View File

@@ -38,6 +38,7 @@ export {
capArrayByJsonBytes,
readFirstUserMessageFromTranscript,
readLastMessagePreviewFromTranscript,
readSessionPreviewItemsFromTranscript,
readSessionMessages,
resolveSessionTranscriptCandidates,
} from "./session-utils.fs.js";
@@ -47,6 +48,8 @@ export type {
GatewaySessionsDefaults,
SessionsListResult,
SessionsPatchResult,
SessionsPreviewEntry,
SessionsPreviewResult,
} from "./session-utils.types.js";
const DERIVED_TITLE_MAX_LEN = 60;

View File

@@ -55,6 +55,22 @@ export type GatewayAgentRow = {
};
};
export type SessionPreviewItem = {
role: "user" | "assistant" | "tool" | "system" | "other";
text: string;
};
export type SessionsPreviewEntry = {
key: string;
status: "ok" | "empty" | "missing" | "error";
items: SessionPreviewItem[];
};
export type SessionsPreviewResult = {
ts: number;
previews: SessionsPreviewEntry[];
};
export type SessionsListResult = {
ts: number;
path: string;

View File

@@ -160,11 +160,9 @@ const saveSessionToMemory: HookHandler = async (event) => {
await fs.writeFile(memoryFilePath, entry, "utf-8");
console.log("[session-memory] Memory file written successfully");
// Send confirmation message to user with filename
// Log completion (but don't send user-visible confirmation - it's internal housekeeping)
const relPath = memoryFilePath.replace(os.homedir(), "~");
const confirmMsg = `💾 Session context saved to memory before reset.\n📄 ${relPath}`;
event.messages.push(confirmMsg);
console.log("[session-memory] Confirmation message queued:", confirmMsg);
console.log(`[session-memory] Session context saved to ${relPath}`);
} catch (err) {
console.error(
"[session-memory] Failed to save session memory:",

View File

@@ -358,14 +358,20 @@ function resolveExecutablePath(rawExecutable: string, cwd?: string, env?: NodeJS
const candidate = path.resolve(base, expanded);
return isExecutableFile(candidate) ? candidate : undefined;
}
const envPath = env?.PATH ?? process.env.PATH ?? "";
const envPath = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? "";
const entries = envPath.split(path.delimiter).filter(Boolean);
const hasExtension = process.platform === "win32" && path.extname(expanded).length > 0;
const extensions =
process.platform === "win32"
? hasExtension
? [""]
: (env?.PATHEXT ?? process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
: (
env?.PATHEXT ??
env?.Pathext ??
process.env.PATHEXT ??
process.env.Pathext ??
".EXE;.CMD;.BAT;.COM"
)
.split(";")
.map((ext) => ext.toLowerCase())
: [""];
@@ -459,6 +465,21 @@ function matchesPattern(pattern: string, target: string): boolean {
return regex.test(normalizedTarget);
}
function resolveAllowlistCandidatePath(
resolution: CommandResolution | null,
cwd?: string,
): string | undefined {
if (!resolution) return undefined;
if (resolution.resolvedPath) return resolution.resolvedPath;
const raw = resolution.rawExecutable?.trim();
if (!raw) return undefined;
const expanded = raw.startsWith("~") ? expandHome(raw) : raw;
if (!expanded.includes("/") && !expanded.includes("\\")) return undefined;
if (path.isAbsolute(expanded)) return expanded;
const base = cwd && cwd.trim() ? cwd.trim() : process.cwd();
return path.resolve(base, expanded);
}
export function matchAllowlist(
entries: ExecAllowlistEntry[],
resolution: CommandResolution | null,
@@ -513,7 +534,7 @@ function splitShellPipeline(command: string): { ok: boolean; reason?: string; se
escaped = false;
continue;
}
if (!inSingle && ch === "\\") {
if (!inSingle && !inDouble && ch === "\\") {
escaped = true;
buf += ch;
continue;
@@ -589,7 +610,7 @@ function tokenizeShellSegment(segment: string): string[] | null {
escaped = false;
continue;
}
if (!inSingle && ch === "\\") {
if (!inSingle && !inDouble && ch === "\\") {
escaped = true;
continue;
}
@@ -764,7 +785,12 @@ export function evaluateExecAllowlist(params: {
}
const allowSkills = params.autoAllowSkills === true && (params.skillBins?.size ?? 0) > 0;
const allowlistSatisfied = params.analysis.segments.every((segment) => {
const match = matchAllowlist(params.allowlist, segment.resolution);
const candidatePath = resolveAllowlistCandidatePath(segment.resolution, params.cwd);
const candidateResolution =
candidatePath && segment.resolution
? { ...segment.resolution, resolvedPath: candidatePath }
: segment.resolution;
const match = matchAllowlist(params.allowlist, candidateResolution);
if (match) allowlistMatches.push(match);
const safe = isSafeBinUsage({
argv: segment.argv,

View File

@@ -184,9 +184,19 @@ export async function loadCostUsageSummary(params?: {
const sessionsDir = resolveSessionTranscriptsDirForAgent(params?.agentId);
const entries = await fs.promises.readdir(sessionsDir, { withFileTypes: true }).catch(() => []);
const files = entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
.map((entry) => path.join(sessionsDir, entry.name));
const files = (
await Promise.all(
entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
.map(async (entry) => {
const filePath = path.join(sessionsDir, entry.name);
const stats = await fs.promises.stat(filePath).catch(() => null);
if (!stats) return null;
if (stats.mtimeMs < sinceTime) return null;
return filePath;
}),
)
).filter((filePath): filePath is string => Boolean(filePath));
for (const filePath of files) {
await scanUsageFile({

View File

@@ -94,7 +94,7 @@ async function uploadSlackFile(params: {
file: buffer,
filename: fileName,
...(params.caption ? { initial_comment: params.caption } : {}),
...(contentType ? { filetype: contentType } : {}),
// Note: filetype is deprecated in files.uploadV2, Slack auto-detects from file content
};
const payload: FilesUploadV2Arguments = params.threadTs
? { ...basePayload, thread_ts: params.threadTs }

View File

@@ -191,7 +191,7 @@ export async function configureGatewayForOnboarding(
const tokenInput = await prompter.text({
message: "Gateway token (blank to generate)",
placeholder: "Needed for multi-machine or non-loopback access",
initialValue: quickstartGateway.token ?? randomToken(),
initialValue: quickstartGateway.token ?? "",
});
gatewayToken = String(tokenInput).trim() || randomToken();
}

View File

@@ -82,10 +82,9 @@ export async function runOnboardingWizard(
const snapshot = await readConfigFileSnapshot();
let baseConfig: ClawdbotConfig = snapshot.valid ? snapshot.config : {};
if (snapshot.exists) {
const title = snapshot.valid ? "Existing config detected" : "Invalid config";
await prompter.note(summarizeExistingConfig(baseConfig), title);
if (!snapshot.valid && snapshot.issues.length > 0) {
if (snapshot.exists && !snapshot.valid) {
await prompter.note(summarizeExistingConfig(baseConfig), "Invalid config");
if (snapshot.issues.length > 0) {
await prompter.note(
[
...snapshot.issues.map((iss) => `- ${iss.path}: ${iss.message}`),
@@ -95,14 +94,51 @@ export async function runOnboardingWizard(
"Config issues",
);
}
await prompter.outro(
`Config invalid. Run \`${formatCliCommand("clawdbot doctor")}\` to repair it, then re-run onboarding.`,
);
runtime.exit(1);
return;
}
if (!snapshot.valid) {
await prompter.outro(
`Config invalid. Run \`${formatCliCommand("clawdbot doctor")}\` to repair it, then re-run onboarding.`,
);
runtime.exit(1);
return;
}
const quickstartHint = `Configure details later via ${formatCliCommand("clawdbot configure")}.`;
const manualHint = "Configure port, network, Tailscale, and auth options.";
const explicitFlowRaw = opts.flow?.trim();
const normalizedExplicitFlow = explicitFlowRaw === "manual" ? "advanced" : explicitFlowRaw;
if (
normalizedExplicitFlow &&
normalizedExplicitFlow !== "quickstart" &&
normalizedExplicitFlow !== "advanced"
) {
runtime.error("Invalid --flow (use quickstart, manual, or advanced).");
runtime.exit(1);
return;
}
const explicitFlow: WizardFlow | undefined =
normalizedExplicitFlow === "quickstart" || normalizedExplicitFlow === "advanced"
? normalizedExplicitFlow
: undefined;
let flow: WizardFlow =
explicitFlow ??
((await prompter.select({
message: "Onboarding mode",
options: [
{ value: "quickstart", label: "QuickStart", hint: quickstartHint },
{ value: "advanced", label: "Manual", hint: manualHint },
],
initialValue: "quickstart",
})) as "quickstart" | "advanced");
if (opts.mode === "remote" && flow === "quickstart") {
await prompter.note(
"QuickStart only supports local gateways. Switching to Manual mode.",
"QuickStart",
);
flow = "advanced";
}
if (snapshot.exists) {
await prompter.note(summarizeExistingConfig(baseConfig), "Existing config detected");
const action = (await prompter.select({
message: "Config handling",
@@ -134,37 +170,6 @@ export async function runOnboardingWizard(
}
}
const quickstartHint = `Configure details later via ${formatCliCommand("clawdbot configure")}.`;
const advancedHint = "Configure port, network, Tailscale, and auth options.";
const explicitFlowRaw = opts.flow?.trim();
if (explicitFlowRaw && explicitFlowRaw !== "quickstart" && explicitFlowRaw !== "advanced") {
runtime.error("Invalid --flow (use quickstart or advanced).");
runtime.exit(1);
return;
}
const explicitFlow: WizardFlow | undefined =
explicitFlowRaw === "quickstart" || explicitFlowRaw === "advanced"
? explicitFlowRaw
: undefined;
let flow: WizardFlow =
explicitFlow ??
((await prompter.select({
message: "Onboarding mode",
options: [
{ value: "quickstart", label: "QuickStart", hint: quickstartHint },
{ value: "advanced", label: "Advanced", hint: advancedHint },
],
initialValue: "quickstart",
})) as "quickstart" | "advanced");
if (opts.mode === "remote" && flow === "quickstart") {
await prompter.note(
"QuickStart only supports local gateways. Switching to Advanced mode.",
"QuickStart",
);
flow = "advanced";
}
const quickstartGateway: QuickstartGatewayDefaults = (() => {
const hasExisting =
typeof baseConfig.gateway?.port === "number" ||