diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 66062621..5b3c71d1 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -17,12 +17,27 @@ ChatGPT should call `open_workspace` once for a project folder: The result includes a `workspaceId`. All later file, search, edit, show-changes, and shell calls should reuse that same `workspaceId`. -Do not reopen the same folder unless: +ChatGPT sends an anonymized conversation identifier in +`_meta["openai/session"]`. DevSpace uses that value only as a correlation scope: +if checkout mode is called again for the same canonical project path in the same +ChatGPT conversation, DevSpace returns the existing checkout `workspaceId`. +Worktree mode is deliberately different: every call creates a new managed +worktree and a new workspace session, even for the same path and base ref. + +Project bootstrap delivery is tracked separately from workspace reuse. The first +open for a canonical project path in a ChatGPT conversation returns project +instructions, skills, subagent metadata, and diagnostics. Later checkout or +worktree opens for that project omit those fields from the model response, even +when a new worktree workspace is created. This state is persisted across MCP +reconnects and DevSpace restarts. The workspace card still receives the complete +hidden display payload, so every call renders full workspace details without +adding the bootstrap fields to the model transcript again. + +Do not reopen the same checkout folder unless: - the `workspaceId` is rejected as unknown - the user switches to another folder -- the user switches between checkout and worktree mode -- the user explicitly asks to reopen +- the user asks for a new isolated worktree ## Checkout Mode @@ -56,6 +71,11 @@ Managed worktrees are created under: Worktree mode requires a Git repository with at least one commit. It starts from `HEAD` unless `baseRef` is provided. +Each worktree-mode call creates a new managed worktree and returns a new +`workspaceId`. Reuse that ID for work inside that worktree; call +`open_workspace` in worktree mode again only when another isolated worktree is +actually required. + Uncommitted source checkout changes are not copied into the managed worktree. DevSpace reports when the source checkout was dirty so the model can decide how to proceed with the user. diff --git a/package.json b/package.json index 4489360e..cb8f4618 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 2bda20c2..47887eb5 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -22,6 +22,16 @@ const migrations: Migration[] = [ name: "local-agent-sessions", up: migrateLocalAgentSessions, }, + { + version: 4, + name: "workspace-conversation-bindings", + up: migrateWorkspaceConversationBindings, + }, + { + version: 5, + name: "workspace-conversation-bootstraps", + up: migrateWorkspaceConversationBootstraps, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -174,6 +184,76 @@ function migrateLocalAgentSessions(sqlite: Database.Database): void { addColumnIfMissing(sqlite, "local_agent_sessions", "thinking", "text"); } +function migrateWorkspaceConversationBindings(sqlite: Database.Database): void { + sqlite.exec(` + create table if not exists workspace_conversation_bindings ( + conversation_scope_hash text not null, + target_key text not null, + workspace_session_id text not null, + created_at text not null, + last_used_at text not null, + primary key (conversation_scope_hash, target_key), + foreign key (workspace_session_id) + references workspace_sessions(id) + on delete cascade + ); + + create index if not exists workspace_conversation_bindings_workspace_idx + on workspace_conversation_bindings(workspace_session_id); + `); +} + +function migrateWorkspaceConversationBootstraps(sqlite: Database.Database): void { + sqlite.exec(` + create table if not exists workspace_conversation_bootstraps ( + conversation_scope_hash text not null, + project_key text not null, + created_at text not null, + last_used_at text not null, + primary key (conversation_scope_hash, project_key) + ); + `); + + const bindings = sqlite.prepare(` + select conversation_scope_hash, target_key, created_at, last_used_at + from workspace_conversation_bindings + `).all() as Array<{ + conversation_scope_hash: string; + target_key: string; + created_at: string; + last_used_at: string; + }>; + const insertBootstrap = sqlite.prepare(` + insert or ignore into workspace_conversation_bootstraps ( + conversation_scope_hash, + project_key, + created_at, + last_used_at + ) values (?, ?, ?, ?) + `); + + for (const binding of bindings) { + const projectKey = projectKeyFromConversationTarget(binding.target_key); + if (!projectKey) continue; + insertBootstrap.run( + binding.conversation_scope_hash, + projectKey, + binding.created_at, + binding.last_used_at, + ); + } +} + +function projectKeyFromConversationTarget(targetKey: string): string | undefined { + try { + const parsed = JSON.parse(targetKey) as unknown; + if (!Array.isArray(parsed)) return undefined; + return typeof parsed[1] === "string" && parsed[1].length > 0 ? parsed[1] : undefined; + } catch { + return undefined; + } +} + function addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions" | "local_agent_sessions", diff --git a/src/db/schema.ts b/src/db/schema.ts index 01d13fac..7d557e97 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -38,6 +38,36 @@ export const loadedAgentFiles = sqliteTable( ], ); +export const workspaceConversationBindings = sqliteTable( + "workspace_conversation_bindings", + { + conversationScopeHash: text("conversation_scope_hash").notNull(), + targetKey: text("target_key").notNull(), + workspaceSessionId: text("workspace_session_id") + .notNull() + .references(() => workspaceSessions.id, { onDelete: "cascade" }), + createdAt: text("created_at").notNull(), + lastUsedAt: text("last_used_at").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.conversationScopeHash, table.targetKey] }), + index("workspace_conversation_bindings_workspace_idx").on(table.workspaceSessionId), + ], +); + +export const workspaceConversationBootstraps = sqliteTable( + "workspace_conversation_bootstraps", + { + conversationScopeHash: text("conversation_scope_hash").notNull(), + projectKey: text("project_key").notNull(), + createdAt: text("created_at").notNull(), + lastUsedAt: text("last_used_at").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.conversationScopeHash, table.projectKey] }), + ], +); + export const oauthClients = sqliteTable( "oauth_clients", { @@ -101,5 +131,9 @@ export type WorkspaceSessionRow = typeof workspaceSessions.$inferSelect; export type NewWorkspaceSessionRow = typeof workspaceSessions.$inferInsert; export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert; +export type WorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferSelect; +export type NewWorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferInsert; +export type WorkspaceConversationBootstrapRow = typeof workspaceConversationBootstraps.$inferSelect; +export type NewWorkspaceConversationBootstrapRow = typeof workspaceConversationBootstraps.$inferInsert; export type LocalAgentSessionRow = typeof localAgentSessions.$inferSelect; export type NewLocalAgentSessionRow = typeof localAgentSessions.$inferInsert; diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index e1c00338..2aadaf1e 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -21,6 +21,7 @@ const redirectUri = "https://chatgpt.com/connector_platform_oauth_redirect"; try { await testDatabaseConfiguration(join(root, "database-configuration")); + testConversationBootstrapMigration(join(root, "bootstrap-migration")); testPersistenceAndTokenHashing(join(root, "persistence")); testExpiredTokenCleanup(join(root, "expiration")); testTransactionalTokenRotation(join(root, "rotation")); @@ -29,6 +30,52 @@ try { await rm(root, { recursive: true, force: true }); } +function testConversationBootstrapMigration(stateDir: string): void { + const initial = openDatabase(stateDir); + try { + initial.sqlite.prepare(` + insert into workspace_sessions ( + id, root, status, mode, managed, created_at, last_used_at + ) values (?, ?, 'active', 'worktree', 'true', ?, ?) + `).run("ws_existing", "/tmp/project-worktree", "2026-01-01T00:00:00.000Z", "2026-01-02T00:00:00.000Z"); + initial.sqlite.prepare(` + insert into workspace_conversation_bindings ( + conversation_scope_hash, target_key, workspace_session_id, created_at, last_used_at + ) values (?, ?, ?, ?, ?) + `).run( + "chat-existing", + JSON.stringify(["worktree", "/tmp/project", "HEAD"]), + "ws_existing", + "2026-01-01T00:00:00.000Z", + "2026-01-02T00:00:00.000Z", + ); + initial.sqlite.exec(` + drop table workspace_conversation_bootstraps; + delete from devspace_schema_migrations where version = 5; + `); + } finally { + initial.close(); + } + + const migrated = openDatabase(stateDir); + try { + assert.deepEqual( + migrated.sqlite.prepare(` + select conversation_scope_hash, project_key, created_at, last_used_at + from workspace_conversation_bootstraps + `).all(), + [{ + conversation_scope_hash: "chat-existing", + project_key: "/tmp/project", + created_at: "2026-01-01T00:00:00.000Z", + last_used_at: "2026-01-02T00:00:00.000Z", + }], + ); + } finally { + migrated.close(); + } +} + async function testDatabaseConfiguration(stateDir: string): Promise { const database = openDatabase(stateDir); try { @@ -44,6 +91,8 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 1, name: "workspace-state" }, { version: 2, name: "oauth-state" }, { version: 3, name: "local-agent-sessions" }, + { version: 4, name: "workspace-conversation-bindings" }, + { version: 5, name: "workspace-conversation-bootstraps" }, ]); } finally { database.close(); diff --git a/src/request-meta.test.ts b/src/request-meta.test.ts new file mode 100644 index 00000000..de63e61f --- /dev/null +++ b/src/request-meta.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { openAiConversationScopeHash } from "./request-meta.js"; + +assert.equal(openAiConversationScopeHash(undefined), undefined); +assert.equal(openAiConversationScopeHash({}), undefined); +assert.equal(openAiConversationScopeHash({ "openai/session": "" }), undefined); + +const sessionOnly = openAiConversationScopeHash({ "openai/session": "chat-1" }); +assert.match(sessionOnly ?? "", /^[a-f0-9]{64}$/); +assert.equal( + sessionOnly, + openAiConversationScopeHash({ "openai/session": "chat-1" }), +); +assert.notEqual( + sessionOnly, + openAiConversationScopeHash({ "openai/session": "chat-2" }), +); + +assert.equal( + sessionOnly, + openAiConversationScopeHash({ + "openai/session": "chat-1", + "openai/subject": "user-1", + "openai/organization": "org-1", + }), +); diff --git a/src/request-meta.ts b/src/request-meta.ts new file mode 100644 index 00000000..4b17f6c2 --- /dev/null +++ b/src/request-meta.ts @@ -0,0 +1,20 @@ +import { createHash } from "node:crypto"; + +function metadataString( + meta: Record | undefined, + key: string, +): string | undefined { + const value = meta?.[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function openAiConversationScopeHash( + meta: Record | undefined, +): string | undefined { + const session = metadataString(meta, "openai/session"); + if (!session) return undefined; + + return createHash("sha256") + .update(JSON.stringify(["openai", session])) + .digest("hex"); +} diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index 3ec4676a..227edd0d 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -40,6 +40,16 @@ try { assert.equal(firstReview.files.some((file) => file.path === "new.txt"), true); assert.match(firstReview.patch, /world/); + const restartedManager = createReviewCheckpointManager(); + await restartedManager.initializeWorkspace({ workspaceId: "ws_review", root }); + const afterRestart = await restartedManager.reviewChanges({ + workspaceId: "ws_review", + root, + markReviewed: false, + }); + assert.equal(afterRestart.summary.files, 2); + assert.match(afterRestart.patch, /world/); + const stillUnreviewed = await manager.reviewChanges({ workspaceId: "ws_review", root, diff --git a/src/review-checkpoints.ts b/src/review-checkpoints.ts index eaa04dfa..07ef212f 100644 --- a/src/review-checkpoints.ts +++ b/src/review-checkpoints.ts @@ -48,26 +48,32 @@ const REVIEW_REF_PREFIX = "refs/devspace/review"; export function createReviewCheckpointManager(): ReviewCheckpointManager { const states = new Map(); + const initializations = new Map>(); return { async initializeWorkspace({ workspaceId, root }) { - const refs = reviewRefs(workspaceId); - const state: WorkspaceReviewState = { root, ...refs }; - states.set(workspaceId, state); + const existingState = states.get(workspaceId); + if ( + existingState?.root === root && + (existingState.gitRoot !== undefined || existingState.diagnostic !== undefined) + ) { + return; + } + + const pending = initializations.get(workspaceId); + if (pending) { + await pending; + return; + } + const initialize = initializeWorkspaceState(states, workspaceId, root); + initializations.set(workspaceId, initialize); try { - const eligibility = await getGitEligibility(root); - if (!eligibility.ok || !eligibility.gitRoot) { - state.diagnostic = eligibility.message ?? "show_changes requires a Git workspace in this version."; - return; + await initialize; + } finally { + if (initializations.get(workspaceId) === initialize) { + initializations.delete(workspaceId); } - - state.gitRoot = eligibility.gitRoot; - const commit = await createWorkingTreeSnapshot(eligibility.gitRoot); - await git(eligibility.gitRoot, ["update-ref", state.openRef, commit]); - await git(eligibility.gitRoot, ["update-ref", state.baselineRef, commit]); - } catch (error) { - state.diagnostic = error instanceof Error ? error.message : String(error); } }, @@ -111,6 +117,50 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { }; } +async function initializeWorkspaceState( + states: Map, + workspaceId: string, + root: string, +): Promise { + const refs = reviewRefs(workspaceId); + const state: WorkspaceReviewState = { root, ...refs }; + states.set(workspaceId, state); + + try { + const eligibility = await getGitEligibility(root); + if (!eligibility.ok || !eligibility.gitRoot) { + state.diagnostic = eligibility.message ?? "show_changes requires a Git workspace in this version."; + return; + } + + state.gitRoot = eligibility.gitRoot; + const [hasOpenRef, hasBaselineRef] = await Promise.all([ + hasCommitRef(eligibility.gitRoot, state.openRef), + hasCommitRef(eligibility.gitRoot, state.baselineRef), + ]); + if (hasOpenRef && hasBaselineRef) return; + + const commit = await createWorkingTreeSnapshot(eligibility.gitRoot); + if (!hasOpenRef) { + await git(eligibility.gitRoot, ["update-ref", state.openRef, commit]); + } + if (!hasBaselineRef) { + await git(eligibility.gitRoot, ["update-ref", state.baselineRef, commit]); + } + } catch (error) { + state.diagnostic = error instanceof Error ? error.message : String(error); + } +} + +async function hasCommitRef(gitRoot: string, ref: string): Promise { + try { + await git(gitRoot, ["rev-parse", "--verify", `${ref}^{commit}`]); + return true; + } catch { + return false; + } +} + function reviewRefs(workspaceId: string): Pick { const segment = safeWorkspaceRefSegment(workspaceId); return { diff --git a/src/server.ts b/src/server.ts index 91f5df61..f1930e82 100644 --- a/src/server.ts +++ b/src/server.ts @@ -50,6 +50,7 @@ import { } from "./mcp-sessions.js"; import { ProcessSessionManager, type ProcessSnapshot } from "./process-sessions.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; +import { openAiConversationScopeHash } from "./request-meta.js"; import { shutdownHttpServer } from "./server-shutdown.js"; import { formatPathForPrompt } from "./skills.js"; import { createWorkspaceStore } from "./workspace-store.js"; @@ -751,7 +752,7 @@ function createMcpServer( { title: "Open workspace", description: - "Open a local project directory as a coding workspace. Call this once per project folder or worktree before reading, editing, searching, writing, showing changes, or running commands. Reuse the returned workspaceId for later calls in the same folder; do not call open_workspace again unless switching folders/worktrees, changing checkout/worktree mode, the workspaceId is rejected as unknown, or the user explicitly asks to reopen. By default this opens the actual checkout; set mode=\"worktree\" when the user asks for an isolated or parallel coding session. Returns a workspaceId, loaded root project instructions, and nested instruction file paths the model should read before working in those directories.", + "Open a local project directory as a coding workspace. Call this before reading, editing, searching, writing, showing changes, or running commands, then reuse the returned workspaceId. In ChatGPT, checkout mode reuses the existing checkout workspace for the same project and conversation. Every worktree-mode call creates a new managed worktree and workspace. After the first open for a project in one ChatGPT conversation, later opens omit bootstrap details already returned. By default this opens the actual checkout; set mode=\"worktree\" when the user asks for a new isolated or parallel coding session.", inputSchema: { path: z .string() @@ -762,7 +763,7 @@ function createMcpServer( .enum(["checkout", "worktree"]) .optional() .describe( - "Defaults to checkout. Use checkout to work in the actual directory. Use worktree to create an isolated managed Git worktree for parallel work.", + "Defaults to checkout. Use checkout to work in the actual directory. Each worktree-mode call creates a new isolated managed Git worktree and workspace for parallel work.", ), baseRef: z .string() @@ -784,60 +785,83 @@ function createMcpServer( managed: z.boolean(), }) .optional(), - agentsFiles: z.array(workspaceAgentsFileOutputSchema), - availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema), - skills: z.array(workspaceSkillOutputSchema), - agentProviders: z.array(workspaceLocalAgentProviderOutputSchema), - agents: z.array(workspaceLocalAgentOutputSchema), - skillDiagnostics: z.array(z.unknown()), + agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(), + availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema).optional(), + skills: z.array(workspaceSkillOutputSchema).optional(), + agentProviders: z.array(workspaceLocalAgentProviderOutputSchema).optional(), + agents: z.array(workspaceLocalAgentOutputSchema).optional(), + skillDiagnostics: z.array(z.unknown()).optional(), instruction: z.string(), }, ...toolWidgetDescriptorMeta(config, "workspace"), annotations: { readOnlyHint: true }, }, - async ({ path, mode, baseRef }) => { + async ({ path, mode, baseRef }, { _meta }) => { const startedAt = performance.now(); - const { workspace, agentsFiles, availableAgentsFiles } = await workspaces.openWorkspace({ path, mode, baseRef }); + const { + workspace, + agentsFiles, + availableAgentsFiles, + workspaceReused, + includeBootstrapContext, + } = await workspaces.openWorkspace( + { path, mode, baseRef }, + { conversationScopeHash: openAiConversationScopeHash(_meta) }, + ); + const bootstrapOmitted = !includeBootstrapContext; if (config.widgets === "changes") { - void reviewCheckpoints.initializeWorkspace({ + await reviewCheckpoints.initializeWorkspace({ workspaceId: workspace.id, root: workspace.root, }); } - const visibleSkills = workspace.skills + const cardSkills = workspace.skills .filter((skill) => !skill.disableModelInvocation) .map((skill) => ({ name: skill.name, description: skill.description, path: formatPathForPrompt(skill.filePath), })); - const visibleAgentProviders = config.subagents ? localAgentProviders : []; - const visibleAgents = workspace.agentProfiles.map((profile) => { + const cardAgentProviders = config.subagents ? localAgentProviders : []; + const cardAgents = workspace.agentProfiles.map((profile) => { const summary = summarizeLocalAgentProfile(profile); - const availability = visibleAgentProviders.find((provider) => provider.name === summary.provider); + const availability = cardAgentProviders.find((provider) => provider.name === summary.provider); return { ...summary, providerAvailable: availability?.available, providerUnavailableReason: availability?.reason, }; }); - const loadedAgentsFiles = agentsFiles.map((file) => ({ + const cardAgentsFiles = agentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), content: file.content, })); - const availableAgentsFileOutputs = availableAgentsFiles.map((file) => ({ + const cardAvailableAgentsFiles = availableAgentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), })); - const instruction = config.skillsEnabled + const visibleSkills = includeBootstrapContext ? cardSkills : []; + const visibleAgentProviders = includeBootstrapContext ? cardAgentProviders : []; + const visibleAgents = includeBootstrapContext ? cardAgents : []; + const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : []; + const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : []; + const cardInstruction = config.skillsEnabled ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; + const instruction = includeBootstrapContext + ? cardInstruction + : workspaceReused + ? "Reuse this workspaceId for subsequent tool calls. Project instructions, nested instruction paths, skills, subagent metadata, and diagnostics for this project were already returned earlier in this ChatGPT conversation and are intentionally omitted here." + : "Use this new workspaceId for subsequent tool calls. Project instructions, nested instruction paths, skills, subagent metadata, and diagnostics for this project were already returned earlier in this ChatGPT conversation and are intentionally omitted here."; const resultContent: ToolContent[] = [ { type: "text" as const, text: [ - `Opened workspace ${workspace.id}`, + `${workspaceReused ? "Workspace already open as" : "Opened workspace"} ${workspace.id}`, `Root: ${workspace.root}`, `Mode: ${workspace.mode}`, + bootstrapOmitted + ? "Project bootstrap details omitted because they were already returned for this project in this ChatGPT conversation." + : undefined, loadedAgentsFiles.length > 0 ? `Loaded project instructions: ${loadedAgentsFiles.map((file) => file.path).join(", ")}` : undefined, @@ -876,13 +900,23 @@ function createMcpServer( workspaceId: workspace.id, root: workspace.root, path: workspace.root, + mode: workspace.mode, + sourceRoot: workspace.sourceRoot, + worktree: workspace.worktree, + agentsFiles: cardAgentsFiles, + availableAgentsFiles: cardAvailableAgentsFiles, + skills: cardSkills, + agentProviders: cardAgentProviders, + agents: cardAgents, + skillDiagnostics: workspace.skillDiagnostics, + instruction: cardInstruction, summary: { mode: workspace.mode, - agentsFiles: loadedAgentsFiles.length, - availableAgentsFiles: availableAgentsFileOutputs.length, - skills: visibleSkills.length, - agentProviders: visibleAgentProviders.length, - agents: visibleAgents.length, + agentsFiles: cardAgentsFiles.length, + availableAgentsFiles: cardAvailableAgentsFiles.length, + skills: cardSkills.length, + agentProviders: cardAgentProviders.length, + agents: cardAgents.length, skillDiagnostics: workspace.skillDiagnostics.length, }, }, @@ -893,12 +927,16 @@ function createMcpServer( mode: workspace.mode, sourceRoot: workspace.sourceRoot, worktree: workspace.worktree, - agentsFiles: loadedAgentsFiles, - availableAgentsFiles: availableAgentsFileOutputs, - skills: visibleSkills, - agentProviders: visibleAgentProviders, - agents: visibleAgents, - skillDiagnostics: workspace.skillDiagnostics, + ...(includeBootstrapContext + ? { + agentsFiles: loadedAgentsFiles, + availableAgentsFiles: availableAgentsFileOutputs, + skills: visibleSkills, + agentProviders: visibleAgentProviders, + agents: visibleAgents, + skillDiagnostics: workspace.skillDiagnostics, + } + : {}), instruction, }, }; diff --git a/src/ui/card-types.test.ts b/src/ui/card-types.test.ts index eb47e9a0..4e0f2263 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -23,3 +23,18 @@ assert.equal( true, ); assert.equal(isExpandableCard({ tool: "apply_patch" }), false); + +assert.equal( + isExpandableCard({ + tool: "open_workspace", + agentProviders: [{ name: "codex", available: true }], + }), + true, +); +assert.equal( + isExpandableCard({ + tool: "open_workspace", + agents: [{ name: "reviewer", provider: "codex" }], + }), + true, +); diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 1e3c9409..cb3ab0fa 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -23,6 +23,16 @@ export interface ToolResultCard { workspaceId?: string; path?: string; root?: string; + mode?: "checkout" | "worktree"; + sourceRoot?: string; + worktree?: { + path?: string; + baseRef?: string; + baseSha?: string; + dirtySource?: boolean; + detached?: boolean; + managed?: boolean; + }; status?: string; summary?: Record; files?: Array<{ @@ -46,6 +56,20 @@ export interface ToolResultCard { description?: string; path?: string; }>; + agentProviders?: Array<{ + name?: string; + available?: boolean; + reason?: string; + }>; + agents?: Array<{ + name?: string; + description?: string; + provider?: string; + model?: string; + thinking?: string; + providerAvailable?: boolean; + providerUnavailableReason?: string; + }>; skillDiagnostics?: unknown[]; instruction?: string; } @@ -137,10 +161,16 @@ export function isExpandableCard(card: ToolResultCard): boolean { return ( Number(card.summary?.agentsFiles ?? 0) > 0 || Number(card.summary?.skills ?? 0) > 0 || + Number(card.summary?.agentProviders ?? 0) > 0 || + Number(card.summary?.agents ?? 0) > 0 || Number(card.summary?.skillDiagnostics ?? 0) > 0 || Boolean(card.agentsFiles?.length) || Boolean(card.availableAgentsFiles?.length) || Boolean(card.skills?.length) || + Boolean(card.agentProviders?.length) || + Boolean(card.agents?.length) || + Boolean(card.worktree) || + Boolean(card.instruction) || Boolean(card.skillDiagnostics?.length) ); } diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 78723864..cfeec817 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -449,23 +449,80 @@ function workspacePayloadText(card: ToolResultCard): string { const agentsFiles = card.agentsFiles ?? []; const availableAgentsFiles = card.availableAgentsFiles ?? []; const skills = card.skills ?? []; + const agentProviders = card.agentProviders ?? []; + const agents = card.agents ?? []; + const diagnostics = card.skillDiagnostics ?? []; const lines = [ card.workspaceId ? `Workspace: ${card.workspaceId}` : undefined, card.root ? `Root: ${card.root}` : undefined, + card.mode ? `Mode: ${card.mode}` : undefined, + card.sourceRoot ? `Source root: ${card.sourceRoot}` : undefined, + card.worktree ? formatWorktree(card.worktree) : undefined, skills.length > 0 ? `Skills: ${skills.map((skill) => skill.name ?? skill.path ?? "unnamed").join(", ")}` : "Skills: none", + agentProviders.length > 0 + ? `Agent providers: ${agentProviders.map(formatAgentProvider).join(", ")}` + : undefined, + agents.length > 0 + ? `Agents: ${agents.map(formatAgent).join(", ")}` + : undefined, + diagnostics.length > 0 + ? `Skill diagnostics: ${diagnostics.map(formatDiagnostic).join("; ")}` + : undefined, availableAgentsFiles.length > 0 ? `Nested instructions: ${availableAgentsFiles.map((file) => file.path ?? "unknown").join(", ")}` : undefined, agentsFiles.length > 0 ? `\n${formatAgentsFilesForPayload(agentsFiles)}` : "\nAGENTS.md: none loaded", + card.instruction ? `\nInstruction: ${card.instruction}` : undefined, ].filter((line): line is string => typeof line === "string"); return lines.join("\n"); } +function formatWorktree(worktree: NonNullable): string { + const details = [ + worktree.baseRef ? `base ${worktree.baseRef}` : undefined, + worktree.baseSha ? `at ${worktree.baseSha.slice(0, 12)}` : undefined, + worktree.managed === true ? "managed" : undefined, + worktree.detached === true ? "detached" : undefined, + worktree.dirtySource === true ? "dirty source" : undefined, + ].filter((detail): detail is string => Boolean(detail)); + return `Worktree: ${worktree.path ?? "unknown"}${details.length > 0 ? ` (${details.join(", ")})` : ""}`; +} + +function formatAgentProvider( + provider: NonNullable[number], +): string { + const name = provider.name ?? "unknown"; + if (provider.available !== false) return name; + return provider.reason ? `${name} unavailable: ${provider.reason}` : `${name} unavailable`; +} + +function formatAgent(agent: NonNullable[number]): string { + const details = [ + agent.provider, + agent.model, + agent.thinking ? `thinking ${agent.thinking}` : undefined, + agent.providerAvailable === false + ? agent.providerUnavailableReason ?? "provider unavailable" + : undefined, + ].filter((detail): detail is string => Boolean(detail)); + return `${agent.name ?? "unnamed"}${details.length > 0 ? ` (${details.join(", ")})` : ""}`; +} + +function formatDiagnostic(diagnostic: unknown): string { + if (typeof diagnostic === "string") return diagnostic; + if (diagnostic instanceof Error) return diagnostic.message; + try { + return JSON.stringify(diagnostic); + } catch { + return String(diagnostic); + } +} + function formatAgentsFilesForPayload( agentsFiles: NonNullable, ): string { diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 39c2ed09..5fb66169 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -1,7 +1,10 @@ -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { + workspaceConversationBootstraps, + workspaceConversationBindings, workspaceSessions, + type WorkspaceConversationBindingRow, type WorkspaceSessionRow, } from "./db/schema.js"; @@ -20,6 +23,14 @@ export interface WorkspaceSession { lastUsedAt: string; } +export interface WorkspaceConversationBinding { + conversationScopeHash: string; + targetKey: string; + workspaceSessionId: string; + createdAt: string; + lastUsedAt: string; +} + export interface WorkspaceStore { createSession(input: { id: string; @@ -32,6 +43,18 @@ export interface WorkspaceStore { }): WorkspaceSession; getSession(id: string): WorkspaceSession | undefined; touchSession(id: string): void; + getConversationBinding( + conversationScopeHash: string, + targetKey: string, + ): WorkspaceConversationBinding | undefined; + setConversationBinding(input: { + conversationScopeHash: string; + targetKey: string; + workspaceSessionId: string; + }): WorkspaceConversationBinding; + touchConversationBinding(conversationScopeHash: string, targetKey: string): void; + deleteConversationBinding(conversationScopeHash: string, targetKey: string): void; + claimConversationBootstrap(conversationScopeHash: string, projectKey: string): boolean; close?(): void; } @@ -102,6 +125,113 @@ export class SqliteWorkspaceStore implements WorkspaceStore { .run(); } + getConversationBinding( + conversationScopeHash: string, + targetKey: string, + ): WorkspaceConversationBinding | undefined { + const row = this.database.db + .select() + .from(workspaceConversationBindings) + .where( + and( + eq(workspaceConversationBindings.conversationScopeHash, conversationScopeHash), + eq(workspaceConversationBindings.targetKey, targetKey), + ), + ) + .get(); + + return row ? rowToWorkspaceConversationBinding(row) : undefined; + } + + setConversationBinding(input: { + conversationScopeHash: string; + targetKey: string; + workspaceSessionId: string; + }): WorkspaceConversationBinding { + const now = new Date().toISOString(); + const row = this.database.db + .insert(workspaceConversationBindings) + .values({ + conversationScopeHash: input.conversationScopeHash, + targetKey: input.targetKey, + workspaceSessionId: input.workspaceSessionId, + createdAt: now, + lastUsedAt: now, + }) + .onConflictDoUpdate({ + target: [ + workspaceConversationBindings.conversationScopeHash, + workspaceConversationBindings.targetKey, + ], + set: { + workspaceSessionId: input.workspaceSessionId, + lastUsedAt: now, + }, + }) + .returning() + .get(); + + if (!row) { + throw new Error("Conversation workspace binding upsert returned no row."); + } + + return rowToWorkspaceConversationBinding(row); + } + + touchConversationBinding(conversationScopeHash: string, targetKey: string): void { + this.database.db + .update(workspaceConversationBindings) + .set({ lastUsedAt: new Date().toISOString() }) + .where( + and( + eq(workspaceConversationBindings.conversationScopeHash, conversationScopeHash), + eq(workspaceConversationBindings.targetKey, targetKey), + ), + ) + .run(); + } + + deleteConversationBinding(conversationScopeHash: string, targetKey: string): void { + this.database.db + .delete(workspaceConversationBindings) + .where( + and( + eq(workspaceConversationBindings.conversationScopeHash, conversationScopeHash), + eq(workspaceConversationBindings.targetKey, targetKey), + ), + ) + .run(); + } + + claimConversationBootstrap(conversationScopeHash: string, projectKey: string): boolean { + const now = new Date().toISOString(); + const [inserted] = this.database.db + .insert(workspaceConversationBootstraps) + .values({ + conversationScopeHash, + projectKey, + createdAt: now, + lastUsedAt: now, + }) + .onConflictDoNothing() + .returning() + .all(); + + if (inserted) return true; + + this.database.db + .update(workspaceConversationBootstraps) + .set({ lastUsedAt: now }) + .where( + and( + eq(workspaceConversationBootstraps.conversationScopeHash, conversationScopeHash), + eq(workspaceConversationBootstraps.projectKey, projectKey), + ), + ) + .run(); + return false; + } + close(): void { this.database.close(); } @@ -126,3 +256,15 @@ function rowToWorkspaceSession(row: WorkspaceSessionRow): WorkspaceSession { lastUsedAt: row.lastUsedAt, }; } + +function rowToWorkspaceConversationBinding( + row: WorkspaceConversationBindingRow, +): WorkspaceConversationBinding { + return { + conversationScopeHash: row.conversationScopeHash, + targetKey: row.targetKey, + workspaceSessionId: row.workspaceSessionId, + createdAt: row.createdAt, + lastUsedAt: row.lastUsedAt, + }; +} diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 4f3eb769..2c4963fd 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -59,6 +59,7 @@ try { agentsFiles.map((file) => file.content), ["global instructions\n", "root instructions\n"], ); + assert.deepEqual( availableAgentsFiles.map((file) => file.path), [join(root, "nested", "AGENTS.md")], @@ -159,11 +160,117 @@ try { const stateDir = join(root, ".state"); const firstStore = new SqliteWorkspaceStore(stateDir); const persistentRegistry = new WorkspaceRegistry(config, firstStore); - const persistentWorkspace = await persistentRegistry.openWorkspace(root); - const persistentWorktree = await persistentRegistry.openWorkspace({ - path: gitRoot, - mode: "worktree", + const persistentWorkspace = await persistentRegistry.openWorkspace(root, { + conversationScopeHash: "chat-checkout", + }); + const reusedPersistentWorkspace = await persistentRegistry.openWorkspace(root, { + conversationScopeHash: "chat-checkout", + }); + assert.equal(persistentWorkspace.includeBootstrapContext, true); + assert.equal(persistentWorkspace.workspaceReused, false); + assert.equal(reusedPersistentWorkspace.includeBootstrapContext, false); + assert.equal(reusedPersistentWorkspace.workspaceReused, true); + assert.equal(reusedPersistentWorkspace.workspace.id, persistentWorkspace.workspace.id); + assert.deepEqual( + reusedPersistentWorkspace.agentsFiles.map((file) => file.content), + persistentWorkspace.agentsFiles.map((file) => file.content), + ); + assert.deepEqual( + reusedPersistentWorkspace.availableAgentsFiles, + persistentWorkspace.availableAgentsFiles, + ); + + const otherConversationWorkspace = await persistentRegistry.openWorkspace(root, { + conversationScopeHash: "chat-checkout-other", + }); + assert.equal(otherConversationWorkspace.includeBootstrapContext, true); + assert.equal(otherConversationWorkspace.workspaceReused, false); + assert.notEqual(otherConversationWorkspace.workspace.id, persistentWorkspace.workspace.id); + + const staleWorkspaceRoot = join(root, "stale-conversation-workspace"); + await mkdir(staleWorkspaceRoot); + const staleWorkspace = await persistentRegistry.openWorkspace(staleWorkspaceRoot, { + conversationScopeHash: "chat-stale", + }); + await rm(staleWorkspaceRoot, { recursive: true, force: true }); + const replacementWorkspace = await persistentRegistry.openWorkspace(staleWorkspaceRoot, { + conversationScopeHash: "chat-stale", + }); + assert.equal(replacementWorkspace.includeBootstrapContext, false); + assert.equal(replacementWorkspace.workspaceReused, false); + assert.notEqual(replacementWorkspace.workspace.id, staleWorkspace.workspace.id); + assert.equal((await stat(staleWorkspaceRoot)).isDirectory(), true); + + const worktreeInput = { path: gitRoot, mode: "worktree" as const }; + const projectCheckout = await persistentRegistry.openWorkspace(gitRoot, { + conversationScopeHash: "chat-project-modes", + }); + const firstProjectWorktree = await persistentRegistry.openWorkspace(worktreeInput, { + conversationScopeHash: "chat-project-modes", + }); + const secondProjectWorktree = await persistentRegistry.openWorkspace(worktreeInput, { + conversationScopeHash: "chat-project-modes", + }); + const reusedProjectCheckout = await persistentRegistry.openWorkspace(gitRoot, { + conversationScopeHash: "chat-project-modes", + }); + assert.equal(projectCheckout.includeBootstrapContext, true); + assert.equal(projectCheckout.workspaceReused, false); + assert.equal(firstProjectWorktree.includeBootstrapContext, false); + assert.equal(firstProjectWorktree.workspaceReused, false); + assert.equal(secondProjectWorktree.includeBootstrapContext, false); + assert.equal(secondProjectWorktree.workspaceReused, false); + assert.notEqual(firstProjectWorktree.workspace.id, projectCheckout.workspace.id); + assert.notEqual(firstProjectWorktree.workspace.id, secondProjectWorktree.workspace.id); + assert.notEqual(firstProjectWorktree.workspace.root, secondProjectWorktree.workspace.root); + assert.equal(reusedProjectCheckout.workspace.id, projectCheckout.workspace.id); + assert.equal(reusedProjectCheckout.workspaceReused, true); + assert.equal(reusedProjectCheckout.includeBootstrapContext, false); + + const worktreeFirst = await persistentRegistry.openWorkspace(worktreeInput, { + conversationScopeHash: "chat-worktree-first", + }); + const checkoutAfterWorktree = await persistentRegistry.openWorkspace(gitRoot, { + conversationScopeHash: "chat-worktree-first", + }); + const reusedCheckoutAfterWorktree = await persistentRegistry.openWorkspace(gitRoot, { + conversationScopeHash: "chat-worktree-first", }); + assert.equal(worktreeFirst.includeBootstrapContext, true); + assert.equal(worktreeFirst.workspaceReused, false); + assert.equal(checkoutAfterWorktree.includeBootstrapContext, false); + assert.equal(checkoutAfterWorktree.workspaceReused, false); + assert.equal(checkoutAfterWorktree.workspace.mode, "checkout"); + assert.notEqual(checkoutAfterWorktree.workspace.id, worktreeFirst.workspace.id); + assert.equal(reusedCheckoutAfterWorktree.includeBootstrapContext, false); + assert.equal(reusedCheckoutAfterWorktree.workspaceReused, true); + assert.equal(reusedCheckoutAfterWorktree.workspace.id, checkoutAfterWorktree.workspace.id); + + const [persistentWorktree, concurrentWorktree] = await Promise.all([ + persistentRegistry.openWorkspace(worktreeInput, { + conversationScopeHash: "chat-worktree-concurrent", + }), + persistentRegistry.openWorkspace(worktreeInput, { + conversationScopeHash: "chat-worktree-concurrent", + }), + ]); + assert.notEqual(concurrentWorktree.workspace.id, persistentWorktree.workspace.id); + assert.notEqual(concurrentWorktree.workspace.root, persistentWorktree.workspace.root); + assert.equal(persistentWorktree.workspaceReused, false); + assert.equal(concurrentWorktree.workspaceReused, false); + const concurrentWorktreeOpens = [persistentWorktree, concurrentWorktree]; + assert.equal( + concurrentWorktreeOpens.filter((open) => open.includeBootstrapContext).length, + 1, + ); + assert.deepEqual( + concurrentWorktree.agentsFiles.map((file) => file.content), + persistentWorktree.agentsFiles.map((file) => file.content), + ); + assert.deepEqual( + concurrentWorktree.availableAgentsFiles.map((file) => file.path.replace(concurrentWorktree.workspace.root, "")), + persistentWorktree.availableAgentsFiles.map((file) => file.path.replace(persistentWorktree.workspace.root, "")), + ); firstStore.close(); const secondStore = new SqliteWorkspaceStore(stateDir); @@ -172,16 +279,61 @@ try { assert.equal(restoredWorkspace.root, root); assert.equal(restoredWorkspace.mode, "checkout"); + const reboundWorkspace = await restoredRegistry.openWorkspace(root, { + conversationScopeHash: "chat-checkout", + }); + assert.equal(reboundWorkspace.includeBootstrapContext, false); + assert.equal(reboundWorkspace.workspaceReused, true); + assert.equal(reboundWorkspace.workspace.id, persistentWorkspace.workspace.id); + assert.deepEqual( + reboundWorkspace.agentsFiles.map((file) => file.content), + persistentWorkspace.agentsFiles.map((file) => file.content), + ); + assert.deepEqual(reboundWorkspace.availableAgentsFiles, persistentWorkspace.availableAgentsFiles); + assert.deepEqual( + reboundWorkspace.workspace.agentProfiles.map((profile) => profile.name), + persistentWorkspace.workspace.agentProfiles.map((profile) => profile.name), + ); + const restoredWorktree = restoredRegistry.getWorkspace(persistentWorktree.workspace.id); assert.equal(restoredWorktree.mode, "worktree"); assert.equal(restoredWorktree.sourceRoot, gitRoot); assert.equal(restoredWorktree.root, persistentWorktree.workspace.root); assert.equal(restoredWorktree.worktree?.managed, true); + + const reboundWorktree = await restoredRegistry.openWorkspace(worktreeInput, { + conversationScopeHash: "chat-worktree-concurrent", + }); + assert.equal(reboundWorktree.includeBootstrapContext, false); + assert.equal(reboundWorktree.workspaceReused, false); + assert.notEqual(reboundWorktree.workspace.id, persistentWorktree.workspace.id); + assert.notEqual(reboundWorktree.workspace.root, persistentWorktree.workspace.root); + assert.deepEqual( + reboundWorktree.agentsFiles.map((file) => file.content), + persistentWorktree.agentsFiles.map((file) => file.content), + ); secondStore.close(); if (platform() !== "win32") { const aliasRoot = join(root, "alias-root"); await symlink(root, aliasRoot, "dir"); + + const aliasStateDir = join(root, ".alias-state"); + const aliasStore = new SqliteWorkspaceStore(aliasStateDir); + const aliasRegistry = new WorkspaceRegistry(config, aliasStore); + const directConversationWorkspace = await aliasRegistry.openWorkspace(root, { + conversationScopeHash: "chat-alias", + }); + const aliasedConversationWorkspace = await aliasRegistry.openWorkspace(aliasRoot, { + conversationScopeHash: "chat-alias", + }); + assert.equal(aliasedConversationWorkspace.includeBootstrapContext, false); + assert.equal( + aliasedConversationWorkspace.workspace.id, + directConversationWorkspace.workspace.id, + ); + aliasStore.close(); + const aliasConfig = loadConfig({ DEVSPACE_ALLOWED_ROOTS: aliasRoot, DEVSPACE_WORKTREE_ROOT: join(aliasRoot, ".devspace", "alias-worktrees"), diff --git a/src/workspaces.ts b/src/workspaces.ts index e3c252f4..b68030c5 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -53,6 +53,8 @@ export interface WorkspaceContext { workspace: Workspace; agentsFiles: LoadedAgentsFile[]; availableAgentsFiles: AvailableAgentsFile[]; + workspaceReused: boolean; + includeBootstrapContext: boolean; } export interface WorkspaceReadPath { @@ -67,6 +69,10 @@ export interface OpenWorkspaceInput { baseRef?: string; } +export interface OpenWorkspaceOptions { + conversationScopeHash?: string; +} + type PathStats = Stats; type DirectoryOps = { stat: (path: string) => Promise; @@ -75,14 +81,66 @@ type DirectoryOps = { export class WorkspaceRegistry { private readonly workspaces = new Map(); + private readonly pendingCheckoutOpens = new Map>(); constructor( private readonly config: ServerConfig, private readonly store?: WorkspaceStore, ) {} - async openWorkspace(input: string | OpenWorkspaceInput): Promise { - const options = typeof input === "string" ? { path: input } : input; + async openWorkspace( + input: string | OpenWorkspaceInput, + openOptions: OpenWorkspaceOptions = {}, + ): Promise { + const workspaceInput = typeof input === "string" ? { path: input } : input; + const conversationScopeHash = openOptions.conversationScopeHash; + if (!conversationScopeHash || !this.store) { + return this.openNewWorkspace(workspaceInput); + } + + const projectKey = await this.conversationProjectKey(workspaceInput); + const mode = workspaceInput.mode ?? "checkout"; + if (mode === "worktree") { + const context = await this.openWorktreeWorkspace(workspaceInput.path, workspaceInput.baseRef); + return { + ...context, + includeBootstrapContext: this.store.claimConversationBootstrap( + conversationScopeHash, + projectKey, + ), + }; + } + + const targetKey = this.conversationCheckoutTargetKey(projectKey); + const operationKey = JSON.stringify([conversationScopeHash, targetKey]); + const pending = this.pendingCheckoutOpens.get(operationKey); + if (pending) { + const context = await pending; + return { + ...context, + workspaceReused: true, + includeBootstrapContext: false, + }; + } + + const open = this.openConversationCheckout( + workspaceInput, + conversationScopeHash, + targetKey, + projectKey, + ); + this.pendingCheckoutOpens.set(operationKey, open); + + try { + return await open; + } finally { + if (this.pendingCheckoutOpens.get(operationKey) === open) { + this.pendingCheckoutOpens.delete(operationKey); + } + } + } + + private async openNewWorkspace(options: OpenWorkspaceInput): Promise { const mode = options.mode ?? "checkout"; if (mode === "worktree") { @@ -92,6 +150,71 @@ export class WorkspaceRegistry { return this.openCheckoutWorkspace(options.path); } + private async openConversationCheckout( + input: OpenWorkspaceInput, + conversationScopeHash: string, + targetKey: string, + projectKey: string, + ): Promise { + const binding = this.store?.getConversationBinding(conversationScopeHash, targetKey); + if (binding) { + try { + const workspace = this.getWorkspace(binding.workspaceSessionId); + const workspaceStats = await stat(workspace.root); + if (workspaceStats.isDirectory()) { + this.store?.touchConversationBinding(conversationScopeHash, targetKey); + return await this.reusedWorkspaceContext( + workspace, + this.store?.claimConversationBootstrap(conversationScopeHash, projectKey) ?? true, + ); + } + } catch { + // The persisted workspace is no longer usable; replace its binding below. + } + + this.workspaces.delete(binding.workspaceSessionId); + this.store?.deleteConversationBinding(conversationScopeHash, targetKey); + } + + const context = await this.openCheckoutWorkspace(input.path); + this.store?.setConversationBinding({ + conversationScopeHash, + targetKey, + workspaceSessionId: context.workspace.id, + }); + return { + ...context, + includeBootstrapContext: + this.store?.claimConversationBootstrap(conversationScopeHash, projectKey) ?? true, + }; + } + + private async conversationProjectKey(input: OpenWorkspaceInput): Promise { + const path = assertAllowedPath(input.path, this.config.allowedRoots); + return await realpath(path).catch(() => path); + } + + private conversationCheckoutTargetKey(projectKey: string): string { + return JSON.stringify(["checkout", projectKey, null]); + } + + private async reusedWorkspaceContext( + workspace: Workspace, + includeBootstrapContext: boolean, + ): Promise { + workspace.agentProfiles = await loadLocalAgentProfiles(this.config, workspace.root); + const agentsFiles = await this.loadInitialAgentsFiles(workspace.root); + const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles); + + return { + workspace, + agentsFiles, + availableAgentsFiles, + workspaceReused: true, + includeBootstrapContext, + }; + } + getWorkspace(workspaceId: string): Workspace { const workspace = this.workspaces.get(workspaceId); if (workspace) { @@ -228,7 +351,13 @@ export class WorkspaceRegistry { const agentsFiles = await this.loadInitialAgentsFiles(workspace.root); const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles); - return { workspace, agentsFiles, availableAgentsFiles }; + return { + workspace, + agentsFiles, + availableAgentsFiles, + workspaceReused: false, + includeBootstrapContext: true, + }; } private loadSkillsForWorkspace(root: string): Pick {