feat(server): self-identification tools

This commit is contained in:
Mohamed Boudra
2026-01-22 14:01:57 +07:00
parent cc2bdbf01f
commit ef9c538842
11 changed files with 557 additions and 175 deletions

View File

@@ -0,0 +1,289 @@
import { describe, expect, test } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { execSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager } from "./agent-manager.js";
import { AgentRegistry } from "./agent-registry.js";
import { createAgentMcpServer } from "./mcp-server.js";
import { createWorktree } from "../../utils/worktree.js";
import type {
AgentClient,
AgentRunResult,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
} from "./agent-sdk-types.js";
const TEST_CAPABILITIES = {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
} as const;
class TestAgentClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new TestAgentSession(config);
}
async resumeSession(config?: Partial<AgentSessionConfig>): Promise<AgentSession> {
return new TestAgentSession({
provider: "codex",
cwd: config?.cwd ?? process.cwd(),
});
}
}
class TestAgentSession implements AgentSession {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
readonly id = randomUUID();
constructor(private readonly config: AgentSessionConfig) {}
async run(): Promise<AgentRunResult> {
return {
sessionId: this.id ?? this.config.provider,
finalText: "",
timeline: [],
};
}
async *stream(): AsyncGenerator<AgentStreamEvent> {
yield { type: "turn_started", provider: this.provider };
yield { type: "turn_completed", provider: this.provider };
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
async getRuntimeInfo() {
return {
provider: this.provider,
sessionId: this.id,
model: this.config.model ?? null,
modeId: this.config.modeId ?? null,
};
}
async getAvailableModes() {
return [];
}
async getCurrentMode() {
return null;
}
async setMode(): Promise<void> {}
getPendingPermissions() {
return [];
}
async respondToPermission(): Promise<void> {}
describePersistence() {
return {
provider: this.provider,
sessionId: this.id,
};
}
async interrupt(): Promise<void> {}
async close(): Promise<void> {}
}
function initGitRepo(repoDir: string): void {
execSync("git init -b main", { cwd: repoDir, stdio: "ignore" });
execSync('git config user.email "paseo-test@example.com"', {
cwd: repoDir,
stdio: "ignore",
});
execSync('git config user.name "Paseo Test"', {
cwd: repoDir,
stdio: "ignore",
});
writeFileSync(path.join(repoDir, "README.md"), "init\n");
execSync("git add README.md", { cwd: repoDir, stdio: "ignore" });
execSync('git commit -m "init"', { cwd: repoDir, stdio: "ignore" });
}
describe("self-identification MCP tools", () => {
const logger = createTestLogger();
test("set_branch renames branch for Paseo worktree", async () => {
const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-"));
try {
initGitRepo(repoDir);
const worktree = await createWorktree({
branchName: "self-ident",
cwd: repoDir,
worktreeSlug: "self-ident",
});
const registryPath = path.join(repoDir, "agents.json");
const registry = new AgentRegistry(registryPath, logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry,
logger,
idFactory: () => "agent-self-ident",
});
const agent = await manager.createAgent({
provider: "codex",
cwd: worktree.worktreePath,
});
const server = await createAgentMcpServer({
agentManager: manager,
agentRegistry: registry,
callerAgentId: agent.id,
logger,
});
const tool = (server as any)._registeredTools["set_branch"];
await tool.callback({ name: "self-ident-ready" });
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim();
expect(branch).toBe("self-ident-ready");
} finally {
rmSync(repoDir, { recursive: true, force: true });
}
});
test("set_branch allows agents running in a subdirectory of a Paseo worktree", async () => {
const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-"));
try {
initGitRepo(repoDir);
const worktree = await createWorktree({
branchName: "self-ident-subdir",
cwd: repoDir,
worktreeSlug: "self-ident-subdir",
});
const nestedDir = path.join(worktree.worktreePath, "nested");
execSync(`mkdir -p "${nestedDir}"`, { stdio: "ignore" });
const registryPath = path.join(repoDir, "agents.json");
const registry = new AgentRegistry(registryPath, logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry,
logger,
idFactory: () => "agent-self-ident-subdir",
});
const agent = await manager.createAgent({
provider: "codex",
cwd: nestedDir,
});
const server = await createAgentMcpServer({
agentManager: manager,
agentRegistry: registry,
callerAgentId: agent.id,
logger,
});
const tool = (server as any)._registeredTools["set_branch"];
await tool.callback({ name: "self-ident-subdir-ready" });
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: nestedDir,
stdio: "pipe",
})
.toString()
.trim();
expect(branch).toBe("self-ident-subdir-ready");
} finally {
rmSync(repoDir, { recursive: true, force: true });
}
});
test("set_branch rejects non-Paseo checkouts", async () => {
const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-"));
try {
initGitRepo(repoDir);
const registryPath = path.join(repoDir, "agents.json");
const registry = new AgentRegistry(registryPath, logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry,
logger,
idFactory: () => "agent-non-worktree",
});
const agent = await manager.createAgent({
provider: "codex",
cwd: repoDir,
});
const server = await createAgentMcpServer({
agentManager: manager,
agentRegistry: registry,
callerAgentId: agent.id,
logger,
});
const tool = (server as any)._registeredTools["set_branch"];
await expect(tool.callback({ name: "should-fail" })).rejects.toMatchObject({
code: "NOT_ALLOWED",
});
} finally {
rmSync(repoDir, { recursive: true, force: true });
}
});
test("set_branch rejects non-git directories", async () => {
const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-"));
try {
const registryPath = path.join(repoDir, "agents.json");
const registry = new AgentRegistry(registryPath, logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry,
logger,
idFactory: () => "agent-non-git",
});
const agent = await manager.createAgent({
provider: "codex",
cwd: repoDir,
});
const server = await createAgentMcpServer({
agentManager: manager,
agentRegistry: registry,
callerAgentId: agent.id,
logger,
});
const tool = (server as any)._registeredTools["set_branch"];
await expect(tool.callback({ name: "nope" })).rejects.toMatchObject({
code: "NOT_GIT_REPO",
});
} finally {
rmSync(repoDir, { recursive: true, force: true });
}
});
});

