feat(server): workspace-scoped explorer + worktree createdAt

This commit is contained in:
Mohamed Boudra
2026-03-01 16:32:02 +07:00
parent ec5b416b8a
commit 2e201cab09
11 changed files with 140 additions and 167 deletions
+4 -4
View File
@@ -2166,7 +2166,7 @@ export class DaemonClient {
// ============================================================================ // ============================================================================
async exploreFileSystem( async exploreFileSystem(
agentId: string, cwd: string,
path: string, path: string,
mode: 'list' | 'file' = 'list', mode: 'list' | 'file' = 'list',
requestId?: string requestId?: string
@@ -2175,7 +2175,7 @@ export class DaemonClient {
requestId, requestId,
message: { message: {
type: 'file_explorer_request', type: 'file_explorer_request',
agentId, cwd,
path, path,
mode, mode,
}, },
@@ -2185,7 +2185,7 @@ export class DaemonClient {
} }
async requestDownloadToken( async requestDownloadToken(
agentId: string, cwd: string,
path: string, path: string,
requestId?: string requestId?: string
): Promise<FileDownloadTokenPayload> { ): Promise<FileDownloadTokenPayload> {
@@ -2193,7 +2193,7 @@ export class DaemonClient {
requestId, requestId,
message: { message: {
type: 'file_download_token_request', type: 'file_download_token_request',
agentId, cwd,
path, path,
}, },
responseType: 'file_download_token_response', responseType: 'file_download_token_response',
@@ -79,6 +79,7 @@ export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
export class AgentStorage { export class AgentStorage {
private cache: Map<string, StoredAgentRecord> = new Map(); private cache: Map<string, StoredAgentRecord> = new Map();
private pathById: Map<string, string> = new Map(); private pathById: Map<string, string> = new Map();
private pathsById: Map<string, Set<string>> = new Map();
private pendingWrites: Map<string, Promise<void>> = new Map(); private pendingWrites: Map<string, Promise<void>> = new Map();
private deleting: Set<string> = new Set(); private deleting: Set<string> = new Set();
private loaded = false; private loaded = false;
@@ -91,6 +92,10 @@ export class AgentStorage {
this.logger = logger.child({ module: "agent", component: "agent-storage" }); this.logger = logger.child({ module: "agent", component: "agent-storage" });
} }
async initialize(): Promise<void> {
await this.load();
}
async list(): Promise<StoredAgentRecord[]> { async list(): Promise<StoredAgentRecord[]> {
await this.load(); await this.load();
return Array.from(this.cache.values()); return Array.from(this.cache.values());
@@ -98,24 +103,7 @@ export class AgentStorage {
async get(agentId: string): Promise<StoredAgentRecord | null> { async get(agentId: string): Promise<StoredAgentRecord | null> {
await this.load(); await this.load();
const cached = this.cache.get(agentId); return this.cache.get(agentId) ?? null;
if (cached) {
return cached;
}
const discoveredPath = await this.findAgentPathById(agentId);
if (!discoveredPath) {
return null;
}
const record = await this.readRecordFile(discoveredPath);
if (!record) {
return null;
}
this.cache.set(record.id, record);
this.pathById.set(record.id, discoveredPath);
return record;
} }
async upsert(record: StoredAgentRecord): Promise<void> { async upsert(record: StoredAgentRecord): Promise<void> {
@@ -132,6 +120,7 @@ export class AgentStorage {
await fs.mkdir(path.dirname(nextPath), { recursive: true }); await fs.mkdir(path.dirname(nextPath), { recursive: true });
await writeFileAtomically(nextPath, JSON.stringify(record, null, 2)); await writeFileAtomically(nextPath, JSON.stringify(record, null, 2));
this.addIndexedPath(agentId, nextPath);
if (previousPath && previousPath !== nextPath) { if (previousPath && previousPath !== nextPath) {
try { try {
@@ -139,6 +128,7 @@ export class AgentStorage {
} catch { } catch {
// ignore cleanup errors // ignore cleanup errors
} }
this.removeIndexedPath(agentId, previousPath);
} }
this.cache.set(agentId, record); this.cache.set(agentId, record);
@@ -165,47 +155,8 @@ export class AgentStorage {
await this.load(); await this.load();
this.beginDelete(agentId); this.beginDelete(agentId);
await (this.pendingWrites.get(agentId) ?? Promise.resolve()); await (this.pendingWrites.get(agentId) ?? Promise.resolve());
const candidates = new Set<string>(); const paths = Array.from(this.pathsById.get(agentId) ?? []);
for (const filePath of paths) {
const existingPath =
this.pathById.get(agentId) ?? (await this.findAgentPathById(agentId));
if (existingPath) {
candidates.add(existingPath);
}
// Remove any stray duplicate record files across project directories.
// This can happen across storage layout migrations or when a record path changes.
try {
const projects = await fs.readdir(this.baseDir, { withFileTypes: true });
for (const project of projects) {
if (!project.isDirectory()) {
continue;
}
const projectDir = path.join(this.baseDir, project.name);
candidates.add(path.join(projectDir, `${agentId}.json`));
// Support one more nesting layer (e.g. provider/version subfolders).
let entries: Array<import("node:fs").Dirent> = [];
try {
entries = await fs.readdir(projectDir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
candidates.add(path.join(projectDir, entry.name, `${agentId}.json`));
}
}
} catch {
// ignore scan errors
}
// Support legacy flat layouts: baseDir/<agentId>.json
candidates.add(path.join(this.baseDir, `${agentId}.json`));
for (const filePath of candidates) {
try { try {
await fs.unlink(filePath); await fs.unlink(filePath);
} catch (error) { } catch (error) {
@@ -221,6 +172,7 @@ export class AgentStorage {
this.cache.delete(agentId); this.cache.delete(agentId);
this.pathById.delete(agentId); this.pathById.delete(agentId);
this.pathsById.delete(agentId);
} }
async applySnapshot( async applySnapshot(
@@ -282,6 +234,7 @@ export class AgentStorage {
private async doLoad(): Promise<StoredAgentRecord[]> { private async doLoad(): Promise<StoredAgentRecord[]> {
this.cache.clear(); this.cache.clear();
this.pathById.clear(); this.pathById.clear();
this.pathsById.clear();
try { try {
const records = await this.scanDisk(); const records = await this.scanDisk();
@@ -311,6 +264,19 @@ export class AgentStorage {
} }
for (const entry of entries) { for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith(".json")) {
const rootPath = path.join(this.baseDir, entry.name);
const rootRecord = await this.readRecordFile(rootPath);
if (!rootRecord) {
continue;
}
records.push(rootRecord);
this.cache.set(rootRecord.id, rootRecord);
this.pathById.set(rootRecord.id, rootPath);
this.addIndexedPath(rootRecord.id, rootPath);
continue;
}
if (!entry.isDirectory()) { if (!entry.isDirectory()) {
continue; continue;
} }
@@ -334,6 +300,7 @@ export class AgentStorage {
records.push(record); records.push(record);
this.cache.set(record.id, record); this.cache.set(record.id, record);
this.pathById.set(record.id, filePath); this.pathById.set(record.id, filePath);
this.addIndexedPath(record.id, filePath);
} }
} }
@@ -356,28 +323,21 @@ export class AgentStorage {
return path.join(this.baseDir, projectDir, `${record.id}.json`); return path.join(this.baseDir, projectDir, `${record.id}.json`);
} }
private async findAgentPathById(agentId: string): Promise<string | null> { private addIndexedPath(agentId: string, filePath: string): void {
let projects: Array<import("node:fs").Dirent> = []; const paths = this.pathsById.get(agentId) ?? new Set<string>();
try { paths.add(filePath);
projects = await fs.readdir(this.baseDir, { withFileTypes: true }); this.pathsById.set(agentId, paths);
} catch { }
return null;
}
for (const project of projects) { private removeIndexedPath(agentId: string, filePath: string): void {
if (!project.isDirectory()) { const paths = this.pathsById.get(agentId);
continue; if (!paths) {
} return;
const candidate = path.join(this.baseDir, project.name, `${agentId}.json`); }
try { paths.delete(filePath);
await fs.access(candidate); if (paths.size === 0) {
return candidate; this.pathsById.delete(agentId);
} catch {
// not here
}
} }
return null;
} }
private async waitForPendingWrite(agentId: string): Promise<void> { private async waitForPendingWrite(agentId: string): Promise<void> {
+1
View File
@@ -307,6 +307,7 @@ export async function createPaseoDaemon(
agentManager, agentManager,
agentStorage agentStorage
); );
await agentStorage.initialize();
const persistedRecords = await agentStorage.list(); const persistedRecords = await agentStorage.list();
logger.info( logger.info(
`Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand` `Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand`
@@ -1163,7 +1163,7 @@ describe("daemon client E2E", () => {
if (message.type !== "file_explorer_response") { if (message.type !== "file_explorer_response") {
return; return;
} }
if (message.payload.agentId !== agent.id) { if (message.payload.cwd !== cwd) {
return; return;
} }
if (message.payload.mode !== "list") { if (message.payload.mode !== "list") {
@@ -1179,7 +1179,7 @@ describe("daemon client E2E", () => {
}); });
const listResult = await ctx.client.exploreFileSystem( const listResult = await ctx.client.exploreFileSystem(
agent.id, cwd,
".", ".",
"list", "list",
listRequestId listRequestId
@@ -1199,7 +1199,7 @@ describe("daemon client E2E", () => {
if (message.type !== "file_explorer_response") { if (message.type !== "file_explorer_response") {
return; return;
} }
if (message.payload.agentId !== agent.id) { if (message.payload.cwd !== cwd) {
return; return;
} }
if (message.payload.mode !== "file") { if (message.payload.mode !== "file") {
@@ -1215,7 +1215,7 @@ describe("daemon client E2E", () => {
}); });
const fileResult = await ctx.client.exploreFileSystem( const fileResult = await ctx.client.exploreFileSystem(
agent.id, cwd,
"download.txt", "download.txt",
"file", "file",
fileRequestId fileRequestId
@@ -1235,7 +1235,7 @@ describe("daemon client E2E", () => {
if (message.type !== "file_download_token_response") { if (message.type !== "file_download_token_response") {
return; return;
} }
if (message.payload.agentId !== agent.id) { if (message.payload.cwd !== cwd) {
return; return;
} }
if (!message.payload.path.endsWith("download.txt")) { if (!message.payload.path.endsWith("download.txt")) {
@@ -1251,7 +1251,7 @@ describe("daemon client E2E", () => {
}); });
const tokenResponse = await ctx.client.requestDownloadToken( const tokenResponse = await ctx.client.requestDownloadToken(
agent.id, cwd,
"download.txt", "download.txt",
tokenRequestId tokenRequestId
); );
@@ -1259,7 +1259,7 @@ describe("daemon client E2E", () => {
expect(tokenResponse.error).toBeNull(); expect(tokenResponse.error).toBeNull();
expect(tokenResponse.token).toBeTruthy(); expect(tokenResponse.token).toBeTruthy();
expect(tokenResponse.requestId).toBe(tokenRequestId); expect(tokenResponse.requestId).toBe(tokenRequestId);
expect(tokenMessage.payload.agentId).toBe(agent.id); expect(tokenMessage.payload.cwd).toBe(cwd);
expect(tokenMessage.payload.requestId).toBe(tokenRequestId); expect(tokenMessage.payload.requestId).toBe(tokenRequestId);
const response = await fetch( const response = await fetch(
@@ -46,7 +46,7 @@ describe("daemon E2E", () => {
expect(agent.id).toBeTruthy(); expect(agent.id).toBeTruthy();
const tokenResponse = await ctx.client.requestDownloadToken( const tokenResponse = await ctx.client.requestDownloadToken(
agent.id, cwd,
"download.txt" "download.txt"
); );
@@ -102,7 +102,7 @@ describe("daemon E2E", () => {
}); });
const tokenResponse = await ctx.client.requestDownloadToken( const tokenResponse = await ctx.client.requestDownloadToken(
agent.id, cwd,
"expired.txt" "expired.txt"
); );
@@ -123,7 +123,7 @@ describe("daemon E2E", () => {
); );
test( test(
"rejects paths outside the agent cwd", "rejects paths outside the workspace cwd",
async () => { async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const agent = await ctx.client.createAgent({ const agent = await ctx.client.createAgent({
@@ -133,7 +133,7 @@ describe("daemon E2E", () => {
}); });
const tokenResponse = await ctx.client.requestDownloadToken( const tokenResponse = await ctx.client.requestDownloadToken(
agent.id, cwd,
"../outside.txt" "../outside.txt"
); );
@@ -51,7 +51,7 @@ describe("daemon E2E", () => {
expect(agent.status).toBe("idle"); expect(agent.status).toBe("idle");
// List directory contents // List directory contents
const result = await ctx.client.exploreFileSystem(agent.id, cwd, "list"); const result = await ctx.client.exploreFileSystem(cwd, ".", "list");
// Verify listing returned without error // Verify listing returned without error
expect(result.error).toBeNull(); expect(result.error).toBeNull();
@@ -100,7 +100,7 @@ describe("daemon E2E", () => {
expect(agent.id).toBeTruthy(); expect(agent.id).toBeTruthy();
// Read file contents // Read file contents
const result = await ctx.client.exploreFileSystem(agent.id, testFile, "file"); const result = await ctx.client.exploreFileSystem(cwd, "readme.txt", "file");
// Verify file read // Verify file read
expect(result.error).toBeNull(); expect(result.error).toBeNull();
@@ -134,8 +134,7 @@ describe("daemon E2E", () => {
expect(agent.id).toBeTruthy(); expect(agent.id).toBeTruthy();
// Try to list non-existent path // Try to list non-existent path
const nonExistent = path.join(cwd, "does-not-exist"); const result = await ctx.client.exploreFileSystem(cwd, "does-not-exist", "list");
const result = await ctx.client.exploreFileSystem(agent.id, nonExistent, "list");
// Should return error // Should return error
expect(result.error).toBeTruthy(); expect(result.error).toBeTruthy();
@@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto";
export type DownloadTokenEntry = { export type DownloadTokenEntry = {
token: string; token: string;
agentId: string;
path: string; path: string;
absolutePath: string; absolutePath: string;
fileName: string; fileName: string;
@@ -234,7 +234,7 @@ async function resolveScopedPath({
return requestedPath; return requestedPath;
} }
throw new Error("Access outside of agent workspace is not allowed"); throw new Error("Access outside of workspace is not allowed");
} }
async function buildEntryPayload({ async function buildEntryPayload({
+52 -58
View File
@@ -4380,6 +4380,7 @@ export class Session {
payload: { payload: {
worktrees: worktrees.map((entry) => ({ worktrees: worktrees.map((entry) => ({
worktreePath: entry.path, worktreePath: entry.path,
createdAt: entry.createdAt,
branchName: entry.branchName ?? null, branchName: entry.branchName ?? null,
head: entry.head ?? null, head: entry.head ?? null,
})), })),
@@ -4604,41 +4605,38 @@ export class Session {
} }
/** /**
* Handle read-only file explorer requests scoped to an agent's cwd * Handle read-only file explorer requests scoped to a workspace cwd
*/ */
private async handleFileExplorerRequest(request: FileExplorerRequest): Promise<void> { private async handleFileExplorerRequest(request: FileExplorerRequest): Promise<void> {
const { agentId, path: requestedPath = '.', mode, requestId } = request const { cwd: workspaceCwd, path: requestedPath = '.', mode, requestId } = request
const cwd = workspaceCwd.trim()
if (!cwd) {
this.emit({
type: 'file_explorer_response',
payload: {
cwd: workspaceCwd,
path: requestedPath,
mode,
directory: null,
file: null,
error: 'cwd is required',
requestId,
},
})
return
}
try { try {
const agents = this.agentManager.listAgents()
const agent = agents.find((a) => a.id === agentId)
if (!agent) {
this.emit({
type: 'file_explorer_response',
payload: {
agentId,
path: requestedPath,
mode,
directory: null,
file: null,
error: `Agent not found: ${agentId}`,
requestId,
},
})
return
}
if (mode === 'list') { if (mode === 'list') {
const directory = await listDirectoryEntries({ const directory = await listDirectoryEntries({
root: agent.cwd, root: cwd,
relativePath: requestedPath, relativePath: requestedPath,
}) })
this.emit({ this.emit({
type: 'file_explorer_response', type: 'file_explorer_response',
payload: { payload: {
agentId, cwd,
path: directory.path, path: directory.path,
mode, mode,
directory, directory,
@@ -4649,14 +4647,14 @@ export class Session {
}) })
} else { } else {
const file = await readExplorerFile({ const file = await readExplorerFile({
root: agent.cwd, root: cwd,
relativePath: requestedPath, relativePath: requestedPath,
}) })
this.emit({ this.emit({
type: 'file_explorer_response', type: 'file_explorer_response',
payload: { payload: {
agentId, cwd,
path: file.path, path: file.path,
mode, mode,
directory: null, directory: null,
@@ -4668,13 +4666,13 @@ export class Session {
} }
} catch (error: any) { } catch (error: any) {
this.sessionLogger.error( this.sessionLogger.error(
{ err: error, agentId, path: requestedPath }, { err: error, cwd, path: requestedPath },
`Failed to fulfill file explorer request for agent ${agentId}` `Failed to fulfill file explorer request for workspace ${cwd}`
) )
this.emit({ this.emit({
type: 'file_explorer_response', type: 'file_explorer_response',
payload: { payload: {
agentId, cwd,
path: requestedPath, path: requestedPath,
mode, mode,
directory: null, directory: null,
@@ -4719,44 +4717,40 @@ export class Session {
} }
/** /**
* Handle file download token request scoped to an agent's cwd * Handle file download token request scoped to a workspace cwd
*/ */
private async handleFileDownloadTokenRequest(request: FileDownloadTokenRequest): Promise<void> { private async handleFileDownloadTokenRequest(request: FileDownloadTokenRequest): Promise<void> {
const { agentId, path: requestedPath, requestId } = request const { cwd: workspaceCwd, path: requestedPath, requestId } = request
const cwd = workspaceCwd.trim()
if (!cwd) {
this.emit({
type: 'file_download_token_response',
payload: {
cwd: workspaceCwd,
path: requestedPath,
token: null,
fileName: null,
mimeType: null,
size: null,
error: 'cwd is required',
requestId,
},
})
return
}
this.sessionLogger.debug( this.sessionLogger.debug(
{ agentId, path: requestedPath }, { cwd, path: requestedPath },
`Handling file download token request for agent ${agentId} (${requestedPath})` `Handling file download token request for workspace ${cwd} (${requestedPath})`
) )
try { try {
const agents = this.agentManager.listAgents()
const agent = agents.find((a) => a.id === agentId)
if (!agent) {
this.emit({
type: 'file_download_token_response',
payload: {
agentId,
path: requestedPath,
token: null,
fileName: null,
mimeType: null,
size: null,
error: `Agent not found: ${agentId}`,
requestId,
},
})
return
}
const info = await getDownloadableFileInfo({ const info = await getDownloadableFileInfo({
root: agent.cwd, root: cwd,
relativePath: requestedPath, relativePath: requestedPath,
}) })
const entry = this.downloadTokenStore.issueToken({ const entry = this.downloadTokenStore.issueToken({
agentId,
path: info.path, path: info.path,
absolutePath: info.absolutePath, absolutePath: info.absolutePath,
fileName: info.fileName, fileName: info.fileName,
@@ -4767,7 +4761,7 @@ export class Session {
this.emit({ this.emit({
type: 'file_download_token_response', type: 'file_download_token_response',
payload: { payload: {
agentId, cwd,
path: info.path, path: info.path,
token: entry.token, token: entry.token,
fileName: entry.fileName, fileName: entry.fileName,
@@ -4779,13 +4773,13 @@ export class Session {
}) })
} catch (error: any) { } catch (error: any) {
this.sessionLogger.error( this.sessionLogger.error(
{ err: error, agentId, path: requestedPath }, { err: error, cwd, path: requestedPath },
`Failed to issue download token for agent ${agentId}` `Failed to issue download token for workspace ${cwd}`
) )
this.emit({ this.emit({
type: 'file_download_token_response', type: 'file_download_token_response',
payload: { payload: {
agentId, cwd,
path: requestedPath, path: requestedPath,
token: null, token: null,
fileName: null, fileName: null,
+5 -4
View File
@@ -927,7 +927,7 @@ const FileExplorerDirectorySchema = z.object({
export const FileExplorerRequestSchema = z.object({ export const FileExplorerRequestSchema = z.object({
type: z.literal('file_explorer_request'), type: z.literal('file_explorer_request'),
agentId: z.string(), cwd: z.string(),
path: z.string().optional(), path: z.string().optional(),
mode: z.enum(['list', 'file']), mode: z.enum(['list', 'file']),
requestId: z.string(), requestId: z.string(),
@@ -941,7 +941,7 @@ export const ProjectIconRequestSchema = z.object({
export const FileDownloadTokenRequestSchema = z.object({ export const FileDownloadTokenRequestSchema = z.object({
type: z.literal('file_download_token_request'), type: z.literal('file_download_token_request'),
agentId: z.string(), cwd: z.string(),
path: z.string(), path: z.string(),
requestId: z.string(), requestId: z.string(),
}) })
@@ -1786,6 +1786,7 @@ export const DirectorySuggestionsResponseSchema = z.object({
const PaseoWorktreeSchema = z.object({ const PaseoWorktreeSchema = z.object({
worktreePath: z.string(), worktreePath: z.string(),
createdAt: z.string(),
branchName: z.string().nullable().optional(), branchName: z.string().nullable().optional(),
head: z.string().nullable().optional(), head: z.string().nullable().optional(),
}) })
@@ -1812,7 +1813,7 @@ export const PaseoWorktreeArchiveResponseSchema = z.object({
export const FileExplorerResponseSchema = z.object({ export const FileExplorerResponseSchema = z.object({
type: z.literal('file_explorer_response'), type: z.literal('file_explorer_response'),
payload: z.object({ payload: z.object({
agentId: z.string(), cwd: z.string(),
path: z.string(), path: z.string(),
mode: z.enum(['list', 'file']), mode: z.enum(['list', 'file']),
directory: FileExplorerDirectorySchema.nullable(), directory: FileExplorerDirectorySchema.nullable(),
@@ -1840,7 +1841,7 @@ export const ProjectIconResponseSchema = z.object({
export const FileDownloadTokenResponseSchema = z.object({ export const FileDownloadTokenResponseSchema = z.object({
type: z.literal('file_download_token_response'), type: z.literal('file_download_token_response'),
payload: z.object({ payload: z.object({
agentId: z.string(), cwd: z.string(),
path: z.string(), path: z.string(),
token: z.string().nullable(), token: z.string().nullable(),
fileName: z.string().nullable(), fileName: z.string().nullable(),
+24 -5
View File
@@ -1,6 +1,6 @@
import { exec, spawn } from "child_process"; import { exec, spawn } from "child_process";
import { promisify } from "util"; import { promisify } from "util";
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "fs"; import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs";
import { join, basename, dirname, resolve, sep } from "path"; import { join, basename, dirname, resolve, sep } from "path";
import net from "node:net"; import net from "node:net";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
@@ -108,6 +108,7 @@ export class WorktreeDestroyError extends Error {
export interface PaseoWorktreeInfo { export interface PaseoWorktreeInfo {
path: string; path: string;
createdAt: string;
branchName?: string; branchName?: string;
head?: string; head?: string;
} }
@@ -728,9 +729,11 @@ export async function isPaseoOwnedWorktreeCwd(
}; };
} }
function parseWorktreeList(output: string): PaseoWorktreeInfo[] { type ParsedPaseoWorktreeInfo = Omit<PaseoWorktreeInfo, "createdAt">;
const entries: PaseoWorktreeInfo[] = [];
let current: PaseoWorktreeInfo | null = null; function parseWorktreeList(output: string): ParsedPaseoWorktreeInfo[] {
const entries: ParsedPaseoWorktreeInfo[] = [];
let current: ParsedPaseoWorktreeInfo | null = null;
for (const line of output.split("\n")) { for (const line of output.split("\n")) {
if (line.startsWith("worktree ")) { if (line.startsWith("worktree ")) {
@@ -767,6 +770,18 @@ function parseWorktreeList(output: string): PaseoWorktreeInfo[] {
return entries; return entries;
} }
function resolveWorktreeCreatedAtIso(worktreePath: string): string {
try {
const stats = statSync(worktreePath);
const birthtimeMs = stats.birthtimeMs;
const createdAtMs =
Number.isFinite(birthtimeMs) && birthtimeMs > 0 ? birthtimeMs : stats.ctimeMs;
return new Date(createdAtMs).toISOString();
} catch {
return new Date(0).toISOString();
}
}
export async function listPaseoWorktrees({ export async function listPaseoWorktrees({
cwd, cwd,
paseoHome, paseoHome,
@@ -783,7 +798,11 @@ export async function listPaseoWorktrees({
const rootPrefix = normalizePathForOwnership(worktreesRoot) + sep; const rootPrefix = normalizePathForOwnership(worktreesRoot) + sep;
return parseWorktreeList(stdout) return parseWorktreeList(stdout)
.map((entry) => ({ ...entry, path: normalizePathForOwnership(entry.path) })) .map((entry) => ({ ...entry, path: normalizePathForOwnership(entry.path) }))
.filter((entry) => entry.path.startsWith(rootPrefix)); .filter((entry) => entry.path.startsWith(rootPrefix))
.map((entry) => ({
...entry,
createdAt: resolveWorktreeCreatedAtIso(entry.path),
}));
} }
export async function resolvePaseoWorktreeRootForCwd( export async function resolvePaseoWorktreeRootForCwd(