Migrate configuration
Version 0.3.0 and later keep policy under the host that executes the hook. OpenCode and Pi can use different global and project policy without reading each other’s destination. Version 0.2.0 reads the shared legacy paths below.
Legacy and destination paths
Section titled “Legacy and destination paths”| Scope | Legacy source | OpenCode destination | Pi destination |
|---|---|---|---|
| Global | $XDG_CONFIG_HOME/auto-mode-gate/config.json, %APPDATA%\auto-mode-gate\config.json on Windows, or ~/.config/auto-mode-gate/config.json |
$OPENCODE_CONFIG_DIR/auto-mode-gate.json or ~/.config/opencode/auto-mode-gate.json |
$PI_CODING_AGENT_DIR/auto-mode-gate.json or ~/.pi/agent/auto-mode-gate.json |
| Project | <project>/.auto-mode-gate.json |
<project>/.opencode/auto-mode-gate.json |
<project>/.pi/auto-mode-gate.json |
Automatic migration
Section titled “Automatic migration”When a host-owned destination is absent, the adapter:
- reads the matching legacy source only if it is a regular non-symlink file;
- applies the same 64 KiB limit and strict parser used for normal configuration;
- writes the exact validated bytes to a unique temporary file beside the destination;
- flushes and closes the temporary file;
- publishes it without replacing a destination created by another process;
- leaves the legacy source unchanged.
An existing destination is authoritative. The adapter ignores legacy even when legacy has newer or different content. Invalid destinations fail closed instead of falling back. Automatic publication requires same-filesystem hard-link support. If the filesystem rejects it, the host fails closed and you must migrate manually.
Migrate manually
Section titled “Migrate manually”- Stop or reload the target host so it is not reading configuration during the copy.
- Confirm the destination does not exist. Do not overwrite it.
- Create the host-owned parent directory if needed.
- Copy the legacy file with a create-only operation that fails when the destination exists.
- Compare the source and destination hashes.
- Start the host and verify behavior.
- Repeat for the other host if you use both.
Windows PowerShell 5.1 or later
Section titled “Windows PowerShell 5.1 or later”Set both paths to the source and destination for one host. FileMode.CreateNew fails if another
process creates the destination first. A failed partial copy removes only the destination created by
this command.
$sourcePath = "C:\absolute\path\to\legacy-config.json"$destinationRoot = "C:\absolute\path\to\the-host-config-root"$destinationPath = Join-Path $destinationRoot "auto-mode-gate.json"$sourceItem = Get-Item -LiteralPath $sourcePath -Forceif ($sourceItem.PSIsContainer -or ($sourceItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { throw "source must be a regular non-symlink file"}$current = $sourceItem.Directorywhile ($null -ne $current) { if ($current.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { throw "source path must not contain a symlink" } $current = $current.Parent}function Assert-SafeExistingTree([string]$Leaf, [string]$Root) { $current = Get-Item -LiteralPath $Leaf -Force $rootItem = Get-Item -LiteralPath $Root -Force while ($true) { if ($current.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { throw "destination path must not contain a symlink" } if ($current.FullName -eq $rootItem.FullName) { return } $current = $current.Parent if ($null -eq $current) { throw "destination path escaped its host root" } }}Assert-SafeExistingTree (Split-Path -Parent $destinationPath) $destinationRoot$sourceStream = [System.IO.File]::Open( $sourcePath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)$destinationStream = $null$destinationCreated = $falsetry { $destinationStream = [System.IO.File]::Open( $destinationPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None ) $destinationCreated = $true $sourceStream.CopyTo($destinationStream) $destinationStream.Flush($true)} catch { if ($destinationCreated) { $destinationStream.Dispose() $destinationStream = $null Remove-Item -LiteralPath $destinationPath -Force } throw} finally { if ($null -ne $destinationStream) { $destinationStream.Dispose() } $sourceStream.Dispose()}Get-FileHash -Algorithm SHA256 -LiteralPath $sourcePath, $destinationPathPOSIX shell with Node 24
Section titled “POSIX shell with Node 24”Create the destination root and parent directory first. Set all three absolute paths. The script
rejects a symlinked source or destination tree, confines the destination to the selected host root,
and uses COPYFILE_EXCL so an existing destination wins.
source_path="/absolute/path/to/legacy-config.json"destination_root="/absolute/path/to/the-host-config-root"destination_path="$destination_root/auto-mode-gate.json"node --input-type=module - "$source_path" "$destination_path" "$destination_root" <<'NODE'import { constants, copyFileSync, lstatSync, realpathSync,} from 'node:fs';import { dirname, isAbsolute, relative, resolve,} from 'node:path';
const [source, destination, root] = process.argv.slice(2).map((value) => resolve(value));const sourceStat = lstatSync(source);if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) { throw new Error('source must be a regular non-symlink file');}for (let current = dirname(source); ; current = dirname(current)) { if (lstatSync(current).isSymbolicLink()) { throw new Error('source path must not contain a symlink'); } if (dirname(current) === current) break;}const realRoot = realpathSync(root);const parent = dirname(destination);const realParent = realpathSync(parent);const relativeParent = relative(realRoot, realParent);if (relativeParent.startsWith('..') || isAbsolute(relativeParent)) { throw new Error('destination path escaped its host root');}for (let current = parent; ; current = dirname(current)) { if (lstatSync(current).isSymbolicLink()) { throw new Error('destination path must not contain a symlink'); } if (current === root) break; if (dirname(current) === current) { throw new Error('destination path escaped its host root'); }}copyFileSync(source, destination, constants.COPYFILE_EXCL);NODECompare both files with the hashing tool available on your operating system before starting the host. Do not paste active configuration, paths, model names, credentials, or raw errors into public issues.
Verify and clean up
Section titled “Verify and clean up”- Start OpenCode and confirm it reacts only to its global and
.opencodeproject files. - Start Pi and confirm it reacts only to its global and
.piproject files. - Change a harmless test setting in one destination and confirm the other host remains unchanged.
- Keep a private backup before deleting legacy.
- Remove legacy only after every host you use has a valid destination and rollback is no longer required.
Roll back to 0.2.0
Section titled “Roll back to 0.2.0”Version 0.2.0 reads only the legacy shared paths. Leave or restore the legacy file before loading that version. Host-owned files are ignored by 0.2.0.
To retry migration in a newer build, stop the host, remove only that host’s destination, keep legacy in place, and start the host again. The adapter attempts a fresh exclusive copy.
Troubleshooting
Section titled “Troubleshooting”- Relative host root: use an absolute
OPENCODE_CONFIG_DIRorPI_CODING_AGENT_DIR. - Missing or non-directory host root: create a regular directory or unset the variable to use the fallback.
- Symlink or non-regular entry: replace it with a regular directory or file you control.
- Invalid destination: repair or remove it deliberately; the adapter will not downgrade to legacy.
- Unsupported publication or permissions: migrate manually without overwriting the destination.
- Temporary file after interruption: do not rename it into place. Remove it only after confirming no migration process is active.
- Changed configuration not loaded: reload or restart the host.