Skip to content

[FEATURE]: Add /cd directory switching command and change_directory agent tool #43223

Closed

Description

@AndyS77

Feature hasn't been suggested before.

  • I have verified this feature I'm about to request hasn't been suggested before.

Describe the enhancement you want to request

Add a /cd TUI slash command and a change_directory agent tool that lets users and agents switch the session working directory without restarting. This supports worktree-based workflows where you move between git worktrees of the same project.

TUI command (/cd): Opens a dialog, resolves relative paths against the current directory, calls moveSession, sends a synthetic system-reminder to the session, refreshes VCS state, and shows a toast.

Agent tool (change_directory): Resolves relative paths against the instance directory (not process.cwd()), calls MoveSession.Service.moveSession, and returns success/error output.

Both use the existing experimental.controlPlane.moveSession API.

Reference implementation — TUI command
// packages/tui/src/feature-plugins/home/change-directory.tsx
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
import { createSignal, Show } from "solid-js"
import { reconcile } from "solid-js/store"
import { useSDK } from "../../context/sdk"
import { useSync } from "../../context/sync"
import { useToast } from "../../ui/toast"
import { useDialog } from "../../ui/dialog"
import { errorMessage } from "../../util/error"
import { useProject } from "../../context/project"
import { useRoute } from "../../context/route"
import path from "path"

function ChangeDirectoryDialog(props: { api: TuiPluginApi }) {
  const sdk = useSDK()
  const sync = useSync()
  const toast = useToast()
  const dialog = useDialog()
  const project = useProject()
  const route = useRoute()
  const [input, setInput] = createSignal("")
  const [busy, setBusy] = createSignal(false)

  const currentDir = () => project.instance.directory() ?? ""

  async function submit() {
    const target = input().trim()
    if (!target) return
    const sessionID = route.data.type === "session" ? route.data.sessionID : undefined
    if (!sessionID) {
      toast.show({ title: "No active session", message: "Open a session before changing directory", variant: "error" })
      return
    }
    const resolved = path.isAbsolute(target) ? target : path.resolve(currentDir(), target)
    setBusy(true)
    try {
      await sdk.client.experimental.controlPlane.moveSession(
        { sessionID, destination: { directory: resolved }, moveChanges: false },
        { throwOnError: true },
      )
      // Non-critical: the move succeeded; a failed reminder doesn't warrant a user-facing error
      await sdk.client.session.promptAsync({
        sessionID, directory: resolved, noReply: true,
        parts: [{ type: "text", text: `<system-reminder>The user has changed the working directory to "${target}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`, synthetic: true }],
      }).catch(() => undefined)
      const res = await sdk.client.vcs.get({ workspace: project.workspace.current() })
      sync.set("vcs", reconcile(res.data))
      toast.show({ title: "Changed directory", message: resolved, variant: "success" })
      dialog.clear()
    } catch (err) {
      toast.show({ title: "Failed to change directory", message: errorMessage(err), variant: "error" })
    } finally {
      setBusy(false)
    }
  }

  return (
    <box flexDirection="column" gap={1} padding={1}>
      <text fg={props.api.theme.current.text}>Change working directory</text>
      <text fg={props.api.theme.current.textMuted}>Current: {currentDir()}</text>
      <box flexDirection="row" gap={1}>
        <text fg={props.api.theme.current.text}>{">"}</text>
        <input value={input()} onInput={(value) => setInput(value)} onSubmit={() => void submit()} placeholder="/path/to/worktree" />
      </box>
      <box flexDirection="row" gap={2}>
        <Show when={!busy()}><text fg={props.api.theme.current.accent}>Enter to confirm</text></Show>
        <Show when={busy()}><text fg={props.api.theme.current.textMuted}>Moving...</text></Show>
        <text fg={props.api.theme.current.textMuted}>Esc to cancel</text>
      </box>
    </box>
  )
}

const tui: TuiPlugin = async (api) => {
  api.keymap.registerLayer({
    commands: [{
      name: "cd.open", title: "Change directory", slashName: "cd",
      category: "Session", namespace: "palette",
      run() { api.ui.dialog.replace(() => <ChangeDirectoryDialog api={api} />) },
    }],
  })
}

