From de349d5a191079f713562b6ad94a2419645c8a96 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Tue, 7 Jul 2026 13:59:58 +0000 Subject: [PATCH 1/5] feat(opencode-plugin): address Cubic PR #4 findings Signed-off-by: Mislav Ivanda --- .../.opencode/plugin/daytona/core/logger.ts | 14 +- .../daytona/core/project-data-storage.ts | 39 +++- .../plugin/daytona/core/session-manager.ts | 119 +++++++----- .../plugin/daytona/git/host-git-manager.ts | 174 +++++++++--------- .../plugin/daytona/git/sandbox-git-manager.ts | 9 +- .../plugin/daytona/git/session-git-manager.ts | 13 +- .../.opencode/plugin/daytona/index.ts | 6 +- .../plugin/daytona/plugins/session-events.ts | 14 +- .../.opencode/plugin/daytona/tools.ts | 4 - .../.opencode/plugin/daytona/tools/bash.ts | 6 +- .../.opencode/plugin/daytona/tools/edit.ts | 3 + .../plugin/daytona/tools/get-preview-url.ts | 2 +- .../.opencode/plugin/daytona/tools/grep.ts | 10 +- .../.opencode/plugin/daytona/tools/lsp.ts | 26 --- .../plugin/daytona/tools/multiedit.ts | 9 + .../.opencode/plugin/daytona/tools/patch.ts | 24 --- .../.opencode/plugin/daytona/tools/write.ts | 2 +- .../opencode-plugin/.opencode/plugin/index.ts | 2 +- packages/opencode-plugin/README.md | 8 +- 19 files changed, 251 insertions(+), 233 deletions(-) delete mode 100644 packages/opencode-plugin/.opencode/plugin/daytona/tools/lsp.ts delete mode 100644 packages/opencode-plugin/.opencode/plugin/daytona/tools/patch.ts diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/core/logger.ts b/packages/opencode-plugin/.opencode/plugin/daytona/core/logger.ts index e75e179..bef1745 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/core/logger.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/core/logger.ts @@ -31,24 +31,28 @@ class Logger { } catch (err) { // Directory may already exist, ignore } - // Trim log file if it exceeds 3MB (keep last 1MB) + // Trim by byte length (not characters) so the 1MB target holds for non-ASCII logs try { const stats = statSync(this.logFile) const maxSize = 3 * 1024 * 1024 const keepSize = 1024 * 1024 if (stats.size > maxSize) { - const content = readFileSync(this.logFile, 'utf8') - const trimmed = content.slice(-keepSize) + const buffer = readFileSync(this.logFile) + const trimmed = buffer.subarray(buffer.length - keepSize) // Drop partial first line so we don't start mid-log const firstNewline = trimmed.indexOf('\n') - writeFileSync(this.logFile, firstNewline >= 0 ? trimmed.slice(firstNewline + 1) : trimmed) + writeFileSync(this.logFile, firstNewline >= 0 ? trimmed.subarray(firstNewline + 1) : trimmed) } } catch (err) { // File may not exist yet, ignore } const timestamp = new Date().toISOString() const logEntry = `[${timestamp}] [${level}] ${message}\n` - appendFileSync(this.logFile, logEntry) + try { + appendFileSync(this.logFile, logEntry) + } catch (err) { + // Best-effort logging: never let a write failure crash the caller + } } info(message: string): void { diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts b/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts index 32f40a1..9ed3119 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts @@ -29,7 +29,9 @@ export class ProjectDataStorage { * Get the file path for a project's session data */ private getProjectFilePath(projectId: string): string { - return join(this.storageDir, `${projectId}.json`) + // Strip path separators so a projectId can never traverse outside storageDir. + const safeId = projectId.replace(/[/\\\0]/g, '_') + return join(this.storageDir, `${safeId}.json`) } /** @@ -86,7 +88,19 @@ export class ProjectDataStorage { sessions: {}, } - // Remove from source first (best effort). + // Write the destination first and confirm it landed on disk, so a write + // failure can never delete the source before the copy is safely persisted. + destination.sessions[sessionId] = found + // Prefer the worktree for the project we're actually operating on. + destination.worktree = worktree + this.save(projectId, destination.worktree, destination.sessions) + + if (!this.load(projectId)?.sessions?.[sessionId]) { + logger.error(`Migration of session ${sessionId} to project ${projectId} did not persist; leaving source intact`) + return found + } + + // Destination is safe; now remove from the source (best effort). try { delete otherData!.sessions[sessionId] this.save(otherProjectId, otherData!.worktree, otherData!.sessions) @@ -94,12 +108,6 @@ export class ProjectDataStorage { logger.warn(`Failed to remove session ${sessionId} from project ${otherProjectId}: ${err}`) } - // Add to destination and persist. - destination.sessions[sessionId] = found - // Prefer the worktree for the project we're actually operating on. - destination.worktree = worktree - this.save(projectId, destination.worktree, destination.sessions) - logger.info(`Migrated session ${sessionId} from project ${otherProjectId} to project ${projectId}`) return found } @@ -107,6 +115,21 @@ export class ProjectDataStorage { return undefined } + /** + * Read-only lookup of a session across all project files. Unlike getSession, this never + * migrates or writes, so it is safe to use on the delete path. + */ + findSession(sessionId: string): { projectId: string; worktree: string; session: SessionInfo } | undefined { + for (const projectId of this.listProjectIds()) { + const data = this.load(projectId) + const session = data?.sessions?.[sessionId] + if (session) { + return { projectId, worktree: data!.worktree, session } + } + } + return undefined + } + /** * Save project session data to disk */ diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts index 55baeb4..5052a57 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts @@ -8,7 +8,7 @@ * Stores data per-project in ~/.local/share/opencode/storage/daytona/{projectId}.json */ -import { Daytona, type Sandbox } from '@daytona/sdk' +import { Daytona, DaytonaNotFoundError, type Sandbox } from '@daytona/sdk' import { logger } from './logger' import type { SessionSandboxMap, SandboxInfo } from './types' import { SessionGitManager } from '../git/session-git-manager' @@ -112,43 +112,50 @@ export class DaytonaSessionManager { // If we have a sandboxId but not a full sandbox object, reconnect to it if (this.isPartiallyInitialized(existing)) { - logger.info(`Reconnecting to existing sandbox: ${existing.id}`) - const daytona = new Daytona({ apiKey: this.apiKey }) - const reconnectStart = Date.now() - logger.info(`Daytona get begin sandboxId=${existing.id}`) - const sandbox = await daytona.get(existing.id) - logger.info(`Daytona get done sandboxId=${existing.id} in ${Date.now() - reconnectStart}ms`) - logger.info(`Starting sandbox begin sandboxId=${sandbox.id}`) - await sandbox.start() - logger.info(`Starting sandbox done sandboxId=${sandbox.id} in ${Date.now() - reconnectStart}ms`) - this.sessionSandboxes.set(sessionId, sandbox) - // Preserve branch number if it exists for this sandbox - let branchNumber = this.dataStorage.getBranchNumberForSandbox(projectId, sandbox.id) - if (!branchNumber) { - try { - branchNumber = SessionGitManager.allocateAndReserveBranchNumber(worktree) - } catch { - // No local git repo (or git unavailable) shouldn't block sandbox usage. - branchNumber = undefined + try { + logger.info(`Reconnecting to existing sandbox: ${existing.id}`) + const daytona = new Daytona({ apiKey: this.apiKey }) + const reconnectStart = Date.now() + logger.info(`Daytona get begin sandboxId=${existing.id}`) + const sandbox = await daytona.get(existing.id) + logger.info(`Daytona get done sandboxId=${existing.id} in ${Date.now() - reconnectStart}ms`) + logger.info(`Starting sandbox begin sandboxId=${sandbox.id}`) + await sandbox.start() + logger.info(`Starting sandbox done sandboxId=${sandbox.id} in ${Date.now() - reconnectStart}ms`) + this.sessionSandboxes.set(sessionId, sandbox) + // Preserve branch number if it exists for this sandbox + let branchNumber = this.dataStorage.getBranchNumberForSandbox(projectId, sandbox.id) + if (!branchNumber) { + try { + branchNumber = SessionGitManager.allocateAndReserveBranchNumber(worktree) + } catch { + // No local git repo (or git unavailable) shouldn't block sandbox usage. + branchNumber = undefined + } } - } - this.dataStorage.updateSession(projectId, worktree, sessionId, sandbox.id, branchNumber) - toast.show({ - title: 'Sandbox connected', - message: `Connected to existing sandbox.`, - variant: 'info', - }) + this.dataStorage.updateSession(projectId, worktree, sessionId, sandbox.id, branchNumber) + toast.show({ + title: 'Sandbox connected', + message: `Connected to existing sandbox.`, + variant: 'info', + }) - // Even if git syncing is disabled, ensure the project directory exists in the sandbox. - if (!branchNumber) { - try { - await new DaytonaSandboxGitManager(sandbox, this.repoPath).ensureDirectory() - } catch (err) { - logger.warn(`Failed to ensure sandbox project directory exists: ${err}`) + // Even if git syncing is disabled, ensure the project directory exists in the sandbox. + if (!branchNumber) { + try { + await new DaytonaSandboxGitManager(sandbox, this.repoPath).ensureDirectory() + } catch (err) { + logger.warn(`Failed to ensure sandbox project directory exists: ${err}`) + } } - } - return sandbox + return sandbox + } catch (err) { + // Stored sandbox is stale/deleted: drop the mapping and create a replacement below. + logger.error(`Failed to reconnect to sandbox ${existing.id}; creating a replacement: ${err}`) + this.sessionSandboxes.delete(sessionId) + this.dataStorage.removeSession(projectId, worktree, sessionId) + } } // If not in cache/storage for this project, try to recover from other projects and migrate. @@ -220,39 +227,55 @@ export class DaytonaSessionManager { /** * Delete the sandbox associated with the given session ID */ - async deleteSandbox(sessionId: string, projectId: string): Promise { + async deleteSandbox(sessionId: string, projectId: string): Promise { let sandbox = this.sessionSandboxes.get(sessionId) + // Read-only lookup so deleting never migrates sessions or rewrites project metadata. + const stored = this.dataStorage.findSession(sessionId) + + let sandboxGone = false + // If not in cache, try to load from storage and reconnect if (!sandbox || this.isPartiallyInitialized(sandbox)) { - const storedWorktree = this.dataStorage.load(projectId)?.worktree ?? '' - const sessionInfo = this.dataStorage.getSession(projectId, storedWorktree, sessionId) - if (sessionInfo?.sandboxId) { + if (stored?.session.sandboxId) { const daytona = new Daytona({ apiKey: this.apiKey }) try { - sandbox = await daytona.get(sessionInfo.sandboxId) + sandbox = await daytona.get(stored.session.sandboxId) this.sessionSandboxes.set(sessionId, sandbox) } catch (err) { - logger.error(`Failed to reconnect to sandbox ${sessionInfo.sandboxId}: ${err}`) + if (err instanceof DaytonaNotFoundError) { + sandboxGone = true + logger.warn(`Sandbox ${stored.session.sandboxId} no longer exists; clearing stale mapping.`) + } else { + logger.error(`Failed to reconnect to sandbox ${stored.session.sandboxId}: ${err}`) + } } + } else { + sandboxGone = true } } // Delete the sandbox if we have a fully initialized one + let deleted = false if (this.isFullyInitialized(sandbox)) { logger.info(`Removing sandbox for session: ${sessionId}`) await sandbox.delete() - this.sessionSandboxes.delete(sessionId) - - // Remove from storage - const projectData = this.dataStorage.load(projectId) - if (projectData) { - this.dataStorage.removeSession(projectId, projectData.worktree, sessionId) - } - + deleted = true + sandboxGone = true logger.info(`Sandbox deleted successfully.`) } else { logger.warn(`No sandbox found for session: ${sessionId}`) } + + // Clear the local mapping when the sandbox is gone (deleted or already absent) so a + // stale entry can't cause repeated reconnect failures. Preserve it on transient errors. + if (sandboxGone) { + this.sessionSandboxes.delete(sessionId) + const cleanupProjectId = stored?.projectId ?? projectId + const cleanupWorktree = stored?.worktree ?? this.dataStorage.load(projectId)?.worktree ?? '' + this.dataStorage.removeSession(cleanupProjectId, cleanupWorktree, sessionId) + } + + return deleted } } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts index 1a4580e..fcb257a 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts @@ -4,7 +4,7 @@ */ import { logger } from '../core/logger' -import { execSync, spawnSync } from 'child_process' +import { spawnSync } from 'child_process' type ExecResult = { ok: boolean @@ -13,25 +13,30 @@ type ExecResult = { status: number | null } -function execCommand(cmd: string, options: any = {}): ExecResult { - try { - const stdout = execSync(cmd, { - stdio: ['ignore', 'pipe', 'pipe'], - encoding: 'utf8', - ...options, - }) as unknown as string - return { ok: true, stdout: stdout ?? '', stderr: '', status: 0 } - } catch (err: any) { - const stdout = err?.stdout?.toString?.() ?? '' - const stderr = err?.stderr?.toString?.() ?? err?.message ?? String(err) - const status = typeof err?.status === 'number' ? err.status : null - return { ok: false, stdout, stderr, status } +type ExecOptions = { + cwd?: string + env?: NodeJS.ProcessEnv +} + +// Runs git with an explicit argument vector and no shell, so interpolated values +// (SSH URLs, refs, remote names) can never be interpreted as shell syntax. +function execGit(args: string[], options: ExecOptions = {}): ExecResult { + const res = spawnSync('git', args, { + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + ...options, + }) + if (res.error) { + return { ok: false, stdout: '', stderr: res.error.message, status: null } } + const status = typeof res.status === 'number' ? res.status : null + return { ok: status === 0, stdout: res.stdout ?? '', stderr: res.stderr ?? '', status } } export class HostGitManager { - // No constructor needed; use global logger - private operationQueue: Promise = Promise.resolve() + // Static so a single queue serializes git ops across every instance sharing the + // worktree; per-instance queues would let concurrent session syncs race. + private static operationQueue: Promise = Promise.resolve() /** Cached OID of an empty commit used to reserve branch refs (branches must point at commits, not blobs). */ private emptyCommitOidCache = new Map() @@ -40,7 +45,7 @@ export class HostGitManager { * @returns true if a git repo exists, false otherwise */ hasRepo(cwd?: string): boolean { - return execCommand('git rev-parse --is-inside-work-tree', cwd ? { cwd } : {}).ok + return execGit(['rev-parse', '--is-inside-work-tree'], cwd ? { cwd } : {}).ok } /** @@ -56,7 +61,7 @@ export class HostGitManager { } const base = `refs/heads/${prefix}/` - const listRes = execCommand(`git for-each-ref --format='%(refname:strip=3)' ${base}`, { cwd }) + const listRes = execGit(['for-each-ref', '--format=%(refname:strip=3)', base], { cwd }) if (!listRes.ok) throw new Error(listRes.stderr) const list = listRes.stdout.trim() const nums = @@ -80,7 +85,7 @@ export class HostGitManager { continue } const oid = this.getOrCreateEmptyCommitOid(cwd) - const result = execCommand(`git update-ref "${ref}" "${oid}"`, { cwd }) + const result = execGit(['update-ref', ref, oid], { cwd }) if (result.ok) { logger.info(`[branch-alloc] reserved ${prefix}/${n} in ${Date.now() - start}ms`) return n @@ -90,12 +95,16 @@ export class HostGitManager { } } const oid = this.getOrCreateEmptyCommitOid(cwd) - const last = execCommand(`git update-ref "${base}${n}" "${oid}"`, { cwd }) + const last = execGit(['update-ref', `${base}${n}`, oid], { cwd }) + if (last.ok) { + logger.info(`[branch-alloc] reserved ${prefix}/${n} in ${Date.now() - start}ms`) + return n + } throw new Error(`Failed to allocate branch number after ${attempts} attempts. Last error: ${last.stderr}`) } private refExists(cwd: string, ref: string): boolean { - return execCommand(`git show-ref --verify --quiet "${ref}"`, { cwd }).ok + return execGit(['show-ref', '--verify', '--quiet', ref], { cwd }).ok } /** @@ -106,7 +115,7 @@ export class HostGitManager { private getOrCreateEmptyCommitOid(cwd: string): string { const cached = this.emptyCommitOidCache.get(cwd) if (cached) return cached - const headRes = execCommand('git rev-parse HEAD', { cwd }) + const headRes = execGit(['rev-parse', 'HEAD'], { cwd }) const head = headRes.ok ? headRes.stdout.trim() : '' if (head) { this.emptyCommitOidCache.set(cwd, head) @@ -114,9 +123,7 @@ export class HostGitManager { } // Create an empty tree (idempotent) then a commit pointing at it. - // Branch refs must point at commits, so we can't reserve with a blob. const treeResult = spawnSync('git', ['hash-object', '-t', 'tree', '-w', '--stdin'], { - // Empty stdin => empty tree input: '', cwd, encoding: 'utf8', @@ -140,11 +147,7 @@ export class HostGitManager { GIT_COMMITTER_EMAIL: reservationCommitEmail, } - // Create the commit - const commitRes = execCommand(`git commit-tree ${treeOid} -m "${reservationCommitMessage}"`, { - cwd, - env: commitEnv, - }) + const commitRes = execGit(['commit-tree', treeOid, '-m', reservationCommitMessage], { cwd, env: commitEnv }) if (!commitRes.ok) throw new Error(`Failed to create empty commit: ${commitRes.stderr}`) const commitOid = commitRes.stdout.trim() this.emptyCommitOidCache.set(cwd, commitOid) @@ -157,63 +160,56 @@ export class HostGitManager { * @param sshUrl The SSH URL of the sandbox remote. * @param branch The branch to push to. * @param cwd Worktree path to run git in. - * @returns true if push was successful, false if no repo exists + * @returns true if push succeeded, false if no repo exists. Throws if the push fails. */ async pushLocalToSandboxRemote(remoteName: string, sshUrl: string, branch: string, cwd: string): Promise { if (!this.hasRepo(cwd)) { logger.warn('No local git repository found. Skipping push to sandbox.') return false } - try { - logger.info(`Pushing to ${remoteName} (${sshUrl}) on branch ${branch}`) - const operation = this.operationQueue.then(async () => { - const statusRes = execCommand('git status --porcelain', { cwd }) - if (!statusRes.ok) { - throw new Error(statusRes.stderr) - } - if (statusRes.stdout.trim().length > 0) { - logger.warn('Local repository has uncommitted changes; pushing HEAD only (no auto-commit).') - } + logger.info(`Pushing to ${remoteName} (${sshUrl}) on branch ${branch}`) + const operation = HostGitManager.operationQueue.then(async () => { + const statusRes = execGit(['status', '--porcelain'], { cwd }) + if (!statusRes.ok) { + throw new Error(statusRes.stderr) + } + if (statusRes.stdout.trim().length > 0) { + logger.warn('Local repository has uncommitted changes; pushing HEAD only (no auto-commit).') + } - this.setRemote(remoteName, sshUrl, cwd) - let attempts = 0 - while (attempts < 3) { - try { - const pushRes = execCommand(`git push ${remoteName} HEAD:${branch}`, { cwd }) - if (!pushRes.ok) throw new Error(pushRes.stderr) - logger.info(`✓ Pushed local changes to ${remoteName}`) - return - } catch (e) { - attempts++ - if (attempts >= 3) { - logger.error(`Error pushing to ${remoteName} after 3 attempts: ${e}`) - } else { - logger.warn(`Push attempt ${attempts} failed, retrying...`) - } - } + this.setRemote(remoteName, sshUrl, cwd) + let attempts = 0 + while (attempts < 3) { + const pushRes = execGit(['push', remoteName, `HEAD:${branch}`], { cwd }) + if (pushRes.ok) { + logger.info(`✓ Pushed local changes to ${remoteName}`) + return } - }) - this.operationQueue = operation - await operation - return true - } catch (e) { - logger.error(`Error pushing to sandbox: ${e}`) - return false - } + attempts++ + if (attempts >= 3) { + logger.error(`Error pushing to ${remoteName} after 3 attempts: ${pushRes.stderr}`) + throw new Error(pushRes.stderr) + } + logger.warn(`Push attempt ${attempts} failed, retrying...`) + } + }) + // Detach failures from the shared queue so one failed op doesn't block later ones. + HostGitManager.operationQueue = operation.catch(() => {}) + await operation + return true } private setRemote(remoteName: string, sshUrl: string, cwd: string): void { - try { - // remove existing remote if it exists - execCommand(`git remote remove ${remoteName}`, { cwd }) - execCommand(`git remote add ${remoteName} ${sshUrl}`, { cwd }) - } catch (e) { - logger.warn(`Could not set sandbox remote: ${e}`) + // remove existing remote if it exists + execGit(['remote', 'remove', remoteName], { cwd }) + const addRes = execGit(['remote', 'add', remoteName, sshUrl], { cwd }) + if (!addRes.ok) { + logger.warn(`Could not set sandbox remote: ${addRes.stderr}`) } } async pull(remoteName: string, sshUrl: string, branch: string, cwd: string, localBranch?: string): Promise { - const operation = this.operationQueue.then(async () => { + const operation = HostGitManager.operationQueue.then(async () => { this.setRemote(remoteName, sshUrl, cwd) let attempts = 0 let lastError: unknown = undefined @@ -223,23 +219,23 @@ export class HostGitManager { if (localBranch) { // Fetch into FETCH_HEAD only (never into refs/heads) so we don't hit // "refusing to fetch into branch checked out" when this branch is checked out. - const fetchRes = execCommand(`git fetch ${remoteName} ${branch}`, { cwd }) + const fetchRes = execGit(['fetch', remoteName, branch], { cwd }) if (!fetchRes.ok) throw new Error(fetchRes.stderr) - const updateRefRes = execCommand(`git update-ref refs/heads/${localBranch} FETCH_HEAD`, { cwd }) + const updateRefRes = execGit(['update-ref', `refs/heads/${localBranch}`, 'FETCH_HEAD'], { cwd }) if (!updateRefRes.ok) throw new Error(updateRefRes.stderr) // Only reset working directory if we're currently on this branch - const currentBranchRes = execCommand(`git rev-parse --abbrev-ref HEAD`, { cwd }) + const currentBranchRes = execGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd }) const currentBranch = currentBranchRes.ok ? currentBranchRes.stdout.trim() : '' if (currentBranch === localBranch) { - const resetRes = execCommand(`git reset --hard refs/heads/${localBranch}`, { cwd }) + const resetRes = execGit(['reset', '--hard', `refs/heads/${localBranch}`], { cwd }) if (!resetRes.ok) throw new Error(resetRes.stderr) } logger.info(`✓ Force pulled latest changes from sandbox into ${localBranch}`) } else { - const pullRes = execCommand(`git pull ${remoteName} ${branch}`, { cwd }) + const pullRes = execGit(['pull', remoteName, branch], { cwd }) if (!pullRes.ok) throw new Error(pullRes.stderr) logger.info('✓ Pulled latest changes from sandbox') } @@ -258,31 +254,31 @@ export class HostGitManager { // If we got here, all attempts failed. throw lastError ?? new Error('Pull failed after 3 attempts') }) - this.operationQueue = operation + // Detach failures from the shared queue so one failed op doesn't block later ones. + HostGitManager.operationQueue = operation.catch(() => {}) await operation } async push(remoteName: string, sshUrl: string, branch: string, cwd: string): Promise { - const operation = this.operationQueue.then(async () => { + const operation = HostGitManager.operationQueue.then(async () => { this.setRemote(remoteName, sshUrl, cwd) let attempts = 0 while (attempts < 3) { - try { - const pushRes = execCommand(`git push ${remoteName} HEAD:${branch}`, { cwd }) - if (!pushRes.ok) throw new Error(pushRes.stderr) + const pushRes = execGit(['push', remoteName, `HEAD:${branch}`], { cwd }) + if (pushRes.ok) { logger.info('✓ Pushed changes to sandbox') return - } catch (e) { - attempts++ - if (attempts >= 3) { - logger.error(`Error pushing to sandbox after 3 attempts: ${e}`) - } else { - logger.warn(`Push attempt ${attempts} failed, retrying...`) - } } + attempts++ + if (attempts >= 3) { + logger.error(`Error pushing to sandbox after 3 attempts: ${pushRes.stderr}`) + throw new Error(pushRes.stderr) + } + logger.warn(`Push attempt ${attempts} failed, retrying...`) } }) - this.operationQueue = operation + // Detach failures from the shared queue so one failed op doesn't block later ones. + HostGitManager.operationQueue = operation.catch(() => {}) await operation } } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/sandbox-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/sandbox-git-manager.ts index f1f20e2..29b1d37 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/sandbox-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/sandbox-git-manager.ts @@ -56,8 +56,15 @@ export class DaytonaSandboxGitManager { return true } + async getCurrentBranch(): Promise { + const branch = await this.runGitCommand('git rev-parse --abbrev-ref HEAD') + return branch.trim() + } + async resetToRemote(branch: string): Promise { - const checkout = await this.runGitCommand(`git checkout -B ${branch}`) + // Check out the branch the host just pushed. Using -f (not -B) checks out the + // pushed commit instead of resetting the branch ref to the sandbox's current HEAD. + const checkout = await this.runGitCommand(`git checkout -f ${branch}`) logger.info(`Checked out branch '${branch}': ${checkout}`) await this.runGitCommand('git reset --hard') await this.runGitCommand('git clean -fd') diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts index 04b97e4..8be5ea8 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/session-git-manager.ts @@ -48,14 +48,6 @@ export class SessionGitManager { return `ssh://${sshAccess.token}@ssh.app.daytona.io${this.repoPath}` } - /** - * Check if local git repository exists - * @returns true if repo exists, false otherwise - */ - hasLocalRepo(): boolean { - return this.hostGit.hasRepo(this.worktree) - } - /** * Initialize git in the sandbox and sync with host * Used when a new sandbox is created for a session @@ -120,7 +112,10 @@ export class SessionGitManager { const sshUrl = await this.getSshUrl() - await this.hostGit.pull(this.remoteName, sshUrl, this.branch, this.worktree, this.localBranch) + // Pull the branch the sandbox actually committed to, which may differ from the + // initial 'opencode' branch, so commits are never left unsynced. + const sandboxBranch = await this.sandboxGit.getCurrentBranch() + await this.hostGit.pull(this.remoteName, sshUrl, sandboxBranch, this.worktree, this.localBranch) toast.show({ title: 'Changes synced', message: `Changes have been synced to ${this.localBranch} in your local repository`, diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/index.ts b/packages/opencode-plugin/.opencode/plugin/daytona/index.ts index 54441e4..69d4ed6 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/index.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/index.ts @@ -24,6 +24,7 @@ */ import { join } from 'path' +import { homedir } from 'os' import { xdgData } from 'xdg-basedir' import type { PluginInput } from '@opencode-ai/plugin' import { setLogFilePath } from './core/logger' @@ -35,8 +36,9 @@ import { systemPromptTransform } from './plugins/system-transform' export type { EventSessionDeleted, LogLevel, SandboxInfo, SessionInfo, ProjectSessionData } from './core/types' -const LOG_FILE = join(xdgData, 'opencode', 'log', 'daytona.log') -const STORAGE_DIR = join(xdgData, 'opencode', 'storage', 'daytona') +const xdgDataDir = xdgData ?? join(homedir(), '.local', 'share') +const LOG_FILE = join(xdgDataDir, 'opencode', 'log', 'daytona.log') +const STORAGE_DIR = join(xdgDataDir, 'opencode', 'storage', 'daytona') const REPO_PATH = '/home/daytona/project' setLogFilePath(LOG_FILE) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/plugins/session-events.ts b/packages/opencode-plugin/.opencode/plugin/daytona/plugins/session-events.ts index 5c9eaa9..ca5e865 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/plugins/session-events.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/plugins/session-events.ts @@ -21,8 +21,10 @@ export async function eventHandlers(ctx: PluginInput, sessionManager: DaytonaSes if (event.type === EVENT_TYPE_SESSION_DELETED) { const sessionId = (event as EventSessionDeleted).properties.info.id try { - await sessionManager.deleteSandbox(sessionId, projectId) - toast.show({ title: 'Session deleted', message: 'Sandbox deleted successfully.', variant: 'success' }) + const deleted = await sessionManager.deleteSandbox(sessionId, projectId) + if (deleted) { + toast.show({ title: 'Session deleted', message: 'Sandbox deleted successfully.', variant: 'success' }) + } } catch (err: any) { toast.show({ title: 'Delete failed', message: err?.message || 'Failed to delete sandbox.', variant: 'error' }) throw err @@ -40,13 +42,9 @@ export async function eventHandlers(ctx: PluginInput, sessionManager: DaytonaSes `[idle] done sessionId=${sessionId} sandboxId=${sandbox.id} synced=${didSync} in ${Date.now() - start}ms`, ) } catch (err: any) { + // autoCommitAndPull already shows a toast; only log here to avoid a duplicate + // error toast and noisy propagation out of the idle event hook. logger.error(`[idle] error sessionId=${sessionId} in ${Date.now() - start}ms: ${err}`) - toast.show({ - title: 'Auto-commit error', - message: err?.message || 'Failed to auto-commit and pull.', - variant: 'error', - }) - throw err } } } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools.ts index 57a1a1e..bf9d368 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/tools.ts @@ -12,11 +12,9 @@ import { readTool } from './tools/read' import { writeTool } from './tools/write' import { editTool } from './tools/edit' import { multieditTool } from './tools/multiedit' -import { patchTool } from './tools/patch' import { lsTool } from './tools/ls' import { globTool } from './tools/glob' import { grepTool } from './tools/grep' -import { lspTool } from './tools/lsp' import { getPreviewURLTool } from './tools/get-preview-url' import type { DaytonaSessionManager } from './core/session-manager' @@ -35,11 +33,9 @@ export function createDaytonaTools( write: writeTool(sessionManager, projectId, worktree, pluginCtx), edit: editTool(sessionManager, projectId, worktree, pluginCtx), multiedit: multieditTool(sessionManager, projectId, worktree, pluginCtx), - patch: patchTool(sessionManager, projectId, worktree, pluginCtx), ls: lsTool(sessionManager, projectId, worktree, pluginCtx), glob: globTool(sessionManager, projectId, worktree, pluginCtx), grep: grepTool(sessionManager, projectId, worktree, pluginCtx), - lsp: lspTool(sessionManager, projectId, worktree, pluginCtx), getPreviewURL: getPreviewURLTool(sessionManager, projectId, worktree, pluginCtx), } } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools/bash.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools/bash.ts index c339279..e3757c2 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools/bash.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/tools/bash.ts @@ -4,6 +4,7 @@ */ import { z } from 'zod' +import { DaytonaNotFoundError } from '@daytona/sdk' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' import type { DaytonaSessionManager } from '../core/session-manager' @@ -28,7 +29,10 @@ export const bashTool = ( const execSessionId = `exec-session-${sessionId}` try { await sandbox.process.getSession(execSessionId) - } catch { + } catch (err) { + if (!(err instanceof DaytonaNotFoundError)) { + throw err + } await sandbox.process.createSession(execSessionId) } await sandbox.process.executeSessionCommand(execSessionId, { diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools/edit.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools/edit.ts index 3057d71..c5aead8 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools/edit.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/tools/edit.ts @@ -25,6 +25,9 @@ export const editTool = ( const buffer = await sandbox.fs.downloadFile(args.filePath) const decoder = new TextDecoder() const content = decoder.decode(buffer) + if (!content.includes(args.oldString)) { + throw new Error(`oldString not found in ${args.filePath}; no changes were made.`) + } const newContent = content.replace(args.oldString, args.newString) await sandbox.fs.uploadFile(Buffer.from(newContent), args.filePath) return `Edited ${args.filePath}` diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools/get-preview-url.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools/get-preview-url.ts index 54e755b..0eccabc 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools/get-preview-url.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/tools/get-preview-url.ts @@ -16,7 +16,7 @@ export const getPreviewURLTool = ( ) => ({ description: 'Gets a preview URL for the Daytona sandbox', args: { - port: z.number(), + port: z.number().int().min(1).max(65535), }, async execute(args: { port: number }, ctx: ToolContext) { const sandbox = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools/grep.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools/grep.ts index eeb08ec..3607f6c 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools/grep.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/tools/grep.ts @@ -26,6 +26,14 @@ export const grepTool = ( throw new Error('Work directory not available') } const matches = await sandbox.fs.findFiles(workDir, args.pattern) - return matches.map((m: Match) => `${m.file}:${m.line}: ${m.content}`).join('\n') + const maxMatches = 100 + const formatted = matches + .slice(0, maxMatches) + .map((m: Match) => `${m.file}:${m.line}: ${m.content}`) + .join('\n') + if (matches.length > maxMatches) { + return `${formatted}\n... (${matches.length - maxMatches} more matches truncated; refine your pattern)` + } + return formatted }, }) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools/lsp.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools/lsp.ts deleted file mode 100644 index ba14eec..0000000 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools/lsp.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Copyright Daytona Platforms Inc. - * SPDX-License-Identifier: Apache-2.0 - */ - -import { z } from 'zod' -import type { PluginInput } from '@opencode-ai/plugin' -import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { DaytonaSessionManager } from '../core/session-manager' - -export const lspTool = ( - sessionManager: DaytonaSessionManager, - projectId: string, - worktree: string, - pluginCtx: PluginInput, -) => ({ - description: 'LSP operation in Daytona sandbox (code intelligence)', - args: { - op: z.string(), - filePath: z.string(), - line: z.number(), - }, - async execute(args: { op: string; filePath: string; line: number }, ctx: ToolContext) { - return `LSP operations are not yet implemented in the Daytona plugin.` - }, -}) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools/multiedit.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools/multiedit.ts index a340fe3..75c579a 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools/multiedit.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/tools/multiedit.ts @@ -31,6 +31,15 @@ export const multieditTool = ( let content = decoder.decode(buffer) for (const edit of args.edits) { + const occurrences = content.split(edit.oldString).length - 1 + if (occurrences === 0) { + throw new Error(`oldString not found in ${args.filePath}; no changes were made: ${JSON.stringify(edit.oldString)}`) + } + if (occurrences > 1) { + throw new Error( + `oldString is ambiguous (${occurrences} matches) in ${args.filePath}; no changes were made: ${JSON.stringify(edit.oldString)}`, + ) + } content = content.replace(edit.oldString, edit.newString) } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools/patch.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools/patch.ts deleted file mode 100644 index 389992a..0000000 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools/patch.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Copyright Daytona Platforms Inc. - * SPDX-License-Identifier: Apache-2.0 - */ - -import { z } from 'zod' -import type { PluginInput } from '@opencode-ai/plugin' -import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { DaytonaSessionManager } from '../core/session-manager' - -export const patchTool = ( - sessionManager: DaytonaSessionManager, - projectId: string, - worktree: string, - pluginCtx: PluginInput, -) => ({ - description: 'Applies a patch to the project in Daytona sandbox', - args: { - patchText: z.string().describe('The full patch text that describes all changes to be made'), - }, - async execute(args: { filePath: string; oldSnippet: string; newSnippet: string }, ctx: ToolContext) { - return `Patch operations are not yet implemented in the Daytona plugin.` - }, -}) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools/write.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools/write.ts index 974405e..886c212 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools/write.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/tools/write.ts @@ -22,6 +22,6 @@ export const writeTool = ( async execute(args: { filePath: string; content: string }, ctx: ToolContext) { const sandbox = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) await sandbox.fs.uploadFile(Buffer.from(args.content), args.filePath) - return `Written ${args.content.length} bytes to ${args.filePath}` + return `Written ${Buffer.byteLength(args.content, 'utf8')} bytes to ${args.filePath}` }, }) diff --git a/packages/opencode-plugin/.opencode/plugin/index.ts b/packages/opencode-plugin/.opencode/plugin/index.ts index 81728da..a56047d 100644 --- a/packages/opencode-plugin/.opencode/plugin/index.ts +++ b/packages/opencode-plugin/.opencode/plugin/index.ts @@ -8,4 +8,4 @@ * Re-exports the default plugin from daytona. */ -export { default } from './daytona/index.js' +export { default } from './daytona/index' diff --git a/packages/opencode-plugin/README.md b/packages/opencode-plugin/README.md index 746e358..24d99ed 100644 --- a/packages/opencode-plugin/README.md +++ b/packages/opencode-plugin/README.md @@ -30,7 +30,7 @@ To install the plugin globally, edit `~/.config/opencode/opencode.json`. This plugin requires a [Daytona account](https://www.daytona.io/) and [Daytona API key](https://app.daytona.io/dashboard/keys) to create sandboxes. -Set your Daytona API key and URL as environment variables: +Set your Daytona API key as an environment variable: ```bash export DAYTONA_API_KEY="your-api-key" @@ -108,8 +108,8 @@ The plugin only synchronizes changes from the sandbox to your system. To pass lo The plugin keeps track of which sandbox belongs to each OpenCode project using local state files. This data is stored in a separate JSON file for each project: -- On macOS: `~/.local/share/opencode/storage/daytona/[projectid].json`. -- On Windows: `%LOCALAPPDATA%\opencode\storage\daytona\[projectid].json`. +- Default (when `XDG_DATA_HOME` is unset): `~/.local/share/opencode/storage/daytona/[projectid].json`. +- When `XDG_DATA_HOME` is set: `$XDG_DATA_HOME/opencode/storage/daytona/[projectid].json`. Each JSON file contains the sandbox metadata for each session in the project, including when the sandbox was created, and when it was last used. @@ -173,7 +173,7 @@ The published package contains the compiled `.js`/`.d.ts`; the `.ts` sources are #### Test the built package -After building, create a test project and add a plugin file to load the built plugin (replace `[ABSOLUTE_PATH_TO_DAYTONA]` with your clone path, e.g. `/Users/you/daytona`): +After building, create a test project and add a plugin file to load the built plugin (replace `[ABSOLUTE_PATH_TO_REPO]` with your clone path, e.g. `/Users/you/integrations`): ```bash mkdir -p ~/myproject && cd ~/myproject From 2636208f2af1e2f4c55a7e502d734f0e18863e33 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Wed, 8 Jul 2026 12:59:20 +0000 Subject: [PATCH 2/5] fix: Cubic suggestions Signed-off-by: Mislav Ivanda --- .../daytona/core/project-data-storage.ts | 27 +++++++--- .../plugin/daytona/core/session-manager.ts | 20 ++++++-- .../plugin/daytona/git/host-git-manager.ts | 49 ++++++++++++------- .../.opencode/plugin/daytona/tools/edit.ts | 11 ++++- .../plugin/daytona/tools/multiedit.ts | 5 ++ 5 files changed, 82 insertions(+), 30 deletions(-) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts b/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts index 9ed3119..99476af 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts @@ -26,22 +26,31 @@ export class ProjectDataStorage { } /** - * Get the file path for a project's session data + * Get the file path for a project's session data. + * encodeURIComponent gives a reversible, collision-free encoding that also + * strips path separators, so a projectId can't traverse outside storageDir + * and distinct ids can't collide onto the same file. */ private getProjectFilePath(projectId: string): string { - // Strip path separators so a projectId can never traverse outside storageDir. - const safeId = projectId.replace(/[/\\\0]/g, '_') - return join(this.storageDir, `${safeId}.json`) + return join(this.storageDir, `${encodeURIComponent(projectId)}.json`) } /** - * List known project IDs from storage. + * List known project IDs from storage, decoded to the canonical form used by callers. */ private listProjectIds(): string[] { try { return readdirSync(this.storageDir) .filter((name) => name.endsWith('.json')) - .map((name) => name.slice(0, -'.json'.length)) + .map((name) => { + const encoded = name.slice(0, -'.json'.length) + try { + return decodeURIComponent(encoded) + } catch { + // Malformed filename (not our encoding): fall back to raw name so it stays visible + return encoded + } + }) } catch (err) { logger.error(`Failed to list project data files: ${err}`) return [] @@ -123,8 +132,10 @@ export class ProjectDataStorage { for (const projectId of this.listProjectIds()) { const data = this.load(projectId) const session = data?.sessions?.[sessionId] - if (session) { - return { projectId, worktree: data!.worktree, session } + if (session && data) { + // Return the canonical projectId stored inside the JSON, not the filename-derived + // one, so callers reusing this value for cleanup always match the source of truth. + return { projectId: data.projectId, worktree: data.worktree, session } } } return undefined diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts index 5052a57..7520ad1 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/core/session-manager.ts @@ -151,10 +151,18 @@ export class DaytonaSessionManager { return sandbox } catch (err) { - // Stored sandbox is stale/deleted: drop the mapping and create a replacement below. - logger.error(`Failed to reconnect to sandbox ${existing.id}; creating a replacement: ${err}`) - this.sessionSandboxes.delete(sessionId) - this.dataStorage.removeSession(projectId, worktree, sessionId) + // Only treat 404 as "sandbox is confirmed gone" — clear the mapping and fall through + // to create a replacement. For transient errors (network, auth, timeout, provisioning), + // preserve the mapping and propagate so the caller can retry later without losing the + // session→sandbox binding and its branchNumber. + if (err instanceof DaytonaNotFoundError) { + logger.error(`Sandbox ${existing.id} no longer exists; creating a replacement.`) + this.sessionSandboxes.delete(sessionId) + this.dataStorage.removeSession(projectId, worktree, sessionId) + } else { + logger.error(`Failed to reconnect to sandbox ${existing.id}: ${err}`) + throw err + } } } @@ -247,7 +255,11 @@ export class DaytonaSessionManager { sandboxGone = true logger.warn(`Sandbox ${stored.session.sandboxId} no longer exists; clearing stale mapping.`) } else { + // Non-404: we cannot confirm the sandbox is gone. Surface the error so the + // event handler shows a "Delete failed" toast instead of silently reporting + // success while leaving a running sandbox on the Daytona account. logger.error(`Failed to reconnect to sandbox ${stored.session.sandboxId}: ${err}`) + throw err } } } else { diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts index fcb257a..29f0968 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts @@ -34,9 +34,33 @@ function execGit(args: string[], options: ExecOptions = {}): ExecResult { } export class HostGitManager { - // Static so a single queue serializes git ops across every instance sharing the - // worktree; per-instance queues would let concurrent session syncs race. - private static operationQueue: Promise = Promise.resolve() + // Per-worktree serialization: one queue per cwd. Same-worktree ops still serialize + // (needed to avoid .git/index.lock races), but different worktrees run in parallel + // instead of contending on a single global queue. + private static operationQueues = new Map>() + + /** + * Chain `fn` onto the queue for `cwd`. The stored promise is `.catch`-wrapped so a + * failure doesn't poison the chain; callers still see errors via `await` on the + * returned promise. When the tail settles, its map entry is cleared to avoid leaking + * entries for worktrees that are no longer active. + */ + private static enqueue(cwd: string, fn: () => Promise): Promise { + const prev = HostGitManager.operationQueues.get(cwd) ?? Promise.resolve() + const operation = prev.then(fn) + const stored: Promise = operation.then( + () => undefined, + () => undefined, + ) + HostGitManager.operationQueues.set(cwd, stored) + stored.then(() => { + if (HostGitManager.operationQueues.get(cwd) === stored) { + HostGitManager.operationQueues.delete(cwd) + } + }) + return operation + } + /** Cached OID of an empty commit used to reserve branch refs (branches must point at commits, not blobs). */ private emptyCommitOidCache = new Map() @@ -168,7 +192,7 @@ export class HostGitManager { return false } logger.info(`Pushing to ${remoteName} (${sshUrl}) on branch ${branch}`) - const operation = HostGitManager.operationQueue.then(async () => { + await HostGitManager.enqueue(cwd, async () => { const statusRes = execGit(['status', '--porcelain'], { cwd }) if (!statusRes.ok) { throw new Error(statusRes.stderr) @@ -193,23 +217,20 @@ export class HostGitManager { logger.warn(`Push attempt ${attempts} failed, retrying...`) } }) - // Detach failures from the shared queue so one failed op doesn't block later ones. - HostGitManager.operationQueue = operation.catch(() => {}) - await operation return true } private setRemote(remoteName: string, sshUrl: string, cwd: string): void { - // remove existing remote if it exists + // Remote may not exist yet — ignore this result. `remote add` below is the check that matters. execGit(['remote', 'remove', remoteName], { cwd }) const addRes = execGit(['remote', 'add', remoteName, sshUrl], { cwd }) if (!addRes.ok) { - logger.warn(`Could not set sandbox remote: ${addRes.stderr}`) + throw new Error(`Failed to configure sandbox remote '${remoteName}': ${addRes.stderr}`) } } async pull(remoteName: string, sshUrl: string, branch: string, cwd: string, localBranch?: string): Promise { - const operation = HostGitManager.operationQueue.then(async () => { + await HostGitManager.enqueue(cwd, async () => { this.setRemote(remoteName, sshUrl, cwd) let attempts = 0 let lastError: unknown = undefined @@ -254,13 +275,10 @@ export class HostGitManager { // If we got here, all attempts failed. throw lastError ?? new Error('Pull failed after 3 attempts') }) - // Detach failures from the shared queue so one failed op doesn't block later ones. - HostGitManager.operationQueue = operation.catch(() => {}) - await operation } async push(remoteName: string, sshUrl: string, branch: string, cwd: string): Promise { - const operation = HostGitManager.operationQueue.then(async () => { + await HostGitManager.enqueue(cwd, async () => { this.setRemote(remoteName, sshUrl, cwd) let attempts = 0 while (attempts < 3) { @@ -277,8 +295,5 @@ export class HostGitManager { logger.warn(`Push attempt ${attempts} failed, retrying...`) } }) - // Detach failures from the shared queue so one failed op doesn't block later ones. - HostGitManager.operationQueue = operation.catch(() => {}) - await operation } } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools/edit.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools/edit.ts index c5aead8..430aee4 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools/edit.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/tools/edit.ts @@ -25,9 +25,18 @@ export const editTool = ( const buffer = await sandbox.fs.downloadFile(args.filePath) const decoder = new TextDecoder() const content = decoder.decode(buffer) - if (!content.includes(args.oldString)) { + if (args.oldString === '') { + throw new Error(`oldString must be non-empty; refusing to prepend to ${args.filePath}.`) + } + const occurrences = content.split(args.oldString).length - 1 + if (occurrences === 0) { throw new Error(`oldString not found in ${args.filePath}; no changes were made.`) } + if (occurrences > 1) { + throw new Error( + `oldString is ambiguous (${occurrences} matches) in ${args.filePath}; no changes were made.`, + ) + } const newContent = content.replace(args.oldString, args.newString) await sandbox.fs.uploadFile(Buffer.from(newContent), args.filePath) return `Edited ${args.filePath}` diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools/multiedit.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools/multiedit.ts index 75c579a..0588e8e 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools/multiedit.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/tools/multiedit.ts @@ -31,6 +31,11 @@ export const multieditTool = ( let content = decoder.decode(buffer) for (const edit of args.edits) { + if (edit.oldString === '') { + throw new Error( + `oldString must be non-empty; refusing to prepend to ${args.filePath}: ${JSON.stringify(edit)}`, + ) + } const occurrences = content.split(edit.oldString).length - 1 if (occurrences === 0) { throw new Error(`oldString not found in ${args.filePath}; no changes were made: ${JSON.stringify(edit.oldString)}`) From 840013405a28cce0693309381f74a0d6d1db1d08 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Wed, 8 Jul 2026 13:15:06 +0000 Subject: [PATCH 3/5] fix: Cubic suggestions v2 Signed-off-by: Mislav Ivanda --- .../daytona/core/project-data-storage.ts | 35 +++++++------ .../plugin/daytona/git/host-git-manager.ts | 49 ++++++++++++++----- .../plugin/daytona/tools/multiedit.ts | 12 ++--- 3 files changed, 65 insertions(+), 31 deletions(-) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts b/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts index 99476af..2abd75e 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts @@ -37,20 +37,23 @@ export class ProjectDataStorage { /** * List known project IDs from storage, decoded to the canonical form used by callers. + * Filenames that can't be decoded (e.g. hand-created files with invalid percent escapes) + * are skipped: exposing them would return an id that can't round-trip through + * getProjectFilePath, causing subsequent load/save/remove to silently target the wrong file. */ private listProjectIds(): string[] { try { - return readdirSync(this.storageDir) - .filter((name) => name.endsWith('.json')) - .map((name) => { - const encoded = name.slice(0, -'.json'.length) - try { - return decodeURIComponent(encoded) - } catch { - // Malformed filename (not our encoding): fall back to raw name so it stays visible - return encoded - } - }) + const ids: string[] = [] + for (const name of readdirSync(this.storageDir)) { + if (!name.endsWith('.json')) continue + const encoded = name.slice(0, -'.json'.length) + try { + ids.push(decodeURIComponent(encoded)) + } catch { + logger.warn(`Skipping project data file with undecodable name: ${name}`) + } + } + return ids } catch (err) { logger.error(`Failed to list project data files: ${err}`) return [] @@ -133,9 +136,13 @@ export class ProjectDataStorage { const data = this.load(projectId) const session = data?.sessions?.[sessionId] if (session && data) { - // Return the canonical projectId stored inside the JSON, not the filename-derived - // one, so callers reusing this value for cleanup always match the source of truth. - return { projectId: data.projectId, worktree: data.worktree, session } + // Return the filename-derived projectId (the value that maps back to the file we + // just loaded), NOT data.projectId. The delete cleanup path passes this value to + // removeSession → load → getProjectFilePath; if we returned the raw canonical id + // and it doesn't round-trip identically (e.g. legacy files with a different + // sanitization scheme), the cleanup would silently target a different file and + // leave the stale mapping behind. + return { projectId, worktree: data.worktree, session } } } return undefined diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts index 29f0968..f5c8a06 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts @@ -5,6 +5,7 @@ import { logger } from '../core/logger' import { spawnSync } from 'child_process' +import { isAbsolute, resolve as pathResolve } from 'path' type ExecResult = { ok: boolean @@ -34,28 +35,54 @@ function execGit(args: string[], options: ExecOptions = {}): ExecResult { } export class HostGitManager { - // Per-worktree serialization: one queue per cwd. Same-worktree ops still serialize - // (needed to avoid .git/index.lock races), but different worktrees run in parallel - // instead of contending on a single global queue. + // Per-repo serialization: one queue per git-common-dir. Linked worktrees (git worktree + // add) of the same repo share `.git/config` and refs, so they must share a queue to + // avoid racing on `remote add/remove` and `.git/config.lock`. Different repos still + // proceed in parallel. private static operationQueues = new Map>() + // cwd → queue-key cache. `git rev-parse --git-common-dir` shells out; caching avoids + // running it on every enqueue. Repo layout doesn't move at runtime, so this is stable. + private static queueKeyCache = new Map() + + /** + * Resolve the serialization key for a worktree: the absolute path of its shared + * git dir (common across linked worktrees of the same repo). Falls back to the cwd + * itself if git can't identify a repo, so per-directory serialization still applies. + */ + private static queueKeyFor(cwd: string): string { + const cached = HostGitManager.queueKeyCache.get(cwd) + if (cached) return cached + const res = execGit(['rev-parse', '--git-common-dir'], { cwd }) + let key: string + if (!res.ok) { + key = cwd + } else { + const raw = res.stdout.trim() + key = isAbsolute(raw) ? raw : pathResolve(cwd, raw) + } + HostGitManager.queueKeyCache.set(cwd, key) + return key + } + /** - * Chain `fn` onto the queue for `cwd`. The stored promise is `.catch`-wrapped so a - * failure doesn't poison the chain; callers still see errors via `await` on the - * returned promise. When the tail settles, its map entry is cleared to avoid leaking - * entries for worktrees that are no longer active. + * Chain `fn` onto the queue for the repo containing `cwd`. The stored promise is + * always-resolves so a failure doesn't poison the chain; callers still see errors via + * `await` on the returned promise. Cleared from the map when the tail settles to avoid + * leaking entries for repos that are no longer active. */ private static enqueue(cwd: string, fn: () => Promise): Promise { - const prev = HostGitManager.operationQueues.get(cwd) ?? Promise.resolve() + const queueKey = HostGitManager.queueKeyFor(cwd) + const prev = HostGitManager.operationQueues.get(queueKey) ?? Promise.resolve() const operation = prev.then(fn) const stored: Promise = operation.then( () => undefined, () => undefined, ) - HostGitManager.operationQueues.set(cwd, stored) + HostGitManager.operationQueues.set(queueKey, stored) stored.then(() => { - if (HostGitManager.operationQueues.get(cwd) === stored) { - HostGitManager.operationQueues.delete(cwd) + if (HostGitManager.operationQueues.get(queueKey) === stored) { + HostGitManager.operationQueues.delete(queueKey) } }) return operation diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/tools/multiedit.ts b/packages/opencode-plugin/.opencode/plugin/daytona/tools/multiedit.ts index 0588e8e..69a51ab 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/tools/multiedit.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/tools/multiedit.ts @@ -30,19 +30,19 @@ export const multieditTool = ( const decoder = new TextDecoder() let content = decoder.decode(buffer) - for (const edit of args.edits) { + for (const [i, edit] of args.edits.entries()) { if (edit.oldString === '') { - throw new Error( - `oldString must be non-empty; refusing to prepend to ${args.filePath}: ${JSON.stringify(edit)}`, - ) + throw new Error(`edits[${i}].oldString is empty; refusing to prepend to ${args.filePath}.`) } const occurrences = content.split(edit.oldString).length - 1 if (occurrences === 0) { - throw new Error(`oldString not found in ${args.filePath}; no changes were made: ${JSON.stringify(edit.oldString)}`) + throw new Error( + `edits[${i}].oldString not found in ${args.filePath}: ${JSON.stringify(edit.oldString)}`, + ) } if (occurrences > 1) { throw new Error( - `oldString is ambiguous (${occurrences} matches) in ${args.filePath}; no changes were made: ${JSON.stringify(edit.oldString)}`, + `edits[${i}].oldString is ambiguous (${occurrences} matches) in ${args.filePath}: ${JSON.stringify(edit.oldString)}`, ) } content = content.replace(edit.oldString, edit.newString) From 41e71a8b39adbdbc5c5731c6709aef8549af5207 Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Wed, 8 Jul 2026 13:36:29 +0000 Subject: [PATCH 4/5] fix: Cubic suggestions v3 Signed-off-by: Mislav Ivanda --- .../daytona/core/project-data-storage.ts | 30 +++++++++---------- .../plugin/daytona/git/host-git-manager.ts | 25 +++++++++++----- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts b/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts index 2abd75e..4b5255f 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts @@ -105,7 +105,7 @@ export class ProjectDataStorage { destination.sessions[sessionId] = found // Prefer the worktree for the project we're actually operating on. destination.worktree = worktree - this.save(projectId, destination.worktree, destination.sessions) + this.save(projectId, destination) if (!this.load(projectId)?.sessions?.[sessionId]) { logger.error(`Migration of session ${sessionId} to project ${projectId} did not persist; leaving source intact`) @@ -115,7 +115,7 @@ export class ProjectDataStorage { // Destination is safe; now remove from the source (best effort). try { delete otherData!.sessions[sessionId] - this.save(otherProjectId, otherData!.worktree, otherData!.sessions) + this.save(otherProjectId, otherData!) } catch (err) { logger.warn(`Failed to remove session ${sessionId} from project ${otherProjectId}: ${err}`) } @@ -149,21 +149,21 @@ export class ProjectDataStorage { } /** - * Save project session data to disk + * Save project session data to disk. + * + * `storageKey` identifies WHICH FILE on disk to write (round-trips through + * getProjectFilePath). `projectData.projectId` is the CANONICAL id written into + * the JSON body — kept separate so callers who reached a file via a filename-derived + * key (e.g. findSession → removeSession for legacy files) don't clobber the + * pre-existing canonical id with the storage key. */ - save(projectId: string, worktree: string, sessions: Record): void { - const filePath = this.getProjectFilePath(projectId) - const projectData: ProjectSessionData = { - projectId, - worktree, - sessions, - } - + save(storageKey: string, projectData: ProjectSessionData): void { + const filePath = this.getProjectFilePath(storageKey) try { writeFileSync(filePath, JSON.stringify(projectData, null, 2)) - logger.info(`Saved project data for ${projectId}`) + logger.info(`Saved project data for ${projectData.projectId}`) } catch (err) { - logger.error(`Failed to save project data for ${projectId}: ${err}`) + logger.error(`Failed to save project data for ${projectData.projectId} at ${filePath}: ${err}`) } } @@ -214,7 +214,7 @@ export class ProjectDataStorage { } } - this.save(projectId, worktree, projectData.sessions) + this.save(projectId, projectData) } /** @@ -224,7 +224,7 @@ export class ProjectDataStorage { const projectData = this.load(projectId) if (projectData && projectData.sessions[sessionId]) { delete projectData.sessions[sessionId] - this.save(projectId, worktree, projectData.sessions) + this.save(projectId, projectData) } } } diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts b/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts index f5c8a06..e40e3dd 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/git/host-git-manager.ts @@ -5,6 +5,7 @@ import { logger } from '../core/logger' import { spawnSync } from 'child_process' +import { realpathSync } from 'fs' import { isAbsolute, resolve as pathResolve } from 'path' type ExecResult = { @@ -46,20 +47,28 @@ export class HostGitManager { private static queueKeyCache = new Map() /** - * Resolve the serialization key for a worktree: the absolute path of its shared - * git dir (common across linked worktrees of the same repo). Falls back to the cwd - * itself if git can't identify a repo, so per-directory serialization still applies. + * Resolve the serialization key for a worktree: the CANONICAL absolute path of its + * shared git dir (common across linked worktrees of the same repo). Canonicalizing + * with realpath collapses symlinked aliases onto the same key, so two callers + * reaching the same repo via different symlink paths still share a queue and can't + * race on `.git/config`. Falls back to (canonical) cwd if git can't identify a repo. */ private static queueKeyFor(cwd: string): string { const cached = HostGitManager.queueKeyCache.get(cwd) if (cached) return cached const res = execGit(['rev-parse', '--git-common-dir'], { cwd }) + const rawPath = !res.ok + ? cwd + : (() => { + const out = res.stdout.trim() + return isAbsolute(out) ? out : pathResolve(cwd, out) + })() let key: string - if (!res.ok) { - key = cwd - } else { - const raw = res.stdout.trim() - key = isAbsolute(raw) ? raw : pathResolve(cwd, raw) + try { + key = realpathSync(rawPath) + } catch { + // Path may have been deleted between git resolution and our stat; use as-is + key = rawPath } HostGitManager.queueKeyCache.set(cwd, key) return key From f5d8be2541e8f72e45740158bbe85f322d094c8c Mon Sep 17 00:00:00 2001 From: Mislav Ivanda Date: Wed, 8 Jul 2026 13:43:08 +0000 Subject: [PATCH 5/5] fix: updateSession worktree update Signed-off-by: Mislav Ivanda --- .../.opencode/plugin/daytona/core/project-data-storage.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts b/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts index 4b5255f..57db5ad 100644 --- a/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts +++ b/packages/opencode-plugin/.opencode/plugin/daytona/core/project-data-storage.ts @@ -214,6 +214,10 @@ export class ProjectDataStorage { } } + // Refresh worktree from the current call (state that can change over time), but + // NEVER refresh projectData.projectId — that's identity, set at creation, and + // preserving it is the whole point of save()'s two-parameter API. + projectData.worktree = worktree this.save(projectId, projectData) }