Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ OpenCode V2: ~/.config/opencode/plugins/coding-agents-tmux/index.ts
OpenCode V1: ~/.config/opencode/plugins/coding-agents-tmux.ts
```

The V2 directory or V1 file is symlinked to the TPM-installed plugin. If `XDG_CONFIG_HOME` is set, replace `~/.config` with that directory. The installer removes the managed layout for the other generation when upgrading or rolling back.
The V2 directory or V1 file is symlinked to the TPM-installed plugin. If `XDG_CONFIG_HOME` is set, replace `~/.config` with that directory. The installer removes the managed layout for the other generation when upgrading or rolling back. It also removes an old `plugins/opencode-tmux.ts` symlink when its target is a verified managed plugin (including the original `plugin/opencode-tmux.ts` source in a previous checkout); unrelated links and user-managed files are preserved.

TPM runs the generation-aware installer automatically. To run it manually after changing OpenCode versions:

Expand Down Expand Up @@ -503,7 +503,9 @@ Pi runtime support is intentionally minimal and extensible:
- if preview is inconclusive, Pi falls back to a coarse `running` state when a `pi` process is still detected in the tmux pane
- Pi has no built-in permission or plan mode integration here yet, so those states are not modeled specially

After first install or update, restart Pi sessions in tmux so they load the bundled extension.
The installer removes the old `~/.pi/agent/extensions/opencode-tmux/index.ts` entry only when it is a symlink to this checkout's bundled Pi extension or the exact `plugin/pi-tmux.ts` path in a verified previous `opencode-tmux` or `coding-agents-tmux` checkout. It removes the old directory only if empty; custom files, unrelated links, and symlinked directories are preserved.

After first install or update, restart Pi sessions in tmux so they load the bundled extension and unload any previously loaded duplicate.

## Codex

Expand Down
13 changes: 12 additions & 1 deletion coding-agents-tmux.tmux
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ install_copilot_hooks() {
}

install_pi_extension() {
local extension_source pi_dir extension_dir extension_target existing_target installed_changed
local extension_source pi_dir extension_dir extension_target existing_target installed_changed legacy_dir legacy_target

extension_source="$CURRENT_DIR/plugin/pi-tmux.ts"

Expand All @@ -393,6 +393,17 @@ install_pi_extension() {

mkdir -p "$extension_dir"
ln -sfn "$extension_source" "$extension_target"

# Only migrate links to this checkout or a verified previous managed checkout.
# Never traverse a user-managed directory symlink or remove custom files.
legacy_dir="$pi_dir/extensions/opencode-tmux"
legacy_target="$legacy_dir/index.ts"
if [ ! -L "$legacy_dir" ] && [ -L "$legacy_target" ] && node "$CURRENT_DIR/scripts/is-managed-pi-link.mjs" "$legacy_target" "$extension_source"; then
rm "$legacy_target"
rmdir "$legacy_dir" 2>/dev/null || true
installed_changed='on'
fi

tmux set-option -gq '@coding-agents-tmux-pi-extension-path' "$extension_target"

if [ "$installed_changed" = 'on' ]; then
Expand Down
14 changes: 14 additions & 0 deletions scripts/is-managed-pi-link.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { lstatSync, readlinkSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { isCheckoutSymlink } from "../src/core/integration-ownership.ts";

const [path, source] = process.argv.slice(2);
try {
const owned =
lstatSync(path).isSymbolicLink() &&
(resolve(dirname(path), readlinkSync(path)) === resolve(source) ||
isCheckoutSymlink(path, ["plugin", "pi-tmux.ts"], ["coding-agents-tmux", "opencode-tmux"]));
process.exitCode = owned ? 0 : 1;
} catch {
process.exitCode = 1;
}
23 changes: 23 additions & 0 deletions src/core/integration-ownership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { lstatSync, readFileSync, readlinkSync } from "node:fs";
import { dirname, join, resolve } from "node:path";

/** Verify an exact integration path and checkout identity without requiring a live target. */
export function isCheckoutSymlink(
path: string,
expectedRelativeTarget: string[],
packageNames = ["coding-agents-tmux"],
): boolean {
try {
if (!lstatSync(path).isSymbolicLink()) return false;
const target = resolve(dirname(path), readlinkSync(path));
let checkoutRoot = target;
for (const _segment of expectedRelativeTarget) checkoutRoot = dirname(checkoutRoot);
if (target !== join(checkoutRoot, ...expectedRelativeTarget)) return false;
const packageJson = JSON.parse(readFileSync(join(checkoutRoot, "package.json"), "utf8")) as {
name?: unknown;
};
return typeof packageJson.name === "string" && packageNames.includes(packageJson.name);
} catch {
return false;
}
}
50 changes: 22 additions & 28 deletions src/core/opencode-install.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
import {
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readlinkSync,
symlinkSync,
unlinkSync,
} from "node:fs";
import { existsSync, lstatSync, mkdirSync, readlinkSync, symlinkSync, unlinkSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

import type { OpenCodeVersion } from "./opencode-generation.ts";
import { isCheckoutSymlink } from "./integration-ownership.ts";

export interface OpenCodeInstallOptions {
configRoot?: string;
Expand Down Expand Up @@ -49,24 +42,6 @@ function symlinkPointsTo(path: string, sources: string[]): boolean {
return sources.some((source) => resolvedTarget === resolve(source));
}

function isCheckoutSymlink(path: string, expectedRelativeTarget: string[]): boolean {
if (!pathExists(path) || !lstatSync(path).isSymbolicLink()) return false;

const target = resolve(dirname(path), readlinkSync(path));
let checkoutRoot = target;
for (const _segment of expectedRelativeTarget) checkoutRoot = dirname(checkoutRoot);
if (target !== join(checkoutRoot, ...expectedRelativeTarget)) return false;

try {
const packageJson = JSON.parse(readFileSync(join(checkoutRoot, "package.json"), "utf8")) as {
name?: unknown;
};
return packageJson.name === "coding-agents-tmux";
} catch {
return false;
}
}

function isLegacyV1Symlink(path: string): boolean {
return isCheckoutSymlink(path, ["plugin", "coding-agents-tmux.ts"]);
}
Expand Down Expand Up @@ -154,6 +129,25 @@ export function installOpenCodeIntegration(
);

let changed = false;
// The old project name can remain auto-loaded alongside the current plugin.
// Unlike conflicting current paths, unrelated legacy entries are left alone.
const legacyNamedPath = join(pluginRoot, "opencode-tmux.ts");
if (
symlinkPointsTo(legacyNamedPath, [
v1.source,
legacyV1Source,
join(repoRoot, "plugin", "opencode-tmux.ts"),
]) ||
isV1Symlink(legacyNamedPath) ||
isCheckoutSymlink(
legacyNamedPath,
["plugin", "opencode-tmux.ts"],
["opencode-tmux", "coding-agents-tmux"],
)
) {
unlinkSync(legacyNamedPath);
changed = true;
}
if (pathExists(stale.path)) {
unlinkSync(stale.path);
changed = true;
Expand Down
84 changes: 84 additions & 0 deletions test/opencode-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,90 @@ test("installer refuses a legacy-named symlink from an unrelated checkout", () =
assert.equal(readlinkSync(staleV1Path), unrelatedPlugin);
});

test("installer migrates owned old-name OpenCode symlinks for both generations", () => {
for (const generation of [1, 2] as const) {
for (const source of [
join("plugin", "coding-agents-tmux.ts"),
join("plugin", "opencode", "index.ts"),
join("plugin", "opencode-tmux.ts"),
]) {
const fixture = makeFixture();
const pluginRoot = join(fixture.configRoot, "opencode", "plugins");
const legacyPath = join(pluginRoot, "opencode-tmux.ts");
mkdirSync(pluginRoot, { recursive: true });
// Relative links must be recognized too.
symlinkSync(join("..", "..", "..", "repository with spaces", source), legacyPath);
const version = generation === 1 ? "1.18.29" : "2.0.9";
const first = installOpenCodeIntegration({ generation, version }, fixture);
assert.equal(first.changed, true);
assert.throws(() => lstatSync(legacyPath), /ENOENT/);
assert.equal(installOpenCodeIntegration({ generation, version }, fixture).changed, false);
}
}
});

test("original OpenCode source links require a verified historical checkout", () => {
for (const generation of [1, 2] as const) {
for (const name of [
"opencode-tmux",
"coding-agents-tmux",
"unrelated",
"missing",
"malformed",
]) {
for (const live of [true, false]) {
const fixture = makeFixture();
const pluginRoot = join(fixture.configRoot, "opencode", "plugins");
const legacyPath = join(pluginRoot, "opencode-tmux.ts");
const checkout = join(fixture.repoRoot, "..", "historical checkout");
mkdirSync(join(checkout, "plugin"), { recursive: true });
mkdirSync(pluginRoot, { recursive: true });
if (name !== "missing")
writeFileSync(
join(checkout, "package.json"),
name === "malformed" ? "not json" : JSON.stringify({ name }),
);
const target = join(checkout, "plugin", "opencode-tmux.ts");
if (live) writeFileSync(target, "export default {};\n");
symlinkSync(target, legacyPath);
const version = generation === 1 ? "1.18.29" : "2.0.9";
installOpenCodeIntegration({ generation, version }, fixture);
if (name === "opencode-tmux" || name === "coding-agents-tmux") {
assert.throws(() => lstatSync(legacyPath), /ENOENT/);
} else assert.equal(readlinkSync(legacyPath), target);
assert.equal(installOpenCodeIntegration({ generation, version }, fixture).changed, false);
}
}
}
});

test("old-name OpenCode entries without proven ownership are preserved", () => {
for (const kind of ["file", "directory", "unrelated-link", "owned-other-checkout"]) {
const fixture = makeFixture();
const pluginRoot = join(fixture.configRoot, "opencode", "plugins");
const legacyPath = join(pluginRoot, "opencode-tmux.ts");
mkdirSync(pluginRoot, { recursive: true });
if (kind === "file") writeFileSync(legacyPath, "user data");
else if (kind === "directory") mkdirSync(legacyPath);
else {
const checkout = join(fixture.repoRoot, "..", "other checkout");
mkdirSync(join(checkout, "plugin"), { recursive: true });
writeFileSync(
join(checkout, "package.json"),
JSON.stringify({
name: kind === "owned-other-checkout" ? "coding-agents-tmux" : "unrelated",
}),
);
// Target need not exist: managed broken symlinks can be cleaned up.
symlinkSync(join(checkout, "plugin", "coding-agents-tmux.ts"), legacyPath);
}
installOpenCodeIntegration({ generation: 2, version: "2.0.9" }, fixture);
if (kind === "owned-other-checkout") assert.throws(() => lstatSync(legacyPath), /ENOENT/);
else assert.ok(lstatSync(legacyPath));
if (kind === "file") assert.equal(readFileSync(legacyPath, "utf8"), "user data");
}
});

test("installer never removes or replaces unrelated user files, directories, or symlinks", () => {
const cases: Array<{
generation: 1 | 2;
Expand Down
100 changes: 99 additions & 1 deletion test/tmux-plugin-rename.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ import {
chmodSync,
existsSync,
lstatSync,
mkdirSync,
mkdtempSync,
readlinkSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { dirname, join, relative } from "node:path";
import test from "node:test";

import { runCommand } from "../src/runtime.ts";
Expand Down Expand Up @@ -184,6 +186,102 @@ exit 1
}
});

test("Pi installation safely migrates legacy entries and preserves user-managed paths", async () => {
for (const kind of [
"managed",
"extra-file",
"file",
"unrelated",
"directory-link",
"old-checkout",
"renamed-checkout",
"broken-old-checkout",
"relative-old-checkout",
"wrong-package",
"wrong-path",
"missing-package",
]) {
const fakeTmux = installFakeTmux(`
case "$1" in
show-option)
case "\${!#}" in
@coding-agents-tmux-status) printf 'off\\n' ;;
@coding-agents-tmux-auto-install) printf 'pi\\n' ;;
esac
;;
esac
exit 0
`);
installFakeNpm(fakeTmux.pathEntry);
const home = mkdtempSync(join(tmpdir(), "coding-agents-tmux-pi-migration-"));
const piHome = join(home, "pi home");
const legacyDir = join(piHome, "extensions", "opencode-tmux");
const legacyTarget = join(legacyDir, "index.ts");
mkdirSync(join(piHome, "extensions"), { recursive: true });
if (kind === "directory-link") {
const externalDir = join(home, "external");
mkdirSync(externalDir);
symlinkSync(externalDir, legacyDir, "dir");
} else {
mkdirSync(legacyDir);
}
if (kind === "file") {
writeFileSync(legacyTarget, "user extension");
} else {
let target =
kind === "unrelated"
? join(home, "missing.ts")
: join(process.cwd(), "plugin", "pi-tmux.ts");
if (kind.includes("checkout") || kind.startsWith("wrong-") || kind === "missing-package") {
const checkout = join(home, "previous checkout");
mkdirSync(join(checkout, "plugin"), { recursive: true });
if (kind !== "missing-package")
writeFileSync(
join(checkout, "package.json"),
JSON.stringify({
name:
kind === "wrong-package"
? "unrelated"
: kind === "renamed-checkout"
? "coding-agents-tmux"
: "opencode-tmux",
}),
);
target = join(checkout, "plugin", kind === "wrong-path" ? "other.ts" : "pi-tmux.ts");
if (kind !== "broken-old-checkout") writeFileSync(target, "export default {};\n");
if (kind === "relative-old-checkout") target = relative(legacyDir, target);
}
symlinkSync(target, legacyTarget);
}
if (kind === "extra-file") writeFileSync(join(legacyDir, "keep.txt"), "user data");
const restoreEnv = setEnv({
HOME: home,
PI_CODING_AGENT_DIR: piHome,
PATH: `${fakeTmux.pathEntry}:${process.env.PATH ?? ""}`,
});
try {
for (let run = 0; run < 2; run += 1) {
const result = await runCommand([join(process.cwd(), "coding-agents-tmux.tmux")]);
assert.equal(result.exitCode, 0);
assert.equal(result.stderrText.trim(), "");
}
assert.ok(existsSync(join(piHome, "extensions", "coding-agents-tmux", "index.ts")));
if (kind === "managed" || kind.includes("checkout"))
assert.equal(existsSync(legacyDir), false);
else if (kind === "extra-file") {
assert.equal(existsSync(legacyTarget), false);
assert.equal(readFileSync(join(legacyDir, "keep.txt"), "utf8"), "user data");
} else {
assert.ok(lstatSync(legacyTarget));
if (kind === "file") assert.equal(readFileSync(legacyTarget, "utf8"), "user extension");
}
} finally {
restoreEnv();
rmSync(home, { recursive: true, force: true });
}
}
});

test("coding-agents-tmux.tmux silently skips OpenCode auto-install when OpenCode is missing", async () => {
const fakeTmux = installFakeTmux(`
log_path='__LOG_PATH__'
Expand Down
Loading