const plugin: BuiltinTuiPlugin = { id: "change-directory", tui }
export default plugin
Reference implementation — Agent tool
// packages/opencode/src/tool/change-directory.ts
import path from "path"
import { Effect, Schema } from "effect"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { SessionV2 } from "@opencode-ai/core/session"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Tool } from "./tool"
import { InstanceState } from "@/effect/instance-state"
import DESCRIPTION from "./change-directory.txt"

export const Parameters = Schema.Struct({
  directory: Schema.String.annotate({
    description: "The directory to switch to (absolute or relative to the current working directory)",
  }),
  moveChanges: Schema.optional(Schema.Boolean).annotate({
    description: "Whether to move uncommitted changes to the destination directory. Defaults to false.",
  }),
})

export const ChangeDirectoryTool = Tool.define<typeof Parameters, { directory: string }, MoveSession.Service>(
  "change_directory",
  Effect.gen(function* () {
    const moveSession = yield* MoveSession.Service
    return {
      description: DESCRIPTION,
      parameters: Parameters,
      execute: (params, ctx) =>
        Effect.gen(function* () {
          yield* ctx.ask({
            permission: "change_directory",
            patterns: [params.directory],
            always: [params.directory],
            metadata: { directory: params.directory },
          })
          const instance = yield* InstanceState.context
          const directory = AbsolutePath.make(
            path.isAbsolute(params.directory)
              ? params.directory
              : path.resolve(instance.directory, params.directory),
          )
          yield* moveSession
            .moveSession({ sessionID: ctx.sessionID, destination: { directory }, moveChanges: params.moveChanges ?? false })
            .pipe(Effect.mapError((error) => new Error(describeError(error))))
          return {
            title: `Changed directory to ${directory}`,
            output: `Successfully changed working directory to ${directory}`,
            metadata: { directory },
          }
        }).pipe(Effect.orDie),
    }
  }),
)

function describeError(error: MoveSession.Error): string {
  if (error instanceof SessionV2.NotFoundError) return `Session not found: ${error.sessionID}`
  if (error instanceof MoveSession.DestinationProjectMismatchError) return "Destination directory belongs to another project"
  if (error instanceof MoveSession.CaptureChangesError) return `Unable to capture changes in the source directory: ${error.message}`
  if (error instanceof MoveSession.ApplyChangesError) return `Unable to apply changes in the destination directory: ${error.message}`
  return `Unable to reset source changes in ${error.directory}: ${error.message}`
}
Reference implementation — Tool description text
Change the session's working directory. Use this when you need to work in a different directory, such as a different worktree or project subdirectory.

The directory can be an absolute path or relative to the current working directory. It must belong to the same project. Set moveChanges to true to transfer uncommitted changes to the destination directory.

