Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### 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
Expand Down
16 changes: 15 additions & 1 deletion packages/coding-agent/src/advisor/watchdog.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
102 changes: 75 additions & 27 deletions packages/coding-agent/src/modes/components/status-line/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getProjectDir } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import { settings } from "../../../config/settings";
import type { AgentSession } from "../../../session/agent-session";
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";
Expand Down Expand Up @@ -153,6 +154,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;
Expand Down Expand Up @@ -193,17 +200,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;

Expand Down Expand Up @@ -244,6 +254,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.
Expand Down Expand Up @@ -324,7 +346,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)
Expand Down Expand Up @@ -379,17 +402,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;
Expand All @@ -401,12 +424,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) {
Expand All @@ -418,32 +447,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)) {
Expand All @@ -452,13 +492,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 () => {
Expand All @@ -475,7 +522,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);
Expand Down Expand Up @@ -741,13 +788,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 };

Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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>
23 changes: 18 additions & 5 deletions packages/coding-agent/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,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";
Expand Down Expand Up @@ -182,6 +182,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";
Expand Down Expand Up @@ -1132,6 +1133,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
Expand Down Expand Up @@ -1377,10 +1387,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;
Expand Down Expand Up @@ -2197,6 +2208,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
memoryRootEnabled: memoryBackend.id === "local",
model: settings.get("includeModelInPrompt") ? getActiveModelString() : undefined,
personality: agentKind === "sub" ? "none" : settings.get("personality"),
activeRepoContext,
});

if (options.systemPrompt === undefined) {
Expand Down Expand Up @@ -2584,10 +2596,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;
Expand Down
Loading
Loading