View File

@@ -101,4 +101,26 @@ describe("create_agent MCP tool", () => {
})
);
});
it("set_title trims and persists titles for caller agent", async () => {
const { agentManager, agentRegistry, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "agent-1",
} as ManagedAgent);
const server = await createAgentMcpServer({
agentManager,
agentRegistry,
logger,
callerAgentId: "agent-1",
});
const tool = (server as any)._registeredTools["set_title"];
await tool.callback({ title: " Fix auth " });
expect(spies.agentRegistry.setTitle).toHaveBeenCalledWith(
"agent-1",
"Fix auth"
);
});
});

View File

@@ -25,8 +25,13 @@ import { toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import { AgentRegistry } from "./agent-registry.js";
import { createWorktree } from "../../utils/worktree.js";
import {
createWorktree,
isPaseoOwnedWorktreeCwd,
validateBranchSlug,
} from "../../utils/worktree.js";
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
import { NotGitRepoError, renameCurrentBranch } from "../../utils/checkout-git.js";
export interface AgentMcpServerOptions {
agentManager: AgentManager;
@@ -105,6 +110,18 @@ function expandPath(path: string): string {
return resolve(path);
}
type ToolErrorCode = "NOT_ALLOWED" | "NOT_GIT_REPO" | "INVALID_BRANCH";
class AgentMcpToolError extends Error {
readonly code: ToolErrorCode;
constructor(code: ToolErrorCode, message: string) {
super(message);
this.name = "AgentMcpToolError";
this.code = code;
}
}
/**
* Wraps agentManager.waitForAgentEvent with a self-imposed timeout.
* Returns a friendly message when timeout occurs, rather than letting
@@ -880,6 +897,134 @@ export async function createAgentMcpServer(
}
);
server.registerTool(
"set_title",
{
title: "Set Agent Title",
description: "Update the agent's title in the registry.",
inputSchema: {
title: z
.string()
.min(1)
.max(40)
.describe("Short descriptive title (<= 40 chars)."),
},
outputSchema: {
success: z.boolean(),
title: z.string(),
},
},
async ({ title }) => {
if (!callerAgentId) {
throw new AgentMcpToolError(
"NOT_ALLOWED",
"set_title can only be called by a managed agent"
);
}
const agent = agentManager.getAgent(callerAgentId);
if (!agent) {
throw new Error(`Agent ${callerAgentId} not found`);
}
const normalizedTitle = title.trim();
if (!normalizedTitle) {
throw new AgentMcpToolError("NOT_ALLOWED", "Title cannot be empty");
}
if (normalizedTitle.length > 40) {
throw new AgentMcpToolError(
"NOT_ALLOWED",
"Title must be 40 characters or fewer"
);
}
await agentRegistry.setTitle(agent.id, normalizedTitle);
return {
content: [],
structuredContent: ensureValidJson({
success: true,
title: normalizedTitle,
}),
};
}
);
server.registerTool(
"set_branch",
{
title: "Set Agent Branch",
description:
"Rename the current git branch. Allowed only inside Paseo-owned worktrees.",
inputSchema: {
name: z
.string()
.min(1)
.describe("Git branch name (lowercase letters, numbers, hyphens, slashes)."),
},
outputSchema: {
success: z.boolean(),
branch: z.string(),
},
},
async ({ name }) => {
if (!callerAgentId) {
throw new AgentMcpToolError(
"NOT_ALLOWED",
"set_branch can only be called by a managed agent"
);
}
const agent = agentManager.getAgent(callerAgentId);
if (!agent) {
throw new Error(`Agent ${callerAgentId} not found`);
}
const validation = validateBranchSlug(name);
if (!validation.valid) {
throw new AgentMcpToolError(
"INVALID_BRANCH",
validation.error ?? "Invalid branch name"
);
}
let ownership;
try {
ownership = await isPaseoOwnedWorktreeCwd(agent.cwd);
} catch (error) {
const notGitError =
error instanceof NotGitRepoError
? error
: new NotGitRepoError(agent.cwd);
throw new AgentMcpToolError(
"NOT_GIT_REPO",
notGitError.message
);
}
if (!ownership.allowed) {
throw new AgentMcpToolError(
"NOT_ALLOWED",
"Branch renames are only allowed inside Paseo-owned worktrees"
);
}
const result = await renameCurrentBranch(agent.cwd, name);
if (result.currentBranch !== name) {
throw new Error(
`Branch rename failed (expected ${name}, got ${result.currentBranch ?? "unknown"})`
);
}
return {
content: [],
structuredContent: ensureValidJson({
success: true,
branch: name,
}),
};
}
);
server.registerTool(
"set_agent_mode",
{

View File

@@ -48,6 +48,7 @@ import type {
PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
import { getSelfIdentificationInstructions } from "../self-identification-instructions.js";
const fsPromises = promises;
@@ -801,7 +802,12 @@ class ClaudeAgentSession implements AgentSession {
systemPrompt: {
type: "preset",
preset: "claude_code",
append: getOrchestratorModeInstructions(),
append: [
getOrchestratorModeInstructions(),
this.currentMode === "plan" ? "" : getSelfIdentificationInstructions(),
]
.filter(Boolean)
.join("\n"),
},
settingSources: ["user", "project"],
stderr: (data: string) => {

View File

@@ -39,6 +39,7 @@ import type {
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js";
import { getSelfIdentificationInstructions } from "../self-identification-instructions.js";
type CodexMcpAgentConfig = AgentSessionConfig & { provider: "codex" };
@@ -2757,6 +2758,7 @@ function buildCodexMcpConfig(
developerInstructions = history;
}
}
const selfIdentificationInstructions = getSelfIdentificationInstructions();
// Build MCP servers configuration
const mcpServers: Record<string, CodexMcpServerConfig> = {};
@@ -2830,9 +2832,12 @@ function buildCodexMcpConfig(
}
// Add developer instructions for session resume context
if (developerInstructions) {
configPayload["developer-instructions"] = developerInstructions;
const combinedDeveloperInstructions = [developerInstructions, selfIdentificationInstructions]
.filter(Boolean)
.join("\n\n");
if (combinedDeveloperInstructions) {
configPayload["developer-instructions"] = combinedDeveloperInstructions;
}
return configPayload;

View File

@@ -0,0 +1,9 @@
export function getSelfIdentificationInstructions(): string {
return [
"Once you understand the task and are about to execute multi-step work (e.g., edits, tests, or repo changes), call set_title and set_branch exactly once to self-identify.",
"Skip these calls in plan/read-only modes or when running a slash command.",
"Do not call set_title or set_branch for short, fixed, or single-response requests (e.g., “say X exactly”, yes/no, one-liners).",
"Only call set_branch when you are certain you are running inside a Paseo-owned worktree under .paseo/worktrees.",
"If set_branch fails due to permissions or not being in a Paseo worktree, do not retry.",
].join("\n");
}

View File

@@ -43,7 +43,6 @@ import { OpenAISTT, type STTConfig } from "./agent/stt-openai.js";
import { OpenAITTS, type TTSConfig } from "./agent/tts-openai.js";
import { AgentManager } from "./agent/agent-manager.js";
import { AgentRegistry } from "./agent/agent-registry.js";
import { initializeTitleGenerator } from "../services/agent-title-generator.js";
import { attachAgentRegistryPersistence } from "./persistence-hooks.js";
import { createAgentMcpServer } from "./agent/mcp-server.js";
import { createAllClients, shutdownProviders } from "./agent/provider-registry.js";
@@ -367,7 +366,6 @@ export async function createPaseoDaemon(
);
}
initializeTitleGenerator(logger.child({ module: "agent-title-generator" }), openaiApiKey);
} else {
logger.warn("OPENAI_API_KEY not set - LLM, STT, and TTS features will not work");
}

View File

@@ -63,10 +63,6 @@ import {
} from "./file-explorer/service.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js";
import {
generateAgentTitle,
isTitleGeneratorInitialized,
} from "../services/agent-title-generator.js";
import { createWorktree, slugify, validateBranchSlug } from "../utils/worktree.js";
import {
getCheckoutDiff,
@@ -86,7 +82,6 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
...process.env,
GIT_OPTIONAL_LOCKS: "0",
};
const ACTIVE_TITLE_GENERATIONS = new Set<string>();
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
let restartRequested = false;
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
@@ -249,7 +244,6 @@ export class Session {
private readonly downloadTokenStore: DownloadTokenStore;
private readonly pushTokenStore: PushTokenStore;
private readonly providerRegistry: ReturnType<typeof buildProviderRegistry>;
private agentTitleCache: Map<string, string | null> = new Map();
private unsubscribeAgentEvents: (() => void) | null = null;
private clientActivity: {
deviceType: "web" | "mobile";
@@ -529,12 +523,7 @@ export class Session {
});
}
if (
event.event.type === "timeline" ||
event.event.type === "turn_completed"
) {
void this.maybeGenerateAgentTitle(event.agentId);
}
// Title updates are now handled by set_title MCP tool calls.
},
{ replayState: false }
);
@@ -646,14 +635,9 @@ export class Session {
}
private async getStoredAgentTitle(agentId: string): Promise<string | null> {
if (this.agentTitleCache.has(agentId)) {
return this.agentTitleCache.get(agentId) ?? null;
}
try {
const record = await this.agentRegistry.get(agentId);
const title = record?.title ?? null;
this.agentTitleCache.set(agentId, title);
return title;
} catch (error) {
this.sessionLogger.error(
@@ -664,59 +648,6 @@ export class Session {
}
}
private setCachedTitle(agentId: string, title: string | null): void {
this.agentTitleCache.set(agentId, title);
}
private async maybeGenerateAgentTitle(agentId: string): Promise<void> {
if (!isTitleGeneratorInitialized()) {
return;
}
const existingTitle = await this.getStoredAgentTitle(agentId);
if (existingTitle) {
return;
}
if (ACTIVE_TITLE_GENERATIONS.has(agentId)) {
return;
}
const timeline = this.agentManager.getTimeline(agentId);
if (timeline.length === 0) {
return;
}
const snapshot = this.agentManager.getAgent(agentId);
if (!snapshot) {
return;
}
ACTIVE_TITLE_GENERATIONS.add(agentId);
try {
this.sessionLogger.debug(
{ agentId },
`Generating title for agent ${agentId}`
);
const title = await generateAgentTitle(
this.sessionLogger.child({ module: "agent-title-generator" }),
timeline,
snapshot.cwd
);
await this.agentRegistry.setTitle(agentId, title);
this.setCachedTitle(agentId, title);
const latest = this.agentManager.getAgent(agentId) ?? snapshot;
await this.forwardAgentState(latest);
} catch (error) {
this.sessionLogger.error(
{ err: error, agentId },
`Failed to generate title for agent ${agentId}`
);
} finally {
ACTIVE_TITLE_GENERATIONS.delete(agentId);
}
}
/**
* Main entry point for processing session messages
*/
@@ -1071,7 +1002,6 @@ export class Session {
);
}
this.agentTitleCache.delete(agentId);
this.emit({
type: "agent_deleted",
payload: {
@@ -1327,7 +1257,6 @@ export class Session {
worktreeName
);
const snapshot = await this.agentManager.createAgent(sessionConfig);
this.setCachedTitle(snapshot.id, null);
await this.forwardAgentState(snapshot);
const trimmedPrompt = initialPrompt?.trim();
@@ -1424,7 +1353,6 @@ export class Session {
handle,
overrides
);
this.setCachedTitle(snapshot.id, null);
await this.agentManager.primeAgentHistory(snapshot.id);
await this.forwardAgentState(snapshot);
const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
@@ -1493,7 +1421,6 @@ export class Session {
buildConfigOverrides(record),
agentId
);
this.setCachedTitle(agentId, null);
}
await this.agentManager.primeAgentHistory(agentId);
await this.forwardAgentState(snapshot);

View File

@@ -1,76 +0,0 @@
import { generateObject } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
import { z } from "zod";
import type { AgentTimelineItem } from "../server/agent/agent-sdk-types.js";
import { curateAgentActivity } from "../server/agent/activity-curator.js";
import type pino from "pino";
let openai: ReturnType<typeof createOpenAI> | null = null;
export function initializeTitleGenerator(logger: pino.Logger, apiKey: string): void {
openai = createOpenAI({ apiKey });
logger.child({ action: "initialize" }).info("Agent title generator initialized");
}
export function isTitleGeneratorInitialized(): boolean {
return openai !== null;
}
/**
* Generate a concise title for an agent based on its activity
* Returns a 3-5 word title similar to ChatGPT/Claude.ai
*/
export async function generateAgentTitle(
logger: pino.Logger,
timeline: AgentTimelineItem[],
cwd: string
): Promise<string> {
const titleLogger = logger.child({ action: "generate" });
if (!openai) {
throw new Error("Title generator not initialized");
}
if (timeline.length === 0) {
return "New Agent";
}
const activityContext = curateAgentActivity(timeline);
if (!activityContext.trim() || activityContext === "No activity to display.") {
return "New Agent";
}
try {
const titleSchema = z.object({
title: z.string().describe("A concise 3-5 word title describing what the agent is working on"),
});
const model = openai("gpt-4o-mini");
const prompt = `Generate a concise title for this agent session. The title should describe what the user asked the agent to do.
IMPORTANT: Focus primarily on [User] messages to understand the task. User messages contain the actual request - use their words and intent to name the chat. Tool calls and assistant messages are just implementation details and should NOT drive the title.
Be specific but brief (3-5 words). Examples: "Fix Authentication Bug", "Build Dashboard Component", "Refactor API Routes".
Working directory: ${cwd}
Activity:
${activityContext}`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await (generateObject as any)({
model,
schema: titleSchema,
prompt,
temperature: 0.7,
});
const object = result.object as z.infer<typeof titleSchema>;
titleLogger.debug({ title: object.title }, "Generated agent title");
return object.title;
} catch (err) {
titleLogger.error({ err }, "Failed to generate agent title");
return "New Agent";
}
}

View File

@@ -13,6 +13,7 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
export class NotGitRepoError extends Error {
readonly cwd: string;
readonly code = "NOT_GIT_REPO";
constructor(cwd: string) {
super(`Not a git repository: ${cwd}`);
@@ -96,6 +97,25 @@ async function getCurrentBranch(cwd: string): Promise<string | null> {
return branch.length > 0 ? branch : null;
}
export async function renameCurrentBranch(
cwd: string,
newName: string
): Promise<{ previousBranch: string | null; currentBranch: string | null }> {
await requireRepoInfo(cwd);
const previousBranch = await getCurrentBranch(cwd);
if (!previousBranch || previousBranch === "HEAD") {
throw new Error("Cannot rename branch in detached HEAD state");
}
await execAsync(`git branch -m "${newName}"`, {
cwd,
});
const currentBranch = await getCurrentBranch(cwd);
return { previousBranch, currentBranch };
}
async function isWorkingTreeDirty(cwd: string, repoType: "bare" | "normal"): Promise<boolean> {
if (repoType === "bare") {
return false;

View File

@@ -35,6 +35,13 @@ export interface PaseoWorktreeInfo {
head?: string;
}
export type PaseoWorktreeOwnership = {
allowed: boolean;
repoRoot?: string;
worktreeRoot?: string;
worktreePath?: string;
};
interface CreateWorktreeOptions {
branchName: string;
cwd: string;
@@ -99,26 +106,26 @@ export async function detectRepoInfo(cwd: string): Promise<RepoInfo> {
}
}
// Check if we're in a normal git repository
const gitDirPath = join(cwd, ".git");
if (existsSync(gitDirPath)) {
try {
const { stdout } = await execAsync("git rev-parse --show-toplevel", {
cwd,
env: READ_ONLY_GIT_ENV,
});
const repoRoot = stdout.trim();
return {
type: "normal",
path: repoRoot,
name: basename(repoRoot),
};
} catch (error) {
throw new Error("Failed to determine git repository root");
// Fallback: allow running from any subdirectory inside a git checkout/worktree.
// Use git's common dir (shared .git) to find the repo root even when cwd has no .git entry.
try {
const { stdout } = await execAsync(
"git rev-parse --path-format=absolute --git-common-dir",
{ cwd, env: READ_ONLY_GIT_ENV }
);
const commonDir = stdout.trim();
if (!commonDir) {
throw new Error("git-common-dir was empty");
}
const repoRoot = basename(commonDir) === ".git" ? dirname(commonDir) : dirname(commonDir);
return {
type: "normal",
path: repoRoot,
name: basename(repoRoot),
};
} catch {
throw new Error("Not in a git repository");
}
throw new Error("Not in a git repository");
}
/**
@@ -195,6 +202,36 @@ function getPaseoWorktreesRoot(repoRoot: string): string {
return join(repoRoot, ".paseo", "worktrees");
}
export async function isPaseoOwnedWorktreeCwd(
cwd: string
): Promise<PaseoWorktreeOwnership> {
const repoInfo = await detectRepoInfo(cwd);
const worktreesRoot = getPaseoWorktreesRoot(repoInfo.path);
const resolvedRoot = resolve(worktreesRoot) + sep;
const resolvedCwd = resolve(cwd);
if (!resolvedCwd.startsWith(resolvedRoot)) {
return {
allowed: false,
repoRoot: repoInfo.path,
worktreeRoot: worktreesRoot,
worktreePath: resolvedCwd,
};
}
const worktrees = await listPaseoWorktrees({ cwd: repoInfo.path });
const allowed = worktrees.some((entry) => {
const worktreePath = resolve(entry.path);
return resolvedCwd === worktreePath || resolvedCwd.startsWith(worktreePath + sep);
});
return {
allowed,
repoRoot: repoInfo.path,
worktreeRoot: worktreesRoot,
worktreePath: resolvedCwd,
};
}
function ensurePaseoIgnoredForRepo(repoInfo: RepoInfo): {
updated: boolean;
skipped: boolean;