diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6289cf28e..109d886fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
- Models/MiniMax auth header defaults: set `authHeader: true` for both onboarding-generated MiniMax API providers and implicit built-in MiniMax (`minimax`, `minimax-portal`) provider templates so first requests no longer fail with MiniMax `401 authentication_error` due to missing `Authorization` header. Landed from contributor PRs #27622 by @riccoyuanft and #27631 by @kevinWangSheng. (#27600, #15303)
- Pi image-token usage: stop re-injecting history image blocks each turn, process image references from the current prompt only, and prune already-answered user-image blocks in stored history to prevent runaway token growth. (#27602)
- BlueBubbles/SSRF: auto-allowlist the configured `serverUrl` hostname for attachment fetches so localhost/private-IP BlueBubbles setups are no longer false-blocked by default SSRF checks. Landed from contributor PR #27648 by @lailoo. (#27599) Thanks @taylorhou for reporting.
+- Agents/Compaction + onboarding safety: prevent destructive double-compaction by stripping stale assistant usage around compaction boundaries, skipping post-compaction custom metadata writes in the same attempt, and cancelling safeguard compaction when there are no real conversation messages to summarize; harden workspace/bootstrap detection for memory-backed workspaces; and change `openclaw onboard --reset` default scope to `config+creds+sessions` (workspace deletion now requires `--reset-scope full`). (#26458, #27314) Thanks @jaden-clovervnd, @Sid-Qin, and @widingmarcus-cyber for fix direction in #26502, #26529, and #27492.
- Security/Gateway node pairing: pin paired-device `platform`/`deviceFamily` metadata across reconnects and bind those fields into device-auth signatures, so reconnect metadata spoofing cannot expand node command allowlists without explicit repair pairing. This ships in the next npm release (`2026.2.26`). Thanks @76embiid21 for reporting.
- Security/Sandbox path alias guard: reject broken symlink targets by resolving through existing ancestors and failing closed on out-of-root targets, preventing workspace-only `apply_patch` writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
- Security/Workspace FS boundary aliases: harden canonical boundary resolution for non-existent-leaf symlink aliases while preserving valid in-root aliases, preventing first-write workspace escapes via out-of-root symlink targets. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
diff --git a/docs/cli/index.md b/docs/cli/index.md
index bf7218146..bb09b0622 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -328,7 +328,8 @@ Interactive wizard to set up gateway, workspace, and skills.
Options:
- `--workspace
`
-- `--reset` (reset config + credentials + sessions + workspace before wizard)
+- `--reset` (reset config + credentials + sessions before wizard)
+- `--reset-scope ` (default `config+creds+sessions`; use `full` to also remove workspace)
- `--non-interactive`
- `--mode `
- `--flow ` (manual is an alias for advanced)
diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md
index 6cc8a83b9..4f85e7e86 100644
--- a/docs/reference/wizard.md
+++ b/docs/reference/wizard.md
@@ -20,6 +20,8 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
- If `~/.openclaw/openclaw.json` exists, choose **Keep / Modify / Reset**.
- Re-running the wizard does **not** wipe anything unless you explicitly choose **Reset**
(or pass `--reset`).
+ - CLI `--reset` defaults to `config+creds+sessions`; use `--reset-scope full`
+ to also remove workspace.
- If the config is invalid or contains legacy keys, the wizard stops and asks
you to run `openclaw doctor` before continuing.
- Reset uses `trash` (never `rm`) and offers scopes:
diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md
index 1790020c8..5019956a0 100644
--- a/docs/start/wizard-cli-reference.md
+++ b/docs/start/wizard-cli-reference.md
@@ -33,6 +33,7 @@ It does not install or modify anything on the remote host.
- If `~/.openclaw/openclaw.json` exists, choose Keep, Modify, or Reset.
- Re-running the wizard does not wipe anything unless you explicitly choose Reset (or pass `--reset`).
+ - CLI `--reset` defaults to `config+creds+sessions`; use `--reset-scope full` to also remove workspace.
- If config is invalid or contains legacy keys, the wizard stops and asks you to run `openclaw doctor` before continuing.
- Reset uses `trash` and offers scopes:
- Config only
diff --git a/docs/start/wizard.md b/docs/start/wizard.md
index 6cdb2e8fa..ecf059c3b 100644
--- a/docs/start/wizard.md
+++ b/docs/start/wizard.md
@@ -77,6 +77,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
Re-running the wizard does **not** wipe anything unless you explicitly choose **Reset** (or pass `--reset`).
+CLI `--reset` defaults to config, credentials, and sessions; use `--reset-scope full` to include workspace.
If the config is invalid or contains legacy keys, the wizard asks you to run `openclaw doctor` first.
diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts
index 6e401b92e..20ea0905d 100644
--- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts
+++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts
@@ -268,6 +268,110 @@ describe("sanitizeSessionHistory", () => {
expect(assistants[1]?.usage).toBeDefined();
});
+ it("drops stale usage when compaction summary appears before kept assistant messages", async () => {
+ vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
+
+ const compactionTs = Date.parse("2026-02-26T12:00:00.000Z");
+ const messages = [
+ {
+ role: "compactionSummary",
+ summary: "compressed",
+ tokensBefore: 191_919,
+ timestamp: new Date(compactionTs).toISOString(),
+ },
+ {
+ role: "assistant",
+ content: [{ type: "text", text: "kept pre-compaction answer" }],
+ stopReason: "stop",
+ timestamp: compactionTs - 1_000,
+ usage: {
+ input: 191_919,
+ output: 2_000,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 193_919,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ },
+ ] as unknown as AgentMessage[];
+
+ const result = await sanitizeSessionHistory({
+ messages,
+ modelApi: "openai-responses",
+ provider: "openai",
+ sessionManager: mockSessionManager,
+ sessionId: TEST_SESSION_ID,
+ });
+
+ const assistant = result.find((message) => message.role === "assistant") as
+ | (AgentMessage & { usage?: unknown })
+ | undefined;
+ expect(assistant?.usage).toBeUndefined();
+ });
+
+ it("keeps fresh usage after compaction timestamp in summary-first ordering", async () => {
+ vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
+
+ const compactionTs = Date.parse("2026-02-26T12:00:00.000Z");
+ const messages = [
+ {
+ role: "compactionSummary",
+ summary: "compressed",
+ tokensBefore: 123_000,
+ timestamp: new Date(compactionTs).toISOString(),
+ },
+ {
+ role: "assistant",
+ content: [{ type: "text", text: "kept pre-compaction answer" }],
+ stopReason: "stop",
+ timestamp: compactionTs - 2_000,
+ usage: {
+ input: 120_000,
+ output: 3_000,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 123_000,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ },
+ { role: "user", content: "new question", timestamp: compactionTs + 1_000 },
+ {
+ role: "assistant",
+ content: [{ type: "text", text: "fresh answer" }],
+ stopReason: "stop",
+ timestamp: compactionTs + 2_000,
+ usage: {
+ input: 1_000,
+ output: 250,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 1_250,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ },
+ ] as unknown as AgentMessage[];
+
+ const result = await sanitizeSessionHistory({
+ messages,
+ modelApi: "openai-responses",
+ provider: "openai",
+ sessionManager: mockSessionManager,
+ sessionId: TEST_SESSION_ID,
+ });
+
+ const assistants = result.filter((message) => message.role === "assistant") as Array<
+ AgentMessage & { usage?: unknown; content?: unknown }
+ >;
+ const keptAssistant = assistants.find((message) =>
+ JSON.stringify(message.content).includes("kept pre-compaction answer"),
+ );
+ const freshAssistant = assistants.find((message) =>
+ JSON.stringify(message.content).includes("fresh answer"),
+ );
+ expect(keptAssistant?.usage).toBeUndefined();
+ expect(freshAssistant?.usage).toBeDefined();
+ });
+
it("keeps reasoning-only assistant messages for openai-responses", async () => {
setNonGoogleModelApi();
diff --git a/src/agents/pi-embedded-runner/google.ts b/src/agents/pi-embedded-runner/google.ts
index 42970ea4e..5e8f546cd 100644
--- a/src/agents/pi-embedded-runner/google.ts
+++ b/src/agents/pi-embedded-runner/google.ts
@@ -133,27 +133,59 @@ function annotateInterSessionUserMessages(messages: AgentMessage[]): AgentMessag
return touched ? out : messages;
}
-function stripStaleAssistantUsageBeforeLatestCompaction(messages: AgentMessage[]): AgentMessage[] {
- let latestCompactionSummaryIndex = -1;
- for (let i = 0; i < messages.length; i += 1) {
- if (messages[i]?.role === "compactionSummary") {
- latestCompactionSummaryIndex = i;
+function parseMessageTimestamp(value: unknown): number | null {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return value;
+ }
+ if (typeof value === "string") {
+ const parsed = Date.parse(value);
+ if (Number.isFinite(parsed)) {
+ return parsed;
}
}
- if (latestCompactionSummaryIndex <= 0) {
+ return null;
+}
+
+function stripStaleAssistantUsageBeforeLatestCompaction(messages: AgentMessage[]): AgentMessage[] {
+ let latestCompactionSummaryIndex = -1;
+ let latestCompactionTimestamp: number | null = null;
+ for (let i = 0; i < messages.length; i += 1) {
+ const entry = messages[i];
+ if (entry?.role !== "compactionSummary") {
+ continue;
+ }
+ latestCompactionSummaryIndex = i;
+ latestCompactionTimestamp = parseMessageTimestamp(
+ (entry as { timestamp?: unknown }).timestamp ?? null,
+ );
+ }
+ if (latestCompactionSummaryIndex === -1) {
return messages;
}
const out = [...messages];
let touched = false;
- for (let i = 0; i < latestCompactionSummaryIndex; i += 1) {
- const candidate = out[i] as (AgentMessage & { usage?: unknown }) | undefined;
+ for (let i = 0; i < out.length; i += 1) {
+ const candidate = out[i] as
+ | (AgentMessage & { usage?: unknown; timestamp?: unknown })
+ | undefined;
if (!candidate || candidate.role !== "assistant") {
continue;
}
if (!candidate.usage || typeof candidate.usage !== "object") {
continue;
}
+
+ const messageTimestamp = parseMessageTimestamp(candidate.timestamp);
+ const staleByTimestamp =
+ latestCompactionTimestamp !== null &&
+ messageTimestamp !== null &&
+ messageTimestamp <= latestCompactionTimestamp;
+ const staleByLegacyOrdering = i < latestCompactionSummaryIndex;
+ if (!staleByTimestamp && !staleByLegacyOrdering) {
+ continue;
+ }
+
const candidateRecord = candidate as unknown as Record;
const { usage: _droppedUsage, ...rest } = candidateRecord;
out[i] = rest as unknown as AgentMessage;
diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts
index a0f4519a4..82f1df852 100644
--- a/src/agents/pi-embedded-runner/run/attempt.ts
+++ b/src/agents/pi-embedded-runner/run/attempt.ts
@@ -1162,13 +1162,15 @@ export async function runEmbeddedAttempt(
}
}
+ const compactionOccurredThisAttempt = getCompactionCount() > 0;
+
// Append cache-TTL timestamp AFTER prompt + compaction retry completes.
// Previously this was before the prompt, which caused a custom entry to be
// inserted between compaction and the next prompt — breaking the
// prepareCompaction() guard that checks the last entry type, leading to
// double-compaction. See: https://github.com/openclaw/openclaw/issues/9282
// Skip when timed out during compaction — session state may be inconsistent.
- if (!timedOutDuringCompaction) {
+ if (!timedOutDuringCompaction && !compactionOccurredThisAttempt) {
const shouldTrackCacheTtl =
params.config?.agents?.defaults?.contextPruning?.mode === "cache-ttl" &&
isCacheTtlEligibleProvider(params.provider, params.modelId);
@@ -1200,7 +1202,7 @@ export async function runEmbeddedAttempt(
messagesSnapshot = snapshotSelection.messagesSnapshot;
sessionIdUsed = snapshotSelection.sessionIdUsed;
- if (promptError && promptErrorSource === "prompt") {
+ if (promptError && promptErrorSource === "prompt" && !compactionOccurredThisAttempt) {
try {
sessionManager.appendCustomEntry("openclaw:prompt-error", {
timestamp: Date.now(),
diff --git a/src/agents/pi-embedded-subscribe.handlers.compaction.ts b/src/agents/pi-embedded-subscribe.handlers.compaction.ts
index a8072bf2e..8ae5d1ef4 100644
--- a/src/agents/pi-embedded-subscribe.handlers.compaction.ts
+++ b/src/agents/pi-embedded-subscribe.handlers.compaction.ts
@@ -52,6 +52,7 @@ export function handleAutoCompactionEnd(
ctx.log.debug(`embedded run compaction retry: runId=${ctx.params.runId}`);
} else {
ctx.maybeResolveCompactionWait();
+ clearStaleAssistantUsageOnSessionMessages(ctx);
}
emitAgentEvent({
runId: ctx.params.runId,
@@ -81,3 +82,23 @@ export function handleAutoCompactionEnd(
}
}
}
+
+function clearStaleAssistantUsageOnSessionMessages(ctx: EmbeddedPiSubscribeContext): void {
+ const messages = ctx.params.session.messages;
+ if (!Array.isArray(messages)) {
+ return;
+ }
+ for (const message of messages) {
+ if (!message || typeof message !== "object") {
+ continue;
+ }
+ const candidate = message as { role?: unknown; usage?: unknown };
+ if (candidate.role !== "assistant") {
+ continue;
+ }
+ if (!("usage" in candidate)) {
+ continue;
+ }
+ delete (candidate as { usage?: unknown }).usage;
+ }
+}
diff --git a/src/agents/pi-extensions/compaction-safeguard.test.ts b/src/agents/pi-extensions/compaction-safeguard.test.ts
index 1c75139df..60d3858c5 100644
--- a/src/agents/pi-extensions/compaction-safeguard.test.ts
+++ b/src/agents/pi-extensions/compaction-safeguard.test.ts
@@ -428,3 +428,59 @@ describe("compaction-safeguard extension model fallback", () => {
expect(getApiKeyMock).not.toHaveBeenCalled();
});
});
+
+describe("compaction-safeguard double-compaction guard", () => {
+ it("cancels compaction when there are no real messages to summarize", async () => {
+ const sessionManager = stubSessionManager();
+ const model = createAnthropicModelFixture();
+ setCompactionSafeguardRuntime(sessionManager, { model });
+
+ const compactionHandler = createCompactionHandler();
+ const mockEvent = {
+ preparation: {
+ messagesToSummarize: [] as AgentMessage[],
+ turnPrefixMessages: [] as AgentMessage[],
+ firstKeptEntryId: "entry-1",
+ tokensBefore: 1500,
+ fileOps: { read: [], edited: [], written: [] },
+ },
+ customInstructions: "",
+ signal: new AbortController().signal,
+ };
+
+ const getApiKeyMock = vi.fn().mockResolvedValue("sk-test");
+ const mockContext = createCompactionContext({
+ sessionManager,
+ getApiKeyMock,
+ });
+
+ const result = (await compactionHandler(mockEvent, mockContext)) as {
+ cancel?: boolean;
+ };
+ expect(result).toEqual({ cancel: true });
+ expect(getApiKeyMock).not.toHaveBeenCalled();
+ });
+
+ it("continues when messages include real conversation content", async () => {
+ const sessionManager = stubSessionManager();
+ const model = createAnthropicModelFixture();
+ setCompactionSafeguardRuntime(sessionManager, { model });
+
+ const compactionHandler = createCompactionHandler();
+ const mockEvent = createCompactionEvent({
+ messageText: "real message",
+ tokensBefore: 1500,
+ });
+ const getApiKeyMock = vi.fn().mockResolvedValue(null);
+ const mockContext = createCompactionContext({
+ sessionManager,
+ getApiKeyMock,
+ });
+
+ const result = (await compactionHandler(mockEvent, mockContext)) as {
+ cancel?: boolean;
+ };
+ expect(result).toEqual({ cancel: true });
+ expect(getApiKeyMock).toHaveBeenCalled();
+ });
+});
diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts
index b7c15d503..fbcf82b20 100644
--- a/src/agents/pi-extensions/compaction-safeguard.ts
+++ b/src/agents/pi-extensions/compaction-safeguard.ts
@@ -130,6 +130,10 @@ function formatToolFailuresSection(failures: ToolFailure[]): string {
return `\n\n## Tool Failures\n${lines.join("\n")}`;
}
+function isRealConversationMessage(message: AgentMessage): boolean {
+ return message.role === "user" || message.role === "assistant" || message.role === "toolResult";
+}
+
function computeFileLists(fileOps: FileOperations): {
readFiles: string[];
modifiedFiles: string[];
@@ -191,6 +195,12 @@ async function readWorkspaceContextForSummary(): Promise {
export default function compactionSafeguardExtension(api: ExtensionAPI): void {
api.on("session_before_compact", async (event, ctx) => {
const { preparation, customInstructions, signal } = event;
+ if (!preparation.messagesToSummarize.some(isRealConversationMessage)) {
+ log.warn(
+ "Compaction safeguard: cancelling compaction with no real conversation messages to summarize.",
+ );
+ return { cancel: true };
+ }
const { readFiles, modifiedFiles } = computeFileLists(preparation.fileOps);
const fileOpsSummary = formatFileOperations(readFiles, modifiedFiles);
const toolFailures = collectToolFailures([
diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts
index 3f080077f..3586c6c8e 100644
--- a/src/agents/workspace.test.ts
+++ b/src/agents/workspace.test.ts
@@ -103,6 +103,24 @@ describe("ensureAgentWorkspace", () => {
expect(state.bootstrapSeededAt).toBeUndefined();
expect(state.onboardingCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
});
+
+ it("treats memory-backed workspaces as existing even when template files are missing", async () => {
+ const tempDir = await makeTempWorkspace("openclaw-workspace-");
+ await fs.mkdir(path.join(tempDir, "memory"), { recursive: true });
+ await fs.writeFile(path.join(tempDir, "memory", "2026-02-25.md"), "# Daily log\nSome notes");
+ await fs.writeFile(path.join(tempDir, "MEMORY.md"), "# Long-term memory\nImportant stuff");
+
+ await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
+
+ await expect(fs.access(path.join(tempDir, DEFAULT_IDENTITY_FILENAME))).resolves.toBeUndefined();
+ await expect(fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME))).rejects.toMatchObject({
+ code: "ENOENT",
+ });
+ const state = await readOnboardingState(tempDir);
+ expect(state.onboardingCompletedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
+ const memoryContent = await fs.readFile(path.join(tempDir, "MEMORY.md"), "utf-8");
+ expect(memoryContent).toBe("# Long-term memory\nImportant stuff");
+ });
});
describe("loadWorkspaceBootstrapFiles", () => {
diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts
index 89b788f1e..d4db74358 100644
--- a/src/agents/workspace.ts
+++ b/src/agents/workspace.ts
@@ -349,7 +349,13 @@ export async function ensureAgentWorkspace(params?: {
const statePath = resolveWorkspaceStatePath(dir);
const isBrandNewWorkspace = await (async () => {
- const paths = [agentsPath, soulPath, toolsPath, identityPath, userPath, heartbeatPath];
+ const templatePaths = [agentsPath, soulPath, toolsPath, identityPath, userPath, heartbeatPath];
+ const userContentPaths = [
+ path.join(dir, "memory"),
+ path.join(dir, DEFAULT_MEMORY_FILENAME),
+ path.join(dir, ".git"),
+ ];
+ const paths = [...templatePaths, ...userContentPaths];
const existing = await Promise.all(
paths.map(async (p) => {
try {
@@ -394,14 +400,27 @@ export async function ensureAgentWorkspace(params?: {
}
if (!state.bootstrapSeededAt && !state.onboardingCompletedAt && !bootstrapExists) {
- // Legacy migration path: if USER/IDENTITY diverged from templates, treat onboarding as complete
- // and avoid recreating BOOTSTRAP for already-onboarded workspaces.
+ // Legacy migration path: if USER/IDENTITY diverged from templates, or if user-content
+ // indicators exist, treat onboarding as complete and avoid recreating BOOTSTRAP for
+ // already-onboarded workspaces.
const [identityContent, userContent] = await Promise.all([
fs.readFile(identityPath, "utf-8"),
fs.readFile(userPath, "utf-8"),
]);
+ const hasUserContent = await (async () => {
+ const indicators = [path.join(dir, "memory"), path.join(dir, DEFAULT_MEMORY_FILENAME)];
+ for (const indicator of indicators) {
+ try {
+ await fs.access(indicator);
+ return true;
+ } catch {
+ // continue
+ }
+ }
+ return false;
+ })();
const legacyOnboardingCompleted =
- identityContent !== identityTemplate || userContent !== userTemplate;
+ identityContent !== identityTemplate || userContent !== userTemplate || hasUserContent;
if (legacyOnboardingCompleted) {
markState({ onboardingCompletedAt: nowIso() });
} else {
diff --git a/src/cli/program/register.onboard.test.ts b/src/cli/program/register.onboard.test.ts
index 89d6e2433..2c923bb70 100644
--- a/src/cli/program/register.onboard.test.ts
+++ b/src/cli/program/register.onboard.test.ts
@@ -108,6 +108,17 @@ describe("registerOnboardCommand", () => {
);
});
+ it("forwards --reset-scope to onboard command options", async () => {
+ await runCli(["onboard", "--reset", "--reset-scope", "full"]);
+ expect(onboardCommandMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ reset: true,
+ resetScope: "full",
+ }),
+ runtime,
+ );
+ });
+
it("parses --mistral-api-key and forwards mistralApiKey", async () => {
await runCli(["onboard", "--mistral-api-key", "sk-mistral-test"]);
expect(onboardCommandMock).toHaveBeenCalledWith(
diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts
index 4c8193ce9..b039b2e83 100644
--- a/src/cli/program/register.onboard.ts
+++ b/src/cli/program/register.onboard.ts
@@ -7,6 +7,7 @@ import type {
GatewayAuthChoice,
GatewayBind,
NodeManagerChoice,
+ ResetScope,
SecretInputMode,
TailscaleMode,
} from "../../commands/onboard-types.js";
@@ -55,7 +56,11 @@ export function registerOnboardCommand(program: Command) {
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/onboard", "docs.openclaw.ai/cli/onboard")}\n`,
)
.option("--workspace ", "Agent workspace directory (default: ~/.openclaw/workspace)")
- .option("--reset", "Reset config + credentials + sessions + workspace before running wizard")
+ .option(
+ "--reset",
+ "Reset config + credentials + sessions before running wizard (workspace only with --reset-scope full)",
+ )
+ .option("--reset-scope ", "Reset scope: config|config+creds+sessions|full")
.option("--non-interactive", "Run without prompts", false)
.option(
"--accept-risk",
@@ -178,6 +183,7 @@ export function registerOnboardCommand(program: Command) {
tailscale: opts.tailscale as TailscaleMode | undefined,
tailscaleResetOnExit: Boolean(opts.tailscaleResetOnExit),
reset: Boolean(opts.reset),
+ resetScope: opts.resetScope as ResetScope | undefined,
installDaemon,
daemonRuntime: opts.daemonRuntime as GatewayDaemonRuntime | undefined,
skipChannels: Boolean(opts.skipChannels),
diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts
index e96983461..5639b5e6d 100644
--- a/src/commands/configure.wizard.ts
+++ b/src/commands/configure.wizard.ts
@@ -1,3 +1,5 @@
+import fsPromises from "node:fs/promises";
+import nodePath from "node:path";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { readConfigFileSnapshot, resolveGatewayPort, writeConfigFile } from "../config/config.js";
@@ -332,6 +334,32 @@ export async function runConfigureWizard(
runtime,
);
workspaceDir = resolveUserPath(String(workspaceInput ?? "").trim() || DEFAULT_WORKSPACE);
+ if (!snapshot.exists) {
+ const indicators = ["MEMORY.md", "memory", ".git"].map((name) =>
+ nodePath.join(workspaceDir, name),
+ );
+ const hasExistingContent = (
+ await Promise.all(
+ indicators.map(async (candidate) => {
+ try {
+ await fsPromises.access(candidate);
+ return true;
+ } catch {
+ return false;
+ }
+ }),
+ )
+ ).some(Boolean);
+ if (hasExistingContent) {
+ note(
+ [
+ `Existing workspace detected at ${workspaceDir}`,
+ "Existing files are preserved. Missing templates may be created, never overwritten.",
+ ].join("\n"),
+ "Existing workspace",
+ );
+ }
+ }
nextConfig = {
...nextConfig,
agents: {
diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts
index 95b480ce4..fee12d392 100644
--- a/src/commands/onboard-types.ts
+++ b/src/commands/onboard-types.ts
@@ -98,6 +98,7 @@ export type OnboardOptions = {
/** Required for non-interactive onboarding; skips the interactive risk prompt when true. */
acceptRisk?: boolean;
reset?: boolean;
+ resetScope?: ResetScope;
authChoice?: AuthChoice;
/** Used when `authChoice=token` in non-interactive mode. */
tokenProvider?: string;
diff --git a/src/commands/onboard.test.ts b/src/commands/onboard.test.ts
index c1150c73d..9e7dde1ed 100644
--- a/src/commands/onboard.test.ts
+++ b/src/commands/onboard.test.ts
@@ -4,6 +4,8 @@ import type { RuntimeEnv } from "../runtime.js";
const mocks = vi.hoisted(() => ({
runInteractiveOnboarding: vi.fn(async () => {}),
runNonInteractiveOnboarding: vi.fn(async () => {}),
+ readConfigFileSnapshot: vi.fn(async () => ({ exists: false, valid: false, config: {} })),
+ handleReset: vi.fn(async () => {}),
}));
vi.mock("./onboard-interactive.js", () => ({
@@ -14,6 +16,15 @@ vi.mock("./onboard-non-interactive.js", () => ({
runNonInteractiveOnboarding: mocks.runNonInteractiveOnboarding,
}));
+vi.mock("../config/config.js", () => ({
+ readConfigFileSnapshot: mocks.readConfigFileSnapshot,
+}));
+
+vi.mock("./onboard-helpers.js", () => ({
+ DEFAULT_WORKSPACE: "~/.openclaw/workspace",
+ handleReset: mocks.handleReset,
+}));
+
const { onboardCommand } = await import("./onboard.js");
function makeRuntime(): RuntimeEnv {
@@ -27,6 +38,7 @@ function makeRuntime(): RuntimeEnv {
describe("onboardCommand", () => {
afterEach(() => {
vi.clearAllMocks();
+ mocks.readConfigFileSnapshot.mockResolvedValue({ exists: false, valid: false, config: {} });
});
it("fails fast for invalid secret-input-mode before onboarding starts", async () => {
@@ -46,4 +58,55 @@ describe("onboardCommand", () => {
expect(mocks.runInteractiveOnboarding).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveOnboarding).not.toHaveBeenCalled();
});
+
+ it("defaults --reset to config+creds+sessions scope", async () => {
+ const runtime = makeRuntime();
+
+ await onboardCommand(
+ {
+ reset: true,
+ },
+ runtime,
+ );
+
+ expect(mocks.handleReset).toHaveBeenCalledWith(
+ "config+creds+sessions",
+ expect.any(String),
+ runtime,
+ );
+ });
+
+ it("accepts explicit --reset-scope full", async () => {
+ const runtime = makeRuntime();
+
+ await onboardCommand(
+ {
+ reset: true,
+ resetScope: "full",
+ },
+ runtime,
+ );
+
+ expect(mocks.handleReset).toHaveBeenCalledWith("full", expect.any(String), runtime);
+ });
+
+ it("fails fast for invalid --reset-scope", async () => {
+ const runtime = makeRuntime();
+
+ await onboardCommand(
+ {
+ reset: true,
+ resetScope: "invalid" as never,
+ },
+ runtime,
+ );
+
+ expect(runtime.error).toHaveBeenCalledWith(
+ 'Invalid --reset-scope. Use "config", "config+creds+sessions", or "full".',
+ );
+ expect(runtime.exit).toHaveBeenCalledWith(1);
+ expect(mocks.handleReset).not.toHaveBeenCalled();
+ expect(mocks.runInteractiveOnboarding).not.toHaveBeenCalled();
+ expect(mocks.runNonInteractiveOnboarding).not.toHaveBeenCalled();
+ });
});
diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts
index c2affc60d..1901d70e0 100644
--- a/src/commands/onboard.ts
+++ b/src/commands/onboard.ts
@@ -8,7 +8,9 @@ import { isDeprecatedAuthChoice, normalizeLegacyOnboardAuthChoice } from "./auth
import { DEFAULT_WORKSPACE, handleReset } from "./onboard-helpers.js";
import { runInteractiveOnboarding } from "./onboard-interactive.js";
import { runNonInteractiveOnboarding } from "./onboard-non-interactive.js";
-import type { OnboardOptions } from "./onboard-types.js";
+import type { OnboardOptions, ResetScope } from "./onboard-types.js";
+
+const VALID_RESET_SCOPES = new Set(["config", "config+creds+sessions", "full"]);
export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv = defaultRuntime) {
assertSupportedRuntime(runtime);
@@ -45,6 +47,12 @@ export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv =
return;
}
+ if (normalizedOpts.resetScope && !VALID_RESET_SCOPES.has(normalizedOpts.resetScope)) {
+ runtime.error('Invalid --reset-scope. Use "config", "config+creds+sessions", or "full".');
+ runtime.exit(1);
+ return;
+ }
+
if (normalizedOpts.nonInteractive && normalizedOpts.acceptRisk !== true) {
runtime.error(
[
@@ -62,7 +70,8 @@ export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv =
const baseConfig = snapshot.valid ? snapshot.config : {};
const workspaceDefault =
normalizedOpts.workspace ?? baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE;
- await handleReset("full", resolveUserPath(workspaceDefault), runtime);
+ const resetScope: ResetScope = normalizedOpts.resetScope ?? "config+creds+sessions";
+ await handleReset(resetScope, resolveUserPath(workspaceDefault), runtime);
}
if (process.platform === "win32") {
diff --git a/src/plugins/wired-hooks-compaction.test.ts b/src/plugins/wired-hooks-compaction.test.ts
index 05e63a2b2..f58d0d680 100644
--- a/src/plugins/wired-hooks-compaction.test.ts
+++ b/src/plugins/wired-hooks-compaction.test.ts
@@ -122,4 +122,72 @@ describe("compaction hook wiring", () => {
expect(hookMocks.runner.runAfterCompaction).not.toHaveBeenCalled();
});
+
+ it("clears stale assistant usage after final compaction", () => {
+ const messages = [
+ { role: "user", content: "hello" },
+ {
+ role: "assistant",
+ content: "response one",
+ usage: { totalTokens: 180_000, input: 100, output: 50 },
+ },
+ {
+ role: "assistant",
+ content: "response two",
+ usage: { totalTokens: 181_000, input: 120, output: 60 },
+ },
+ ];
+
+ const ctx = {
+ params: { runId: "r4", session: { messages } },
+ state: { compactionInFlight: true },
+ log: { debug: vi.fn(), warn: vi.fn() },
+ maybeResolveCompactionWait: vi.fn(),
+ getCompactionCount: () => 1,
+ incrementCompactionCount: vi.fn(),
+ };
+
+ handleAutoCompactionEnd(
+ ctx as never,
+ {
+ type: "auto_compaction_end",
+ willRetry: false,
+ } as never,
+ );
+
+ const assistantOne = messages[1] as { usage?: unknown };
+ const assistantTwo = messages[2] as { usage?: unknown };
+ expect(assistantOne.usage).toBeUndefined();
+ expect(assistantTwo.usage).toBeUndefined();
+ });
+
+ it("does not clear assistant usage while compaction is retrying", () => {
+ const messages = [
+ {
+ role: "assistant",
+ content: "response",
+ usage: { totalTokens: 184_297, input: 130_000, output: 2_000 },
+ },
+ ];
+
+ const ctx = {
+ params: { runId: "r5", session: { messages } },
+ state: { compactionInFlight: true },
+ log: { debug: vi.fn(), warn: vi.fn() },
+ noteCompactionRetry: vi.fn(),
+ resetForCompactionRetry: vi.fn(),
+ getCompactionCount: () => 0,
+ };
+
+ handleAutoCompactionEnd(
+ ctx as never,
+ {
+ type: "auto_compaction_end",
+ willRetry: true,
+ } as never,
+ );
+
+ const assistant = messages[0] as { usage?: unknown };
+ expect(assistant.usage).toEqual({ totalTokens: 184_297, input: 130_000, output: 2_000 });
+ });
});