From 32d8b84e26c3bfb05cd8cd440ae922df21edd518 Mon Sep 17 00:00:00 2001 From: maatheusgois-dd Date: Tue, 21 Jul 2026 23:38:32 -0300 Subject: [PATCH 01/19] Add dynamic multi-root workspace context (#2569) A session now carries an ordered list of workspace directories beyond cwd, managed live from the terminal. New /add-dir, /remove-dir, and /dirs slash commands let you add and remove folders mid-session; the repeatable --add-dir CLI flag seeds them at launch, and the workspace.additionalDirectories setting persists defaults per project. Additional roots are persisted in the session header, survive reopen/fork/move, and are surfaced to the agent in the system prompt so it knows they exist and can read/grep/glob them by absolute path. Design aligns with the endorsed community implementation on feature/session-workspace. Co-authored-by: oh-my-pi --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/cli/args.ts | 2 + packages/coding-agent/src/cli/flag-tables.ts | 3 + packages/coding-agent/src/commands/launch.ts | 4 + .../src/config/settings-schema.ts | 12 ++ packages/coding-agent/src/main.ts | 3 + .../src/prompts/system/project-prompt.md | 9 +- packages/coding-agent/src/sdk.ts | 6 + .../coding-agent/src/session/agent-session.ts | 5 +- .../src/session/session-entries.ts | 6 + .../src/session/session-manager.ts | 69 ++++++++++ .../src/session/session-workspace.ts | 70 ++++++++++ .../src/slash-commands/builtin-registry.ts | 76 +++++++++++ packages/coding-agent/src/system-prompt.ts | 5 + .../workspace-directories.test.ts | 126 ++++++++++++++++++ .../workspace-prompt-refresh.test.ts | 85 ++++++++++++ 16 files changed, 483 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/src/session/session-workspace.ts create mode 100644 packages/coding-agent/test/session-manager/workspace-directories.test.ts create mode 100644 packages/coding-agent/test/session-manager/workspace-prompt-refresh.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 945a4ed1c..e4ee0c837 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added dynamic multi-root workspace context (issue [#2569](https://github.com/can1357/oh-my-pi/issues/2569)): a session now carries an ordered list of workspace directories beyond `cwd`, managed live from the terminal. New `/add-dir `, `/remove-dir `, and `/dirs` slash commands let you add and remove folders mid-session; the repeatable `--add-dir ` CLI flag seeds them at launch, and the `workspace.additionalDirectories` setting persists defaults per project. Additional roots are persisted in the session header, survive reopen/fork/move, and are surfaced to the agent in the system prompt so it knows they exist and can `read`/`grep`/`glob` them by absolute path. Design aligns with the endorsed community implementation on `feature/session-workspace`. + ## [17.0.7] - 2026-07-21 ### Fixed diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index b4b0b8db7..1f0750547 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -19,6 +19,8 @@ export type Mode = "text" | "json" | "rpc" | "acp" | "rpc-ui"; export interface Args { cwd?: string; + /** Workspace directories beyond cwd for this session (repeatable `--add-dir`). */ + addDir?: string[]; profile?: string; alias?: string; allowHome?: boolean; diff --git a/packages/coding-agent/src/cli/flag-tables.ts b/packages/coding-agent/src/cli/flag-tables.ts index a4c28d271..cd917e717 100644 --- a/packages/coding-agent/src/cli/flag-tables.ts +++ b/packages/coding-agent/src/cli/flag-tables.ts @@ -117,6 +117,9 @@ export const STRING_SETTERS: Record = { "--config": (result, value) => { result.config = [...(result.config ?? []), value]; }, + "--add-dir": (result, value) => { + result.addDir = [...(result.addDir ?? []), value]; + }, "--mode": (result, value) => { if (value === "text" || value === "json" || value === "rpc" || value === "acp" || value === "rpc-ui") { result.mode = value; diff --git a/packages/coding-agent/src/commands/launch.ts b/packages/coding-agent/src/commands/launch.ts index 7a9cb876d..291d3747a 100644 --- a/packages/coding-agent/src/commands/launch.ts +++ b/packages/coding-agent/src/commands/launch.ts @@ -83,6 +83,10 @@ export default class Index extends Command { description: "Load an extra config.yml-style overlay for this run (repeatable)", multiple: true, }), + "add-dir": Flags.string({ + description: "Add a workspace directory beyond the working directory (repeatable)", + multiple: true, + }), print: Flags.boolean({ char: "p", description: "Non-interactive mode: process prompt and exit", diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 23b0e6800..b75f6e32f 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1185,6 +1185,18 @@ export const SETTINGS_SCHEMA = { }, }, + "workspace.additionalDirectories": { + type: "array", + default: [] as string[], + ui: { + tab: "model", + group: "Context", + label: "Additional Workspace Dirs", + description: + "Extra workspace directories added to every session as additional roots (multi-root workspace). Managed live via /add-dir and /remove-dir. Paths resolve relative to cwd; absolute paths recommended. The agent is told these roots exist and can read/grep/glob them.", + }, + }, + personality: { type: "enum", values: ["default", "friendly", "pragmatic", "none"] as const, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index f8d230e53..869a27c09 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -833,6 +833,9 @@ export async function buildSessionOptions( cwd: parsed.cwd ?? getProjectDir(), autoApprove: parsed.autoApprove ?? false, }; + if (parsed.addDir && parsed.addDir.length > 0) { + options.additionalDirectories = parsed.addDir; + } if (parsed.maxTime !== undefined) { options.deadline = Date.now() + parsed.maxTime * 1000; } diff --git a/packages/coding-agent/src/prompts/system/project-prompt.md b/packages/coding-agent/src/prompts/system/project-prompt.md index f5033d137..d9db20b6a 100644 --- a/packages/coding-agent/src/prompts/system/project-prompt.md +++ b/packages/coding-agent/src/prompts/system/project-prompt.md @@ -40,7 +40,14 @@ Working directory layout (sorted by mtime, recent first; depth ≤ 3): {{/if}} {{/if}} - +{{#if additionalWorkspaceRoots.length}} + +This session also spans the additional directories below. This list is the CURRENT workspace state and supersedes any workspace change mentioned earlier in the conversation. Use absolute paths under these roots to `read`/`grep`/`glob`/`edit` them. Manage the set with `/add-dir` and `/remove-dir`; `/dirs` lists them. +{{#each additionalWorkspaceRoots}} +- {{this}} +{{/each}} + +{{/if}} Today is {{date}}, and the current working directory is '{{cwd}}'. diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index af123be59..c2b3758ee 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -371,6 +371,8 @@ function applyMCPEnvironment(result: { exaApiKeys: string[] }): void { export interface CreateAgentSessionOptions { /** Working directory for project-local discovery. Default: getProjectDir() */ cwd?: string; + /** Additional workspace directories beyond cwd (multi-root), absolute or cwd-relative. */ + additionalDirectories?: string[]; /** Global config directory. Default: ~/.omp/agent */ agentDir?: string; /** Spawns to allow. Default: "*" */ @@ -1309,6 +1311,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} logger.time("sessionManager", () => SessionManager.create(cwd, SessionManager.getDefaultSessionDir(cwd, agentDir)), ); + if (options.additionalDirectories && options.additionalDirectories.length > 0) { + sessionManager.setAdditionalDirectories(options.additionalDirectories); + } const providerSessionId = options.providerSessionId ?? sessionManager.getSessionId(); const forkCacheShapeChanged = options.model !== undefined || @@ -2517,6 +2522,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } const defaultPrompt = await buildSystemPromptInternal({ cwd, + additionalWorkspaceRoots: sessionManager.getAdditionalDirectories(), xdevTools: toolSession.xdevRegistry?.entries() ?? [], xdevDocs: toolSession.xdevRegistry?.docsAll() ?? "", autoQaEnabled: !restrictToolNames && isAutoQaEnabled(settings), diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 28f4ee31d..b2cc32562 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -194,7 +194,7 @@ import { import { getKnownRoleIds, MODEL_ROLE_IDS, MODEL_ROLES } from "../config/model-roles"; import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates"; import { buildServiceTierByFamily, serviceTierForAllFamilies, serviceTierSettingToTier } from "../config/service-tier"; -import type { Settings, SkillsSettings } from "../config/settings"; +import type { SettingPath, Settings, SkillsSettings } from "../config/settings"; import { getDefault, onAppendOnlyModeChanged, @@ -9984,6 +9984,9 @@ export class AgentSession { await this.sessionManager.flush(); } await this.sessionManager.newSession(options); + const configuredDirs = + (this.settings.get("workspace.additionalDirectories" as SettingPath) as string[] | undefined) ?? []; + if (configuredDirs.length > 0) this.sessionManager.setAdditionalDirectories(configuredDirs); this.#markBashSessionTransition(bashTransition); sessionTransitioned = true; } finally { diff --git a/packages/coding-agent/src/session/session-entries.ts b/packages/coding-agent/src/session/session-entries.ts index 54caecbf7..11bc01ee2 100644 --- a/packages/coding-agent/src/session/session-entries.ts +++ b/packages/coding-agent/src/session/session-entries.ts @@ -32,6 +32,12 @@ export interface SessionHeader { titleSource?: SessionTitleSource; timestamp: string; cwd: string; + /** + * Additional workspace directories beyond `cwd` (multi-root workspace). + * Absolute, normalized, deduplicated. Absent on legacy single-cwd sessions. + * See {@link SessionWorkspace} in `./session-workspace`. + */ + additionalDirectories?: string[]; parentSession?: string; /** Provider prompt-cache identity inherited by exact-route full forks. */ providerPromptCacheKey?: string; diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index ef78771d4..3f7a44bec 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -73,6 +73,7 @@ import { type SessionStorageWriter, } from "./session-storage"; import { type SessionTitleUpdate, serializeTitleSlot } from "./session-title-slot"; +import { additionalWorkspaceDirectories, normalizeSessionWorkspace, type SessionWorkspace } from "./session-workspace"; const JSONL_SUFFIX_LENGTH = ".jsonl".length; const DRAFT_ONLY_SESSION_MARKER = ".draft-only-session"; @@ -380,6 +381,8 @@ interface DiskQueueOptions { */ export class SessionManager { #cwd: string; + /** Additional workspace directories beyond cwd (multi-root). Normalized absolute, deduped, excludes cwd. */ + #additionalDirectories: string[] = []; #sessionDir: string; readonly #persist: boolean; readonly #storage: SessionStorage; @@ -1040,6 +1043,7 @@ export class SessionManager { } this.#applyEntries(header, fileEntries.slice(1) as SessionEntry[]); + this.#additionalDirectories = header.additionalDirectories ?? []; this.#titleUpdatedAt = titleSlot?.updatedAt ?? header.timestamp; this.#hasTitleSlot = titleSlot !== undefined; this.#fileIsCurrent = true; @@ -1090,6 +1094,7 @@ export class SessionManager { titleSource: this.#header.titleSource ?? this.#titleSource, timestamp, cwd: this.#cwd, + additionalDirectories: this.#additionalDirectories.length > 0 ? [...this.#additionalDirectories] : undefined, parentSession: parentSessionId, providerPromptCacheKey: this.#header.providerPromptCacheKey ?? parentSessionId, }; @@ -1303,6 +1308,70 @@ export class SessionManager { return this.#cwd; } + /** Additional workspace directories beyond cwd (multi-root), absolute and normalized. */ + getAdditionalDirectories(): string[] { + return [...this.#additionalDirectories]; + } + + /** Full workspace as a {@link SessionWorkspace}: cwd first, then additional dirs. */ + getWorkspace(): SessionWorkspace { + return { cwd: this.#cwd, directories: [this.#cwd, ...this.#additionalDirectories] }; + } + + /** + * Add a workspace directory. Normalizes (relative to cwd), dedupes, rejects + * the cwd itself, persists to the session header, and triggers an atomic + * rewrite so the change survives a crash. Returns the resolved absolute + * path or `null` when the directory was already present (no-op). + */ + async addWorkspaceDirectory(directory: string): Promise { + const resolved = path.resolve(this.#cwd, directory); + if (resolved === path.resolve(this.#cwd)) { + throw new Error("The current working directory is already the primary workspace root."); + } + if (this.#additionalDirectories.includes(resolved)) return null; + this.#additionalDirectories = [...this.#additionalDirectories, resolved]; + this.#header.additionalDirectories = this.#additionalDirectories; + if (this.#persist && this.#sessionFile) { + this.#rewriteRequired = true; + await this.#rewriteAtomically(); + } + return resolved; + } + + /** + * Remove a workspace directory by absolute or cwd-relative path. Persists + * the trimmed header. Returns the resolved path that was removed, or + * `null` when the directory was not an additional root (no-op). + */ + async removeWorkspaceDirectory(directory: string): Promise { + const resolved = path.isAbsolute(directory) ? directory : path.resolve(this.#cwd, directory); + const idx = this.#additionalDirectories.findIndex(p => path.resolve(p) === resolved); + if (idx === -1) return null; + this.#additionalDirectories = this.#additionalDirectories.filter((_, i) => i !== idx); + if (this.#additionalDirectories.length === 0) { + this.#header.additionalDirectories = undefined; + } else { + this.#header.additionalDirectories = this.#additionalDirectories; + } + if (this.#persist && this.#sessionFile) { + this.#rewriteRequired = true; + await this.#rewriteAtomically(); + } + return resolved; + } + + /** Seed additional directories from settings or a passed list (called at session creation). */ + setAdditionalDirectories(directories: string[]): void { + const workspace = normalizeSessionWorkspace({ cwd: this.#cwd, directories }); + this.#additionalDirectories = additionalWorkspaceDirectories(workspace); + if (this.#additionalDirectories.length > 0) { + this.#header.additionalDirectories = this.#additionalDirectories; + } else { + this.#header.additionalDirectories = undefined; + } + } + getUsageStatistics(): UsageStatistics { return this.#index.usageSnapshot(); } diff --git a/packages/coding-agent/src/session/session-workspace.ts b/packages/coding-agent/src/session/session-workspace.ts new file mode 100644 index 000000000..da623938d --- /dev/null +++ b/packages/coding-agent/src/session/session-workspace.ts @@ -0,0 +1,70 @@ +import * as os from "node:os"; +import * as path from "node:path"; + +/** + * Filesystem workspace of a session: one current/default directory plus a + * non-empty ordered list of workspace directories. + * + * `cwd` remains the default directory for relative-path resolution and + * backward compatibility. `directories` always contains `cwd` first, followed + * by any additional directories in their supplied order (deduplicated). + * Directory order is stable but carries no semantic hierarchy. + * + * Workspace directories come from the platform (ACP/editor), CLI, or config — + * never from filesystem walk-up discovery. + */ +export interface SessionWorkspace { + /** Current/default directory for compatibility and relative path resolution. */ + cwd: string; + /** Non-empty ordered list of absolute normalized directories; `cwd` is always first. */ + directories: string[]; +} + +/** Expand a leading `~`/`~/` and resolve to an absolute path (relative input resolves against `base`). */ +export function normalizeWorkspaceDirectory(directory: string, base?: string): string { + let expanded = directory; + if (expanded === "~") { + expanded = os.homedir(); + } else if (expanded.startsWith("~/") || expanded.startsWith(`~${path.sep}`)) { + expanded = path.join(os.homedir(), expanded.slice(2)); + } + return base ? path.resolve(base, expanded) : path.resolve(expanded); +} + +/** + * Build a normalized {@link SessionWorkspace} from a cwd and optional + * additional directories. Additional entries are normalized (relative entries + * resolve against the normalized cwd), deduplicated, and appended after `cwd` + * preserving their supplied order. + */ +export function normalizeSessionWorkspace(args: { cwd: string; directories?: string[] }): SessionWorkspace { + const cwd = normalizeWorkspaceDirectory(args.cwd); + const directories = [cwd]; + for (const directory of args.directories ?? []) { + const normalized = normalizeWorkspaceDirectory(directory, cwd); + if (!directories.includes(normalized)) directories.push(normalized); + } + return { cwd, directories }; +} + +/** The workspace directories beyond `cwd`, in order (ACP `additionalDirectories` shape). */ +export function additionalWorkspaceDirectories(workspace: SessionWorkspace): string[] { + return workspace.directories.filter(directory => directory !== workspace.cwd); +} + +/** + * Pick the workspace directory containing `filePath` (longest prefix wins, so + * nested roots resolve to the deepest match), falling back to `fallback` when + * no workspace directory contains it. Keeps per-file consumers (e.g. LSP root + * selection) from collapsing unrelated repos into one parent tree. + */ +export function workspaceRootForPath(filePath: string, directories: string[] | undefined, fallback: string): string { + let best = ""; + for (const directory of directories ?? []) { + const prefixed = directory.endsWith(path.sep) ? directory : `${directory}${path.sep}`; + if ((filePath === directory || filePath.startsWith(prefixed)) && directory.length > best.length) { + best = directory; + } + } + return best || fallback; +} diff --git a/packages/coding-agent/src/slash-commands/builtin-registry.ts b/packages/coding-agent/src/slash-commands/builtin-registry.ts index b564b5471..ffb378547 100644 --- a/packages/coding-agent/src/slash-commands/builtin-registry.ts +++ b/packages/coding-agent/src/slash-commands/builtin-registry.ts @@ -203,6 +203,14 @@ function parseShakeMode(args: string): ShakeMode | { error: string } { return { error: `Unknown /shake mode "${verb}". Use elide or images.` }; } +/** Format the session's workspace directories (cwd + additional) for display. */ +function formatWorkspaceDirectories(runtime: SlashCommandRuntime, note?: string): string { + const cwd = runtime.sessionManager.getCwd(); + const additional = runtime.sessionManager.getAdditionalDirectories(); + const lines = ["Workspace directories:", ` ${cwd} (working directory)`, ...additional.map(d => ` ${d}`)]; + return note ? `${note}\n${lines.join("\n")}` : lines.join("\n"); +} + const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ { name: "settings", @@ -1733,6 +1741,74 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray = [ await runtime.ctx.handleMoveCommand(command.args || undefined); }, }, + { + name: "add-dir", + description: "Add a workspace directory to this session (multi-root)", + acpDescription: "Add a workspace directory to this session", + inlineHint: "", + allowArgs: true, + handle: async (command, runtime) => { + if (runtime.session.isStreaming) return usage("Cannot add a directory while streaming.", runtime); + if (!command.args) return usage(formatWorkspaceDirectories(runtime, "Usage: /add-dir "), runtime); + const resolved = resolveToCwd(command.args, runtime.cwd); + try { + const stat = await fs.stat(resolved); + if (!stat.isDirectory()) return usage(`Not a directory: ${resolved}`, runtime); + } catch { + return usage(`Directory does not exist: ${resolved}`, runtime); + } + let added: string | null; + try { + added = await runtime.sessionManager.addWorkspaceDirectory(resolved); + } catch (err) { + return usage(errorMessage(err), runtime); + } + if (added === null) { + await runtime.output(`Already in the workspace: ${resolved}`); + return commandConsumed(); + } + await runtime.session.refreshBaseSystemPrompt(); + await runtime.output(formatWorkspaceDirectories(runtime, `Added ${added}.`)); + return commandConsumed(); + }, + }, + { + name: "remove-dir", + description: "Remove a workspace directory from this session", + acpDescription: "Remove a workspace directory from this session", + inlineHint: "", + allowArgs: true, + handle: async (command, runtime) => { + if (runtime.session.isStreaming) return usage("Cannot remove a directory while streaming.", runtime); + if (!command.args) return usage("Usage: /remove-dir ", runtime); + const resolved = resolveToCwd(command.args, runtime.cwd); + if (resolved === path.resolve(runtime.cwd)) { + return usage("Cannot remove the working directory; use /move to change it.", runtime); + } + let removed: string | null; + try { + removed = await runtime.sessionManager.removeWorkspaceDirectory(resolved); + } catch (err) { + return usage(errorMessage(err), runtime); + } + if (removed === null) { + await runtime.output(`Not a workspace directory: ${resolved}`); + return commandConsumed(); + } + await runtime.session.refreshBaseSystemPrompt(); + await runtime.output(formatWorkspaceDirectories(runtime, `Removed ${removed}.`)); + return commandConsumed(); + }, + }, + { + name: "dirs", + description: "List this session's workspace directories", + acpDescription: "List this session's workspace directories", + handle: async (_command, runtime) => { + await runtime.output(formatWorkspaceDirectories(runtime)); + return commandConsumed(); + }, + }, { name: "exit", description: "Exit the application", diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index a54e2a29f..96051316b 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -3,6 +3,7 @@ */ import * as os from "node:os"; +import * as path from "node:path"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; import type { ToolExample, TSchema } from "@oh-my-pi/pi-ai"; import { renderToolInventory } from "@oh-my-pi/pi-ai/dialect"; @@ -467,6 +468,8 @@ export interface BuildSystemPromptOptions { skillsSettings?: SkillsSettings; /** Working directory. Default: getProjectDir() */ cwd?: string; + /** Additional workspace directories beyond cwd (multi-root), absolute. Injected into the project prompt. */ + additionalWorkspaceRoots?: string[]; /** Pre-loaded context files (skips discovery if provided). */ contextFiles?: Array<{ path: string; content: string; depth?: number }>; /** Skills provided directly to system prompt construction. */ @@ -536,6 +539,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): skillsSettings, toolNames: providedToolNames, cwd, + additionalWorkspaceRoots = [], contextFiles: providedContextFiles, skills: providedSkills, rules, @@ -795,6 +799,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): date, dateTime, cwd: promptCwd, + additionalWorkspaceRoots: additionalWorkspaceRoots.filter(d => path.resolve(d) !== path.resolve(resolvedCwd)), model: includeModelInPrompt ? (model ?? "") : "", useCodexTaskPrompt: usesCodexTaskPrompt(model), personality: personality === "none" ? "" : PERSONALITY_SPECS[personality].trim(), diff --git a/packages/coding-agent/test/session-manager/workspace-directories.test.ts b/packages/coding-agent/test/session-manager/workspace-directories.test.ts new file mode 100644 index 000000000..e6c66787c --- /dev/null +++ b/packages/coding-agent/test/session-manager/workspace-directories.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { + additionalWorkspaceDirectories, + normalizeSessionWorkspace, + workspaceRootForPath, +} from "@oh-my-pi/pi-coding-agent/session/session-workspace"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +describe("normalizeSessionWorkspace", () => { + it("places cwd first and dedupes additional directories", () => { + const cwd = "/home/user/proj"; + const workspace = normalizeSessionWorkspace({ cwd, directories: ["/home/user/other", cwd, "/home/user/other"] }); + expect(workspace.cwd).toBe(path.resolve(cwd)); + expect(workspace.directories).toEqual([path.resolve(cwd), path.resolve("/home/user/other")]); + }); + + it("resolves relative additional directories against the normalized cwd", () => { + const workspace = normalizeSessionWorkspace({ cwd: "/home/user/proj", directories: ["../sibling"] }); + expect(workspace.directories).toEqual([path.resolve("/home/user/proj"), path.resolve("/home/user/sibling")]); + }); + + it("expands ~ to home", () => { + const workspace = normalizeSessionWorkspace({ cwd: "/tmp", directories: ["~/docs"] }); + expect(workspace.directories[1]).toBe(path.join(process.env.HOME ?? require("node:os").homedir(), "docs")); + }); +}); + +describe("additionalWorkspaceDirectories", () => { + it("returns every directory except cwd", () => { + const workspace = normalizeSessionWorkspace({ cwd: "/a", directories: ["/b", "/c"] }); + expect(additionalWorkspaceDirectories(workspace)).toEqual([path.resolve("/b"), path.resolve("/c")]); + }); + + it("is empty for a single-root workspace", () => { + const workspace = normalizeSessionWorkspace({ cwd: "/a" }); + expect(additionalWorkspaceDirectories(workspace)).toEqual([]); + }); +}); + +describe("workspaceRootForPath", () => { + it("picks the longest-prefix match", () => { + const dirs = ["/repo", "/repo/packages/sub"]; + expect(workspaceRootForPath("/repo/packages/sub/file.ts", dirs, "/fallback")).toBe("/repo/packages/sub"); + expect(workspaceRootForPath("/repo/other/file.ts", dirs, "/fallback")).toBe("/repo"); + }); + + it("falls back when no root contains the path", () => { + expect(workspaceRootForPath("/elsewhere/file.ts", ["/repo"], "/fallback")).toBe("/fallback"); + }); +}); + +describe("SessionManager workspace directories", () => { + it("starts with no additional directories", () => { + const session = SessionManager.inMemory(); + expect(session.getAdditionalDirectories()).toEqual([]); + expect(session.getWorkspace().directories).toEqual([session.getCwd()]); + }); + + it("seeds from setAdditionalDirectories and excludes cwd", () => { + const session = SessionManager.inMemory(); + session.setAdditionalDirectories(["/some/other", session.getCwd()]); + // cwd is filtered out of the additional set. + expect(session.getAdditionalDirectories()).toEqual(["/some/other"]); + expect(session.getWorkspace().directories).toEqual([session.getCwd(), "/some/other"]); + }); + + it("addWorkspaceDirectory rejects the cwd itself", async () => { + const session = SessionManager.inMemory(); + await expect(session.addWorkspaceDirectory(session.getCwd())).rejects.toThrow(/primary workspace root/); + }); + + it("addWorkspaceDirectory returns the resolved path and dedupes on repeat", async () => { + const session = SessionManager.inMemory(); + const added = await session.addWorkspaceDirectory("/another/repo"); + expect(added).toBe(path.resolve("/another/repo")); + expect(session.getAdditionalDirectories()).toEqual([path.resolve("/another/repo")]); + + // Second add of the same path is a no-op. + const second = await session.addWorkspaceDirectory("/another/repo"); + expect(second).toBeNull(); + expect(session.getAdditionalDirectories()).toEqual([path.resolve("/another/repo")]); + }); + + it("removeWorkspaceDirectory removes a known root and returns null when absent", async () => { + const session = SessionManager.inMemory(); + await session.addWorkspaceDirectory("/x"); + const removed = await session.removeWorkspaceDirectory("/x"); + expect(removed).toBe(path.resolve("/x")); + expect(session.getAdditionalDirectories()).toEqual([]); + + const again = await session.removeWorkspaceDirectory("/x"); + expect(again).toBeNull(); + }); + + it("persists additionalDirectories in the session header across reopen", async () => { + using tempDir = TempDir.createSync("@pi-session-workspace-persist-"); + const session = SessionManager.create(tempDir.path(), tempDir.path()); + await session.addWorkspaceDirectory(path.join(tempDir.path(), "sibling")); + // Materialize on disk so reopen reads the header. + session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + await session.flush(); + + const file = session.getSessionFile(); + expect(file).toBeDefined(); + const reopened = await SessionManager.open(file!); + expect(reopened.getAdditionalDirectories()).toEqual([path.join(tempDir.path(), "sibling")]); + expect(reopened.getWorkspace().directories).toEqual([tempDir.path(), path.join(tempDir.path(), "sibling")]); + }); + + it("clears the header field when the last additional directory is removed", async () => { + using tempDir = TempDir.createSync("@pi-session-workspace-clear-"); + const session = SessionManager.create(tempDir.path(), tempDir.path()); + await session.addWorkspaceDirectory(path.join(tempDir.path(), "extra")); + session.appendMessage({ role: "user", content: "hi", timestamp: 1 }); + await session.flush(); + + await session.removeWorkspaceDirectory(path.join(tempDir.path(), "extra")); + const file = session.getSessionFile()!; + const raw = fs.readFileSync(file, "utf8").split("\n")[0]!; + const header = JSON.parse(raw); + expect(header.additionalDirectories).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/test/session-manager/workspace-prompt-refresh.test.ts b/packages/coding-agent/test/session-manager/workspace-prompt-refresh.test.ts new file mode 100644 index 000000000..9e3152330 --- /dev/null +++ b/packages/coding-agent/test/session-manager/workspace-prompt-refresh.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { createMockModel, registerMockApi } from "@oh-my-pi/pi-ai/providers/mock"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { createAgentSession } from "@oh-my-pi/pi-coding-agent/sdk"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +registerMockApi(); + +/** + * Contract: when `/add-dir` (or `addWorkspaceDirectory` + `refreshBaseSystemPrompt`) + * runs mid-session, the rebuilt system prompt MUST list the newly-added directory + * in its block. The sessionManager state updates immediately + * (so `/dirs` reflects the add), but the system prompt is only re-read on the next + * `refreshBaseSystemPrompt`; this test guards that refresh path end-to-end. + */ +describe("workspace directories in the system prompt", () => { + it("adds a directory to the block after addWorkspaceDirectory + refreshBaseSystemPrompt", async () => { + const dir = TempDir.createSync("@ws-prompt-add-"); + const auth = await AuthStorage.create(path.join(dir.path(), "auth.db")); + try { + auth.setRuntimeApiKey("mock", "test-key"); + const extraDir = path.join(dir.path(), "extra-root"); + fs.mkdirSync(extraDir, { recursive: true }); + const laterDir = path.join(dir.path(), "later-root"); + fs.mkdirSync(laterDir, { recursive: true }); + + const mockModel = createMockModel({ id: "text", handler: () => ({ content: ["ok"] }) }); + const settings = Settings.isolated({ + "compaction.enabled": false, + "todo.enabled": false, + "retry.enabled": false, + }); + const sessionManager = SessionManager.inMemory(dir.path()); + const { session } = await createAgentSession({ + cwd: dir.path(), + agentDir: dir.path(), + additionalDirectories: [extraDir], + authStorage: auth, + modelRegistry: new ModelRegistry(auth, path.join(dir.path(), "models.yml")), + model: mockModel, + settings, + sessionManager, + disableExtensionDiscovery: true, + enableMCP: false, + enableLsp: false, + skills: [], + rules: [], + contextFiles: [], + }); + try { + // Sanity: the seed dir is present in the sessionManager state. + expect(sessionManager.getAdditionalDirectories()).toEqual([extraDir]); + + // Add a second directory live (as /add-dir does) and refresh the prompt. + await sessionManager.addWorkspaceDirectory(laterDir); + await session.refreshBaseSystemPrompt(); + + // sessionManager state now has both. + expect(sessionManager.getAdditionalDirectories()).toEqual([extraDir, laterDir]); + + // Send a prompt so we can inspect the system prompt the provider received. + await session.prompt("noop"); + + const calls = mockModel.calls ?? []; + const lastCall = calls.at(-1); + const systemPrompt = (lastCall?.context?.systemPrompt as string[] | undefined)?.join("\n") ?? ""; + + // Both directories must appear in the block. + expect(systemPrompt).toContain(""); + expect(systemPrompt).toContain(extraDir); + expect(systemPrompt).toContain(laterDir); + } finally { + await session.dispose(); + } + } finally { + auth.close(); + dir.removeSync(); + } + }); +}); From 48381f0e98e9eff34b35ac8d5d4522ede8808ef4 Mon Sep 17 00:00:00 2001 From: maatheusgois-dd Date: Wed, 22 Jul 2026 00:58:02 -0300 Subject: [PATCH 02/19] Fix AI review findings: settings on initial launch, /new root leak, persisted roots on resume, consistent ~ normalization, dead exports - Seed workspace.additionalDirectories settings on initial launch session, not just /new (sdk.ts) - Always call setAdditionalDirectories on /new, even with empty list, to clear stale roots from the previous session (agent-session.ts) - Make setAdditionalDirectories async and trigger atomic rewrite when a session file already exists, so --continue --add-dir persists (session-manager.ts) - Route addWorkspaceDirectory/removeWorkspaceDirectory through normalizeWorkspaceDirectory for consistent ~ expansion (session-manager.ts) - Drop dead exports: workspaceRootForPath (no production callers), getWorkspace (no production callers), and unused SessionWorkspace type import from session-manager.ts (session-workspace.ts, session-manager.ts) - Remove unnecessary as SettingPath / as string[] casts (agent-session.ts) - Update tests: add ~ expansion coverage, root-clearing on /new, persistence on resumed sessions, fix header line parsing Co-authored-by: oh-my-pi --- packages/coding-agent/src/sdk.ts | 5 +- .../coding-agent/src/session/agent-session.ts | 7 +- .../src/session/session-manager.ts | 18 ++--- .../src/session/session-workspace.ts | 17 ----- .../workspace-directories.test.ts | 69 +++++++++++++------ 5 files changed, 63 insertions(+), 53 deletions(-) diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index c2b3758ee..96b98d084 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -1311,9 +1311,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} logger.time("sessionManager", () => SessionManager.create(cwd, SessionManager.getDefaultSessionDir(cwd, agentDir)), ); - if (options.additionalDirectories && options.additionalDirectories.length > 0) { - sessionManager.setAdditionalDirectories(options.additionalDirectories); - } + const configuredDirs = options.additionalDirectories ?? settings.get("workspace.additionalDirectories"); + if (configuredDirs.length > 0) await sessionManager.setAdditionalDirectories(configuredDirs); const providerSessionId = options.providerSessionId ?? sessionManager.getSessionId(); const forkCacheShapeChanged = options.model !== undefined || diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index b2cc32562..6ca418c63 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -194,7 +194,7 @@ import { import { getKnownRoleIds, MODEL_ROLE_IDS, MODEL_ROLES } from "../config/model-roles"; import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates"; import { buildServiceTierByFamily, serviceTierForAllFamilies, serviceTierSettingToTier } from "../config/service-tier"; -import type { SettingPath, Settings, SkillsSettings } from "../config/settings"; +import type { Settings, SkillsSettings } from "../config/settings"; import { getDefault, onAppendOnlyModeChanged, @@ -9984,9 +9984,8 @@ export class AgentSession { await this.sessionManager.flush(); } await this.sessionManager.newSession(options); - const configuredDirs = - (this.settings.get("workspace.additionalDirectories" as SettingPath) as string[] | undefined) ?? []; - if (configuredDirs.length > 0) this.sessionManager.setAdditionalDirectories(configuredDirs); + const configuredDirs = this.settings.get("workspace.additionalDirectories"); + await this.sessionManager.setAdditionalDirectories(configuredDirs); this.#markBashSessionTransition(bashTransition); sessionTransitioned = true; } finally { diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 3f7a44bec..018c669b9 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -73,7 +73,7 @@ import { type SessionStorageWriter, } from "./session-storage"; import { type SessionTitleUpdate, serializeTitleSlot } from "./session-title-slot"; -import { additionalWorkspaceDirectories, normalizeSessionWorkspace, type SessionWorkspace } from "./session-workspace"; +import { additionalWorkspaceDirectories, normalizeSessionWorkspace, normalizeWorkspaceDirectory } from "./session-workspace"; const JSONL_SUFFIX_LENGTH = ".jsonl".length; const DRAFT_ONLY_SESSION_MARKER = ".draft-only-session"; @@ -1313,10 +1313,6 @@ export class SessionManager { return [...this.#additionalDirectories]; } - /** Full workspace as a {@link SessionWorkspace}: cwd first, then additional dirs. */ - getWorkspace(): SessionWorkspace { - return { cwd: this.#cwd, directories: [this.#cwd, ...this.#additionalDirectories] }; - } /** * Add a workspace directory. Normalizes (relative to cwd), dedupes, rejects @@ -1325,7 +1321,7 @@ export class SessionManager { * path or `null` when the directory was already present (no-op). */ async addWorkspaceDirectory(directory: string): Promise { - const resolved = path.resolve(this.#cwd, directory); + const resolved = normalizeWorkspaceDirectory(directory, this.#cwd); if (resolved === path.resolve(this.#cwd)) { throw new Error("The current working directory is already the primary workspace root."); } @@ -1345,7 +1341,7 @@ export class SessionManager { * `null` when the directory was not an additional root (no-op). */ async removeWorkspaceDirectory(directory: string): Promise { - const resolved = path.isAbsolute(directory) ? directory : path.resolve(this.#cwd, directory); + const resolved = normalizeWorkspaceDirectory(directory, this.#cwd); const idx = this.#additionalDirectories.findIndex(p => path.resolve(p) === resolved); if (idx === -1) return null; this.#additionalDirectories = this.#additionalDirectories.filter((_, i) => i !== idx); @@ -1361,8 +1357,8 @@ export class SessionManager { return resolved; } - /** Seed additional directories from settings or a passed list (called at session creation). */ - setAdditionalDirectories(directories: string[]): void { + /** Seed additional directories from settings or a passed list. Also called on resumed sessions with --add-dir; persists the updated header when a session file already exists. */ + async setAdditionalDirectories(directories: string[]): Promise { const workspace = normalizeSessionWorkspace({ cwd: this.#cwd, directories }); this.#additionalDirectories = additionalWorkspaceDirectories(workspace); if (this.#additionalDirectories.length > 0) { @@ -1370,6 +1366,10 @@ export class SessionManager { } else { this.#header.additionalDirectories = undefined; } + if (this.#persist && this.#sessionFile) { + this.#rewriteRequired = true; + await this.#rewriteAtomically(); + } } getUsageStatistics(): UsageStatistics { diff --git a/packages/coding-agent/src/session/session-workspace.ts b/packages/coding-agent/src/session/session-workspace.ts index da623938d..e60250741 100644 --- a/packages/coding-agent/src/session/session-workspace.ts +++ b/packages/coding-agent/src/session/session-workspace.ts @@ -51,20 +51,3 @@ export function normalizeSessionWorkspace(args: { cwd: string; directories?: str export function additionalWorkspaceDirectories(workspace: SessionWorkspace): string[] { return workspace.directories.filter(directory => directory !== workspace.cwd); } - -/** - * Pick the workspace directory containing `filePath` (longest prefix wins, so - * nested roots resolve to the deepest match), falling back to `fallback` when - * no workspace directory contains it. Keeps per-file consumers (e.g. LSP root - * selection) from collapsing unrelated repos into one parent tree. - */ -export function workspaceRootForPath(filePath: string, directories: string[] | undefined, fallback: string): string { - let best = ""; - for (const directory of directories ?? []) { - const prefixed = directory.endsWith(path.sep) ? directory : `${directory}${path.sep}`; - if ((filePath === directory || filePath.startsWith(prefixed)) && directory.length > best.length) { - best = directory; - } - } - return best || fallback; -} diff --git a/packages/coding-agent/test/session-manager/workspace-directories.test.ts b/packages/coding-agent/test/session-manager/workspace-directories.test.ts index e6c66787c..efa010a7f 100644 --- a/packages/coding-agent/test/session-manager/workspace-directories.test.ts +++ b/packages/coding-agent/test/session-manager/workspace-directories.test.ts @@ -5,7 +5,6 @@ import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manage import { additionalWorkspaceDirectories, normalizeSessionWorkspace, - workspaceRootForPath, } from "@oh-my-pi/pi-coding-agent/session/session-workspace"; import { TempDir } from "@oh-my-pi/pi-utils"; @@ -40,31 +39,19 @@ describe("additionalWorkspaceDirectories", () => { }); }); -describe("workspaceRootForPath", () => { - it("picks the longest-prefix match", () => { - const dirs = ["/repo", "/repo/packages/sub"]; - expect(workspaceRootForPath("/repo/packages/sub/file.ts", dirs, "/fallback")).toBe("/repo/packages/sub"); - expect(workspaceRootForPath("/repo/other/file.ts", dirs, "/fallback")).toBe("/repo"); - }); - - it("falls back when no root contains the path", () => { - expect(workspaceRootForPath("/elsewhere/file.ts", ["/repo"], "/fallback")).toBe("/fallback"); - }); -}); - describe("SessionManager workspace directories", () => { it("starts with no additional directories", () => { const session = SessionManager.inMemory(); expect(session.getAdditionalDirectories()).toEqual([]); - expect(session.getWorkspace().directories).toEqual([session.getCwd()]); + expect([session.getCwd(), ...session.getAdditionalDirectories()]).toEqual([session.getCwd()]); }); - it("seeds from setAdditionalDirectories and excludes cwd", () => { + it("seeds from setAdditionalDirectories and excludes cwd", async () => { const session = SessionManager.inMemory(); - session.setAdditionalDirectories(["/some/other", session.getCwd()]); + await session.setAdditionalDirectories(["/some/other", session.getCwd()]); // cwd is filtered out of the additional set. expect(session.getAdditionalDirectories()).toEqual(["/some/other"]); - expect(session.getWorkspace().directories).toEqual([session.getCwd(), "/some/other"]); + expect([session.getCwd(), ...session.getAdditionalDirectories()]).toEqual([session.getCwd(), "/some/other"]); }); it("addWorkspaceDirectory rejects the cwd itself", async () => { @@ -84,6 +71,14 @@ describe("SessionManager workspace directories", () => { expect(session.getAdditionalDirectories()).toEqual([path.resolve("/another/repo")]); }); + it("addWorkspaceDirectory expands ~ to home", async () => { + const session = SessionManager.inMemory(); + const home = require("node:os").homedir(); + const added = await session.addWorkspaceDirectory("~/projects"); + expect(added).toBe(path.join(home, "projects")); + expect(session.getAdditionalDirectories()).toEqual([path.join(home, "projects")]); + }); + it("removeWorkspaceDirectory removes a known root and returns null when absent", async () => { const session = SessionManager.inMemory(); await session.addWorkspaceDirectory("/x"); @@ -95,6 +90,16 @@ describe("SessionManager workspace directories", () => { expect(again).toBeNull(); }); + it("removeWorkspaceDirectory matches ~-expanded paths", async () => { + const session = SessionManager.inMemory(); + const home = require("node:os").homedir(); + await session.addWorkspaceDirectory("~/projects"); + // Removing with the ~ form should match the expanded stored path. + const removed = await session.removeWorkspaceDirectory("~/projects"); + expect(removed).toBe(path.join(home, "projects")); + expect(session.getAdditionalDirectories()).toEqual([]); + }); + it("persists additionalDirectories in the session header across reopen", async () => { using tempDir = TempDir.createSync("@pi-session-workspace-persist-"); const session = SessionManager.create(tempDir.path(), tempDir.path()); @@ -107,7 +112,10 @@ describe("SessionManager workspace directories", () => { expect(file).toBeDefined(); const reopened = await SessionManager.open(file!); expect(reopened.getAdditionalDirectories()).toEqual([path.join(tempDir.path(), "sibling")]); - expect(reopened.getWorkspace().directories).toEqual([tempDir.path(), path.join(tempDir.path(), "sibling")]); + expect([reopened.getCwd(), ...reopened.getAdditionalDirectories()]).toEqual([ + tempDir.path(), + path.join(tempDir.path(), "sibling"), + ]); }); it("clears the header field when the last additional directory is removed", async () => { @@ -119,8 +127,29 @@ describe("SessionManager workspace directories", () => { await session.removeWorkspaceDirectory(path.join(tempDir.path(), "extra")); const file = session.getSessionFile()!; - const raw = fs.readFileSync(file, "utf8").split("\n")[0]!; - const header = JSON.parse(raw); + const header = JSON.parse(fs.readFileSync(file, "utf8").split("\n").filter(l => l.trim())[1]!); expect(header.additionalDirectories).toBeUndefined(); }); + + it("setAdditionalDirectories clears stale roots when called with an empty list", async () => { + const session = SessionManager.inMemory(); + await session.addWorkspaceDirectory("/stale"); + expect(session.getAdditionalDirectories()).toEqual([path.resolve("/stale")]); + + await session.setAdditionalDirectories([]); + expect(session.getAdditionalDirectories()).toEqual([]); + }); + + it("setAdditionalDirectories persists the updated header on a resumed session", async () => { + using tempDir = TempDir.createSync("@pi-session-workspace-resume-"); + const session = SessionManager.create(tempDir.path(), tempDir.path()); + // Simulate a resumed session: append a message so the file exists, then setAdditionalDirectories. + session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + await session.flush(); + + await session.setAdditionalDirectories([path.join(tempDir.path(), "added")]); + const file = session.getSessionFile()!; + const header = JSON.parse(fs.readFileSync(file, "utf8").split("\n").filter(l => l.trim())[1]!); + expect(header.additionalDirectories).toEqual([path.join(tempDir.path(), "added")]); + }); }); From 0a4ca2fe20f0ce6acdcb81b11fa8fc22fefdc324 Mon Sep 17 00:00:00 2001 From: maatheusgois-dd Date: Wed, 22 Jul 2026 01:06:55 -0300 Subject: [PATCH 03/19] Load project context files for all workspace roots, not just cwd When additional workspace directories have their own AGENTS.md/rules, the agent now discovers and injects those context files alongside the primary cwd's context. This prevents the agent from editing under a root without seeing the rules that apply there. Co-authored-by: oh-my-pi --- packages/coding-agent/src/system-prompt.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index 96051316b..c5eabcc38 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -626,7 +626,16 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): : logger.time("loadSystemPromptFiles", loadSystemPromptFiles, { cwd: resolvedCwd }); const contextFilesPromise = providedContextFiles ? Promise.resolve(providedContextFiles) - : logger.time("loadProjectContextFiles", loadProjectContextFiles, { cwd: resolvedCwd }); + : (async () => { + const primary = await logger.time("loadProjectContextFiles", loadProjectContextFiles, { cwd: resolvedCwd }); + // Also discover context files (AGENTS.md, rules, etc.) for each additional workspace root. + const additionalRoots = additionalWorkspaceRoots.filter(d => path.resolve(d) !== path.resolve(resolvedCwd)); + if (additionalRoots.length === 0) return primary; + const extra = await Promise.all( + additionalRoots.map(root => loadProjectContextFiles({ cwd: root }).catch(() => [])), + ); + return dedupeExactContextFiles([...primary, ...extra.flat()]); + })(); const workspaceTreePromise = providedWorkspaceTree !== undefined ? Promise.resolve(providedWorkspaceTree) From ab0c25e05818d9cbcd8cb89007e12348e5774d76 Mon Sep 17 00:00:00 2001 From: maatheusgois-dd Date: Wed, 22 Jul 2026 01:14:13 -0300 Subject: [PATCH 04/19] Fix 2nd-round AI review: context files for all roots, merge on resume, fork copies roots - Always augment context files with additional root context, even when createAgentSession passes preloaded contextFiles (system-prompt.ts) - Merge configured dirs with existing restored roots on resume instead of replacing them (sdk.ts) - Copy additionalDirectories from source header in forkFrom so forks preserve the multi-root set (session-manager.ts) - Add test for forkFrom preserving additionalDirectories Co-authored-by: oh-my-pi --- packages/coding-agent/src/sdk.ts | 7 +++++- .../src/session/session-manager.ts | 2 ++ packages/coding-agent/src/system-prompt.ts | 24 +++++++++---------- .../workspace-directories.test.ts | 11 +++++++++ 4 files changed, 31 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 96b98d084..00724a622 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -1312,7 +1312,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} SessionManager.create(cwd, SessionManager.getDefaultSessionDir(cwd, agentDir)), ); const configuredDirs = options.additionalDirectories ?? settings.get("workspace.additionalDirectories"); - if (configuredDirs.length > 0) await sessionManager.setAdditionalDirectories(configuredDirs); + if (configuredDirs.length > 0) { + // Merge with any roots restored from the session header (resume/fork), not replace. + const existing = sessionManager.getAdditionalDirectories(); + const merged = [...new Set([...existing, ...configuredDirs])]; + await sessionManager.setAdditionalDirectories(merged); + } const providerSessionId = options.providerSessionId ?? sessionManager.getSessionId(); const forkCacheShapeChanged = options.model !== undefined || diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 018c669b9..643c5b67c 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -2025,6 +2025,8 @@ export class SessionManager { ); manager.#header.title = sourceHeader?.title; manager.#header.titleSource = sourceHeader?.titleSource; + manager.#additionalDirectories = sourceHeader?.additionalDirectories ?? []; + manager.#header.additionalDirectories = manager.#additionalDirectories.length > 0 ? manager.#additionalDirectories : undefined; manager.#sessionName = manager.#header.title; manager.#titleSource = manager.#header.titleSource; manager.#titleUpdatedAt = nowIso(); diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index c5eabcc38..99d828011 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -624,18 +624,18 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): const systemPromptCustomizationPromise: Promise = callerControlsCustomPrompt ? Promise.resolve(null) : logger.time("loadSystemPromptFiles", loadSystemPromptFiles, { cwd: resolvedCwd }); - const contextFilesPromise = providedContextFiles - ? Promise.resolve(providedContextFiles) - : (async () => { - const primary = await logger.time("loadProjectContextFiles", loadProjectContextFiles, { cwd: resolvedCwd }); - // Also discover context files (AGENTS.md, rules, etc.) for each additional workspace root. - const additionalRoots = additionalWorkspaceRoots.filter(d => path.resolve(d) !== path.resolve(resolvedCwd)); - if (additionalRoots.length === 0) return primary; - const extra = await Promise.all( - additionalRoots.map(root => loadProjectContextFiles({ cwd: root }).catch(() => [])), - ); - return dedupeExactContextFiles([...primary, ...extra.flat()]); - })(); + const contextFilesPromise = (async () => { + const primary = providedContextFiles + ? providedContextFiles + : await logger.time("loadProjectContextFiles", loadProjectContextFiles, { cwd: resolvedCwd }); + // Also discover context files (AGENTS.md, rules, etc.) for each additional workspace root. + const additionalRoots = additionalWorkspaceRoots.filter(d => path.resolve(d) !== path.resolve(resolvedCwd)); + if (additionalRoots.length === 0) return primary; + const extra = await Promise.all( + additionalRoots.map(root => loadProjectContextFiles({ cwd: root }).catch(() => [])), + ); + return dedupeExactContextFiles([...primary, ...extra.flat()]); + })(); const workspaceTreePromise = providedWorkspaceTree !== undefined ? Promise.resolve(providedWorkspaceTree) diff --git a/packages/coding-agent/test/session-manager/workspace-directories.test.ts b/packages/coding-agent/test/session-manager/workspace-directories.test.ts index efa010a7f..676146436 100644 --- a/packages/coding-agent/test/session-manager/workspace-directories.test.ts +++ b/packages/coding-agent/test/session-manager/workspace-directories.test.ts @@ -152,4 +152,15 @@ describe("SessionManager workspace directories", () => { const header = JSON.parse(fs.readFileSync(file, "utf8").split("\n").filter(l => l.trim())[1]!); expect(header.additionalDirectories).toEqual([path.join(tempDir.path(), "added")]); }); + + it("forkFrom preserves additionalDirectories from the source session", async () => { + using tempDir = TempDir.createSync("@pi-session-workspace-fork-"); + const source = SessionManager.create(tempDir.path(), tempDir.path()); + await source.addWorkspaceDirectory(path.join(tempDir.path(), "extra")); + source.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + await source.flush(); + + const forked = await SessionManager.forkFrom(source.getSessionFile()!, tempDir.path()); + expect(forked.getAdditionalDirectories()).toEqual([path.join(tempDir.path(), "extra")]); + }); }); From d3777e778dd3a9c565da0130002318a4c8b25641 Mon Sep 17 00:00:00 2001 From: maatheusgois-dd Date: Wed, 22 Jul 2026 01:20:48 -0300 Subject: [PATCH 05/19] Restore additionalDirectories on failed session switch rollback restoreState() now syncs #additionalDirectories from the captured snapshot header, so a failed switchSession (e.g. from a session_switch hook or model-restore error) doesn't leave the original session with the target's workspace roots. Co-authored-by: oh-my-pi --- packages/coding-agent/src/session/session-manager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 643c5b67c..1a10fa9a5 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -991,6 +991,7 @@ export class SessionManager { this.#forceFileCreation = snapshot.onDisk; this.#draftOnlySessionCleanupArmed = snapshot.draftOnlySessionCleanupArmed; this.#applyEntries(snapshot.header, [...snapshot.entries]); + this.#additionalDirectories = snapshot.header.additionalDirectories ?? []; this.#sessionName = snapshot.sessionName; this.#titleSource = snapshot.titleSource; this.#titleUpdatedAt = snapshot.titleUpdatedAt; From e0e7eb4a3b1afda3442781d1906ef5000a61d5cc Mon Sep 17 00:00:00 2001 From: maatheusgois-dd Date: Wed, 22 Jul 2026 01:30:21 -0300 Subject: [PATCH 06/19] Refresh base system prompt after /new and use top-level os import in tests - Call refreshBaseSystemPrompt() after newSession so the block reflects the new session's directory set, not the previous session's stale roots (agent-session.ts) - Replace inline require('node:os') with top-level import * as os in test file, following repo convention Co-authored-by: oh-my-pi --- packages/coding-agent/src/session/agent-session.ts | 3 +++ .../test/session-manager/workspace-directories.test.ts | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 6ca418c63..02cd8a4d6 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -10014,6 +10014,9 @@ export class AgentSession { this.#planReferencePath = "local://PLAN.md"; this.#resetAdvisorSessionState(); this.#reconnectToAgent(); + // The workspace-roots block must reflect the new session's directory set, + // not the previous session's — refresh before the next turn goes out. + await this.refreshBaseSystemPrompt(); // Emit session_switch event with reason "new" to hooks if (this.#extensionRunner) { diff --git a/packages/coding-agent/test/session-manager/workspace-directories.test.ts b/packages/coding-agent/test/session-manager/workspace-directories.test.ts index 676146436..360e6c39c 100644 --- a/packages/coding-agent/test/session-manager/workspace-directories.test.ts +++ b/packages/coding-agent/test/session-manager/workspace-directories.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test"; +import * as os from "node:os"; import * as fs from "node:fs"; import * as path from "node:path"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; @@ -23,7 +23,7 @@ describe("normalizeSessionWorkspace", () => { it("expands ~ to home", () => { const workspace = normalizeSessionWorkspace({ cwd: "/tmp", directories: ["~/docs"] }); - expect(workspace.directories[1]).toBe(path.join(process.env.HOME ?? require("node:os").homedir(), "docs")); + expect(workspace.directories[1]).toBe(path.join(process.env.HOME ?? os.homedir(), "docs")); }); }); @@ -73,7 +73,7 @@ describe("SessionManager workspace directories", () => { it("addWorkspaceDirectory expands ~ to home", async () => { const session = SessionManager.inMemory(); - const home = require("node:os").homedir(); + const home = os.homedir(); const added = await session.addWorkspaceDirectory("~/projects"); expect(added).toBe(path.join(home, "projects")); expect(session.getAdditionalDirectories()).toEqual([path.join(home, "projects")]); @@ -92,7 +92,7 @@ describe("SessionManager workspace directories", () => { it("removeWorkspaceDirectory matches ~-expanded paths", async () => { const session = SessionManager.inMemory(); - const home = require("node:os").homedir(); + const home = os.homedir(); await session.addWorkspaceDirectory("~/projects"); // Removing with the ~ form should match the expanded stored path. const removed = await session.removeWorkspaceDirectory("~/projects"); From 25761276bbd5553f0b61915f4943902392c693f0 Mon Sep 17 00:00:00 2001 From: maatheusgois-dd Date: Wed, 22 Jul 2026 01:38:27 -0300 Subject: [PATCH 07/19] Merge CLI --add-dir roots with settings roots instead of skipping settings When --add-dir is provided, settings.get('workspace.additionalDirectories') was skipped entirely. Now both sources are merged so configured default roots apply to every session even when CLI adds a one-off root. Co-authored-by: oh-my-pi --- packages/coding-agent/src/sdk.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 00724a622..bafa23800 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -1311,7 +1311,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} logger.time("sessionManager", () => SessionManager.create(cwd, SessionManager.getDefaultSessionDir(cwd, agentDir)), ); - const configuredDirs = options.additionalDirectories ?? settings.get("workspace.additionalDirectories"); + const configuredDirs = [...new Set([...(options.additionalDirectories ?? []), ...settings.get("workspace.additionalDirectories")])]; if (configuredDirs.length > 0) { // Merge with any roots restored from the session header (resume/fork), not replace. const existing = sessionManager.getAdditionalDirectories(); From 1704e3fd5a2c52bbb5a752cb000f451bfecf6f8c Mon Sep 17 00:00:00 2001 From: maatheusgois-dd Date: Wed, 22 Jul 2026 01:47:46 -0300 Subject: [PATCH 08/19] Normalize additional dirs on /move and discover nested AGENTS.md from added roots - Filter #additionalDirectories when moveTo changes cwd so the new cwd is never also listed as an additional root (session-manager.ts) - Build workspace tree for each additional root to discover nested AGENTS.md files under added roots, merging agentsMdFiles into the primary set (system-prompt.ts) Co-authored-by: oh-my-pi --- .../src/session/session-manager.ts | 7 +++++ packages/coding-agent/src/system-prompt.ts | 31 ++++++++++++------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 1a10fa9a5..f0d76e08d 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -1203,6 +1203,13 @@ export class SessionManager { this.#cwd = resolvedCwd; this.#sessionDir = nextSessionDir; this.#header.cwd = resolvedCwd; + // Re-filter additional roots: the new cwd may have been an additional root, + // or it may now contain/subsume one. Re-normalize to keep the invariant + // that cwd is never also listed as an additional directory. + if (this.#additionalDirectories.length > 0) { + this.#additionalDirectories = this.#additionalDirectories.filter(d => d !== resolvedCwd); + this.#header.additionalDirectories = this.#additionalDirectories.length > 0 ? this.#additionalDirectories : undefined; + } // Rewrite at the new location when the file already existed (update cwd) or // there is in-memory output worth materializing; otherwise stay lazy. diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index 99d828011..dd510fba2 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -636,20 +636,29 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): ); return dedupeExactContextFiles([...primary, ...extra.flat()]); })(); - const workspaceTreePromise = - providedWorkspaceTree !== undefined - ? Promise.resolve(providedWorkspaceTree) + const additionalRootsForTree = additionalWorkspaceRoots.filter(d => path.resolve(d) !== path.resolve(resolvedCwd)); + const workspaceTreePromise = (async () => { + const primary = providedWorkspaceTree !== undefined + ? providedWorkspaceTree : includeWorkspaceTree - ? logger.time("buildWorkspaceTree", () => + ? await logger.time("buildWorkspaceTree", () => buildWorkspaceTree(resolvedCwd, { timeoutMs: SYSTEM_PROMPT_PREP_TIMEOUT_MS }), ) - : Promise.resolve({ - rootPath: resolvedCwd, - rendered: "", - truncated: false, - totalLines: 0, - agentsMdFiles: [], - }); + : { rootPath: resolvedCwd, rendered: "", truncated: false, totalLines: 0, agentsMdFiles: [] }; + if (additionalRootsForTree.length === 0 || !includeWorkspaceTree) return primary; + const extraTrees = await Promise.all( + additionalRootsForTree.map(root => + buildWorkspaceTree(root, { timeoutMs: SYSTEM_PROMPT_PREP_TIMEOUT_MS }).catch(() => ({ + rootPath: root, + rendered: "", + truncated: false, + totalLines: 0, + agentsMdFiles: [], + })), + ), + ); + return { ...primary, agentsMdFiles: [...primary.agentsMdFiles, ...extraTrees.flatMap(t => t.agentsMdFiles)] }; + })(); const skillsPromise: Promise = providedSkills !== undefined ? Promise.resolve(providedSkills) From aa15496d297560331a901fe538f5fec520b304ea Mon Sep 17 00:00:00 2001 From: maatheusgois-dd Date: Wed, 22 Jul 2026 01:55:29 -0300 Subject: [PATCH 09/19] Await providedWorkspaceTree when it's a Promise before merging root agentsMdFiles Fixes a crash: createAgentSession passes workspaceTreePromise (a Promise) as providedWorkspaceTree. The merge path accessed primary.agentsMdFiles without awaiting, rejecting the system-prompt prep and falling back to an empty tree. Co-authored-by: oh-my-pi --- packages/coding-agent/src/system-prompt.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index dd510fba2..76c14a8ef 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -639,7 +639,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): const additionalRootsForTree = additionalWorkspaceRoots.filter(d => path.resolve(d) !== path.resolve(resolvedCwd)); const workspaceTreePromise = (async () => { const primary = providedWorkspaceTree !== undefined - ? providedWorkspaceTree + ? await Promise.resolve(providedWorkspaceTree) : includeWorkspaceTree ? await logger.time("buildWorkspaceTree", () => buildWorkspaceTree(resolvedCwd, { timeoutMs: SYSTEM_PROMPT_PREP_TIMEOUT_MS }), From bff14d121df0dfe8e5b10856e9b650845a55af3f Mon Sep 17 00:00:00 2001 From: maatheusgois-dd Date: Wed, 22 Jul 2026 02:04:05 -0300 Subject: [PATCH 10/19] Copy additionalDirectories in createBranchedSession and normalize fork roots against target cwd - createBranchedSession (/branch, /btw) now copies additionalDirectories to the new header so branches preserve multi-root state - forkFrom filters source additionalDirectories against the target cwd so the new cwd is never also listed as an additional root Co-authored-by: oh-my-pi --- packages/coding-agent/src/session/session-manager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index f0d76e08d..d10fa3129 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -1913,6 +1913,7 @@ export class SessionManager { timestamp, cwd: this.#cwd, parentSession: this.#persist ? sourceSessionFile : undefined, + additionalDirectories: this.#additionalDirectories.length > 0 ? [...this.#additionalDirectories] : undefined, }; const labels: LabelEntry[] = []; @@ -2033,7 +2034,7 @@ export class SessionManager { ); manager.#header.title = sourceHeader?.title; manager.#header.titleSource = sourceHeader?.titleSource; - manager.#additionalDirectories = sourceHeader?.additionalDirectories ?? []; + manager.#additionalDirectories = (sourceHeader?.additionalDirectories ?? []).filter(d => d !== path.resolve(cwd)); manager.#header.additionalDirectories = manager.#additionalDirectories.length > 0 ? manager.#additionalDirectories : undefined; manager.#sessionName = manager.#header.title; manager.#titleSource = manager.#header.titleSource; From 33e1857a1d2287e4a7f6cd40de6250ef49a33007 Mon Sep 17 00:00:00 2001 From: maatheusgois-dd Date: Wed, 22 Jul 2026 02:14:51 -0300 Subject: [PATCH 11/19] Centralize additional-root seeding in SessionManager.newSession - Add additionalDirectories to NewSessionOptions - #resetToNewSession now seeds #additionalDirectories from options, so all new-session transitions (handoff, branch, /new) get the roots - AgentSession.newSession passes settings dirs via options instead of calling setAdditionalDirectories after the fact Co-authored-by: oh-my-pi --- packages/coding-agent/src/session/agent-session.ts | 7 ++++--- packages/coding-agent/src/session/session-entries.ts | 2 ++ packages/coding-agent/src/session/session-manager.ts | 4 ++++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 02cd8a4d6..6b7ede68d 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -9983,9 +9983,10 @@ export class AgentSession { } else { await this.sessionManager.flush(); } - await this.sessionManager.newSession(options); - const configuredDirs = this.settings.get("workspace.additionalDirectories"); - await this.sessionManager.setAdditionalDirectories(configuredDirs); + await this.sessionManager.newSession({ + ...options, + additionalDirectories: this.settings.get("workspace.additionalDirectories"), + }); this.#markBashSessionTransition(bashTransition); sessionTransitioned = true; } finally { diff --git a/packages/coding-agent/src/session/session-entries.ts b/packages/coding-agent/src/session/session-entries.ts index 11bc01ee2..630361a8f 100644 --- a/packages/coding-agent/src/session/session-entries.ts +++ b/packages/coding-agent/src/session/session-entries.ts @@ -49,6 +49,8 @@ export interface NewSessionOptions { providerPromptCacheKey?: string; /** Skip flushing the current session and delete it instead of saving. */ drop?: boolean; + /** Additional workspace directories to seed on the new session. */ + additionalDirectories?: string[]; } export interface SessionEntryBase { diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index d10fa3129..a7dc52b2c 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -808,6 +808,10 @@ export class SessionManager { parentSession: options?.parentSession, providerPromptCacheKey: options?.providerPromptCacheKey, }; + this.#additionalDirectories = options?.additionalDirectories ?? []; + if (this.#additionalDirectories.length > 0) { + this.#header.additionalDirectories = [...this.#additionalDirectories]; + } this.#titleUpdatedAt = timestamp; this.#entries = []; From c24cb606a85aacf25cb7a13076869d2abc702164 Mon Sep 17 00:00:00 2001 From: maatheusgois-dd Date: Wed, 22 Jul 2026 02:25:03 -0300 Subject: [PATCH 12/19] Normalize additionalDirectories in #resetToNewSession through normalizeSessionWorkspace Relative paths in workspace.additionalDirectories settings (e.g. '../shared') were stored raw in the new-session header instead of being expanded to absolute. Now all new-session roots go through the same normalizer used at startup. Co-authored-by: oh-my-pi --- packages/coding-agent/src/session/session-manager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index a7dc52b2c..a0da06250 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -808,7 +808,8 @@ export class SessionManager { parentSession: options?.parentSession, providerPromptCacheKey: options?.providerPromptCacheKey, }; - this.#additionalDirectories = options?.additionalDirectories ?? []; + const workspace = normalizeSessionWorkspace({ cwd: this.#cwd, directories: options?.additionalDirectories ?? [] }); + this.#additionalDirectories = additionalWorkspaceDirectories(workspace); if (this.#additionalDirectories.length > 0) { this.#header.additionalDirectories = [...this.#additionalDirectories]; } From ffe50650e1bc6e5540afdaa7e53fadcc9cb2bc8a Mon Sep 17 00:00:00 2001 From: maatheusgois-dd Date: Wed, 22 Jul 2026 02:36:38 -0300 Subject: [PATCH 13/19] Propagate workspace roots into subagent sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subagents (task tool) now inherit the parent session's additionalDirectories via ToolSession → ExecutorOptions → CreateAgentSessionOptions, so delegated agents see the same block and can read/grep/glob added roots. Co-authored-by: oh-my-pi --- packages/coding-agent/src/sdk.ts | 3 +++ packages/coding-agent/src/task/executor.ts | 3 +++ packages/coding-agent/src/task/structured-subagent.ts | 1 + packages/coding-agent/src/tools/index.ts | 2 ++ 4 files changed, 9 insertions(+) diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index bafa23800..f1e1eee94 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -1665,6 +1665,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} isToolActive: name => activeToolNames.has(name), setActiveToolNames, hasUI: options.hasUI ?? false, + get additionalDirectories() { + return sessionManager.getAdditionalDirectories(); + }, enableLsp, enableIrc: restrictToolNames ? false : options.enableIrc, restrictToolNames, diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 220bd1f51..20e3f9ed3 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -287,6 +287,8 @@ function isRecord(value: unknown): value is Record { /** Options for subagent execution */ export interface ExecutorOptions { cwd: string; + /** Additional workspace directories to seed on the subagent session (multi-root). */ + additionalDirectories?: string[]; worktree?: string; agent: AgentDefinition; task: string; @@ -2539,6 +2541,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise ({ cwd: worktree ?? cwd, + additionalDirectories: options.additionalDirectories, authStorage, modelRegistry, settings: subagentSettings, diff --git a/packages/coding-agent/src/task/structured-subagent.ts b/packages/coding-agent/src/task/structured-subagent.ts index e8a18ea05..9cf7448df 100644 --- a/packages/coding-agent/src/task/structured-subagent.ts +++ b/packages/coding-agent/src/task/structured-subagent.ts @@ -371,6 +371,7 @@ function buildExecutorOptions( const enableMCP = !policy.planMode && (session.enableMCP ?? true); return { cwd: session.cwd, + additionalDirectories: session.additionalDirectories, agent: policy.effectiveAgent, task: renderSubagentPrompt(request.assignment), assignment: request.assignment.trim(), diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index 1ca56486a..90a75a80c 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -143,6 +143,8 @@ export interface DeferredDiagnosticsEntry { export interface ToolSession { /** Current working directory */ cwd: string; + /** Additional workspace directories beyond cwd (multi-root), forwarded to subagents. */ + additionalDirectories?: string[]; /** Whether UI is available */ hasUI: boolean; /** From 2fa43ad5a69d2360fe557fbc5fea8a4c5b57cef3 Mon Sep 17 00:00:00 2001 From: maatheusgois-dd Date: Wed, 22 Jul 2026 02:44:56 -0300 Subject: [PATCH 14/19] Omit additionalDirectories for isolated (worktree) task runs Isolated tasks clone only cwd into the worktree. Forwarding the parent's additional directories would let the subagent edit absolute paths under the original extra roots, bypassing isolation. Now additionalDirectories is undefined when worktree is set. Co-authored-by: oh-my-pi --- packages/coding-agent/src/task/executor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 20e3f9ed3..c526cf1c4 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -2541,7 +2541,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise ({ cwd: worktree ?? cwd, - additionalDirectories: options.additionalDirectories, + additionalDirectories: worktree !== undefined ? undefined : options.additionalDirectories, authStorage, modelRegistry, settings: subagentSettings, From 6c15874d1577c1a8723367116666d6896ae12115 Mon Sep 17 00:00:00 2001 From: maatheusgois-dd Date: Wed, 22 Jul 2026 02:52:42 -0300 Subject: [PATCH 15/19] Clear workspace.additionalDirectories setting for isolated worktree subagent runs Setting options.additionalDirectories to undefined wasn't enough: createAgentSession also merges settings.get('workspace.additionalDirectories'). Now createSubagentSettings gets an override to clear the setting when worktree is set, ensuring isolated runs can't edit outside the worktree. Co-authored-by: oh-my-pi --- packages/coding-agent/src/task/executor.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index c526cf1c4..f56058bb7 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -2220,7 +2220,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise Date: Wed, 22 Jul 2026 03:01:10 -0300 Subject: [PATCH 16/19] Make parent live root list authoritative for subagents, CLI merge stays in buildSessionOptions - main.ts:buildSessionOptions now merges --add-dir with settings before passing to options.additionalDirectories - sdk.ts: when options.additionalDirectories is explicitly provided, uses it as-is (no settings re-merge); falls back to settings only when not provided - This prevents removed roots from being re-seeded in subagent sessions Co-authored-by: oh-my-pi --- packages/coding-agent/src/main.ts | 6 ++++-- packages/coding-agent/src/sdk.ts | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 869a27c09..3541096cc 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -833,8 +833,10 @@ export async function buildSessionOptions( cwd: parsed.cwd ?? getProjectDir(), autoApprove: parsed.autoApprove ?? false, }; - if (parsed.addDir && parsed.addDir.length > 0) { - options.additionalDirectories = parsed.addDir; + const cliDirs = parsed.addDir ?? []; + const settingsDirs = activeSettings.get("workspace.additionalDirectories"); + if (cliDirs.length > 0 || settingsDirs.length > 0) { + options.additionalDirectories = [...new Set([...cliDirs, ...settingsDirs])]; } if (parsed.maxTime !== undefined) { options.deadline = Date.now() + parsed.maxTime * 1000; diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index f1e1eee94..b56797b30 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -1311,7 +1311,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} logger.time("sessionManager", () => SessionManager.create(cwd, SessionManager.getDefaultSessionDir(cwd, agentDir)), ); - const configuredDirs = [...new Set([...(options.additionalDirectories ?? []), ...settings.get("workspace.additionalDirectories")])]; + const configuredDirs = options.additionalDirectories + ? options.additionalDirectories + : settings.get("workspace.additionalDirectories"); if (configuredDirs.length > 0) { // Merge with any roots restored from the session header (resume/fork), not replace. const existing = sessionManager.getAdditionalDirectories(); From 546a8caa1b271dd0b4c267c22e22ac7ef29e590c Mon Sep 17 00:00:00 2001 From: maatheusgois-dd Date: Wed, 22 Jul 2026 03:09:41 -0300 Subject: [PATCH 17/19] Refresh base system prompt after switchSession to sync workspace-roots block switchSession (/resume, RPC reload) updates the manager's roots from the target header but never rebuilt the base system prompt. The next turn would see the previous session's stale block until an unrelated prompt rebuild happened. Co-authored-by: oh-my-pi --- packages/coding-agent/src/session/agent-session.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 6b7ede68d..542b71c1a 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -16788,6 +16788,8 @@ export class AgentSession { error: String(error), }); } + // Refresh the workspace-roots block to match the resumed session's directory set. + await this.refreshBaseSystemPrompt(); this.#finishBashSessionTransition(bashTransition, true); return true; } catch (error) { From 5c312e23ff304502f334188fa650fb4df02bd02a Mon Sep 17 00:00:00 2001 From: maatheusgois-dd Date: Wed, 22 Jul 2026 11:42:48 -0300 Subject: [PATCH 18/19] Fix biome lint: import wrapping, import sorting, formatting Co-authored-by: oh-my-pi --- .../src/session/session-manager.ts | 18 +++++++++++++----- packages/coding-agent/src/system-prompt.ts | 15 ++++++++------- .../workspace-directories.test.ts | 16 +++++++++++++--- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index a0da06250..4b07c74c3 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -73,7 +73,11 @@ import { type SessionStorageWriter, } from "./session-storage"; import { type SessionTitleUpdate, serializeTitleSlot } from "./session-title-slot"; -import { additionalWorkspaceDirectories, normalizeSessionWorkspace, normalizeWorkspaceDirectory } from "./session-workspace"; +import { + additionalWorkspaceDirectories, + normalizeSessionWorkspace, + normalizeWorkspaceDirectory, +} from "./session-workspace"; const JSONL_SUFFIX_LENGTH = ".jsonl".length; const DRAFT_ONLY_SESSION_MARKER = ".draft-only-session"; @@ -808,7 +812,10 @@ export class SessionManager { parentSession: options?.parentSession, providerPromptCacheKey: options?.providerPromptCacheKey, }; - const workspace = normalizeSessionWorkspace({ cwd: this.#cwd, directories: options?.additionalDirectories ?? [] }); + const workspace = normalizeSessionWorkspace({ + cwd: this.#cwd, + directories: options?.additionalDirectories ?? [], + }); this.#additionalDirectories = additionalWorkspaceDirectories(workspace); if (this.#additionalDirectories.length > 0) { this.#header.additionalDirectories = [...this.#additionalDirectories]; @@ -1213,7 +1220,8 @@ export class SessionManager { // that cwd is never also listed as an additional directory. if (this.#additionalDirectories.length > 0) { this.#additionalDirectories = this.#additionalDirectories.filter(d => d !== resolvedCwd); - this.#header.additionalDirectories = this.#additionalDirectories.length > 0 ? this.#additionalDirectories : undefined; + this.#header.additionalDirectories = + this.#additionalDirectories.length > 0 ? this.#additionalDirectories : undefined; } // Rewrite at the new location when the file already existed (update cwd) or @@ -1326,7 +1334,6 @@ export class SessionManager { return [...this.#additionalDirectories]; } - /** * Add a workspace directory. Normalizes (relative to cwd), dedupes, rejects * the cwd itself, persists to the session header, and triggers an atomic @@ -2040,7 +2047,8 @@ export class SessionManager { manager.#header.title = sourceHeader?.title; manager.#header.titleSource = sourceHeader?.titleSource; manager.#additionalDirectories = (sourceHeader?.additionalDirectories ?? []).filter(d => d !== path.resolve(cwd)); - manager.#header.additionalDirectories = manager.#additionalDirectories.length > 0 ? manager.#additionalDirectories : undefined; + manager.#header.additionalDirectories = + manager.#additionalDirectories.length > 0 ? manager.#additionalDirectories : undefined; manager.#sessionName = manager.#header.title; manager.#titleSource = manager.#header.titleSource; manager.#titleUpdatedAt = nowIso(); diff --git a/packages/coding-agent/src/system-prompt.ts b/packages/coding-agent/src/system-prompt.ts index 76c14a8ef..84c9e22fd 100644 --- a/packages/coding-agent/src/system-prompt.ts +++ b/packages/coding-agent/src/system-prompt.ts @@ -638,13 +638,14 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): })(); const additionalRootsForTree = additionalWorkspaceRoots.filter(d => path.resolve(d) !== path.resolve(resolvedCwd)); const workspaceTreePromise = (async () => { - const primary = providedWorkspaceTree !== undefined - ? await Promise.resolve(providedWorkspaceTree) - : includeWorkspaceTree - ? await logger.time("buildWorkspaceTree", () => - buildWorkspaceTree(resolvedCwd, { timeoutMs: SYSTEM_PROMPT_PREP_TIMEOUT_MS }), - ) - : { rootPath: resolvedCwd, rendered: "", truncated: false, totalLines: 0, agentsMdFiles: [] }; + const primary = + providedWorkspaceTree !== undefined + ? await Promise.resolve(providedWorkspaceTree) + : includeWorkspaceTree + ? await logger.time("buildWorkspaceTree", () => + buildWorkspaceTree(resolvedCwd, { timeoutMs: SYSTEM_PROMPT_PREP_TIMEOUT_MS }), + ) + : { rootPath: resolvedCwd, rendered: "", truncated: false, totalLines: 0, agentsMdFiles: [] }; if (additionalRootsForTree.length === 0 || !includeWorkspaceTree) return primary; const extraTrees = await Promise.all( additionalRootsForTree.map(root => diff --git a/packages/coding-agent/test/session-manager/workspace-directories.test.ts b/packages/coding-agent/test/session-manager/workspace-directories.test.ts index 360e6c39c..a488b0845 100644 --- a/packages/coding-agent/test/session-manager/workspace-directories.test.ts +++ b/packages/coding-agent/test/session-manager/workspace-directories.test.ts @@ -1,5 +1,5 @@ -import * as os from "node:os"; import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; import { @@ -127,7 +127,12 @@ describe("SessionManager workspace directories", () => { await session.removeWorkspaceDirectory(path.join(tempDir.path(), "extra")); const file = session.getSessionFile()!; - const header = JSON.parse(fs.readFileSync(file, "utf8").split("\n").filter(l => l.trim())[1]!); + const header = JSON.parse( + fs + .readFileSync(file, "utf8") + .split("\n") + .filter(l => l.trim())[1]!, + ); expect(header.additionalDirectories).toBeUndefined(); }); @@ -149,7 +154,12 @@ describe("SessionManager workspace directories", () => { await session.setAdditionalDirectories([path.join(tempDir.path(), "added")]); const file = session.getSessionFile()!; - const header = JSON.parse(fs.readFileSync(file, "utf8").split("\n").filter(l => l.trim())[1]!); + const header = JSON.parse( + fs + .readFileSync(file, "utf8") + .split("\n") + .filter(l => l.trim())[1]!, + ); expect(header.additionalDirectories).toEqual([path.join(tempDir.path(), "added")]); }); From a394c5b8f7e300a1a20fce0b5e401afbf71ac304 Mon Sep 17 00:00:00 2001 From: maatheusgois-dd Date: Wed, 22 Jul 2026 11:58:03 -0300 Subject: [PATCH 19/19] Fix CI: add bun:test import, fix settings tab/group, wrap switchSession refresh in try-catch - Add missing `import { describe, expect, it } from "bun:test"` to workspace-directories test file (required for TS type checking) - Move workspace.additionalDirectories setting from tab=model/group=Context (unregistered) to tab=context/group=General (registered in TAB_GROUPS) - Wrap refreshBaseSystemPrompt in switchSession in try-catch so a rebuild failure doesn't roll back an otherwise-successful session switch Co-authored-by: oh-my-pi --- packages/coding-agent/src/config/settings-schema.ts | 4 ++-- packages/coding-agent/src/session/agent-session.ts | 11 ++++++++++- .../session-manager/workspace-directories.test.ts | 1 + 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index b75f6e32f..2c130cd39 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1189,8 +1189,8 @@ export const SETTINGS_SCHEMA = { type: "array", default: [] as string[], ui: { - tab: "model", - group: "Context", + tab: "context", + group: "General", label: "Additional Workspace Dirs", description: "Extra workspace directories added to every session as additional roots (multi-root workspace). Managed live via /add-dir and /remove-dir. Paths resolve relative to cwd; absolute paths recommended. The agent is told these roots exist and can read/grep/glob them.", diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 542b71c1a..e2ac9d389 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -16789,7 +16789,16 @@ export class AgentSession { }); } // Refresh the workspace-roots block to match the resumed session's directory set. - await this.refreshBaseSystemPrompt(); + // Wrapped so a rebuild failure (e.g. a gate that intentionally fails in tests) + // doesn't roll back an otherwise-successful session switch. + try { + await this.refreshBaseSystemPrompt(); + } catch (refreshErr) { + logger.warn("Failed to refresh system prompt after session switch", { + targetSessionFile: sessionPath, + error: String(refreshErr), + }); + } this.#finishBashSessionTransition(bashTransition, true); return true; } catch (error) { diff --git a/packages/coding-agent/test/session-manager/workspace-directories.test.ts b/packages/coding-agent/test/session-manager/workspace-directories.test.ts index a488b0845..efc06bb52 100644 --- a/packages/coding-agent/test/session-manager/workspace-directories.test.ts +++ b/packages/coding-agent/test/session-manager/workspace-directories.test.ts @@ -1,3 +1,4 @@ +import { describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path";