diff --git a/.opencode/plugin/tensorlake/core/client.ts b/.opencode/plugin/tensorlake/core/client.ts index a6fc091..9e68808 100644 --- a/.opencode/plugin/tensorlake/core/client.ts +++ b/.opencode/plugin/tensorlake/core/client.ts @@ -1,6 +1,6 @@ import { RemoteAPIError, Sandbox, SandboxConnectionError, SandboxNotFoundError } from 'tensorlake' +import type { FileSystemMount } from 'tensorlake' import { execFileSync } from 'child_process' -import { PROJECT_KEY_PREFIX } from './credentials.js' import { logger } from './logger.js' const MANAGEMENT_API = process.env.TENSORLAKE_API_URL ?? 'https://api.tensorlake.ai' @@ -35,7 +35,15 @@ type HandleEntry = { sandbox?: Sandbox } -export class TensorLakeClient { +export type ProcessStatusInfo = { + pid: number + status: string + exitCode?: number + signal?: number + command: string +} + +export class TensorlakeClient { // Connected handles keyed by sandboxId, so repeated operations reuse the // resolved proxy routing instead of re-resolving on every call. The pending // connect promise is cached (not the resolved handle) so concurrent calls @@ -58,31 +66,10 @@ export class TensorLakeClient { return this.resolveKey() ?? '' } - private warnedProjectKeyScopeOverride = false - + // Ingress derives the organization/project scope from the API key itself + // (SDK >= 0.5.114); explicit scope options are no longer forwarded. private clientOptions() { - const apiKey = this.getApiKey() - const organizationId = process.env.TENSORLAKE_ORGANIZATION_ID - const projectId = process.env.TENSORLAKE_PROJECT_ID - // A project API key carries its own org/project scope. Forwarding env IDs - // alongside it could point requests at a different project than the key - // authorizes, so the key's scope wins and the variables are ignored. - if (apiKey.startsWith(PROJECT_KEY_PREFIX) && (organizationId || projectId)) { - if (!this.warnedProjectKeyScopeOverride) { - this.warnedProjectKeyScopeOverride = true - logger.warn( - 'TENSORLAKE_ORGANIZATION_ID/TENSORLAKE_PROJECT_ID are set, but the API key is a project key ' + - `(${PROJECT_KEY_PREFIX}...) that carries its own scope; ignoring the environment variables.`, - ) - } - return { apiKey, apiUrl: MANAGEMENT_API } - } - return { - apiKey, - apiUrl: MANAGEMENT_API, - ...(organizationId ? { organizationId } : {}), - ...(projectId ? { projectId } : {}), - } + return { apiKey: this.getApiKey(), apiUrl: MANAGEMENT_API } } private connectSandbox(sandboxId: string): Promise { @@ -167,7 +154,7 @@ export class TensorLakeClient { }) } - async createSandbox(opts: { image?: string; name?: string; timeoutSecs?: number } = {}): Promise { + async createSandbox(opts: { image?: string; name?: string; timeoutSecs?: number; fileSystems?: FileSystemMount[] } = {}): Promise { const cpus = parseFloat(process.env.TENSORLAKE_CPUS ?? '2') const memoryMb = parseInt(process.env.TENSORLAKE_MEMORY_MB ?? '4096', 10) const ephemeralDiskMb = parseInt(process.env.TENSORLAKE_DISK_MB ?? '10240', 10) @@ -181,12 +168,29 @@ export class TensorLakeClient { diskMb: ephemeralDiskMb, ...(opts.name ? { name: opts.name } : {}), ...(opts.timeoutSecs ? { timeoutSecs: opts.timeoutSecs } : {}), + ...(opts.fileSystems?.length ? { fileSystems: opts.fileSystems } : {}), ...this.clientOptions(), }) this.handles.set(sandbox.sandboxId, { apiKey: this.getApiKey(), promise: Promise.resolve(sandbox), sandbox }) return { sandbox_id: sandbox.sandboxId, status: 'running' } } + async listSandboxFileSystems(sandboxId: string): Promise { + const info = await this.withSandbox(sandboxId, (sandbox) => sandbox.info()) + return info.fileSystems ?? [] + } + + async attachFileSystem(sandboxId: string, fileSystemId: string, mountPath: string): Promise { + // retry: false — a retried attach after a mid-flight failure could double-attach + await this.withSandbox(sandboxId, (sandbox) => sandbox.attachFileSystem(fileSystemId, mountPath), { retry: false }) + } + + async detachFileSystem(sandboxId: string, mountPath: string): Promise { + // retry: false — a retry after a mid-flight success would fail on the + // already-detached path and mask the real outcome + await this.withSandbox(sandboxId, (sandbox) => sandbox.detachFileSystem(mountPath), { retry: false }) + } + async getSandbox(sandboxId: string): Promise { const info = await this.withSandbox(sandboxId, (sandbox) => sandbox.info()) return { sandbox_id: info.sandboxId, status: info.status as unknown as string } @@ -260,7 +264,10 @@ export class TensorLakeClient { while (Date.now() < deadline) { const info = await this.getSandbox(sandboxId) if (info.status === 'running') return - if (info.status === 'terminated') throw new Error(`Sandbox ${sandboxId} was terminated`) + // 'timeout' is terminal like 'terminated' — fail fast instead of polling to the deadline + if (info.status === 'terminated' || info.status === 'timeout') { + throw new Error(`Sandbox ${sandboxId} is ${info.status}`) + } await new Promise((r) => setTimeout(r, 500)) } throw new Error(`Sandbox ${sandboxId} did not become running within ${timeoutMs}ms`) @@ -289,6 +296,41 @@ export class TensorLakeClient { } } + // Processes are started unnamed (non-managed) on purpose: the daemon keeps + // tracking them after exit or kill, so status and output stay queryable by PID. + async startBackgroundProcess(sandboxId: string, command: string, workingDir: string): Promise { + const info = await this.withSandbox( + sandboxId, + (sandbox) => + sandbox.startProcess('sh', { + args: ['-c', command], + workingDir, + }), + { retry: false }, + ) + return info.pid + } + + async getProcessStatus(sandboxId: string, pid: number): Promise { + const info = await this.withSandbox(sandboxId, (sandbox) => sandbox.getProcess(pid)) + return { + pid: info.pid, + status: info.status as unknown as string, + exitCode: info.exitCode, + signal: info.signal, + command: [info.command, ...(info.args ?? [])].join(' '), + } + } + + async getProcessOutput(sandboxId: string, pid: number): Promise { + const output = await this.withSandbox(sandboxId, (sandbox) => sandbox.getOutput(pid)) + return output.lines + } + + async killProcess(sandboxId: string, pid: number): Promise { + await this.withSandbox(sandboxId, (sandbox) => sandbox.killProcess(pid)) + } + async readFile(sandboxId: string, path: string): Promise { const data = await this.withSandbox(sandboxId, (sandbox) => sandbox.readFile(path)) return Buffer.from(data) diff --git a/.opencode/plugin/tensorlake/core/credentials.ts b/.opencode/plugin/tensorlake/core/credentials.ts index 5d5e4fb..e455149 100644 --- a/.opencode/plugin/tensorlake/core/credentials.ts +++ b/.opencode/plugin/tensorlake/core/credentials.ts @@ -61,15 +61,16 @@ export function resolveApiKey(): string | undefined { /** * OpenCode's built-in API-key prompt cannot validate the key at login (the * plugin never sees the masked value), so keys are checked here on first use. - * A non-project key still works when its scope is supplied via env vars, so + * The SDK (>= 0.5.114) accepts only project API keys — ingress derives the + * project scope from the key, and Personal Access Tokens are not supported — + * but the key may still be valid in ways this prefix check cannot see, so * this is a warning, not a hard failure. */ export function projectKeyWarning(apiKey: string): string | undefined { if (apiKey.startsWith(PROJECT_KEY_PREFIX)) return undefined - if (process.env.TENSORLAKE_ORGANIZATION_ID && process.env.TENSORLAKE_PROJECT_ID) return undefined return ( `The stored key is not a project API key (${PROJECT_KEY_PREFIX}...). ` + 'Sandbox calls may fail. Re-run `opencode auth login` with a project API key from ' + - 'https://cloud.tensorlake.ai (Project → API Keys), or set TENSORLAKE_ORGANIZATION_ID and TENSORLAKE_PROJECT_ID.' + 'https://cloud.tensorlake.ai (Project → API Keys).' ) } diff --git a/.opencode/plugin/tensorlake/core/glob-match.ts b/.opencode/plugin/tensorlake/core/glob-match.ts new file mode 100644 index 0000000..02bde20 --- /dev/null +++ b/.opencode/plugin/tensorlake/core/glob-match.ts @@ -0,0 +1,126 @@ +/** + * Compiles a glob pattern into a RegExp that matches a whole path, using the + * same semantics as OpenCode's built-in glob tool: + * + * * any run of characters except `/` + * ? one character except `/` + * ** any run of characters, `/` included + * [...] a character class; a leading `!` or `^` negates it + * {a,b} alternation, which may nest + * + * `/` separates path segments, so `*.ts` matches only the top level and + * `src/**\/*.ts` is what descends. A `find -name` search cannot express this: + * it matches the basename alone and treats `/` as an ordinary character. + */ +export function globToRegExp(pattern: string): RegExp { + let out = '' + let braces = 0 + let i = 0 + while (i < pattern.length) { + const c = pattern[i] + if (c === '\\' && i + 1 < pattern.length) { + out += escapeLiteral(pattern[i + 1]) + i += 2 + continue + } + if (c === '*') { + let j = i + while (pattern[j] === '*') j++ + if (j - i >= 2) { + // `**/` also matches zero directories, so `src/**/*.ts` finds src/a.ts. + if (pattern[j] === '/') { + out += '(?:.*/)?' + j++ + } else { + out += '.*' + } + } else { + out += '[^/]*' + } + i = j + continue + } + if (c === '?') { + out += '[^/]' + i++ + continue + } + if (c === '[') { + const cls = readCharClass(pattern, i) + if (cls) { + out += cls.regex + i = cls.next + } else { + // Unterminated class: the bracket is just a bracket. + out += '\\[' + i++ + } + continue + } + if (c === '{') { + out += '(?:' + braces++ + i++ + continue + } + if (c === '}' && braces > 0) { + out += ')' + braces-- + i++ + continue + } + if (c === ',' && braces > 0) { + out += '|' + i++ + continue + } + out += escapeLiteral(c) + i++ + } + // Close any brace that was never closed, so the RegExp still compiles. + while (braces-- > 0) out += ')' + return new RegExp(`^${out}$`) +} + +function readCharClass(pattern: string, start: number): { regex: string; next: number } | undefined { + let j = start + 1 + let negated = false + if (pattern[j] === '!' || pattern[j] === '^') { + negated = true + j++ + } + let body = '' + // A `]` in the first position is a literal, not the terminator. + if (pattern[j] === ']') { + body += '\\]' + j++ + } + while (j < pattern.length && pattern[j] !== ']') { + const ch = pattern[j] + body += ch === '\\' || ch === '^' || ch === '[' ? `\\${ch}` : ch + j++ + } + if (j >= pattern.length || body === '') return undefined + return { regex: `[${negated ? '^' : ''}${body}]`, next: j + 1 } +} + +/** + * Splits off the leading path segments that contain no glob metacharacter. + * The search can then start at that subdirectory instead of walking the whole + * project: `src/**\/*.ts` only ever matches files under `src`. + */ +export function literalPrefix(pattern: string): string { + if (pattern.startsWith('/')) return '' + const segments = pattern.split('/') + const literal: string[] = [] + // The last segment is the filename part, never a directory to descend into. + for (const segment of segments.slice(0, -1)) { + if (/[*?[\]{}\\]/.test(segment) || segment === '' || segment === '..') break + literal.push(segment) + } + return literal.join('/') +} + +function escapeLiteral(ch: string): string { + return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch +} diff --git a/.opencode/plugin/tensorlake/core/project-context.ts b/.opencode/plugin/tensorlake/core/project-context.ts new file mode 100644 index 0000000..a93edcf --- /dev/null +++ b/.opencode/plugin/tensorlake/core/project-context.ts @@ -0,0 +1,64 @@ +import { createHash } from 'crypto' +import { existsSync, realpathSync } from 'fs' +import { resolve } from 'path' +import type { PluginInput } from '@opencode-ai/plugin' + +/** OpenCode's project id for a directory that is not in a version-controlled repo. */ +const GLOBAL_PROJECT_ID = 'global' + +export type ProjectContext = { + /** Absolute path of the local project on this machine. */ + worktree: string + /** + * Identity used for the sync resource name (hosted git repo or cloud volume) + * and the local session store. + */ + projectId: string +} + +/** + * Resolve the local project from the plugin input. + * + * OpenCode only fills `project.worktree` for a repository. A plain folder gets + * the shared 'global' project, whose worktree is the literal '/' — which is not + * a project path and must never be synced. In that case the session directory + * (`ctx.directory`) is the project, and the id is derived from it so that two + * different plain folders never share a volume, a hosted repo, or a sandbox. + */ +export function resolveProjectContext(ctx: PluginInput): ProjectContext { + const reported = ctx.project?.worktree ?? '' + const projectId = ctx.project?.id ?? '' + if (isUsableWorktree(reported) && projectId && projectId !== GLOBAL_PROJECT_ID) { + return { worktree: canonical(reported), projectId } + } + + const fallback = [ctx.worktree, ctx.directory].find(isUsableWorktree) + if (!fallback) return { worktree: '', projectId: projectId || GLOBAL_PROJECT_ID } + + const worktree = canonical(fallback) + return { worktree, projectId: projectId === GLOBAL_PROJECT_ID ? folderProjectId(worktree) : projectId } +} + +function isUsableWorktree(path: string | undefined): path is string { + if (!path || path === '/') return false + return existsSync(path) +} + +/** + * The path as the filesystem itself spells it. On a case-insensitive volume + * (macOS, Windows) the same folder can be entered as .../gtm/... or .../GTM/...; + * without this each spelling would hash to a different id and get its own + * cloud volume and sandbox. + */ +function canonical(path: string): string { + try { + return realpathSync.native(path) + } catch { + return resolve(path) + } +} + +/** Stable per-folder id for a plain (non-repository) directory. */ +function folderProjectId(worktree: string): string { + return `folder-${createHash('sha1').update(worktree).digest('hex').slice(0, 16)}` +} diff --git a/.opencode/plugin/tensorlake/core/project-sync.ts b/.opencode/plugin/tensorlake/core/project-sync.ts new file mode 100644 index 0000000..182dfd1 --- /dev/null +++ b/.opencode/plugin/tensorlake/core/project-sync.ts @@ -0,0 +1,1512 @@ +import { createHash } from 'crypto' +import { + existsSync, + readdirSync, + lstatSync, + statSync, + unlinkSync, + renameSync, + readFileSync, + writeFileSync, + mkdirSync, + chmodSync, + realpathSync, +} from 'fs' +import { homedir } from 'os' +import { join, basename, dirname, sep } from 'path' +import { posix } from 'path' +import { tmpdir } from 'os' +import { execFile } from 'child_process' +import { promisify } from 'util' +import { RepositoryClient, FilesystemClient } from 'tensorlake' +import type { FileSystemMount, FileEntry } from 'tensorlake' +import { logger } from './logger.js' +import type { TensorlakeClient } from './client.js' + +export type SyncMode = 'git' | 'volume' | 'mount' | 'off' + +// Directories that are always regenerable and expensive to upload. +const SKIP_DIRS = new Set([ + '.git', + 'node_modules', + '.venv', + 'venv', + '__pycache__', + '.next', + '.nuxt', + '.turbo', + '.cache', + 'dist', + 'build', + 'target', + '.DS_Store', +]) + +// Files larger than this are skipped in volume mode. +const MAX_FILE_BYTES = 100 * 1024 * 1024 + +// Uncommitted-changes patches larger than this are not synced into the sandbox. +const MAX_PATCH_BYTES = 50 * 1024 * 1024 + +// Older versions wrote the sync manifest into the volume root, i.e. into the +// tree mounted as the agent's project dir. That let sandbox code rewrite the +// file that drives laptop-side deletions, so the manifest now lives on the +// laptop (see syncManifestPath) and this legacy on-volume copy is scrubbed. +const LEGACY_VOLUME_MANIFEST_PATH = '.tensorlake-sync-manifest.json' + +const execFileAsync = promisify(execFile) + +function apiUrl(): string | undefined { + return process.env.TENSORLAKE_API_URL +} + +// Ingress derives the organization/project scope from the API key itself +// (SDK >= 0.5.114), so no scope lookup or env-var fallback is needed here. +function cloudOptions(apiKey: string) { + return { + apiKey, + ...(apiUrl() ? { apiUrl: apiUrl() } : {}), + } +} + +export function sanitizeName(input: string): string { + return input.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 63) +} + +/** Directory name the project is synced to inside the sandbox workspace. */ +export function projectDirName(worktree: string): string { + const name = basename(worktree ?? '').replace(/[^a-zA-Z0-9._-]/g, '-') + return name || 'project' +} + +/** + * Detected mode per worktree, plus the filesystem a 'mount' worktree serves. + * Filled once by {@link detectSyncMode} at plugin startup so that the + * synchronous {@link resolveSyncMode} — called on every tool call — never has + * to run the mount probe again. + */ +const detectedModes = new Map() + +function envSyncMode(): SyncMode | 'auto' { + const env = (process.env.TENSORLAKE_SYNC_MODE ?? 'auto').toLowerCase() + if (env === 'git' || env === 'volume' || env === 'mount' || env === 'off') return env + return 'auto' +} + +/** + * Mode derived from the worktree alone — everything except 'mount', which + * needs the mount daemon and so is resolved by {@link detectSyncMode}. + */ +function deriveSyncMode(worktree: string): SyncMode { + const env = envSyncMode() + if (env === 'git' || env === 'volume' || env === 'off') return env + if (!worktree || worktree === '/' || !existsSync(worktree)) return 'off' + return existsSync(join(worktree, '.git')) ? 'git' : 'volume' +} + +/** + * Resolve how the local project is synced into the sandbox. + * TENSORLAKE_SYNC_MODE=git|volume|mount|off overrides; default 'auto' picks + * 'mount' when the project folder is already a Tensorlake filesystem mount, + * 'git' for git repositories, and an uploaded cloud volume otherwise. + * + * Synchronous, so it answers from what detectSyncMode found; without that it + * can still answer everything but 'mount'. + */ +export function resolveSyncMode(worktree: string): SyncMode { + return detectedModes.get(worktree)?.mode ?? deriveSyncMode(worktree) +} + +/** + * Resolve the mode once, including the probe for a local Tensorlake mount, and + * cache it for the process. Call this before any other sync entry point. + */ +export async function detectSyncMode(worktree: string): Promise { + const cached = detectedModes.get(worktree) + if (cached) return cached.mode + + const env = envSyncMode() + const derived = deriveSyncMode(worktree) + // Nothing to probe: the mode is pinned, or there is no project to sync. + if ((env !== 'auto' && env !== 'mount') || derived === 'off') { + detectedModes.set(worktree, { mode: derived }) + return derived + } + // The probe shells out to the `tl` CLI, so it only runs where a mount can + // actually be: a directory that is its own mount point. + if (env === 'mount' || isMountPoint(worktree)) { + const fileSystemId = await mountedFileSystem(worktree) + if (fileSystemId) { + logger.info( + `${worktree} is a Tensorlake filesystem mount (${fileSystemId}); the sandbox will mount the same filesystem`, + ) + detectedModes.set(worktree, { mode: 'mount', fileSystemId }) + return 'mount' + } + if (env === 'mount') { + logger.warn( + `TENSORLAKE_SYNC_MODE=mount, but ${worktree} is not a Tensorlake filesystem mount; falling back to ${derived}. Mount one with \`tl fs mount \`.`, + ) + } + } + detectedModes.set(worktree, { mode: derived }) + return derived +} + +/** The filesystem a 'mount' worktree serves, once detectSyncMode has run. */ +export function mountedFileSystemId(worktree: string): string | undefined { + return detectedModes.get(worktree)?.fileSystemId +} + +/** + * True when `path` is the root of its own filesystem. A mount point's device + * id differs from its parent's — the cheap, dependency-free way to skip the + * CLI probe for the overwhelming majority of folders, which are plain + * directories on the boot volume. + */ +function isMountPoint(path: string): boolean { + try { + const parent = dirname(path) + if (parent === path) return true + return statSync(path).dev !== statSync(parent).dev + } catch { + return false + } +} + +/** + * Name of the Tensorlake filesystem mounted at `path`, or undefined when the + * path is not a Tensorlake mount (a plain folder, or some other mounted + * volume). The SDK's FilesystemClient.mountStatus() answers the same question, + * but constructing that client requires cloud credentials and project scope — + * and this probe runs at plugin startup, before any of that is guaranteed, for + * a purely local check. So the CLI is asked directly, parsing the same fields + * the SDK does. + */ +async function mountedFileSystem(path: string): Promise { + const cli = await findFsCli() + if (!cli) { + logger.info('`tl` CLI not found; cannot check whether the project folder is a Tensorlake filesystem mount') + return undefined + } + let raw: Record + try { + const { stdout } = await execFileAsync(cli, ['fs', 'status', '--json', '--', path], { timeout: 60_000 }) + raw = JSON.parse(stdout) as Record + } catch (err: any) { + // The expected outcome for a plain folder: the CLI exits non-zero with + // "not inside a Tensorlake filesystem mount". + const detail = `${err?.stderr ?? err?.message ?? err}`.trim().slice(0, 200) + logger.info(`No Tensorlake mount at ${path}: ${detail}`) + return undefined + } + // Field names follow the SDK's own reading of this payload, which is + // versioned independently of the SDK. + const mounted = 'mounted' in raw ? Boolean(raw.mounted) : 'active' in raw ? Boolean(raw.active) : true + const name = raw.filesystem ?? raw.file_system ?? raw.repository + if (!mounted || typeof name !== 'string' || !name) return undefined + return name +} + +/** The `tl` binary: TENSORLAKE_CLI override, PATH, or the default install location — the SDK's own search order. */ +async function findFsCli(): Promise { + const candidates = process.env.TENSORLAKE_CLI + ? [process.env.TENSORLAKE_CLI, 'tl', join(homedir(), '.tensorlake', 'bin', 'tl')] + : ['tl', join(homedir(), '.tensorlake', 'bin', 'tl')] + for (const candidate of candidates) { + try { + await execFileAsync(candidate, ['fs', '--help'], { timeout: 15_000 }) + return candidate + } catch (err: any) { + // Present but unhappy (wrong version, no auth) still means `tl` is there. + if (err?.code !== 'ENOENT') return candidate + } + } + return undefined +} + +/** Name shared by the hosted git repo (git mode) and cloud volume (volume mode). */ +function syncResourceName(projectId: string): string { + return sanitizeName(`opencode-${projectId}`) +} + +// Branch names that are safe to embed in the single-quoted sandbox sync +// script and in refspecs. Git allows more than this (e.g. quotes), but such +// names cannot be forwarded verbatim, so they sync as 'main' instead. +const SAFE_BRANCH_RE = /^[A-Za-z0-9._/-]+$/ + +/** + * The branch the project syncs under: the local checkout's current branch, + * kept under the same name on the sync repo and in the sandbox clone, so agent + * pushes land exactly where the user expects. Falls back to 'main' for a + * detached HEAD or a name that cannot be embedded safely. + */ +export async function resolveSyncBranch(worktree: string): Promise { + let name: string + try { + // --show-current also answers on an unborn branch (fresh `git init`), + // where rev-parse HEAD has nothing to resolve. + name = (await localGit(worktree, ['branch', '--show-current'])).trim() + } catch { + return 'main' + } + if (!name) { + logger.warn('Local checkout is a detached HEAD; syncing as branch main') + return 'main' + } + if (name.startsWith('-') || !SAFE_BRANCH_RE.test(name)) { + logger.warn(`Local branch name ${JSON.stringify(name)} cannot be synced verbatim; syncing as branch main`) + return 'main' + } + return name +} + +/** Run git in the local worktree; rejects with stderr attached on failure. */ +async function localGit(worktree: string, args: string[], env?: Record): Promise { + const { stdout } = await execFileAsync('git', args, { + cwd: worktree, + maxBuffer: MAX_PATCH_BYTES + 1024 * 1024, + timeout: 300_000, + // LC_ALL=C keeps git's messages untranslated: pushRealHistory classifies + // push failures by matching git's English error strings. + env: { ...process.env, LC_ALL: 'C', ...env }, + }) + return stdout +} + +// Credentials go in an HTTP header instead of the remote URL so the token +// never appears in git's error output or the process list. +function gitAuthConfig(cred: { gitUsername: string; token: string }): string { + const basic = Buffer.from(`${cred.gitUsername}:${cred.token}`).toString('base64') + return `http.extraHeader=Authorization: Basic ${basic}` +} + +function redactAuth(text: string): string { + return text.replace(/Basic [A-Za-z0-9+/=]+/g, 'Basic ***') +} + +// Throwaway ref the sync repo's sync branch is fetched into for the ancestry +// check, so the user's FETCH_HEAD (which their own fetch/merge workflows may +// be reading) is never touched. +const SYNC_REPO_CHECK_REF = 'refs/tensorlake/sync-repo-check' + +/** True when `ancestor` is reachable from `descendant`. */ +async function isAncestor(worktree: string, ancestor: string, descendant: string): Promise { + try { + await localGit(worktree, ['merge-base', '--is-ancestor', ancestor, descendant]) + return true + } catch (err: any) { + // Exit code 1 is merge-base's defined "not an ancestor" answer. Anything + // else (128, a timeout) means the check itself failed, and must propagate + // so callers treat the state as indeterminate instead of acting on it. + if (err?.code === 1) return false + throw err + } +} + +/** + * True when the sync repo's sync branch already contains local HEAD — i.e. the + * sync repo is simply ahead (an agent pushed commits the laptop hasn't fetched + * yet), as opposed to local history having been rewritten. Fetches the + * sync repo's branch into a temporary ref to make the ancestry check possible. + */ +async function syncRepoContainsLocalHead( + repos: RepositoryClient, + repo: string, + worktree: string, + branch: string, +): Promise { + const cred = await repos.credential(repo) + await localGit(worktree, [ + '-c', + gitAuthConfig(cred), + 'fetch', + '--quiet', + '--no-write-fetch-head', + await repos.url(repo), + `+refs/heads/${branch}:${SYNC_REPO_CHECK_REF}`, + ]) + try { + return await isAncestor(worktree, 'HEAD', SYNC_REPO_CHECK_REF) + } finally { + await localGit(worktree, ['update-ref', '-d', SYNC_REPO_CHECK_REF]).catch(() => {}) + } +} + +// Scratch namespace the sync repo's refs are fetched into right before a push +// that overwrites history, and the namespace the ones worth keeping are then +// moved to. Both sit outside refs/heads, refs/remotes and the wip namespace, so +// neither the user's branch workflows nor sync-back's --prune can touch them. +const RESCUE_SCRATCH_PREFIX = 'refs/tensorlake-scratch/' +const RESCUE_REF_PREFIX = 'refs/tensorlake-rescue/' + +/** + * Copy every ref on the sync repo into the local scratch namespace. Nothing an + * agent pushed — other branches, wip captures, the branch tip about to be + * overwritten — can then be lost by what follows. Rejects when the copy fails, + * so no destructive step runs without it. + */ +async function archiveSyncRepoRefs(repos: RepositoryClient, repo: string, worktree: string): Promise { + const cred = await repos.credential(repo) + await localGit(worktree, [ + '-c', + gitAuthConfig(cred), + 'fetch', + '--quiet', + '--no-write-fetch-head', + // --prune keeps the scratch namespace a faithful copy of the sync repo as + // it is now; refs worth keeping have already been moved out of it. + '--prune', + await repos.url(repo), + `+refs/*:${RESCUE_SCRATCH_PREFIX}*`, + ]) +} + +/** + * Turn the scratch copies into durable rescue refs: drop the ones local HEAD + * already contains, keep the rest under refs/tensorlake-rescue/, and + * report them — they hold work that exists nowhere else on the laptop. Naming + * a rescue ref after its commit makes the move idempotent, so a later rewrite + * cannot overwrite what an earlier one saved. + */ +async function rescueArchivedRefs(worktree: string, repo: string): Promise { + const out = await localGit(worktree, [ + 'for-each-ref', + '--format=%(refname)%00%(objectname)', + RESCUE_SCRATCH_PREFIX.slice(0, -1), + ]) + const kept: string[] = [] + for (const line of out.split('\n')) { + const [scratchRef, oid] = line.split('\0') + if (!scratchRef || !oid) continue + let redundant = false + try { + redundant = await isAncestor(worktree, oid, 'HEAD') + } catch { + // Indeterminate: keep the commit rather than drop work we cannot vouch for. + } + if (!redundant) { + const rescueRef = `${RESCUE_REF_PREFIX}${oid}` + const wasOn = scratchRef.slice(RESCUE_SCRATCH_PREFIX.length) + await localGit(worktree, ['update-ref', rescueRef, oid]) + kept.push(`${rescueRef} (refs/${wasOn} on the sync repo)`) + } + await localGit(worktree, ['update-ref', '-d', scratchRef]).catch(() => {}) + } + if (kept.length) { + logger.warn( + `Commits that were only on sync repo ${repo} are saved locally on ${kept.length} rescue ref(s): ${kept.join(', ')}. Read one with \`git log \` and drop it with \`git update-ref -d \` once it is handled.`, + ) + } +} + +/** + * Push the local repository's real history (HEAD) to the sync repo's sync + * branch, preserving commits, authors, and dates. A non-fast-forward rejection + * has two causes that must be told apart: the sync repo being ahead of the laptop + * (agents commit and push their work to it), in which case the push is simply + * skipped, or local history having been rewritten by a rebase or amend, in + * which case every sync repo ref is first copied to a local rescue ref and only + * the sync branch is then force-updated. Recreating the whole sync repo is the + * last resort, for a host that refuses the force push. + */ +async function pushRealHistory(repos: RepositoryClient, repo: string, worktree: string, branch: string): Promise { + const push = async (force = false) => { + const cred = await repos.credential(repo) + await localGit(worktree, [ + '-c', + gitAuthConfig(cred), + 'push', + '--quiet', + await repos.url(repo), + `${force ? '+' : ''}HEAD:refs/heads/${branch}`, + ]) + } + try { + await push() + } catch (err: any) { + const detail = redactAuth(`${err?.stderr ?? ''} ${err?.message ?? err}`) + if (!/non-fast-forward|\[rejected\]|failed to push some refs|fetch first/i.test(detail)) { + throw new Error(`git push to sync repo ${repo} failed: ${detail}`) + } + let remoteAhead: boolean + try { + remoteAhead = await syncRepoContainsLocalHead(repos, repo, worktree, branch) + } catch (checkErr: any) { + // Indeterminate state: never recreate the sync repo without proof of a + // rewrite, or agent commits that exist only on the sync repo would be lost. + throw new Error( + `Sync repo ${repo} rejected a non-fast-forward push and its state could not be inspected; not recreating it to avoid discarding remote-only commits: ${redactAuth(`${checkErr?.stderr ?? ''} ${checkErr?.message ?? checkErr}`)}`, + ) + } + if (remoteAhead) { + logger.info( + `Sync repo ${repo} is ahead of local HEAD (agent commits not yet fetched locally); skipping push — local history is already on the sync repo.`, + ) + return + } + // Local history was rewritten. Save every sync repo ref locally first: the + // force push below overwrites the branch tip, and the recreate fallback + // drops other branches and wip refs too. + try { + await archiveSyncRepoRefs(repos, repo, worktree) + } catch (archiveErr: any) { + throw new Error( + `Sync repo ${repo} needs a history-overwriting push, but its refs could not be copied locally first; not overwriting it to avoid discarding remote-only commits: ${redactAuth(`${archiveErr?.stderr ?? ''} ${archiveErr?.message ?? archiveErr}`)}`, + ) + } + logger.warn( + `Sync repo ${repo} rejected a non-fast-forward push and its ${branch} branch does not contain local HEAD (local history was rewritten); force-updating ${branch}. Every sync repo ref was copied locally first; any commit only the sync repo had is kept on a ${RESCUE_REF_PREFIX}* ref.`, + ) + try { + await push(true) + } catch (forceErr: any) { + logger.warn( + `Force push to sync repo ${repo} was refused (${redactAuth(`${forceErr?.stderr ?? ''} ${forceErr?.message ?? forceErr}`)}); recreating the repo instead. Its branches and wip refs survive only on the local rescue refs.`, + ) + await repos.delete(repo) + await repos.create(repo, { defaultBranch: branch }) + try { + await push() + } catch (err2: any) { + throw new Error( + `git push after recreating sync repo ${repo} failed: ${redactAuth(`${err2?.stderr ?? ''} ${err2?.message ?? err2}`)}`, + ) + } + } + await rescueArchivedRefs(worktree, repo).catch((rescueErr) => { + logger.warn( + `Sync repo refs were copied to ${RESCUE_SCRATCH_PREFIX}* but could not be moved to ${RESCUE_REF_PREFIX}*: ${rescueErr}`, + ) + }) + } +} + +/** + * Diff of everything not yet committed locally (modified, staged, untracked, + * deleted), built against a temporary index so the real index is untouched. + * Returns null when the working tree is clean or the patch is oversized. + */ +async function buildUncommittedPatch(worktree: string): Promise { + const tmpIndex = join(tmpdir(), `tensorlake-sync-index-${process.pid}-${Date.now()}`) + const env = { GIT_INDEX_FILE: tmpIndex } + try { + await localGit(worktree, ['read-tree', 'HEAD'], env) + await localGit(worktree, ['add', '-A'], env) + const patch = await localGit(worktree, ['diff', '--cached', '--binary', 'HEAD'], env) + if (!patch.trim()) return null + const bytes = Buffer.byteLength(patch) + if (bytes > MAX_PATCH_BYTES) { + logger.warn(`Uncommitted changes are ${bytes} bytes (> ${MAX_PATCH_BYTES}); not syncing them into the sandbox`) + return null + } + return patch + } finally { + try { + unlinkSync(tmpIndex) + } catch { + // temp index may not exist if an early git call failed + } + } +} + +/** + * Content hash of the whole worktree for repos with no commits yet: stage + * everything into a temporary index and hash the resulting tree. There is no + * HEAD to diff against, so this is the fingerprint's only view of the files. + */ +async function worktreeTreeHash(worktree: string): Promise { + const tmpIndex = join(tmpdir(), `tensorlake-sync-index-${process.pid}-${Date.now()}`) + const env = { GIT_INDEX_FILE: tmpIndex } + try { + await localGit(worktree, ['add', '-A'], env) + return (await localGit(worktree, ['write-tree'], env)).trim() + } finally { + try { + unlinkSync(tmpIndex) + } catch { + // temp index may not exist if an early git call failed + } + } +} + +/** + * Compact fingerprint of the local repo state that inbound sync replicates: + * branch, HEAD, and a hash of the uncommitted patch. Two equal fingerprints + * mean an inbound re-sync would be a no-op. Null when the state cannot be + * read (callers then skip re-sync rather than churn). + */ +export async function localGitFingerprint(worktree: string): Promise { + try { + const branch = await resolveSyncBranch(worktree) + let head = '' + try { + head = (await localGit(worktree, ['rev-parse', 'HEAD'])).trim() + } catch { + // no commits yet — the snapshot path syncs the worktree instead + } + // An oversized patch returns null and so hashes like a clean tree: such + // changes are not synced either way, and must not trigger re-sync churn. + // With no HEAD there is no patch to build; hash the whole worktree + // instead, so edits in a not-yet-committed repo still change the + // fingerprint and re-trigger the snapshot sync. + let patchHash = '' + if (head) { + const patch = await buildUncommittedPatch(worktree) + if (patch) patchHash = createHash('sha1').update(patch).digest('hex') + } else { + patchHash = await worktreeTreeHash(worktree) + } + return `${branch}\n${head}\n${patchHash}` + } catch (err) { + logger.warn(`Could not fingerprint local repo state: ${err}`) + return null + } +} + +/** + * Replicate uncommitted local changes into the sandbox working tree without + * committing them, so the sandbox matches the laptop's exact state. Applied + * only when the sandbox tree is clean and at the same HEAD as the laptop — + * agent work in the sandbox is never overwritten. + */ +async function applyUncommittedChanges( + client: TensorlakeClient, + sandboxId: string, + worktree: string, + destDir: string, + localHead: string, +): Promise { + const patch = await buildUncommittedPatch(worktree) + if (!patch) return + const state = await client.executeCommand(sandboxId, `cd '${destDir}' && git rev-parse HEAD && git status --porcelain`, '/') + if (state.exitCode !== 0) { + logger.warn(`Could not inspect sandbox clone before applying uncommitted changes: ${state.stderr || state.stdout}`) + return + } + const [sandboxHead, ...statusLines] = state.stdout.trim().split('\n') + const dirty = statusLines.some((line) => line.trim() !== '') + if (sandboxHead !== localHead || dirty) { + logger.info( + `Skipping uncommitted-changes sync: sandbox tree ${dirty ? 'has its own modifications' : `is at ${sandboxHead?.slice(0, 8)}, not ${localHead.slice(0, 8)}`}`, + ) + return + } + const patchPath = '/tmp/.tensorlake-sync.patch' + await client.writeFile(sandboxId, patchPath, Buffer.from(patch)) + const apply = await client.executeCommand( + sandboxId, + `cd '${destDir}' && git apply --whitespace=nowarn '${patchPath}'; code=$?; rm -f '${patchPath}'; exit $code`, + '/', + ) + if (apply.exitCode !== 0) { + logger.warn(`Failed to apply uncommitted local changes in sandbox: ${apply.stderr || apply.stdout}`) + } else { + logger.info(`Applied uncommitted local changes (${Buffer.byteLength(patch)} bytes) to ${destDir}`) + } +} + +/** + * Sync the local git repository into the sandbox with full commit history: + * push real refs to a Tensorlake-sync repo, then clone (or fast-forward) + * the sync repo inside the sandbox at `destDir`, and finally replay uncommitted + * local changes onto the sandbox working tree. A repo with no commits yet + * falls back to a single snapshot commit via pushWorktree. + * + * Returns 'synced' when the sandbox clone now matches the pushed state, and + * 'diverged' when the clone has its own commits or changes and was left alone + * (the pushed state is still on its 'origin' remote). + */ +export async function syncGitProject( + client: TensorlakeClient, + apiKey: string, + sandboxId: string, + worktree: string, + projectId: string, + destDir: string, +): Promise<'synced' | 'diverged'> { + const repos = RepositoryClient.forCloud(cloudOptions(apiKey)) + try { + const repo = syncResourceName(projectId) + const branch = await resolveSyncBranch(worktree) + try { + await repos.info(repo) + } catch { + logger.info(`Creating hosted git repository ${repo}`) + await repos.create(repo, { defaultBranch: branch }) + } + + let localHead: string | null = null + try { + localHead = (await localGit(worktree, ['rev-parse', 'HEAD'])).trim() + } catch { + // no commits yet, or no usable local git — use the snapshot path + } + + if (localHead) { + logger.info( + `Pushing real history (HEAD ${localHead.slice(0, 8)}, branch ${branch}) from ${worktree} to sync repo ${repo}`, + ) + await pushRealHistory(repos, repo, worktree, branch) + } else { + logger.info(`Local repository has no commits; pushing worktree snapshot to ${repo} (branch ${branch})`) + await repos.pushWorktree(repo, { + path: worktree, + branch, + message: 'Sync from OpenCode', + }) + } + + const url = await repos.url(repo) + const credLine = await credentialLine(repos, repo) + + // Re-syncs only fast-forward: a sandbox clone with its own commits or + // uncommitted changes must never be clobbered by a hard reset. A clean + // clone left on another branch (the user switched branches locally) + // switches to the sync branch; a dirty one is left alone. The git + // identity lets agents commit their work inside the sandbox. + const script = [ + 'set -e', + 'git config --global credential.helper store', + credentialStoreScript(credLine), + `git config --global user.name >/dev/null 2>&1 || git config --global user.name 'OpenCode Agent'`, + `git config --global user.email >/dev/null 2>&1 || git config --global user.email 'opencode-agent@tensorlake.ai'`, + `if [ -d '${destDir}/.git' ]; then`, + ` cd '${destDir}' && git fetch origin '${branch}'`, + ` current="$(git branch --show-current)"`, + ` if [ "$current" != '${branch}' ]; then`, + ' if [ -n "$(git status --porcelain)" ]; then', + ' echo TENSORLAKE_SYNC_DIVERGED', + ' else', + ` git checkout -q '${branch}' 2>/dev/null || git checkout -q -b '${branch}' 'origin/${branch}'`, + ` git merge --ff-only 'origin/${branch}' || echo TENSORLAKE_SYNC_DIVERGED`, + ' fi', + ` elif ! git merge --ff-only 'origin/${branch}'; then`, + ' echo TENSORLAKE_SYNC_DIVERGED', + ' fi', + 'else', + ` rm -rf '${destDir}' && git clone --branch '${branch}' '${url}' '${destDir}'`, + 'fi', + ].join('\n') + + const result = await client.executeCommand(sandboxId, script, '/', 300_000) + if (result.exitCode !== 0) { + throw new Error(`git sync failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`) + } + if (result.stdout.includes('TENSORLAKE_SYNC_DIVERGED')) { + logger.warn( + `Sandbox clone at ${destDir} has commits or changes that diverge from the pushed project; left untouched instead of resetting. Delete the session's sandbox to start from a fresh clone.`, + ) + return 'diverged' + } + logger.info(`Project cloned into sandbox at ${destDir}`) + if (localHead) { + await applyUncommittedChanges(client, sandboxId, worktree, destDir, localHead) + } + return 'synced' + } finally { + repos.close() + } +} + +// Git credential store line for the sync repo, in git's expected +// https://user:token@host format. +async function credentialLine(repos: RepositoryClient, repo: string): Promise { + const cred = await repos.credential(repo) + const parsed = new URL(await repos.url(repo)) + return `${parsed.protocol}//${encodeURIComponent(cred.gitUsername)}:${encodeURIComponent(cred.token)}@${parsed.host}` +} + +// Shell script that replaces only this host's line in the sandbox's +// ~/.git-credentials, keeping credentials the agent added for other remotes. +function credentialStoreScript(credLine: string): string { + const host = credLine.slice(credLine.lastIndexOf('@') + 1) + const hostPattern = host.replace(/[.[\]^$*\\]/g, '\\$&') + return [ + 'touch ~/.git-credentials', + `grep -v '@${hostPattern}$' ~/.git-credentials > ~/.git-credentials.tmp || true`, + `printf '%s\\n' '${credLine}' >> ~/.git-credentials.tmp`, + 'chmod 600 ~/.git-credentials.tmp', + 'mv ~/.git-credentials.tmp ~/.git-credentials', + ].join('\n') +} + +// Git tokens live about one hour; re-mint the sandbox's stored credential +// well before that so agent pushes keep working in long sessions. +export const GIT_CREDENTIAL_REFRESH_MS = 30 * 60 * 1000 + +/** + * Write a freshly minted git credential into the sandbox's credential store, + * replacing the (possibly expired) one written by the last sync. + */ +export async function refreshSandboxGitCredential( + client: TensorlakeClient, + apiKey: string, + sandboxId: string, + projectId: string, +): Promise { + const repos = RepositoryClient.forCloud(cloudOptions(apiKey)) + try { + const credLine = await credentialLine(repos, syncResourceName(projectId)) + const result = await client.executeCommand(sandboxId, credentialStoreScript(credLine), '/', 30_000) + if (result.exitCode !== 0) { + throw new Error(`writing ~/.git-credentials failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`) + } + } finally { + repos.close() + } +} + +/** What a sync-back pass did, for user-facing reporting. */ +export type SyncBackResult = + | { kind: 'up-to-date' } + | { kind: 'fast-forwarded'; branch: string; commits: number; oid: string } + | { kind: 'staged'; branch: string; ref: string; commits: number; oid: string; reason: string } + +/** One uncommitted-sandbox-changes capture staged on a local scratch ref. */ +export type WipStaged = { branch: string; ref: string; oid: string; files: number } + +/** Everything one sync-back pass brought home: committed work plus WIP captures. */ +export type SyncBackReport = { committed: SyncBackResult; wip: WipStaged[] } + +// Scratch namespace (on the sync repo and locally) holding synthetic commits +// that capture a sandbox clone's uncommitted working tree. Kept outside +// refs/heads and refs/remotes so no branch or fetch workflow ever sees them +// as real history. +const WIP_REF_PREFIX = 'refs/tensorlake-wip/' + +/** + * Pull agent work from the sync repo back into the local repository. + * Fetches every sync repo branch into refs/remotes/tensorlake/* (so nothing an + * agent pushed is ever stranded on the sync repo), then fast-forwards the local + * sync branch when that is safe. Unsafe cases — a dirty worktree or diverged + * histories — leave the commits on the tensorlake/ tracking ref for + * the user to merge deliberately; the local checkout is never disturbed. + * Uncommitted sandbox changes captured by {@link captureSandboxWip} arrive in + * the same fetch and are only ever staged on local tensorlake-wip/* scratch + * refs — they are never applied to the user's working tree. + */ +export async function syncBackFromSyncRepo( + apiKey: string, + worktree: string, + projectId: string, +): Promise { + const branch = await resolveSyncBranch(worktree) + const repo = syncResourceName(projectId) + const repos = RepositoryClient.forCloud(cloudOptions(apiKey)) + try { + const cred = await repos.credential(repo) + // --prune drops tracking refs for branches deleted (or recreated) on the + // sync repo, so a stale tensorlake/* ref can't masquerade as agent work; + // it likewise drops a local wip ref once the sandbox clears its capture. + await localGit(worktree, [ + '-c', + gitAuthConfig(cred), + 'fetch', + '--quiet', + '--no-write-fetch-head', + '--prune', + await repos.url(repo), + '+refs/heads/*:refs/remotes/tensorlake/*', + `+${WIP_REF_PREFIX}*:${WIP_REF_PREFIX}*`, + ]) + } finally { + repos.close() + } + + const committed = await reconcileCommitted(worktree, branch) + const wip = await stagedWipRefs(worktree) + return { committed, wip } +} + +/** Fast-forward or stage the sync repo's committed work, post-fetch. */ +async function reconcileCommitted(worktree: string, branch: string): Promise { + const trackingRef = `refs/remotes/tensorlake/${branch}` + const shortRef = `tensorlake/${branch}` + let syncRepoOid: string + try { + syncRepoOid = (await localGit(worktree, ['rev-parse', '--verify', '--quiet', trackingRef])).trim() + } catch { + return { kind: 'up-to-date' } // branch not on the sync repo yet + } + + let localOid: string + try { + localOid = (await localGit(worktree, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])).trim() + } catch { + // No local branch to advance (e.g. a repo that had no commits at sync + // time). The fetch above already saved the agent's work locally. + return { kind: 'staged', branch, ref: shortRef, commits: 0, oid: syncRepoOid, reason: `local branch ${branch} not found` } + } + + if (syncRepoOid === localOid) return { kind: 'up-to-date' } + // Sync repo behind the laptop: the next inbound sync's push handles that. + if (await isAncestor(worktree, syncRepoOid, localOid)) return { kind: 'up-to-date' } + + const commits = + parseInt((await localGit(worktree, ['rev-list', '--count', `${localOid}..${syncRepoOid}`])).trim(), 10) || 0 + if (!(await isAncestor(worktree, localOid, syncRepoOid))) { + return { kind: 'staged', branch, ref: shortRef, commits, oid: syncRepoOid, reason: 'local and agent histories diverged' } + } + + const current = (await localGit(worktree, ['branch', '--show-current'])).trim() + if (current !== branch) { + // The branch exists but is not checked out (detached HEAD fallback): + // advance the ref directly — no worktree files are involved. The + // old-value argument makes it a compare-and-swap against races. + await localGit(worktree, ['update-ref', `refs/heads/${branch}`, syncRepoOid, localOid]) + return { kind: 'fast-forwarded', branch, commits, oid: syncRepoOid } + } + + const dirty = (await localGit(worktree, ['status', '--porcelain'])).trim() !== '' + if (dirty) { + return { kind: 'staged', branch, ref: shortRef, commits, oid: syncRepoOid, reason: 'local uncommitted changes' } + } + + await localGit(worktree, ['merge', '--ff-only', '--quiet', syncRepoOid]) + return { kind: 'fast-forwarded', branch, commits, oid: syncRepoOid } +} + +/** The wip captures currently staged on local tensorlake-wip/* refs. */ +async function stagedWipRefs(worktree: string): Promise { + const out = await localGit(worktree, ['for-each-ref', '--format=%(refname)%00%(objectname)', WIP_REF_PREFIX.slice(0, -1)]) + const staged: WipStaged[] = [] + for (const line of out.split('\n')) { + const [refname, oid] = line.split('\0') + if (!refname || !oid) continue + const branch = refname.slice(WIP_REF_PREFIX.length) + let files = 0 + try { + const names = await localGit(worktree, ['diff', '--name-only', `${oid}^`, oid]) + files = names.split('\n').filter((name) => name.trim() !== '').length + } catch { + // parentless capture (repo synced as a snapshot) — file count unknown + } + // Short form resolves via git's refs/ lookup: 'tensorlake-wip/x' + // -> refs/tensorlake-wip/x, so the ref works in user-facing commands. + staged.push({ branch, ref: `tensorlake-wip/${branch}`, oid, files }) + } + return staged +} + +export type WipCaptureResult = 'pushed' | 'unchanged' | 'clean' | 'cleared' | 'skipped' + +/** + * Capture the sandbox clone's uncommitted changes (modified + untracked) as a + * synthetic commit and push it to the sync repo's tensorlake-wip/ + * scratch ref, where the next sync-back fetch stages it locally. Built against + * a temporary index, so the sandbox's real index and working tree are never + * touched — exactly the laptop-side buildUncommittedPatch technique. A clean + * tree clears a previously pushed capture; an unchanged tree pushes nothing. + */ +export async function captureSandboxWip( + client: TensorlakeClient, + sandboxId: string, + destDir: string, +): Promise { + const script = [ + `cd '${destDir}'`, + 'git rev-parse -q --verify HEAD >/dev/null 2>&1 || { echo TENSORLAKE_WIP_SKIPPED; exit 0; }', + 'branch="$(git branch --show-current)"', + '[ -n "$branch" ] || { echo TENSORLAKE_WIP_SKIPPED; exit 0; }', + 'if [ -z "$(git status --porcelain)" ]; then', + ' if [ -f .git/tensorlake-wip-tree ]; then', + // The remote ref may already be gone (repo recreated); the marker is + // removed either way so a delete failure cannot retry forever. + ' git push --quiet origin ":refs/tensorlake-wip/$branch" >/dev/null 2>&1 || true', + ' rm -f .git/tensorlake-wip-tree', + ' echo TENSORLAKE_WIP_CLEARED', + ' else', + ' echo TENSORLAKE_WIP_CLEAN', + ' fi', + ' exit 0', + 'fi', + 'GIT_INDEX_FILE="$(mktemp)" || exit 1', + 'export GIT_INDEX_FILE', + 'git read-tree HEAD || exit 1', + 'git add -A || exit 1', + 'tree="$(git write-tree)" || exit 1', + 'rm -f "$GIT_INDEX_FILE"; unset GIT_INDEX_FILE', + `if [ "$tree" = "$(git rev-parse 'HEAD^{tree}')" ]; then echo TENSORLAKE_WIP_CLEAN; exit 0; fi`, + 'if [ "$tree" = "$(cat .git/tensorlake-wip-tree 2>/dev/null)" ]; then echo TENSORLAKE_WIP_UNCHANGED; exit 0; fi', + `commit="$(git commit-tree "$tree" -p HEAD -m 'Uncommitted sandbox changes (captured by tensorlake-opencode)')" || exit 1`, + 'git push --quiet origin "+$commit:refs/tensorlake-wip/$branch" || exit 1', + `printf '%s\\n' "$tree" > .git/tensorlake-wip-tree`, + 'echo TENSORLAKE_WIP_PUSHED', + ].join('\n') + const result = await client.executeCommand(sandboxId, script, '/', 120_000) + if (result.exitCode !== 0) { + throw new Error(`wip capture failed (exit ${result.exitCode}): ${redactAuth(result.stderr || result.stdout)}`) + } + if (result.stdout.includes('TENSORLAKE_WIP_PUSHED')) return 'pushed' + if (result.stdout.includes('TENSORLAKE_WIP_UNCHANGED')) return 'unchanged' + if (result.stdout.includes('TENSORLAKE_WIP_CLEARED')) return 'cleared' + if (result.stdout.includes('TENSORLAKE_WIP_SKIPPED')) return 'skipped' + return 'clean' +} + +/** + * Recursively collect files to upload (remote path -> absolute local path) + * plus every path that still exists locally, uploaded or not. Deletion + * propagation must key off `present`, not `files`: a path can be skipped from + * the upload (oversized, symlink, unreadable) while very much still existing. + */ +function collectFiles(worktree: string): { files: Record; present: Set } { + const files: Record = {} + const present = new Set() + const walk = (dir: string, prefix: string) => { + for (const entry of readdirSync(dir)) { + if (SKIP_DIRS.has(entry)) continue + const localPath = join(dir, entry) + const remotePath = prefix ? posix.join(prefix, entry) : entry + let st + try { + st = lstatSync(localPath) + } catch { + // Unreadable is not deleted — keep it out of the upload but never let + // its absence from `files` be read as a local deletion. + present.add(remotePath) + continue + } + if (st.isSymbolicLink()) { + present.add(remotePath) + continue + } + if (st.isDirectory()) { + walk(localPath, remotePath) + } else if (st.isFile()) { + present.add(remotePath) + if (st.size > MAX_FILE_BYTES) { + logger.warn(`Skipping ${localPath} (${st.size} bytes > ${MAX_FILE_BYTES})`) + continue + } + files[remotePath] = localPath + } + } + } + walk(worktree, '') + return { files, present } +} + +/** + * Ensure the project's cloud volume exists and holds the current worktree + * content. Returns the mount spec for the sandbox. Sandbox mounts address + * volumes by their filesystem name. `manifestDir` is a laptop-local directory + * where the record of uploaded paths is kept between syncs. + */ +export async function ensureVolumeWithProject( + apiKey: string, + worktree: string, + projectId: string, + mountPath: string, + manifestDir: string, +): Promise { + const name = syncResourceName(projectId) + const options = cloudOptions(apiKey) + const fsClient = new FilesystemClient(options) + let fs + try { + fs = await fsClient.get(name) + } catch { + logger.info(`Creating cloud volume ${name}`) + fs = await fsClient.create(name) + } + + const { files, present } = collectFiles(worktree) + const count = Object.keys(files).length + const manifestPath = syncManifestPath(manifestDir, name) + const manifest = readVolumeManifest(manifestPath) + const stale = staleRemotePaths(manifest.paths, present) + logger.info(`Uploading ${count} files from ${worktree} to volume ${name}`) + let version: string | null = null + if (count > 0) { + version = (await fs.writeFilesFromPaths(files, 'Sync from OpenCode')).versionId + } + + // Drop previously uploaded paths that no longer exist locally, so local + // deletions propagate to later sandboxes. Non-fatal: the uploads above + // already landed, and paths that fail to delete stay in the manifest so the + // deletion is retried on the next sync. + let undeleted: string[] = [] + if (stale.length > 0) { + try { + logger.info(`Removing ${stale.length} locally deleted files from volume ${name}`) + version = (await fs.writeFiles({}, 'Sync from OpenCode (remove deleted files)', stale)).versionId + for (const path of stale) { + delete manifest.files[path] + delete manifest.localHash[path] + } + } catch (err) { + logger.warn(`Failed to remove deleted files from volume ${name}: ${err}`) + undeleted = stale + } + } + + // Scrub the manifest older versions wrote into the volume root, where every + // sandbox agent could see and commit it. + try { + version = (await fs.deleteFile(LEGACY_VOLUME_MANIFEST_PATH, 'Remove legacy sync manifest')).versionId + } catch { + // already gone — the common case + } + + // Content hashes of what was just uploaded: sync-back uses them to tell + // "the user changed this file since" apart from "safe to overwrite". + for (const [remotePath, localPath] of Object.entries(files)) { + try { + manifest.localHash[remotePath] = sha1File(localPath) + } catch (err) { + logger.warn(`Could not hash ${localPath}: ${err}`) + } + } + + // Record the volume's content ids at this version as the sync-back + // baseline. Anything the volume holds right now is treated as already + // seen — which is why callers must download pending agent changes BEFORE + // uploading. A failed walk keeps the old baseline (less pruning, no harm). + try { + if (version === null) version = (await fs.status()).versionId + if (version) { + const tree = await walkVolumeTree(fs, version, manifest) + manifest.files = tree.files + manifest.dirs = tree.dirs + manifest.exec = tree.exec + manifest.versionId = version + } + } catch (err) { + manifest.versionId = undefined + logger.warn(`Could not record volume baseline for ${name}: ${err}`) + } + + // Previously uploaded paths that still exist locally but were skipped this + // pass (oversized, unreadable, or now a symlink) stay tracked: if they are + // deleted later, their remote copy must still be removed. + const skippedButPresent = manifest.paths.filter((path) => present.has(path) && !(path in files)) + manifest.paths = [...Object.keys(files), ...undeleted, ...skippedButPresent] + writeVolumeManifest(manifestPath, manifest) + + return { fileSystemId: name, mountPath } +} + +export type VolumeSyncBack = { downloaded: number; conflicts: string[]; deletedRemotely: number } + +/** + * Download files the agent changed on the project's cloud volume into the + * local worktree. One `status()` call answers "anything new?"; a changed head + * is diffed with a tree walk that prunes every directory whose server-side + * contentId is unchanged. A local file is only ever overwritten when its + * content still matches what the last sync recorded (`localHash`) — a file + * the user edited too is skipped and reported as a conflict, and files + * deleted on the volume are never deleted locally. + */ +export async function syncBackFromVolume( + apiKey: string, + worktree: string, + projectId: string, + manifestDir: string, +): Promise { + const none: VolumeSyncBack = { downloaded: 0, conflicts: [], deletedRemotely: 0 } + const name = syncResourceName(projectId) + const manifestPath = syncManifestPath(manifestDir, name) + const manifest = readVolumeManifest(manifestPath) + const fsClient = new FilesystemClient(cloudOptions(apiKey)) + let fs + try { + fs = await fsClient.get(name) + } catch { + // No volume yet — nothing to pull. + return none + } + const head = (await fs.status()).versionId + if (!head || head === manifest.versionId) return none + + const tree = await walkVolumeTree(fs, head, manifest) + let worktreeReal: string + try { + worktreeReal = realpathSync(worktree) + } catch (err) { + logger.warn(`Sync-back: cannot resolve worktree ${worktree}: ${err}`) + return none + } + let downloaded = 0 + let tmpSeq = 0 + const conflicts: string[] = [] + for (const entry of tree.changed) { + if (!isSafeRelativePath(entry.path)) { + logger.warn(`Sync-back: refusing unsafe path from volume ${name}: ${entry.path}`) + continue + } + if (entry.size !== null && entry.size > MAX_FILE_BYTES) { + logger.warn(`Sync-back: skipping ${entry.path} (${entry.size} bytes > ${MAX_FILE_BYTES})`) + continue + } + const localPath = safeSyncBackDestination(worktree, worktreeReal, entry.path) + if (localPath === null) { + logger.warn(`Sync-back: refusing ${entry.path} — it resolves through a symlink to outside the worktree`) + conflicts.push(entry.path) + continue + } + const recorded = manifest.localHash[entry.path] + const before = localSyncState(localPath, recorded) + if (before === 'conflict') { + conflicts.push(entry.path) + continue + } + const data = await fs.readFile(entry.path, head) + if (data.byteLength > MAX_FILE_BYTES) { + logger.warn(`Sync-back: skipping ${entry.path} (${data.byteLength} bytes > ${MAX_FILE_BYTES})`) + continue + } + // The download above is slow enough for the user to edit the file after + // the first check, so check again here. This leaves only a few syscalls + // between the last look at the file and the rename that replaces it. + if (localSyncState(localPath, recorded) !== before) { + logger.info(`Sync-back: ${entry.path} changed locally during the download; keeping the local copy`) + conflicts.push(entry.path) + continue + } + const buffer = Buffer.from(data) + mkdirSync(dirname(localPath), { recursive: true }) + // Write a temp file in the same directory and rename it over the + // destination. Rename is atomic, so a disk error or a killed process can + // never leave a half-written file in the worktree. + const tmpPath = join(dirname(localPath), `.${basename(localPath)}.tensorlake-${process.pid}-${tmpSeq++}.tmp`) + try { + writeFileSync(tmpPath, buffer) + // Keep the destination's permissions when it already exists, then set or + // clear the exec bits to match the volume. + let mode = statSync(tmpPath).mode + if (before === 'clean') { + try { + mode = statSync(localPath).mode + } catch { + // Gone after the check — keep the temp file's own mode. + } + } + const wanted = entry.executable ? mode | 0o111 : mode & ~0o111 + if (wanted !== statSync(tmpPath).mode) chmodSync(tmpPath, wanted) + renameSync(tmpPath, localPath) + } catch (err) { + try { + unlinkSync(tmpPath) + } catch { + // Never created, or already renamed away. + } + logger.warn(`Sync-back: could not write ${entry.path}: ${err}`) + conflicts.push(entry.path) + continue + } + manifest.localHash[entry.path] = createHash('sha1').update(buffer).digest('hex') + downloaded++ + } + + // Files deleted on the volume: forget their sync state but keep the local + // copy — deleting local files behind the user's back is not worth the risk. + let deletedRemotely = 0 + for (const path of Object.keys(manifest.files)) { + if (!(path in tree.files)) { + deletedRemotely++ + delete manifest.localHash[path] + logger.info(`Sync-back: ${path} was deleted on volume ${name}; the local copy was kept`) + } + } + + manifest.files = tree.files + manifest.dirs = tree.dirs + manifest.exec = tree.exec + manifest.versionId = head + writeVolumeManifest(manifestPath, manifest) + return { downloaded, conflicts, deletedRemotely } +} + +type VolumeTree = { + files: Record + dirs: Record + exec: Record + changed: FileEntry[] +} + +/** + * List the volume's tree at one pinned version, skipping every directory + * whose contentId matches the previous walk (its old entries are copied + * forward), regenerable SKIP_DIRS, and symlinks. `changed` holds the file + * entries whose contentId or executable bit moved off the previous baseline — + * an exec-bit flip leaves the blob's contentId unchanged, so it needs its own + * comparison. `exec` records only the executable paths; absence means plain. + */ +async function walkVolumeTree( + fs: { listFiles(dirPath?: string, version?: string): Promise }, + version: string, + prev: { files: Record; dirs: Record; exec: Record }, +): Promise { + const files: Record = {} + const dirs: Record = {} + const exec: Record = {} + const changed: FileEntry[] = [] + const copyForward = (dirPath: string) => { + const prefix = `${dirPath}/` + for (const [path, id] of Object.entries(prev.files)) { + if (path.startsWith(prefix)) { + files[path] = id + if (prev.exec[path]) exec[path] = true + } + } + for (const [path, id] of Object.entries(prev.dirs)) if (path.startsWith(prefix)) dirs[path] = id + } + const stack = [''] + while (stack.length > 0) { + const dir = stack.pop()! + const entries = await fs.listFiles(dir === '' ? undefined : dir, version) + for (const entry of entries) { + if (entry.kind === 'symlink') continue + if (entry.kind === 'directory') { + dirs[entry.path] = entry.contentId + if (SKIP_DIRS.has(entry.name)) continue + if (prev.dirs[entry.path] === entry.contentId) copyForward(entry.path) + else stack.push(entry.path) + } else { + files[entry.path] = entry.contentId + if (entry.executable) exec[entry.path] = true + if (prev.files[entry.path] !== entry.contentId || Boolean(prev.exec[entry.path]) !== entry.executable) { + changed.push(entry) + } + } + } + } + return { files, dirs, exec, changed } +} + +function sha1File(path: string): string { + return createHash('sha1').update(readFileSync(path)).digest('hex') +} + +/** + * Classify a sync-back destination against what the last sync recorded for it. + * `clean` means the local file still holds the synced content and may be + * replaced; `absent` means the path was never synced and nothing is there; + * `conflict` covers everything the user owns — an edited file, an untracked + * file, a non-regular file, and a synced file the user has since deleted + * (recreating it would silently undo that deletion). The result is also the + * before/after token for the re-check that guards the write. + */ +function localSyncState(localPath: string, recorded: string | undefined): 'absent' | 'clean' | 'conflict' { + let stats + try { + stats = lstatSync(localPath) + } catch { + return recorded === undefined ? 'absent' : 'conflict' + } + // A symlink or a directory where a file belongs is never written through. + if (!stats.isFile() || recorded === undefined) return 'conflict' + try { + return sha1File(localPath) === recorded ? 'clean' : 'conflict' + } catch { + return 'conflict' + } +} + +/** + * Confirm a filesystem exists in this project before it is attached anywhere. + * Any failure to confirm blocks the attach: a wrong or unverifiable name costs + * the whole sandbox, while a blocked sync only leaves the workspace empty and + * is retried on the next turn. + */ +async function assertFileSystemExists(apiKey: string, name: string): Promise { + const fsClient = new FilesystemClient(cloudOptions(apiKey)) + try { + await fsClient.get(name) + } catch (err: any) { + throw new Error( + `Refusing to attach filesystem ${name}: it could not be confirmed to exist in this project (${err?.message ?? err}).`, + ) + } +} + +/** + * Laptop-local record of a volume's sync state: `paths` drives deletion + * propagation on upload (as before); `versionId`/`files`/`dirs` are the + * volume-side contentId baseline for sync-back; `localHash` is the sha1 each + * synced file had locally, the guard against overwriting a user edit. + */ +type VolumeManifest = { + paths: string[] + versionId?: string + files: Record + dirs: Record + exec: Record + localHash: Record +} + +function syncManifestPath(manifestDir: string, volumeName: string): string { + return join(manifestDir, `${volumeName}.sync-manifest.json`) +} + +function stringRecord(value: unknown): Record { + if (value == null || typeof value !== 'object' || Array.isArray(value)) return {} + const out: Record = {} + for (const [k, v] of Object.entries(value as Record)) { + if (typeof v === 'string') out[k] = v + } + return out +} + +function boolRecord(value: unknown): Record { + if (value == null || typeof value !== 'object' || Array.isArray(value)) return {} + const out: Record = {} + for (const [k, v] of Object.entries(value as Record)) { + if (v === true) out[k] = true + } + return out +} + +function readVolumeManifest(manifestPath: string): VolumeManifest { + try { + const parsed = JSON.parse(readFileSync(manifestPath, 'utf-8')) as Partial + return { + // first sync, or a missing/corrupt manifest — never guess at deletions + paths: Array.isArray(parsed.paths) ? parsed.paths.filter((p): p is string => typeof p === 'string') : [], + versionId: typeof parsed.versionId === 'string' ? parsed.versionId : undefined, + files: stringRecord(parsed.files), + dirs: stringRecord(parsed.dirs), + exec: boolRecord(parsed.exec), + localHash: stringRecord(parsed.localHash), + } + } catch { + return { paths: [], files: {}, dirs: {}, exec: {}, localHash: {} } + } +} + +function writeVolumeManifest(manifestPath: string, manifest: VolumeManifest): void { + try { + mkdirSync(dirname(manifestPath), { recursive: true }) + writeFileSync(manifestPath, JSON.stringify({ ...manifest, paths: [...new Set(manifest.paths)].sort() })) + } catch (err) { + logger.warn(`Failed to write sync manifest ${manifestPath}: ${err}`) + } +} + +/** + * Resolve where a sync-back write of `relPath` really lands, and return that + * destination only when it stays inside the worktree. `isSafeRelativePath` + * validates the path string; this validates the filesystem: the destination + * itself must not be a symlink (a dangling one would redirect the write), and + * its nearest existing ancestor must not be, or resolve through, a symlink + * that leads outside the worktree. Returns null when the write must not + * happen. + */ +function safeSyncBackDestination(worktree: string, worktreeReal: string, relPath: string): string | null { + const localPath = join(worktree, relPath) + try { + if (lstatSync(localPath).isSymbolicLink()) return null + } catch { + // Nothing at the destination — fine, it is a new file. + } + let ancestor = dirname(localPath) + while (true) { + try { + if (lstatSync(ancestor).isSymbolicLink()) return null + break + } catch { + const parent = dirname(ancestor) + if (parent === ancestor) return null + ancestor = parent + } + } + try { + const real = realpathSync(ancestor) + if (real !== worktreeReal && !real.startsWith(worktreeReal + sep)) return null + } catch { + return null + } + return localPath +} + +// Deletion paths are sent to the volume API verbatim, so accept only plain +// relative paths — no absolute paths, no `.`/`..` segments, no backslashes. +function isSafeRelativePath(path: string): boolean { + if (!path || path.startsWith('/') || path.includes('\\')) return false + return path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..') +} + +/** + * Previously uploaded paths that have since been deleted locally. Computed + * against everything still present in the worktree (not just the uploaded + * set), so files skipped from an upload are never treated as deleted, and + * only from the manifest of past uploads, so files created by agents inside + * the mounted volume are never touched. Paths now under SKIP_DIRS are also + * left alone — the sandbox may own them (e.g. an agent-built dist/). + */ +function staleRemotePaths(previous: string[], present: Set): string[] { + return previous.filter( + (path) => + isSafeRelativePath(path) && + !present.has(path) && + !path.split('/').some((segment) => SKIP_DIRS.has(segment)), + ) +} + +/** Attach the project volume to an already-running sandbox if not mounted. */ +export async function ensureVolumeMounted( + client: TensorlakeClient, + mount: FileSystemMount, + sandboxId: string, +): Promise { + const mounts = await client.listSandboxFileSystems(sandboxId) + const existing = mounts.find((m) => m.mountPath === mount.mountPath) + if (existing && existing.fileSystemId !== mount.fileSystemId) { + // A different filesystem occupies the path (e.g. the project switched + // from a forced volume to a locally mounted filesystem). Detach it first + // — leaving it would run tools against stale storage while the plugin + // reports the requested filesystem as mounted. + logger.info( + `Detaching filesystem ${existing.fileSystemId} from sandbox ${sandboxId} at ${mount.mountPath} (expected ${mount.fileSystemId})`, + ) + await client.detachFileSystem(sandboxId, mount.mountPath) + // The guest unmounts asynchronously; the readiness probe below cannot + // tell filesystems apart, so wait until the old mount is really gone + // before it can be mistaken for the new one. + await waitForGuestMount(client, sandboxId, mount.mountPath, { mounted: false }) + } + if (!existing || existing.fileSystemId !== mount.fileSystemId) { + // Attaching a filesystem the project does not have terminates the sandbox + // — the guest dies instead of the attach call failing — so the name is + // confirmed before it is ever sent to a live sandbox. + await assertFileSystemExists(client.getApiKey(), mount.fileSystemId) + logger.info(`Attaching volume ${mount.fileSystemId} to sandbox ${sandboxId} at ${mount.mountPath}`) + await client.attachFileSystem(sandboxId, mount.fileSystemId, mount.mountPath) + } + // Both attachFileSystem and the control plane's mount listing reflect + // control-plane state; the guest materializes the mount asynchronously on + // the dataplane. Callers mark the sandbox synced and run tools with + // mountPath as cwd immediately after, so always wait for the guest to see a + // real mount — including when the control plane already listed it. + await waitForGuestMount(client, sandboxId, mount.mountPath) +} + +async function waitForGuestMount( + client: TensorlakeClient, + sandboxId: string, + path: string, + opts: { mounted?: boolean; timeoutMs?: number; probeTimeoutMs?: number } = {}, +): Promise { + const { mounted = true, timeoutMs = 30_000, probeTimeoutMs = 5_000 } = opts + // `test -d` is not enough here: the sync-failure fallback mkdirs a plain + // directory at this exact path, which would satisfy it while the volume is + // not mounted at all. Require the path to be an actual mountpoint. + const probe = `mountpoint -q '${path}' 2>/dev/null || awk -v p='${path}' '$2 == p { found = 1 } END { exit !found }' /proc/mounts` + const deadline = Date.now() + timeoutMs + for (;;) { + try { + const check = await client.executeCommand(sandboxId, probe, '/', probeTimeoutMs) + if ((check.exitCode === 0) === mounted) return + } catch (err) { + logger.warn(`Mount readiness check failed: ${err}`) + } + if (Date.now() >= deadline) { + throw new Error( + `Volume mount at ${path} did not become ${mounted ? 'visible' : 'unmounted'} in the sandbox within ${timeoutMs}ms`, + ) + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } +} diff --git a/.opencode/plugin/tensorlake/core/session-manager.ts b/.opencode/plugin/tensorlake/core/session-manager.ts index ad26405..9bd779c 100644 --- a/.opencode/plugin/tensorlake/core/session-manager.ts +++ b/.opencode/plugin/tensorlake/core/session-manager.ts @@ -1,34 +1,605 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' -import { join } from 'path' +import { createHash } from 'crypto' +import { join, posix } from 'path' import { RemoteAPIError } from 'tensorlake' -import { TensorLakeClient } from './client.js' +import { TensorlakeClient } from './client.js' import { LOGIN_HINT, projectKeyWarning } from './credentials.js' import { logger } from './logger.js' import { toast } from './toast.js' import type { ProjectSessionData } from './types.js' import type { PluginInput } from '@opencode-ai/plugin' +import { + resolveSyncMode, + resolveSyncBranch, + projectDirName, + syncGitProject, + syncBackFromSyncRepo, + syncBackFromVolume, + captureSandboxWip, + ensureVolumeWithProject, + ensureVolumeMounted, + mountedFileSystemId, + localGitFingerprint, + refreshSandboxGitCredential, + GIT_CREDENTIAL_REFRESH_MS, +} from './project-sync.js' +import type { SyncBackReport, VolumeSyncBack } from './project-sync.js' -export class TensorLakeSessionManager { - private readonly client: TensorLakeClient +/** What a (re-)sync attempt did, for the sync tool's report to the agent. */ +export type SyncOutcome = { ok: true; diverged: boolean } | { ok: false; error: string } + +export class TensorlakeSessionManager { + private readonly client: TensorlakeClient // In-memory cache: sessionId -> { sandboxId } private readonly cache = new Map() // In-flight getSandbox promises keyed by sessionId — prevents concurrent double-resume private readonly inflight = new Map>() + // Sandboxes whose project sync already ran in this process + private readonly synced = new Set() + // In-flight sync promises keyed by sandboxId — prevents concurrent double-sync + private readonly syncInflight = new Map>() + // Sandboxes confirmed to already contain the project dir (check runs once per process) + private readonly hasProjectDir = new Set() + // Last failed sync attempt per sandbox — retried after a cooldown instead of on every tool call + private readonly syncFailedAt = new Map() + private static readonly SYNC_RETRY_COOLDOWN_MS = 60_000 + // Local git state (branch/HEAD/uncommitted hash) each sandbox last synced, keyed by sandboxId. + // A later tool call whose fingerprint differs triggers an automatic re-sync. + private readonly syncedFingerprint = new Map() + // When the local-change fingerprint was last computed per sandbox — the check is throttled + private readonly resyncCheckedAt = new Map() + private static readonly RESYNC_CHECK_INTERVAL_MS = 15_000 + // In-flight or queued sync-back per worktree+sandbox — session.idle can fire + // faster than a fetch completes; keying by sandbox keeps one session's idle + // from swallowing another sandbox's sync-back + private readonly syncBackInflight = new Map>() + // Tail of the sync-back chain per worktree — host git operations (fetch, + // merge, wip prune) on one local repo must never run concurrently + private readonly syncBackTail = new Map>() + // Last failed sync-back per worktree — retried after a cooldown instead of on every idle + private readonly syncBackFailedAt = new Map() + // Sync repo commit already reported as "staged, not merged" per worktree — suppresses repeat toasts + private readonly lastStagedOid = new Map() + // Wip capture already reported per worktree+branch — suppresses repeat toasts + private readonly lastWipOid = new Map() // Keys already checked for project scope — the warning fires once per key private readonly warnedKeys = new Set() + // Sessions whose sandbox was (or is being) deleted — a late tool call must + // fail instead of silently creating a fresh sandbox for a dead session + private readonly deleting = new Set() + // When each sandbox's git credential was last written — tokens expire in ~1h + private readonly credRefreshedAt = new Map() + // Post-sync setup command (from plugin options / env); undefined = none + private setupCommand: string | undefined + // Sandboxes whose setup marker was already checked in this process — keeps + // re-syncs from paying a probe roundtrip every time + private readonly setupChecked = new Set() + private static readonly SETUP_TIMEOUT_MS = 900_000 + // Background work that must finish before the process exits: inbound sync, + // setup command, sync-back, wip capture, volume download, sandbox creation. + // Suspending a sandbox (or exiting) mid-flight abandons agent work, so + // shutdown() drains this set first. + private readonly pending = new Set>() + // Set once shutdown starts — stops new fire-and-forget work from being + // queued behind the drain. + private shuttingDown = false + // The single shutdown run; a second signal or event joins it. + private shutdownRun: Promise | undefined + private static readonly DRAIN_TIMEOUT_MS = 15_000 + + /** Drain budget for shutdown; TENSORLAKE_SHUTDOWN_DRAIN_MS overrides it. */ + private static drainBudgetMs(): number { + const raw = Number(process.env.TENSORLAKE_SHUTDOWN_DRAIN_MS) + if (Number.isFinite(raw) && raw >= 0) return raw + return TensorlakeSessionManager.DRAIN_TIMEOUT_MS + } public readonly workDir: string private readonly storageDir: string constructor(resolveKey: () => string | undefined, storageDir: string, workDir: string) { - this.client = new TensorLakeClient(resolveKey) + this.client = new TensorlakeClient(resolveKey) this.storageDir = storageDir this.workDir = workDir } - getClient(): TensorLakeClient { + getClient(): TensorlakeClient { return this.client } + setSetupCommand(command: string | undefined): void { + this.setupCommand = command?.trim() || undefined + } + + /** Directory inside the sandbox where the local project is synced. */ + projectDir(worktree: string): string { + if (resolveSyncMode(worktree) === 'off') return this.workDir + // Guest (Linux sandbox) path — must stay POSIX even on a Windows host + return posix.join(this.workDir, projectDirName(worktree)) + } + + /** + * Sync the local project into the sandbox. Git repos are pushed to a + * Tensorlake-hosted repo and cloned inside the sandbox; a folder that is + * already a Tensorlake filesystem mount is attached to the sandbox as-is, + * with no copy at all; any other plain folder is uploaded to a cloud volume + * mounted into the sandbox. Failures are logged and surfaced but never block + * the sandbox. + */ + private async syncProject( + sandboxId: string, + projectId: string, + worktree: string, + opts: { force?: boolean } = {}, + ): Promise { + if (!opts.force && this.synced.has(sandboxId)) return { ok: true, diverged: false } + const mode = resolveSyncMode(worktree) + if (mode === 'off') { + this.synced.add(sandboxId) + return { ok: true, diverged: false } + } + if (!opts.force) { + const failedAt = this.syncFailedAt.get(sandboxId) + if (failedAt !== undefined && Date.now() - failedAt < TensorlakeSessionManager.SYNC_RETRY_COOLDOWN_MS) { + return { ok: false, error: 'a recent sync failed; the retry cooldown has not elapsed' } + } + } + const destDir = this.projectDir(worktree) + try { + let diverged = false + if (mode === 'git') { + // Fingerprint before the push: edits made while the sync runs make the + // next check see a difference and sync again. + const fingerprint = await localGitFingerprint(worktree) + toast.show({ title: 'Syncing project', message: `Pushing ${worktree} and cloning into the sandbox...`, variant: 'info' }) + diverged = + (await syncGitProject(this.client, this.client.getApiKey(), sandboxId, worktree, projectId, destDir)) === + 'diverged' + // The sync script wrote a fresh credential into the sandbox. + this.credRefreshedAt.set(sandboxId, Date.now()) + this.syncedFingerprint.set(sandboxId, fingerprint) + } else if (mode === 'mount') { + // The local folder already *is* the filesystem, so there is nothing to + // copy: the sandbox mounts the same one, and the mount daemon's + // autosave carries writes both ways within a second or so. + const fileSystemId = mountedFileSystemId(worktree) + if (!fileSystemId) throw new Error(`No Tensorlake filesystem is mounted at ${worktree}`) + toast.show({ title: 'Mounting project', message: `Mounting filesystem ${fileSystemId} into the sandbox...`, variant: 'info' }) + await ensureVolumeMounted(this.client, { fileSystemId, mountPath: destDir }, sandboxId) + } else { + // Pull pending agent changes off the volume FIRST: the upload below + // records the volume's current content as the new sync-back baseline, + // so anything not downloaded now would never be downloaded later. + // Non-fatal — a failed pull must not block the sync. + try { + this.reportVolumeSyncBack( + worktree, + await syncBackFromVolume(this.client.getApiKey(), worktree, projectId, this.storageDir), + ) + } catch (err: any) { + logger.warn(`Volume sync-back before upload failed: ${err?.message ?? err}`) + } + toast.show({ title: 'Syncing project', message: `Uploading ${worktree} to a cloud volume...`, variant: 'info' }) + const mount = await ensureVolumeWithProject(this.client.getApiKey(), worktree, projectId, destDir, this.storageDir) + await ensureVolumeMounted(this.client, mount, sandboxId) + } + this.synced.add(sandboxId) + this.syncFailedAt.delete(sandboxId) + // Runs before the sync outcome is reported, so on a fresh sandbox the + // first tool call waits until dependencies are installed. + await this.maybeRunSetup(sandboxId, destDir) + if (diverged) { + toast.show({ + title: 'Project synced (sandbox diverged)', + message: `The sandbox clone at ${destDir} has its own commits or changes and was left as-is; the pushed state is on its 'origin' remote.`, + variant: 'warning', + }) + } else { + toast.show({ + title: mode === 'mount' ? 'Project mounted' : 'Project synced', + message: + mode === 'mount' + ? `${destDir} and ${worktree} are the same filesystem; changes flow both ways.` + : `Project available at ${destDir}`, + variant: 'success', + }) + } + return { ok: true, diverged } + } catch (err: any) { + this.syncFailedAt.set(sandboxId, Date.now()) + logger.error(`Project sync (${mode}) failed: ${err?.stack ?? err}`) + // Tools default their working directory to destDir; create it so the + // sandbox really is usable (but empty) while sync is failing. + try { + await this.client.executeCommand(sandboxId, `mkdir -p ${destDir}`, '/') + } catch (mkdirErr) { + logger.warn(`Failed to create project dir after sync failure: ${mkdirErr}`) + } + toast.show({ title: 'Project sync failed', message: `${err?.message ?? err}. Sandbox is usable but empty.`, variant: 'error' }) + return { ok: false, error: `${err?.message ?? err}` } + } + } + + /** Run syncProject at most once concurrently per sandbox. Never rejects (syncProject handles its own errors). */ + private syncProjectOnce( + sandboxId: string, + projectId: string, + worktree: string, + opts: { force?: boolean } = {}, + ): Promise { + const existing = this.syncInflight.get(sandboxId) + if (existing) return existing + const promise = this.syncProject(sandboxId, projectId, worktree, opts) + .finally(() => this.syncInflight.delete(sandboxId)) + this.syncInflight.set(sandboxId, promise) + return this.track(promise) + } + + /** + * Run the configured setup command (dependency install, seed data) after a + * successful sync — once per sandbox lifetime. Sandboxes are stateful, so + * "once" is tracked with a marker file inside the sandbox, not in process + * memory: an OpenCode restart reconnects to the same sandbox and must not + * pay for setup again. The marker name hashes the command and project dir, + * so changing the command re-runs it. A failed command is not retried (the + * marker is written regardless) — the toast reports the failure and the + * user fixes the command or runs it through the agent. Never throws. + */ + private setupMarkerPath(destDir: string, command: string): string { + const hash = createHash('sha1').update(`${destDir}\0${command}`).digest('hex').slice(0, 12) + return posix.join(this.workDir, `.tensorlake-setup-${hash}`) + } + + /** + * Whether the setup command still has to run in this sandbox (no marker for + * the current command). Used to decide if a background refresh sync must be + * awaited instead: setup runs at the end of that sync, and the contract is + * that it completes before the first tool call. An unanswerable probe + * counts as pending — waiting is the safe side. + */ + private async setupStillPending(sandboxId: string, destDir: string): Promise { + const command = this.setupCommand + if (!command || this.setupChecked.has(sandboxId)) return false + const marker = this.setupMarkerPath(destDir, command) + try { + const probe = await this.client.executeCommand(sandboxId, `[ -f '${marker}' ]`, '/', 15_000) + if (probe.exitCode === 0) { + this.setupChecked.add(sandboxId) + return false + } + return true + } catch { + return true + } + } + + private async maybeRunSetup(sandboxId: string, destDir: string): Promise { + const command = this.setupCommand + if (!command || this.setupChecked.has(sandboxId)) return + const marker = this.setupMarkerPath(destDir, command) + try { + const probe = await this.client.executeCommand(sandboxId, `[ -f '${marker}' ]`, '/', 15_000) + this.setupChecked.add(sandboxId) + if (probe.exitCode === 0) return + logger.info(`Running setup command in sandbox ${sandboxId}: ${command}`) + toast.show({ title: 'Running setup command', message: `${command} (in ${destDir})`, variant: 'info' }) + const result = await this.client.executeCommand( + sandboxId, + command, + destDir, + TensorlakeSessionManager.SETUP_TIMEOUT_MS, + ) + await this.client.executeCommand(sandboxId, `touch '${marker}'`, '/', 15_000) + if (result.exitCode === 0) { + logger.info(`Setup command finished in sandbox ${sandboxId}`) + toast.show({ title: 'Setup complete', message: `'${command}' finished in ${destDir}.`, variant: 'success' }) + } else { + const tail = (result.stderr || result.stdout).trim().split('\n').slice(-3).join(' ').slice(0, 300) + logger.error(`Setup command failed in sandbox ${sandboxId} (exit ${result.exitCode}): ${tail}`) + toast.show({ + title: 'Setup command failed', + message: `'${command}' exited ${result.exitCode} in ${destDir}. ${tail}`, + variant: 'error', + }) + } + } catch (err: any) { + // Transient (sandbox hiccup, timeout): allow the next sync to try again. + this.setupChecked.delete(sandboxId) + logger.warn(`Setup command could not run in sandbox ${sandboxId}: ${err?.message ?? err}`) + toast.show({ title: 'Setup command failed', message: `${err?.message ?? err}`, variant: 'error' }) + } + } + + /** + * Fire-and-forget check for local changes since this sandbox's last sync + * (new commits, edits, a branch switch); a difference re-runs the inbound + * sync in the background. Throttled per sandbox, so tool calls stay cheap. + * Git mode only: mount mode is live already, and re-uploading a whole + * volume behind the user's back would clobber agent edits. + */ + private maybeResyncIfLocalChanged(sandboxId: string, projectId: string, worktree: string): void { + if (resolveSyncMode(worktree) !== 'git') return + if (this.shuttingDown) return + if (this.syncInflight.has(sandboxId)) return + const lastSynced = this.syncedFingerprint.get(sandboxId) + if (typeof lastSynced !== 'string') return + const checkedAt = this.resyncCheckedAt.get(sandboxId) ?? 0 + if (Date.now() - checkedAt < TensorlakeSessionManager.RESYNC_CHECK_INTERVAL_MS) return + this.resyncCheckedAt.set(sandboxId, Date.now()) + void this.track( + (async () => { + const current = await localGitFingerprint(worktree) + if (!current || current === lastSynced) return + logger.info(`Local project changed since the last sync; re-syncing into sandbox ${sandboxId}`) + await this.syncProjectOnce(sandboxId, projectId, worktree, { force: true }) + })(), + ).catch((err) => logger.warn(`Automatic re-sync failed: ${err}`)) + } + + /** + * Explicit re-sync for the `sync` tool: push the current local state into + * the sandbox now, then (in git mode) pull any agent commits back. Returns + * a message for the agent. + */ + async syncNow(sessionId: string, projectId: string, worktree: string, pluginCtx?: PluginInput): Promise { + const mode = resolveSyncMode(worktree) + if (mode === 'off') return 'Project sync is disabled (mode off); nothing to sync.' + const { sandboxId } = await this.getSandbox(sessionId, projectId, worktree, pluginCtx) + const destDir = this.projectDir(worktree) + if (mode === 'mount') { + return `The local folder and the sandbox share the same Tensorlake filesystem at ${destDir}; changes already flow both ways, no sync is needed.` + } + // getSandbox may have kicked off a background sync; let it finish, then + // force a fresh pass so state captured after it started is included too. + const inflight = this.syncInflight.get(sandboxId) + if (inflight) await inflight + const outcome = await this.syncProjectOnce(sandboxId, projectId, worktree, { force: true }) + if (!outcome.ok) return `Project sync failed: ${outcome.error}` + if (mode === 'git') { + const report = await this.syncBack(sessionId, projectId, worktree) + const wipNote = + report && 'wip' in report && report.wip.length > 0 + ? ` Uncommitted sandbox changes were staged on the user's local '${report.wip.map((w) => w.ref).join("', '")}' ref(s), never applied to their working tree.` + : '' + if (outcome.diverged) { + const branch = await resolveSyncBranch(worktree) + return `Pushed the user's local state to the sync repo, but the sandbox clone at ${destDir} has diverging commits or uncommitted changes and was left untouched. To take the update, reconcile inside the sandbox (e.g. commit or stash local work, then merge 'origin/${branch}').${wipNote}` + } + return `Synced the user's local project state into the sandbox clone at ${destDir} and pulled any agent commits back to the user's machine.${wipNote}` + } + return `Uploaded the user's local project to the cloud volume mounted at ${destDir}. Files the agent changed on the volume were downloaded to the user's folder first (a file changed on both sides keeps the local version); then local file versions replaced the volume's copies.` + } + + /** + * Make sure tools can run against the project dir without always paying for + * a full sync up front. If the sandbox already contains the project dir + * (e.g. a resumed sandbox synced by an earlier process), the refresh sync + * runs in the background; only a sandbox with no project copy at all blocks + * on the initial sync. + */ + private async ensureProjectAvailable(sandboxId: string, projectId: string, worktree: string): Promise { + if (this.synced.has(sandboxId)) { + this.refreshGitCredentialIfStale(sandboxId, projectId, worktree) + this.maybeResyncIfLocalChanged(sandboxId, projectId, worktree) + return + } + if (resolveSyncMode(worktree) === 'off') { + this.synced.add(sandboxId) + return + } + const destDir = this.projectDir(worktree) + if (this.hasProjectDir.has(sandboxId)) { + const sync = this.syncProjectOnce(sandboxId, projectId, worktree) + // Setup runs at the end of the sync; it must finish before the first + // tool call, so only let the sync run in the background when the setup + // marker for the current command is already in place. + if (await this.setupStillPending(sandboxId, destDir)) await sync + return + } + try { + // The check runs BEFORE the sync starts, so it can never observe a + // half-populated clone. In git mode it requires a .git dir: only then + // does the sync script take its safe fast-forward path — without .git + // it runs `rm -rf && git clone`, which must never execute behind the + // agent's back (e.g. on the decoy dir the sync-failure fallback leaves, + // after the agent has written files into it). In volume mode the dir + // must be non-empty so that same empty decoy dir does not count. + const mode = resolveSyncMode(worktree) + // In mount mode the answer is whether the filesystem is mounted, which + // ensureVolumeMounted already establishes cheaply and exactly — so skip + // the guess and let the sync run. + if (mode === 'mount') { + await this.syncProjectOnce(sandboxId, projectId, worktree) + return + } + const checkCmd = mode === 'git' + ? `[ -d '${destDir}/.git' ]` + : `[ -d '${destDir}' ] && [ -n "$(ls -A '${destDir}' 2>/dev/null)" ]` + const check = await this.client.executeCommand(sandboxId, checkCmd, '/', 15_000) + if (check.exitCode === 0) { + this.hasProjectDir.add(sandboxId) + const sync = this.syncProjectOnce(sandboxId, projectId, worktree) + // Same contract as above: a new or changed setup command must finish + // (it runs at the end of this sync) before the first tool call. + if (await this.setupStillPending(sandboxId, destDir)) await sync + return + } + } catch (err) { + logger.warn(`Failed to check for existing project dir: ${err}`) + } + await this.syncProjectOnce(sandboxId, projectId, worktree) + } + + /** + * Re-mint the sandbox's stored git credential when the last one is near + * expiry (tokens last about an hour). Fire-and-forget: a turn never blocks + * on it, and a failed refresh is retried on the next turn. + */ + private refreshGitCredentialIfStale(sandboxId: string, projectId: string, worktree: string): void { + if (resolveSyncMode(worktree) !== 'git') return + if (this.shuttingDown) return + const last = this.credRefreshedAt.get(sandboxId) ?? 0 + if (Date.now() - last < GIT_CREDENTIAL_REFRESH_MS) return + // Claim the slot up front so concurrent tool calls don't stack refreshes. + this.credRefreshedAt.set(sandboxId, Date.now()) + void this.track(refreshSandboxGitCredential(this.client, this.client.getApiKey(), sandboxId, projectId)) + .then(() => logger.info(`Refreshed git credential in sandbox ${sandboxId}`)) + .catch((err: any) => { + this.credRefreshedAt.delete(sandboxId) + logger.warn(`Failed to refresh git credential in sandbox ${sandboxId}: ${err?.message ?? err}`) + }) + } + + /** + * Pull agent commits from the sync repo back into the local checkout. + * Runs after each agent turn (session.idle) so a "commit and push" by the + * agent lands on the user's local branch within seconds; a turn where the + * agent pushed nothing costs one cheap fetch. Only sessions that actually + * have a sandbox in this process trigger it, and only in git mode. + */ + async syncBack(sessionId: string, projectId: string, worktree: string): Promise { + const cached = this.cache.get(sessionId) + if (!cached) return + const mode = resolveSyncMode(worktree) + if (mode !== 'git' && mode !== 'volume') return + const failedAt = this.syncBackFailedAt.get(worktree) + if (failedAt !== undefined && Date.now() - failedAt < TensorlakeSessionManager.SYNC_RETRY_COOLDOWN_MS) return + const sandboxId = cached.sandboxId + // Dedup per sandbox: a second idle for the SAME sandbox joins its pending + // run. A different sandbox of the same worktree gets its own run instead — + // returning the first sandbox's promise would silently skip its wip capture. + const key = `${worktree}\0${sandboxId}` + const existing = this.syncBackInflight.get(key) + if (existing) return existing + // Serialize per worktree: each run chains behind the current tail so two + // sandboxes never fetch/merge into the same local repo at once. + const prev = this.syncBackTail.get(worktree) ?? Promise.resolve() + const run = prev + .catch(() => undefined) + .then((): Promise => + mode === 'git' + ? this._syncBack(worktree, projectId, sandboxId) + : this._syncBackVolume(worktree, projectId, sandboxId), + ) + .finally(() => { + this.syncBackInflight.delete(key) + if (this.syncBackTail.get(worktree) === run) this.syncBackTail.delete(worktree) + }) + this.syncBackInflight.set(key, run) + this.syncBackTail.set(worktree, run) + return this.track(run) + } + + private async _syncBack(worktree: string, projectId: string, sandboxId: string): Promise { + // Capture the sandbox's uncommitted changes onto the sync repo's wip + // scratch ref first, so the fetch below brings committed and uncommitted + // work home in one pass. Failures never block the committed sync-back. + try { + const captured = await captureSandboxWip(this.client, sandboxId, this.projectDir(worktree)) + if (captured === 'pushed' || captured === 'cleared') { + logger.info(`Sync-back: ${captured} uncommitted-sandbox-changes capture for sandbox ${sandboxId}`) + } + } catch (err: any) { + logger.warn(`Could not capture uncommitted sandbox changes: ${err?.message ?? err}`) + } + try { + const report = await syncBackFromSyncRepo(this.client.getApiKey(), worktree, projectId) + const result = report.committed + if (result.kind === 'fast-forwarded') { + this.lastStagedOid.delete(worktree) + logger.info(`Sync-back: fast-forwarded local ${result.branch} by ${result.commits} agent commit(s)`) + toast.show({ + title: 'Agent work pulled', + message: `${result.commits} commit(s) from the sandbox are now on your local '${result.branch}' branch.`, + variant: 'success', + }) + } else if (result.kind === 'staged' && result.oid !== this.lastStagedOid.get(worktree)) { + this.lastStagedOid.set(worktree, result.oid) + logger.info(`Sync-back: staged ${result.commits} agent commit(s) on ${result.ref} (${result.reason})`) + toast.show({ + title: 'Agent work fetched', + message: `${result.commits} commit(s) are on '${result.ref}' but not merged (${result.reason}). Merge when ready: git merge ${result.ref}`, + variant: 'warning', + }) + } + this.reportStagedWip(worktree, report) + return report + } catch (err: any) { + this.syncBackFailedAt.set(worktree, Date.now()) + logger.warn(`Sync-back from sync repo failed: ${err?.message ?? err}`) + return undefined + } + } + + /** + * Volume-mode sync-back: download files the agent changed on the volume + * into the local folder (idle turns and the sync tool). Skipped while an + * upload for the same sandbox is in flight — both rewrite the manifest. + */ + private async _syncBackVolume(worktree: string, projectId: string, sandboxId: string): Promise { + if (this.syncInflight.has(sandboxId)) return undefined + try { + const result = await syncBackFromVolume(this.client.getApiKey(), worktree, projectId, this.storageDir) + this.reportVolumeSyncBack(worktree, result) + return result + } catch (err: any) { + this.syncBackFailedAt.set(worktree, Date.now()) + logger.warn(`Volume sync-back failed: ${err?.message ?? err}`) + return undefined + } + } + + /** Toast what a volume sync-back changed. Conflicts fire once per remote change. */ + private reportVolumeSyncBack(worktree: string, result: VolumeSyncBack): void { + if (result.downloaded > 0) { + logger.info(`Volume sync-back: downloaded ${result.downloaded} file(s) into ${worktree}`) + toast.show({ + title: 'Agent files downloaded', + message: `${result.downloaded} file(s) the agent changed were downloaded into ${worktree}.`, + variant: 'success', + }) + } + if (result.conflicts.length > 0) { + const shown = result.conflicts.slice(0, 3).join(', ') + const more = result.conflicts.length > 3 ? `, +${result.conflicts.length - 3} more` : '' + logger.warn(`Volume sync-back: ${result.conflicts.length} conflict(s) kept local: ${result.conflicts.join(', ')}`) + toast.show({ + title: 'Sync-back conflicts', + message: `${result.conflicts.length} file(s) changed both locally and in the sandbox; your local versions were kept: ${shown}${more}. Run a sync to make your versions win.`, + variant: 'warning', + }) + } + if (result.deletedRemotely > 0) { + logger.info(`Volume sync-back: ${result.deletedRemotely} file(s) deleted on the volume; local copies kept`) + } + } + + /** + * Toast each new wip capture staged on a local tensorlake-wip/* ref. The + * changes are deliberately never applied to the user's working tree — the + * toast tells them how to look at and take the work themselves. + */ + private reportStagedWip(worktree: string, report: SyncBackReport): void { + const seen = new Set() + for (const wip of report.wip) { + const key = `${worktree}\0${wip.branch}` + seen.add(key) + if (this.lastWipOid.get(key) === wip.oid) continue + this.lastWipOid.set(key, wip.oid) + const count = wip.files > 0 ? `${wip.files} file(s) of uncommitted` : 'Uncommitted' + logger.info(`Sync-back: staged uncommitted sandbox changes on ${wip.ref} (${wip.oid.slice(0, 8)})`) + toast.show({ + title: 'Uncommitted sandbox work staged', + message: `${count} agent changes are on '${wip.ref}' (not applied to your tree). View: git diff ${wip.ref}~ ${wip.ref} — take them: git cherry-pick -n ${wip.ref}`, + variant: 'info', + }) + } + // Captures cleared on the sync repo were pruned locally; forget them so a + // later capture on the same branch is reported again. + for (const key of this.lastWipOid.keys()) { + if (key.startsWith(`${worktree}\0`) && !seen.has(key)) this.lastWipOid.delete(key) + } + } + private storagePath(projectId: string): string { return join(this.storageDir, `${projectId}.json`) } @@ -80,6 +651,9 @@ export class TensorLakeSessionManager { worktree: string, pluginCtx?: PluginInput, ): Promise<{ sandboxId: string }> { + if (this.deleting.has(sessionId)) { + return Promise.reject(new Error(`Session ${sessionId} was deleted; its sandbox is gone and will not be recreated.`)) + } const existing = this.inflight.get(sessionId) if (existing) return existing const promise = this._getSandbox(sessionId, projectId, worktree, pluginCtx) @@ -88,7 +662,7 @@ export class TensorLakeSessionManager { }) .finally(() => this.inflight.delete(sessionId)) this.inflight.set(sessionId, promise) - return promise + return this.track(promise) } // Login cannot validate the key (OpenCode masks it away from the plugin), @@ -135,18 +709,27 @@ export class TensorLakeSessionManager { if (cached) { try { const info = await this.client.getSandbox(cached.sandboxId) - if (info.status === 'suspended') { - logger.info(`Resuming sandbox ${cached.sandboxId}`) - await this.client.resumeSandbox(cached.sandboxId) - await this.client.waitForRunning(cached.sandboxId) - toast.show({ title: 'Sandbox resumed', message: 'Sandbox resumed from suspension.', variant: 'info' }) - } else if (info.status === 'terminated') { - logger.warn(`Sandbox ${cached.sandboxId} was terminated, creating new one`) + // 'timeout' is terminal like 'terminated' — waiting on it never ends in 'running' + if (info.status === 'terminated' || info.status === 'timeout') { + logger.warn(`Sandbox ${cached.sandboxId} is ${info.status}, creating new one`) this.cache.delete(sessionId) this.removeSession(projectId, sessionId) - return this.getSandbox(sessionId, projectId, worktree, pluginCtx) + // Call _getSandbox directly: getSandbox would return the still-pending + // in-flight promise for this session, resolving the promise to itself. + return this._getSandbox(sessionId, projectId, worktree, pluginCtx) + } + if (info.status === 'suspended' || info.status === 'suspending') { + logger.info(`Resuming sandbox ${cached.sandboxId} (was ${info.status})`) + if (info.status === 'suspending') await this.client.waitForSuspended(cached.sandboxId) + // resumeSandbox blocks until the sandbox is running again + await this.client.resumeSandbox(cached.sandboxId) + toast.show({ title: 'Sandbox resumed', message: 'Sandbox resumed from suspension.', variant: 'info' }) + } else if (info.status !== 'running') { + await this.client.waitForRunning(cached.sandboxId) } this.updateSession(projectId, worktree, sessionId, cached.sandboxId) + // No-op when already synced; retries a previously failed sync (after cooldown) + await this.ensureProjectAvailable(cached.sandboxId, projectId, worktree) return cached } catch (err) { logger.warn(`Failed to check cached sandbox: ${err}`) @@ -154,42 +737,38 @@ export class TensorLakeSessionManager { } } - // Check persistent storage — first for this session, then for any session in the project + // Check persistent storage for this session only. Never adopt another + // session's sandbox: sessions must stay isolated, and deleting either + // session would tear down the shared sandbox. const projectData = this.loadProjectData(projectId) - const candidateSessions = projectData - ? [ - ...(projectData.sessions[sessionId] ? [[sessionId, projectData.sessions[sessionId]] as const] : []), - ...Object.entries(projectData.sessions) - .filter(([id]) => id !== sessionId) - .sort(([, a], [, b]) => b.lastAccessed - a.lastAccessed), - ] - : [] - - for (const [storedSessionId, stored] of candidateSessions) { - logger.info(`Trying sandbox ${stored.sandboxId} from session ${storedSessionId}`) + const stored = projectData?.sessions[sessionId] + + if (stored) { + logger.info(`Trying sandbox ${stored.sandboxId} from session ${sessionId}`) try { const info = await this.client.getSandbox(stored.sandboxId) - if (info.status === 'terminated') { - this.removeSession(projectId, storedSessionId) - continue - } - if (info.status === 'suspended' || info.status === 'suspending') { - logger.info(`Resuming sandbox ${stored.sandboxId} (was ${info.status})`) - if (info.status === 'suspending') await this.client.waitForSuspended(stored.sandboxId) - await this.client.resumeSandbox(stored.sandboxId) - await this.client.waitForRunning(stored.sandboxId) - } else if (info.status !== 'running') { - await this.client.waitForRunning(stored.sandboxId) + // 'timeout' is terminal like 'terminated' — waiting on it never ends in 'running' + if (info.status === 'terminated' || info.status === 'timeout') { + this.removeSession(projectId, sessionId) + } else { + if (info.status === 'suspended' || info.status === 'suspending') { + logger.info(`Resuming sandbox ${stored.sandboxId} (was ${info.status})`) + if (info.status === 'suspending') await this.client.waitForSuspended(stored.sandboxId) + // resumeSandbox blocks until the sandbox is running again + await this.client.resumeSandbox(stored.sandboxId) + } else if (info.status !== 'running') { + await this.client.waitForRunning(stored.sandboxId) + } + const entry = { sandboxId: stored.sandboxId } + this.cache.set(sessionId, entry) + this.updateSession(projectId, worktree, sessionId, stored.sandboxId) + toast.show({ title: 'Sandbox connected', message: 'Connected to existing sandbox.', variant: 'info' }) + await this.ensureProjectAvailable(stored.sandboxId, projectId, worktree) + return entry } - const entry = { sandboxId: stored.sandboxId } - this.cache.set(sessionId, entry) - this.updateSession(projectId, worktree, sessionId, stored.sandboxId) - const reused = storedSessionId !== sessionId - toast.show({ title: 'Sandbox connected', message: reused ? 'Reusing sandbox from previous session.' : 'Connected to existing sandbox.', variant: 'info' }) - return entry } catch (err) { logger.warn(`Failed to connect to sandbox ${stored.sandboxId}: ${err}`) - this.removeSession(projectId, storedSessionId) + this.removeSession(projectId, sessionId) } } @@ -213,9 +792,85 @@ export class TensorLakeSessionManager { this.updateSession(projectId, worktree, sessionId, created.sandbox_id) toast.show({ title: 'Sandbox created', message: 'New sandbox is ready.', variant: 'success' }) + // A fresh sandbox has no project copy — the sync must finish before tools run + await this.syncProjectOnce(created.sandbox_id, projectId, worktree) return entry } + /** + * Register background work so shutdown can wait for it. Returns the same + * promise, so callers keep their own error handling; the tracked copy never + * rejects. + */ + private track(promise: Promise): Promise { + const settled: Promise = promise.then( + () => undefined, + () => undefined, + ) + this.pending.add(settled) + void settled.then(() => this.pending.delete(settled)) + return promise + } + + /** + * Whether any tracked background work is still running. Signal handlers use + * it to keep the old synchronous exit path when there is nothing to drain. + */ + hasPendingWork(): boolean { + return this.pending.size > 0 + } + + /** + * Wait for tracked background work, re-checking after each batch: a + * sync-back that was queued behind another one only appears in the set once + * the first finishes. Gives up (and says so) when the budget runs out + * rather than holding the exit open forever. + */ + private async drainPending(timeoutMs: number): Promise { + if (this.pending.size === 0) return + const deadline = Date.now() + timeoutMs + logger.info(`Shutdown: waiting for ${this.pending.size} pending task(s) to finish`) + while (this.pending.size > 0) { + const remaining = deadline - Date.now() + if (remaining <= 0) break + const batch = Promise.all([...this.pending]) + let timer: ReturnType | undefined + const expiry = new Promise<'timeout'>((resolve) => { + timer = setTimeout(() => resolve('timeout'), remaining) + }) + const outcome = await Promise.race([batch.then(() => 'done' as const), expiry]) + if (timer) clearTimeout(timer) + if (outcome === 'timeout') break + } + if (this.pending.size > 0) { + logger.warn(`Shutdown drain timed out; ${this.pending.size} task(s) were abandoned`) + } else { + logger.info('Shutdown: pending tasks drained') + } + } + + /** + * Finish (or time out) the work that carries agent changes between the + * sandbox and the user's machine, then suspend the sandboxes. Called from + * the signal handlers and from server.instance.disposed; the first call + * does the work and every later one joins it. + */ + shutdown(reason: string, opts: { drainMs?: number } = {}): Promise { + if (this.shutdownRun) return this.shutdownRun + this.shuttingDown = true + const drainMs = opts.drainMs ?? TensorlakeSessionManager.drainBudgetMs() + this.shutdownRun = (async () => { + logger.info(`Shutdown (${reason}): draining background work before suspend`) + try { + await this.drainPending(drainMs) + } catch (err) { + logger.warn(`Shutdown drain failed: ${err}`) + } + this.suspendAllSandboxes() + })() + return this.shutdownRun + } + suspendAllSandboxes(): void { for (const [sessionId, { sandboxId }] of this.cache.entries()) { logger.info(`Suspending sandbox ${sandboxId} for session ${sessionId} (app exit)`) @@ -228,7 +883,18 @@ export class TensorLakeSessionManager { } } - async deleteSandbox(sessionId: string, projectId: string): Promise { + deleteSandbox(sessionId: string, projectId: string, worktree?: string): Promise { + return this.track(this._deleteSandbox(sessionId, projectId, worktree)) + } + + private async _deleteSandbox(sessionId: string, projectId: string, worktree?: string): Promise { + // Block new tool calls from resurrecting the session first, then wait for + // an in-flight getSandbox: a delete that raced sandbox creation would + // otherwise see no sandboxId, report success, and orphan the new sandbox. + this.deleting.add(sessionId) + const inflight = this.inflight.get(sessionId) + if (inflight) await inflight.catch(() => undefined) + const cached = this.cache.get(sessionId) const projectData = this.loadProjectData(projectId) const stored = projectData?.sessions[sessionId] @@ -239,10 +905,53 @@ export class TensorLakeSessionManager { return } - logger.info(`Deleting sandbox ${sandboxId} for session ${sessionId}`) - await this.client.deleteSandbox(sandboxId) + // Data persisted before sessions were isolated can alias one sandbox to + // several sessions. Only tear down the sandbox when no other session + // still references it; otherwise just detach this session. + const sharedWith = Object.entries(projectData?.sessions ?? {}).filter( + ([id, s]) => id !== sessionId && s.sandboxId === sandboxId, + ) + if (sharedWith.length > 0) { + this.cache.delete(sessionId) + this.removeSession(projectId, sessionId) + logger.warn(`Sandbox ${sandboxId} is still used by ${sharedWith.length} other session(s); detached session ${sessionId} without deleting it`) + return + } + + // Final sync-back before the sandbox (and its only copy of uncommitted + // agent work) is destroyed. Best-effort: a failure is logged and never + // blocks the deletion the user asked for. + if (worktree) { + try { + // Let an in-flight inbound sync settle — it holds locks the volume + // sync-back skips on — and lift the retry cooldown: this is the last + // chance to pull work home. + const syncing = this.syncInflight.get(sandboxId) + if (syncing) await syncing + this.syncBackFailedAt.delete(worktree) + // syncBack reads the session's cache entry; restore it if the sandbox + // was only known from persistent storage. + if (!this.cache.has(sessionId)) this.cache.set(sessionId, { sandboxId }) + await this.syncBack(sessionId, projectId, worktree) + } catch (err: any) { + logger.warn(`Final sync-back before deleting sandbox ${sandboxId} failed: ${err?.message ?? err}`) + } + } + this.cache.delete(sessionId) this.removeSession(projectId, sessionId) + + logger.info(`Deleting sandbox ${sandboxId} for session ${sessionId}`) + await this.client.deleteSandbox(sandboxId) logger.info(`Sandbox ${sandboxId} deleted`) + + // Drop per-sandbox state — the id never comes back. + this.synced.delete(sandboxId) + this.hasProjectDir.delete(sandboxId) + this.syncFailedAt.delete(sandboxId) + this.syncedFingerprint.delete(sandboxId) + this.resyncCheckedAt.delete(sandboxId) + this.credRefreshedAt.delete(sandboxId) + this.setupChecked.delete(sandboxId) } } diff --git a/.opencode/plugin/tensorlake/core/shell.ts b/.opencode/plugin/tensorlake/core/shell.ts new file mode 100644 index 0000000..06f378d --- /dev/null +++ b/.opencode/plugin/tensorlake/core/shell.ts @@ -0,0 +1,8 @@ +/** + * Quote a string for safe use as a single word in a POSIX shell command. + * Wraps the value in single quotes; embedded single quotes become '\''. + * Nothing inside single quotes is expanded by the shell. + */ +export function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} diff --git a/.opencode/plugin/tensorlake/index.ts b/.opencode/plugin/tensorlake/index.ts index fd5420e..777f22a 100644 --- a/.opencode/plugin/tensorlake/index.ts +++ b/.opencode/plugin/tensorlake/index.ts @@ -3,7 +3,9 @@ import { xdgData } from 'xdg-basedir' import type { PluginInput } from '@opencode-ai/plugin' import { setLogFilePath, logger } from './core/logger.js' import { resolveApiKey } from './core/credentials.js' -import { TensorLakeSessionManager } from './core/session-manager.js' +import { resolveProjectContext } from './core/project-context.js' +import { detectSyncMode } from './core/project-sync.js' +import { TensorlakeSessionManager } from './core/session-manager.js' import { toast } from './core/toast.js' import { authHook } from './plugins/auth.js' import { customTools } from './plugins/custom-tools.js' @@ -15,10 +17,11 @@ const STORAGE_DIR = join(xdgData ?? '/tmp', 'opencode', 'storage', 'tensorlake') const WORK_DIR = '/tmp/workspace' setLogFilePath(LOG_FILE) -const sessionManager = new TensorLakeSessionManager(resolveApiKey, STORAGE_DIR, WORK_DIR) +const sessionManager = new TensorlakeSessionManager(resolveApiKey, STORAGE_DIR, WORK_DIR) -function suspendAndExit(signal: string) { - logger.info(`Received ${signal}, suspending sandboxes before exit`) +let exitRequested = false + +function suspendNowAndExit(signal: string) { try { sessionManager.suspendAllSandboxes() } catch (err) { @@ -27,16 +30,53 @@ function suspendAndExit(signal: string) { process.exit(0) } +/** + * Exit path for SIGINT/SIGTERM. Background work carries agent changes between + * the sandbox and the user's machine (inbound sync, sync-back, uncommitted-work + * capture, volume download, setup), so it has to finish before the sandboxes + * are suspended — cutting it off loses the work. With nothing pending the old + * synchronous path is kept, so an idle exit is as fast (and as robust against + * another handler exiting first) as before. A second signal skips the wait. + */ +function suspendAndExit(signal: string) { + if (exitRequested) { + logger.warn(`Received ${signal} again, suspending without waiting for pending work`) + suspendNowAndExit(signal) + return + } + exitRequested = true + if (!sessionManager.hasPendingWork()) { + logger.info(`Received ${signal}, suspending sandboxes before exit`) + suspendNowAndExit(signal) + return + } + logger.info(`Received ${signal}, finishing pending work before suspending sandboxes`) + sessionManager + .shutdown(signal) + .catch((err) => logger.error(`Failed to shut down cleanly on ${signal}: ${err}`)) + .finally(() => process.exit(0)) +} + process.on('SIGTERM', () => suspendAndExit('SIGTERM')) process.on('SIGINT', () => suspendAndExit('SIGINT')) -async function tensorlakePlugin(ctx: PluginInput) { +async function tensorlakePlugin(ctx: PluginInput, options?: Record) { toast.initialize(ctx.client?.tui) + const { worktree } = resolveProjectContext(ctx) + // Post-sync setup command (dependency install, seed data). Configured as a + // plugin option in opencode.json — ["tensorlake-opencode", {"setup": "npm ci"}] + // — so it can live in the project's own opencode.json; the env var wins. + const setup = process.env.TENSORLAKE_SETUP_COMMAND ?? (typeof options?.setup === 'string' ? options.setup : undefined) + sessionManager.setSetupCommand(setup) + // Resolve the sync mode once, before anything reads it: detecting a local + // Tensorlake mount needs the CLI, and every later read is synchronous. + await detectSyncMode(worktree) + const projectDir = sessionManager.projectDir(worktree) return { auth: authHook, tool: await customTools(ctx, sessionManager), event: await eventHandlers(ctx, sessionManager), - 'experimental.chat.system.transform': await systemPromptTransform(ctx, WORK_DIR), + 'experimental.chat.system.transform': await systemPromptTransform(ctx, WORK_DIR, projectDir), } } diff --git a/.opencode/plugin/tensorlake/plugins/custom-tools.ts b/.opencode/plugin/tensorlake/plugins/custom-tools.ts index 891d611..4366fff 100644 --- a/.opencode/plugin/tensorlake/plugins/custom-tools.ts +++ b/.opencode/plugin/tensorlake/plugins/custom-tools.ts @@ -1,9 +1,14 @@ import type { PluginInput } from '@opencode-ai/plugin' -import { createTensorLakeTools } from '../tools.js' +import { createTensorlakeTools } from '../tools.js' +import { resolveSyncMode } from '../core/project-sync.js' import { logger } from '../core/logger.js' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import { resolveProjectContext } from '../core/project-context.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' -export async function customTools(ctx: PluginInput, sessionManager: TensorLakeSessionManager) { - logger.info('OpenCode started with TensorLake plugin') - return createTensorLakeTools(sessionManager, ctx.project.id, ctx.project.worktree, ctx) +export async function customTools(ctx: PluginInput, sessionManager: TensorlakeSessionManager) { + const { projectId, worktree } = resolveProjectContext(ctx) + logger.info( + `OpenCode started with Tensorlake plugin (project=${projectId}, worktree=${worktree || '(none)'}, sync=${resolveSyncMode(worktree)})`, + ) + return createTensorlakeTools(sessionManager, projectId, worktree, ctx) } diff --git a/.opencode/plugin/tensorlake/plugins/session-events.ts b/.opencode/plugin/tensorlake/plugins/session-events.ts index fdaf6d5..656d26c 100644 --- a/.opencode/plugin/tensorlake/plugins/session-events.ts +++ b/.opencode/plugin/tensorlake/plugins/session-events.ts @@ -1,25 +1,39 @@ import type { PluginInput } from '@opencode-ai/plugin' -import { EVENT_TYPE_SESSION_DELETED, EVENT_TYPE_SERVER_INSTANCE_DISPOSED, type EventSessionDeleted } from '../core/types.js' +import { + EVENT_TYPE_SESSION_DELETED, + EVENT_TYPE_SESSION_IDLE, + EVENT_TYPE_SERVER_INSTANCE_DISPOSED, + type EventSessionDeleted, + type EventSessionIdle, +} from '../core/types.js' import { toast } from '../core/toast.js' import { logger } from '../core/logger.js' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' +import { resolveProjectContext } from '../core/project-context.js' -export async function eventHandlers(ctx: PluginInput, sessionManager: TensorLakeSessionManager) { - const projectId = ctx.project.id +export async function eventHandlers(ctx: PluginInput, sessionManager: TensorlakeSessionManager) { + const { projectId, worktree } = resolveProjectContext(ctx) return async (args: any) => { const event = args.event - if (event.type === EVENT_TYPE_SESSION_DELETED) { + if (event.type === EVENT_TYPE_SESSION_IDLE) { + // The agent just finished a turn: pull whatever it pushed to the sync repo + // back into the local checkout. Failures are logged inside syncBack. + const sessionId = (event as EventSessionIdle).properties.sessionID + await sessionManager.syncBack(sessionId, projectId, worktree) + } else if (event.type === EVENT_TYPE_SESSION_DELETED) { const sessionId = (event as EventSessionDeleted).properties.info.id try { - await sessionManager.deleteSandbox(sessionId, projectId) + await sessionManager.deleteSandbox(sessionId, projectId, worktree) toast.show({ title: 'Session deleted', message: 'Sandbox deleted successfully.', variant: 'success' }) } catch (err: any) { logger.error(`Failed to delete sandbox: ${err}`) toast.show({ title: 'Delete failed', message: err?.message ?? 'Failed to delete sandbox.', variant: 'error' }) } } else if (event.type === EVENT_TYPE_SERVER_INSTANCE_DISPOSED) { + // Let pending sync work finish before the sandboxes are suspended; + // shutdown() bounds the wait and suspends either way. try { - sessionManager.suspendAllSandboxes() + await sessionManager.shutdown('server.instance.disposed') } catch (err: any) { logger.error(`Failed to suspend sandboxes on exit: ${err}`) } diff --git a/.opencode/plugin/tensorlake/plugins/system-transform.ts b/.opencode/plugin/tensorlake/plugins/system-transform.ts index 1d5bb7c..13340d0 100644 --- a/.opencode/plugin/tensorlake/plugins/system-transform.ts +++ b/.opencode/plugin/tensorlake/plugins/system-transform.ts @@ -1,17 +1,42 @@ import type { PluginInput } from '@opencode-ai/plugin' import type { ExperimentalChatSystemTransformInput, ExperimentalChatSystemTransformOutput } from '../core/types.js' +import { resolveSyncMode, resolveSyncBranch } from '../core/project-sync.js' +import { resolveProjectContext } from '../core/project-context.js' -export async function systemPromptTransform(ctx: PluginInput, workDir: string) { +export async function systemPromptTransform(ctx: PluginInput, workDir: string, projectDir: string) { + const { worktree } = resolveProjectContext(ctx) + const mode = resolveSyncMode(worktree) return async (_input: ExperimentalChatSystemTransformInput, output: ExperimentalChatSystemTransformOutput) => { - output.system.push( - [ - '## TensorLake Sandbox Integration', - 'This session is running inside a TensorLake sandbox.', - `The working directory is: ${workDir}`, - 'All bash commands, file reads/writes, and searches run inside the sandbox.', - `Put all project files in ${workDir}. Do NOT use paths from the host system.`, - "For long-running commands (servers, watchers), use the 'background' option.", - ].join('\n'), - ) + const lines = [ + '## Tensorlake Sandbox Integration', + 'This session is running inside a Tensorlake sandbox.', + 'All bash commands, file reads/writes, and searches run inside the sandbox.', + 'Do NOT use paths from the host system.', + "For long-running commands (servers, watchers), use bash with background=true; check on them with bash_output and stop them with bash_kill.", + ] + if (mode === 'git') { + // Resolved per message, not at plugin startup: the user can switch + // local branches between turns and later syncs follow the new branch. + const branch = await resolveSyncBranch(worktree) + lines.push( + `The local project is synced into the sandbox as a git clone at: ${projectDir} (branch: ${branch})`, + `Work in ${projectDir} on branch '${branch}'. Commit and push to 'origin ${branch}' to persist changes; pushed commits are pulled back into the user's local checkout automatically.`, + "If the user mentions local edits, commits, or a branch switch you cannot see, run the 'sync' tool to bring their latest local state into the sandbox.", + ) + } else if (mode === 'mount') { + lines.push( + `The user's local folder and this sandbox mount the same Tensorlake filesystem; it is at: ${projectDir}`, + `Work in ${projectDir}. Writes are saved automatically and appear in the user's local folder within about a second, so the user may be editing the same files — prefer touching only what you were asked to change.`, + ) + } else if (mode === 'volume') { + lines.push( + `The local project is mounted into the sandbox on a cloud volume at: ${projectDir}`, + `Work in ${projectDir}. Writes there are persisted automatically.`, + "If the user mentions local edits you cannot see, run the 'sync' tool to upload their latest local files (it replaces the volume's copies of those files).", + ) + } else { + lines.push(`The working directory is: ${workDir}`, `Put all project files in ${workDir}.`) + } + output.system.push(lines.join('\n')) } } diff --git a/.opencode/plugin/tensorlake/tools.ts b/.opencode/plugin/tensorlake/tools.ts index be6c8f0..eeadd9a 100644 --- a/.opencode/plugin/tensorlake/tools.ts +++ b/.opencode/plugin/tensorlake/tools.ts @@ -1,24 +1,34 @@ -import type { TensorLakeSessionManager } from './core/session-manager.js' +import type { TensorlakeSessionManager } from './core/session-manager.js' import type { PluginInput } from '@opencode-ai/plugin' -import { bashTool } from './tools/bash.js' +import { bashTool, bashOutputTool, bashKillTool } from './tools/bash.js' import { readTool } from './tools/read.js' import { writeTool } from './tools/write.js' import { editTool } from './tools/edit.js' +import { multiEditTool } from './tools/multiedit.js' +import { applyPatchTool } from './tools/apply-patch.js' import { lsTool } from './tools/ls.js' import { globTool } from './tools/glob.js' import { grepTool } from './tools/grep.js' +import { syncTool } from './tools/sync.js' -export function createTensorLakeTools( - sessionManager: TensorLakeSessionManager, +export function createTensorlakeTools( + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) { return { bash: bashTool(sessionManager, projectId, worktree, pluginCtx), + bash_output: bashOutputTool(sessionManager, projectId, worktree, pluginCtx), + bash_kill: bashKillTool(sessionManager, projectId, worktree, pluginCtx), + sync: syncTool(sessionManager, projectId, worktree, pluginCtx), read: readTool(sessionManager, projectId, worktree, pluginCtx), write: writeTool(sessionManager, projectId, worktree, pluginCtx), edit: editTool(sessionManager, projectId, worktree, pluginCtx), + multiedit: multiEditTool(sessionManager, projectId, worktree, pluginCtx), + // Shadows OpenCode's built-in apply_patch, which writes to the LOCAL + // filesystem and would otherwise bypass the sandbox entirely. + apply_patch: applyPatchTool(sessionManager, projectId, worktree, pluginCtx), ls: lsTool(sessionManager, projectId, worktree, pluginCtx), glob: globTool(sessionManager, projectId, worktree, pluginCtx), grep: grepTool(sessionManager, projectId, worktree, pluginCtx), diff --git a/.opencode/plugin/tensorlake/tools/apply-patch.ts b/.opencode/plugin/tensorlake/tools/apply-patch.ts new file mode 100644 index 0000000..4c8afff --- /dev/null +++ b/.opencode/plugin/tensorlake/tools/apply-patch.ts @@ -0,0 +1,255 @@ +import { z } from 'zod' +import { posix } from 'path' +import type { PluginInput } from '@opencode-ai/plugin' +import type { ToolContext } from '@opencode-ai/plugin/tool' +import type { TensorlakeSessionManager } from '../core/session-manager.js' +import { shellQuote } from '../core/shell.js' + +/** + * Replacement for OpenCode's built-in apply_patch tool. The built-in edits + * files on the LOCAL machine; every other file tool here routes to the + * sandbox, so leaving it in place would let the agent bypass the sandbox and + * modify the host project directly. This tool shadows it by name and applies + * the same patch format (the "*** Begin Patch" envelope with Add/Update/ + * Delete File sections) inside the Tensorlake sandbox instead. + */ + +type HunkPart = { kind: 'ctx' | 'del' | 'add'; line: string } + +type Hunk = { + anchor?: string + // The hunk body in order: 'ctx' and 'del' lines must appear consecutively + // in the file; 'add' lines are inserted in their place alongside the + // (preserved) context lines. + parts: HunkPart[] +} + +type PatchOp = + | { type: 'add'; path: string; lines: string[] } + | { type: 'delete'; path: string } + | { type: 'update'; path: string; movePath?: string; hunks: Hunk[] } + +export function parsePatch(patchText: string): PatchOp[] { + const lines = patchText.replace(/\r\n/g, '\n').split('\n') + let i = 0 + while (i < lines.length && lines[i].trim() === '') i++ + if (lines[i]?.trim() !== '*** Begin Patch') { + throw new Error(`apply_patch: patch must start with '*** Begin Patch'`) + } + i++ + const ops: PatchOp[] = [] + let sawEnd = false + while (i < lines.length) { + const line = lines[i] + if (line.trim() === '*** End Patch') { + sawEnd = true + break + } + let m: RegExpMatchArray | null + if ((m = line.match(/^\*\*\* Add File: (.+)$/))) { + const path = m[1].trim() + i++ + const content: string[] = [] + while (i < lines.length && !lines[i].startsWith('***')) { + const l = lines[i] + // Every content line must carry a '+', but tolerate a bare empty + // line: models emit those for blank lines often enough. + if (l.startsWith('+')) content.push(l.slice(1)) + else if (l === '') content.push('') + else throw new Error(`apply_patch: in 'Add File: ${path}' every line must start with '+'`) + i++ + } + ops.push({ type: 'add', path, lines: content }) + } else if ((m = line.match(/^\*\*\* Delete File: (.+)$/))) { + ops.push({ type: 'delete', path: m[1].trim() }) + i++ + } else if ((m = line.match(/^\*\*\* Update File: (.+)$/))) { + const path = m[1].trim() + i++ + let movePath: string | undefined + const mv = lines[i]?.match(/^\*\*\* Move to: (.+)$/) + if (mv) { + movePath = mv[1].trim() + i++ + } + const hunks: Hunk[] = [] + let cur: Hunk = { parts: [] } + const flush = () => { + if (cur.anchor !== undefined || cur.parts.length > 0) hunks.push(cur) + cur = { parts: [] } + } + while (i < lines.length && (!lines[i].startsWith('***') || lines[i].trim() === '*** End of File')) { + const l = lines[i] + if (l.trim() === '*** End of File') { + // Marks a hunk anchored at EOF; the apply step already falls back + // to appending at the end, so the marker itself needs no state. + } else if (l.startsWith('@@')) { + flush() + const anchor = l.slice(2).trim() + if (anchor) cur.anchor = anchor + } else if (l.startsWith('+')) { + cur.parts.push({ kind: 'add', line: l.slice(1) }) + } else if (l.startsWith('-')) { + cur.parts.push({ kind: 'del', line: l.slice(1) }) + } else if (l.startsWith(' ')) { + cur.parts.push({ kind: 'ctx', line: l.slice(1) }) + } else if (l === '') { + // A blank context line whose leading space was dropped. + cur.parts.push({ kind: 'ctx', line: '' }) + } else { + throw new Error(`apply_patch: in 'Update File: ${path}' unexpected line: ${l}`) + } + i++ + } + flush() + if (hunks.length === 0) throw new Error(`apply_patch: 'Update File: ${path}' has no hunks`) + ops.push({ type: 'update', path, movePath, hunks }) + } else { + throw new Error(`apply_patch: unexpected line in patch: ${line}`) + } + } + if (!sawEnd) throw new Error(`apply_patch: patch must end with '*** End Patch'`) + if (ops.length === 0) throw new Error('apply_patch: patch contains no file operations') + return ops +} + +// Find `seq` as a run of consecutive lines at index >= from. Three passes of +// decreasing strictness — exact, ignoring trailing whitespace, ignoring all +// edge whitespace — so hunks survive the whitespace drift models introduce. +function findSequence(lines: string[], seq: string[], from: number): number { + const canons = [(s: string) => s, (s: string) => s.trimEnd(), (s: string) => s.trim()] + for (const canon of canons) { + for (let at = from; at + seq.length <= lines.length; at++) { + let ok = true + for (let j = 0; j < seq.length; j++) { + if (canon(lines[at + j]) !== canon(seq[j])) { + ok = false + break + } + } + if (ok) return at + } + } + return -1 +} + +export function applyHunks(content: string, hunks: Hunk[], path: string): string { + const lines = content.split('\n') + let cursor = 0 + for (const hunk of hunks) { + let anchorFound = false + if (hunk.anchor !== undefined) { + const at = findSequence(lines, [hunk.anchor], cursor) + if (at >= 0) { + cursor = at + 1 + anchorFound = true + } + // A missed anchor is not fatal: it only narrows the search, and the + // context match below still validates the hunk. + } + const old = hunk.parts.filter((p) => p.kind !== 'add').map((p) => p.line) + if (old.length === 0) { + // Pure insertion. After a found anchor it goes right there; otherwise + // it appends at EOF, before the final empty element that represents + // the file's trailing newline. + const added = hunk.parts.map((p) => p.line) + let at = lines.length + if (anchorFound) at = cursor + else if (lines.length > 0 && lines[lines.length - 1] === '') at = lines.length - 1 + lines.splice(at, 0, ...added) + cursor = at + added.length + continue + } + const at = findSequence(lines, old, cursor) + if (at < 0) { + throw new Error( + `apply_patch: context not found in ${path} near: ${old[0]}\n` + + `The file in the sandbox may differ from what you expect — read it and retry, or use the edit tool.`, + ) + } + // Build the replacement, keeping the file's own context lines: on a + // whitespace-fuzzy match the patch's copy of a context line may differ + // from the file, and re-emitting the patch's copy would churn it. + const replacement: string[] = [] + let matched = at + for (const part of hunk.parts) { + if (part.kind === 'ctx') replacement.push(lines[matched++]) + else if (part.kind === 'del') matched++ + else replacement.push(part.line) + } + lines.splice(at, old.length, ...replacement) + cursor = at + replacement.length + } + return lines.join('\n') +} + +export const applyPatchTool = ( + sessionManager: TensorlakeSessionManager, + projectId: string, + worktree: string, + pluginCtx: PluginInput, +) => ({ + description: + 'Applies a patch to files in the Tensorlake sandbox. The patch uses the standard envelope: ' + + "'*** Begin Patch', then one or more '*** Add File: path' / '*** Update File: path' / " + + "'*** Delete File: path' sections, then '*** End Patch'. Update sections contain hunks of " + + "' ' context, '-' removed, and '+' added lines, optionally preceded by an '@@ anchor' line. " + + 'Relative paths resolve against the project directory in the sandbox.', + args: { + patchText: z.string().describe('The full patch text describing add, update, and delete operations'), + }, + async execute(args: { patchText: string }, ctx: ToolContext) { + const ops = parsePatch(args.patchText) + const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) + const client = sessionManager.getClient() + const projectDir = sessionManager.projectDir(worktree) + const resolve = (p: string) => (p.startsWith('/') ? posix.normalize(p) : posix.join(projectDir, p)) + + // Plan every write before performing any, so a bad hunk in the third + // file cannot leave the first two half-applied. + type Write = { path: string; data: Buffer; note: string; removeAfter?: string } + type Remove = { path: string; note: string } + const writes: Write[] = [] + const removes: Remove[] = [] + for (const op of ops) { + const path = resolve(op.path) + if (op.type === 'add') { + writes.push({ path, data: Buffer.from(op.lines.join('\n') + '\n'), note: `A ${op.path}` }) + } else if (op.type === 'delete') { + removes.push({ path, note: `D ${op.path}` }) + } else { + const buffer = await client.readFile(sandboxId, path) + const updated = applyHunks(new TextDecoder().decode(buffer), op.hunks, op.path) + const dest = op.movePath ? resolve(op.movePath) : path + writes.push({ + path: dest, + data: Buffer.from(updated), + note: op.movePath ? `M ${op.path} -> ${op.movePath}` : `M ${op.path}`, + removeAfter: dest !== path ? path : undefined, + }) + } + } + + const results: string[] = [] + for (const w of writes) { + const dir = posix.dirname(w.path) + if (dir && dir !== '/') { + await client.executeCommand(sandboxId, `mkdir -p ${shellQuote(dir)}`, '/').catch(() => {}) + } + await client.writeFile(sandboxId, w.path, w.data) + if (w.removeAfter) { + await client.executeCommand(sandboxId, `rm -f -- ${shellQuote(w.removeAfter)}`, '/').catch(() => {}) + } + results.push(w.note) + } + for (const r of removes) { + const rm = await client.executeCommand(sandboxId, `rm -- ${shellQuote(r.path)}`, '/') + if (rm.exitCode !== 0) { + results.push(`FAILED ${r.note}: ${rm.stderr || rm.stdout}`) + continue + } + results.push(r.note) + } + return `Applied patch in the Tensorlake sandbox:\n${results.join('\n')}` + }, +}) diff --git a/.opencode/plugin/tensorlake/tools/bash.ts b/.opencode/plugin/tensorlake/tools/bash.ts index a789650..6ed1d57 100644 --- a/.opencode/plugin/tensorlake/tools/bash.ts +++ b/.opencode/plugin/tensorlake/tools/bash.ts @@ -1,15 +1,35 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' + +// Lines already returned per background process (keyed by sandboxId:pid), +// so bash_output only shows output produced since the previous call. +const outputOffsets = new Map() + +// With no stored offset (e.g. after a plugin restart) the whole buffer would +// count as "new"; cap the replay so a long-running server's log can't flood. +const MAX_REPLAY_LINES = 200 + +function offsetKey(sandboxId: string, pid: number): string { + return `${sandboxId}:${pid}` +} + +function describeStatus(pid: number, status: string, exitCode?: number, signal?: number): string { + if (status === 'running') return `Background process ${pid} is running.` + const detail = + exitCode !== undefined ? ` with exit code ${exitCode}` : signal !== undefined ? ` by signal ${signal}` : '' + return `Background process ${pid} has ${status}${detail}.` +} export const bashTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Executes shell commands in a TensorLake sandbox', + description: + 'Executes shell commands in a Tensorlake sandbox. Set background=true for long-running commands (servers, watchers); it returns a pid to use with bash_output and bash_kill.', args: { command: z.string(), background: z.boolean().optional(), @@ -17,11 +37,12 @@ export const bashTool = ( async execute(args: { command: string; background?: boolean }, ctx: ToolContext) { const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) const client = sessionManager.getClient() - const workDir = sessionManager.workDir + const workDir = sessionManager.projectDir(worktree) if (args.background) { - client.executeCommand(sandboxId, args.command, workDir, 300_000).catch(() => {}) - return `Command started in background: ${args.command}` + const pid = await client.startBackgroundProcess(sandboxId, args.command, workDir) + outputOffsets.set(offsetKey(sandboxId, pid), 0) + return `Started background process with pid ${pid}. Use bash_output with pid=${pid} to read its output, bash_kill to stop it.` } const result = await client.executeCommand(sandboxId, args.command, workDir) @@ -29,3 +50,61 @@ export const bashTool = ( return `Exit code: ${result.exitCode}\n${output}` }, }) + +export const bashOutputTool = ( + sessionManager: TensorlakeSessionManager, + projectId: string, + worktree: string, + pluginCtx: PluginInput, +) => ({ + description: + 'Returns new output and status of a background process started with bash background=true. Only lines produced since the previous bash_output call for that pid are returned.', + args: { + pid: z.number().describe('Pid returned by bash when background=true'), + }, + async execute(args: { pid: number }, ctx: ToolContext) { + const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) + const client = sessionManager.getClient() + + const key = offsetKey(sandboxId, args.pid) + let status + try { + status = await client.getProcessStatus(sandboxId, args.pid) + } catch (err: any) { + outputOffsets.delete(key) + return `No background process with pid ${args.pid} found in the sandbox (${err?.message ?? err}).` + } + const lines = await client.getProcessOutput(sandboxId, args.pid) + const offset = outputOffsets.get(key) + let fresh = lines.slice(offset ?? 0) + let truncationNote = '' + if (offset === undefined && fresh.length > MAX_REPLAY_LINES) { + truncationNote = `(showing last ${MAX_REPLAY_LINES} of ${fresh.length} buffered lines)\n` + fresh = fresh.slice(-MAX_REPLAY_LINES) + } + outputOffsets.set(key, lines.length) + + const header = describeStatus(status.pid, status.status, status.exitCode, status.signal) + const body = fresh.length > 0 ? fresh.join('\n') : '(no new output)' + return `${header}\n${truncationNote}${body}` + }, +}) + +export const bashKillTool = ( + sessionManager: TensorlakeSessionManager, + projectId: string, + worktree: string, + pluginCtx: PluginInput, +) => ({ + description: 'Kills a background process started with bash background=true.', + args: { + pid: z.number().describe('Pid returned by bash when background=true'), + }, + async execute(args: { pid: number }, ctx: ToolContext) { + const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) + const client = sessionManager.getClient() + await client.killProcess(sandboxId, args.pid) + outputOffsets.delete(offsetKey(sandboxId, args.pid)) + return `Background process ${args.pid} killed.` + }, +}) diff --git a/.opencode/plugin/tensorlake/tools/edit.ts b/.opencode/plugin/tensorlake/tools/edit.ts index 27b5b6e..2516562 100644 --- a/.opencode/plugin/tensorlake/tools/edit.ts +++ b/.opencode/plugin/tensorlake/tools/edit.ts @@ -1,27 +1,79 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' + +export type EditSpec = { oldString: string; newString: string; replaceAll?: boolean } + +function countOccurrences(haystack: string, needle: string): number { + let count = 0 + let from = 0 + for (;;) { + const at = haystack.indexOf(needle, from) + if (at === -1) return count + count++ + // Advance past the match so overlapping candidates are not double counted, + // which matches how the replacement below consumes the string. + from = at + needle.length + } +} + +/** + * Applies one edit to `content` and returns the new content. + * + * Every failure mode is an error rather than a silent no-op: a plain + * String.replace() would report success after changing nothing (missing + * oldString), quietly edit only the first of several matches, or insert text + * at offset 0 for an empty oldString. It would also expand `$&` / `$1` / + * '$`' in the replacement, corrupting any newString that contains them. + */ +export function applyEdit(content: string, edit: EditSpec, label: string): string { + if (edit.oldString === '') { + throw new Error(`${label}: oldString must not be empty; use the write tool to create or overwrite a file`) + } + if (edit.oldString === edit.newString) { + throw new Error(`${label}: oldString and newString are identical, so the edit would do nothing`) + } + const matches = countOccurrences(content, edit.oldString) + if (matches === 0) { + throw new Error(`${label}: oldString was not found. The text must match the file exactly, including whitespace.`) + } + if (matches > 1 && !edit.replaceAll) { + throw new Error( + `${label}: oldString matches ${matches} times. Add more surrounding context to make it unique, or set replaceAll to true.`, + ) + } + // split/join replaces literally; String.replace would expand $-patterns. + if (edit.replaceAll) return content.split(edit.oldString).join(edit.newString) + const at = content.indexOf(edit.oldString) + return content.slice(0, at) + edit.newString + content.slice(at + edit.oldString.length) +} export const editTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Replaces a string in a file in the TensorLake sandbox', + description: + 'Replaces a string in a file in the Tensorlake sandbox. oldString must match exactly once unless replaceAll is set.', args: { filePath: z.string(), oldString: z.string(), newString: z.string(), + replaceAll: z.boolean().optional().describe('Replace every occurrence instead of requiring exactly one match'), }, - async execute(args: { filePath: string; oldString: string; newString: string }, ctx: ToolContext) { + async execute( + args: { filePath: string; oldString: string; newString: string; replaceAll?: boolean }, + ctx: ToolContext, + ) { const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) const client = sessionManager.getClient() const buffer = await client.readFile(sandboxId, args.filePath) const content = new TextDecoder().decode(buffer) - const newContent = content.replace(args.oldString, args.newString) + const newContent = applyEdit(content, args, `edit ${args.filePath}`) await client.writeFile(sandboxId, args.filePath, Buffer.from(newContent)) - return `Edited ${args.filePath}` + const replaced = args.replaceAll ? countOccurrences(content, args.oldString) : 1 + return `Edited ${args.filePath} (${replaced} replacement${replaced === 1 ? '' : 's'})` }, }) diff --git a/.opencode/plugin/tensorlake/tools/glob.ts b/.opencode/plugin/tensorlake/tools/glob.ts index 97c2fa6..f0d227d 100644 --- a/.opencode/plugin/tensorlake/tools/glob.ts +++ b/.opencode/plugin/tensorlake/tools/glob.ts @@ -1,25 +1,74 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' +import { globToRegExp, literalPrefix } from '../core/glob-match.js' +import { shellQuote } from '../core/shell.js' + +const MAX_RESULTS = 100 export const globTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Finds files matching a glob pattern in the TensorLake sandbox', + description: + 'Finds files matching a glob pattern in the Tensorlake sandbox. The pattern is matched against the ' + + 'path relative to `path` (the project directory by default). `*` and `?` stop at a directory ' + + "separator, so use `**` to descend: `**/*.ts`, not `*.ts`. Results are newest first, at most " + + `${MAX_RESULTS}.`, args: { pattern: z.string(), path: z.string().optional(), }, async execute(args: { pattern: string; path?: string }, ctx: ToolContext) { const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) - const searchPath = args.path ?? sessionManager.workDir - const result = await sessionManager - .getClient() - .executeCommand(sandboxId, `find ${searchPath} -name "${args.pattern}" 2>/dev/null`, '/') - return result.stdout.trim() || '(no matches)' + const base = (args.path ?? sessionManager.projectDir(worktree)).replace(/\/+$/, '') + const prefix = literalPrefix(args.pattern) + const root = prefix ? `${base}/${prefix}` : base + + const entries = await listFiles(sessionManager, sandboxId, root) + const regex = globToRegExp(args.pattern) + const matched = entries.filter((entry) => { + const relative = entry.path.startsWith(`${base}/`) ? entry.path.slice(base.length + 1) : entry.path + return regex.test(relative) || regex.test(entry.path) + }) + if (matched.length === 0) return '(no matches)' + + matched.sort((a, b) => b.mtime - a.mtime) + const shown = matched.slice(0, MAX_RESULTS) + const output = shown.map((entry) => entry.path).join('\n') + return matched.length > shown.length + ? `${output}\n\n(showing ${shown.length} of ${matched.length} matches; narrow the pattern or set path)` + : output }, }) + +/** + * Lists every file under `root`, newest first when the sandbox has GNU find. + * Busybox find has no -printf and writes nothing, so a second pass collects the + * paths without timestamps rather than leaving the tool with no results. + */ +async function listFiles( + sessionManager: TensorlakeSessionManager, + sandboxId: string, + root: string, +): Promise> { + const client = sessionManager.getClient() + const find = `find ${shellQuote(root)} -type f -not -path '*/.git/*'` + const timed = await client.executeCommand(sandboxId, `${find} -printf '%T@ %p\\n' 2>/dev/null`, '/') + const timedLines = splitLines(timed.stdout) + if (timedLines.length > 0) { + return timedLines.map((line) => { + const match = /^(\d+(?:\.\d+)?) (.*)$/.exec(line) + return match ? { path: match[2], mtime: Number(match[1]) } : { path: line, mtime: 0 } + }) + } + const plain = await client.executeCommand(sandboxId, `${find} 2>/dev/null`, '/') + return splitLines(plain.stdout).map((path) => ({ path, mtime: 0 })) +} + +function splitLines(stdout: string): string[] { + return stdout.split('\n').filter((line) => line !== '') +} diff --git a/.opencode/plugin/tensorlake/tools/grep.ts b/.opencode/plugin/tensorlake/tools/grep.ts index 098f544..c47b9d8 100644 --- a/.opencode/plugin/tensorlake/tools/grep.ts +++ b/.opencode/plugin/tensorlake/tools/grep.ts @@ -1,26 +1,51 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' +import { shellQuote } from '../core/shell.js' + +const DEFAULT_LIMIT = 100 +const MAX_LINE_LENGTH = 250 export const grepTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Searches for a text pattern in files in the TensorLake sandbox', + description: + 'Searches for a text pattern in files in the Tensorlake sandbox. `include` narrows the search to ' + + 'files matching a name pattern (for example `*.ts`). At most `limit` matching lines are returned ' + + `(default ${DEFAULT_LIMIT}); long lines are shortened.`, args: { pattern: z.string(), path: z.string().optional(), - filePattern: z.string().optional(), + include: z.string().optional(), + limit: z.number().int().min(1).optional(), }, - async execute(args: { pattern: string; path?: string; filePattern?: string }, ctx: ToolContext) { + async execute( + args: { pattern: string; path?: string; include?: string; limit?: number }, + ctx: ToolContext, + ) { const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) - const searchPath = args.path ?? sessionManager.workDir - const include = args.filePattern ? `--include="${args.filePattern}"` : '' - const cmd = `grep -rn ${include} "${args.pattern}" ${searchPath} 2>/dev/null` + const searchPath = args.path ?? sessionManager.projectDir(worktree) + const limit = args.limit ?? DEFAULT_LIMIT + const include = args.include ? `--include=${shellQuote(args.include)}` : '' + // One line over the limit tells us whether anything was cut off. `head` + // also caps what a runaway search can pull back into the model's context. + const cmd = + `grep -rn --binary-files=without-match --exclude-dir=.git ${include} ` + + `-e ${shellQuote(args.pattern)} -- ${shellQuote(searchPath)} 2>/dev/null | head -n ${limit + 1}` const result = await sessionManager.getClient().executeCommand(sandboxId, cmd, '/') - return result.stdout.trim() || '(no matches)' + + const lines = result.stdout.split('\n').filter((line) => line !== '') + if (lines.length === 0) return '(no matches)' + const shown = lines + .slice(0, limit) + .map((line) => (line.length > MAX_LINE_LENGTH ? `${line.slice(0, MAX_LINE_LENGTH)}… (line truncated)` : line)) + const output = shown.join('\n') + return lines.length > limit + ? `${output}\n\n(stopped at ${limit} matches; narrow the pattern, set include, or raise limit)` + : output }, }) diff --git a/.opencode/plugin/tensorlake/tools/ls.ts b/.opencode/plugin/tensorlake/tools/ls.ts index 38b49e7..986307d 100644 --- a/.opencode/plugin/tensorlake/tools/ls.ts +++ b/.opencode/plugin/tensorlake/tools/ls.ts @@ -1,21 +1,21 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' export const lsTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Lists files in a directory in the TensorLake sandbox', + description: 'Lists files in a directory in the Tensorlake sandbox', args: { dirPath: z.string().optional(), }, async execute(args: { dirPath?: string }, ctx: ToolContext) { const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) - const path = args.dirPath ?? sessionManager.workDir + const path = args.dirPath ?? sessionManager.projectDir(worktree) const entries = await sessionManager.getClient().listDirectory(sandboxId, path) return entries.map((e) => (e.is_dir ? `${e.name}/` : e.name)).join('\n') }, diff --git a/.opencode/plugin/tensorlake/tools/multiedit.ts b/.opencode/plugin/tensorlake/tools/multiedit.ts new file mode 100644 index 0000000..b0b7ba0 --- /dev/null +++ b/.opencode/plugin/tensorlake/tools/multiedit.ts @@ -0,0 +1,48 @@ +import { z } from 'zod' +import type { PluginInput } from '@opencode-ai/plugin' +import type { ToolContext } from '@opencode-ai/plugin/tool' +import type { TensorlakeSessionManager } from '../core/session-manager.js' +import { applyEdit, type EditSpec } from './edit.js' + +/** + * Applies several edits to one file atomically. Each edit runs against the + * result of the previous one, and the file is written only after all of them + * succeed, so a failure in edit 3 leaves the file untouched instead of half + * edited. + */ +export const multiEditTool = ( + sessionManager: TensorlakeSessionManager, + projectId: string, + worktree: string, + pluginCtx: PluginInput, +) => ({ + description: + 'Applies several string replacements to one file in the Tensorlake sandbox, in order and atomically. ' + + 'If any edit fails, the file is left unchanged. Each oldString must match exactly once unless replaceAll is set.', + args: { + filePath: z.string(), + edits: z + .array( + z.object({ + oldString: z.string(), + newString: z.string(), + replaceAll: z.boolean().optional().describe('Replace every occurrence instead of requiring exactly one match'), + }), + ) + .min(1) + .describe('Edits applied in order, each against the result of the previous one'), + }, + async execute(args: { filePath: string; edits: EditSpec[] }, ctx: ToolContext) { + const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) + const client = sessionManager.getClient() + const buffer = await client.readFile(sandboxId, args.filePath) + const original = new TextDecoder().decode(buffer) + let content = original + args.edits.forEach((edit, i) => { + content = applyEdit(content, edit, `multiedit ${args.filePath}: edit ${i + 1}/${args.edits.length}`) + }) + if (content === original) return `No change to ${args.filePath}` + await client.writeFile(sandboxId, args.filePath, Buffer.from(content)) + return `Edited ${args.filePath} (${args.edits.length} edit${args.edits.length === 1 ? '' : 's'})` + }, +}) diff --git a/.opencode/plugin/tensorlake/tools/read.ts b/.opencode/plugin/tensorlake/tools/read.ts index b01a57c..aeacb3e 100644 --- a/.opencode/plugin/tensorlake/tools/read.ts +++ b/.opencode/plugin/tensorlake/tools/read.ts @@ -1,21 +1,55 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' + +const DEFAULT_LIMIT = 2000 +const MAX_LINE_LENGTH = 2000 export const readTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Reads a file from the TensorLake sandbox', + description: + 'Reads a file from the Tensorlake sandbox. Output is one numbered line per source line. ' + + '`offset` is the 0-based line to start at and `limit` is how many lines to return ' + + `(default ${DEFAULT_LIMIT}). Use them to page through a file that is too long to read at once.`, args: { filePath: z.string(), + offset: z.number().int().min(0).optional(), + limit: z.number().int().min(1).optional(), }, - async execute(args: { filePath: string }, ctx: ToolContext) { + async execute(args: { filePath: string; offset?: number; limit?: number }, ctx: ToolContext) { const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) const buffer = await sessionManager.getClient().readFile(sandboxId, args.filePath) - return new TextDecoder().decode(buffer) + return formatFile(new TextDecoder().decode(buffer), args.offset ?? 0, args.limit ?? DEFAULT_LIMIT) }, }) + +export function formatFile(text: string, offset: number, limit: number): string { + const lines = text.split('\n') + // A trailing newline leaves an empty last element that is not a real line. + if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop() + if (lines.length === 0) return '(empty file)' + + const selected = lines.slice(offset, offset + limit) + if (selected.length === 0) { + return `(offset ${offset} is past the end of the file, which has ${lines.length} lines)` + } + + const body = selected + .map((line, index) => { + const number = String(offset + index + 1).padStart(5, '0') + const shown = line.length > MAX_LINE_LENGTH ? `${line.slice(0, MAX_LINE_LENGTH)}… (line truncated)` : line + return `${number}| ${shown}` + }) + .join('\n') + + const end = offset + selected.length + const output = `\n${body}\n` + return end < lines.length + ? `${output}\n\n(showing lines ${offset + 1}-${end} of ${lines.length}; read on with offset=${end})` + : output +} diff --git a/.opencode/plugin/tensorlake/tools/sync.ts b/.opencode/plugin/tensorlake/tools/sync.ts new file mode 100644 index 0000000..73b2373 --- /dev/null +++ b/.opencode/plugin/tensorlake/tools/sync.ts @@ -0,0 +1,17 @@ +import type { PluginInput } from '@opencode-ai/plugin' +import type { ToolContext } from '@opencode-ai/plugin/tool' +import type { TensorlakeSessionManager } from '../core/session-manager.js' + +export const syncTool = ( + sessionManager: TensorlakeSessionManager, + projectId: string, + worktree: string, + pluginCtx: PluginInput, +) => ({ + description: + "Re-syncs the user's local project into the sandbox now. Use when the user says they edited files, committed, or switched branches locally and the sandbox should pick that up. In git mode it pushes the local state (including uncommitted changes) and pulls agent commits back; in volume mode it re-uploads local files, replacing the volume's copies of them.", + args: {}, + async execute(_args: Record, ctx: ToolContext) { + return sessionManager.syncNow(ctx.sessionID, projectId, worktree, pluginCtx) + }, +}) diff --git a/.opencode/plugin/tensorlake/tools/write.ts b/.opencode/plugin/tensorlake/tools/write.ts index 9342874..1629d4e 100644 --- a/.opencode/plugin/tensorlake/tools/write.ts +++ b/.opencode/plugin/tensorlake/tools/write.ts @@ -1,15 +1,16 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' +import { shellQuote } from '../core/shell.js' export const writeTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Writes content to a file in the TensorLake sandbox', + description: 'Writes content to a file in the Tensorlake sandbox', args: { filePath: z.string(), content: z.string(), @@ -19,7 +20,7 @@ export const writeTool = ( const buf = Buffer.from(args.content) const dir = args.filePath.split('/').slice(0, -1).join('/') if (dir) { - await sessionManager.getClient().executeCommand(sandboxId, `mkdir -p ${dir}`, '/').catch(() => {}) + await sessionManager.getClient().executeCommand(sandboxId, `mkdir -p ${shellQuote(dir)}`, '/').catch(() => {}) } await sessionManager.getClient().writeFile(sandboxId, args.filePath, buf) return `Written ${buf.length} bytes to ${args.filePath}` diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 0e782bd..9055565 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -45,7 +45,10 @@ opencode-tensorlake-plugin/ │ ├── client.ts # Tensorlake SDK client wrapper │ ├── credentials.ts # API key resolution (auth.json + env) │ ├── logger.ts # file-based logger with rotation + │ ├── project-context.ts # resolves the local project path and its sync identity + │ ├── project-sync.ts # syncs the local project into the sandbox (git/mount/volume) │ ├── session-manager.ts # sandbox lifecycle management + │ ├── shell.ts # local shell command helpers │ ├── toast.ts # TUI toast queue │ └── types.ts # shared type definitions ├── tools/ diff --git a/README.md b/README.md index c42d18d..c659e19 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,125 @@ An OpenCode plugin that runs all AI sessions inside isolated [Tensorlake](https: ## How it works -The plugin intercepts OpenCode's standard tools (`bash`, `read`, `write`, `edit`, `ls`, `glob`, `grep`) and routes them to a Tensorlake sandbox at `/tmp/workspace`. +The plugin intercepts OpenCode's standard tools (`bash`, `read`, `write`, `edit`, `apply_patch`, `ls`, `glob`, `grep`) and routes them to a Tensorlake sandbox at `/tmp/workspace`. - **Lazy creation** — no sandbox starts when you launch OpenCode. The sandbox is created on the model's first tool call in a session. To start one, ask the model to run a command. - **Lifecycle** — the sandbox is deleted when you delete the session. Sandbox state persists to disk, so sessions reconnect across OpenCode restarts. A suspended sandbox resumes automatically before use. +- **Project sync** — the project you opened in OpenCode is synced into the sandbox at `/tmp/workspace/` on first use. See [Project sync](#project-sync). +- **Background processes** — `bash` with `background=true` starts a long-running process in the sandbox (dev server, watcher) and returns its pid. Two extra tools, `bash_output` and `bash_kill`, let the model read new output and stop the process. - **Visibility** — sandbox events (created, connected, resumed, deleted) appear as TUI toasts, and all plugin activity is logged to `~/.local/share/opencode/log/tensorlake.log`. +> **LSP stays local.** OpenCode's experimental LSP support runs language servers in the OpenCode process, against your local worktree — a plugin cannot redirect it into the sandbox. Diagnostics therefore describe your local files, which drift from the sandbox as the agent edits. Keep LSP off (`"lsp": {}` or the default) when you want everything to reflect the sandbox, and get diagnostics by running the project's own type-checker or linter through `bash`. + +## Project sync + +The first time a sandbox is used (per OpenCode process), the plugin syncs your local project into it so the sandbox is not empty. The mode is chosen automatically: + +| Local project | Sync mode | How it works | +|---|---|---| +| Git repository (has `.git`) | `git` | Your repo's **real commit history** is pushed to the **sync repo** — a [Tensorlake git repository](https://docs.tensorlake.ai/git/introduction) the plugin creates for your project, named `opencode-` — **under your current branch name**, and the sync repo is cloned inside the sandbox on that same branch — so `git log`, `git blame`, and `git diff` in the sandbox show your actual commits, authors, and dates. Uncommitted local changes (modified + untracked files) are replayed onto the sandbox working tree, uncommitted, so the sandbox matches your laptop exactly. Git credentials and a fallback identity are configured in the sandbox so the model can commit and `git push` to persist changes back to the sync repo; **pushed commits are pulled back into your local branch automatically** (see below). A repo with no commits yet is synced as a single snapshot commit instead. | +| Folder that is already a `tl fs` mount | `mount` | The sandbox mounts **the same filesystem** your folder serves, so nothing is copied in either direction. The mount daemon's autosave carries writes both ways: what the agent writes appears in your local folder in about a second, and what you edit locally is what the agent sees. Detected by asking `tl fs status --json` about the folder — the plugin never creates or converts a mount itself, because `tl fs mount` requires an empty mountpoint. Set one up with `tl fs create && tl fs mount `, then open that directory in OpenCode. Same-path writes are last-writer-wins, so avoid editing the same file as the agent at the same moment. | +| Plain folder | `volume` | The folder is uploaded to a Tensorlake cloud volume (`opencode-folder-` — OpenCode reports no project for a non-repository folder, so the plugin identifies it by its path) and the volume is mounted into the sandbox. Writes inside the mount are persisted to durable storage automatically and survive sandbox termination. Common build artifacts (`node_modules`, `.venv`, `dist`, `target`, …) and files over 100 MB are skipped. **Files the agent changes are downloaded back into your local folder after each agent turn** (see [Volume-mode sync-back](#volume-mode-sync-back)). | + +The project lands at `/tmp/workspace/`, which is also the default working directory for `bash`, `ls`, `glob`, and `grep`. + +Override the automatic choice with `TENSORLAKE_SYNC_MODE`: + +```bash +export TENSORLAKE_SYNC_MODE=git # always use git push/clone +export TENSORLAKE_SYNC_MODE=mount # require the folder to be a tl fs mount and attach it +export TENSORLAKE_SYNC_MODE=volume # always upload to a cloud volume +export TENSORLAKE_SYNC_MODE=off # disable project sync (pre-0.3.0 behavior) +``` + +Sync failures are surfaced as a toast and logged, but never block the sandbox — you just get an empty workspace. + +> A sandbox clone that has its own commits or edits is never reset — re-sync only fast-forwards, and uncommitted local changes are only replayed onto a clean sandbox tree. + +### Post-sync setup command + +Sync carries no dependency directories (`node_modules`, `.venv`, …), so a fresh sandbox has none. Instead of letting the agent discover this and burn turns on `npm install`, configure a setup command that runs automatically after the first sync — like a devcontainer `postCreateCommand`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + ["tensorlake-opencode", { "setup": "npm ci" }] + ] +} +``` + +Put it in the project's own `opencode.json` so the whole team gets it, or in the global `~/.config/opencode/opencode.json`. The `TENSORLAKE_SETUP_COMMAND` environment variable overrides both. + +The command runs in the sandbox project directory, before the first tool call proceeds, with a 15-minute timeout. It runs **once per sandbox lifetime**: sandboxes are stateful and survive OpenCode restarts, so a marker file inside the sandbox — not plugin memory — records that setup ran. Changing the command runs the new one once. A failed command shows an error toast with the last output lines and is not retried; fix it, or ask the agent to run it. + +### Re-sync: local changes made mid-session + +You do not need to restart OpenCode to get local changes into the sandbox: + +- **Automatic (git mode).** On tool calls the plugin checks (at most every 15 s) whether your local state changed since the last sync — new commits, edits, or a branch switch. If it did, the sync re-runs in the background: your state is pushed to the sync repo, the sandbox clone fast-forwards, and uncommitted changes are replayed onto a clean sandbox tree. +- **On demand (`sync` tool).** The model has a `sync` tool that forces a full sync right now, both ways in git and volume mode. Just say "sync my local changes" (or the model calls it itself when you mention edits it cannot see). In volume mode it first downloads files the agent changed, then re-uploads your local files, replacing the volume's copies of them; in mount mode it is a no-op (the filesystem is already shared). + +### Where agent commits go (git mode) + +**Agent commits do not go to GitHub directly.** Inside the sandbox, `origin` points to the sync repo — not to your GitHub/GitLab remote. The plugin never copies your GitHub credentials or remotes into the sandbox. When the agent commits and runs `git push`, the work lands on the sync repo, on the same branch you have checked out locally. + +**The plugin pulls agent commits back to your machine automatically.** After each agent turn, it fetches the sync repo into `refs/remotes/tensorlake/*` and fast-forwards your local branch when that is safe (your worktree is clean and the histories have not diverged). You see a toast when commits land. So the everyday flow is: + +```bash +# you are on feature-x; the agent works in the sandbox on feature-x +# agent commits and pushes -> your local feature-x advances automatically + +# review, then send it to GitHub from your machine as usual: +git push origin feature-x +gh pr create +``` + +When a fast-forward is not safe, nothing in your checkout is touched. The commits wait on the `tensorlake/` tracking ref, a toast tells you why (uncommitted local changes, or diverged histories), and you merge on your own terms: + +```bash +git stash # if the blocker was uncommitted changes +git merge tensorlake/feature-x +git stash pop +``` + +**Uncommitted sandbox work comes back too — but is never applied.** After each agent turn, if the sandbox working tree has uncommitted changes (edits the agent did not commit), the plugin captures them as a WIP commit on the sync repo and stages that commit on a local scratch ref, `tensorlake-wip/`. Your working tree and branches are never touched. A toast tells you when new WIP is staged: + +```bash +git diff tensorlake-wip/feature-x~ tensorlake-wip/feature-x # look at it +git cherry-pick -n tensorlake-wip/feature-x # take it, uncommitted +``` + +When the agent commits (or discards) that work, the WIP ref is cleared automatically on the next turn. The capture uses a temporary index, so it never disturbs the agent's index or working tree either. + +The sync repo is a normal Tensorlake git repository, so the standard `tl git` commands (`tl git list`, `tl git token`, …) work with it if you ever want to inspect it directly. + +To push agent work to GitHub, let the sync-back land it (or merge the tracking ref), then push from your machine. Alternatively, add your GitHub remote and credentials inside the sandbox yourself — the plugin does not do this for you. + +### Volume-mode sync-back + +For a plain (non-git) folder, agent output no longer stays stranded on the cloud volume. After each agent turn, the plugin asks the volume for its current version — one cheap call — and if anything changed, downloads the changed files into your local folder. A toast reports how many files landed. Change detection is server-side: every file and directory on a volume has a stable content id, so unchanged subtrees are skipped without listing them. + +Your own edits are protected: + +- A local file is overwritten **only** when it is unchanged since the last sync (its content still matches what was uploaded or downloaded last time). +- A file changed both locally and in the sandbox is left untouched locally and reported as a conflict. Run a sync (say "sync my local changes") to make your local version win — the volume keeps every prior version if you need the agent's copy. +- A file the agent deleted on the volume is **never** deleted locally. + +Skipped-directory content (`node_modules`, `dist`, …) and files over 100 MB are not downloaded. + +### Why a sync repo instead of cloning your remote in the sandbox? + +The agent could simply `git clone` your GitHub repo inside the sandbox. The sync repo exists because that has three problems the plugin is designed to avoid: + +- **No GitHub credentials in the sandbox.** The sandbox runs agent-generated code. A GitHub token inside it can leak. The sandbox only receives a Tensorlake credential scoped to the one sync repo — your GitHub account is unreachable by construction. +- **The sandbox matches your laptop, not your remote.** A clone of GitHub misses unpushed commits, uncommitted edits, and untracked files. The sync includes all of them, and works for repos with no remote at all. +- **A review gate.** Agent pushes land on the sync repo and sync back to your local branch. Only you push to GitHub, after review — the agent never can. + +> **Branch naming.** The sync branch is whatever `git branch --show-current` reports on your machine when the sync runs — one name everywhere: local, sync repo, and sandbox. A detached HEAD (or a branch name that cannot be embedded safely) syncs as `main`. If you switch local branches, the next sandbox sync follows: a clean sandbox clone switches to the new branch; one with its own edits is left untouched. + +> If you rewrite local history (rebase, amend), the next sync force-updates the sync branch on the sync repo from your rewritten history. Before it does, every ref on the sync repo is copied to a local `refs/tensorlake-rescue/*` ref, so agent commits that existed only there stay recoverable — the plugin logs the refs it kept; read them with `git log ` and remove them with `git update-ref -d `. Other branches and wip captures on the sync repo are untouched. An existing sandbox clone can't fast-forward to rewritten history; delete the session's sandbox to get a fresh clone. + ## Requirements - An OpenCode installation ([opencode.ai](https://opencode.ai)) @@ -44,7 +157,7 @@ opencode auth login Select **Tensorlake** and paste a project API key (starts with `tl_apiKey_`). The key is stored in OpenCode's credential store next to your other provider credentials. If the key is wrong, the first tool call shows an error toast that tells you to log in again. -**CI / automation:** set `TENSORLAKE_API_KEY` instead. The environment variable wins over the stored key. Personal Access Tokens work only through this path and also require `TENSORLAKE_ORGANIZATION_ID` and `TENSORLAKE_PROJECT_ID`; prefer project API keys. +**CI / automation:** set `TENSORLAKE_API_KEY` instead. The environment variable wins over the stored key. Use a project API key — the key itself selects the organization and project. Personal Access Tokens are not supported. ## Configuration @@ -53,14 +166,14 @@ All settings are optional environment variables: | Variable | Default | Description | |---|---|---| | `TENSORLAKE_API_KEY` | — | Overrides the key stored by `opencode auth login`. For CI/automation. | -| `TENSORLAKE_ORGANIZATION_ID` | — | Required only for Personal Access Tokens. A project key carries its own scope. | -| `TENSORLAKE_PROJECT_ID` | — | Required only for Personal Access Tokens. | | `TENSORLAKE_IMAGE` | server default | Container image for new sandboxes. | | `TENSORLAKE_CPUS` | `2` | vCPUs per sandbox. | | `TENSORLAKE_MEMORY_MB` | `4096` | RAM in MB. | | `TENSORLAKE_DISK_MB` | `10240` | Ephemeral disk in MB. | | `TENSORLAKE_API_URL` | `https://api.tensorlake.ai` | Management API base URL. | | `TENSORLAKE_SANDBOX_PROXY_URL` | auto | Sandbox proxy URL override, for local development. | +| `TENSORLAKE_SYNC_MODE` | `auto` | Project sync mode: `auto`, `git`, `mount`, `volume`, or `off`. See [Project sync](#project-sync). | +| `TENSORLAKE_SETUP_COMMAND` | — | Command to run once after the first project sync (e.g. `npm ci`). Overrides the `setup` plugin option. See [Post-sync setup command](#post-sync-setup-command). | ## Troubleshooting diff --git a/package-lock.json b/package-lock.json index 01fe63d..d166997 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "tensorlake-opencode", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tensorlake-opencode", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { "@opencode-ai/plugin": "^1.18.21", - "tensorlake": "^0.5.112", + "tensorlake": "^0.5.114", "xdg-basedir": "^5.1.0", "zod": "^4.2.1" }, @@ -713,9 +713,9 @@ } }, "node_modules/tensorlake": { - "version": "0.5.112", - "resolved": "https://registry.npmjs.org/tensorlake/-/tensorlake-0.5.112.tgz", - "integrity": "sha512-Issapl4HIyah5t3BQeAj3Ym9D6G5K6IWoHevqaW3/Cq8Jdp0Tgnx/bPEq1ku4n8jlx6NQbYioCHfdZWZerh5YQ==", + "version": "0.5.114", + "resolved": "https://registry.npmjs.org/tensorlake/-/tensorlake-0.5.114.tgz", + "integrity": "sha512-SKa/A6vlYPc1Aep+0M2BPd7ha7leVzL8H+tWicsLS5m52WgPYBPOB5z/34pq0d9kRAlVfamqUfOSzFGrfbtnCw==", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.13.0", diff --git a/package.json b/package.json index 9a21215..3e3f899 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tensorlake-opencode", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "description": "OpenCode plugin that runs all sessions in Tensorlake sandboxes for isolated execution environments", "keywords": [ @@ -29,7 +29,7 @@ }, "dependencies": { "@opencode-ai/plugin": "^1.18.21", - "tensorlake": "^0.5.112", + "tensorlake": "^0.5.114", "xdg-basedir": "^5.1.0", "zod": "^4.2.1" },