Skip to content

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.

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

When a host-owned destination is absent, the adapter:

  1. reads the matching legacy source only if it is a regular non-symlink file;
  2. applies the same 64 KiB limit and strict parser used for normal configuration;
  3. writes the exact validated bytes to a unique temporary file beside the destination;
  4. flushes and closes the temporary file;
  5. publishes it without replacing a destination created by another process;
  6. 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.

  1. Stop or reload the target host so it is not reading configuration during the copy.
  2. Confirm the destination does not exist. Do not overwrite it.
  3. Create the host-owned parent directory if needed.
  4. Copy the legacy file with a create-only operation that fails when the destination exists.
  5. Compare the source and destination hashes.
  6. Start the host and verify behavior.
  7. Repeat for the other host if you use both.

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.

Terminal window
$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 -Force
if ($sourceItem.PSIsContainer -or
($sourceItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
throw "source must be a regular non-symlink file"
}
$current = $sourceItem.Directory
while ($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 = $false
try {
$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, $destinationPath

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.

Terminal window
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);
NODE

Compare 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.

  • Start OpenCode and confirm it reacts only to its global and .opencode project files.
  • Start Pi and confirm it reacts only to its global and .pi project 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.

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.

  • Relative host root: use an absolute OPENCODE_CONFIG_DIR or PI_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.