Activity

  1. github-actions commented on Aug 18, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

  2. AndyS77 commented on Aug 18, 2026

    @AndyS77
    Author

    Thanks for the pointers.

    #35306 — That issue is about the description text of the existing "Move session" dialog being unclear. Our /cd is a different UX: a lightweight slash command with inline path input, not the full directory-picker dialog. Both use the same moveSession API, but /cd is for quick switching (e.g. between worktrees) without navigating a list. The change_directory agent tool is a separate addition that doesn't exist today — agents currently have no way to switch directories at all.

    #38456 — That issue is about !cd (shell command) not updating the statusline. Our feature calls moveSession directly (which updates session state server-side) and refreshes VCS state, so the statusline reflects the new directory. Different problem, different mechanism.

    Happy to adjust scope or split the PR if the maintainers prefer to review the TUI command and the agent tool separately.

  3. AndyS77 commented on Aug 18, 2026

    @AndyS77
    Author

    Adding the test files we wrote for verification.

    Agent tool tests — packages/opencode/test/tool/change-directory.test.ts
    import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
    import { LayerNode } from "@opencode-ai/core/effect/layer-node"
    import { ProjectV2 } from "@opencode-ai/core/project"
    import { AbsolutePath } from "@opencode-ai/core/schema"
    import { Effect, Exit, Layer, Ref, Schema } from "effect"
    import { describe, expect } from "bun:test"
    import path from "path"
    import { Agent } from "../../src/agent/agent"
    import { SessionID, MessageID } from "../../src/session/schema"
    import { ChangeDirectoryTool, Parameters } from "../../src/tool/change-directory"
    import { Tool } from "@/tool/tool"
    import { Truncate } from "@/tool/truncate"
    import { testEffect } from "../lib/effect"
    
    const calls = Ref.makeUnsafe<MoveSession.Input[]>([])
    const failure = Ref.makeUnsafe<MoveSession.Error | undefined>(undefined)
    
    const moveSessionLayer = Layer.mock(MoveSession.Service)({
      moveSession: (input: MoveSession.Input) =>
        Effect.gen(function* () {
          yield* Ref.update(calls, (arr) => [...arr, input])
          const err = yield* Ref.get(failure)
          if (err) yield* Effect.fail(err)
        }),
    })
    
    const agentLayer = Layer.mock(Agent.Service)({
      get: () =>
        Effect.succeed({
          name: "build",
          mode: "primary" as const,
          permission: [],
          options: {},
        }),
    })
    
    const it = testEffect(
      Layer.mergeAll(
        LayerNode.compile(LayerNode.group([Truncate.node])),
        moveSessionLayer,
        agentLayer,
      ),
    )
    
    function makeCtx(): Tool.Context {
      return {
        sessionID: SessionID.make("ses_test"),
        messageID: MessageID.make("msg_test"),
        callID: "",
        agent: "build",
        abort: new AbortController().signal,
        messages: [],
        metadata: () => Effect.void,
        ask: () => Effect.void,
      }
    }
    
    describe("tool.change_directory", () => {
      it.effect("is registered with id 'change_directory'", () =>
        Effect.gen(function* () {
          expect(ChangeDirectoryTool.id).toBe("change_directory")
        }),
      )
    
      it.effect("has a non-empty description", () =>
        Effect.gen(function* () {
          const info = yield* ChangeDirectoryTool
          const def = yield* Tool.init(info)
          expect(def.description.length).toBeGreaterThan(0)
        }),
      )
    
      it.effect("Parameters schema accepts a directory field", () =>
        Effect.gen(function* () {
          const decoded = Schema.decodeSync(Parameters)({ directory: "/some/path" })
          expect(decoded.directory).toBe("/some/path")
        }),
      )
    
      it.effect("calls moveSession with session ID and target directory on valid input", () =>
        Effect.gen(function* () {
          yield* Ref.set(calls, [])
          yield* Ref.set(failure, undefined)
    
          const info = yield* ChangeDirectoryTool
          const tool = yield* Tool.init(info)
          const result = yield* tool.execute({ directory: "/new/directory" }, makeCtx())
    
          const input = (yield* Ref.get(calls))[0]
          expect(input.sessionID).toBe(SessionID.make("ses_test"))
          expect(input.destination.directory).toBe(AbsolutePath.make(path.resolve("/new/directory")))
          expect(result.output).toContain(path.resolve("/new/directory"))
        }),
      )
    
      it.effect("returns error when directory parameter is missing", () =>
        Effect.gen(function* () {
          const info = yield* ChangeDirectoryTool
          const tool = yield* Tool.init(info)
          const execute = tool.execute as unknown as (args: unknown, ctx: Tool.Context) => ReturnType<typeof tool.execute>
          const exit = yield* execute({}, makeCtx()).pipe(Effect.exit)
          expect(Exit.isFailure(exit)).toBe(true)
        }),
      )
    
      it.effect("returns error when moveSession fails with DestinationProjectMismatchError", () =>
        Effect.gen(function* () {
          yield* Ref.set(calls, [])
          yield* Ref.set(
            failure,
            new MoveSession.DestinationProjectMismatchError({
              expected: ProjectV2.ID.make("proj_a"),
              actual: ProjectV2.ID.make("proj_b"),
            }),
          )
    
          const info = yield* ChangeDirectoryTool
          const tool = yield* Tool.init(info)
          const exit = yield* tool.execute({ directory: "/wrong/project" }, makeCtx()).pipe(Effect.exit)
          expect(Exit.isFailure(exit)).toBe(true)
        }),
      )
    })
    TUI command tests — packages/tui/src/feature-plugins/home/change-directory.test.tsx
    /**
     * @jsxImportSource @opentui/solid
     *
     * Tests for the /cd directory switch command.
     * Covers plugin registration, command properties, submit() flow,
     * and error handling (toast on failure, no-session guard).
     */
    import { expect, mock, test } from "bun:test"
    import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
    import { testRender } from "@opentui/solid"
    import { InputRenderable } from "@opentui/core"
    import { createTuiPluginApi } from "../../../test/fixture/tui-plugin"
    
    type MockFn = ReturnType<typeof mock>
    
    function setupPluginRegistration() {
      const registerLayer = mock((_layer: unknown) => {})
      const replace = mock((_fn: () => unknown) => {})
      const api = createTuiPluginApi({
        keymap: { registerLayer, dispatchCommand: () => false } as never,
      })
      api.ui.dialog.replace = replace as never
      return { api, registerLayer, replace }
    }
    
    async function loadPlugin() {
      const mod = await import("./change-directory")
      return mod.default
    }
    
    function findInput(root: unknown): InputRenderable | undefined {
      if (root instanceof InputRenderable) return root
      const node = root as { getChildren?: () => unknown[] }
      const children = node.getChildren?.() ?? []
      for (const child of children) {
        const found = findInput(child)
        if (found) return found
      }
      return undefined
    }
    
    function mockContexts(options: {
      sessionID?: string
      vcsData?: unknown
    } = {}) {
      const sessionID = options.sessionID ?? "session-123"
      const vcsData = options.vcsData ?? { branch: "main" }
    
      const moveSession = mock(() => Promise.resolve({ data: undefined }))
      const promptAsync = mock(() => Promise.resolve({ data: undefined }))
      const vcsGet = mock(() => Promise.resolve({ data: vcsData }))
      const setStore = mock(() => {})
      const toastShow = mock(() => {})
      const dialogClear = mock(() => {})
    
      const sdk = {
        client: {
          experimental: { controlPlane: { moveSession } },
          session: { promptAsync },
          vcs: { get: vcsGet },
        },
      }
    
      const sync = {
        data: { session: [] },
        set: setStore,
        setStore,
      }
    
      const route = {
        data: { type: "session" as const, sessionID },
      }
    
      const toast = {
        show: toastShow,
        error: mock(() => {}),
        currentToast: null,
      }
    
      const dialog = {
        clear: dialogClear,
        replace: mock(() => {}),
        setSize: mock(() => {}),
        size: "medium" as const,
        depth: 0,
        open: false,
      }
    
      const project = {
        instance: {
          directory: () => "/old/path",
          path: () => ({
            home: "",
            state: "",
            config: "",
            worktree: "",
            directory: "/old/path",
          }),
        },
        workspace: {
          current: () => undefined,
        },
      }
    
      return {
        sdk,
        sync,
        route,
        toast,
        dialog,
        project,
        spies: { moveSession, promptAsync, vcsGet, setStore, toastShow, dialogClear },
      }
    }
    
    async function renderChangeDirectoryDialog(options?: {
      sessionID?: string
      vcsData?: unknown
    }) {
      const ctx = mockContexts(options)
    
      mock.module("../../context/sdk", () => ({ useSDK: () => ctx.sdk }))
      mock.module("../../context/sync", () => ({ useSync: () => ctx.sync }))
      mock.module("../../ui/toast", () => ({ useToast: () => ctx.toast }))
      mock.module("../../ui/dialog", () => ({ useDialog: () => ctx.dialog }))
      mock.module("../../context/project", () => ({ useProject: () => ctx.project }))
      mock.module("../../context/route", () => ({ useRoute: () => ctx.route }))
    
      try {
        const plugin = await loadPlugin()
        const { api, registerLayer, replace } = setupPluginRegistration()
        await plugin.tui(api, undefined, {} as never)
    
        const command = registerLayer.mock.calls[0][0].commands[0]
    
        let factory: (() => unknown) | undefined
        replace.mockImplementation((fn: () => unknown) => {
          factory = fn
        })
        command.run()
        if (!factory) throw new Error("dialog.replace was not called by run()")
    
        const app = await testRender(() => factory() as never)
    
        await Bun.sleep(100)
        const input = findInput((app.renderer as unknown as { root: unknown }).root)
        if (!input) throw new Error("InputRenderable not found in rendered tree")
        input.focus()
        await Bun.sleep(10)
    
        return { app, ctx, spies: ctx.spies, input }
      } catch (err) {
        mock.restore()
        throw err
      }
    }
    
    async function wait(predicate: () => boolean, timeout = 3000): Promise<void> {
      const start = Date.now()
      while (!predicate()) {
        if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
        await Bun.sleep(10)
      }
    }
    
    // --- Registration Tests ---
    
    test("plugin id is 'change-directory'", async () => {
      const plugin = await loadPlugin()
      expect(plugin.id).toBe("change-directory")
    })
    
    test("tui() registers a command layer via api.keymap.registerLayer", async () => {
      const plugin = await loadPlugin()
      const { api, registerLayer } = setupPluginRegistration()
      await plugin.tui(api, undefined, {} as never)
      expect(registerLayer).toHaveBeenCalledTimes(1)
      const layer = registerLayer.mock.calls[0][0] as { commands: unknown[] }
      expect(layer.commands).toHaveLength(1)
    })
    
    test("registered command has slashName 'cd' and category 'Session'", async () => {
      const plugin = await loadPlugin()
      const { api, registerLayer } = setupPluginRegistration()
      await plugin.tui(api, undefined, {} as never)
      const command = registerLayer.mock.calls[0][0] as {
        commands: { name: string; title: string; slashName: string; category: string }[]
      }
      const cmd = command.commands[0]
      expect(cmd.name).toBe("cd.open")
      expect(cmd.title).toBe("Change directory")
      expect(cmd.slashName).toBe("cd")
      expect(cmd.category).toBe("Session")
    })
    
    test("run() opens a dialog via api.ui.dialog.replace", async () => {
      const plugin = await loadPlugin()
      const { api, registerLayer, replace } = setupPluginRegistration()
      await plugin.tui(api, undefined, {} as never)
      const layer = registerLayer.mock.calls[0][0] as {
        commands: { run(): void }[]
      }
      layer.commands[0].run()
      expect(replace).toHaveBeenCalledTimes(1)
      expect(typeof replace.mock.calls[0][0]).toBe("function")
    })
    
    // --- Submit Flow Tests ---
    
    test("submit() calls moveSession with sessionID, destination, and moveChanges=false", async () => {
      const { app, spies } = await renderChangeDirectoryDialog()
      try {
        await app.mockInput.typeText("/new/worktree")
        app.mockInput.pressEnter()
        await wait(() => spies.moveSession.mock.calls.length > 0)
    
        const call = spies.moveSession.mock.calls[0]
        const body = call[0] as { sessionID: string; destination: { directory: string }; moveChanges: boolean }
        const opts = call[1] as { throwOnError: boolean }
        expect(body.sessionID).toBe("session-123")
        expect(body.destination).toEqual({ directory: "/new/worktree" })
        expect(body.moveChanges).toBe(false)
        expect(opts).toEqual({ throwOnError: true })
      } finally {
        app.renderer.destroy()
        mock.restore()
      }
    })
    
    test("submit() calls promptAsync with a system-reminder containing the new directory", async () => {
      const { app, spies } = await renderChangeDirectoryDialog()
      try {
        await app.mockInput.typeText("/new/worktree")
        app.mockInput.pressEnter()
        await wait(() => spies.promptAsync.mock.calls.length > 0)
    
        const call = spies.promptAsync.mock.calls[0]
        const body = call[0] as {
          sessionID: string
          directory: string
          noReply: boolean
          parts: { type: string; text: string; synthetic: boolean }[]
        }
        expect(body.sessionID).toBe("session-123")
        expect(body.directory).toBe("/new/worktree")
        expect(body.noReply).toBe(true)
        expect(body.parts).toHaveLength(1)
        const part = body.parts[0]
        expect(part.type).toBe("text")
        expect(part.synthetic).toBe(true)
        expect(part.text).toContain("/new/worktree")
        expect(part.text).toContain("system-reminder")
        expect(part.text).toContain("changed the working directory")
      } finally {
        app.renderer.destroy()
        mock.restore()
      }
    })
    
    test("submit() refreshes VCS via vcs.get after a successful move", async () => {
      const { app, spies } = await renderChangeDirectoryDialog({ vcsData: { branch: "develop" } })
      try {
        await app.mockInput.typeText("/new/worktree")
        app.mockInput.pressEnter()
        await wait(() => spies.vcsGet.mock.calls.length > 0)
    
        expect(spies.vcsGet).toHaveBeenCalledTimes(1)
        await wait(() => spies.setStore.mock.calls.some((c) => c[0] === "vcs"))
        const setStoreCall = spies.setStore.mock.calls.find((c) => c[0] === "vcs")
        expect(setStoreCall![1]).toEqual({ branch: "develop" })
      } finally {
        app.renderer.destroy()
        mock.restore()
      }
    })
    
    test("submit() shows a success toast after a successful move", async () => {
      const { app, spies } = await renderChangeDirectoryDialog()
      try {
        await app.mockInput.typeText("/new/worktree")
        app.mockInput.pressEnter()
        await wait(() => spies.toastShow.mock.calls.length > 0)
    
        const toastCall = spies.toastShow.mock.calls[0][0] as {
          variant: string
          title: string
          message: string
        }
        expect(toastCall.variant).toBe("success")
        expect(toastCall.title).toBe("Changed directory")
        expect(toastCall.message).toBe("/new/worktree")
      } finally {
        app.renderer.destroy()
        mock.restore()
      }
    })
    
    test("submit() shows an error toast when moveSession fails", async () => {
      const { app, spies } = await renderChangeDirectoryDialog()
      spies.moveSession.mockImplementation(() => Promise.reject(new Error("Network failure")))
      try {
        await app.mockInput.typeText("/new/worktree")
        app.mockInput.pressEnter()
        await wait(() => spies.toastShow.mock.calls.length > 0)
    
        const toastCall = spies.toastShow.mock.calls[0][0] as {
          variant: string
          title: string
          message: string
        }
        expect(toastCall.variant).toBe("error")
        expect(toastCall.title).toBe("Failed to change directory")
        expect(toastCall.message).toContain("Network failure")
      } finally {
        app.renderer.destroy()
        mock.restore()
      }
    })
    
    test("submit() shows an error toast when no active session exists", async () => {
      const { app, spies } = await renderChangeDirectoryDialog({ sessionID: "" })
      try {
        await app.mockInput.typeText("/new/worktree")
        app.mockInput.pressEnter()
        await wait(() => spies.toastShow.mock.calls.length > 0)
    
        const toastCall = spies.toastShow.mock.calls[0][0] as {
          variant: string
          title: string
        }
        expect(toastCall.variant).toBe("error")
        expect(toastCall.title).toBe("No active session")
        expect(spies.moveSession).not.toHaveBeenCalled()
      } finally {
        app.renderer.destroy()
        mock.restore()
      }
    })
  4. AndyS77 commented on Aug 18, 2026

    @AndyS77
    Author

    PR: #43238

  5. fobiat commented on Sep 17, 2026

    @fobiat

    Design review request, following #43238 rather than opening a competing implementation:

    Current dev has DialogMoveSession and packages/core/src/control-plane/move-session.ts. The latter checks project identity and publishes SessionEvent.Moved; it does not itself serialize the move against an active drain. CONTEXT.md says the destination gets a new context epoch and Location-scoped services on the next drain.

    Proposed contract: /cd opens the existing move picker; /cd <path> is a shortcut if command parsing supports arguments. Resolve paths on the server against the session's durable location, validate directories/permissions and canonical symlinks, then use the move service with moveChanges: false. Preserve identity/history and reject different projects clearly. Transferring changes remains an explicit separate action.

    The agent tool needs a permission-checked, deferred relocation at a safe boundary after in-flight tools settle. Calling move directly inside a tool risks continuing the same drain with source-location services. The context-epoch mechanism should record the destination truthfully; a synthetic prompt claiming arbitrary directories share a project is insufficient.

    Could the core team approve the command/tool contract and choose reject-while-busy versus deferred relocation? Verification should include a real tool after movement, concurrent sessions, project mismatch, missing/file/inaccessible targets, symlinks, spaces and no-op moves. I will wait for approval before implementation.

  6. neriousy commented on Oct 19, 2026

    @neriousy
    Member

    This is implemented in v2: /cd <path> changes the session working directory, and agents can use opencode.session_move to move at the next safe boundary. Upgrade with npm i -g @opencode/cli@latest. If you still see this on v2, please open a new issue with the details.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions