diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index ca0bed10c..e8a5e8f41 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -50,6 +50,9 @@
### Removed
- Removed history URI support for reading agent transcripts
+### Fixed
+
+- Fixed `/move
` to relocate the current session file and artifacts into the target directory's resume bucket instead of switching to a new empty target session, so `/resume` from the target directory shows the moved conversation.
## [16.2.2] - 2026-06-27
diff --git a/packages/coding-agent/src/config/provider-globals.ts b/packages/coding-agent/src/config/provider-globals.ts
new file mode 100644
index 000000000..9fdd5d4a3
--- /dev/null
+++ b/packages/coding-agent/src/config/provider-globals.ts
@@ -0,0 +1,25 @@
+import * as imageGen from "../tools/image-gen";
+import * as webSearch from "../web/search";
+
+interface ProviderGlobalSettings {
+ get(path: "providers.webSearchExclude"): unknown;
+ get(path: "providers.webSearch"): unknown;
+ get(path: "providers.image"): unknown;
+}
+
+export function applyProviderGlobalsFromSettings(settings: ProviderGlobalSettings): void {
+ const excludedWebSearchProviders = settings.get("providers.webSearchExclude");
+ if (Array.isArray(excludedWebSearchProviders)) {
+ webSearch.setExcludedSearchProviders(excludedWebSearchProviders.filter(webSearch.isSearchProviderId));
+ }
+
+ const webSearchProvider = settings.get("providers.webSearch");
+ if (typeof webSearchProvider === "string" && webSearch.isSearchProviderPreference(webSearchProvider)) {
+ webSearch.setPreferredSearchProvider(webSearchProvider);
+ }
+
+ const imageProvider = settings.get("providers.image");
+ if (imageGen.isImageProviderPreference(imageProvider)) {
+ imageGen.setPreferredImageProvider(imageProvider);
+ }
+}
diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts
index bd603232d..98e446646 100644
--- a/packages/coding-agent/src/modes/controllers/command-controller.ts
+++ b/packages/coding-agent/src/modes/controllers/command-controller.ts
@@ -42,7 +42,6 @@ import type { AsyncJobSnapshotItem } from "../../session/agent-session";
import type { AuthStorage, OAuthAccountIdentity } from "../../session/auth-storage";
import type { CompactMode } from "../../session/compact-modes";
import type { NewSessionOptions } from "../../session/session-entries";
-import { SessionManager } from "../../session/session-manager";
import { formatShakeSummary, type ShakeMode, type ShakeResult } from "../../session/shake-types";
import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-oauth-account";
import { outputMeta } from "../../tools/output-meta";
@@ -935,13 +934,13 @@ export class CommandController {
}
/**
- * `/move` — switch to a fresh empty session in a different directory.
+ * `/move` — relocate the current session to a different directory.
*
* With no `targetPath` (TUI only), opens an autocomplete overlay so the user
* can pick or type a directory. With a `targetPath`, resolves it directly.
* If the target directory does not exist, the user is asked whether to create
- * it. A brand-new empty session is then started in the target directory and
- * the current session is left behind (resumable via `/resume`).
+ * it. The active session file and artifacts are moved into the target
+ * directory's session bucket so `/resume` from that directory can find it.
*/
async handleMoveCommand(targetPath?: string): Promise {
if (this.ctx.session.isStreaming) {
@@ -1003,47 +1002,18 @@ export class CommandController {
}
}
- let newSessionFile: string | undefined;
try {
- // Create a fresh empty session file in the target directory's session
- // folder, then switch to it. The current session is left behind and
- // remains resumable via /resume.
- newSessionFile = SessionManager.createEmptySessionFile(resolvedPath);
- const switched = await this.ctx.session.switchSession(newSessionFile);
- if (!switched) {
- await this.ctx.sessionManager.dropSession(newSessionFile);
- return;
- }
+ await this.ctx.sessionManager.moveTo(resolvedPath);
} catch (err) {
- if (newSessionFile) {
- try {
- await this.ctx.sessionManager.dropSession(newSessionFile);
- } catch (dropErr) {
- this.ctx.showError(
- `Move failed: ${err instanceof Error ? err.message : String(err)}; failed to remove empty session: ${dropErr instanceof Error ? dropErr.message : String(dropErr)}`,
- );
- return;
- }
- }
this.ctx.showError(`Move failed: ${err instanceof Error ? err.message : String(err)}`);
return;
}
- this.ctx.session.markMovedFromEmptySessionFile(newSessionFile!);
await this.ctx.applyCwdChange(resolvedPath);
- this.ctx.chatContainer.clear();
- this.ctx.pendingMessagesContainer.clear();
- this.ctx.compactionQueuedMessages = [];
- this.ctx.streamingComponent = undefined;
- this.ctx.streamingMessage = undefined;
- this.ctx.pendingTools.clear();
- this.ctx.statusLine.invalidate();
- this.ctx.statusLine.resetActiveTime();
- this.ctx.updateEditorTopBorder();
this.ctx.updateEditorBorderColor();
await this.ctx.reloadTodos();
- this.ctx.ui.requestRender(true, { clearScrollback: true });
+ this.ctx.ui.requestRender();
this.ctx.present([
new Spacer(1),
diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts
index 02eb281e3..423502adf 100644
--- a/packages/coding-agent/src/modes/interactive-mode.ts
+++ b/packages/coding-agent/src/modes/interactive-mode.ts
@@ -53,6 +53,7 @@ import { reset as resetCapabilities } from "../capability";
import type { CollabGuestLink } from "../collab/guest";
import type { CollabHost } from "../collab/host";
import { KeybindingsManager } from "../config/keybindings";
+import { applyProviderGlobalsFromSettings } from "../config/provider-globals";
import { isSettingsInitialized, onStatusLineSessionAccentChanged, Settings, settings } from "../config/settings";
import { clearClaudePluginRootsCache } from "../discovery/helpers";
import type {
@@ -100,7 +101,6 @@ import { STTController, type SttState } from "../stt";
import { discoverTitleSystemPromptFile, resolvePromptInput } from "../system-prompt";
import { formatTaskId } from "../task/render";
import type { LspStartupServerInfo } from "../tools";
-import { isImageProviderPreference, setPreferredImageProvider } from "../tools/image-gen";
import { normalizeLocalScheme } from "../tools/path-utils";
import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../tools/render-utils";
import { setAutoQaConsentHandler } from "../tools/report-tool-issue";
@@ -114,12 +114,6 @@ import { getEditorCommand, openInEditor } from "../utils/external-editor";
import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
import { messageHasDisplayableThinking } from "../utils/thinking-display";
import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator";
-import {
- isSearchProviderId,
- isSearchProviderPreference,
- setExcludedSearchProviders,
- setPreferredSearchProvider,
-} from "../web/search";
import type { AssistantMessageComponent } from "./components/assistant-message";
import type { BashExecutionComponent } from "./components/bash-execution";
import { ChatBlock, type ChatBlockHost } from "./components/chat-block";
@@ -1053,18 +1047,7 @@ export class InteractiveMode implements InteractiveModeContext {
// module-level search/image provider state reflects the destination
// project's configuration. Without this, the previous project's
// exclusions leak and newly-excluded providers are still used.
- const excludedWebSearchProviders = settings.get("providers.webSearchExclude");
- if (Array.isArray(excludedWebSearchProviders)) {
- setExcludedSearchProviders(excludedWebSearchProviders.filter(isSearchProviderId));
- }
- const webSearchProvider = settings.get("providers.webSearch");
- if (typeof webSearchProvider === "string" && isSearchProviderPreference(webSearchProvider)) {
- setPreferredSearchProvider(webSearchProvider);
- }
- const imageProvider = settings.get("providers.image");
- if (isImageProviderPreference(imageProvider)) {
- setPreferredImageProvider(imageProvider);
- }
+ applyProviderGlobalsFromSettings(settings);
}
// Re-warm plugin roots, capabilities, slash commands, and the ssh tool so
// the next prompt sees everything scoped to the new project directory.
diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts
index 3d133474b..ca2d7f4a5 100644
--- a/packages/coding-agent/src/session/session-manager.ts
+++ b/packages/coding-agent/src/session/session-manager.ts
@@ -1618,9 +1618,8 @@ export class SessionManager {
/**
* Create a fresh empty session file in the default session directory for
* `cwd`, writing only the session header. The returned path can be passed to
- * `setSessionFile` / `AgentSession.switchSession` to start a new empty
- * session in that directory. Used by `/move` to switch projects without
- * dragging the current conversation along.
+ * `setSessionFile` / `AgentSession.switchSession` when a caller explicitly
+ * needs a brand-new persisted session at a cwd-derived path.
*/
static createEmptySessionFile(cwd: string, storage: SessionStorage = new FileSessionStorage()): string {
const sessionDir = SessionManager.getDefaultSessionDir(cwd, undefined, storage);
diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts
index c9908c117..91a6ead79 100644
--- a/packages/coding-agent/src/slash-commands/builtin-registry.ts
+++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts
@@ -6,6 +6,7 @@ import { type AutocompleteItem, Spacer } from "@oh-my-pi/pi-tui";
import { APP_NAME, getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils";
import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest";
import { CollabHost } from "../collab/host";
+import { applyProviderGlobalsFromSettings } from "../config/provider-globals";
import type { SettingPath, SettingValue } from "../config/settings";
import { settings } from "../config/settings";
import {
@@ -29,7 +30,6 @@ import type { InteractiveModeContext } from "../modes/types";
import type { AgentSession, FreshSessionResult } from "../session/agent-session";
import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes";
import { resolveResumableSession } from "../session/session-listing";
-import { SessionManager } from "../session/session-manager";
import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
import { expandTilde, resolveToCwd } from "../tools/path-utils";
import { urlHyperlinkAlways } from "../tui";
@@ -1606,8 +1606,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [
},
{
name: "move",
- description: "Switch to a fresh session in a different directory",
- acpDescription: "Start a fresh session in a different directory",
+ description: "Move the current session to a different directory",
+ acpDescription: "Move the current session to a different directory",
inlineHint: "[]",
allowArgs: true,
handle: async (command, runtime) => {
@@ -1622,32 +1622,18 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [
} catch {
return usage(`Directory does not exist: ${resolvedPath}`, runtime);
}
- let newSessionFile: string | undefined;
try {
- newSessionFile = SessionManager.createEmptySessionFile(resolvedPath);
- const switched = await runtime.session.switchSession(newSessionFile);
- if (!switched) {
- await runtime.sessionManager.dropSession(newSessionFile);
- return usage("Move cancelled.", runtime);
- }
+ await runtime.sessionManager.moveTo(resolvedPath);
} catch (err) {
- if (newSessionFile) {
- try {
- await runtime.sessionManager.dropSession(newSessionFile);
- } catch (dropErr) {
- return usage(
- `Move failed: ${errorMessage(err)}; failed to remove empty session: ${errorMessage(dropErr)}`,
- runtime,
- );
- }
- }
return usage(`Move failed: ${errorMessage(err)}`, runtime);
}
- runtime.session.markMovedFromEmptySessionFile(newSessionFile!);
setProjectDir(resolvedPath);
+ await runtime.settings.reloadForCwd(resolvedPath);
+ applyProviderGlobalsFromSettings(runtime.settings);
// Reload plugin/capability caches so the next prompt sees commands and
// capabilities scoped to the new cwd.
await runtime.reloadPlugins();
+ await runtime.notifyConfigChanged?.();
await runtime.notifyTitleChanged?.();
await runtime.output(`Moved to ${runtime.sessionManager.getCwd()}.`);
return commandConsumed();
diff --git a/packages/coding-agent/test/acp-builtins.test.ts b/packages/coding-agent/test/acp-builtins.test.ts
index 4bb7bd60e..f42c2a217 100644
--- a/packages/coding-agent/test/acp-builtins.test.ts
+++ b/packages/coding-agent/test/acp-builtins.test.ts
@@ -12,7 +12,7 @@ import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import type { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { executeAcpBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-commands/acp-builtins";
-import { removeWithRetries } from "@oh-my-pi/pi-utils";
+import { removeWithRetries, setProjectDir } from "@oh-my-pi/pi-utils";
interface FakeAcpBuiltinSession {
fastMode: boolean;
@@ -745,6 +745,33 @@ describe("wave 3 commands", () => {
expect(output[0]).toContain("does not exist");
});
+ it("/move: relocates the current session instead of switching to an empty target session", async () => {
+ const { output, runtime, session, fakeSessionManager } = createRuntime();
+ const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-move-target-"));
+ const originalProjectDir = process.cwd();
+ const reloadForCwd = spyOn(runtime.settings, "reloadForCwd");
+ let configNotified = 0;
+ runtime.notifyConfigChanged = () => {
+ configNotified++;
+ };
+
+ try {
+ const result = await executeAcpBuiltinSlashCommand(`/move ${targetDir}`, runtime);
+
+ expect(result).toEqual({ consumed: true });
+ expect(fakeSessionManager._movedTo).toBe(targetDir);
+ expect(fakeSessionManager.getCwd()).toBe(targetDir);
+ expect(session._switchedTo).toBeUndefined();
+ expect(session._movedFromEmptySessionFile).toBeUndefined();
+ expect(reloadForCwd).toHaveBeenCalledWith(targetDir);
+ expect(configNotified).toBe(1);
+ expect(output[0]).toContain(`Moved to ${targetDir}.`);
+ } finally {
+ setProjectDir(originalProjectDir);
+ await fs.rm(targetDir, { recursive: true, force: true });
+ }
+ });
+
// /memory
it("/memory unknown: returns usage message", async () => {
const { output, runtime } = createRuntime();
diff --git a/packages/coding-agent/test/config/provider-globals.test.ts b/packages/coding-agent/test/config/provider-globals.test.ts
new file mode 100644
index 000000000..81e4d6dcc
--- /dev/null
+++ b/packages/coding-agent/test/config/provider-globals.test.ts
@@ -0,0 +1,31 @@
+import { afterEach, describe, expect, it, vi } from "bun:test";
+import { applyProviderGlobalsFromSettings } from "@oh-my-pi/pi-coding-agent/config/provider-globals";
+import * as imageGen from "@oh-my-pi/pi-coding-agent/tools/image-gen";
+import * as webSearch from "@oh-my-pi/pi-coding-agent/web/search";
+
+describe("applyProviderGlobalsFromSettings", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("reapplies valid web and image provider globals from cwd-scoped settings", () => {
+ const excludeSpy = vi.spyOn(webSearch, "setExcludedSearchProviders").mockImplementation(() => {});
+ const webSpy = vi.spyOn(webSearch, "setPreferredSearchProvider").mockImplementation(() => {});
+ const imageSpy = vi.spyOn(imageGen, "setPreferredImageProvider").mockImplementation(() => {});
+
+ applyProviderGlobalsFromSettings({
+ get(path: "providers.webSearchExclude" | "providers.webSearch" | "providers.image"): unknown {
+ const values: Record = {
+ "providers.webSearchExclude": ["exa", "not-a-provider", "gemini"],
+ "providers.webSearch": "perplexity",
+ "providers.image": "xai",
+ };
+ return values[path];
+ },
+ });
+
+ expect(excludeSpy).toHaveBeenCalledWith(["exa", "gemini"]);
+ expect(webSpy).toHaveBeenCalledWith("perplexity");
+ expect(imageSpy).toHaveBeenCalledWith("xai");
+ });
+});
diff --git a/packages/coding-agent/test/modes/controllers/move-command.test.ts b/packages/coding-agent/test/modes/controllers/move-command.test.ts
new file mode 100644
index 000000000..e6bf15a20
--- /dev/null
+++ b/packages/coding-agent/test/modes/controllers/move-command.test.ts
@@ -0,0 +1,67 @@
+import { beforeAll, describe, expect, it, vi } from "bun:test";
+import * as fs from "node:fs/promises";
+import * as os from "node:os";
+import * as path from "node:path";
+import { CommandController } from "@oh-my-pi/pi-coding-agent/modes/controllers/command-controller";
+import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
+import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
+
+function createMoveContext(sourceDir: string) {
+ const state = { cwd: sourceDir, movedTo: undefined as string | undefined };
+ const present = vi.fn();
+ const applyCwdChange = vi.fn(async (cwd: string) => {
+ expect(state.cwd).toBe(cwd);
+ });
+ const ctx = {
+ session: { isStreaming: false },
+ sessionManager: {
+ getCwd: () => state.cwd,
+ moveTo: vi.fn(async (cwd: string) => {
+ state.cwd = cwd;
+ state.movedTo = cwd;
+ }),
+ dropSession: vi.fn(async () => {}),
+ },
+ showHookCustom: vi.fn(),
+ showHookConfirm: vi.fn(),
+ showError: vi.fn(),
+ showWarning: vi.fn(),
+ applyCwdChange,
+ updateEditorBorderColor: vi.fn(),
+ reloadTodos: vi.fn(async () => {}),
+ ui: { requestRender: vi.fn() },
+ present,
+ } as unknown as InteractiveModeContext;
+ return { ctx, state, present };
+}
+
+describe("CommandController /move", () => {
+ beforeAll(async () => {
+ const theme = await getThemeByName("dark");
+ if (!theme) throw new Error("Expected dark theme");
+ setThemeInstance(theme);
+ });
+
+ it("relocates the active session before re-scoping cwd-derived state", async () => {
+ const sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-move-source-"));
+ const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-move-target-"));
+ try {
+ const { ctx, state, present } = createMoveContext(sourceDir);
+ const controller = new CommandController(ctx);
+
+ await controller.handleMoveCommand(targetDir);
+
+ expect(state.movedTo).toBe(targetDir);
+ expect(ctx.sessionManager.dropSession).not.toHaveBeenCalled();
+ expect(ctx.applyCwdChange).toHaveBeenCalledWith(targetDir);
+ expect(ctx.updateEditorBorderColor).toHaveBeenCalled();
+ expect(ctx.reloadTodos).toHaveBeenCalled();
+ expect(ctx.ui.requestRender).toHaveBeenCalledWith();
+ expect(present).toHaveBeenCalled();
+ expect(ctx.showError).not.toHaveBeenCalled();
+ } finally {
+ await fs.rm(sourceDir, { recursive: true, force: true });
+ await fs.rm(targetDir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/packages/coding-agent/test/session-manager/move-to.test.ts b/packages/coding-agent/test/session-manager/move-to.test.ts
index f4a4728b4..84e5592b3 100644
--- a/packages/coding-agent/test/session-manager/move-to.test.ts
+++ b/packages/coding-agent/test/session-manager/move-to.test.ts
@@ -109,6 +109,23 @@ describe("SessionManager.moveTo", () => {
expect(hasAssistantEntry(entries)).toBe(true);
});
+ it("makes the moved session visible to resume from the target cwd", async () => {
+ const session = SessionManager.create(cwdA);
+ session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
+ session.appendMessage(makeAssistantMessage());
+ await session.flush();
+ const oldFile = session.getSessionFile()!;
+
+ await session.moveTo(cwdB);
+
+ const movedFile = session.getSessionFile()!;
+ const sourceSessions = await SessionManager.list(cwdA);
+ const targetSessions = await SessionManager.list(cwdB);
+
+ expect(sourceSessions.some(item => item.path === oldFile)).toBe(false);
+ expect(targetSessions.some(item => item.path === movedFile)).toBe(true);
+ });
+
it("succeeds on fresh session without ENOENT, then deferred persistence works", async () => {
const session = SessionManager.create(cwdA);
// No messages — file never written to disk