Merge PR #6255: add dynamic multi-root workspace context (@maatheusgois-dd)

This commit is contained in:
can1357
2026-07-23 17:30:33 +02:00
19 changed files with 612 additions and 20 deletions
+1
View File
@@ -103,6 +103,7 @@
- Fixed in-progress aborts awaiting `session_stop` extension handlers whose results would be discarded.
- Fixed `/retry` reporting "Nothing to retry" after a stream stalled or aborted mid-tool-call.
- Fixed locally consumed extension commands triggering automatic title generation and exposing their command text to the title model.
- 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 <path>`, `/remove-dir <path>`, and `/dirs` slash commands let you add and remove folders mid-session; the repeatable `--add-dir <path>` 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
+2
View File
@@ -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;
@@ -117,6 +117,9 @@ export const STRING_SETTERS: Record<string, StringSetter> = {
"--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;
@@ -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",
@@ -1188,6 +1188,18 @@ export const SETTINGS_SCHEMA = {
},
},
"workspace.additionalDirectories": {
type: "array",
default: [] as string[],
ui: {
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.",
},
},
personality: {
type: "enum",
values: ["default", "friendly", "pragmatic", "none"] as const,
+5
View File
@@ -834,6 +834,11 @@ export async function buildSessionOptions(
cwd: parsed.cwd ?? getProjectDir(),
autoApprove: parsed.autoApprove ?? false,
};
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;
}
@@ -40,7 +40,14 @@ Working directory layout (sorted by mtime, recent first; depth ≤ 3):
</workspace-tree>
{{/if}}
{{/if}}
{{#if additionalWorkspaceRoots.length}}
<workspace-roots>
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}}
</workspace-roots>
{{/if}}
Today is {{date}}, and the current working directory is '{{cwd}}'.
<critical>
+15
View File
@@ -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: "*" */
@@ -1312,6 +1314,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
logger.time("sessionManager", () =>
SessionManager.create(cwd, SessionManager.getDefaultSessionDir(cwd, agentDir)),
);
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();
const merged = [...new Set([...existing, ...configuredDirs])];
await sessionManager.setAdditionalDirectories(merged);
}
const providerSessionId = options.providerSessionId ?? sessionManager.getSessionId();
const forkCacheShapeChanged =
options.model !== undefined ||
@@ -1660,6 +1671,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,
@@ -2644,6 +2658,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),
@@ -10071,7 +10071,10 @@ export class AgentSession {
} else {
await this.sessionManager.flush();
}
await this.sessionManager.newSession(options);
await this.sessionManager.newSession({
...options,
additionalDirectories: this.settings.get("workspace.additionalDirectories"),
});
this.#markBashSessionTransition(bashTransition);
sessionTransitioned = true;
} finally {
@@ -10100,6 +10103,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) {
@@ -17160,6 +17166,17 @@ export class AgentSession {
error: String(error),
});
}
// Refresh the workspace-roots block to match the resumed session's directory set.
// 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) {
@@ -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;
@@ -43,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 {
@@ -73,6 +73,11 @@ import {
type SessionStorageWriter,
} from "./session-storage";
import { type SessionTitleUpdate, serializeTitleSlot } from "./session-title-slot";
import {
additionalWorkspaceDirectories,
normalizeSessionWorkspace,
normalizeWorkspaceDirectory,
} from "./session-workspace";
const JSONL_SUFFIX_LENGTH = ".jsonl".length;
const DRAFT_ONLY_SESSION_MARKER = ".draft-only-session";
@@ -380,6 +385,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;
@@ -805,6 +812,14 @@ export class SessionManager {
parentSession: options?.parentSession,
providerPromptCacheKey: options?.providerPromptCacheKey,
};
const workspace = normalizeSessionWorkspace({
cwd: this.#cwd,
directories: options?.additionalDirectories ?? [],
});
this.#additionalDirectories = additionalWorkspaceDirectories(workspace);
if (this.#additionalDirectories.length > 0) {
this.#header.additionalDirectories = [...this.#additionalDirectories];
}
this.#titleUpdatedAt = timestamp;
this.#entries = [];
@@ -988,6 +1003,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;
@@ -1040,6 +1056,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 +1107,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,
};
@@ -1197,6 +1215,14 @@ 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.
@@ -1303,6 +1329,69 @@ export class SessionManager {
return this.#cwd;
}
/** Additional workspace directories beyond cwd (multi-root), absolute and normalized. */
getAdditionalDirectories(): string[] {
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
* 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<string | null> {
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.");
}
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<string | null> {
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);
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. Also called on resumed sessions with --add-dir; persists the updated header when a session file already exists. */
async setAdditionalDirectories(directories: string[]): Promise<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;
}
if (this.#persist && this.#sessionFile) {
this.#rewriteRequired = true;
await this.#rewriteAtomically();
}
}
getUsageStatistics(): UsageStatistics {
return this.#index.usageSnapshot();
}
@@ -1836,6 +1925,7 @@ export class SessionManager {
timestamp,
cwd: this.#cwd,
parentSession: this.#persist ? sourceSessionFile : undefined,
additionalDirectories: this.#additionalDirectories.length > 0 ? [...this.#additionalDirectories] : undefined,
};
const labels: LabelEntry[] = [];
@@ -1956,6 +2046,9 @@ 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.#sessionName = manager.#header.title;
manager.#titleSource = manager.#header.titleSource;
manager.#titleUpdatedAt = nowIso();
@@ -0,0 +1,53 @@
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);
}
@@ -204,6 +204,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<SlashCommandSpec> = [
{
name: "settings",
@@ -1730,6 +1738,74 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
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: "<path>",
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 <path>"), 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: "<path>",
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 <path>", 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",
+41 -17
View File
@@ -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";
@@ -466,6 +467,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. */
@@ -535,6 +538,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
skillsSettings,
toolNames: providedToolNames,
cwd,
additionalWorkspaceRoots = [],
contextFiles: providedContextFiles,
skills: providedSkills,
rules,
@@ -619,23 +623,42 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
const systemPromptCustomizationPromise: Promise<string | null> = callerControlsCustomPrompt
? Promise.resolve(null)
: logger.time("loadSystemPromptFiles", loadSystemPromptFiles, { cwd: resolvedCwd });
const contextFilesPromise = providedContextFiles
? Promise.resolve(providedContextFiles)
: logger.time("loadProjectContextFiles", loadProjectContextFiles, { cwd: resolvedCwd });
const workspaceTreePromise =
providedWorkspaceTree !== undefined
? Promise.resolve(providedWorkspaceTree)
: includeWorkspaceTree
? logger.time("buildWorkspaceTree", () =>
buildWorkspaceTree(resolvedCwd, { timeoutMs: SYSTEM_PROMPT_PREP_TIMEOUT_MS }),
)
: Promise.resolve({
rootPath: resolvedCwd,
rendered: "",
truncated: false,
totalLines: 0,
agentsMdFiles: [],
});
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 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: [] };
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<readonly Skill[]> =
providedSkills !== undefined
? Promise.resolve(providedSkills)
@@ -794,6 +817,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(),
+8 -1
View File
@@ -287,6 +287,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
/** 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;
@@ -2237,7 +2239,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
const settings = options.settings ?? Settings.isolated();
const subagentSettings = createSubagentSettings(
settings,
agent.readSummarize === false ? { "read.summarize.enabled": false } : undefined,
{
...(agent.readSummarize === false ? { "read.summarize.enabled": false } : undefined),
// Isolated runs must not expose roots outside the worktree.
...(worktree !== undefined ? { "workspace.additionalDirectories": [] } : undefined),
},
options.parentServiceTier,
);
const maxRecursionDepth = settings.get("task.maxRecursionDepth") ?? 2;
@@ -2556,6 +2562,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
// artifacts dir) — only the SessionManager differs.
const buildSubagentSessionOptions = (sessionManagerForRun: SessionManager): CreateAgentSessionOptions => ({
cwd: worktree ?? cwd,
additionalDirectories: worktree !== undefined ? undefined : options.additionalDirectories,
authStorage,
modelRegistry,
settings: subagentSettings,
@@ -373,6 +373,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(),
+2
View File
@@ -144,6 +144,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;
/**
@@ -0,0 +1,177 @@
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";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import {
additionalWorkspaceDirectories,
normalizeSessionWorkspace,
} 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 ?? 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("SessionManager workspace directories", () => {
it("starts with no additional directories", () => {
const session = SessionManager.inMemory();
expect(session.getAdditionalDirectories()).toEqual([]);
expect([session.getCwd(), ...session.getAdditionalDirectories()]).toEqual([session.getCwd()]);
});
it("seeds from setAdditionalDirectories and excludes cwd", async () => {
const session = SessionManager.inMemory();
await session.setAdditionalDirectories(["/some/other", session.getCwd()]);
// cwd is filtered out of the additional set.
expect(session.getAdditionalDirectories()).toEqual(["/some/other"]);
expect([session.getCwd(), ...session.getAdditionalDirectories()]).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("addWorkspaceDirectory expands ~ to home", async () => {
const session = SessionManager.inMemory();
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")]);
});
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("removeWorkspaceDirectory matches ~-expanded paths", async () => {
const session = SessionManager.inMemory();
const home = 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());
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.getCwd(), ...reopened.getAdditionalDirectories()]).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 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")]);
});
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")]);
});
});
@@ -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 <workspace-roots> 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 <workspace-roots> 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 <workspace-roots> block.
expect(systemPrompt).toContain("<workspace-roots>");
expect(systemPrompt).toContain(extraDir);
expect(systemPrompt).toContain(laterDir);
} finally {
await session.dispose();
}
} finally {
auth.close();
dir.removeSync();
}
});
});