Merge PR #3156: fix(advisor): surface nested repo context (@oldschoola)

# Conflicts:
#	packages/coding-agent/src/modes/components/status-line/component.ts
#	packages/coding-agent/src/sdk.ts
#	packages/coding-agent/src/system-prompt.ts
This commit is contained in:
can1357
2026-06-27 01:40:12 +02:00
17 changed files with 599 additions and 66 deletions
+4
View File
@@ -361,6 +361,10 @@
- Removed `/debug dump-next-request` command
- Removed Wafer Pass from CLI credential help; Wafer Serverless remains available.
### Fixed
- Fixed advisor and status-line context when the session cwd is a parent of a single child git repo, so nested-repo work is surfaced before missing parent-cwd paths are treated as destroyed. ([#3130](https://github.com/can1357/oh-my-pi/issues/3130))
## [16.1.8] - 2026-06-20
### Added
+15 -1
View File
@@ -1,9 +1,23 @@
import * as os from "node:os";
import * as path from "node:path";
import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
import { getAgentDir, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
import { expandAtImports } from "../discovery/at-imports";
import activeRepoWatchdogTemplate from "../prompts/advisor/active-repo-watchdog.md" with { type: "text" };
import type { ActiveRepoContext } from "../utils/active-repo-context";
import { repo } from "../utils/git";
function normalizePromptPath(value: string): string {
return value.replace(/\\/g, "/");
}
export function formatActiveRepoWatchdogPrompt(activeRepoContext: ActiveRepoContext): string {
return prompt
.render(activeRepoWatchdogTemplate, {
relativeRepoRoot: normalizePromptPath(activeRepoContext.relativeRepoRoot),
})
.trim();
}
/**
* Discover and load WATCHDOG.md files walking up from cwd, project .omp folder, and user agent dir.
* Returns formatted watchdog file blocks ready to be appended to the advisor system prompt.
@@ -9,6 +9,7 @@ import { settings } from "../../../config/settings";
import type { AgentSession } from "../../../session/agent-session";
import type { OAuthAccountIdentity } from "../../../session/auth-storage";
import { limitMatchesActiveAccount } from "../../../slash-commands/helpers/active-oauth-account";
import { type ActiveRepoContext, resolveActiveRepoContextSync } from "../../../utils/active-repo-context";
import * as git from "../../../utils/git";
import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/session-color";
import { sanitizeStatusText } from "../../shared";
@@ -155,6 +156,12 @@ interface ContextUsageMemo {
skillsRef: readonly any[] | undefined;
}
interface ActiveRepoCache {
projectDir: string;
activeRepo: ActiveRepoContext | null;
effectiveGitCwd: string;
}
const EMPTY_MESSAGES: readonly AgentMessage[] = [];
const STATUS_USAGE_START_DELAY_MS = 0;
const STATUS_USAGE_REFRESH_TIMEOUT_MS = 2_000;
@@ -196,17 +203,20 @@ export class StatusLineComponent implements Component {
#goalModeStatus: { enabled: boolean; paused: boolean } | null = null;
#collabStatus: CollabStatus | null = null;
#focusedAgentId: string | undefined;
#activeRepoCache: ActiveRepoCache | undefined;
// Git status caching (1s TTL)
#cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
#cachedGitStatusCwd: string | undefined = undefined;
#gitStatusLastFetch = 0;
#gitStatusInFlight = false;
#gitStatusInFlightCwd: string | undefined = undefined;
// PR lookup caching (invalidated on branch/repo context changes)
#cachedPr: { number: number; url: string } | null | undefined = undefined;
#cachedPrContext: PrCacheContext | undefined = undefined;
#prLookupInFlight = false;
#defaultBranch?: string;
#defaultBranchCwd: string | undefined = undefined;
#lastTokensPerSecond: number | null = null;
#lastTokensPerSecondTimestamp: number | null = null;
@@ -249,6 +259,18 @@ export class StatusLineComponent implements Component {
);
}
#resolveActiveRepoCache(): ActiveRepoCache {
const projectDir = getProjectDir();
if (this.#activeRepoCache?.projectDir === projectDir) {
return this.#activeRepoCache;
}
const activeRepo = resolveActiveRepoContextSync(projectDir);
const effectiveGitCwd = activeRepo?.repoRoot ?? projectDir;
this.#activeRepoCache = { projectDir, activeRepo, effectiveGitCwd };
return this.#activeRepoCache;
}
/**
* Re-point the status line at another session (focus proxy). Invalidate: model/context/usage all derive
* from it. `focusedAgentId` is the focused subagent id while the view is proxied, undefined for main.
@@ -335,7 +357,8 @@ export class StatusLineComponent implements Component {
return;
}
const repository = git.repo.resolveSync(getProjectDir());
const { effectiveGitCwd } = this.#resolveActiveRepoCache();
const repository = git.repo.resolveSync(effectiveGitCwd);
if (!repository) return;
const watchPath = git.repo.isReftableSync(repository)
@@ -390,17 +413,17 @@ export class StatusLineComponent implements Component {
this.#cachedBranchCwd = undefined;
this.#cachedPrContext = undefined;
}
#getCurrentBranch(): string | null {
#getCurrentBranch(effectiveGitCwd?: string): string | null {
if (!this.#gitEnabled()) return null;
const cwd = getProjectDir();
if (this.#cachedBranch !== undefined && this.#cachedBranchCwd === cwd) {
const gitCwd = effectiveGitCwd ?? this.#resolveActiveRepoCache().effectiveGitCwd;
if (this.#cachedBranch !== undefined && this.#cachedBranchCwd === gitCwd) {
return this.#cachedBranch;
}
const head = git.head.resolveSync(cwd);
const head = git.head.resolveSync(gitCwd);
const gitHeadPath = head?.headPath ?? null;
this.#cachedBranchCwd = cwd;
this.#cachedBranchCwd = gitCwd;
this.#cachedBranchRepoId = gitHeadPath;
if (!head) {
this.#cachedBranch = null;
@@ -412,12 +435,18 @@ export class StatusLineComponent implements Component {
return this.#cachedBranch ?? null;
}
#isDefaultBranch(branch: string): boolean {
#isDefaultBranch(branch: string, effectiveGitCwd: string): boolean {
if (this.#defaultBranchCwd !== effectiveGitCwd) {
this.#defaultBranch = undefined;
this.#defaultBranchCwd = effectiveGitCwd;
}
if (this.#defaultBranch === undefined) {
this.#defaultBranch = "main";
const lookupCwd = effectiveGitCwd;
(async () => {
const resolved = await git.branch.default(getProjectDir());
if (this.#disposed) return;
const resolved = await git.branch.default(lookupCwd);
if (this.#disposed || this.#defaultBranchCwd !== lookupCwd) return;
if (resolved) {
this.#defaultBranch = resolved;
if (this.#onBranchChange) {
@@ -429,32 +458,43 @@ export class StatusLineComponent implements Component {
return branch === this.#defaultBranch;
}
#getGitStatus(): { staged: number; unstaged: number; untracked: number } | null {
#getGitStatus(effectiveGitCwd?: string): { staged: number; unstaged: number; untracked: number } | null {
if (!this.#gitEnabled()) return null;
if (this.#gitStatusInFlight || Date.now() - this.#gitStatusLastFetch < 1000) {
const gitCwd = effectiveGitCwd ?? this.#resolveActiveRepoCache().effectiveGitCwd;
if (this.#gitStatusInFlightCwd !== undefined) {
return this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null;
}
if (this.#cachedGitStatusCwd === gitCwd && Date.now() - this.#gitStatusLastFetch < 1000) {
return this.#cachedGitStatus;
}
this.#gitStatusInFlight = true;
this.#gitStatusInFlightCwd = gitCwd;
(async () => {
let nextStatus: { staged: number; unstaged: number; untracked: number } | null = null;
try {
this.#cachedGitStatus = await git.status.summary(getProjectDir());
nextStatus = await git.status.summary(gitCwd);
} catch {
this.#cachedGitStatus = null;
nextStatus = null;
} finally {
this.#gitStatusLastFetch = Date.now();
this.#gitStatusInFlight = false;
if (this.#gitStatusInFlightCwd === gitCwd) {
this.#cachedGitStatus = nextStatus;
this.#cachedGitStatusCwd = gitCwd;
this.#gitStatusLastFetch = Date.now();
this.#gitStatusInFlightCwd = undefined;
}
}
})();
return this.#cachedGitStatus;
return this.#cachedGitStatusCwd === gitCwd ? this.#cachedGitStatus : null;
}
#lookupPr(): { number: number; url: string } | null {
#lookupPr(effectiveGitCwd?: string): { number: number; url: string } | null {
if (!this.#gitEnabled()) return null;
const branch = this.#getCurrentBranch();
const gitCwd = effectiveGitCwd ?? this.#resolveActiveRepoCache().effectiveGitCwd;
const branch = this.#getCurrentBranch(gitCwd);
const currentContext = branch ? createPrCacheContext(branch, this.#cachedBranchRepoId ?? null) : null;
if (canReuseCachedPr(this.#cachedPr, this.#cachedPrContext, currentContext)) {
@@ -463,13 +503,20 @@ export class StatusLineComponent implements Component {
const stalePr = this.#cachedPr;
// Don't look up if no branch, detached HEAD, default branch, or already in flight
if (!branch || branch === "detached" || this.#isDefaultBranch(branch) || this.#prLookupInFlight) {
if (!branch) {
this.#cachedPr = null;
this.#cachedPrContext = undefined;
return null;
}
// Don't look up if detached, default branch, or already in flight.
if (branch === "detached" || this.#isDefaultBranch(branch, gitCwd) || this.#prLookupInFlight) {
return stalePr ?? null;
}
this.#prLookupInFlight = true;
const lookupContext = currentContext;
const lookupCwd = gitCwd;
// Fire async lookup, keep stale value visible until resolved
(async () => {
@@ -486,7 +533,7 @@ export class StatusLineComponent implements Component {
};
try {
// Requires `gh repo set-default` to be configured; fails gracefully if not
const result = await $`gh pr view --json number,url`.quiet().nothrow();
const result = await $`gh pr view --json number,url`.cwd(lookupCwd).quiet().nothrow();
if (this.#disposed) return;
if (result.exitCode !== 0) {
setCachedPr(null);
@@ -795,13 +842,14 @@ export class StatusLineComponent implements Component {
contextPercent = collabState.contextUsage.percent ?? contextPercent;
}
const gitBranch = includeGit || includePr ? this.#getCurrentBranch() : null;
const gitStatus = includeGit ? this.#getGitStatus() : null;
const gitPr = includePr ? this.#lookupPr() : null;
const activeRepoCache = this.#resolveActiveRepoCache();
const gitBranch = includeGit || includePr ? this.#getCurrentBranch(activeRepoCache.effectiveGitCwd) : null;
const gitStatus = includeGit ? this.#getGitStatus(activeRepoCache.effectiveGitCwd) : null;
const gitPr = includePr ? this.#lookupPr(activeRepoCache.effectiveGitCwd) : null;
return {
session: this.session,
focusedAgentId: this.#focusedAgentId,
activeRepo: activeRepoCache.activeRepo,
width,
options: segmentOptions ?? {},
planMode: this.#planModeStatus,
@@ -202,7 +202,7 @@ const pathSegment: StatusLineSegment = {
render(ctx) {
const opts = ctx.options.path ?? {};
const projectDir = getProjectDir();
const projectDir = ctx.activeRepo?.cwd ?? getProjectDir();
const { scratch, relative } = classifyProjectDir(projectDir);
let pwd = projectDir;
@@ -213,6 +213,9 @@ const pathSegment: StatusLineSegment = {
pwd = stripDisplayRoot(pwd);
}
}
if (ctx.activeRepo) {
pwd = `${pwd} ↳ ${ctx.activeRepo.relativeRepoRoot}`;
}
if (opts.abbreviate !== false) {
pwd = shortenPath(pwd);
}
@@ -1,6 +1,7 @@
import type { CollabSessionState } from "../../../collab/protocol";
import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../../config/settings-schema";
import type { AgentSession } from "../../../session/agent-session";
import type { ActiveRepoContext } from "../../../utils/active-repo-context";
export type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle };
@@ -47,6 +48,7 @@ export interface SegmentContext {
session: AgentSession;
/** Focused subagent id while the view is proxied at its session, undefined otherwise. */
focusedAgentId?: string | undefined;
activeRepo: ActiveRepoContext | null;
width: number;
options: StatusLineSegmentOptions;
planMode: {
@@ -0,0 +1,6 @@
Especially pay attention to:
<attention>
The session cwd is outside git, and exactly one direct child git repository was detected at `{{relativeRepoRoot}}`.
Paths under `{{relativeRepoRoot}}/` are the active project. Do not claim work is missing, destroyed, or absent at the parent cwd until you have checked under `{{relativeRepoRoot}}/`.
</attention>
@@ -0,0 +1,4 @@
<active-repo-context>
The session cwd is outside git. Exactly one direct child git repository was detected at `{{relativeRepoRoot}}`.
Paths under `{{relativeRepoRoot}}/` are the active project for this session. Parent-cwd misses are inconclusive until checking under `{{relativeRepoRoot}}/`.
</active-repo-context>
+18 -5
View File
@@ -25,7 +25,7 @@ import { FALLBACK_DIALECT, preferredDialect } from "@oh-my-pi/pi-catalog/identit
import type { Component } from "@oh-my-pi/pi-tui";
import { $env, $flag, getAgentDir, getProjectDir, logger, postmortem, prompt, Snowflake } from "@oh-my-pi/pi-utils";
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
import { ADVISOR_READONLY_TOOL_NAMES, discoverWatchdogFiles } from "./advisor";
import { ADVISOR_READONLY_TOOL_NAMES, discoverWatchdogFiles, formatActiveRepoWatchdogPrompt } from "./advisor";
import { type AsyncJob, AsyncJobManager } from "./async";
import { AutoLearnController, buildAutoLearnInstructions } from "./autolearn/controller";
import { loadCapability } from "./capability";
@@ -189,6 +189,7 @@ import { getImageGenTools } from "./tools/image-gen";
import { wrapToolWithMetaNotice } from "./tools/output-meta";
import { queueResolveHandler } from "./tools/resolve";
import { ttsTool } from "./tools/tts";
import { resolveActiveRepoContext } from "./utils/active-repo-context";
import { EventBus } from "./utils/event-bus";
import { buildNamedToolChoice } from "./utils/tool-choice";
import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
@@ -1146,6 +1147,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
? Promise.resolve(options.contextFiles)
: logger.time("discoverContextFiles", discoverContextFiles, cwd, agentDir);
contextFilesPromise.catch(() => {});
const activeRepoContextPromise = logger.time("resolveActiveRepoContext", async () => {
try {
return await resolveActiveRepoContext(cwd);
} catch (err) {
logger.debug("Failed to resolve active repo context", { err: String(err) });
return null;
}
});
activeRepoContextPromise.catch(() => {});
const watchdogFilesPromise = logger.time("discoverWatchdogFiles", () => discoverWatchdogFiles(cwd, agentDir));
watchdogFilesPromise.catch(() => {});
const promptTemplatesPromise = options.promptTemplates
@@ -1393,10 +1403,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
}
return result;
};
const [contextFiles, resolvedWorkspaceTree, watchdogFiles] = await Promise.all([
const [contextFiles, resolvedWorkspaceTree, watchdogFiles, activeRepoContext] = await Promise.all([
contextFilesPromise,
raceWithDeadline("buildWorkspaceTree", workspaceTreePromise),
watchdogFilesPromise,
activeRepoContextPromise,
]);
let agent: Agent;
@@ -2268,6 +2279,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
model: settings.get("includeModelInPrompt") ? getActiveModelString() : undefined,
personality: agentKind === "sub" ? "none" : settings.get("personality"),
renderMermaid: settings.get("tui.renderMermaid"),
activeRepoContext,
});
if (options.systemPrompt === undefined) {
@@ -2661,10 +2673,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
.filter((tool): tool is Tool => tool != null)
.map(wrapToolWithMetaNotice);
let advisorWatchdogPrompt: string | undefined;
if (watchdogFiles && watchdogFiles.length > 0) {
advisorWatchdogPrompt = watchdogFiles.join("\n\n");
const advisorWatchdogPrompts = [...watchdogFiles];
if (activeRepoContext) {
advisorWatchdogPrompts.push(formatActiveRepoWatchdogPrompt(activeRepoContext));
}
const advisorWatchdogPrompt = advisorWatchdogPrompts.length > 0 ? advisorWatchdogPrompts.join("\n\n") : undefined;
// Owned only when this session created the manager; subagents receive a
// parent's manager via `options.mcpManager` and MUST NOT disconnect it.
const ownedMcpManager = options.mcpManager ? undefined : mcpManager;
+59 -28
View File
@@ -16,6 +16,7 @@ import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile
import { expandAtImports } from "./discovery/at-imports";
import { loadSkills, type Skill } from "./extensibility/skills";
import { hasObsidian } from "./internal-urls/vault-protocol";
import activeRepoContextTemplate from "./prompts/system/active-repo-context.md" with { type: "text" };
import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
import defaultPersonality from "./prompts/system/personalities/default.md" with { type: "text" };
import friendlyPersonality from "./prompts/system/personalities/friendly.md" with { type: "text" };
@@ -23,6 +24,7 @@ import pragmaticPersonality from "./prompts/system/personalities/pragmatic.md" w
import projectPromptTemplate from "./prompts/system/project-prompt.md" with { type: "text" };
import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
import { shortenPath } from "./tools/render-utils";
import { type ActiveRepoContext, resolveActiveRepoContext } from "./utils/active-repo-context";
import { AGENTS_MD_LIMIT, buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
/** Bundled personality specs, keyed by the `personality` setting value. */
@@ -90,6 +92,19 @@ function firstNonEmpty(...values: (string | undefined | null)[]): string | null
return null;
}
function normalizePromptPath(value: string): string {
return value.replace(/\\/g, "/");
}
function renderActiveRepoContextPrompt(activeRepoContext: ActiveRepoContext | null): string {
if (!activeRepoContext) return "";
return prompt
.render(activeRepoContextTemplate, {
relativeRepoRoot: normalizePromptPath(activeRepoContext.relativeRepoRoot),
})
.trim();
}
function parseWmicTable(output: string, header: string): string | null {
const lines = output
.split("\n")
@@ -425,6 +440,8 @@ export interface BuildSystemPromptOptions {
includeWorkspaceTree?: boolean;
/** Whether Mermaid fenced blocks render as terminal ASCII diagrams. Default: true */
renderMermaid?: boolean;
/** Pre-resolved nested active repo context. Undefined resolves from cwd. */
activeRepoContext?: ActiveRepoContext | null;
}
/** Result of building provider-facing system prompt messages. */
@@ -467,6 +484,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
personality = "default",
includeWorkspaceTree = false,
renderMermaid = true,
activeRepoContext: providedActiveRepoContext,
} = options;
const inlineToolDescriptors = providedInlineToolDescriptors ?? false;
const resolvedCwd = cwd ?? getProjectDir();
@@ -484,6 +502,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
totalLines: 0,
agentsMdFiles: [],
} satisfies WorkspaceTree,
activeRepoContext: null as ActiveRepoContext | null,
};
const deadline = Bun.sleep(SYSTEM_PROMPT_PREP_TIMEOUT_MS).then(() => "__timeout__" as const);
@@ -546,34 +565,42 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
: skillsSettings?.enabled !== false
? loadSkills({ ...skillsSettings, cwd: resolvedCwd }).then(result => result.skills)
: Promise.resolve([]);
const activeRepoContextPromise =
providedActiveRepoContext !== undefined
? Promise.resolve(providedActiveRepoContext)
: logger.time("resolveActiveRepoContext", () => resolveActiveRepoContext(resolvedCwd));
const [resolvedCustomPrompt, resolvedAppendPrompt, systemPromptCustomization, contextFiles, skills, workspaceTree] =
await Promise.all([
withDeadline(
"customPrompt",
providedResolvedCustomPrompt !== undefined
? Promise.resolve(providedResolvedCustomPrompt)
: resolvePromptInput(customPrompt, "system prompt"),
prepDefaults.resolvedCustomPrompt,
),
withDeadline(
"appendSystemPrompt",
providedResolvedAppendPrompt !== undefined
? Promise.resolve(providedResolvedAppendPrompt)
: resolvePromptInput(appendSystemPrompt, "append system prompt"),
prepDefaults.resolvedAppendPrompt,
),
withDeadline(
"loadSystemPromptFiles",
systemPromptCustomizationPromise,
prepDefaults.systemPromptCustomization,
),
withDeadline("loadProjectContextFiles", contextFilesPromise, prepDefaults.contextFiles).then(
dedupeExactContextFiles,
),
withDeadline("loadSkills", skillsPromise, prepDefaults.skills),
withDeadline("buildWorkspaceTree", workspaceTreePromise, prepDefaults.workspaceTree),
]);
const [
resolvedCustomPrompt,
resolvedAppendPrompt,
systemPromptCustomization,
contextFiles,
skills,
workspaceTree,
activeRepoContext,
] = await Promise.all([
withDeadline(
"customPrompt",
providedResolvedCustomPrompt !== undefined
? Promise.resolve(providedResolvedCustomPrompt)
: resolvePromptInput(customPrompt, "system prompt"),
prepDefaults.resolvedCustomPrompt,
),
withDeadline(
"appendSystemPrompt",
providedResolvedAppendPrompt !== undefined
? Promise.resolve(providedResolvedAppendPrompt)
: resolvePromptInput(appendSystemPrompt, "append system prompt"),
prepDefaults.resolvedAppendPrompt,
),
withDeadline("loadSystemPromptFiles", systemPromptCustomizationPromise, prepDefaults.systemPromptCustomization),
withDeadline("loadProjectContextFiles", contextFilesPromise, prepDefaults.contextFiles).then(
dedupeExactContextFiles,
),
withDeadline("loadSkills", skillsPromise, prepDefaults.skills),
withDeadline("buildWorkspaceTree", workspaceTreePromise, prepDefaults.workspaceTree),
withDeadline("resolveActiveRepoContext", activeRepoContextPromise, prepDefaults.activeRepoContext),
]);
const agentsMdFiles = Array.from(new Set(workspaceTree.agentsMdFiles)).sort().slice(0, AGENTS_MD_LIMIT);
if (timedOut.length > 0) {
@@ -598,7 +625,8 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
const date = new Date().toISOString().slice(0, 10);
const dateTime = date;
const promptCwd = shortenPath(resolvedCwd.replace(/\\/g, "/"));
const promptCwd = shortenPath(normalizePromptPath(resolvedCwd));
const activeRepoContextPrompt = renderActiveRepoContextPrompt(activeRepoContext);
// Build tool metadata for system prompt rendering.
// Priority: explicit list > tools map > conservative SDK fallback.
@@ -697,6 +725,9 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
if (projectPrompt) {
systemPrompt.push(projectPrompt);
}
if (activeRepoContextPrompt) {
systemPrompt.push(activeRepoContextPrompt);
}
return { systemPrompt };
}
@@ -0,0 +1,127 @@
import * as fs from "node:fs";
import * as fsPromises from "node:fs/promises";
import * as path from "node:path";
import { type GitRepository, repo } from "./git";
export interface ActiveRepoContext {
cwd: string;
repoRoot: string;
relativeRepoRoot: string;
source: "single-direct-child-repo";
}
function compareEntryNames(left: fs.Dirent, right: fs.Dirent): number {
if (left.name < right.name) return -1;
if (left.name > right.name) return 1;
return 0;
}
function buildContext(cwd: string, repoRoot: string): ActiveRepoContext {
const resolvedCwd = path.resolve(cwd);
const resolvedRepoRoot = path.resolve(repoRoot);
return {
cwd: resolvedCwd,
repoRoot: resolvedRepoRoot,
relativeRepoRoot: path.relative(resolvedCwd, resolvedRepoRoot),
source: "single-direct-child-repo",
};
}
async function resolveRepository(cwd: string): Promise<GitRepository | null> {
try {
return await repo.resolve(cwd);
} catch {
return null;
}
}
function resolveRepositorySync(cwd: string): GitRepository | null {
try {
return repo.resolveSync(cwd);
} catch {
return null;
}
}
async function readDirectChildren(cwd: string): Promise<fs.Dirent[]> {
try {
const entries = await fsPromises.readdir(cwd, { withFileTypes: true });
entries.sort(compareEntryNames);
return entries;
} catch {
return [];
}
}
function readDirectChildrenSync(cwd: string): fs.Dirent[] {
try {
const entries = fs.readdirSync(cwd, { withFileTypes: true });
entries.sort(compareEntryNames);
return entries;
} catch {
return [];
}
}
async function resolveDirectChildDirectory(cwd: string, entry: fs.Dirent): Promise<string | null> {
const childPath = path.join(cwd, entry.name);
if (entry.isDirectory()) return childPath;
if (!entry.isSymbolicLink()) return null;
try {
const stat = await fsPromises.stat(childPath);
return stat.isDirectory() ? childPath : null;
} catch {
return null;
}
}
function resolveDirectChildDirectorySync(cwd: string, entry: fs.Dirent): string | null {
const childPath = path.join(cwd, entry.name);
if (entry.isDirectory()) return childPath;
if (!entry.isSymbolicLink()) return null;
try {
const stat = fs.statSync(childPath);
return stat.isDirectory() ? childPath : null;
} catch {
return null;
}
}
async function findSingleDirectChildRepo(cwd: string): Promise<ActiveRepoContext | null> {
let context: ActiveRepoContext | null = null;
for (const entry of await readDirectChildren(cwd)) {
const childPath = await resolveDirectChildDirectory(cwd, entry);
if (!childPath) continue;
const repository = await resolveRepository(childPath);
if (!repository || path.resolve(repository.repoRoot) !== path.resolve(childPath)) continue;
if (context) return null;
context = buildContext(cwd, repository.repoRoot);
}
return context;
}
function findSingleDirectChildRepoSync(cwd: string): ActiveRepoContext | null {
let context: ActiveRepoContext | null = null;
for (const entry of readDirectChildrenSync(cwd)) {
const childPath = resolveDirectChildDirectorySync(cwd, entry);
if (!childPath) continue;
const repository = resolveRepositorySync(childPath);
if (!repository || path.resolve(repository.repoRoot) !== path.resolve(childPath)) continue;
if (context) return null;
context = buildContext(cwd, repository.repoRoot);
}
return context;
}
export async function resolveActiveRepoContext(cwd: string): Promise<ActiveRepoContext | null> {
const resolvedCwd = path.resolve(cwd);
if (await resolveRepository(resolvedCwd)) return null;
return findSingleDirectChildRepo(resolvedCwd);
}
export function resolveActiveRepoContextSync(cwd: string): ActiveRepoContext | null {
const resolvedCwd = path.resolve(cwd);
if (resolveRepositorySync(resolvedCwd)) return null;
return findSingleDirectChildRepoSync(resolvedCwd);
}
@@ -20,6 +20,62 @@ describe("advisor watchdog prompt discovery", () => {
}
});
async function withAdvisorHistory(
tempDir: TempDir,
cwd: string,
run: (dump: string) => void | Promise<void>,
): Promise<void> {
const authStorage = await AuthStorage.create(tempDir.join("testauth.db"));
let session: AgentSession | undefined;
try {
authStorage.setRuntimeApiKey("openai", "test-key");
const modelRegistry = new ModelRegistry(authStorage);
const sessionManager = SessionManager.create(cwd, tempDir.join("sessions"));
const result = await createAgentSession({
cwd,
agentDir: tempDir.path(),
sessionManager,
authStorage,
modelRegistry,
settings: (() => {
const s = Settings.isolated({
"async.enabled": false,
"advisor.enabled": true,
});
s.setModelRole("advisor", "openai/gpt-4o-mini");
return s;
})(),
model: getBundledModel("openai", "gpt-4o-mini"),
disableExtensionDiscovery: true,
skills: [],
contextFiles: [],
workspaceTree: {
rootPath: cwd,
rendered: "",
truncated: false,
totalLines: 0,
agentsMdFiles: [],
},
promptTemplates: [],
slashCommands: [],
enableMCP: false,
enableLsp: false,
});
session = result.session;
expect(session.isAdvisorActive()).toBe(true);
const dump = session.formatAdvisorHistoryAsText();
if (dump === null) throw new Error("Advisor history was not available.");
await run(dump);
} finally {
try {
await session?.dispose();
} finally {
authStorage.close();
}
}
}
it("discovers and appends WATCHDOG.md to the advisor prompt", async () => {
const tempDir = TempDir.createSync("@pi-advisor-watchdog-");
tempDirs.push(tempDir);
@@ -77,6 +133,39 @@ describe("advisor watchdog prompt discovery", () => {
}
});
it("adds built-in active child repo context to the advisor prompt", async () => {
const tempDir = TempDir.createSync("@pi-advisor-watchdog-");
tempDirs.push(tempDir);
const cwd = tempDir.join("parent-cwd");
fs.mkdirSync(path.join(cwd, "active-project", ".git"), { recursive: true });
const watchdogContent = "Parent watchdog remains before built-in active repo context.";
fs.writeFileSync(path.join(cwd, "WATCHDOG.md"), watchdogContent, "utf8");
await withAdvisorHistory(tempDir, cwd, dump => {
expect(dump).toContain("Especially pay attention to:");
expect(dump).toContain("exactly one direct child git repository");
expect(dump).toContain("`active-project`");
expect(dump).toContain("Do not claim work is missing, destroyed, or absent at the parent cwd");
expect(dump).toContain(watchdogContent);
expect(dump.indexOf(watchdogContent)).toBeLessThan(
dump.indexOf("Do not claim work is missing, destroyed, or absent at the parent cwd"),
);
});
});
it("omits built-in active child repo context when multiple direct child repos exist", async () => {
const tempDir = TempDir.createSync("@pi-advisor-watchdog-");
tempDirs.push(tempDir);
const cwd = tempDir.join("parent-cwd");
fs.mkdirSync(path.join(cwd, "active-project", ".git"), { recursive: true });
fs.mkdirSync(path.join(cwd, "second-project", ".git"), { recursive: true });
await withAdvisorHistory(tempDir, cwd, dump => {
expect(dump).not.toContain("exactly one direct child git repository");
expect(dump).not.toContain("Do not claim work is missing, destroyed, or absent at the parent cwd");
});
});
it("resolves nested folders and sorts by depth", async () => {
const tempDir = TempDir.createSync("@pi-advisor-watchdog-");
tempDirs.push(tempDir);
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, 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 {
type ActiveRepoContext,
resolveActiveRepoContext,
resolveActiveRepoContextSync,
} from "@oh-my-pi/pi-coding-agent/utils/active-repo-context";
function createGitDirectory(repoRoot: string): void {
const gitDir = path.join(repoRoot, ".git");
fs.mkdirSync(gitDir, { recursive: true });
fs.writeFileSync(path.join(gitDir, "HEAD"), "ref: refs/heads/main\n", "utf8");
}
function createLinkedWorktreeGitFile(worktreeRoot: string, gitDir: string, commonDir: string): void {
fs.mkdirSync(worktreeRoot, { recursive: true });
fs.mkdirSync(gitDir, { recursive: true });
fs.mkdirSync(commonDir, { recursive: true });
fs.writeFileSync(path.join(gitDir, "HEAD"), "ref: refs/heads/main\n", "utf8");
fs.writeFileSync(path.join(gitDir, "commondir"), `${path.relative(gitDir, commonDir)}\n`, "utf8");
fs.writeFileSync(path.join(commonDir, "HEAD"), "ref: refs/heads/main\n", "utf8");
fs.writeFileSync(path.join(worktreeRoot, ".git"), `gitdir: ${path.relative(worktreeRoot, gitDir)}\n`, "utf8");
}
async function expectResolvers(cwd: string, expected: ActiveRepoContext | null): Promise<void> {
expect(resolveActiveRepoContextSync(cwd)).toEqual(expected);
expect(await resolveActiveRepoContext(cwd)).toEqual(expected);
}
describe("resolveActiveRepoContext", () => {
let tempRoot: string;
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omp-active-repo-context-"));
});
afterEach(() => {
fs.rmSync(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
it("returns null when cwd is already inside a repository", async () => {
const repoRoot = path.join(tempRoot, "repo");
const cwd = path.join(repoRoot, "nested");
fs.mkdirSync(cwd, { recursive: true });
createGitDirectory(repoRoot);
await expectResolvers(cwd, null);
});
it("returns null when no direct child repository exists", async () => {
const cwd = path.join(tempRoot, "workspace");
fs.mkdirSync(path.join(cwd, "not-a-repo"), { recursive: true });
fs.writeFileSync(path.join(cwd, "plain-file.txt"), "ignored\n", "utf8");
await expectResolvers(cwd, null);
});
it("returns the sole direct child repository context", async () => {
const cwd = path.join(tempRoot, "workspace");
const repoRoot = path.join(cwd, "repo");
fs.mkdirSync(path.join(cwd, "not-a-repo"), { recursive: true });
fs.writeFileSync(path.join(cwd, "plain-file.txt"), "ignored\n", "utf8");
createGitDirectory(repoRoot);
const expected = {
cwd,
repoRoot,
relativeRepoRoot: "repo",
source: "single-direct-child-repo",
} satisfies ActiveRepoContext;
await expectResolvers(cwd, expected);
});
it("treats a direct child symlink to a repository directory as that child", async () => {
const cwd = path.join(tempRoot, "workspace");
const targetRoot = path.join(tempRoot, "target-repo");
const repoRoot = path.join(cwd, "linked-repo");
fs.mkdirSync(cwd, { recursive: true });
createGitDirectory(targetRoot);
fs.symlinkSync(targetRoot, repoRoot, "junction");
const expected = {
cwd,
repoRoot,
relativeRepoRoot: "linked-repo",
source: "single-direct-child-repo",
} satisfies ActiveRepoContext;
await expectResolvers(cwd, expected);
});
it("returns null when two direct child repositories exist", async () => {
const cwd = path.join(tempRoot, "workspace");
fs.mkdirSync(cwd, { recursive: true });
createGitDirectory(path.join(cwd, "alpha"));
createGitDirectory(path.join(cwd, "beta"));
await expectResolvers(cwd, null);
});
it("accepts a direct child linked-worktree .git file", async () => {
const cwd = path.join(tempRoot, "workspace");
const repoRoot = path.join(cwd, "worktree");
const gitDir = path.join(tempRoot, "admin", "worktrees", "worktree");
const commonDir = path.join(tempRoot, "admin", "common.git");
fs.mkdirSync(cwd, { recursive: true });
createLinkedWorktreeGitFile(repoRoot, gitDir, commonDir);
const expected = {
cwd,
repoRoot,
relativeRepoRoot: "worktree",
source: "single-direct-child-repo",
} satisfies ActiveRepoContext;
await expectResolvers(cwd, expected);
});
});
@@ -37,6 +37,7 @@ function createCtx(usage: Partial<SegmentContext["usageStats"]>): SegmentContext
autoCompactEnabled: false,
subagentCount: 0,
sessionStartTime: Date.now(),
activeRepo: null,
git: {
branch: null,
status: null,
@@ -37,6 +37,7 @@ function createModelContext(advisorActive: boolean): SegmentContext {
autoCompactEnabled: false,
subagentCount: 0,
sessionStartTime: Date.now(),
activeRepo: null,
git: { branch: null, status: null, pr: null },
usage: null,
};
@@ -61,6 +61,7 @@ function createCtx(overrides?: { pathMaxLength?: number; branch?: string | null
autoCompactEnabled: false,
subagentCount: 0,
sessionStartTime: Date.now(),
activeRepo: null,
git: {
branch: overrides?.branch ?? null,
status: null,
@@ -47,6 +47,7 @@ function createPathContext(): SegmentContext {
autoCompactEnabled: false,
subagentCount: 0,
sessionStartTime: Date.now(),
activeRepo: null,
git: {
branch: null,
status: null,
@@ -60,6 +61,14 @@ afterEach(() => {
setProjectDir(originalProjectDir);
});
function expectContentToContainPath(content: string, expected: string): void {
if (process.platform === "win32") {
expect(content.toLowerCase()).toContain(expected.toLowerCase());
return;
}
expect(content).toContain(expected);
}
describe("status line path segment", () => {
it("strips the Projects root for symlink-equivalent aliases", () => {
if (process.platform === "win32") return;
@@ -87,6 +96,7 @@ describe("status line path segment", () => {
expect(rendered.content).not.toContain("home-link");
expect(rendered.content).not.toContain(`${path.sep}Projects${path.sep}`);
} finally {
setProjectDir(originalProjectDir);
fs.rmSync(aliasRoot, { recursive: true, force: true });
fs.rmSync(realProjectDir, { recursive: true, force: true });
}
@@ -102,9 +112,10 @@ describe("status line path segment", () => {
expect(rendered.content).toContain(theme.icon.scratchFolder);
expect(rendered.content).not.toContain(theme.icon.folder);
// Display is just the scratch-relative tail — no leading tmpdir, no ancestor segments.
expect(rendered.content).toContain(path.basename(scratchDir));
expectContentToContainPath(rendered.content, path.basename(getProjectDir()));
expect(rendered.content).not.toContain(os.tmpdir());
} finally {
setProjectDir(originalProjectDir);
fs.rmSync(scratchDir, { recursive: true, force: true });
}
});
@@ -117,11 +128,12 @@ describe("status line path segment", () => {
setProjectDir(nested);
const rendered = renderSegment("path", createPathContext());
const tail = `${path.basename(scratchDir)}${path.sep}sub${path.sep}deep`;
const tail = `${path.basename(path.dirname(path.dirname(getProjectDir())))}${path.sep}sub${path.sep}deep`;
expect(rendered.content).toContain(theme.icon.scratchFolder);
expect(rendered.content).toContain(tail);
expectContentToContainPath(rendered.content, tail);
expect(rendered.content).not.toContain(os.tmpdir());
} finally {
setProjectDir(originalProjectDir);
fs.rmSync(scratchDir, { recursive: true, force: true });
}
});
@@ -138,6 +150,7 @@ describe("status line path segment", () => {
expect(rendered.content).toContain(theme.icon.folder);
expect(rendered.content).not.toContain(theme.icon.scratchFolder);
} finally {
setProjectDir(originalProjectDir);
fs.rmSync(scratchDir, { recursive: true, force: true });
}
});
@@ -154,7 +167,33 @@ describe("status line path segment", () => {
expect(rendered.content).toContain(theme.icon.folder);
expect(rendered.content).not.toContain(theme.icon.scratchFolder);
} finally {
setProjectDir(originalProjectDir);
fs.rmSync(realProjectDir, { recursive: true, force: true });
}
});
it("renders the active nested repo suffix after the parent cwd", () => {
const parentDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-status-line-parent-"));
const repoDir = path.join(parentDir, "pr-workspace");
fs.mkdirSync(repoDir);
try {
setProjectDir(parentDir);
const ctx = createPathContext();
ctx.activeRepo = {
cwd: parentDir,
repoRoot: repoDir,
relativeRepoRoot: "pr-workspace",
source: "single-direct-child-repo",
};
const rendered = renderSegment("path", ctx);
const expected = `${path.basename(getProjectDir())} ↳ pr-workspace`;
expect(rendered.visible).toBe(true);
expectContentToContainPath(rendered.content, expected);
expect(rendered.content).not.toContain(os.tmpdir());
} finally {
setProjectDir(originalProjectDir);
fs.rmSync(parentDir, { recursive: true, force: true });
}
});
});
@@ -132,16 +132,47 @@ describe("SYSTEM.md prompt assembly", () => {
});
const promptText = systemPrompt.join("\n\n");
const normalizedProjectDir = projectDir.replace(/\\/g, "/");
const appendMatches = promptText.match(new RegExp(escapeRegExp(appendPrompt), "g")) ?? [];
expect(systemPrompt).toHaveLength(2);
expect(promptText).toContain("CLI custom prompt");
expect(promptText).toContain("<workspace-tree>");
expect(promptText).toContain("<dir-context>");
expect(promptText).toContain(`current working directory is '${projectDir}'`);
expect(promptText).toMatch(
new RegExp(
`^Today is [^,\\n]+, and the current working directory is '${escapeRegExp(normalizedProjectDir)}'\\.$`,
"m",
),
);
expect(appendMatches).toHaveLength(1);
expect(promptText).not.toContain("Discovered project SYSTEM prompt");
});
it("renders active child repo context in the main system prompt", async () => {
const parentDir = path.join(tempDir, "parent-cwd");
fs.mkdirSync(path.join(parentDir, "active-project", ".git"), { recursive: true });
const { systemPrompt } = await buildSystemPrompt({
cwd: parentDir,
contextFiles: [],
skills: [],
rules: [],
toolNames: [],
workspaceTree: {
rootPath: parentDir,
rendered: "",
truncated: false,
totalLines: 0,
agentsMdFiles: [],
},
});
const promptText = systemPrompt.join("\n\n");
expect(promptText).toContain("<active-repo-context>");
expect(promptText).toContain("Exactly one direct child git repository was detected at `active-project`.");
expect(promptText).toContain("Paths under `active-project/` are the active project");
});
it("prefers project SYSTEM.md over user SYSTEM.md", async () => {
const projectDir = path.join(tempDir, "project");
fs.mkdirSync(path.join(projectDir, ".omp"), { recursive: true });