OpenCode plugin writes .planning/.active_plan by truncation instead of replacing the directory entry

Author: OthmanAdiCreated Sep 17, 2026Updated Sep 17, 2026
Labelsbughelp wanted

Problem

The OpenCode plugin writes the active plan pointer with writeText(path.join(planningRoot, ".active_plan"), ${planId}\n) in .opencode/packages/opencode-planning-with-files/src/core.ts, and writeText is fs.writeFileSync(target, text, { encoding: "utf8" }). writeFileSync opens the existing file and truncates it in place. If .active_plan is a hard link or a symlink, the write lands in whichever file shares that inode or is the link target, instead of replacing the pointer.

The shell initializer removed this bug class in v3.19.0 (PR #249): set-active-plan.sh writes to an exclusive temporary file and renames it over the pointer, and refuses a pointer that is a link or leaves the project. The OpenCode route still uses the older write.

Scope: OpenCode users who create named plans through the plugin's /pwf command, and only when .planning/.active_plan has been replaced by a link. A plain pointer file created by the plugin itself is written correctly. Root mode does not use the pointer.

How to reproduce

bash
mkdir -p .planning && printf 'other-plan\n' > .planning/.active_plan
ln -f .planning/.active_plan elsewhere.txt      # hard link
# create a named plan through the OpenCode plugin
cat elsewhere.txt                               # now contains the new plan id

The same holds for a symlink pointing outside the project: the target file is overwritten.

Suggested fix

Mirror the shell selector in TypeScript:

  • Refuse to write when fs.lstatSync(pointer) is not a regular file, is a symbolic link, reports nlink > 1, or when fs.realpathSync resolves outside planningRoot (the plugin already has realpathOrNull for the read side).
  • Write to a temporary file opened with the wx flag beside the pointer and fs.renameSync it over .active_plan, so the directory entry is replaced instead of the inode being truncated. On Windows, renameSync over an existing file works for regular files.
  • Add a vitest case with a hard-linked pointer that asserts the linked file is untouched.

Origin

Found by the v3.19.0 release review (PRs #247, #248, #249). Tracked as item 7 of #250 before that issue was split into one issue per item. The Hermes plugin has the same pattern, tracked in #259.

Source: OthmanAdi/planning-with-files