Update workspace and app changes

This commit is contained in:
Mohamed Boudra
2026-03-07 19:09:00 +07:00
parent 1d1c7058f1
commit 7d76da3249
40 changed files with 2693 additions and 976 deletions

View File

@@ -33,6 +33,8 @@ import type {
PaseoWorktreeListResponse,
PaseoWorktreeArchiveResponse,
ProjectIconResponse,
OpenProjectResponseMessage,
ArchiveWorkspaceResponseMessage,
ListCommandsResponse,
ListProviderModelsResponseMessage,
ListAvailableProvidersResponse,
@@ -305,6 +307,8 @@ export type FetchWorkspacesOptions = Omit<FetchWorkspacesRequest, 'type' | 'requ
}
export type FetchWorkspacesEntry = FetchWorkspacesPayload['entries'][number]
export type FetchWorkspacesPageInfo = FetchWorkspacesPayload['pageInfo']
type OpenProjectPayload = OpenProjectResponseMessage['payload']
type ArchiveWorkspacePayload = ArchiveWorkspaceResponseMessage['payload']
export type FetchAgentResult = {
agent: AgentSnapshotPayload
@@ -1143,6 +1147,30 @@ export class DaemonClient {
})
}
async openProject(cwd: string, requestId?: string): Promise<OpenProjectPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: 'open_project_request',
cwd,
},
responseType: 'open_project_response',
timeout: 10000,
})
}
async archiveWorkspace(workspaceId: string, requestId?: string): Promise<ArchiveWorkspacePayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: 'archive_workspace_request',
workspaceId,
},
responseType: 'archive_workspace_response',
timeout: 10000,
})
}
async fetchAgent(agentId: string, requestId?: string): Promise<FetchAgentResult | null> {
const resolvedRequestId = this.createRequestId(requestId)
const message = SessionInboundMessageSchema.parse({

View File

@@ -979,6 +979,177 @@ describe("ClaudeAgentSession interrupt restart regression", () => {
await session.close();
});
test("does not emit live autonomous turn events for local_agent task_started during a foreground run", async () => {
const logger = createTestLogger();
const keepQueryAlive = deferred<void>();
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const readPromptUuid = createPromptUuidReader(prompt);
let step = 0;
return {
next: vi.fn(async () => {
if (step === 0) {
step += 1;
return {
done: false,
value: {
type: "system",
subtype: "init",
session_id: "task-started-live-session",
permissionMode: "default",
model: "opus",
},
};
}
if (step === 1) {
step += 1;
return {
done: false,
value: {
type: "assistant",
message: {
id: "tool-call-msg",
content: [
{
type: "tool_use",
id: "toolu_live_1",
name: "Agent",
input: { description: "verify", prompt: "sub-task" },
},
],
},
},
};
}
if (step === 2) {
step += 1;
return {
done: false,
value: {
type: "system",
subtype: "task_started",
task_id: "task-live-1",
tool_use_id: "toolu_live_1",
description: "verify",
task_type: "local_agent",
session_id: "task-started-live-session",
uuid: "task-started-live-1",
},
};
}
if (step === 3) {
step += 1;
return {
done: false,
value: {
type: "stream_event",
event: {
type: "content_block_start",
index: 2,
content_block: {
type: "tool_use",
id: "toolu_live_2",
name: "Agent",
input: {},
caller: { type: "direct" },
},
},
session_id: "task-started-live-session",
parent_tool_use_id: null,
uuid: "content-block-start-live-tool-use",
},
};
}
if (step === 4) {
step += 1;
const promptUuid = (await readPromptUuid()) ?? "missing-prompt-uuid";
return {
done: false,
value: {
type: "user",
message: { role: "user", content: "current prompt" },
parent_tool_use_id: null,
uuid: promptUuid,
session_id: "task-started-live-session",
isReplay: true,
},
};
}
if (step === 5) {
step += 1;
return {
done: false,
value: {
type: "assistant",
message: {
content: "FOREGROUND_DONE",
},
},
};
}
if (step === 6) {
step += 1;
return {
done: false,
value: {
type: "result",
subtype: "success",
usage: buildUsage(),
total_cost_usd: 0,
},
};
}
if (step === 7) {
await keepQueryAlive.promise;
return { done: true, value: undefined };
}
return { done: true, value: undefined };
}),
interrupt: vi.fn(async () => undefined),
return: vi.fn(async () => undefined),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
} satisfies QueryMock;
});
const client = new ClaudeAgentClient({ logger });
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
const foregroundEvents = await collectUntilTerminal(session.stream("current prompt"));
const liveIterator = (
session as unknown as {
streamLiveEvents: () => AsyncGenerator<AgentStreamEvent>;
}
).streamLiveEvents();
const timedReader = createTimedIteratorReader({ iterator: liveIterator });
const liveEvents: AgentStreamEvent[] = [];
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const next = await timedReader.nextWithTimeout(25);
if (next.done) {
break;
}
liveEvents.push(next.value);
} catch {
break;
}
}
expect(collectAssistantText(foregroundEvents)).toContain("FOREGROUND_DONE");
expect(liveEvents.some((event) => event.type === "turn_started")).toBe(false);
expect(liveEvents.some((event) => event.type === "turn_completed")).toBe(false);
keepQueryAlive.resolve(undefined);
await session.close();
});
test("emits autonomous live events from SDK stream when Claude wakes itself", async () => {
const logger = createTestLogger();
let queryCreateCount = 0;

View File

@@ -50,6 +50,11 @@ import { AgentStorage } from "./agent/agent-storage.js";
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
import { createAgentMcpServer } from "./agent/mcp-server.js";
import { createAllClients, shutdownProviders } from "./agent/provider-registry.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
import {
FileBackedProjectRegistry,
FileBackedWorkspaceRegistry,
} from "./workspace-registry.js";
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
import {
createConnectionOfferV2,
@@ -289,6 +294,14 @@ export async function createPaseoDaemon(
const httpServer = createHTTPServer(app);
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
const projectRegistry = new FileBackedProjectRegistry(
path.join(config.paseoHome, "projects", "projects.json"),
logger
);
const workspaceRegistry = new FileBackedWorkspaceRegistry(
path.join(config.paseoHome, "projects", "workspaces.json"),
logger
);
const agentManager = new AgentManager({
clients: {
...createAllClients(logger, {
@@ -308,6 +321,13 @@ export async function createPaseoDaemon(
agentStorage
);
await agentStorage.initialize();
await bootstrapWorkspaceRegistries({
paseoHome: config.paseoHome,
agentStorage,
projectRegistry,
workspaceRegistry,
logger,
});
const persistedRecords = await agentStorage.list();
logger.info(
`Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand`
@@ -532,7 +552,9 @@ export async function createPaseoDaemon(
} catch (error) {
logger.error({ err: error, intent }, "Failed to handle daemon lifecycle intent");
}
}
},
projectRegistry,
workspaceRegistry
);
unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => {
wsServer?.publishSpeechReadiness(snapshot);

View File

@@ -28,7 +28,6 @@ import {
type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest,
type DirectorySuggestionsRequest,
type ProjectCheckoutLitePayload,
type ProjectPlacementPayload,
type WorkspaceDescriptorPayload,
type WorkspaceStateBucket,
@@ -95,6 +94,24 @@ import type {
} from './agent/agent-sdk-types.js'
import { AgentStorage, type StoredAgentRecord } from './agent/agent-storage.js'
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from './agent/provider-manifest.js'
import {
buildProjectPlacementForCwd,
deriveProjectKind,
deriveProjectRootPath,
deriveWorkspaceDisplayName,
deriveWorkspaceKind,
normalizeWorkspaceId as normalizePersistedWorkspaceId,
} from './workspace-registry-model.js'
import type {
PersistedProjectRecord,
PersistedWorkspaceRecord,
ProjectRegistry,
WorkspaceRegistry,
} from './workspace-registry.js'
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from './workspace-registry.js'
import {
buildVoiceAgentMcpServerConfig,
buildVoiceModeSystemPrompt,
@@ -121,7 +138,6 @@ import { createAgentWorktree, runAsyncWorktreeBootstrap } from './worktree-boots
import {
getCheckoutDiff,
getCheckoutStatus,
getCheckoutStatusLite,
listBranchSuggestions,
NotGitRepoError,
MergeConflictError,
@@ -194,82 +210,6 @@ export function resolveCreateAgentTitles(options: {
}
}
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
if (!remoteUrl) {
return null
}
const trimmed = remoteUrl.trim()
if (!trimmed) {
return null
}
let host: string | null = null
let path: string | null = null
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/)
if (scpLike) {
host = scpLike[1] ?? null
path = scpLike[2] ?? null
} else if (trimmed.includes('://')) {
try {
const parsed = new URL(trimmed)
host = parsed.hostname || null
path = parsed.pathname ? parsed.pathname.replace(/^\//, '') : null
} catch {
return null
}
}
if (!host || !path) {
return null
}
let cleanedPath = path.trim().replace(/^\/+/, '').replace(/\/+$/, '')
if (cleanedPath.endsWith('.git')) {
cleanedPath = cleanedPath.slice(0, -4)
}
if (!cleanedPath.includes('/')) {
return null
}
const cleanedHost = host.toLowerCase()
if (cleanedHost === 'github.com') {
return `remote:github.com/${cleanedPath}`
}
return `remote:${cleanedHost}/${cleanedPath}`
}
function deriveProjectGroupingKey(options: {
cwd: string
remoteUrl: string | null
isPaseoOwnedWorktree: boolean
mainRepoRoot: string | null
}): string {
const remoteKey = deriveRemoteProjectKey(options.remoteUrl)
if (remoteKey) {
return remoteKey
}
const mainRepoRoot = options.mainRepoRoot?.trim()
if (options.isPaseoOwnedWorktree && mainRepoRoot) {
return mainRepoRoot
}
return options.cwd
}
function deriveProjectGroupingName(projectKey: string): string {
const githubRemotePrefix = 'remote:github.com/'
if (projectKey.startsWith(githubRemotePrefix)) {
return projectKey.slice(githubRemotePrefix.length) || projectKey
}
const segments = projectKey.split(/[\\/]/).filter(Boolean)
return segments[segments.length - 1] || projectKey
}
type ProcessingPhase = 'idle' | 'transcribing'
type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest['compare']
@@ -431,6 +371,8 @@ export type SessionOptions = {
paseoHome: string
agentManager: AgentManager
agentStorage: AgentStorage
projectRegistry: ProjectRegistry
workspaceRegistry: WorkspaceRegistry
createAgentMcpTransport: AgentMcpTransportFactory
stt: Resolvable<SpeechToTextProvider | null>
tts: Resolvable<TextToSpeechProvider | null>
@@ -618,6 +560,8 @@ export class Session {
private agentTools: ToolSet | null = null
private agentManager: AgentManager
private readonly agentStorage: AgentStorage
private readonly projectRegistry: ProjectRegistry
private readonly workspaceRegistry: WorkspaceRegistry
private readonly createAgentMcpTransport: AgentMcpTransportFactory
private readonly downloadTokenStore: DownloadTokenStore
private readonly pushTokenStore: PushTokenStore
@@ -682,6 +626,8 @@ export class Session {
paseoHome,
agentManager,
agentStorage,
projectRegistry,
workspaceRegistry,
createAgentMcpTransport,
stt,
tts,
@@ -701,6 +647,8 @@ export class Session {
this.paseoHome = paseoHome
this.agentManager = agentManager
this.agentStorage = agentStorage
this.projectRegistry = projectRegistry
this.workspaceRegistry = workspaceRegistry
this.createAgentMcpTransport = createAgentMcpTransport
this.terminalManager = terminalManager
if (this.terminalManager) {
@@ -1292,65 +1240,16 @@ export class Session {
}
}
private buildFallbackProjectCheckout(cwd: string): ProjectCheckoutLitePayload {
return {
cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
}
}
private toProjectCheckoutLite(
cwd: string,
status: Awaited<ReturnType<typeof getCheckoutStatusLite>>
): ProjectCheckoutLitePayload {
if (!status.isGit) {
return this.buildFallbackProjectCheckout(cwd)
}
if (status.isPaseoOwnedWorktree) {
return {
cwd,
isGit: true,
currentBranch: status.currentBranch,
remoteUrl: status.remoteUrl,
isPaseoOwnedWorktree: true,
mainRepoRoot: status.mainRepoRoot,
}
}
return {
cwd,
isGit: true,
currentBranch: status.currentBranch,
remoteUrl: status.remoteUrl,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
}
}
private async buildProjectPlacement(cwd: string): Promise<ProjectPlacementPayload> {
const checkout = await getCheckoutStatusLite(cwd, { paseoHome: this.paseoHome })
.then((status) => this.toProjectCheckoutLite(cwd, status))
.catch(() => this.buildFallbackProjectCheckout(cwd))
const projectKey = deriveProjectGroupingKey({
return buildProjectPlacementForCwd({
cwd,
remoteUrl: checkout.remoteUrl,
isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree,
mainRepoRoot: checkout.mainRepoRoot,
paseoHome: this.paseoHome,
})
return {
projectKey,
projectName: deriveProjectGroupingName(projectKey),
checkout,
}
}
private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> {
try {
await this.ensureWorkspaceRegistered(agent.cwd)
const subscription = this.agentUpdatesSubscription
const payload = await this.buildAgentPayload(agent)
if (subscription) {
@@ -1572,6 +1471,14 @@ export class Session {
await this.handlePaseoWorktreeArchiveRequest(msg)
break
case 'open_project_request':
await this.handleOpenProjectRequest(msg)
break
case 'archive_workspace_request':
await this.handleArchiveWorkspaceRequest(msg)
break
case 'file_explorer_request':
await this.handleFileExplorerRequest(msg)
break
@@ -1874,7 +1781,7 @@ export class Session {
private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise<void> {
this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`)
const { archivedAt, archivedRecord } = await this.archiveAgentState(agentId)
const { archivedAt } = await this.archiveAgentState(agentId)
this.emit({
type: 'agent_archived',
@@ -1884,12 +1791,6 @@ export class Session {
requestId,
},
})
await this.maybeArchiveWorktreeAfterLastAgentArchived({
archivedAgentId: agentId,
archivedAgentCwd: archivedRecord.cwd,
requestId,
})
}
private async archiveAgentState(agentId: string): Promise<{
@@ -2626,6 +2527,7 @@ export class Session {
worktreeName,
labels
)
await this.ensureWorkspaceRegistered(sessionConfig.cwd)
const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, { labels })
await this.forwardAgentUpdate(snapshot)
@@ -4570,74 +4472,6 @@ export class Session {
}
}
private async maybeArchiveWorktreeAfterLastAgentArchived(options: {
archivedAgentId: string
archivedAgentCwd: string
requestId: string
}): Promise<void> {
try {
const ownership = await isPaseoOwnedWorktreeCwd(options.archivedAgentCwd, {
paseoHome: this.paseoHome,
})
if (!ownership.allowed) {
return
}
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(options.archivedAgentCwd, {
paseoHome: this.paseoHome,
})
if (!resolvedWorktree) {
return
}
const records = await this.agentStorage.list()
const recordsById = new Map(records.map((record) => [record.id, record]))
const targetPath = resolvedWorktree.worktreePath
const hasRemainingNonArchivedRecord = records.some((record) => {
if (record.id === options.archivedAgentId || record.archivedAt) {
return false
}
return this.isPathWithinRoot(targetPath, record.cwd)
})
if (hasRemainingNonArchivedRecord) {
return
}
const hasUnknownLiveAgent = this.agentManager.listAgents().some((agent) => {
if (agent.id === options.archivedAgentId) {
return false
}
if (!this.isPathWithinRoot(targetPath, agent.cwd)) {
return false
}
return !recordsById.has(agent.id)
})
if (hasUnknownLiveAgent) {
return
}
const repoRoot = ownership.repoRoot
if (!repoRoot) {
this.sessionLogger.warn(
{ agentId: options.archivedAgentId, worktreePath: targetPath },
'Unable to resolve repo root for auto-archive after agent archive'
)
return
}
await this.archivePaseoWorktree({
targetPath,
repoRoot,
requestId: options.requestId,
})
} catch (error: any) {
this.sessionLogger.warn(
{ err: error, agentId: options.archivedAgentId, cwd: options.archivedAgentCwd },
'Failed to auto-archive worktree after agent archive'
)
}
}
private async archivePaseoWorktree(options: {
targetPath: string
repoRoot: string
@@ -4653,11 +4487,13 @@ export class Session {
const removedAgents = new Set<string>()
const affectedWorkspaceCwds = new Set<string>([targetPath])
const affectedWorkspaceIds = new Set<string>([normalizePersistedWorkspaceId(targetPath)])
const agents = this.agentManager.listAgents()
for (const agent of agents) {
if (this.isPathWithinRoot(targetPath, agent.cwd)) {
removedAgents.add(agent.id)
affectedWorkspaceCwds.add(agent.cwd)
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd))
try {
await this.agentManager.closeAgent(agent.id)
} catch {
@@ -4676,6 +4512,7 @@ export class Session {
if (this.isPathWithinRoot(targetPath, record.cwd)) {
removedAgents.add(record.id)
affectedWorkspaceCwds.add(record.cwd)
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd))
try {
await this.agentStorage.remove(record.id)
} catch {
@@ -4692,6 +4529,10 @@ export class Session {
paseoHome: this.paseoHome,
})
for (const workspaceId of affectedWorkspaceIds) {
await this.archiveWorkspaceRecord(workspaceId)
}
for (const agentId of removedAgents) {
this.emit({
type: 'agent_deleted',
@@ -5364,14 +5205,6 @@ export class Session {
done: 4,
}
private normalizeWorkspaceId(cwd: string): string {
const trimmed = cwd.trim()
if (!trimmed) {
return cwd
}
return resolve(trimmed)
}
private deriveWorkspaceStateBucket(agent: AgentSnapshotPayload): WorkspaceStateBucket {
const pendingPermissionCount = agent.pendingPermissions?.length ?? 0
if (pendingPermissionCount > 0 || agent.attentionReason === 'permission') {
@@ -5389,23 +5222,6 @@ export class Session {
return 'done'
}
private deriveWorkspaceDirectoryName(cwd: string): string {
const normalized = cwd.replace(/\\/g, '/')
const segments = normalized.split('/').filter(Boolean)
return segments[segments.length - 1] ?? cwd
}
private deriveWorkspaceName(input: {
cwd: string
checkout: ProjectCheckoutLitePayload
}): string {
const branch = input.checkout.currentBranch?.trim() ?? null
if (branch && branch.toUpperCase() !== 'HEAD') {
return branch
}
return this.deriveWorkspaceDirectoryName(input.cwd)
}
private accumulateLatestActivityAt(
current: string | null,
agent: AgentSnapshotPayload
@@ -5425,20 +5241,55 @@ export class Session {
return current
}
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
const agents = await this.listAgentPayloads()
private async describeWorkspaceRecord(
workspace: PersistedWorkspaceRecord,
projectRecord?: PersistedProjectRecord | null
): Promise<WorkspaceDescriptorPayload> {
const resolvedProjectRecord = projectRecord ?? (await this.projectRegistry.get(workspace.projectId))
let displayName = workspace.displayName
try {
const placement = await this.buildProjectPlacement(workspace.cwd)
displayName = deriveWorkspaceDisplayName({
cwd: workspace.cwd,
checkout: placement.checkout,
})
} catch {
// Fall back to the persisted label if checkout metadata is unavailable.
}
return {
id: workspace.workspaceId,
projectId: workspace.projectId,
projectDisplayName: resolvedProjectRecord?.displayName ?? workspace.projectId,
projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd,
projectKind: resolvedProjectRecord?.kind ?? 'non_git',
workspaceKind: workspace.kind,
name: displayName,
status: 'done',
activityAt: null,
}
}
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
const [agents, persistedWorkspaces, persistedProjects] = await Promise.all([
this.listAgentPayloads(),
this.workspaceRegistry.list(),
this.projectRegistry.list(),
])
const activeRecords = persistedWorkspaces.filter((workspace) => !workspace.archivedAt)
const activeProjects = new Map(
persistedProjects
.filter((project) => !project.archivedAt)
.map((project) => [project.projectId, project] as const)
)
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>()
const placementByWorkspaceId = new Map<string, Promise<ProjectPlacementPayload>>()
const getPlacement = (workspaceCwd: string): Promise<ProjectPlacementPayload> => {
const key = this.normalizeWorkspaceId(workspaceCwd)
const existing = placementByWorkspaceId.get(key)
if (existing) {
return existing
}
const next = this.buildProjectPlacement(workspaceCwd)
placementByWorkspaceId.set(key, next)
return next
for (const workspace of activeRecords) {
descriptorsByWorkspaceId.set(
workspace.workspaceId,
await this.describeWorkspaceRecord(workspace, activeProjects.get(workspace.projectId) ?? null)
)
}
for (const agent of agents) {
@@ -5446,21 +5297,9 @@ export class Session {
continue
}
const workspaceId = this.normalizeWorkspaceId(agent.cwd)
const placement = await getPlacement(workspaceId)
const workspaceId = normalizePersistedWorkspaceId(agent.cwd)
const existing = descriptorsByWorkspaceId.get(workspaceId)
if (!existing) {
const bucket = this.deriveWorkspaceStateBucket(agent)
descriptorsByWorkspaceId.set(workspaceId, {
id: workspaceId,
projectId: placement.projectKey,
name: this.deriveWorkspaceName({
cwd: workspaceId,
checkout: placement.checkout,
}),
status: bucket,
activityAt: this.accumulateLatestActivityAt(null, agent),
})
continue
}
@@ -5751,13 +5590,71 @@ export class Session {
}
}
private async ensureWorkspaceRegistered(cwd: string): Promise<PersistedWorkspaceRecord> {
const workspaceId = normalizePersistedWorkspaceId(cwd)
const existing = await this.workspaceRegistry.get(workspaceId)
if (existing && !existing.archivedAt) {
return existing
}
const placement = await this.buildProjectPlacement(workspaceId)
const now = new Date().toISOString()
const projectExisting = await this.projectRegistry.get(placement.projectKey)
const projectRecord: PersistedProjectRecord = createPersistedProjectRecord({
projectId: placement.projectKey,
rootPath: deriveProjectRootPath({
cwd: workspaceId,
checkout: placement.checkout,
}),
kind: deriveProjectKind(placement.checkout),
displayName: placement.projectName,
createdAt: projectExisting?.createdAt ?? now,
updatedAt: now,
archivedAt: null,
})
await this.projectRegistry.upsert(projectRecord)
const workspaceRecord = createPersistedWorkspaceRecord({
workspaceId,
projectId: placement.projectKey,
cwd: workspaceId,
kind: deriveWorkspaceKind(placement.checkout),
displayName: deriveWorkspaceDisplayName({
cwd: workspaceId,
checkout: placement.checkout,
}),
createdAt: existing?.createdAt ?? now,
updatedAt: now,
archivedAt: null,
})
await this.workspaceRegistry.upsert(workspaceRecord)
return workspaceRecord
}
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
const existing = await this.workspaceRegistry.get(workspaceId)
if (!existing || existing.archivedAt) {
return
}
const nextArchivedAt = archivedAt ?? new Date().toISOString()
await this.workspaceRegistry.archive(workspaceId, nextArchivedAt)
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === existing.projectId && !workspace.archivedAt
)
if (siblingWorkspaces.length === 0) {
await this.projectRegistry.archive(existing.projectId, nextArchivedAt)
}
}
private async emitWorkspaceUpdateForCwd(cwd: string): Promise<void> {
const subscription = this.workspaceUpdatesSubscription
if (!subscription) {
return
}
const workspaceId = this.normalizeWorkspaceId(cwd)
const workspaceId = normalizePersistedWorkspaceId(cwd)
const all = await this.listWorkspaceDescriptors()
const workspace = all.find((entry) => entry.id === workspaceId)
if (!workspace) {
@@ -5789,7 +5686,7 @@ export class Session {
const uniqueWorkspaceCwds = new Set<string>()
for (const cwd of cwds) {
const normalized = this.normalizeWorkspaceId(cwd)
const normalized = normalizePersistedWorkspaceId(cwd)
if (!normalized) {
continue
}
@@ -5923,6 +5820,76 @@ export class Session {
}
}
private async handleOpenProjectRequest(
request: Extract<SessionInboundMessage, { type: 'open_project_request' }>
): Promise<void> {
try {
const workspace = await this.ensureWorkspaceRegistered(request.cwd)
await this.emitWorkspaceUpdateForCwd(workspace.cwd)
const descriptor = await this.describeWorkspaceRecord(workspace)
this.emit({
type: 'open_project_response',
payload: {
requestId: request.requestId,
workspace: descriptor,
error: null,
},
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to open project'
this.sessionLogger.error({ err: error, cwd: request.cwd }, 'Failed to open project')
this.emit({
type: 'open_project_response',
payload: {
requestId: request.requestId,
workspace: null,
error: message,
},
})
}
}
private async handleArchiveWorkspaceRequest(
request: Extract<SessionInboundMessage, { type: 'archive_workspace_request' }>
): Promise<void> {
try {
const existing = await this.workspaceRegistry.get(request.workspaceId)
if (!existing) {
throw new Error(`Workspace not found: ${request.workspaceId}`)
}
if (existing.kind === 'worktree') {
throw new Error('Use worktree archive for Paseo worktrees')
}
const archivedAt = new Date().toISOString()
await this.archiveWorkspaceRecord(request.workspaceId, archivedAt)
await this.emitWorkspaceUpdateForCwd(existing.cwd)
this.emit({
type: 'archive_workspace_response',
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
archivedAt,
error: null,
},
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to archive workspace'
this.sessionLogger.error(
{ err: error, workspaceId: request.workspaceId },
'Failed to archive workspace'
)
this.emit({
type: 'archive_workspace_response',
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
archivedAt: null,
error: message,
},
})
}
}
private async handleFetchAgent(agentIdOrIdentifier: string, requestId: string): Promise<void> {
const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier)
if (!resolved.ok) {

View File

@@ -1,6 +1,7 @@
import { describe, expect, test, vi } from 'vitest'
import { Session } from './session.js'
import type { AgentSnapshotPayload } from '../shared/messages.js'
import { createPersistedProjectRecord, createPersistedWorkspaceRecord } from './workspace-registry.js'
function makeAgent(input: {
id: string
@@ -79,6 +80,24 @@ function createSessionForWorkspaceTests(): Session {
list: async () => [],
get: async () => null,
} as any,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
createAgentMcpTransport: async () => {
throw new Error('not used')
},
@@ -91,6 +110,17 @@ function createSessionForWorkspaceTests(): Session {
describe('workspace aggregation', () => {
test('non-git workspace uses deterministic directory name and no unknown branch fallback', async () => {
const session = createSessionForWorkspaceTests() as any
session.workspaceRegistry.list = async () => [
createPersistedWorkspaceRecord({
workspaceId: '/tmp/non-git',
projectId: '/tmp/non-git',
cwd: '/tmp/non-git',
kind: 'directory',
displayName: 'non-git',
createdAt: '2026-03-01T12:00:00.000Z',
updatedAt: '2026-03-01T12:00:00.000Z',
}),
]
session.listAgentPayloads = async () => [
makeAgent({
id: 'a1',
@@ -99,19 +129,6 @@ describe('workspace aggregation', () => {
updatedAt: '2026-03-01T12:00:00.000Z',
}),
]
session.buildProjectPlacement = async (cwd: string) => ({
projectKey: cwd,
projectName: 'non-git',
checkout: {
cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
})
const result = await session.listFetchWorkspacesEntries({
type: 'fetch_workspaces_request',
requestId: 'req-1',
@@ -124,6 +141,17 @@ describe('workspace aggregation', () => {
test('git branch workspace uses branch as canonical name', async () => {
const session = createSessionForWorkspaceTests() as any
session.workspaceRegistry.list = async () => [
createPersistedWorkspaceRecord({
workspaceId: '/tmp/repo-branch',
projectId: '/tmp/repo-branch',
cwd: '/tmp/repo-branch',
kind: 'local_checkout',
displayName: 'feature/name-from-server',
createdAt: '2026-03-01T12:00:00.000Z',
updatedAt: '2026-03-01T12:00:00.000Z',
}),
]
session.listAgentPayloads = async () => [
makeAgent({
id: 'a1',
@@ -144,7 +172,6 @@ describe('workspace aggregation', () => {
mainRepoRoot: null,
},
})
const result = await session.listFetchWorkspacesEntries({
type: 'fetch_workspaces_request',
requestId: 'req-branch',
@@ -156,6 +183,17 @@ describe('workspace aggregation', () => {
test('branch/detached policies and dominant status bucket are deterministic', async () => {
const session = createSessionForWorkspaceTests() as any
session.workspaceRegistry.list = async () => [
createPersistedWorkspaceRecord({
workspaceId: '/tmp/repo',
projectId: '/tmp/repo',
cwd: '/tmp/repo',
kind: 'local_checkout',
displayName: 'repo',
createdAt: '2026-03-01T12:00:00.000Z',
updatedAt: '2026-03-01T12:00:00.000Z',
}),
]
session.listAgentPayloads = async () => [
makeAgent({
id: 'a1',
@@ -177,19 +215,6 @@ describe('workspace aggregation', () => {
pendingPermissions: 1,
}),
]
session.buildProjectPlacement = async (cwd: string) => ({
projectKey: cwd,
projectName: 'repo',
checkout: {
cwd,
isGit: true,
currentBranch: 'HEAD',
remoteUrl: 'https://github.com/acme/repo.git',
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
})
const result = await session.listFetchWorkspacesEntries({
type: 'fetch_workspaces_request',
requestId: 'req-2',
@@ -200,7 +225,7 @@ describe('workspace aggregation', () => {
expect(result.entries[0]?.status).toBe('needs_input')
})
test('workspace update stream emits upsert and remove on lifecycle changes', async () => {
test('workspace update stream keeps persisted workspace visible after agents stop', async () => {
const emitted: Array<{ type: string; payload: unknown }> = []
const logger = {
child: () => logger,
@@ -227,6 +252,24 @@ describe('workspace aggregation', () => {
list: async () => [],
get: async () => null,
} as any,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
createAgentMcpTransport: async () => {
throw new Error('not used')
},
@@ -246,6 +289,10 @@ describe('workspace aggregation', () => {
{
id: '/tmp/repo',
projectId: '/tmp/repo',
projectDisplayName: 'repo',
projectRootPath: '/tmp/repo',
projectKind: 'non_git',
workspaceKind: 'directory',
name: 'repo',
status: 'running',
activityAt: '2026-03-01T12:00:00.000Z',
@@ -253,15 +300,37 @@ describe('workspace aggregation', () => {
]
await session.emitWorkspaceUpdateForCwd('/tmp/repo')
session.listWorkspaceDescriptors = async () => []
session.listWorkspaceDescriptors = async () => [
{
id: '/tmp/repo',
projectId: '/tmp/repo',
projectDisplayName: 'repo',
projectRootPath: '/tmp/repo',
projectKind: 'non_git',
workspaceKind: 'directory',
name: 'repo',
status: 'done',
activityAt: null,
},
]
await session.emitWorkspaceUpdateForCwd('/tmp/repo')
const workspaceUpdates = emitted.filter((message) => message.type === 'workspace_update')
expect(workspaceUpdates).toHaveLength(2)
expect((workspaceUpdates[0] as any).payload.kind).toBe('upsert')
expect((workspaceUpdates[1] as any).payload).toEqual({
kind: 'remove',
id: '/tmp/repo',
kind: 'upsert',
workspace: {
id: '/tmp/repo',
projectId: '/tmp/repo',
projectDisplayName: 'repo',
projectRootPath: '/tmp/repo',
projectKind: 'non_git',
workspaceKind: 'directory',
name: 'repo',
status: 'done',
activityAt: null,
},
})
})
@@ -289,4 +358,80 @@ describe('workspace aggregation', () => {
expect(emitWorkspaceUpdateForCwd).toHaveBeenNthCalledWith(1, '/tmp/repo')
expect(emitWorkspaceUpdateForCwd).toHaveBeenNthCalledWith(2, '/tmp/repo/sub')
})
test('open_project_request registers a workspace before any agent exists', async () => {
const emitted: Array<{ type: string; payload: unknown }> = []
const session = createSessionForWorkspaceTests() as any
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>()
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>()
session.emit = (message: any) => emitted.push(message)
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null
session.projectRegistry.upsert = async (record: ReturnType<typeof createPersistedProjectRecord>) => {
projects.set(record.projectId, record)
}
session.workspaceRegistry.get = async (workspaceId: string) => workspaces.get(workspaceId) ?? null
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>
) => {
workspaces.set(record.workspaceId, record)
}
session.projectRegistry.list = async () => Array.from(projects.values())
session.workspaceRegistry.list = async () => Array.from(workspaces.values())
session.buildProjectPlacement = async (cwd: string) => ({
projectKey: cwd,
projectName: 'repo',
checkout: {
cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
})
await session.handleMessage({
type: 'open_project_request',
cwd: '/tmp/repo',
requestId: 'req-open',
})
expect(workspaces.get('/tmp/repo')).toBeTruthy()
const response = emitted.find((message) => message.type === 'open_project_response') as any
expect(response?.payload.error).toBeNull()
expect(response?.payload.workspace?.id).toBe('/tmp/repo')
})
test('archive_workspace_request hides non-destructive workspace records', async () => {
const emitted: Array<{ type: string; payload: unknown }> = []
const session = createSessionForWorkspaceTests() as any
const workspace = createPersistedWorkspaceRecord({
workspaceId: '/tmp/repo',
projectId: '/tmp/repo',
cwd: '/tmp/repo',
kind: 'directory',
displayName: 'repo',
createdAt: '2026-03-01T12:00:00.000Z',
updatedAt: '2026-03-01T12:00:00.000Z',
})
session.emit = (message: any) => emitted.push(message)
session.workspaceRegistry.get = async () => workspace
session.workspaceRegistry.archive = async (_workspaceId: string, archivedAt: string) => {
workspace.archivedAt = archivedAt
}
session.workspaceRegistry.list = async () => [workspace]
session.projectRegistry.archive = async () => {}
await session.handleMessage({
type: 'archive_workspace_request',
workspaceId: '/tmp/repo',
requestId: 'req-archive',
})
expect(workspace.archivedAt).toBeTruthy()
const response = emitted.find((message) => message.type === 'archive_workspace_response') as any
expect(response?.payload.error).toBeNull()
})
})

View File

@@ -8,6 +8,7 @@ import type { AgentStorage } from "./agent/agent-storage.js";
import type { DownloadTokenStore } from "./file-download/token-store.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type pino from "pino";
import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js";
import {
type ServerInfoStatusPayload,
type WSHelloMessage,
@@ -70,6 +71,30 @@ type WebSocketServerConfig = {
allowedHosts?: AllowedHostsConfig;
};
function createNoopProjectRegistry(): ProjectRegistry {
return {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
};
}
function createNoopWorkspaceRegistry(): WorkspaceRegistry {
return {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
};
}
function toServerCapabilityState(
params: {
state: SpeechReadinessSnapshot["dictation"];
@@ -212,6 +237,8 @@ export class VoiceAssistantWebSocketServer {
private readonly daemonVersion: string;
private readonly agentManager: AgentManager;
private readonly agentStorage: AgentStorage;
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly paseoHome: string;
private readonly pushTokenStore: PushTokenStore;
@@ -295,7 +322,9 @@ export class VoiceAssistantWebSocketServer {
},
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap,
daemonVersion?: string,
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void,
projectRegistry?: ProjectRegistry,
workspaceRegistry?: WorkspaceRegistry
) {
this.logger = logger.child({ module: "websocket-server" });
this.serverId = serverId;
@@ -305,6 +334,8 @@ export class VoiceAssistantWebSocketServer {
this.daemonVersion = daemonVersion.trim();
this.agentManager = agentManager;
this.agentStorage = agentStorage;
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry();
this.downloadTokenStore = downloadTokenStore;
this.paseoHome = paseoHome;
this.createAgentMcpTransport = createAgentMcpTransport;
@@ -597,6 +628,8 @@ export class VoiceAssistantWebSocketServer {
paseoHome: this.paseoHome,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
projectRegistry: this.projectRegistry,
workspaceRegistry: this.workspaceRegistry,
createAgentMcpTransport: this.createAgentMcpTransport,
stt: this.stt,
tts: this.tts,

View File

@@ -0,0 +1,167 @@
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync, rmSync } from 'node:fs'
import { afterEach, beforeEach, describe, expect, test } from 'vitest'
import { createTestLogger } from '../test-utils/test-logger.js'
import { AgentStorage } from './agent/agent-storage.js'
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from './workspace-registry.js'
import { bootstrapWorkspaceRegistries } from './workspace-registry-bootstrap.js'
describe('bootstrapWorkspaceRegistries', () => {
let tmpDir: string
let paseoHome: string
let agentStorage: AgentStorage
let projectRegistry: FileBackedProjectRegistry
let workspaceRegistry: FileBackedWorkspaceRegistry
const logger = createTestLogger()
beforeEach(() => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'workspace-bootstrap-'))
paseoHome = path.join(tmpDir, '.paseo')
agentStorage = new AgentStorage(path.join(paseoHome, 'agents'), logger)
projectRegistry = new FileBackedProjectRegistry(
path.join(paseoHome, 'projects', 'projects.json'),
logger
)
workspaceRegistry = new FileBackedWorkspaceRegistry(
path.join(paseoHome, 'projects', 'workspaces.json'),
logger
)
})
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true })
})
test('materializes workspace registries from non-archived agent records', async () => {
await agentStorage.initialize()
await agentStorage.upsert({
id: 'agent-1',
provider: 'codex',
cwd: '/tmp/non-git-project',
createdAt: '2026-03-01T00:00:00.000Z',
updatedAt: '2026-03-02T00:00:00.000Z',
lastActivityAt: '2026-03-02T00:00:00.000Z',
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: 'idle',
lastModeId: null,
config: null,
runtimeInfo: { provider: 'codex', sessionId: null },
persistence: null,
archivedAt: null,
})
await agentStorage.upsert({
id: 'agent-2',
provider: 'codex',
cwd: '/tmp/non-git-project',
createdAt: '2026-03-01T01:00:00.000Z',
updatedAt: '2026-03-03T00:00:00.000Z',
lastActivityAt: '2026-03-03T00:00:00.000Z',
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: 'running',
lastModeId: null,
config: null,
runtimeInfo: { provider: 'codex', sessionId: null },
persistence: null,
archivedAt: null,
})
await agentStorage.upsert({
id: 'agent-archived',
provider: 'codex',
cwd: '/tmp/archived-project',
createdAt: '2026-03-01T00:00:00.000Z',
updatedAt: '2026-03-01T00:00:00.000Z',
lastActivityAt: '2026-03-01T00:00:00.000Z',
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: 'idle',
lastModeId: null,
config: null,
runtimeInfo: { provider: 'codex', sessionId: null },
persistence: null,
archivedAt: '2026-03-02T00:00:00.000Z',
})
await bootstrapWorkspaceRegistries({
paseoHome,
agentStorage,
projectRegistry,
workspaceRegistry,
logger,
})
const workspaces = await workspaceRegistry.list()
expect(workspaces).toHaveLength(1)
expect(workspaces[0]?.workspaceId).toBe('/tmp/non-git-project')
expect(workspaces[0]?.createdAt).toBe('2026-03-01T00:00:00.000Z')
expect(workspaces[0]?.updatedAt).toBe('2026-03-03T00:00:00.000Z')
const projects = await projectRegistry.list()
expect(projects).toHaveLength(1)
expect(projects[0]?.projectId).toBe('/tmp/non-git-project')
expect(projects[0]?.createdAt).toBe('2026-03-01T00:00:00.000Z')
expect(projects[0]?.updatedAt).toBe('2026-03-03T00:00:00.000Z')
})
test('does not rematerialize when registry files already exist', async () => {
await projectRegistry.initialize()
await workspaceRegistry.initialize()
await projectRegistry.upsert({
projectId: '/tmp/existing',
rootPath: '/tmp/existing',
kind: 'non_git',
displayName: 'existing',
createdAt: '2026-03-01T00:00:00.000Z',
updatedAt: '2026-03-01T00:00:00.000Z',
archivedAt: null,
})
await workspaceRegistry.upsert({
workspaceId: '/tmp/existing',
projectId: '/tmp/existing',
cwd: '/tmp/existing',
kind: 'directory',
displayName: 'existing',
createdAt: '2026-03-01T00:00:00.000Z',
updatedAt: '2026-03-01T00:00:00.000Z',
archivedAt: null,
})
await agentStorage.initialize()
await agentStorage.upsert({
id: 'agent-1',
provider: 'codex',
cwd: '/tmp/another-project',
createdAt: '2026-03-02T00:00:00.000Z',
updatedAt: '2026-03-02T00:00:00.000Z',
lastActivityAt: '2026-03-02T00:00:00.000Z',
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: 'idle',
lastModeId: null,
config: null,
runtimeInfo: { provider: 'codex', sessionId: null },
persistence: null,
archivedAt: null,
})
await bootstrapWorkspaceRegistries({
paseoHome,
agentStorage,
projectRegistry,
workspaceRegistry,
logger,
})
expect(await projectRegistry.list()).toHaveLength(1)
expect(await workspaceRegistry.list()).toHaveLength(1)
expect((await workspaceRegistry.list())[0]?.workspaceId).toBe('/tmp/existing')
})
})

View File

@@ -0,0 +1,142 @@
import path from 'node:path'
import type { Logger } from 'pino'
import type { StoredAgentRecord } from './agent/agent-storage.js'
import type { AgentStorage } from './agent/agent-storage.js'
import {
buildProjectPlacementForCwd,
deriveProjectKind,
deriveProjectRootPath,
deriveWorkspaceDisplayName,
deriveWorkspaceKind,
normalizeWorkspaceId,
} from './workspace-registry-model.js'
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
type ProjectRegistry,
type WorkspaceRegistry,
} from './workspace-registry.js'
function minIsoDate(left: string | null, right: string | null): string | null {
if (!left) {
return right
}
if (!right) {
return left
}
return Date.parse(left) <= Date.parse(right) ? left : right
}
function maxIsoDate(left: string | null, right: string | null): string | null {
if (!left) {
return right
}
if (!right) {
return left
}
return Date.parse(left) >= Date.parse(right) ? left : right
}
function resolveAgentCreatedAt(record: StoredAgentRecord): string {
return record.createdAt || record.updatedAt || new Date(0).toISOString()
}
function resolveAgentUpdatedAt(record: StoredAgentRecord): string {
return record.lastActivityAt || record.updatedAt || record.createdAt || new Date(0).toISOString()
}
export async function bootstrapWorkspaceRegistries(options: {
paseoHome: string
agentStorage: AgentStorage
projectRegistry: ProjectRegistry
workspaceRegistry: WorkspaceRegistry
logger: Logger
}): Promise<void> {
const [projectsExists, workspacesExists] = await Promise.all([
options.projectRegistry.existsOnDisk(),
options.workspaceRegistry.existsOnDisk(),
])
await Promise.all([options.projectRegistry.initialize(), options.workspaceRegistry.initialize()])
if (projectsExists && workspacesExists) {
return
}
const records = await options.agentStorage.list()
const activeRecords = records.filter((record) => !record.archivedAt)
const recordsByWorkspaceId = new Map<string, StoredAgentRecord[]>()
for (const record of activeRecords) {
const workspaceId = normalizeWorkspaceId(record.cwd)
const existing = recordsByWorkspaceId.get(workspaceId) ?? []
existing.push(record)
recordsByWorkspaceId.set(workspaceId, existing)
}
const projectRanges = new Map<string, { createdAt: string | null; updatedAt: string | null }>()
for (const [workspaceId, workspaceRecords] of recordsByWorkspaceId.entries()) {
const placement = await buildProjectPlacementForCwd({
cwd: workspaceId,
paseoHome: options.paseoHome,
})
let workspaceCreatedAt: string | null = null
let workspaceUpdatedAt: string | null = null
for (const record of workspaceRecords) {
workspaceCreatedAt = minIsoDate(workspaceCreatedAt, resolveAgentCreatedAt(record))
workspaceUpdatedAt = maxIsoDate(workspaceUpdatedAt, resolveAgentUpdatedAt(record))
}
const createdAt = workspaceCreatedAt ?? new Date().toISOString()
const updatedAt = workspaceUpdatedAt ?? createdAt
await options.workspaceRegistry.upsert(
createPersistedWorkspaceRecord({
workspaceId,
projectId: placement.projectKey,
cwd: workspaceId,
kind: deriveWorkspaceKind(placement.checkout),
displayName: deriveWorkspaceDisplayName({
cwd: workspaceId,
checkout: placement.checkout,
}),
createdAt,
updatedAt,
})
)
const existingProjectRange = projectRanges.get(placement.projectKey) ?? {
createdAt: null,
updatedAt: null,
}
existingProjectRange.createdAt = minIsoDate(existingProjectRange.createdAt, createdAt)
existingProjectRange.updatedAt = maxIsoDate(existingProjectRange.updatedAt, updatedAt)
projectRanges.set(placement.projectKey, existingProjectRange)
await options.projectRegistry.upsert(
createPersistedProjectRecord({
projectId: placement.projectKey,
rootPath: deriveProjectRootPath({
cwd: workspaceId,
checkout: placement.checkout,
}),
kind: deriveProjectKind(placement.checkout),
displayName: placement.projectName,
createdAt: existingProjectRange.createdAt ?? createdAt,
updatedAt: existingProjectRange.updatedAt ?? updatedAt,
})
)
}
options.logger.info(
{
projectsFile: path.join(options.paseoHome, 'projects', 'projects.json'),
workspacesFile: path.join(options.paseoHome, 'projects', 'workspaces.json'),
materializedProjects: projectRanges.size,
materializedWorkspaces: recordsByWorkspaceId.size,
},
'Workspace registries bootstrapped from existing agent storage'
)
}

View File

@@ -0,0 +1,192 @@
import { resolve } from 'node:path'
import { getCheckoutStatusLite } from '../utils/checkout-git.js'
import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from '../shared/messages.js'
export type PersistedProjectKind = 'git' | 'non_git'
export type PersistedWorkspaceKind = 'local_checkout' | 'worktree' | 'directory'
export function normalizeWorkspaceId(cwd: string): string {
const trimmed = cwd.trim()
if (!trimmed) {
return cwd
}
return resolve(trimmed)
}
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
if (!remoteUrl) {
return null
}
const trimmed = remoteUrl.trim()
if (!trimmed) {
return null
}
let host: string | null = null
let remotePath: string | null = null
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/)
if (scpLike) {
host = scpLike[1] ?? null
remotePath = scpLike[2] ?? null
} else if (trimmed.includes('://')) {
try {
const parsed = new URL(trimmed)
host = parsed.hostname || null
remotePath = parsed.pathname ? parsed.pathname.replace(/^\/+/, '') : null
} catch {
return null
}
}
if (!host || !remotePath) {
return null
}
let cleanedPath = remotePath.trim().replace(/^\/+/, '').replace(/\/+$/, '')
if (cleanedPath.endsWith('.git')) {
cleanedPath = cleanedPath.slice(0, -4)
}
if (!cleanedPath.includes('/')) {
return null
}
const cleanedHost = host.toLowerCase()
if (cleanedHost === 'github.com') {
return `remote:github.com/${cleanedPath}`
}
return `remote:${cleanedHost}/${cleanedPath}`
}
export function deriveProjectGroupingKey(options: {
cwd: string
remoteUrl: string | null
isPaseoOwnedWorktree: boolean
mainRepoRoot: string | null
}): string {
const remoteKey = deriveRemoteProjectKey(options.remoteUrl)
if (remoteKey) {
return remoteKey
}
const mainRepoRoot = options.mainRepoRoot?.trim()
if (options.isPaseoOwnedWorktree && mainRepoRoot) {
return mainRepoRoot
}
return options.cwd
}
export function deriveProjectGroupingName(projectKey: string): string {
const githubRemotePrefix = 'remote:github.com/'
if (projectKey.startsWith(githubRemotePrefix)) {
return projectKey.slice(githubRemotePrefix.length) || projectKey
}
const segments = projectKey.split(/[\\/]/).filter(Boolean)
return segments[segments.length - 1] || projectKey
}
function deriveWorkspaceDirectoryName(cwd: string): string {
const normalized = cwd.replace(/\\/g, '/')
const segments = normalized.split('/').filter(Boolean)
return segments[segments.length - 1] ?? cwd
}
export function deriveWorkspaceDisplayName(input: {
cwd: string
checkout: ProjectCheckoutLitePayload
}): string {
const branch = input.checkout.currentBranch?.trim() ?? null
if (branch && branch.toUpperCase() !== 'HEAD') {
return branch
}
return deriveWorkspaceDirectoryName(input.cwd)
}
export function deriveProjectRootPath(input: {
cwd: string
checkout: ProjectCheckoutLitePayload
}): string {
if (input.checkout.isGit && input.checkout.isPaseoOwnedWorktree) {
return input.checkout.mainRepoRoot
}
return input.cwd
}
export function deriveProjectKind(checkout: ProjectCheckoutLitePayload): PersistedProjectKind {
return checkout.isGit ? 'git' : 'non_git'
}
export function deriveWorkspaceKind(checkout: ProjectCheckoutLitePayload): PersistedWorkspaceKind {
if (!checkout.isGit) {
return 'directory'
}
return checkout.isPaseoOwnedWorktree ? 'worktree' : 'local_checkout'
}
export async function buildProjectPlacementForCwd(input: {
cwd: string
paseoHome: string
}): Promise<ProjectPlacementPayload> {
const normalizedCwd = normalizeWorkspaceId(input.cwd)
const checkout = await getCheckoutStatusLite(normalizedCwd, { paseoHome: input.paseoHome })
.then((status): ProjectCheckoutLitePayload => {
if (!status.isGit) {
return {
cwd: normalizedCwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
}
}
if (status.isPaseoOwnedWorktree && status.mainRepoRoot) {
return {
cwd: normalizedCwd,
isGit: true,
currentBranch: status.currentBranch,
remoteUrl: status.remoteUrl,
isPaseoOwnedWorktree: true,
mainRepoRoot: status.mainRepoRoot,
}
}
return {
cwd: normalizedCwd,
isGit: true,
currentBranch: status.currentBranch,
remoteUrl: status.remoteUrl,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
}
})
.catch(
(): ProjectCheckoutLitePayload => ({
cwd: normalizedCwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
})
)
const projectKey = deriveProjectGroupingKey({
cwd: normalizedCwd,
remoteUrl: checkout.remoteUrl,
isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree,
mainRepoRoot: checkout.mainRepoRoot,
})
return {
projectKey,
projectName: deriveProjectGroupingName(projectKey),
checkout,
}
}

View File

@@ -0,0 +1,106 @@
import os from 'node:os'
import path from 'node:path'
import { mkdtempSync, rmSync } from 'node:fs'
import { beforeEach, afterEach, describe, expect, test } from 'vitest'
import { createTestLogger } from '../test-utils/test-logger.js'
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
FileBackedProjectRegistry,
FileBackedWorkspaceRegistry,
} from './workspace-registry.js'
describe('workspace registries', () => {
let tmpDir: string
let projectRegistry: FileBackedProjectRegistry
let workspaceRegistry: FileBackedWorkspaceRegistry
const logger = createTestLogger()
beforeEach(() => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'workspace-registry-'))
projectRegistry = new FileBackedProjectRegistry(
path.join(tmpDir, 'projects', 'projects.json'),
logger
)
workspaceRegistry = new FileBackedWorkspaceRegistry(
path.join(tmpDir, 'projects', 'workspaces.json'),
logger
)
})
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true })
})
test('creates, updates, archives, deletes, and lists project records', async () => {
await projectRegistry.initialize()
await projectRegistry.upsert(
createPersistedProjectRecord({
projectId: 'remote:github.com/acme/repo',
rootPath: '/tmp/repo',
kind: 'git',
displayName: 'acme/repo',
createdAt: '2026-03-01T00:00:00.000Z',
updatedAt: '2026-03-01T00:00:00.000Z',
})
)
await projectRegistry.upsert(
createPersistedProjectRecord({
projectId: 'remote:github.com/acme/repo',
rootPath: '/tmp/repo',
kind: 'git',
displayName: 'acme/repo',
createdAt: '2026-03-01T00:00:00.000Z',
updatedAt: '2026-03-02T00:00:00.000Z',
})
)
await projectRegistry.archive('remote:github.com/acme/repo', '2026-03-03T00:00:00.000Z')
const archived = await projectRegistry.get('remote:github.com/acme/repo')
expect(archived?.archivedAt).toBe('2026-03-03T00:00:00.000Z')
expect((await projectRegistry.list())).toHaveLength(1)
await projectRegistry.remove('remote:github.com/acme/repo')
expect(await projectRegistry.get('remote:github.com/acme/repo')).toBeNull()
expect(await projectRegistry.list()).toEqual([])
})
test('creates, updates, archives, deletes, and lists workspace records', async () => {
await workspaceRegistry.initialize()
await workspaceRegistry.upsert(
createPersistedWorkspaceRecord({
workspaceId: '/tmp/repo',
projectId: 'remote:github.com/acme/repo',
cwd: '/tmp/repo',
kind: 'local_checkout',
displayName: 'main',
createdAt: '2026-03-01T00:00:00.000Z',
updatedAt: '2026-03-01T00:00:00.000Z',
})
)
await workspaceRegistry.upsert(
createPersistedWorkspaceRecord({
workspaceId: '/tmp/repo',
projectId: 'remote:github.com/acme/repo',
cwd: '/tmp/repo',
kind: 'local_checkout',
displayName: 'feature/workspace',
createdAt: '2026-03-01T00:00:00.000Z',
updatedAt: '2026-03-02T00:00:00.000Z',
})
)
await workspaceRegistry.archive('/tmp/repo', '2026-03-03T00:00:00.000Z')
const archived = await workspaceRegistry.get('/tmp/repo')
expect(archived?.displayName).toBe('feature/workspace')
expect(archived?.archivedAt).toBe('2026-03-03T00:00:00.000Z')
await workspaceRegistry.remove('/tmp/repo')
expect(await workspaceRegistry.get('/tmp/repo')).toBeNull()
expect(await workspaceRegistry.list()).toEqual([])
})
})

View File

@@ -0,0 +1,221 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
import type { Logger } from 'pino'
import { z } from 'zod'
import type {
PersistedProjectKind,
PersistedWorkspaceKind,
} from './workspace-registry-model.js'
const PersistedProjectRecordSchema = z.object({
projectId: z.string(),
rootPath: z.string(),
kind: z.enum(['git', 'non_git']),
displayName: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
archivedAt: z.string().nullable(),
})
const PersistedWorkspaceRecordSchema = z.object({
workspaceId: z.string(),
projectId: z.string(),
cwd: z.string(),
kind: z.enum(['local_checkout', 'worktree', 'directory']),
displayName: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
archivedAt: z.string().nullable(),
})
export type PersistedProjectRecord = z.infer<typeof PersistedProjectRecordSchema>
export type PersistedWorkspaceRecord = z.infer<typeof PersistedWorkspaceRecordSchema>
export interface ProjectRegistry {
initialize(): Promise<void>
existsOnDisk(): Promise<boolean>
list(): Promise<PersistedProjectRecord[]>
get(projectId: string): Promise<PersistedProjectRecord | null>
upsert(record: PersistedProjectRecord): Promise<void>
archive(projectId: string, archivedAt: string): Promise<void>
remove(projectId: string): Promise<void>
}
export interface WorkspaceRegistry {
initialize(): Promise<void>
existsOnDisk(): Promise<boolean>
list(): Promise<PersistedWorkspaceRecord[]>
get(workspaceId: string): Promise<PersistedWorkspaceRecord | null>
upsert(record: PersistedWorkspaceRecord): Promise<void>
archive(workspaceId: string, archivedAt: string): Promise<void>
remove(workspaceId: string): Promise<void>
}
type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord
class FileBackedRegistry<TRecord extends RegistryRecord> {
private readonly filePath: string
private readonly logger: Logger
private readonly schema: z.ZodSchema<TRecord>
private readonly getId: (record: TRecord) => string
private loaded = false
private readonly cache = new Map<string, TRecord>()
constructor(options: {
filePath: string
logger: Logger
schema: z.ZodSchema<TRecord>
getId: (record: TRecord) => string
component: string
}) {
this.filePath = options.filePath
this.schema = options.schema
this.getId = options.getId
this.logger = options.logger.child({ module: 'workspace-registry', component: options.component })
}
async initialize(): Promise<void> {
await this.load()
}
async existsOnDisk(): Promise<boolean> {
try {
await fs.access(this.filePath)
return true
} catch {
return false
}
}
async list(): Promise<TRecord[]> {
await this.load()
return Array.from(this.cache.values())
}
async get(id: string): Promise<TRecord | null> {
await this.load()
return this.cache.get(id) ?? null
}
async upsert(record: TRecord): Promise<void> {
await this.load()
const parsed = this.schema.parse(record)
this.cache.set(this.getId(parsed), parsed)
await this.persist()
}
async archive(id: string, archivedAt: string): Promise<void> {
await this.load()
const existing = this.cache.get(id)
if (!existing) {
return
}
const next = this.schema.parse({
...existing,
updatedAt: archivedAt,
archivedAt,
})
this.cache.set(id, next)
await this.persist()
}
async remove(id: string): Promise<void> {
await this.load()
if (!this.cache.delete(id)) {
return
}
await this.persist()
}
private async load(): Promise<void> {
if (this.loaded) {
return
}
this.cache.clear()
try {
const raw = await fs.readFile(this.filePath, 'utf8')
const parsed = z.array(this.schema).parse(JSON.parse(raw))
for (const record of parsed) {
this.cache.set(this.getId(record), record)
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== 'ENOENT') {
this.logger.error({ err: error, filePath: this.filePath }, 'Failed to load registry file')
}
}
this.loaded = true
}
private async persist(): Promise<void> {
const records = Array.from(this.cache.values())
await fs.mkdir(path.dirname(this.filePath), { recursive: true })
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`
await fs.writeFile(tempPath, JSON.stringify(records, null, 2), 'utf8')
await fs.rename(tempPath, this.filePath)
}
}
export class FileBackedProjectRegistry
extends FileBackedRegistry<PersistedProjectRecord>
implements ProjectRegistry
{
constructor(filePath: string, logger: Logger) {
super({
filePath,
logger,
schema: PersistedProjectRecordSchema,
getId: (record) => record.projectId,
component: 'projects',
})
}
}
export class FileBackedWorkspaceRegistry
extends FileBackedRegistry<PersistedWorkspaceRecord>
implements WorkspaceRegistry
{
constructor(filePath: string, logger: Logger) {
super({
filePath,
logger,
schema: PersistedWorkspaceRecordSchema,
getId: (record) => record.workspaceId,
component: 'workspaces',
})
}
}
export function createPersistedProjectRecord(input: {
projectId: string
rootPath: string
kind: PersistedProjectKind
displayName: string
createdAt: string
updatedAt: string
archivedAt?: string | null
}): PersistedProjectRecord {
return PersistedProjectRecordSchema.parse({
...input,
archivedAt: input.archivedAt ?? null,
})
}
export function createPersistedWorkspaceRecord(input: {
workspaceId: string
projectId: string
cwd: string
kind: PersistedWorkspaceKind
displayName: string
createdAt: string
updatedAt: string
archivedAt?: string | null
}): PersistedWorkspaceRecord {
return PersistedWorkspaceRecordSchema.parse({
...input,
archivedAt: input.archivedAt ?? null,
})
}

View File

@@ -910,6 +910,18 @@ export const PaseoWorktreeArchiveRequestSchema = z.object({
requestId: z.string(),
})
export const OpenProjectRequestSchema = z.object({
type: z.literal('open_project_request'),
cwd: z.string(),
requestId: z.string(),
})
export const ArchiveWorkspaceRequestSchema = z.object({
type: z.literal('archive_workspace_request'),
workspaceId: z.string(),
requestId: z.string(),
})
// Highlighted diff token schema
// Note: style can be a compound class name (e.g., "heading meta") from the syntax highlighter
const HighlightTokenSchema = z.object({
@@ -1148,6 +1160,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion('type', [
DirectorySuggestionsRequestSchema,
PaseoWorktreeListRequestSchema,
PaseoWorktreeArchiveRequestSchema,
OpenProjectRequestSchema,
ArchiveWorkspaceRequestSchema,
FileExplorerRequestSchema,
ProjectIconRequestSchema,
FileDownloadTokenRequestSchema,
@@ -1469,6 +1483,10 @@ export const ProjectPlacementPayloadSchema = z.object({
export const WorkspaceDescriptorPayloadSchema = z.object({
id: z.string(),
projectId: z.string(),
projectDisplayName: z.string(),
projectRootPath: z.string(),
projectKind: z.enum(['git', 'non_git']),
workspaceKind: z.enum(['local_checkout', 'worktree', 'directory']),
name: z.string(),
status: WorkspaceStateBucketSchema,
activityAt: z.string().nullable(),
@@ -1564,6 +1582,25 @@ export const WorkspaceUpdateMessageSchema = z.object({
]),
})
export const OpenProjectResponseMessageSchema = z.object({
type: z.literal('open_project_response'),
payload: z.object({
requestId: z.string(),
workspace: WorkspaceDescriptorPayloadSchema.nullable(),
error: z.string().nullable(),
}),
})
export const ArchiveWorkspaceResponseMessageSchema = z.object({
type: z.literal('archive_workspace_response'),
payload: z.object({
requestId: z.string(),
workspaceId: z.string(),
archivedAt: z.string().nullable(),
error: z.string().nullable(),
}),
})
export const FetchAgentResponseMessageSchema = z.object({
type: z.literal('fetch_agent_response'),
payload: z.object({
@@ -2133,6 +2170,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion('type', [
AgentStatusMessageSchema,
FetchAgentsResponseMessageSchema,
FetchWorkspacesResponseMessageSchema,
OpenProjectResponseMessageSchema,
ArchiveWorkspaceResponseMessageSchema,
FetchAgentResponseMessageSchema,
FetchAgentTimelineResponseMessageSchema,
SendAgentMessageResponseMessageSchema,
@@ -2202,6 +2241,8 @@ export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema>
export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPayloadSchema>
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>
export type ArchiveWorkspaceResponseMessage = z.infer<typeof ArchiveWorkspaceResponseMessageSchema>
export type FetchAgentResponseMessage = z.infer<typeof FetchAgentResponseMessageSchema>
export type FetchAgentTimelineResponseMessage = z.infer<
typeof FetchAgentTimelineResponseMessageSchema
@@ -2278,6 +2319,8 @@ export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSc
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>

View File

@@ -22,6 +22,16 @@ describe('workspace message schemas', () => {
expect(parsed.type).toBe('fetch_workspaces_request')
})
test('parses open_project_request', () => {
const parsed = SessionInboundMessageSchema.parse({
type: 'open_project_request',
cwd: '/tmp/repo',
requestId: 'req-open',
})
expect(parsed.type).toBe('open_project_request')
})
test('rejects invalid workspace update payload', () => {
const result = SessionOutboundMessageSchema.safeParse({
type: 'workspace_update',
@@ -30,6 +40,10 @@ describe('workspace message schemas', () => {
workspace: {
id: '/repo',
projectId: '/repo',
projectDisplayName: 'repo',
projectRootPath: '/repo',
projectKind: 'non_git',
workspaceKind: 'directory',
name: '',
status: 'not-a-bucket',
activityAt: null,