diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2685559d7..72746ccd4 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the interactive bash status line staying on the old working directory after `cd` changed the persistent shell directory. ([#3958](https://github.com/can1357/oh-my-pi/issues/3958)) + ## [16.2.11] - 2026-07-01 ### Fixed diff --git a/packages/coding-agent/src/exec/bash-cwd-sync.ts b/packages/coding-agent/src/exec/bash-cwd-sync.ts new file mode 100644 index 000000000..1b6d854f5 --- /dev/null +++ b/packages/coding-agent/src/exec/bash-cwd-sync.ts @@ -0,0 +1,53 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import { logger } from "@oh-my-pi/pi-utils"; + +import { type BashResult, executeBash } from "./bash-executor"; + +const CWD_QUERY_TIMEOUT_MS = 5_000; + +export interface BashCwdSyncOptions { + /** Persistent bash session key whose current directory should be queried. */ + sessionKey: string; + /** Session cwd before the user bash command ran. */ + currentCwd: string; + /** Run through the configured user shell when the original command did. */ + useUserShell?: boolean; + /** Apply the discovered cwd to the owning session. */ + applyCwd: (cwd: string) => Promise; +} + +/** Synchronize a persistent bash session's PWD back into the owning session. */ +export async function syncBashSessionCwd(options: BashCwdSyncOptions): Promise { + let result: BashResult; + try { + result = await executeBash("pwd", { + sessionKey: options.sessionKey, + timeout: CWD_QUERY_TIMEOUT_MS, + useUserShell: options.useUserShell, + }); + } catch (error) { + logger.debug("Failed to query bash session cwd", { error: String(error) }); + return null; + } + + if (result.cancelled || result.exitCode !== 0) return null; + const nextCwd = result.output + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean) + .at(-1); + if (!nextCwd || !path.isAbsolute(nextCwd)) return null; + if (path.resolve(nextCwd) === path.resolve(options.currentCwd)) return null; + + try { + const stat = await fs.stat(nextCwd); + if (!stat.isDirectory()) return null; + await options.applyCwd(nextCwd); + return nextCwd; + } catch (error) { + logger.debug("Failed to apply bash session cwd", { cwd: nextCwd, error: String(error) }); + return null; + } +} diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index e0d6f2c51..6f71e6a82 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -130,6 +130,7 @@ import { prompt, relativePathWithinRoot, Snowflake, + setProjectDir, withTimeout, } from "@oh-my-pi/pi-utils"; import * as snapcompact from "@oh-my-pi/snapcompact"; @@ -172,6 +173,7 @@ import { } from "../config/model-resolver"; import { MODEL_ROLE_IDS, MODEL_ROLES } from "../config/model-roles"; import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates"; +import { applyProviderGlobalsFromSettings } from "../config/provider-globals"; import { buildServiceTierByFamily, serviceTierForAllFamilies, serviceTierSettingToTier } from "../config/service-tier"; import type { Settings, SkillsSettings } from "../config/settings"; import { getDefault, onAppendOnlyModeChanged, validateProviderMaxInFlightRequests } from "../config/settings"; @@ -188,6 +190,7 @@ import { } from "../eval/py/executor"; import { disposeRubyKernelSessionsByOwner } from "../eval/rb/executor"; import { defaultEvalSessionId } from "../eval/session-id"; +import { syncBashSessionCwd } from "../exec/bash-cwd-sync"; import { type BashResult, executeBash as executeBashCommand } from "../exec/bash-executor"; import type { TtsrManager, TtsrMatchContext } from "../export/ttsr"; import type { LoadedCustomCommand } from "../extensibility/custom-commands"; @@ -12774,6 +12777,19 @@ export class AgentSession { useUserShell: options?.useUserShell, }); + await syncBashSessionCwd({ + sessionKey: this.sessionId, + currentCwd: cwd, + useUserShell: options?.useUserShell, + applyCwd: async nextCwd => { + await this.sessionManager.moveTo(nextCwd); + setProjectDir(nextCwd); + await this.settings.reloadForCwd(nextCwd); + applyProviderGlobalsFromSettings(this.settings); + resetCapabilities(); + }, + }); + this.recordBashResult(command, result, options); return result; } finally { diff --git a/packages/coding-agent/test/bash-executor.test.ts b/packages/coding-agent/test/bash-executor.test.ts index db46ac62a..fb8ba8b37 100644 --- a/packages/coding-agent/test/bash-executor.test.ts +++ b/packages/coding-agent/test/bash-executor.test.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { resetSettingsForTest, Settings, type ShellMinimizerSettings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { syncBashSessionCwd } from "@oh-my-pi/pi-coding-agent/exec/bash-cwd-sync"; import { buildMinimizerOptions, executeBash } from "@oh-my-pi/pi-coding-agent/exec/bash-executor"; import { DEFAULT_MAX_BYTES } from "@oh-my-pi/pi-coding-agent/session/streaming-output"; import * as shellSnapshot from "@oh-my-pi/pi-coding-agent/utils/shell-snapshot"; @@ -121,6 +122,26 @@ describe("executeBash", () => { expect(result.output.trim()).toBe(fs.realpathSync(tempDir)); }); + it("syncs persistent shell directory changes back to the session", async () => { + const childDir = path.join(tempDir, "child"); + fs.mkdirSync(childDir); + const sessionKey = `cwd-sync-${Date.now()}`; + await executeBash(`cd ${shellQuote(childDir)}`, { sessionKey, timeout: 5000, useUserShell: true }); + + const applied: string[] = []; + const synced = await syncBashSessionCwd({ + sessionKey, + currentCwd: tempDir, + useUserShell: true, + applyCwd: async cwd => { + applied.push(cwd); + }, + }); + + expect(synced).toBe(childDir); + expect(applied).toEqual([childDir]); + }); + it("canonicalizes symlinked cwd before execution", async () => { if (process.platform === "win32") { return;