From 49ce92edfa6839ad88dedee0830f5314191b8094 Mon Sep 17 00:00:00 2001 From: Aizat Hawari Date: Sat, 7 Nov 2026 23:05:40 +0000 Subject: [PATCH 1/7] feat: add shared host self-update module for Pi and OpenCode Decides when to run the host's own update command, de-duplicates for 30 minutes, runs it detached and records the outcome in a marker file. Includes the OpenSpec change pi-opencode-auto-update. --- .../pi-opencode-auto-update/.openspec.yaml | 2 + .../changes/pi-opencode-auto-update/design.md | 87 +++++ .../pi-opencode-auto-update/proposal.md | 36 ++ .../specs/opencode-v2-adapter/spec.md | 70 ++++ .../specs/pi-agent-adapter/spec.md | 69 ++++ .../changes/pi-opencode-auto-update/tasks.md | 31 ++ src/services/host-self-update.ts | 311 ++++++++++++++++ tests/host-self-update.test.ts | 333 ++++++++++++++++++ 8 files changed, 939 insertions(+) create mode 100644 openspec/changes/pi-opencode-auto-update/.openspec.yaml create mode 100644 openspec/changes/pi-opencode-auto-update/design.md create mode 100644 openspec/changes/pi-opencode-auto-update/proposal.md create mode 100644 openspec/changes/pi-opencode-auto-update/specs/opencode-v2-adapter/spec.md create mode 100644 openspec/changes/pi-opencode-auto-update/specs/pi-agent-adapter/spec.md create mode 100644 openspec/changes/pi-opencode-auto-update/tasks.md create mode 100644 src/services/host-self-update.ts create mode 100644 tests/host-self-update.test.ts diff --git a/openspec/changes/pi-opencode-auto-update/.openspec.yaml b/openspec/changes/pi-opencode-auto-update/.openspec.yaml new file mode 100644 index 00000000..e582ee4a --- /dev/null +++ b/openspec/changes/pi-opencode-auto-update/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-07 diff --git a/openspec/changes/pi-opencode-auto-update/design.md b/openspec/changes/pi-opencode-auto-update/design.md new file mode 100644 index 00000000..482f0b5e --- /dev/null +++ b/openspec/changes/pi-opencode-auto-update/design.md @@ -0,0 +1,87 @@ +## Context + +Claude Code already updates OMMS by itself. `src/adapters/claude-code/plugin-self-update.ts` reads the plugin version, asks npm for `latest` (`src/services/update-check.ts`), writes a marker in `~/.omms/claude-plugin-update.json`, and starts `claude plugin update` in a detached Node process. A second session skips the update for 30 minutes. + +OpenCode V2 only tells the user. `src/adapters/opencode/tui-status.ts` checks npm at start and every 6 hours, adds `· available` to the footer and shows one toast with the command. + +Pi has no OMMS update check. The Pi adapter shows a status label (`ctx.ui.setStatus`) and can notify (`ctx.ui.notify`). Pi itself shows a generic package notice. + +Both hosts install OMMS from npm into their own folders: + +- Pi: `~/.pi/agent/npm/node_modules/om-memory-system/`, source `npm:om-memory-system` in `~/.pi/agent/settings.json`. +- OpenCode: `~/.cache/opencode/npm/om-memory-system@/node_modules/om-memory-system/`. The `@` part is `latest` unless the user pinned a version. + +A running host keeps the loaded copy in memory. A new copy loads only after a restart. Past updates (4.27.0, 4.28.0) showed that OpenCode's background service keeps the old copy until it restarts. + +## Goals / Non-Goals + +**Goals:** + +- Pi and OpenCode install approved releases without a typed command. +- The user sees one clear message: restart to finish. +- Same behaviour on both hosts. Shared logic outside the adapters. + +**Non-Goals:** + +- Restarting the host for the user. A restart would end open sessions. +- Updating a pinned version or a local checkout. +- Changing Claude Code behaviour. +- Updating the global CLI or the web app. The web app has its own Update button. + +## Decisions + +### 1. One shared self-update module + +Add `src/services/host-self-update.ts`. It holds the decision that Claude Code has today: disabled check, npm `latest`, `availableUpdate`, 30-minute marker, detached start. Inputs come through a deps object, as in `plugin-self-update.ts`, so tests need no real npm or processes. The module does not import `CONFIG`. Each adapter passes the host name, the update command, the marker path and the running version. + +Claude Code keeps its own module in this change. Moving it onto the shared module is a follow-up refactor with no behaviour change. Alternative considered: change Claude Code in the same change. Rejected to keep the change small and the Claude Code path, which works, untouched. + +### 2. The detached process records the result + +The detached Node script runs the host command, then writes the exit result into the same marker file: `{ version, startedAt, outcome: "installed" | "failed", finishedAt }`. The running plugin cannot hear from the detached process, so it reads the marker. + +- OpenCode: the status loop reads the marker every 60 seconds while an update is pending, for at most 10 minutes. A marker read is one small file read. +- Pi: the same poll runs from the adapter, started on `session_start` and stopped on `session_shutdown`. + +Alternative considered: wait for the child in the plugin process. Rejected because Claude Code's design needs the child to outlive the session, and the same holds when a user closes Pi straight after it starts. + +### 3. When OMMS runs the update + +OMMS runs the update only when all are true: + +- `OMMS_DISABLE_UPDATE_CHECK` is not `1`. +- The running module file sits under a `node_modules/om-memory-system/` folder. A local path, such as a dev checkout, skips the update. +- The install is not pinned. OpenCode: the cache folder name ends in `@latest` or has no version. Pi: the `packages` entry in `~/.pi/agent/settings.json` is `npm:om-memory-system` with no `@`. A settings file that cannot be read counts as pinned, so OMMS does nothing. + +When the update is skipped for a pin or a local path, the notice keeps today's "available, run " text. + +### 4. Notice text + +| State | Footer suffix | One-time message | +| --------------------- | -------------------------- | ------------------------------------------------------ | +| Newer, update skipped | `· available` | names the update command | +| Newer, update running | `· available` | "om-memory-system is installing in the background" | +| Update installed | `· installed, restart` | " installed. Restart to use it." | +| Update failed | `· available` | names the update command | + +Pi's suffix is added to the status label after the glyph, so `↻` and `✎` keep the suffix. + +### 5. Windows + +The detached script runs the command through the shell on Windows, as Claude Code's script does (`shell: process.platform === "win32"`), with a 120-second timeout. + +## Risks / Trade-offs + +- [`opencode plugin update` talks to the running background service, which may still hold the old copy] → The command writes the new copy to the cache. The notice tells the user to restart. Implementation task 5.3 checks this live in a scratch HOME. +- [Pi's `pi update ` may also move a pinned package] → Decision 3 skips pinned installs before the command runs, so the host's pin handling does not matter. +- [A user who never restarts stays on the old version] → Same as Claude Code. The footer keeps `installed, restart` until the restart. +- [Two hosts update at the same moment from one marker race] → Each host has its own marker file. Within one host, the marker is written before the spawn, as in Claude Code. +- [The npm registry is down] → The check returns null and nothing happens, as today. + +## Migration Plan + +No data changes. Users who want the old behaviour set `OMMS_DISABLE_UPDATE_CHECK=1`. Roll back by reverting the release. + +## Open Questions + +None. diff --git a/openspec/changes/pi-opencode-auto-update/proposal.md b/openspec/changes/pi-opencode-auto-update/proposal.md new file mode 100644 index 00000000..586196ef --- /dev/null +++ b/openspec/changes/pi-opencode-auto-update/proposal.md @@ -0,0 +1,36 @@ +## Why + +Claude Code users get each approved OMMS release without any action: the session-start hook compares the plugin with npm `latest` and runs `claude plugin update` in the background. Pi and OpenCode users must notice the release and type an update command themselves. Users who are not technical forget, and stay on old versions with old bugs. + +## What Changes + +- When a Pi or OpenCode V2 session starts, OMMS compares its running version with npm `latest`. When npm has a newer release that is not a prerelease, OMMS runs the host's own update command in a detached process: + - Pi: `pi update npm:om-memory-system` + - OpenCode: `opencode plugin update om-memory-system` +- A second session does not start another update for the same release within 30 minutes. This matches Claude Code. +- `OMMS_DISABLE_UPDATE_CHECK=1` turns off the check and the update, as it does today. +- OMMS runs the update only when the host installed it from npm. A local checkout or a pinned version is left alone. +- After the update succeeds, the host notice changes from " available, run " to " installed, restart to finish". If the update fails, the notice falls back to today's text with the command. +- Pi gets an OMMS update notice in its footer and one notification. Today Pi shows only its own generic package notice. +- OpenCode's footer shows `· installed, restart` after a successful update, in place of `· available`. +- Docs (`docs/upgrading.md`, `UPDATES.md`) say that Pi and OpenCode now update by themselves and only need a restart. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `opencode-v2-adapter`: the newer-release requirement changes from "tell the user" to "install in the background, then tell the user to restart". +- `pi-agent-adapter`: new requirement for the background update and restart notice in Pi. + +## Impact + +- New shared module in `src/services/` for the update decision (npm check, de-dup marker, detached run, result marker). Claude Code's `plugin-self-update.ts` keeps its behaviour. +- `src/adapters/opencode/tui-status.ts`: start the update, read the result, change footer and toast text. +- `src/adapters/pi/extension.ts`: call the shared module on `session_start`, show footer text and one notification. +- New marker files `~/.omms/pi-plugin-update.json` and `~/.omms/opencode-plugin-update.json`. +- Tests for both hosts and the shared module. Docs listed above. +- No new dependencies. No session content leaves the machine: the only network call is the existing npm registry read. diff --git a/openspec/changes/pi-opencode-auto-update/specs/opencode-v2-adapter/spec.md b/openspec/changes/pi-opencode-auto-update/specs/opencode-v2-adapter/spec.md new file mode 100644 index 00000000..e2cd5e58 --- /dev/null +++ b/openspec/changes/pi-opencode-auto-update/specs/opencode-v2-adapter/spec.md @@ -0,0 +1,70 @@ +## MODIFIED Requirements + +### Requirement: OpenCode tells the user about a newer OMMS release + +At start and every 6 hours, OMMS in OpenCode SHALL read the `latest` version of `om-memory-system` from the npm registry. When it is newer than the running version and is not a prerelease, the footer SHALL add `· available`, and OMMS SHALL show one toast that names `opencode plugin update om-memory-system`. When OMMS has started a background update for that version, the toast SHALL say that the update is installing instead of naming the command. The check SHALL send no session content. `OMMS_DISABLE_UPDATE_CHECK=1` SHALL turn the check off. A failed check SHALL show no notice. + +#### Scenario: A newer release exists + +- **WHEN** OpenCode runs OMMS 4.2.0 from a local path and npm `latest` is 4.3.0 +- **THEN** the footer SHALL show `omms: ✔ · 4.3.0 available` +- **AND** a toast SHALL name `opencode plugin update om-memory-system` + +#### Scenario: The check is turned off + +- **WHEN** `OMMS_DISABLE_UPDATE_CHECK=1` is set +- **THEN** OMMS SHALL NOT contact the npm registry + +## ADDED Requirements + +### Requirement: OpenCode installs a newer OMMS release in the background + +When OMMS in OpenCode was installed from npm without a pinned version, and the update check finds a newer release that is not a prerelease, OMMS SHALL run `opencode plugin update om-memory-system` in a detached process and SHALL NOT wait for it. OMMS SHALL NOT start a second update for the same release within 30 minutes, across all OpenCode sessions on the computer. + +#### Scenario: A release is approved while OpenCode is closed + +- **WHEN** OpenCode starts with OMMS 4.2.0 from npm and npm `latest` is 4.3.0 +- **THEN** OMMS SHALL start `opencode plugin update om-memory-system` in the background +- **AND** the log SHALL hold an `OpenCode plugin self-update` record with code `started` + +#### Scenario: A second session starts soon after + +- **WHEN** an update for 4.3.0 started less than 30 minutes ago +- **THEN** a new OpenCode session SHALL NOT start another update + +#### Scenario: OMMS runs from a local checkout or a pinned version + +- **WHEN** OpenCode loads OMMS from a local path, or the configured package names a version +- **THEN** OMMS SHALL NOT run the update command +- **AND** the footer and toast SHALL behave as in the newer-release requirement + +### Requirement: The OpenCode background update is safe and logged + +The background update SHALL run apart from memory work. `OMMS_DISABLE_UPDATE_CHECK=1` SHALL turn the update off. A failure SHALL NOT stop memory capture, retrieval, or the `memory` tool. A started or failed start SHALL write one `OpenCode plugin self-update` log record with a code and the two versions. + +#### Scenario: The update command cannot start + +- **WHEN** the update command cannot be started +- **THEN** memory capture, retrieval and the `memory` tool SHALL keep working +- **AND** the log SHALL hold one `OpenCode plugin self-update` record with code `spawn-failed` and no session content + +#### Scenario: Updates are turned off + +- **WHEN** `OMMS_DISABLE_UPDATE_CHECK=1` is set +- **THEN** OMMS SHALL NOT run the update command + +### Requirement: OpenCode tells the user to restart after a background update + +When a background update installs the newer release, the footer SHALL show `· installed, restart` in place of `· available`, and OMMS SHALL show one toast that says the release is installed and OpenCode must restart to use it. When the update command fails, the footer SHALL keep `· available` and OMMS SHALL show one toast that names `opencode plugin update om-memory-system`. + +#### Scenario: The background update succeeds + +- **WHEN** the background update for 4.3.0 exits with success +- **THEN** the footer SHALL show `omms: ✔ · 4.3.0 installed, restart` +- **AND** one toast SHALL tell the user to restart OpenCode + +#### Scenario: The background update fails + +- **WHEN** the background update for 4.3.0 exits with an error +- **THEN** the footer SHALL show `omms: ✔ · 4.3.0 available` +- **AND** one toast SHALL name `opencode plugin update om-memory-system` diff --git a/openspec/changes/pi-opencode-auto-update/specs/pi-agent-adapter/spec.md b/openspec/changes/pi-opencode-auto-update/specs/pi-agent-adapter/spec.md new file mode 100644 index 00000000..3c573be6 --- /dev/null +++ b/openspec/changes/pi-opencode-auto-update/specs/pi-agent-adapter/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: Pi installs a newer OMMS release in the background + +On `session_start`, when OMMS in Pi was installed from npm without a pinned version, the Pi adapter SHALL read the `latest` version of `om-memory-system` from the npm registry. When it is newer than the running version and is not a prerelease, the adapter SHALL run `pi update npm:om-memory-system` in a detached process and SHALL NOT wait for it. The adapter SHALL NOT start a second update for the same release within 30 minutes, across all Pi sessions on the computer. + +#### Scenario: A release is approved while Pi is closed + +- **WHEN** a Pi session starts with OMMS 4.2.0 from npm and npm `latest` is 4.3.0 +- **THEN** the adapter SHALL start `pi update npm:om-memory-system` in the background +- **AND** the log SHALL hold a `Pi plugin self-update` record with code `started` + +#### Scenario: OMMS is current + +- **WHEN** the running version and npm `latest` are both 4.3.0 +- **THEN** the adapter SHALL NOT run the update command +- **AND** the adapter SHALL show no update notice + +#### Scenario: A second session starts soon after + +- **WHEN** an update for 4.3.0 started less than 30 minutes ago +- **THEN** a new Pi session SHALL NOT start another update + +#### Scenario: OMMS runs from a local checkout or a pinned version + +- **WHEN** Pi loads OMMS from a local path, or the configured source names a version +- **THEN** the adapter SHALL NOT run the update command +- **AND** the adapter SHALL show one notification that names `pi update npm:om-memory-system` + +#### Scenario: The check is turned off + +- **WHEN** `OMMS_DISABLE_UPDATE_CHECK=1` is set +- **THEN** the adapter SHALL NOT contact the npm registry + +### Requirement: The Pi background update is safe and logged + +The background update SHALL run apart from memory work. The check SHALL send no session content. `OMMS_DISABLE_UPDATE_CHECK=1` SHALL turn the check and the update off. A failure SHALL NOT delay `session_start` or stop memory capture, retrieval, or the `memory` tool. A started or failed start SHALL write one `Pi plugin self-update` log record with a code and the two versions. + +#### Scenario: The update command cannot start + +- **WHEN** the update command cannot be started +- **THEN** memory capture, retrieval and the `memory` tool SHALL keep working +- **AND** the log SHALL hold one `Pi plugin self-update` record with code `spawn-failed` and no session content + +#### Scenario: Updates are turned off + +- **WHEN** `OMMS_DISABLE_UPDATE_CHECK=1` is set +- **THEN** OMMS SHALL NOT run the update command + +### Requirement: Pi tells the user to restart after a background update + +While a Pi session with a user interface runs, when a background update installs the newer release, the OMMS footer label SHALL add `· installed, restart`, and the adapter SHALL show one notification that says the release is installed and Pi must restart to use it. When the update command fails, the footer label SHALL add `· available` and the adapter SHALL show one notification that names `pi update npm:om-memory-system`. The suffix SHALL stay when the status glyph changes. + +#### Scenario: The background update succeeds + +- **WHEN** the background update for 4.3.0 exits with success +- **THEN** the footer SHALL show `omms: ✔ · 4.3.0 installed, restart` +- **AND** one notification SHALL tell the user to restart Pi + +#### Scenario: The background update fails + +- **WHEN** the background update for 4.3.0 exits with an error +- **THEN** the footer SHALL show `omms: ✔ · 4.3.0 available` +- **AND** one notification SHALL name `pi update npm:om-memory-system` + +#### Scenario: Retrieval runs after the update + +- **WHEN** the footer shows `· 4.3.0 installed, restart` and retrieval starts +- **THEN** the footer SHALL show `omms: ↻ · 4.3.0 installed, restart` diff --git a/openspec/changes/pi-opencode-auto-update/tasks.md b/openspec/changes/pi-opencode-auto-update/tasks.md new file mode 100644 index 00000000..daed9b45 --- /dev/null +++ b/openspec/changes/pi-opencode-auto-update/tasks.md @@ -0,0 +1,31 @@ +## 1. Shared self-update module + +- [x] 1.1 Write failing tests in `tests/host-self-update.test.ts`: disabled, current, prerelease, unreachable npm, recent marker, started, spawn-failed, local path skip, pinned skip. Confirm they fail before the module exists. +- [x] 1.2 Add `src/services/host-self-update.ts` with a deps object (no `CONFIG` import): decision, marker write before spawn, detached Node script that runs the host command and writes `outcome` and `finishedAt` to the marker. +- [x] 1.3 Add a marker reader that returns `pending`, `installed` or `failed` for a version. Test each state and an unreadable file. +- [x] 1.4 Add install-source checks: module path under `node_modules/om-memory-system/`; OpenCode cache folder `@latest` or no version; Pi `packages` entry without `@`; unreadable Pi settings count as pinned. Test each case. + +## 2. OpenCode V2 + +- [ ] 2.1 Write failing tests in the `tui-status` test file for: update started (installing toast), installed (footer `· installed, restart` and restart toast), failed (command toast), pinned or local path (today's text). +- [ ] 2.2 Wire `src/adapters/opencode/tui-status.ts` to the shared module: start the update after the npm check, poll the marker every 60 seconds for at most 10 minutes, change footer and toast text. +- [ ] 2.3 Log one `OpenCode plugin self-update` record with code and versions on start or failed start. No prompt or reply text. + +## 3. Pi + +- [ ] 3.1 Write failing tests for the Pi adapter: start on `session_start`, no delay to `session_start`, 30-minute de-dup, restart notification, failed notification, suffix kept for `↻` and `✎`, no label without a user interface, poll stops on `session_shutdown`. +- [ ] 3.2 Wire `src/adapters/pi/extension.ts` to the shared module. Keep the adapter thin. Add the suffix to the status label. +- [ ] 3.3 Log one `Pi plugin self-update` record with code and versions on start or failed start. + +## 4. Docs + +- [ ] 4.1 Update `docs/upgrading.md`: Pi and OpenCode install updates by themselves, then ask for a restart; pinned and local installs do not; `OMMS_DISABLE_UPDATE_CHECK=1` turns it off. +- [ ] 4.2 Update `UPDATES.md` to match. +- [ ] 4.3 Check `README.md` and `docs/` for text that says Pi or OpenCode never update by themselves, and fix each hit. + +## 5. Verify + +- [ ] 5.1 Run each new and changed test file with `bash scripts/run-tests-isolated.sh `. Break the code once and confirm the tests fail. +- [ ] 5.2 Run `bun run check` and confirm the file-size ceiling test passes for `tui-status.ts` and `extension.ts`. +- [ ] 5.3 Live check in a scratch HOME with its own store: install an older OMMS in Pi and in OpenCode, start a session, confirm the update command runs once, the marker says `installed`, and the footer shows `installed, restart`. Restart and confirm the new version loads. +- [ ] 5.4 Run `bun run ci:local` and read the exit line. diff --git a/src/services/host-self-update.ts b/src/services/host-self-update.ts new file mode 100644 index 00000000..b196f84a --- /dev/null +++ b/src/services/host-self-update.ts @@ -0,0 +1,311 @@ +import { spawn } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { availableUpdate, latestNpmVersion } from "./update-check.js"; + +// Pi and OpenCode install OMMS from npm, but neither updates its plugins by +// itself. At session start OMMS compares the running copy with npm `latest` +// and, when it is older, runs the host's own update command in a detached +// process. That process writes the result into a marker file, which the +// running plugin reads to tell the user to restart. Claude Code has its own +// module (`src/adapters/claude-code/plugin-self-update.ts`). + +/** A second session does not start another update for the same release within this time. */ +export const HOST_SELF_UPDATE_RETRY_MS = 30 * 60 * 1000; +/** How often a running plugin reads the marker while an update is pending. */ +export const HOST_UPDATE_POLL_MS = 60 * 1000; +/** How long a running plugin reads the marker before it gives up. */ +export const HOST_UPDATE_POLL_LIMIT_MS = 10 * 60 * 1000; +/** The longest time the detached process lets the host command run. */ +const HOST_COMMAND_TIMEOUT_MS = 120_000; + +export type HostSelfUpdateCode = + | "started" + | "current" + | "recent" + | "disabled" + | "unreachable" + | "local" + | "pinned" + | "spawn-failed"; + +/** How the host installed this copy. Only "npm" (unpinned) is updated. */ +export type HostInstallSource = "npm" | "local" | "pinned"; + +export interface HostUpdateMarker { + version: string; + startedAt: number; + outcome?: "installed" | "failed"; + finishedAt?: number; +} + +export type HostUpdateStatus = "pending" | "installed" | "failed"; + +export interface HostSelfUpdateDeps { + /** False when `OMMS_DISABLE_UPDATE_CHECK` is `1`. */ + enabled: boolean; + /** Log message, for example `Pi plugin self-update`. */ + logLabel: string; + currentVersion: string; + /** The host's own update command, for example `pi` with `["update", "npm:om-memory-system"]`. */ + command: string; + args: string[]; + /** The marker the detached process writes its result into. */ + markerPath: string; + installSource: () => HostInstallSource; + fetch: typeof fetch; + now: () => number; + readMarker: () => HostUpdateMarker | null; + writeMarker: (value: HostUpdateMarker) => void; + /** Start a process that outlives this session. */ + spawnDetached: (command: string, args: string[]) => void; + log: (message: string, data: Record) => void | Promise; +} + +export interface HostSelfUpdateResult { + code: HostSelfUpdateCode; + /** The newer release on npm, or null when there is none to report. */ + update: string | null; +} + +/** + * Node or Bun script that runs the host command, then records the outcome in + * the marker. Arguments: marker path, marker JSON, command, command args. + * `BUN_BE_BUN` lets a compiled Bun host (OpenCode) run this script; the host + * command must not inherit it. + */ +const UPDATE_SCRIPT = ` +const { spawnSync } = require("node:child_process"); +const { writeFileSync } = require("node:fs"); +const [markerPath, markerJson, command, ...args] = process.argv.slice(1); +const env = { ...process.env }; +delete env.BUN_BE_BUN; +let ok = false; +try { + const run = spawnSync(command, args, { + stdio: "ignore", + shell: process.platform === "win32", + windowsHide: true, + timeout: ${HOST_COMMAND_TIMEOUT_MS}, + env, + }); + ok = run.status === 0; +} catch {} +const marker = JSON.parse(markerJson); +writeFileSync(markerPath, JSON.stringify({ ...marker, outcome: ok ? "installed" : "failed", finishedAt: Date.now() })); +`; + +/** Arguments for the script runner (`node` or Bun) that runs the host command detached. */ +export function hostUpdateRunnerArgs( + markerPath: string, + marker: HostUpdateMarker, + command: string, + args: string[] +): string[] { + return ["-e", UPDATE_SCRIPT, markerPath, JSON.stringify(marker), command, ...args]; +} + +/** Start a background host update when npm has a newer release. Never throws. */ +export async function startHostSelfUpdate(deps: HostSelfUpdateDeps): Promise { + const result = await decide(deps); + if (result.code === "started" || result.code === "spawn-failed") { + try { + await deps.log(deps.logLabel, { + code: result.code, + current: deps.currentVersion, + latest: result.update, + }); + } catch { + // A failed log write must not change the update result. + } + } + return result; +} + +async function decide(deps: HostSelfUpdateDeps): Promise { + if (!deps.enabled) return { code: "disabled", update: null }; + const latest = await latestNpmVersion(deps.fetch); + if (latest === null) return { code: "unreachable", update: null }; + const update = availableUpdate(deps.currentVersion, latest); + if (!update) return { code: "current", update: null }; + + let source: HostInstallSource; + try { + source = deps.installSource(); + } catch { + source = "pinned"; + } + if (source !== "npm") return { code: source, update }; + + const marker = deps.readMarker(); + const now = deps.now(); + if (marker?.version === update && now - marker.startedAt <= HOST_SELF_UPDATE_RETRY_MS) { + return { code: "recent", update }; + } + const started: HostUpdateMarker = { version: update, startedAt: now }; + try { + // Write first, so a session that starts during the update does not start another. + deps.writeMarker(started); + deps.spawnDetached( + process.execPath, + hostUpdateRunnerArgs(deps.markerPath, started, deps.command, deps.args) + ); + } catch { + return { code: "spawn-failed", update }; + } + return { code: "started", update }; +} + +/** The state of the update for `version` that the marker records, or null. */ +export function markerStatus( + marker: HostUpdateMarker | null, + version: string +): HostUpdateStatus | null { + if (!marker || marker.version !== version) return null; + return marker.outcome ?? "pending"; +} + +/** Read a marker file. A missing, unreadable or malformed file is no marker. */ +export function readMarkerFile(path: string): HostUpdateMarker | null { + try { + const value = JSON.parse(readFileSync(path, "utf8")) as Record; + if (typeof value.version !== "string" || typeof value.startedAt !== "number") return null; + const marker: HostUpdateMarker = { version: value.version, startedAt: value.startedAt }; + if (value.outcome === "installed" || value.outcome === "failed") { + marker.outcome = value.outcome; + if (typeof value.finishedAt === "number") marker.finishedAt = value.finishedAt; + } + return marker; + } catch { + return null; + } +} + +/** + * Read the marker now, then every `intervalMs` until it holds an outcome or + * `maxMs` passes. Calls `onSettled` once with the outcome. Returns a stop function. + */ +export function watchHostUpdate(options: { + read: () => HostUpdateStatus | null; + onSettled: (status: "installed" | "failed") => void; + intervalMs?: number; + maxMs?: number; +}): () => void { + const intervalMs = options.intervalMs ?? HOST_UPDATE_POLL_MS; + const deadline = Date.now() + (options.maxMs ?? HOST_UPDATE_POLL_LIMIT_MS); + let timer: ReturnType | undefined; + let done = false; + const stop = () => { + done = true; + if (timer) clearInterval(timer); + }; + const check = () => { + if (done) return; + const status = options.read(); + if (status === "installed" || status === "failed") { + stop(); + options.onSettled(status); + } else if (Date.now() >= deadline) { + stop(); + } + }; + check(); + if (!done) { + timer = setInterval(check, intervalMs); + timer.unref?.(); + } + return stop; +} + +/** True when the module file sits in an npm install of om-memory-system. */ +export function installedFromNpm(modulePath: string): boolean { + return modulePath.replaceAll("\\", "/").includes("/node_modules/om-memory-system/"); +} + +/** + * True when the module sits in a host package cache folder named + * `om-memory-system` or `om-memory-system@latest`. Any other folder, such as + * one with a version, counts as pinned. + */ +export function unpinnedCacheFolder(modulePath: string): boolean { + const parts = modulePath.replaceAll("\\", "/").split("/"); + const index = parts.lastIndexOf("node_modules"); + if (index < 1 || parts[index + 1] !== "om-memory-system") return false; + const folder = parts[index - 1]; + return folder === "om-memory-system" || folder === "om-memory-system@latest"; +} + +/** + * True when a settings file lists `npm:om-memory-system` in `packages` with no + * version. A file that cannot be read, or an entry with a version, counts as pinned. + */ +export function unpinnedPackageSource(settingsText: string | null): boolean { + if (settingsText === null) return false; + let packages: unknown; + try { + packages = (JSON.parse(settingsText) as { packages?: unknown }).packages; + } catch { + return false; + } + if (!Array.isArray(packages)) return false; + return packages.some((entry: unknown) => { + const source = + typeof entry === "string" ? entry : (entry as { source?: unknown } | null)?.source; + return source === "npm:om-memory-system"; + }); +} + +/** Footer text after the status glyph: a newer release, or one installed and waiting for a restart. */ +export function updateSuffix(update: string, state: "available" | "installed"): string { + return state === "installed" ? ` · ${update} installed, restart` : ` · ${update} available`; +} + +/** The one-time message for each update state. */ +export function updateNotice(options: { + hostName: string; + command: string; + current: string; + update: string; + state: "available" | "installing" | "installed"; +}): string { + const { hostName, command, current, update, state } = options; + if (state === "installing") return `om-memory-system ${update} is installing in the background.`; + if (state === "installed") + return `om-memory-system ${update} installed. Restart ${hostName} to use it.`; + return `om-memory-system ${update} is available (running ${current}). Run: ${command}, then restart ${hostName}.`; +} + +/** Production inputs: npm, the marker file, and a detached script runner. */ +export function hostSelfUpdateDeps(options: { + logLabel: string; + currentVersion: string; + command: string; + args: string[]; + markerPath: string; + installSource: () => HostInstallSource; + log: HostSelfUpdateDeps["log"]; + env?: NodeJS.ProcessEnv; +}): HostSelfUpdateDeps { + const env = options.env ?? process.env; + return { + ...options, + enabled: env.OMMS_DISABLE_UPDATE_CHECK !== "1", + fetch: globalThis.fetch.bind(globalThis), + now: Date.now, + readMarker: () => readMarkerFile(options.markerPath), + writeMarker: (value) => { + mkdirSync(dirname(options.markerPath), { recursive: true }); + writeFileSync(options.markerPath, JSON.stringify(value)); + }, + spawnDetached: (command, args) => { + const child = spawn(command, args, { + detached: true, + stdio: "ignore", + windowsHide: true, + env: { ...env, BUN_BE_BUN: "1" }, + }); + child.on("error", () => {}); + child.unref(); + }, + }; +} diff --git a/tests/host-self-update.test.ts b/tests/host-self-update.test.ts new file mode 100644 index 00000000..612d4ec4 --- /dev/null +++ b/tests/host-self-update.test.ts @@ -0,0 +1,333 @@ +import { expect, it } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + HOST_SELF_UPDATE_RETRY_MS, + hostUpdateRunnerArgs, + installedFromNpm, + markerStatus, + readMarkerFile, + startHostSelfUpdate, + unpinnedCacheFolder, + unpinnedPackageSource, + updateNotice, + updateSuffix, + watchHostUpdate, + type HostSelfUpdateDeps, + type HostUpdateMarker, +} from "../src/services/host-self-update.js"; + +/** A host at `current` with npm `latest` at `latest`. Nothing touches the real home or a host. */ +function fake(options: { + current?: string; + latest?: string | null; + enabled?: boolean; + source?: "npm" | "local" | "pinned"; + marker?: HostUpdateMarker | null; + now?: number; + spawnThrows?: boolean; +}) { + const spawned: { command: string; args: string[] }[] = []; + const logs: { message: string; data: Record }[] = []; + let fetches = 0; + let marker = options.marker ?? null; + const deps: HostSelfUpdateDeps = { + enabled: options.enabled ?? true, + logLabel: "Test plugin self-update", + currentVersion: options.current ?? "4.2.0", + command: "testhost", + args: ["update", "om-memory-system"], + markerPath: "/fake/.omms/test-plugin-update.json", + installSource: () => options.source ?? "npm", + fetch: (async () => { + fetches++; + if (options.latest === null) throw new Error("offline"); + return new Response(JSON.stringify({ version: options.latest ?? "4.3.0" }), { status: 200 }); + }) as unknown as typeof fetch, + now: () => options.now ?? 1_000_000, + readMarker: () => marker, + writeMarker: (value) => { + marker = value; + }, + spawnDetached: (command, args) => { + if (options.spawnThrows) throw new Error("ENOENT"); + spawned.push({ command, args }); + }, + log: (message, data) => logs.push({ message, data }), + }; + return { deps, spawned, logs, marker: () => marker, fetches: () => fetches }; +} + +it("starts a background host update when npm has a newer release", async () => { + const f = fake({}); + expect(await startHostSelfUpdate(f.deps)).toEqual({ code: "started", update: "4.3.0" }); + expect(f.spawned).toHaveLength(1); + expect(f.spawned[0]!.args.join(" ")).toContain("testhost"); + expect(f.marker()).toEqual({ version: "4.3.0", startedAt: 1_000_000 }); + expect(f.logs).toEqual([ + { + message: "Test plugin self-update", + data: { code: "started", current: "4.2.0", latest: "4.3.0" }, + }, + ]); +}); + +it("does nothing and asks no one when the check is turned off", async () => { + const f = fake({ enabled: false }); + expect(await startHostSelfUpdate(f.deps)).toEqual({ code: "disabled", update: null }); + expect(f.fetches()).toBe(0); + expect(f.spawned).toHaveLength(0); + expect(f.logs).toHaveLength(0); +}); + +it("does nothing when the running version is current", async () => { + const f = fake({ current: "4.3.0", latest: "4.3.0" }); + expect(await startHostSelfUpdate(f.deps)).toEqual({ code: "current", update: null }); + expect(f.spawned).toHaveLength(0); + expect(f.marker()).toBeNull(); +}); + +it("ignores a prerelease on npm", async () => { + const f = fake({ latest: "4.4.0-beta.1" }); + expect(await startHostSelfUpdate(f.deps)).toEqual({ code: "current", update: null }); + expect(f.spawned).toHaveLength(0); +}); + +it("does nothing when npm cannot be reached", async () => { + const f = fake({ latest: null }); + expect(await startHostSelfUpdate(f.deps)).toEqual({ code: "unreachable", update: null }); + expect(f.spawned).toHaveLength(0); + expect(f.logs).toHaveLength(0); +}); + +it("does not start a second update for the same release within 30 minutes", async () => { + const f = fake({ + marker: { version: "4.3.0", startedAt: 1_000_000 - HOST_SELF_UPDATE_RETRY_MS + 1 }, + }); + expect(await startHostSelfUpdate(f.deps)).toEqual({ code: "recent", update: "4.3.0" }); + expect(f.spawned).toHaveLength(0); +}); + +it("starts again after 30 minutes, or for a different release", async () => { + const old = fake({ + marker: { version: "4.3.0", startedAt: 1_000_000 - HOST_SELF_UPDATE_RETRY_MS - 1 }, + }); + expect((await startHostSelfUpdate(old.deps)).code).toBe("started"); + const other = fake({ marker: { version: "4.2.5", startedAt: 1_000_000 } }); + expect((await startHostSelfUpdate(other.deps)).code).toBe("started"); +}); + +it("logs spawn-failed and keeps the marker when the update cannot start", async () => { + const f = fake({ spawnThrows: true }); + expect(await startHostSelfUpdate(f.deps)).toEqual({ code: "spawn-failed", update: "4.3.0" }); + expect(f.marker()).toEqual({ version: "4.3.0", startedAt: 1_000_000 }); + expect(f.logs).toEqual([ + { + message: "Test plugin self-update", + data: { code: "spawn-failed", current: "4.2.0", latest: "4.3.0" }, + }, + ]); +}); + +it("skips a local checkout and a pinned install but still reports the release", async () => { + for (const source of ["local", "pinned"] as const) { + const f = fake({ source }); + expect(await startHostSelfUpdate(f.deps)).toEqual({ code: source, update: "4.3.0" }); + expect(f.spawned).toHaveLength(0); + expect(f.marker()).toBeNull(); + expect(f.logs).toHaveLength(0); + } +}); + +it("reads a marker as pending, installed or failed for one release", () => { + expect(markerStatus(null, "4.3.0")).toBeNull(); + expect(markerStatus({ version: "4.2.5", startedAt: 1 }, "4.3.0")).toBeNull(); + expect(markerStatus({ version: "4.3.0", startedAt: 1 }, "4.3.0")).toBe("pending"); + expect( + markerStatus({ version: "4.3.0", startedAt: 1, outcome: "installed", finishedAt: 2 }, "4.3.0") + ).toBe("installed"); + expect( + markerStatus({ version: "4.3.0", startedAt: 1, outcome: "failed", finishedAt: 2 }, "4.3.0") + ).toBe("failed"); +}); + +it("treats a missing or unreadable marker file as no marker", () => { + const dir = mkdtempSync(join(tmpdir(), "omms-host-update-")); + expect(readMarkerFile(join(dir, "missing.json"))).toBeNull(); + writeFileSync(join(dir, "bad.json"), "{not json"); + expect(readMarkerFile(join(dir, "bad.json"))).toBeNull(); + writeFileSync(join(dir, "wrong.json"), JSON.stringify({ version: 4, startedAt: "x" })); + expect(readMarkerFile(join(dir, "wrong.json"))).toBeNull(); + writeFileSync( + join(dir, "odd-outcome.json"), + JSON.stringify({ version: "4.3.0", startedAt: 1, outcome: "maybe" }) + ); + expect(readMarkerFile(join(dir, "odd-outcome.json"))).toEqual({ + version: "4.3.0", + startedAt: 1, + }); +}); + +it("the detached script runs the host command and records the outcome", () => { + const dir = mkdtempSync(join(tmpdir(), "omms-host-update-")); + for (const [exitCode, outcome] of [ + [0, "installed"], + [3, "failed"], + ] as const) { + const markerPath = join(dir, `marker-${exitCode}.json`); + const marker = { version: "4.3.0", startedAt: 5 }; + writeFileSync(markerPath, JSON.stringify(marker)); + const run = spawnSync( + process.execPath, + hostUpdateRunnerArgs(markerPath, marker, process.execPath, [ + "-e", + `process.exit(${exitCode})`, + ]), + { timeout: 30_000, env: { ...process.env, BUN_BE_BUN: "1" } } + ); + expect(run.status).toBe(0); + const written = JSON.parse(readFileSync(markerPath, "utf8")) as HostUpdateMarker; + expect(written.version).toBe("4.3.0"); + expect(written.startedAt).toBe(5); + expect(written.outcome).toBe(outcome); + expect(typeof written.finishedAt).toBe("number"); + } +}); + +it("the detached script records a failure when the host command does not exist", () => { + const dir = mkdtempSync(join(tmpdir(), "omms-host-update-")); + const markerPath = join(dir, "marker.json"); + const marker = { version: "4.3.0", startedAt: 5 }; + spawnSync( + process.execPath, + hostUpdateRunnerArgs(markerPath, marker, "omms-no-such-host-command", ["update"]), + { timeout: 30_000 } + ); + expect(readMarkerFile(markerPath)?.outcome).toBe("failed"); +}); + +it("knows an npm install from a local checkout by the module path", () => { + expect( + installedFromNpm( + "/home/u/.pi/agent/npm/node_modules/om-memory-system/dist/adapters/pi/extension.js" + ) + ).toBe(true); + expect( + installedFromNpm( + "C:\\Users\\u\\.cache\\opencode\\npm\\om-memory-system@latest\\node_modules\\om-memory-system\\dist\\x.js" + ) + ).toBe(true); + expect(installedFromNpm("/home/u/dev/omms/dist/adapters/pi/extension.js")).toBe(false); + expect(installedFromNpm("/home/u/node_modules/om-memory-system-fork/dist/x.js")).toBe(false); +}); + +it("treats an OpenCode cache folder as unpinned only for latest or no version", () => { + const path = (folder: string) => + `/home/u/.cache/opencode/npm/${folder}/node_modules/om-memory-system/dist/adapters/opencode/tui-status.js`; + expect(unpinnedCacheFolder(path("om-memory-system@latest"))).toBe(true); + expect(unpinnedCacheFolder(path("om-memory-system"))).toBe(true); + expect(unpinnedCacheFolder(path("om-memory-system@4.2.0"))).toBe(false); + expect(unpinnedCacheFolder(path("om-memory-system@^4"))).toBe(false); + expect(unpinnedCacheFolder("/home/u/.config/opencode/node_modules/om-memory-system/x.js")).toBe( + false + ); + expect(unpinnedCacheFolder("/home/u/dev/omms/dist/x.js")).toBe(false); +}); + +it("treats a Pi package entry as unpinned only without a version", () => { + const settings = (packages: unknown) => JSON.stringify({ packages }); + expect(unpinnedPackageSource(settings(["npm:om-memory-system", "npm:other"]))).toBe(true); + expect(unpinnedPackageSource(settings([{ source: "npm:om-memory-system" }]))).toBe(true); + expect(unpinnedPackageSource(settings(["npm:om-memory-system@4.2.0"]))).toBe(false); + expect(unpinnedPackageSource(settings([{ source: "npm:om-memory-system@latest" }]))).toBe(false); + expect(unpinnedPackageSource(settings(["npm:other"]))).toBe(false); + expect(unpinnedPackageSource(settings("npm:om-memory-system"))).toBe(false); + expect(unpinnedPackageSource("{not json")).toBe(false); + expect(unpinnedPackageSource(null)).toBe(false); +}); + +it("words the footer suffix and the one-time notices for each state", () => { + expect(updateSuffix("4.3.0", "available")).toBe(" · 4.3.0 available"); + expect(updateSuffix("4.3.0", "installed")).toBe(" · 4.3.0 installed, restart"); + const notice = (state: Parameters[0]["state"]) => + updateNotice({ + hostName: "Pi", + command: "pi update npm:om-memory-system", + current: "4.2.0", + update: "4.3.0", + state, + }); + expect(notice("available")).toBe( + "om-memory-system 4.3.0 is available (running 4.2.0). Run: pi update npm:om-memory-system, then restart Pi." + ); + expect(notice("installing")).toBe("om-memory-system 4.3.0 is installing in the background."); + expect(notice("installed")).toBe("om-memory-system 4.3.0 installed. Restart Pi to use it."); +}); + +it("watches the marker every interval until it settles, for at most the limit", async () => { + let status: "pending" | "installed" | "failed" | null = "pending"; + let reads = 0; + const settled: string[] = []; + const stop = watchHostUpdate({ + read: () => { + reads++; + return status; + }, + onSettled: (value) => settled.push(value), + intervalMs: 5, + maxMs: 1_000, + }); + expect(reads).toBe(1); + await Bun.sleep(12); + status = "installed"; + await Bun.sleep(15); + const readsAtSettle = reads; + await Bun.sleep(15); + expect(settled).toEqual(["installed"]); + expect(reads).toBe(readsAtSettle); + stop(); +}); + +it("settles at once when the marker already holds an outcome", () => { + const settled: string[] = []; + watchHostUpdate({ + read: () => "failed", + onSettled: (value) => settled.push(value), + intervalMs: 5, + maxMs: 1_000, + }); + expect(settled).toEqual(["failed"]); +}); + +it("stops watching after the limit and on stop", async () => { + let reads = 0; + watchHostUpdate({ + read: () => { + reads++; + return "pending"; + }, + onSettled: () => {}, + intervalMs: 5, + maxMs: 12, + }); + await Bun.sleep(40); + const afterLimit = reads; + await Bun.sleep(20); + expect(reads).toBe(afterLimit); + + let stoppedReads = 0; + const stop = watchHostUpdate({ + read: () => { + stoppedReads++; + return "pending"; + }, + onSettled: () => {}, + intervalMs: 5, + maxMs: 1_000, + }); + stop(); + await Bun.sleep(20); + expect(stoppedReads).toBe(1); +}); From 1ee17d766f9d8e1e2aac709e6f07a32c95d1caec Mon Sep 17 00:00:00 2001 From: Aizat Hawari Date: Sat, 7 Nov 2026 23:13:33 +0000 Subject: [PATCH 2/7] feat: install OMMS updates in the background for Pi and OpenCode Pi and OpenCode V2 now run their own update command when npm has a newer release, then show " installed, restart" in the footer and one message. Pinned and local installs keep the "available" notice with the command. OMMS_DISABLE_UPDATE_CHECK=1 turns both off. --- UPDATES.md | 28 +- docs/ci.md | 5 +- docs/opencode-adapter.md | 5 +- docs/pi-adapter.md | 2 + docs/upgrading.md | 49 ++- .../changes/pi-opencode-auto-update/tasks.md | 22 +- src/adapters/opencode/tui-status.ts | 123 ++++++- src/adapters/pi/extension.ts | 23 +- src/adapters/pi/self-update.ts | 186 ++++++++++ tests/opencode-tui-status.test.ts | 172 +++++++++ tests/pi-extension.test.ts | 81 +++++ tests/pi-self-update.test.ts | 341 ++++++++++++++++++ 12 files changed, 990 insertions(+), 47 deletions(-) create mode 100644 src/adapters/pi/self-update.ts create mode 100644 tests/pi-self-update.test.ts diff --git a/UPDATES.md b/UPDATES.md index 4527a404..fd7131c1 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -1,19 +1,33 @@ # How OMMS updates -OMMS is one npm package, `om-memory-system`. Each host keeps its own copy of it. Pi and OpenCode never install an update by themselves. Claude Code does only when you turn on auto-update for the `omms` marketplace. Otherwise you choose when to update, then restart the host. You update one host. Every other part of OMMS on the machine then runs that copy. See [One update updates every host](#one-update-updates-every-host). +OMMS is one npm package, `om-memory-system`. Each host keeps its own copy of it. Pi and OpenCode install each new release by themselves, then ask you to restart. Claude Code does the same through its session-start hook. Claude Code's marketplace auto-update is a separate setting that you can turn on. You update one host. Every other part of OMMS on the machine runs that copy. See [One update updates every host](#one-update-updates-every-host). | Host | How you hear about a new release | Update with | Then | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | -| Pi | Pi shows an update notice when it starts | `pi update npm:om-memory-system` | Restart Pi | -| OpenCode v2 | The footer shows `omms:connected · 4.3.0 available`, and a toast tells you the command. `opencode plugin check` also lists it | `opencode plugin update om-memory-system` | Restart OpenCode, including its background server | +| Pi | OMMS installs it in the background. The footer shows `omms: ✔ · 4.3.0 installed, restart`, and one message tells you to restart Pi | `pi update npm:om-memory-system` (OMMS runs it for you) | Restart Pi | +| OpenCode v2 | OMMS installs it in the background. The footer shows `omms: ✔ · 4.3.0 installed, restart`, and one message tells you to restart OpenCode. `opencode plugin check` also lists new releases | `opencode plugin update om-memory-system` (OMMS runs it for you) | Restart OpenCode, including its background server | | Claude Code | The status line shows `omms: connected · 4.4.0 available`, and a toast tells you the command. `claude plugin list` shows the installed version. Auto-update is off until you turn it on for the `omms` marketplace | `claude plugin update omms@omms` | Run `/reload-plugins` or start a new session | | Web app and terminal command | The sidebar shows the running version. An **Update** button appears in the sidebar footer when npm has a newer release. The Settings page **Web app** card shows the global version | Nothing. They run the newest copy. Or select **Update**, then **Update web app**: it installs the release globally with npm and restarts the web app on it. A global install from 4.3 or earlier needs one `npm i -g om-memory-system@latest` | The next host start replaces an older web app | -## Why OpenCode needs a manual update +## How Pi and OpenCode update -Your OpenCode config names the plugin without a version: `"om-memory-system"`. That means "latest". OpenCode resolves "latest" once, saves that version in `~/.cache/opencode/npm/`, and keeps using the saved copy. It does not ask npm again at the next start. Run `opencode plugin update om-memory-system` to fetch the new release. That command reads `~/.config/opencode/cli.json`, so add `"plugins": ["om-memory-system"]` there too. See [OpenCode setup](docs/opencode-adapter.md). +When a Pi or OpenCode V2 session starts, OMMS compares its version with npm `latest`. OpenCode checks again every 6 hours. When npm has a newer release that is not a prerelease, OMMS runs the host update command in the background. A second session does not start another update for the same release within 30 minutes. -OMMS 4.3.0 and later check npm when OpenCode starts and then every 6 hours. When a newer release exists, the footer and a toast say so. Set `OMMS_DISABLE_UPDATE_CHECK=1` to turn the check off. +While the update installs, OMMS shows `om-memory-system is installing in the background.` When it ends: + +- Success: the footer shows `omms: ✔ · installed, restart`, and one message tells you to restart the host. +- Failure: the footer shows `· available`, and one message names the update command. Run it yourself. + +You must restart Pi or OpenCode to load the new copy. OMMS never restarts the host. OpenCode's background server can keep the old copy in memory until it restarts. + +OMMS does not run the update in two cases. The notice then shows ` available` and the command to run. + +- A local checkout. OMMS does not load from a `node_modules/om-memory-system/` folder. +- A pinned version. For Pi, that is a `packages` entry `npm:om-memory-system@` or a settings file that OMMS cannot read. See [Stay on one version](#stay-on-one-version). + +Your OpenCode config names the plugin without a version: `"om-memory-system"`. That means "latest". OpenCode resolves "latest" once and saves that version in `~/.cache/opencode/npm/`. The OMMS update refreshes that copy. The `opencode plugin update` command reads `~/.config/opencode/cli.json`, so add `"plugins": ["om-memory-system"]` there too. See [OpenCode setup](docs/opencode-adapter.md). + +Set `OMMS_DISABLE_UPDATE_CHECK=1` to turn off the npm check and the update. The check sends no session content. ## Claude Code updates @@ -41,6 +55,6 @@ The login web app runs one copy of OMMS for every host. The login item runs the ## Stay on one version -Install with a version number, for example `pi install npm:om-memory-system@4.2.0` or `opencode plugin add om-memory-system@4.2.0`. OpenCode skips exact versions during `plugin check` and `plugin update`. OMMS can still show its own newer-release notice for a pinned OpenCode install. Set `OMMS_DISABLE_UPDATE_CHECK=1` to turn that notice off. +Install with a version number, for example `pi install npm:om-memory-system@4.2.0` or `opencode plugin add om-memory-system@4.2.0`. OpenCode skips exact versions during `plugin check` and `plugin update`. OMMS does not update a pinned install in the background. It can still show its own newer-release notice. Set `OMMS_DISABLE_UPDATE_CHECK=1` to turn that notice off. More detail: [Updating and upgrading](docs/upgrading.md). diff --git a/docs/ci.md b/docs/ci.md index 6b0c8042..8e1f7914 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -370,8 +370,9 @@ Versions come from commit messages. Use `feat:` (minor), `fix:` (patch), registry. It reads the stage ID from the newest GitHub Release note. If npm does not show the version within 150 seconds, the script stops. Wait a few minutes and run it again. It does not approve a second time. -7. Users with marketplace auto-update get the plugin at their next check. Users - on an unpinned npm install get an update notice. +7. Users with marketplace auto-update get the plugin at their next check. Pi and + OpenCode users on an unpinned npm install get the release installed in the + background and a restart notice. If you approve another way, run `bun run release:approve` again. It sees the version on npm, skips the approval, and moves `stable`. Do not wait for the diff --git a/docs/opencode-adapter.md b/docs/opencode-adapter.md index d9885d07..addea457 100644 --- a/docs/opencode-adapter.md +++ b/docs/opencode-adapter.md @@ -149,7 +149,10 @@ OpenCode 2.0.22 and later load the plugin's `./tui` entry. It shows OMMS's state - `omms: ⧖` at start, then `omms: ✔` when the web app answers on `webServerPort`. - `omms: ✘ web app off` when the web app does not answer. Capture and retrieval keep working. - `omms: ✘` when the footer cannot start its status check. -- `· available` when npm has a newer release. A toast also names the update command once per start. The plugin checks npm at start and every 6 hours. Set `OMMS_DISABLE_UPDATE_CHECK=1` to turn the check off. +- `· available` when npm has a newer release and OMMS does not install it (local checkout, pinned version, or a failed update). A toast also names the update command once per start. +- `· installed, restart` after OMMS installs the newer release in the background. A toast tells you to restart OpenCode. While the update installs, a toast says so. + +The plugin checks npm at start and every 6 hours. When npm has a newer release, it runs `opencode plugin update om-memory-system` in the background. Set `OMMS_DISABLE_UPDATE_CHECK=1` to turn the check and the update off. See [Updating and upgrading](upgrading.md#updating-omms). The footer reads only the health route and the npm registry's `latest` version. It sends nothing about your sessions. diff --git a/docs/pi-adapter.md b/docs/pi-adapter.md index 1add5dd5..2b8bbc35 100644 --- a/docs/pi-adapter.md +++ b/docs/pi-adapter.md @@ -117,6 +117,8 @@ The Pi adapter shows its current state in Pi's footer as `omms: `: - `omms: ✎` while settled work is processed - `omms: ✘` when an OMMS operation fails +When npm has a newer release, the label adds a suffix after the glyph, for example `omms: ✔ · 4.3.0 installed, restart`. At session start, OMMS runs `pi update npm:om-memory-system` in the background, and one notification tells you to restart Pi when the install ends. If the update fails, or OMMS runs from a local checkout or a pinned version, the suffix is `· available` and the notification names the command. Set `OMMS_DISABLE_UPDATE_CHECK=1` to turn the check and the update off. See [Updating and upgrading](upgrading.md#updating-omms). + The adapter clears the status when the Pi session shuts down. ### Recall rows diff --git a/docs/upgrading.md b/docs/upgrading.md index 6e50a3bf..60597909 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -5,17 +5,43 @@ This page covers updates, pinning a version, and older stores. ## Updating OMMS Install OMMS without a version number, as the [README](../README.md#set-up) -shows. Your agent can then tell you when a new release is out. Neither agent -installs updates by itself. You choose when to update. +shows. Pi and OpenCode then install each new release by themselves. -| Agent | How you hear about a new release | Update with | -| ----------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -| Pi | Pi shows an update notice while you work | `pi update npm:om-memory-system` (or `pi update --extensions` for all) | -| OpenCode v2 | The footer shows `omms: ✔ · available` and a toast names the command. `opencode plugin check` also lists it | `opencode plugin update om-memory-system` (or `opencode plugin update`) | +When a Pi or OpenCode session starts, OMMS compares its version with npm +`latest`. If npm has a newer release (not a prerelease), OMMS runs the host +update command in the background. OpenCode also checks every 6 hours. A second +session does not start another update for the same release within 30 minutes. + +| Agent | Command that OMMS runs. You can run it by hand too. | +| ----------- | ----------------------------------------------------------------------- | +| Pi | `pi update npm:om-memory-system` (or `pi update --extensions` for all) | +| OpenCode v2 | `opencode plugin update om-memory-system` (or `opencode plugin update`) | + +While the update installs, OMMS shows one message: `om-memory-system +is installing in the background.` When it ends, the OMMS footer label shows one +of these: + +- `omms: ✔ · installed, restart`, with one message that tells you to + restart the agent. The new copy loads only after you restart Pi or OpenCode. + OMMS never restarts the agent. OpenCode's background service can keep the old + copy in memory until it restarts. +- `omms: ✔ · available`, with one message that names the update + command. This means the background update failed. Run the command yourself. + +OMMS does not run the update for these installs. The notice shows ` +available` and the command to run: + +- A local checkout. OMMS does not load from a `node_modules/om-memory-system/` + folder. +- A pinned version. See [Pinning a version](#pinning-a-version). + +Set `OMMS_DISABLE_UPDATE_CHECK=1` to turn off both the npm check and the +update. The check reads the npm registry. It sends no session content. +`opencode plugin check` also lists a new OpenCode release. The web page tells you too. Its sidebar header shows the running version. When npm has a newer release, an **Update** button shows in the sidebar footer on this computer. Its dialog lists the command for each host. **Update web app** installs the release globally with npm and restarts the web app on it. See [Web UI: Update button](web-ui.md#update-button). -Restart the agent after you update. OpenCode resolves "latest" once and keeps that copy in `~/.cache/opencode/npm/`, so it does not update by itself. Set `OMMS_DISABLE_UPDATE_CHECK=1` to stop the npm check. [UPDATES.md](../UPDATES.md) has a one-page summary for every host. +[UPDATES.md](../UPDATES.md) is a one-page summary for every host. ### Claude Code @@ -88,9 +114,12 @@ To stay on one version, install it with the version number: - OpenCode: `opencode plugin add om-memory-system@3.1.1` OpenCode skips exact versions during `plugin check` and `plugin update`. -OMMS can still show its own newer-release notice for a pinned OpenCode install. -Set `OMMS_DISABLE_UPDATE_CHECK=1` to turn that notice off. To receive updates -again, install without the number. +OMMS also does not update a pinned install in the background. It can still show +its own newer-release notice. Set `OMMS_DISABLE_UPDATE_CHECK=1` to turn that +notice off. To receive updates again, install without the number. + +OMMS treats these Pi installs as pinned: a `packages` entry of +`npm:om-memory-system@`, or a Pi settings file that OMMS cannot read. ### Trying unreleased changes (`next`) diff --git a/openspec/changes/pi-opencode-auto-update/tasks.md b/openspec/changes/pi-opencode-auto-update/tasks.md index daed9b45..218b28f4 100644 --- a/openspec/changes/pi-opencode-auto-update/tasks.md +++ b/openspec/changes/pi-opencode-auto-update/tasks.md @@ -7,25 +7,25 @@ ## 2. OpenCode V2 -- [ ] 2.1 Write failing tests in the `tui-status` test file for: update started (installing toast), installed (footer `· installed, restart` and restart toast), failed (command toast), pinned or local path (today's text). -- [ ] 2.2 Wire `src/adapters/opencode/tui-status.ts` to the shared module: start the update after the npm check, poll the marker every 60 seconds for at most 10 minutes, change footer and toast text. -- [ ] 2.3 Log one `OpenCode plugin self-update` record with code and versions on start or failed start. No prompt or reply text. +- [x] 2.1 Write failing tests in the `tui-status` test file for: update started (installing toast), installed (footer `· installed, restart` and restart toast), failed (command toast), pinned or local path (today's text). +- [x] 2.2 Wire `src/adapters/opencode/tui-status.ts` to the shared module: start the update after the npm check, poll the marker every 60 seconds for at most 10 minutes, change footer and toast text. +- [x] 2.3 Log one `OpenCode plugin self-update` record with code and versions on start or failed start. No prompt or reply text. ## 3. Pi -- [ ] 3.1 Write failing tests for the Pi adapter: start on `session_start`, no delay to `session_start`, 30-minute de-dup, restart notification, failed notification, suffix kept for `↻` and `✎`, no label without a user interface, poll stops on `session_shutdown`. -- [ ] 3.2 Wire `src/adapters/pi/extension.ts` to the shared module. Keep the adapter thin. Add the suffix to the status label. -- [ ] 3.3 Log one `Pi plugin self-update` record with code and versions on start or failed start. +- [x] 3.1 Write failing tests for the Pi adapter: start on `session_start`, no delay to `session_start`, 30-minute de-dup, restart notification, failed notification, suffix kept for `↻` and `✎`, no label without a user interface, poll stops on `session_shutdown`. +- [x] 3.2 Wire `src/adapters/pi/extension.ts` to the shared module. Keep the adapter thin. Add the suffix to the status label. +- [x] 3.3 Log one `Pi plugin self-update` record with code and versions on start or failed start. ## 4. Docs -- [ ] 4.1 Update `docs/upgrading.md`: Pi and OpenCode install updates by themselves, then ask for a restart; pinned and local installs do not; `OMMS_DISABLE_UPDATE_CHECK=1` turns it off. -- [ ] 4.2 Update `UPDATES.md` to match. -- [ ] 4.3 Check `README.md` and `docs/` for text that says Pi or OpenCode never update by themselves, and fix each hit. +- [x] 4.1 Update `docs/upgrading.md`: Pi and OpenCode install updates by themselves, then ask for a restart; pinned and local installs do not; `OMMS_DISABLE_UPDATE_CHECK=1` turns it off. +- [x] 4.2 Update `UPDATES.md` to match. +- [x] 4.3 Check `README.md` and `docs/` for text that says Pi or OpenCode never update by themselves, and fix each hit. ## 5. Verify -- [ ] 5.1 Run each new and changed test file with `bash scripts/run-tests-isolated.sh `. Break the code once and confirm the tests fail. -- [ ] 5.2 Run `bun run check` and confirm the file-size ceiling test passes for `tui-status.ts` and `extension.ts`. +- [x] 5.1 Run each new and changed test file with `bash scripts/run-tests-isolated.sh `. Break the code once and confirm the tests fail. +- [x] 5.2 Run `bun run check` and confirm the file-size ceiling test passes for `tui-status.ts` and `extension.ts`. - [ ] 5.3 Live check in a scratch HOME with its own store: install an older OMMS in Pi and in OpenCode, start a session, confirm the update command runs once, the marker says `installed`, and the footer shows `installed, restart`. Restart and confirm the new version loads. - [ ] 5.4 Run `bun run ci:local` and read the exit line. diff --git a/src/adapters/opencode/tui-status.ts b/src/adapters/opencode/tui-status.ts index d6a075d5..30900844 100644 --- a/src/adapters/opencode/tui-status.ts +++ b/src/adapters/opencode/tui-status.ts @@ -1,15 +1,37 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + hostSelfUpdateDeps, + installedFromNpm, + markerStatus, + readMarkerFile, + startHostSelfUpdate, + unpinnedCacheFolder, + updateNotice, + updateSuffix, + watchHostUpdate, + type HostSelfUpdateResult, + type HostUpdateStatus, +} from "../../services/host-self-update.js"; import { ommsStatusLabel } from "../../services/status-glyphs.js"; import { availableUpdate, latestNpmVersion } from "../../services/update-check.js"; +const UPDATE_COMMAND = "opencode plugin update om-memory-system"; + /** What the OpenCode footer shows: the web app's state and a newer release, if any. */ export interface TuiStatusState { web: "warming" | "connected" | "offline"; update: string | null; + /** "installed" once the background update has finished; otherwise "available". */ + updateState?: "available" | "installed"; } export function tuiStatusText(state: TuiStatusState): string { const web = ommsStatusLabel(state.web === "offline" ? "web app off" : state.web); - return state.update ? `${web} · ${state.update} available` : web; + return state.update + ? `${web}${updateSuffix(state.update, state.updateState ?? "available")}` + : web; } export interface TuiStatusDeps { @@ -17,21 +39,29 @@ export interface TuiStatusDeps { healthUrl: string; fetch: typeof fetch; setText: (text: string) => void; - /** Shown once per start when a newer release is on npm. */ + /** Shown once per update state and version. */ notify: (message: string) => void; checkUpdates?: boolean; healthIntervalMs?: number; updateIntervalMs?: number; + /** Reads npm and starts the background update. Without it the check only reads npm. */ + selfUpdate?: () => Promise; + /** The state the update marker records for a version. */ + readUpdateStatus?: (version: string) => HostUpdateStatus | null; + watchIntervalMs?: number; + watchLimitMs?: number; } /** * Keep the footer text current: the web app is polled, and npm is asked for a - * newer release at start and then every few hours. Returns a stop function. + * newer release at start and then every few hours. A newer release installs in + * the background when the host allows it. Returns a stop function. */ export function startTuiStatus(deps: TuiStatusDeps): () => void { const state: TuiStatusState = { web: "warming", update: null }; let stopped = false; - let notified: string | null = null; + let stopWatch: (() => void) | undefined; + const shown = new Set(); const show = () => { if (!stopped) deps.setText(tuiStatusText(state)); }; @@ -46,15 +76,63 @@ export function startTuiStatus(deps: TuiStatusDeps): () => void { show(); }; - const checkUpdate = async () => { - const update = availableUpdate(deps.currentVersion, await latestNpmVersion(deps.fetch)); - state.update = update; + /** One toast per update state and version. */ + const toast = (update: string, notice: "available" | "installing" | "installed") => { + const key = `${notice}:${update}`; + if (stopped || shown.has(key)) return; + shown.add(key); + deps.notify( + updateNotice({ + hostName: "OpenCode", + command: UPDATE_COMMAND, + current: deps.currentVersion, + update, + state: notice, + }) + ); + }; + + const settle = (update: string, outcome: "installed" | "failed") => { + state.updateState = outcome === "installed" ? "installed" : "available"; show(); - if (update && notified !== update && !stopped) { - notified = update; - deps.notify( - `om-memory-system ${update} is available (running ${deps.currentVersion}). Run: opencode plugin update om-memory-system, then restart OpenCode.` - ); + toast(update, outcome === "installed" ? "installed" : "available"); + }; + + const selfUpdate = + deps.selfUpdate ?? + (async (): Promise => ({ + code: "local", + update: availableUpdate(deps.currentVersion, await latestNpmVersion(deps.fetch)), + })); + + const checkUpdate = async () => { + try { + // The restart installs the release; there is nothing more to ask npm. + if (state.updateState === "installed") return; + const { code, update } = await selfUpdate(); + state.update = update; + state.updateState = "available"; + show(); + if (!update || stopped) return; + if (code !== "started" && code !== "recent") return toast(update, "available"); + const status = (deps.readUpdateStatus ?? (() => null))(update); + if (status === "installed" || status === "failed") return settle(update, status); + toast(update, "installing"); + stopWatch?.(); + stopWatch = watchHostUpdate({ + read: () => (deps.readUpdateStatus ?? (() => null))(update), + onSettled: (outcome) => { + try { + settle(update, outcome); + } catch { + // A failed toast must not break the timer. + } + }, + intervalMs: deps.watchIntervalMs, + maxMs: deps.watchLimitMs, + }); + } catch { + // A failed update check must not break the footer. } }; @@ -68,6 +146,7 @@ export function startTuiStatus(deps: TuiStatusDeps): () => void { for (const timer of timers) timer.unref?.(); return () => { stopped = true; + stopWatch?.(); for (const timer of timers) clearInterval(timer); }; } @@ -76,14 +155,30 @@ export function startTuiStatus(deps: TuiStatusDeps): () => void { export async function startInstalledTuiStatus( callbacks: Pick ): Promise<() => void> { - const [{ CONFIG }, { packageVersion }] = await Promise.all([ + const [{ CONFIG }, { packageVersion }, { log }] = await Promise.all([ import("../../config.js"), import("../../services/package-version.js"), + import("../../services/logger.js"), ]); const host = CONFIG.webServerHost === "0.0.0.0" ? "127.0.0.1" : CONFIG.webServerHost; + const currentVersion = packageVersion(); + const modulePath = fileURLToPath(import.meta.url); + const markerPath = join(homedir(), ".omms", "opencode-plugin-update.json"); + const update = hostSelfUpdateDeps({ + logLabel: "OpenCode plugin self-update", + currentVersion, + command: "opencode", + args: ["plugin", "update", "om-memory-system"], + markerPath, + installSource: () => + !installedFromNpm(modulePath) ? "local" : unpinnedCacheFolder(modulePath) ? "npm" : "pinned", + log: (message, data) => log(message, data), + }); return startTuiStatus({ ...callbacks, - currentVersion: packageVersion(), + currentVersion, + selfUpdate: () => startHostSelfUpdate(update), + readUpdateStatus: (version) => markerStatus(readMarkerFile(markerPath), version), healthUrl: `http://${host.includes(":") ? `[${host}]` : host}:${CONFIG.webServerPort}/api/health`, fetch: globalThis.fetch.bind(globalThis), checkUpdates: process.env.OMMS_DISABLE_UPDATE_CHECK !== "1", diff --git a/src/adapters/pi/extension.ts b/src/adapters/pi/extension.ts index 51257d2e..75320016 100644 --- a/src/adapters/pi/extension.ts +++ b/src/adapters/pi/extension.ts @@ -21,6 +21,7 @@ import { registerPiSkillsTool } from "./skills-tool.js"; import { performPiProfileLearning } from "./profile.js"; import { createPiRecallRows } from "./recall-rows.js"; import { createPiSessionContext } from "./session-context.js"; +import { createPiSelfUpdate } from "./self-update.js"; import { DEFAULT_RETRIEVAL_MAX_TOKENS, tokensToByteCeiling } from "../../core/context-budget.js"; import { ommsStatusLabel, type StatusGlyphState } from "../../services/status-glyphs.js"; @@ -92,12 +93,21 @@ export default function ommsPiExtension(pi: ExtensionAPI): void { let latestCtx: ExtensionContext | null = null; let backfillSessionId: string | null = null; let statusVersion = 0; + // The glyph on screen, so a new update suffix can redraw the label. Null once cleared. + let shownStatus: StatusGlyphState | null = null; + const selfUpdate = createPiSelfUpdate(); const recallRows = createPiRecallRows(); const sessionContext = createPiSessionContext(recallRows); + // Every label carries the update suffix, so it stays when the glyph changes. + const drawStatus = (ctx: ExtensionContext, status: StatusGlyphState): void => { + shownStatus = status; + if (ctx.hasUI) ctx.ui.setStatus(OMMS_STATUS_KEY, ommsStatusLabel(status) + selfUpdate.suffix()); + }; + const startStatus = (ctx: ExtensionContext, status: StatusGlyphState): number => { const version = ++statusVersion; - if (ctx.hasUI) ctx.ui.setStatus(OMMS_STATUS_KEY, ommsStatusLabel(status)); + drawStatus(ctx, status); return version; }; @@ -107,11 +117,12 @@ export default function ommsPiExtension(pi: ExtensionAPI): void { status: "connected" | "error" ): void => { if (version !== statusVersion || !ctx.hasUI) return; - ctx.ui.setStatus(OMMS_STATUS_KEY, ommsStatusLabel(status)); + drawStatus(ctx, status); }; const clearStatus = (ctx: ExtensionContext): void => { statusVersion++; + shownStatus = null; if (ctx.hasUI) ctx.ui.setStatus(OMMS_STATUS_KEY, undefined); }; @@ -130,6 +141,13 @@ export default function ommsPiExtension(pi: ExtensionAPI): void { pi.on("session_start", async (event, ctx) => { const status = startStatus(ctx, "warming"); + // The update check does not delay the session and cannot stop memory work. + void selfUpdate.start( + { hasUI: ctx.hasUI, notify: (message, level) => ctx.ui.notify(message, level) }, + () => { + if (shownStatus) drawStatus(ctx, shownStatus); + } + ); try { latestCtx = ctx; initConfigWithLegacyMigration(ctx.cwd); @@ -368,6 +386,7 @@ export default function ommsPiExtension(pi: ExtensionAPI): void { }); pi.on("session_shutdown", async (_event, ctx) => { + selfUpdate.stop(); clearStatus(ctx); recallRows.reset(ctx); sessionContext.reset(); diff --git a/src/adapters/pi/self-update.ts b/src/adapters/pi/self-update.ts new file mode 100644 index 00000000..b9df97de --- /dev/null +++ b/src/adapters/pi/self-update.ts @@ -0,0 +1,186 @@ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + hostSelfUpdateDeps, + installedFromNpm, + markerStatus, + startHostSelfUpdate, + unpinnedPackageSource, + updateNotice, + updateSuffix, + watchHostUpdate, + type HostInstallSource, + type HostSelfUpdateDeps, + type HostSelfUpdateResult, +} from "../../services/host-self-update.js"; +import { log } from "../../services/logger.js"; +import { packageVersion } from "../../services/package-version.js"; + +// Pi does not update its packages by itself. At session start OMMS runs +// `pi update npm:om-memory-system` in the background when npm has a newer +// release, then tells the user to restart. The decision lives in +// `src/services/host-self-update.ts`; this file adds the Pi wording, the +// footer suffix and the install check. + +const PI_UPDATE_ARGS = ["update", "npm:om-memory-system"]; +const PI_UPDATE_COMMAND = `pi ${PI_UPDATE_ARGS.join(" ")}`; + +/** What the update needs from one Pi session. */ +export interface PiUpdateSession { + hasUI: boolean; + notify: (message: string, level: "info" | "warning") => void; +} + +export interface PiSelfUpdate { + /** Check npm and start the update. Never rejects. The caller does not wait for it. */ + start(session: PiUpdateSession, onSuffixChange: () => void): Promise; + /** Footer text to add after the status glyph, or an empty string. */ + suffix(): string; + /** Stop the marker poll and drop the session. Keeps the suffix for the next session. */ + stop(): void; +} + +export interface PiSelfUpdateOptions { + hostDeps?: () => HostSelfUpdateDeps; + watch?: typeof watchHostUpdate; + log?: (message: string, data: Record, level?: "error") => void; +} + +/** Pi's settings file lists the package source. `PI_CODING_AGENT_DIR` moves Pi's folder. */ +function readPiSettings(env: NodeJS.ProcessEnv): string | null { + const dir = env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"); + try { + return readFileSync(join(dir, "settings.json"), "utf8"); + } catch { + return null; + } +} + +/** Production inputs for Pi: npm, the Pi marker file and a detached `pi update`. */ +export function piHostSelfUpdateDeps( + options: { env?: NodeJS.ProcessEnv; modulePath?: string } = {} +): HostSelfUpdateDeps { + const env = options.env ?? process.env; + const modulePath = options.modulePath ?? fileURLToPath(import.meta.url); + const installSource = (): HostInstallSource => { + if (!installedFromNpm(modulePath)) return "local"; + return unpinnedPackageSource(readPiSettings(env)) ? "npm" : "pinned"; + }; + return hostSelfUpdateDeps({ + logLabel: "Pi plugin self-update", + currentVersion: packageVersion(), + command: "pi", + args: PI_UPDATE_ARGS, + markerPath: join(homedir(), ".omms", "pi-plugin-update.json"), + installSource, + log, + env, + }); +} + +export function createPiSelfUpdate(options: PiSelfUpdateOptions = {}): PiSelfUpdate { + const hostDeps = options.hostDeps ?? (() => piHostSelfUpdateDeps()); + const watch = options.watch ?? watchHostUpdate; + const logError = options.log ?? log; + // One notification per state per version for the life of the Pi process. + const notified = new Set(); + let suffix = ""; + let stopWatch: (() => void) | null = null; + let generation = 0; + let current: { session: PiUpdateSession; onSuffixChange: () => void } | null = null; + // Sessions that start while a check runs share it, so one release spawns one update. + let inFlight: Promise | null = null; + // Once installed, only a restart helps: a later session must not run the update again. + let installed = false; + + const attempt = (action: () => void): void => { + try { + action(); + } catch { + // A stale Pi context must not stop the update path. + } + }; + + const notifyOnce = (key: string, message: string, level: "info" | "warning"): void => { + if (!current || notified.has(key)) return; + notified.add(key); + const { session } = current; + attempt(() => session.notify(message, level)); + }; + + const show = ( + deps: HostSelfUpdateDeps, + update: string, + state: "available" | "installing" | "installed" + ): void => { + if (state === "installed") installed = true; + if (state !== "installing") { + const next = updateSuffix(update, state); + if (next !== suffix) { + suffix = next; + const change = current?.onSuffixChange; + if (change) attempt(change); + } + } + const text = updateNotice({ + hostName: "Pi", + command: PI_UPDATE_COMMAND, + current: deps.currentVersion, + update, + state, + }); + notifyOnce(`${state}:${update}`, text, state === "available" ? "warning" : "info"); + }; + + return { + async start(session, onSuffixChange) { + const mine = ++generation; + stopWatch?.(); + stopWatch = null; + current = session.hasUI ? { session, onSuffixChange } : null; + if (installed) return; + try { + const deps = hostDeps(); + inFlight ??= startHostSelfUpdate(deps).finally(() => { + inFlight = null; + }); + const { code, update } = await inFlight; + // No interface, no label or notice. A shutdown or newer session replaces this one. + if (!update || !session.hasUI || mine !== generation) return; + if (code !== "started" && code !== "recent") { + show(deps, update, "available"); + return; + } + const read = () => markerStatus(deps.readMarker(), update); + const status = read(); + if (status === "installed") show(deps, update, "installed"); + else if (status === "failed") show(deps, update, "available"); + else { + show(deps, update, "installing"); + stopWatch = watch({ + read, + onSettled: (outcome) => { + if (mine === generation) + show(deps, update, outcome === "installed" ? "installed" : "available"); + }, + }); + } + } catch (error) { + logError( + "Pi plugin self-update failed", + { code: error instanceof Error ? error.name : "unknown" }, + "error" + ); + } + }, + suffix: () => suffix, + stop() { + generation++; + stopWatch?.(); + stopWatch = null; + current = null; + }, + }; +} diff --git a/tests/opencode-tui-status.test.ts b/tests/opencode-tui-status.test.ts index 07b8472c..ba19bee4 100644 --- a/tests/opencode-tui-status.test.ts +++ b/tests/opencode-tui-status.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { fileURLToPath } from "node:url"; import { startTuiStatus, tuiStatusText } from "../src/adapters/opencode/tui-status.js"; +import type { HostSelfUpdateResult, HostUpdateStatus } from "../src/services/host-self-update.js"; import { availableUpdate, latestNpmVersion } from "../src/services/update-check.js"; const json = (body: unknown, status = 200) => @@ -95,3 +96,174 @@ describe("OpenCode footer status", () => { expect(calls.every((url) => !url.includes("registry"))).toBe(true); }); }); + +const COMMAND = "opencode plugin update om-memory-system"; +const AVAILABLE_TEXT = `om-memory-system 4.3.0 is available (running 4.2.0). Run: ${COMMAND}, then restart OpenCode.`; + +/** Start the status with a fake self-update and a fake marker; nothing touches npm, a process or ~/.omms. */ +function selfUpdateHarness( + options: { + result?: HostSelfUpdateResult | (() => Promise); + status?: { value: HostUpdateStatus | null; reads: number }; + updateIntervalMs?: number; + } = {} +) { + const texts: string[] = []; + const notes: string[] = []; + const status = options.status ?? { value: null, reads: 0 }; + const result = options.result ?? { code: "started" as const, update: "4.3.0" }; + const calls = { selfUpdate: 0 }; + const stop = startTuiStatus({ + currentVersion: "4.2.0", + healthUrl: "http://127.0.0.1:4747/api/health", + fetch: (async () => json({ status: "ok" })) as any, + setText: (text) => texts.push(text), + notify: (message) => notes.push(message), + selfUpdate: async () => { + calls.selfUpdate += 1; + return typeof result === "function" ? result() : result; + }, + readUpdateStatus: () => { + status.reads += 1; + return status.value; + }, + watchIntervalMs: 5, + watchLimitMs: 2_000, + updateIntervalMs: options.updateIntervalMs, + }); + return { texts, notes, status, calls, stop }; +} + +describe("OpenCode footer background update", () => { + it("says the update is installing, then asks for a restart when it installs", async () => { + const h = selfUpdateHarness({ status: { value: "pending", reads: 0 } }); + await settle(); + expect(h.notes).toEqual(["om-memory-system 4.3.0 is installing in the background."]); + expect(h.texts.at(-1)).toBe("omms: ✔ · 4.3.0 available"); + h.status.value = "installed"; + await settle(); + h.stop(); + expect(h.texts.at(-1)).toBe("omms: ✔ · 4.3.0 installed, restart"); + expect(h.notes).toHaveLength(2); + expect(h.notes[1]).toBe("om-memory-system 4.3.0 installed. Restart OpenCode to use it."); + }); + + it("shows installed at once when another session already finished the update", async () => { + const h = selfUpdateHarness({ + result: { code: "recent", update: "4.3.0" }, + status: { value: "installed", reads: 0 }, + }); + await settle(); + h.stop(); + expect(h.texts.at(-1)).toBe("omms: ✔ · 4.3.0 installed, restart"); + expect(h.notes).toEqual(["om-memory-system 4.3.0 installed. Restart OpenCode to use it."]); + }); + + it("names the command when the update has already failed", async () => { + const h = selfUpdateHarness({ status: { value: "failed", reads: 0 } }); + await settle(); + h.stop(); + expect(h.texts.at(-1)).toBe("omms: ✔ · 4.3.0 available"); + expect(h.notes).toEqual([AVAILABLE_TEXT]); + }); + + it("names the command when a pending update later fails", async () => { + const h = selfUpdateHarness({ status: { value: "pending", reads: 0 } }); + await settle(); + h.status.value = "failed"; + await settle(); + h.stop(); + expect(h.texts.at(-1)).toBe("omms: ✔ · 4.3.0 available"); + expect(h.notes).toEqual([ + "om-memory-system 4.3.0 is installing in the background.", + AVAILABLE_TEXT, + ]); + }); + + for (const code of ["local", "pinned", "spawn-failed"] as const) { + it(`keeps today's text when the install source is ${code}`, async () => { + const h = selfUpdateHarness({ result: { code, update: "4.3.0" } }); + await settle(); + h.stop(); + expect(h.texts.at(-1)).toBe("omms: ✔ · 4.3.0 available"); + expect(h.notes).toEqual([AVAILABLE_TEXT]); + expect(h.status.reads).toBe(0); + }); + } + + it("shows no notice when there is nothing newer", async () => { + const h = selfUpdateHarness({ result: { code: "current", update: null } }); + await settle(); + h.stop(); + expect(h.texts.at(-1)).toBe("omms: ✔"); + expect(h.notes).toEqual([]); + }); + + it("does not repeat a toast on the recheck", async () => { + const h = selfUpdateHarness({ + result: { code: "pinned", update: "4.3.0" }, + updateIntervalMs: 15, + }); + await new Promise((resolve) => setTimeout(resolve, 80)); + h.stop(); + expect(h.calls.selfUpdate).toBeGreaterThan(2); + expect(h.notes).toEqual([AVAILABLE_TEXT]); + }); + + it("stops asking once the release is installed", async () => { + const h = selfUpdateHarness({ + result: { code: "recent", update: "4.3.0" }, + status: { value: "installed", reads: 0 }, + updateIntervalMs: 15, + }); + await new Promise((resolve) => setTimeout(resolve, 80)); + h.stop(); + expect(h.calls.selfUpdate).toBe(1); + expect(h.notes).toHaveLength(1); + }); + + it("stops the marker watcher with the status", async () => { + const h = selfUpdateHarness({ status: { value: "pending", reads: 0 } }); + await settle(); + h.stop(); + const reads = h.status.reads; + expect(reads).toBeGreaterThan(0); + h.status.value = "installed"; + await settle(); + expect(h.status.reads).toBe(reads); + expect(h.notes).toEqual(["om-memory-system 4.3.0 is installing in the background."]); + }); + + it("keeps the footer working when the update path throws", async () => { + const h = selfUpdateHarness({ + result: async () => { + throw new Error("boom"); + }, + }); + await settle(); + h.stop(); + expect(h.texts.at(-1)).toBe("omms: ✔"); + expect(h.notes).toEqual([]); + }); + + it("does not start an update when the check is turned off", async () => { + const texts: string[] = []; + let called = false; + const stop = startTuiStatus({ + currentVersion: "4.2.0", + healthUrl: "http://127.0.0.1:4747/api/health", + fetch: (async () => json({ status: "ok" })) as any, + setText: (text) => texts.push(text), + notify: () => {}, + checkUpdates: false, + selfUpdate: async () => { + called = true; + return { code: "started", update: "4.3.0" }; + }, + }); + await settle(); + stop(); + expect(called).toBe(false); + expect(texts.at(-1)).toBe("omms: ✔"); + }); +}); diff --git a/tests/pi-extension.test.ts b/tests/pi-extension.test.ts index ecbcebd2..a7f74d30 100644 --- a/tests/pi-extension.test.ts +++ b/tests/pi-extension.test.ts @@ -17,6 +17,7 @@ const autostartUrl = new URL("../src/services/web-autostart.js", import.meta.url const ensureUrl = new URL("../src/services/web-ensure.js", import.meta.url).href; const handoffUrl = new URL("../src/services/runtime-handoff.js", import.meta.url).href; const clientUrl = new URL("../src/services/client.js", import.meta.url).href; +const selfUpdateUrl = new URL("../src/adapters/pi/self-update.js", import.meta.url).href; const configUrl = new URL("../src/config.js", import.meta.url).href; const tagsUrl = new URL("../src/services/tags.js", import.meta.url).href; const loggerUrl = new URL("../src/services/logger.js", import.meta.url).href; @@ -200,6 +201,23 @@ mock.module(${JSON.stringify(profileManagerUrl)}, () => ({ }, })); +// The real module asks npm and may start Pi's update command. A scenario drives this fake instead. +const selfUpdateCalls = []; +let selfUpdateSuffix = ""; +let selfUpdateChanged = () => {}; +mock.module(${JSON.stringify(selfUpdateUrl)}, () => ({ + createPiSelfUpdate: () => ({ + start: (session, onChange) => { + selfUpdateCalls.push(["start", session.hasUI]); + selfUpdateChanged = onChange; + // Never settles: a session start must not wait for the update. + return new Promise(() => {}); + }, + suffix: () => selfUpdateSuffix, + stop: () => selfUpdateCalls.push(["stop"]), + }), +})); + const skillOperationCalls = []; mock.module(${JSON.stringify(skillOperationsUrl)}, () => ({ runSkillOperation: async (mode, args, deps) => { @@ -584,6 +602,69 @@ captured = statusCalls; ]); }); + it("starts the self-update at session start without waiting for it", () => { + const output = runScenario(` +await handlers["session_start"]({}, makeCtx()); +captured = selfUpdateCalls; +`); + expect(output.captured).toEqual([["start", true]]); + }); + + it("starts the self-update with no user interface, and stops it at shutdown", () => { + const output = runScenario(` +const ctx = makeCtx({ hasUI: false }); +await handlers["session_start"]({}, ctx); +await handlers["session_shutdown"]({}, ctx); +captured = selfUpdateCalls; +`); + expect(output.captured).toEqual([["start", false], ["stop"]]); + }); + + it("keeps the update suffix when the glyph changes to recalling and capturing", () => { + const output = runScenario(` +selfUpdateSuffix = " · 4.3.0 installed, restart"; +stubConfig.autoCaptureEnabled = true; +await handlers["before_agent_start"]( + { prompt: "how do we run the queue?", systemPrompt: "BASE PROMPT" }, + makeCtx() +); +await handlers["agent_settled"]({}, makeCtx()); +captured = statusCalls.map((call) => call[1]); +`); + expect(output.captured.slice(0, 2)).toEqual([ + "omms: ↻ · 4.3.0 installed, restart", + "omms: ✔ · 4.3.0 installed, restart", + ]); + expect(output.captured).toContain("omms: ✎ · 4.3.0 installed, restart"); + expect( + output.captured.every((label: string) => label.endsWith(" · 4.3.0 installed, restart")) + ).toBe(true); + }); + + it("refreshes the label with the current glyph when the update suffix changes", () => { + const output = runScenario(` +await handlers["session_start"]({}, makeCtx()); +await new Promise((resolve) => setTimeout(resolve, 0)); +selfUpdateSuffix = " · 4.3.0 installed, restart"; +selfUpdateChanged(); +captured = statusCalls.map((call) => call[1]); +`); + expect(output.captured).toEqual(["omms: ⧖", "omms: ✔", "omms: ✔ · 4.3.0 installed, restart"]); + }); + + it("does not redraw the label after the session shut down", () => { + const output = runScenario(` +const ctx = makeCtx(); +await handlers["session_start"]({}, ctx); +await new Promise((resolve) => setTimeout(resolve, 0)); +await handlers["session_shutdown"]({}, ctx); +selfUpdateSuffix = " · 4.3.0 available"; +selfUpdateChanged(); +captured = statusCalls.map((call) => call[1]); +`); + expect(output.captured).toEqual(["omms: ⧖", "omms: ✔", null]); + }); + it("shows recalling then connected during retrieval", async () => { const output = runScenario(` await handlers["before_agent_start"]( diff --git a/tests/pi-self-update.test.ts b/tests/pi-self-update.test.ts new file mode 100644 index 00000000..7ef4495b --- /dev/null +++ b/tests/pi-self-update.test.ts @@ -0,0 +1,341 @@ +import { afterEach, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createPiSelfUpdate, + piHostSelfUpdateDeps, + type PiUpdateSession, +} from "../src/adapters/pi/self-update.js"; +import { + type HostSelfUpdateDeps, + type HostUpdateMarker, + type HostUpdateStatus, +} from "../src/services/host-self-update.js"; +import { packageVersion } from "../src/services/package-version.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +const COMMAND = "pi update npm:om-memory-system"; + +/** Host deps at `current` with npm `latest`. Nothing touches npm, a process or the real home. */ +function fakeHost(options: { + current?: string; + latest?: string | null; + enabled?: boolean; + source?: "npm" | "local" | "pinned"; + marker?: HostUpdateMarker | null; +}) { + const spawned: { command: string; args: string[] }[] = []; + const logs: { message: string; data: Record }[] = []; + let marker = options.marker ?? null; + const deps: HostSelfUpdateDeps = { + enabled: options.enabled ?? true, + logLabel: "Pi plugin self-update", + currentVersion: options.current ?? "4.2.0", + command: "pi", + args: ["update", "npm:om-memory-system"], + markerPath: "/fake/.omms/pi-plugin-update.json", + installSource: () => options.source ?? "npm", + fetch: (async () => { + if (options.latest === null) throw new Error("offline"); + return new Response(JSON.stringify({ version: options.latest ?? "4.3.0" }), { status: 200 }); + }) as unknown as typeof fetch, + now: () => 1_000_000, + readMarker: () => marker, + writeMarker: (value) => { + marker = value; + }, + spawnDetached: (command, args) => spawned.push({ command, args }), + log: (message, data) => logs.push({ message, data }), + }; + return { deps, spawned, logs, setMarker: (value: HostUpdateMarker | null) => (marker = value) }; +} + +/** A controller with a fake poll, plus the messages and label changes it produced. */ +function setup(options: Parameters[0] = {}, hasUI = true) { + const host = fakeHost(options); + const notices: { message: string; level: string }[] = []; + const watches: { + read: () => HostUpdateStatus | null; + onSettled: (status: "installed" | "failed") => void; + stopped: boolean; + }[] = []; + const errors: { message: string; data: unknown }[] = []; + let changes = 0; + const update = createPiSelfUpdate({ + hostDeps: () => host.deps, + watch: (watchOptions) => { + const entry = { ...watchOptions, stopped: false }; + watches.push(entry); + return () => { + entry.stopped = true; + }; + }, + log: (message, data) => errors.push({ message, data }), + }); + const session: PiUpdateSession = { + hasUI, + notify: (message, level) => notices.push({ message, level }), + }; + const start = () => + update.start(session, () => { + changes++; + }); + return { host, notices, watches, errors, update, start, changes: () => changes }; +} + +it("starts the Pi update command in the background when npm has a newer release", async () => { + const t = setup(); + await t.start(); + expect(t.host.spawned).toHaveLength(1); + expect(t.host.spawned[0]!.args.slice(-3)).toEqual(["pi", "update", "npm:om-memory-system"]); + expect(t.host.logs).toEqual([ + { + message: "Pi plugin self-update", + data: { code: "started", current: "4.2.0", latest: "4.3.0" }, + }, + ]); +}); + +it("does not start a second update for the same release within 30 minutes", async () => { + const t = setup(); + await t.start(); + t.update.stop(); + await t.start(); + expect(t.host.spawned).toHaveLength(1); +}); + +it("tells the user once that the update is installing, and polls the marker", async () => { + const t = setup(); + await t.start(); + t.update.stop(); + await t.start(); + expect(t.notices).toEqual([ + { message: "om-memory-system 4.3.0 is installing in the background.", level: "info" }, + ]); + expect(t.watches).toHaveLength(2); + expect(t.update.suffix()).toBe(""); +}); + +it("shows the restart suffix and one restart notification when the update installs", async () => { + const t = setup(); + await t.start(); + t.watches[0]!.onSettled("installed"); + expect(t.update.suffix()).toBe(" · 4.3.0 installed, restart"); + expect(t.changes()).toBe(1); + expect(t.notices.map((n) => n.message)).toEqual([ + "om-memory-system 4.3.0 is installing in the background.", + "om-memory-system 4.3.0 installed. Restart Pi to use it.", + ]); +}); + +it("shows the available suffix and names the command when the update fails", async () => { + const t = setup(); + await t.start(); + t.watches[0]!.onSettled("failed"); + expect(t.update.suffix()).toBe(" · 4.3.0 available"); + expect(t.notices.at(-1)!.message).toContain(COMMAND); + expect(t.notices.at(-1)!.level).toBe("warning"); +}); + +it("reads a finished marker at once, without polling", async () => { + const done = setup({ + marker: { version: "4.3.0", startedAt: 999_000, outcome: "installed", finishedAt: 999_500 }, + }); + await done.start(); + expect(done.update.suffix()).toBe(" · 4.3.0 installed, restart"); + expect(done.notices.map((n) => n.message)).toEqual([ + "om-memory-system 4.3.0 installed. Restart Pi to use it.", + ]); + expect(done.watches).toHaveLength(0); + expect(done.host.spawned).toHaveLength(0); + + const failed = setup({ + marker: { version: "4.3.0", startedAt: 999_000, outcome: "failed", finishedAt: 999_500 }, + }); + await failed.start(); + expect(failed.update.suffix()).toBe(" · 4.3.0 available"); + expect(failed.notices.map((n) => n.message)).toEqual([expect.stringContaining(COMMAND)]); +}); + +it("names the command once for a local checkout or a pinned version, and starts nothing", async () => { + for (const source of ["local", "pinned"] as const) { + const t = setup({ source }); + await t.start(); + t.update.stop(); + await t.start(); + expect(t.host.spawned).toHaveLength(0); + expect(t.update.suffix()).toBe(" · 4.3.0 available"); + expect(t.notices).toHaveLength(1); + expect(t.notices[0]!.message).toContain(COMMAND); + } +}); + +it("shows an available notice when the update command cannot start", async () => { + const t = setup(); + t.host.deps.spawnDetached = () => { + throw new Error("ENOENT"); + }; + await t.start(); + expect(t.host.logs[0]!.data.code).toBe("spawn-failed"); + expect(t.update.suffix()).toBe(" · 4.3.0 available"); + expect(t.notices[0]!.message).toContain(COMMAND); +}); + +it("shows nothing when OMMS is current, npm is unreachable or the check is off", async () => { + for (const options of [{ latest: "4.2.0" }, { latest: null }, { enabled: false }]) { + const t = setup(options); + await t.start(); + expect(t.update.suffix()).toBe(""); + expect(t.notices).toEqual([]); + expect(t.host.spawned).toEqual([]); + } +}); + +it("still runs the update without a user interface, with no label, notice or poll", async () => { + const t = setup({}, false); + await t.start(); + expect(t.host.spawned).toHaveLength(1); + expect(t.update.suffix()).toBe(""); + expect(t.notices).toEqual([]); + expect(t.watches).toEqual([]); + expect(t.changes()).toBe(0); +}); + +it("stops the poll when the session shuts down", async () => { + const t = setup(); + await t.start(); + expect(t.watches[0]!.stopped).toBe(false); + t.update.stop(); + expect(t.watches[0]!.stopped).toBe(true); +}); + +it("ignores a poll result that arrives after the session shut down", async () => { + const t = setup(); + await t.start(); + t.update.stop(); + t.watches[0]!.onSettled("installed"); + expect(t.update.suffix()).toBe(""); + expect(t.notices).toHaveLength(1); +}); + +it("keeps the suffix across sessions and does not tell the user twice", async () => { + const t = setup(); + await t.start(); + t.watches[0]!.onSettled("installed"); + t.update.stop(); + await t.start(); + expect(t.update.suffix()).toBe(" · 4.3.0 installed, restart"); + expect(t.notices.filter((n) => n.message.includes("Restart Pi"))).toHaveLength(1); +}); + +it("never throws into the session when the update path fails", async () => { + const t = setup(); + const broken = createPiSelfUpdate({ + hostDeps: () => { + throw new Error("secret detail /Users/someone/path"); + }, + log: (message, data) => t.errors.push({ message, data }), + }); + await broken.start({ hasUI: true, notify: () => {} }, () => {}); + expect(t.errors).toEqual([{ message: "Pi plugin self-update failed", data: { code: "Error" } }]); +}); + +it("does not fail when a notification or label change throws", async () => { + const t = setup(); + await t.update.start( + { + hasUI: true, + notify: () => { + throw new Error("stale context"); + }, + }, + () => { + throw new Error("stale context"); + } + ); + t.watches[0]!.onSettled("installed"); + expect(t.update.suffix()).toBe(" · 4.3.0 installed, restart"); + expect(t.errors).toEqual([]); +}); + +it("builds production inputs for Pi: label, command, marker file and version", () => { + const deps = piHostSelfUpdateDeps({ env: {} }); + expect(deps.logLabel).toBe("Pi plugin self-update"); + expect(deps.command).toBe("pi"); + expect(deps.args).toEqual(["update", "npm:om-memory-system"]); + expect(deps.markerPath).toBe(join(homedir(), ".omms", "pi-plugin-update.json")); + expect(deps.currentVersion).toBe(packageVersion()); + expect(deps.enabled).toBe(true); +}); + +it("turns the update off with OMMS_DISABLE_UPDATE_CHECK=1", () => { + expect(piHostSelfUpdateDeps({ env: { OMMS_DISABLE_UPDATE_CHECK: "1" } }).enabled).toBe(false); +}); + +it("finds the install source from the module path and Pi's settings file", () => { + const dir = mkdtempSync(join(tmpdir(), "omms-pi-self-update-")); + tempDirs.push(dir); + const settings = (packages: unknown) => + writeFileSync(join(dir, "settings.json"), JSON.stringify({ packages })); + const npmPath = "/home/u/.pi/agent/npm/node_modules/om-memory-system/dist/adapters/pi/x.js"; + const sourceFor = (modulePath: string) => + piHostSelfUpdateDeps({ env: { PI_CODING_AGENT_DIR: dir }, modulePath }).installSource(); + + expect(sourceFor("/work/omms/src/adapters/pi/self-update.ts")).toBe("local"); + expect(sourceFor(npmPath)).toBe("pinned"); + settings(["npm:om-memory-system"]); + expect(sourceFor(npmPath)).toBe("npm"); + settings(["npm:om-memory-system@4.2.0"]); + expect(sourceFor(npmPath)).toBe("pinned"); +}); + +it("reads Pi's settings file from ~/.pi/agent when PI_CODING_AGENT_DIR is not set", () => { + const agentDir = join(homedir(), ".pi", "agent"); + mkdirSync(agentDir, { recursive: true }); + tempDirs.push(join(homedir(), ".pi")); + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ packages: [{ source: "npm:om-memory-system" }] }) + ); + const npmPath = "/home/u/.pi/agent/npm/node_modules/om-memory-system/dist/x.js"; + expect(piHostSelfUpdateDeps({ env: {}, modulePath: npmPath }).installSource()).toBe("npm"); +}); + +it("does not run the update again in a later session once it installed", async () => { + const t = setup(); + await t.start(); + t.watches[0]!.onSettled("installed"); + // Long after the 30-minute de-dup, in the same Pi process with no restart. + const longAgo = 1_000_000 - 31 * 60 * 1000; + t.host.setMarker({ + version: "4.3.0", + startedAt: longAgo, + outcome: "installed", + finishedAt: longAgo, + }); + t.update.stop(); + await t.start(); + expect(t.host.spawned).toHaveLength(1); + expect(t.update.suffix()).toBe(" · 4.3.0 installed, restart"); +}); + +it("shares one check between sessions that start while it runs", async () => { + const t = setup(); + let fetches = 0; + const realFetch = t.host.deps.fetch; + t.host.deps.fetch = (async (...args: Parameters) => { + fetches++; + return realFetch(...args); + }) as typeof fetch; + const first = t.start(); + t.update.stop(); + await Promise.all([first, t.start()]); + expect(fetches).toBe(1); + expect(t.host.spawned).toHaveLength(1); +}); From 420247e8f94a0f72affdf85555a4a4cf5e06d60b Mon Sep 17 00:00:00 2001 From: Aizat Hawari Date: Sat, 7 Nov 2026 23:35:24 +0000 Subject: [PATCH 3/7] fix: detect OpenCode 2.2 cache folders and name the service restart OpenCode 2.2 adds a timestamp folder before node_modules, so every unpinned install looked pinned and never updated. The OpenCode restart notice now names opencode service restart, because the background service keeps the old copy loaded. Both found in the live check. --- UPDATES.md | 14 +++++++------- docs/opencode-adapter.md | 2 +- docs/upgrading.md | 4 ++-- .../changes/pi-opencode-auto-update/design.md | 8 +++++--- .../specs/opencode-v2-adapter/spec.md | 11 ++++++++++- .../changes/pi-opencode-auto-update/tasks.md | 2 +- src/adapters/opencode/tui-status.ts | 3 +++ src/services/host-self-update.ts | 13 +++++++++---- tests/host-self-update.test.ts | 19 +++++++++++++++++++ tests/opencode-tui-status.test.ts | 10 +++++++--- 10 files changed, 64 insertions(+), 22 deletions(-) diff --git a/UPDATES.md b/UPDATES.md index fd7131c1..5c232c3e 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -2,12 +2,12 @@ OMMS is one npm package, `om-memory-system`. Each host keeps its own copy of it. Pi and OpenCode install each new release by themselves, then ask you to restart. Claude Code does the same through its session-start hook. Claude Code's marketplace auto-update is a separate setting that you can turn on. You update one host. Every other part of OMMS on the machine runs that copy. See [One update updates every host](#one-update-updates-every-host). -| Host | How you hear about a new release | Update with | Then | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | -| Pi | OMMS installs it in the background. The footer shows `omms: ✔ · 4.3.0 installed, restart`, and one message tells you to restart Pi | `pi update npm:om-memory-system` (OMMS runs it for you) | Restart Pi | -| OpenCode v2 | OMMS installs it in the background. The footer shows `omms: ✔ · 4.3.0 installed, restart`, and one message tells you to restart OpenCode. `opencode plugin check` also lists new releases | `opencode plugin update om-memory-system` (OMMS runs it for you) | Restart OpenCode, including its background server | -| Claude Code | The status line shows `omms: connected · 4.4.0 available`, and a toast tells you the command. `claude plugin list` shows the installed version. Auto-update is off until you turn it on for the `omms` marketplace | `claude plugin update omms@omms` | Run `/reload-plugins` or start a new session | -| Web app and terminal command | The sidebar shows the running version. An **Update** button appears in the sidebar footer when npm has a newer release. The Settings page **Web app** card shows the global version | Nothing. They run the newest copy. Or select **Update**, then **Update web app**: it installs the release globally with npm and restarts the web app on it. A global install from 4.3 or earlier needs one `npm i -g om-memory-system@latest` | The next host start replaces an older web app | +| Host | How you hear about a new release | Update with | Then | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| Pi | OMMS installs it in the background. The footer shows `omms: ✔ · 4.3.0 installed, restart`, and one message tells you to restart Pi | `pi update npm:om-memory-system` (OMMS runs it for you) | Restart Pi | +| OpenCode v2 | OMMS installs it in the background. The footer shows `omms: ✔ · 4.3.0 installed, restart`, and one message tells you to restart OpenCode and run `opencode service restart`. `opencode plugin check` also lists new releases | `opencode plugin update om-memory-system` (OMMS runs it for you) | Restart OpenCode, then run `opencode service restart` | +| Claude Code | The status line shows `omms: connected · 4.4.0 available`, and a toast tells you the command. `claude plugin list` shows the installed version. Auto-update is off until you turn it on for the `omms` marketplace | `claude plugin update omms@omms` | Run `/reload-plugins` or start a new session | +| Web app and terminal command | The sidebar shows the running version. An **Update** button appears in the sidebar footer when npm has a newer release. The Settings page **Web app** card shows the global version | Nothing. They run the newest copy. Or select **Update**, then **Update web app**: it installs the release globally with npm and restarts the web app on it. A global install from 4.3 or earlier needs one `npm i -g om-memory-system@latest` | The next host start replaces an older web app | ## How Pi and OpenCode update @@ -18,7 +18,7 @@ While the update installs, OMMS shows `om-memory-system is installing - Success: the footer shows `omms: ✔ · installed, restart`, and one message tells you to restart the host. - Failure: the footer shows `· available`, and one message names the update command. Run it yourself. -You must restart Pi or OpenCode to load the new copy. OMMS never restarts the host. OpenCode's background server can keep the old copy in memory until it restarts. +You must restart Pi or OpenCode to load the new copy. OMMS never restarts the host. OpenCode's background server keeps the old copy in memory. For OpenCode, also run `opencode service restart`. OMMS does not run the update in two cases. The notice then shows ` available` and the command to run. diff --git a/docs/opencode-adapter.md b/docs/opencode-adapter.md index addea457..1aebd89d 100644 --- a/docs/opencode-adapter.md +++ b/docs/opencode-adapter.md @@ -150,7 +150,7 @@ OpenCode 2.0.22 and later load the plugin's `./tui` entry. It shows OMMS's state - `omms: ✘ web app off` when the web app does not answer. Capture and retrieval keep working. - `omms: ✘` when the footer cannot start its status check. - `· available` when npm has a newer release and OMMS does not install it (local checkout, pinned version, or a failed update). A toast also names the update command once per start. -- `· installed, restart` after OMMS installs the newer release in the background. A toast tells you to restart OpenCode. While the update installs, a toast says so. +- `· installed, restart` after OMMS installs the newer release in the background. A toast tells you to restart OpenCode and run `opencode service restart`, because the background service keeps the old copy loaded. While the update installs, a toast says so. The plugin checks npm at start and every 6 hours. When npm has a newer release, it runs `opencode plugin update om-memory-system` in the background. Set `OMMS_DISABLE_UPDATE_CHECK=1` to turn the check and the update off. See [Updating and upgrading](upgrading.md#updating-omms). diff --git a/docs/upgrading.md b/docs/upgrading.md index 60597909..6f818758 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -23,8 +23,8 @@ of these: - `omms: ✔ · installed, restart`, with one message that tells you to restart the agent. The new copy loads only after you restart Pi or OpenCode. - OMMS never restarts the agent. OpenCode's background service can keep the old - copy in memory until it restarts. + OMMS never restarts the agent. OpenCode's background service keeps the old + copy in memory. For OpenCode, also run `opencode service restart`. - `omms: ✔ · available`, with one message that names the update command. This means the background update failed. Run the command yourself. diff --git a/openspec/changes/pi-opencode-auto-update/design.md b/openspec/changes/pi-opencode-auto-update/design.md index 482f0b5e..f42f64f9 100644 --- a/openspec/changes/pi-opencode-auto-update/design.md +++ b/openspec/changes/pi-opencode-auto-update/design.md @@ -9,7 +9,7 @@ Pi has no OMMS update check. The Pi adapter shows a status label (`ctx.ui.setSta Both hosts install OMMS from npm into their own folders: - Pi: `~/.pi/agent/npm/node_modules/om-memory-system/`, source `npm:om-memory-system` in `~/.pi/agent/settings.json`. -- OpenCode: `~/.cache/opencode/npm/om-memory-system@/node_modules/om-memory-system/`. The `@` part is `latest` unless the user pinned a version. +- OpenCode: `~/.cache/opencode/npm/om-memory-system@/node_modules/om-memory-system/`. The `@` part is `latest` unless the user pinned a version. OpenCode 2.2 adds a timestamp folder before `node_modules` (`om-memory-system@latest//node_modules/`), found in the live check (task 5.3). A running host keeps the loaded copy in memory. A new copy loads only after a restart. Past updates (4.27.0, 4.28.0) showed that OpenCode's background service keeps the old copy until it restarts. @@ -51,7 +51,7 @@ OMMS runs the update only when all are true: - `OMMS_DISABLE_UPDATE_CHECK` is not `1`. - The running module file sits under a `node_modules/om-memory-system/` folder. A local path, such as a dev checkout, skips the update. -- The install is not pinned. OpenCode: the cache folder name ends in `@latest` or has no version. Pi: the `packages` entry in `~/.pi/agent/settings.json` is `npm:om-memory-system` with no `@`. A settings file that cannot be read counts as pinned, so OMMS does nothing. +- The install is not pinned. OpenCode: the cache folder name ends in `@latest` or has no version. An all-digit timestamp folder below it is skipped. Pi: the `packages` entry in `~/.pi/agent/settings.json` is `npm:om-memory-system` with no `@`. A settings file that cannot be read counts as pinned, so OMMS does nothing. When the update is skipped for a pin or a local path, the notice keeps today's "available, run " text. @@ -62,7 +62,9 @@ When the update is skipped for a pin or a local path, the notice keeps today's " | Newer, update skipped | `· available` | names the update command | | Newer, update running | `· available` | "om-memory-system is installing in the background" | | Update installed | `· installed, restart` | " installed. Restart to use it." | -| Update failed | `· available` | names the update command | + +OpenCode's restart text also names `opencode service restart`: the live check (task 5.3) showed that the background service keeps the old copy after the TUI restarts. +| Update failed | `· available` | names the update command | Pi's suffix is added to the status label after the glyph, so `↻` and `✎` keep the suffix. diff --git a/openspec/changes/pi-opencode-auto-update/specs/opencode-v2-adapter/spec.md b/openspec/changes/pi-opencode-auto-update/specs/opencode-v2-adapter/spec.md index e2cd5e58..f7f23cba 100644 --- a/openspec/changes/pi-opencode-auto-update/specs/opencode-v2-adapter/spec.md +++ b/openspec/changes/pi-opencode-auto-update/specs/opencode-v2-adapter/spec.md @@ -61,10 +61,19 @@ When a background update installs the newer release, the footer SHALL show `· < - **WHEN** the background update for 4.3.0 exits with success - **THEN** the footer SHALL show `omms: ✔ · 4.3.0 installed, restart` -- **AND** one toast SHALL tell the user to restart OpenCode +- **AND** one toast SHALL tell the user to restart OpenCode and run `opencode service restart` #### Scenario: The background update fails - **WHEN** the background update for 4.3.0 exits with an error - **THEN** the footer SHALL show `omms: ✔ · 4.3.0 available` - **AND** one toast SHALL name `opencode plugin update om-memory-system` + +### Requirement: The OpenCode restart notice names the background service + +OpenCode's background service keeps the old OMMS copy loaded after the TUI restarts. The restart toast SHALL name `opencode service restart`. + +#### Scenario: The restart toast names the service command + +- **WHEN** the background update for 4.3.0 exits with success +- **THEN** the toast SHALL name `opencode service restart` diff --git a/openspec/changes/pi-opencode-auto-update/tasks.md b/openspec/changes/pi-opencode-auto-update/tasks.md index 218b28f4..8c326e3b 100644 --- a/openspec/changes/pi-opencode-auto-update/tasks.md +++ b/openspec/changes/pi-opencode-auto-update/tasks.md @@ -27,5 +27,5 @@ - [x] 5.1 Run each new and changed test file with `bash scripts/run-tests-isolated.sh `. Break the code once and confirm the tests fail. - [x] 5.2 Run `bun run check` and confirm the file-size ceiling test passes for `tui-status.ts` and `extension.ts`. -- [ ] 5.3 Live check in a scratch HOME with its own store: install an older OMMS in Pi and in OpenCode, start a session, confirm the update command runs once, the marker says `installed`, and the footer shows `installed, restart`. Restart and confirm the new version loads. +- [x] 5.3 Live check in a scratch HOME with its own store: install an older OMMS in Pi and in OpenCode, start a session, confirm the update command runs once, the marker says `installed`, and the footer shows `installed, restart`. Restart and confirm the new version loads. - [ ] 5.4 Run `bun run ci:local` and read the exit line. diff --git a/src/adapters/opencode/tui-status.ts b/src/adapters/opencode/tui-status.ts index 30900844..c8ff754e 100644 --- a/src/adapters/opencode/tui-status.ts +++ b/src/adapters/opencode/tui-status.ts @@ -18,6 +18,8 @@ import { ommsStatusLabel } from "../../services/status-glyphs.js"; import { availableUpdate, latestNpmVersion } from "../../services/update-check.js"; const UPDATE_COMMAND = "opencode plugin update om-memory-system"; +// The background service keeps the old copy loaded until it restarts too. +const RESTART = "restart OpenCode and run opencode service restart"; /** What the OpenCode footer shows: the web app's state and a newer release, if any. */ export interface TuiStatusState { @@ -85,6 +87,7 @@ export function startTuiStatus(deps: TuiStatusDeps): () => void { updateNotice({ hostName: "OpenCode", command: UPDATE_COMMAND, + restart: RESTART, current: deps.currentVersion, update, state: notice, diff --git a/src/services/host-self-update.ts b/src/services/host-self-update.ts index b196f84a..8136f48e 100644 --- a/src/services/host-self-update.ts +++ b/src/services/host-self-update.ts @@ -225,13 +225,15 @@ export function installedFromNpm(modulePath: string): boolean { /** * True when the module sits in a host package cache folder named * `om-memory-system` or `om-memory-system@latest`. Any other folder, such as - * one with a version, counts as pinned. + * one with a version, counts as pinned. OpenCode 2.2 adds a timestamp folder + * between that folder and `node_modules`. */ export function unpinnedCacheFolder(modulePath: string): boolean { const parts = modulePath.replaceAll("\\", "/").split("/"); const index = parts.lastIndexOf("node_modules"); if (index < 1 || parts[index + 1] !== "om-memory-system") return false; - const folder = parts[index - 1]; + const above = parts[index - 1]!; + const folder = /^\d+$/.test(above) ? parts[index - 2] : above; return folder === "om-memory-system" || folder === "om-memory-system@latest"; } @@ -264,15 +266,18 @@ export function updateSuffix(update: string, state: "available" | "installed"): export function updateNotice(options: { hostName: string; command: string; + /** How to restart the host, in lower case. Defaults to `restart `. */ + restart?: string; current: string; update: string; state: "available" | "installing" | "installed"; }): string { const { hostName, command, current, update, state } = options; + const restart = options.restart ?? `restart ${hostName}`; if (state === "installing") return `om-memory-system ${update} is installing in the background.`; if (state === "installed") - return `om-memory-system ${update} installed. Restart ${hostName} to use it.`; - return `om-memory-system ${update} is available (running ${current}). Run: ${command}, then restart ${hostName}.`; + return `om-memory-system ${update} installed. ${restart[0]!.toUpperCase()}${restart.slice(1)} to use it.`; + return `om-memory-system ${update} is available (running ${current}). Run: ${command}, then ${restart}.`; } /** Production inputs: npm, the marker file, and a detached script runner. */ diff --git a/tests/host-self-update.test.ts b/tests/host-self-update.test.ts index 612d4ec4..07c18916 100644 --- a/tests/host-self-update.test.ts +++ b/tests/host-self-update.test.ts @@ -230,6 +230,10 @@ it("treats an OpenCode cache folder as unpinned only for latest or no version", expect(unpinnedCacheFolder(path("om-memory-system"))).toBe(true); expect(unpinnedCacheFolder(path("om-memory-system@4.2.0"))).toBe(false); expect(unpinnedCacheFolder(path("om-memory-system@^4"))).toBe(false); + // OpenCode 2.2 puts a timestamp folder between the spec folder and node_modules. + expect(unpinnedCacheFolder(path("om-memory-system@latest/1794091868583"))).toBe(true); + expect(unpinnedCacheFolder(path("om-memory-system@4.2.0/1794091868583"))).toBe(false); + expect(unpinnedCacheFolder(path("other@latest/1794091868583"))).toBe(false); expect(unpinnedCacheFolder("/home/u/.config/opencode/node_modules/om-memory-system/x.js")).toBe( false ); @@ -264,6 +268,21 @@ it("words the footer suffix and the one-time notices for each state", () => { ); expect(notice("installing")).toBe("om-memory-system 4.3.0 is installing in the background."); expect(notice("installed")).toBe("om-memory-system 4.3.0 installed. Restart Pi to use it."); + const service = (state: "available" | "installed") => + updateNotice({ + hostName: "OpenCode", + command: "opencode plugin update om-memory-system", + restart: "restart OpenCode and run opencode service restart", + current: "4.2.0", + update: "4.3.0", + state, + }); + expect(service("installed")).toBe( + "om-memory-system 4.3.0 installed. Restart OpenCode and run opencode service restart to use it." + ); + expect(service("available")).toBe( + "om-memory-system 4.3.0 is available (running 4.2.0). Run: opencode plugin update om-memory-system, then restart OpenCode and run opencode service restart." + ); }); it("watches the marker every interval until it settles, for at most the limit", async () => { diff --git a/tests/opencode-tui-status.test.ts b/tests/opencode-tui-status.test.ts index ba19bee4..fdc4ca64 100644 --- a/tests/opencode-tui-status.test.ts +++ b/tests/opencode-tui-status.test.ts @@ -98,7 +98,7 @@ describe("OpenCode footer status", () => { }); const COMMAND = "opencode plugin update om-memory-system"; -const AVAILABLE_TEXT = `om-memory-system 4.3.0 is available (running 4.2.0). Run: ${COMMAND}, then restart OpenCode.`; +const AVAILABLE_TEXT = `om-memory-system 4.3.0 is available (running 4.2.0). Run: ${COMMAND}, then restart OpenCode and run opencode service restart.`; /** Start the status with a fake self-update and a fake marker; nothing touches npm, a process or ~/.omms. */ function selfUpdateHarness( @@ -145,7 +145,9 @@ describe("OpenCode footer background update", () => { h.stop(); expect(h.texts.at(-1)).toBe("omms: ✔ · 4.3.0 installed, restart"); expect(h.notes).toHaveLength(2); - expect(h.notes[1]).toBe("om-memory-system 4.3.0 installed. Restart OpenCode to use it."); + expect(h.notes[1]).toBe( + "om-memory-system 4.3.0 installed. Restart OpenCode and run opencode service restart to use it." + ); }); it("shows installed at once when another session already finished the update", async () => { @@ -156,7 +158,9 @@ describe("OpenCode footer background update", () => { await settle(); h.stop(); expect(h.texts.at(-1)).toBe("omms: ✔ · 4.3.0 installed, restart"); - expect(h.notes).toEqual(["om-memory-system 4.3.0 installed. Restart OpenCode to use it."]); + expect(h.notes).toEqual([ + "om-memory-system 4.3.0 installed. Restart OpenCode and run opencode service restart to use it.", + ]); }); it("names the command when the update has already failed", async () => { From f25c15fbb0b734bbec66bf6a71099253867167d5 Mon Sep 17 00:00:00 2001 From: Aizat Hawari Date: Sat, 7 Nov 2026 23:41:25 +0000 Subject: [PATCH 4/7] chore: archive the pi-opencode-auto-update change and sync its specs --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/opencode-v2-adapter/spec.md | 11 ++- .../specs/pi-agent-adapter/spec.md | 0 .../tasks.md | 2 +- openspec/specs/opencode-v2-adapter/spec.md | 72 ++++++++++++++++++- openspec/specs/pi-agent-adapter/spec.md | 68 ++++++++++++++++++ 8 files changed, 150 insertions(+), 3 deletions(-) rename openspec/changes/{pi-opencode-auto-update => archive/2026-11-07-pi-opencode-auto-update}/.openspec.yaml (100%) rename openspec/changes/{pi-opencode-auto-update => archive/2026-11-07-pi-opencode-auto-update}/design.md (100%) rename openspec/changes/{pi-opencode-auto-update => archive/2026-11-07-pi-opencode-auto-update}/proposal.md (100%) rename openspec/changes/{pi-opencode-auto-update => archive/2026-11-07-pi-opencode-auto-update}/specs/opencode-v2-adapter/spec.md (88%) rename openspec/changes/{pi-opencode-auto-update => archive/2026-11-07-pi-opencode-auto-update}/specs/pi-agent-adapter/spec.md (100%) rename openspec/changes/{pi-opencode-auto-update => archive/2026-11-07-pi-opencode-auto-update}/tasks.md (98%) diff --git a/openspec/changes/pi-opencode-auto-update/.openspec.yaml b/openspec/changes/archive/2026-11-07-pi-opencode-auto-update/.openspec.yaml similarity index 100% rename from openspec/changes/pi-opencode-auto-update/.openspec.yaml rename to openspec/changes/archive/2026-11-07-pi-opencode-auto-update/.openspec.yaml diff --git a/openspec/changes/pi-opencode-auto-update/design.md b/openspec/changes/archive/2026-11-07-pi-opencode-auto-update/design.md similarity index 100% rename from openspec/changes/pi-opencode-auto-update/design.md rename to openspec/changes/archive/2026-11-07-pi-opencode-auto-update/design.md diff --git a/openspec/changes/pi-opencode-auto-update/proposal.md b/openspec/changes/archive/2026-11-07-pi-opencode-auto-update/proposal.md similarity index 100% rename from openspec/changes/pi-opencode-auto-update/proposal.md rename to openspec/changes/archive/2026-11-07-pi-opencode-auto-update/proposal.md diff --git a/openspec/changes/pi-opencode-auto-update/specs/opencode-v2-adapter/spec.md b/openspec/changes/archive/2026-11-07-pi-opencode-auto-update/specs/opencode-v2-adapter/spec.md similarity index 88% rename from openspec/changes/pi-opencode-auto-update/specs/opencode-v2-adapter/spec.md rename to openspec/changes/archive/2026-11-07-pi-opencode-auto-update/specs/opencode-v2-adapter/spec.md index f7f23cba..6a99c97a 100644 --- a/openspec/changes/pi-opencode-auto-update/specs/opencode-v2-adapter/spec.md +++ b/openspec/changes/archive/2026-11-07-pi-opencode-auto-update/specs/opencode-v2-adapter/spec.md @@ -2,7 +2,7 @@ ### Requirement: OpenCode tells the user about a newer OMMS release -At start and every 6 hours, OMMS in OpenCode SHALL read the `latest` version of `om-memory-system` from the npm registry. When it is newer than the running version and is not a prerelease, the footer SHALL add `· available`, and OMMS SHALL show one toast that names `opencode plugin update om-memory-system`. When OMMS has started a background update for that version, the toast SHALL say that the update is installing instead of naming the command. The check SHALL send no session content. `OMMS_DISABLE_UPDATE_CHECK=1` SHALL turn the check off. A failed check SHALL show no notice. +At start and every 6 hours, OMMS in OpenCode SHALL read the `latest` version of `om-memory-system` from the npm registry. When it is newer than the running version and is not a prerelease, the footer SHALL add `· available`, and OMMS SHALL show one toast that names `opencode plugin update om-memory-system`. The check SHALL send no session content. `OMMS_DISABLE_UPDATE_CHECK=1` SHALL turn the check off. A failed check SHALL show no notice. #### Scenario: A newer release exists @@ -17,6 +17,15 @@ At start and every 6 hours, OMMS in OpenCode SHALL read the `latest` version of ## ADDED Requirements +### Requirement: OpenCode says when a background update is installing + +When OMMS has started a background update for a newer version, the toast SHALL say that the update is installing instead of naming the update command. + +#### Scenario: The background update has started + +- **WHEN** OMMS starts the background update for 4.3.0 +- **THEN** one toast SHALL say that om-memory-system 4.3.0 is installing in the background + ### Requirement: OpenCode installs a newer OMMS release in the background When OMMS in OpenCode was installed from npm without a pinned version, and the update check finds a newer release that is not a prerelease, OMMS SHALL run `opencode plugin update om-memory-system` in a detached process and SHALL NOT wait for it. OMMS SHALL NOT start a second update for the same release within 30 minutes, across all OpenCode sessions on the computer. diff --git a/openspec/changes/pi-opencode-auto-update/specs/pi-agent-adapter/spec.md b/openspec/changes/archive/2026-11-07-pi-opencode-auto-update/specs/pi-agent-adapter/spec.md similarity index 100% rename from openspec/changes/pi-opencode-auto-update/specs/pi-agent-adapter/spec.md rename to openspec/changes/archive/2026-11-07-pi-opencode-auto-update/specs/pi-agent-adapter/spec.md diff --git a/openspec/changes/pi-opencode-auto-update/tasks.md b/openspec/changes/archive/2026-11-07-pi-opencode-auto-update/tasks.md similarity index 98% rename from openspec/changes/pi-opencode-auto-update/tasks.md rename to openspec/changes/archive/2026-11-07-pi-opencode-auto-update/tasks.md index 8c326e3b..bd037e3b 100644 --- a/openspec/changes/pi-opencode-auto-update/tasks.md +++ b/openspec/changes/archive/2026-11-07-pi-opencode-auto-update/tasks.md @@ -28,4 +28,4 @@ - [x] 5.1 Run each new and changed test file with `bash scripts/run-tests-isolated.sh `. Break the code once and confirm the tests fail. - [x] 5.2 Run `bun run check` and confirm the file-size ceiling test passes for `tui-status.ts` and `extension.ts`. - [x] 5.3 Live check in a scratch HOME with its own store: install an older OMMS in Pi and in OpenCode, start a session, confirm the update command runs once, the marker says `installed`, and the footer shows `installed, restart`. Restart and confirm the new version loads. -- [ ] 5.4 Run `bun run ci:local` and read the exit line. +- [x] 5.4 Run `bun run ci:local` and read the exit line. diff --git a/openspec/specs/opencode-v2-adapter/spec.md b/openspec/specs/opencode-v2-adapter/spec.md index a49b7ddc..2de9dbfc 100644 --- a/openspec/specs/opencode-v2-adapter/spec.md +++ b/openspec/specs/opencode-v2-adapter/spec.md @@ -154,7 +154,7 @@ At start and every 6 hours, OMMS in OpenCode SHALL read the `latest` version of #### Scenario: A newer release exists -- **WHEN** OpenCode runs OMMS 4.2.0 and npm `latest` is 4.3.0 +- **WHEN** OpenCode runs OMMS 4.2.0 from a local path and npm `latest` is 4.3.0 - **THEN** the footer SHALL show `omms: ✔ · 4.3.0 available` - **AND** a toast SHALL name `opencode plugin update om-memory-system` @@ -162,3 +162,73 @@ At start and every 6 hours, OMMS in OpenCode SHALL read the `latest` version of - **WHEN** `OMMS_DISABLE_UPDATE_CHECK=1` is set - **THEN** OMMS SHALL NOT contact the npm registry + +### Requirement: OpenCode says when a background update is installing + +When OMMS has started a background update for a newer version, the toast SHALL say that the update is installing instead of naming the update command. + +#### Scenario: The background update has started + +- **WHEN** OMMS starts the background update for 4.3.0 +- **THEN** one toast SHALL say that om-memory-system 4.3.0 is installing in the background + +### Requirement: OpenCode installs a newer OMMS release in the background + +When OMMS in OpenCode was installed from npm without a pinned version, and the update check finds a newer release that is not a prerelease, OMMS SHALL run `opencode plugin update om-memory-system` in a detached process and SHALL NOT wait for it. OMMS SHALL NOT start a second update for the same release within 30 minutes, across all OpenCode sessions on the computer. + +#### Scenario: A release is approved while OpenCode is closed + +- **WHEN** OpenCode starts with OMMS 4.2.0 from npm and npm `latest` is 4.3.0 +- **THEN** OMMS SHALL start `opencode plugin update om-memory-system` in the background +- **AND** the log SHALL hold an `OpenCode plugin self-update` record with code `started` + +#### Scenario: A second session starts soon after + +- **WHEN** an update for 4.3.0 started less than 30 minutes ago +- **THEN** a new OpenCode session SHALL NOT start another update + +#### Scenario: OMMS runs from a local checkout or a pinned version + +- **WHEN** OpenCode loads OMMS from a local path, or the configured package names a version +- **THEN** OMMS SHALL NOT run the update command +- **AND** the footer and toast SHALL behave as in the newer-release requirement + +### Requirement: The OpenCode background update is safe and logged + +The background update SHALL run apart from memory work. `OMMS_DISABLE_UPDATE_CHECK=1` SHALL turn the update off. A failure SHALL NOT stop memory capture, retrieval, or the `memory` tool. A started or failed start SHALL write one `OpenCode plugin self-update` log record with a code and the two versions. + +#### Scenario: The update command cannot start + +- **WHEN** the update command cannot be started +- **THEN** memory capture, retrieval and the `memory` tool SHALL keep working +- **AND** the log SHALL hold one `OpenCode plugin self-update` record with code `spawn-failed` and no session content + +#### Scenario: Updates are turned off + +- **WHEN** `OMMS_DISABLE_UPDATE_CHECK=1` is set +- **THEN** OMMS SHALL NOT run the update command + +### Requirement: OpenCode tells the user to restart after a background update + +When a background update installs the newer release, the footer SHALL show `· installed, restart` in place of `· available`, and OMMS SHALL show one toast that says the release is installed and OpenCode must restart to use it. When the update command fails, the footer SHALL keep `· available` and OMMS SHALL show one toast that names `opencode plugin update om-memory-system`. + +#### Scenario: The background update succeeds + +- **WHEN** the background update for 4.3.0 exits with success +- **THEN** the footer SHALL show `omms: ✔ · 4.3.0 installed, restart` +- **AND** one toast SHALL tell the user to restart OpenCode and run `opencode service restart` + +#### Scenario: The background update fails + +- **WHEN** the background update for 4.3.0 exits with an error +- **THEN** the footer SHALL show `omms: ✔ · 4.3.0 available` +- **AND** one toast SHALL name `opencode plugin update om-memory-system` + +### Requirement: The OpenCode restart notice names the background service + +OpenCode's background service keeps the old OMMS copy loaded after the TUI restarts. The restart toast SHALL name `opencode service restart`. + +#### Scenario: The restart toast names the service command + +- **WHEN** the background update for 4.3.0 exits with success +- **THEN** the toast SHALL name `opencode service restart` diff --git a/openspec/specs/pi-agent-adapter/spec.md b/openspec/specs/pi-agent-adapter/spec.md index 19626cdc..87ce39c0 100644 --- a/openspec/specs/pi-agent-adapter/spec.md +++ b/openspec/specs/pi-agent-adapter/spec.md @@ -216,3 +216,71 @@ When `compaction.enabled` is on, the Pi adapter SHALL load the session's own Pi - **WHEN** OMMS runs in a Pi child session with `OMPS_CHILD=1` - **THEN** no restored section SHALL be added + +### Requirement: Pi installs a newer OMMS release in the background + +On `session_start`, when OMMS in Pi was installed from npm without a pinned version, the Pi adapter SHALL read the `latest` version of `om-memory-system` from the npm registry. When it is newer than the running version and is not a prerelease, the adapter SHALL run `pi update npm:om-memory-system` in a detached process and SHALL NOT wait for it. The adapter SHALL NOT start a second update for the same release within 30 minutes, across all Pi sessions on the computer. + +#### Scenario: A release is approved while Pi is closed + +- **WHEN** a Pi session starts with OMMS 4.2.0 from npm and npm `latest` is 4.3.0 +- **THEN** the adapter SHALL start `pi update npm:om-memory-system` in the background +- **AND** the log SHALL hold a `Pi plugin self-update` record with code `started` + +#### Scenario: OMMS is current + +- **WHEN** the running version and npm `latest` are both 4.3.0 +- **THEN** the adapter SHALL NOT run the update command +- **AND** the adapter SHALL show no update notice + +#### Scenario: A second session starts soon after + +- **WHEN** an update for 4.3.0 started less than 30 minutes ago +- **THEN** a new Pi session SHALL NOT start another update + +#### Scenario: OMMS runs from a local checkout or a pinned version + +- **WHEN** Pi loads OMMS from a local path, or the configured source names a version +- **THEN** the adapter SHALL NOT run the update command +- **AND** the adapter SHALL show one notification that names `pi update npm:om-memory-system` + +#### Scenario: The check is turned off + +- **WHEN** `OMMS_DISABLE_UPDATE_CHECK=1` is set +- **THEN** the adapter SHALL NOT contact the npm registry + +### Requirement: The Pi background update is safe and logged + +The background update SHALL run apart from memory work. The check SHALL send no session content. `OMMS_DISABLE_UPDATE_CHECK=1` SHALL turn the check and the update off. A failure SHALL NOT delay `session_start` or stop memory capture, retrieval, or the `memory` tool. A started or failed start SHALL write one `Pi plugin self-update` log record with a code and the two versions. + +#### Scenario: The update command cannot start + +- **WHEN** the update command cannot be started +- **THEN** memory capture, retrieval and the `memory` tool SHALL keep working +- **AND** the log SHALL hold one `Pi plugin self-update` record with code `spawn-failed` and no session content + +#### Scenario: Updates are turned off + +- **WHEN** `OMMS_DISABLE_UPDATE_CHECK=1` is set +- **THEN** OMMS SHALL NOT run the update command + +### Requirement: Pi tells the user to restart after a background update + +While a Pi session with a user interface runs, when a background update installs the newer release, the OMMS footer label SHALL add `· installed, restart`, and the adapter SHALL show one notification that says the release is installed and Pi must restart to use it. When the update command fails, the footer label SHALL add `· available` and the adapter SHALL show one notification that names `pi update npm:om-memory-system`. The suffix SHALL stay when the status glyph changes. + +#### Scenario: The background update succeeds + +- **WHEN** the background update for 4.3.0 exits with success +- **THEN** the footer SHALL show `omms: ✔ · 4.3.0 installed, restart` +- **AND** one notification SHALL tell the user to restart Pi + +#### Scenario: The background update fails + +- **WHEN** the background update for 4.3.0 exits with an error +- **THEN** the footer SHALL show `omms: ✔ · 4.3.0 available` +- **AND** one notification SHALL name `pi update npm:om-memory-system` + +#### Scenario: Retrieval runs after the update + +- **WHEN** the footer shows `· 4.3.0 installed, restart` and retrieval starts +- **THEN** the footer SHALL show `omms: ↻ · 4.3.0 installed, restart` From b6dae1ab647005341353106c5740836c731bc0ed Mon Sep 17 00:00:00 2001 From: Aizat Hawari Date: Sun, 8 Nov 2026 00:00:41 +0000 Subject: [PATCH 5/7] feat: confirm host updates on disk and remove old OpenCode copies OMMS now shows "installed, restart" only after it finds the new version on disk. The OpenCode background service removes older OMMS cache copies after 24 hours, keeping the running, newer and recorded copies. Adds ADR-044 and TDR-071 and archives the host-update-verify-and-prune change. --- UPDATES.md | 6 +- ...osts-update-omms-with-their-own-command.md | 57 +++ docs/adr/ADR_README.md | 1 + docs/opencode-adapter.md | 4 +- ...071-opencode-update-live-check-findings.md | 70 ++++ docs/tdr/README.md | 1 + docs/upgrading.md | 22 +- .../.openspec.yaml | 2 + .../design.md | 64 ++++ .../proposal.md | 32 ++ .../specs/opencode-v2-adapter/spec.md | 54 +++ .../specs/pi-agent-adapter/spec.md | 11 + .../tasks.md | 25 ++ openspec/specs/opencode-v2-adapter/spec.md | 53 +++ openspec/specs/pi-agent-adapter/spec.md | 10 + src/adapters/opencode/tui-status.ts | 6 +- src/adapters/pi/self-update.ts | 5 +- src/services/host-copies.ts | 172 +++++++++ src/services/host-self-update.ts | 39 +- src/v2/plugin.ts | 45 ++- tests/host-copies.test.ts | 360 ++++++++++++++++++ tests/host-self-update.test.ts | 56 ++- tests/opencode-tui-status.test.ts | 54 ++- tests/pi-self-update.test.ts | 50 ++- tests/v2-cache-cleanup.test.ts | 135 +++++++ 25 files changed, 1319 insertions(+), 15 deletions(-) create mode 100644 docs/adr/044-hosts-update-omms-with-their-own-command.md create mode 100644 docs/tdr/071-opencode-update-live-check-findings.md create mode 100644 openspec/changes/archive/2026-11-08-host-update-verify-and-prune/.openspec.yaml create mode 100644 openspec/changes/archive/2026-11-08-host-update-verify-and-prune/design.md create mode 100644 openspec/changes/archive/2026-11-08-host-update-verify-and-prune/proposal.md create mode 100644 openspec/changes/archive/2026-11-08-host-update-verify-and-prune/specs/opencode-v2-adapter/spec.md create mode 100644 openspec/changes/archive/2026-11-08-host-update-verify-and-prune/specs/pi-agent-adapter/spec.md create mode 100644 openspec/changes/archive/2026-11-08-host-update-verify-and-prune/tasks.md create mode 100644 src/services/host-copies.ts create mode 100644 tests/host-copies.test.ts create mode 100644 tests/v2-cache-cleanup.test.ts diff --git a/UPDATES.md b/UPDATES.md index 5c232c3e..8835674d 100644 --- a/UPDATES.md +++ b/UPDATES.md @@ -15,8 +15,8 @@ When a Pi or OpenCode V2 session starts, OMMS compares its version with npm `lat While the update installs, OMMS shows `om-memory-system is installing in the background.` When it ends: -- Success: the footer shows `omms: ✔ · installed, restart`, and one message tells you to restart the host. -- Failure: the footer shows `· available`, and one message names the update command. Run it yourself. +- Success: OMMS checks that the new version is on disk. The footer then shows `omms: ✔ · installed, restart`, and one message tells you to restart the host. +- Failure: the footer shows `· available`, and one message names the update command. Run it yourself. OMMS also shows this when the update command ends with success but the new version is not on disk. You must restart Pi or OpenCode to load the new copy. OMMS never restarts the host. OpenCode's background server keeps the old copy in memory. For OpenCode, also run `opencode service restart`. @@ -29,6 +29,8 @@ Your OpenCode config names the plugin without a version: `"om-memory-system"`. T Set `OMMS_DISABLE_UPDATE_CHECK=1` to turn off the npm check and the update. The check sends no session content. +OpenCode keeps each update in a new folder under `~/.cache/opencode/npm/om-memory-system@latest/`, about 900 MB each, and never removes the old ones. When the OpenCode background service starts, OMMS removes the older OMMS folders. It keeps the running folder, newer folders, the folder in `~/.omms/runtime.json`, and folders without a readable version. It removes nothing while the running folder is less than 24 hours old. See [Updating and upgrading](docs/upgrading.md#updating-omms). + ## Claude Code updates The `omms` marketplace installs from the GitHub `stable` branch. It receives only versions approved on npm: the channel workflow moves `stable` to the release tag named by npm `latest` within an hour of approval, or at once after manual dispatch. Claude Code installs that version at its next update check when auto-update is on. diff --git a/docs/adr/044-hosts-update-omms-with-their-own-command.md b/docs/adr/044-hosts-update-omms-with-their-own-command.md new file mode 100644 index 00000000..86df827e --- /dev/null +++ b/docs/adr/044-hosts-update-omms-with-their-own-command.md @@ -0,0 +1,57 @@ +# ADR-044: Pi and OpenCode update OMMS by running the host's own update command + +- **Date:** 2026-11-07 +- **Status:** Accepted +- **Deciders:** Aizat Hawari +- **Change:** [`pi-opencode-auto-update`](../../openspec/changes/archive/2026-11-07-pi-opencode-auto-update/proposal.md), [`host-update-verify-and-prune`](../../openspec/changes/archive/2026-11-08-host-update-verify-and-prune/proposal.md) + +## Context + +Claude Code installed new OMMS releases without a typed command. Pi and OpenCode only told the user that a release existed. The user had to run `pi update npm:om-memory-system` or `opencode plugin update om-memory-system` and restart the host. + +Neither host has a setting for plugin auto-update. Both hosts install OMMS from npm `latest` into their own folders. A running host keeps the loaded copy in memory, so a restart is always needed. + +## Decision + +1. **OMMS runs the host's own update command.** At session start, Pi and OpenCode V2 compare the OMMS version with npm `latest`. When npm has a newer release that is not a prerelease, OMMS runs `pi update npm:om-memory-system` or `opencode plugin update om-memory-system` in a detached process. OpenCode checks again every 6 hours. +2. **A marker file per host prevents repeat updates and records the result.** The files are `~/.omms/pi-plugin-update.json` and `~/.omms/opencode-plugin-update.json`. A second session does not start another update for the same release within 30 minutes. The detached script writes `outcome` to the marker. The plugin reads the marker every 60 seconds for up to 10 minutes. +3. **OMMS skips pinned and local installs.** The notice then shows `· available` and the command. `OMMS_DISABLE_UPDATE_CHECK=1` turns the check and the update off. +4. **OMMS never restarts the host.** A restart would end open sessions. The footer shows `· installed, restart`. For OpenCode, the message also names `opencode service restart`, because the background service keeps the old copy loaded. +5. **OMMS shows `installed, restart` only after the new version is on disk.** An exit code of 0 is not enough. When no installed copy holds the new version, OMMS shows `· available` and the command. +6. **OMMS removes old OpenCode copies that it owns.** OpenCode 2.2 stores each update in a new `om-memory-system@latest//` folder, about 900 MB each, and never removes old folders. When the OpenCode background service starts, OMMS removes older OMMS folders in that cache. It keeps the running folder, newer folders, the folder in `~/.omms/runtime.json`, and folders without a readable version. It removes nothing while the running folder is less than 24 hours old. +7. **Shared code lives in `src/services/host-self-update.ts`.** Claude Code keeps its own module, `src/adapters/claude-code/plugin-self-update.ts`. + +## Alternatives Considered + +| Option | Rejected Because | +| --------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| A launchd job that runs both update commands hourly | Works on macOS only, sits outside OMMS, and each user must set it up | +| Notice only (the earlier behaviour) | The user must remember to run the command | +| OMMS restarts the host after the update | A restart ends open sessions and can lose work in progress | +| Move Claude Code onto the shared module now | Claude Code works today. The move is a refactor with no new behaviour. It stays a later change. | + +## Consequences + +Positive: + +- Pi and OpenCode users get a new release without a typed command. +- The footer tells the user when a restart is the only step left. +- The disk does not fill with old OpenCode copies. + +Negative: + +- The user must still restart the host. OpenCode also needs `opencode service restart`. +- OMMS writes two marker files under `~/.omms/`. +- OMMS deletes folders in the OpenCode cache. A TUI or web app that has run an old copy for more than 24 hours can fail to load a module until it restarts. +- Two update modules exist until Claude Code moves onto the shared one. + +Neutral: + +- Pinned installs and local checkouts keep the notice-only behaviour. +- Pi updates in place and needs no clean-up. + +## References + +- [Updating and upgrading](../upgrading.md#updating-omms) +- [TDR-071](../tdr/071-opencode-update-live-check-findings.md) +- `src/services/host-self-update.ts` diff --git a/docs/adr/ADR_README.md b/docs/adr/ADR_README.md index c4ad668d..89b770ef 100644 --- a/docs/adr/ADR_README.md +++ b/docs/adr/ADR_README.md @@ -47,3 +47,4 @@ Local decision records for OMMS maintainers. | [041](./041-a-memory-space-is-a-storage-folder.md) | A memory space is a storage folder | 2026-11-06 | Proposed | | [042](./042-a-space-bound-store-and-linked-read-only-spaces.md) | A space-bound store and linked read-only spaces | 2026-11-06 | Proposed | | [043](./043-the-default-space-name-lives-in-the-registry.md) | The Default space name lives in the registry | 2026-11-07 | Proposed | +| [044](./044-hosts-update-omms-with-their-own-command.md) | Pi and OpenCode update OMMS by running the host's own update command | 2026-11-07 | Accepted | diff --git a/docs/opencode-adapter.md b/docs/opencode-adapter.md index 1aebd89d..9c0821d1 100644 --- a/docs/opencode-adapter.md +++ b/docs/opencode-adapter.md @@ -150,10 +150,12 @@ OpenCode 2.0.22 and later load the plugin's `./tui` entry. It shows OMMS's state - `omms: ✘ web app off` when the web app does not answer. Capture and retrieval keep working. - `omms: ✘` when the footer cannot start its status check. - `· available` when npm has a newer release and OMMS does not install it (local checkout, pinned version, or a failed update). A toast also names the update command once per start. -- `· installed, restart` after OMMS installs the newer release in the background. A toast tells you to restart OpenCode and run `opencode service restart`, because the background service keeps the old copy loaded. While the update installs, a toast says so. +- `· installed, restart` after OMMS installs the newer release in the background and finds the new version on disk. A toast tells you to restart OpenCode and run `opencode service restart`, because the background service keeps the old copy loaded. While the update installs, a toast says so. When the update command ends with success but the new version is not on disk, the footer keeps `· available` and a toast names the update command. The plugin checks npm at start and every 6 hours. When npm has a newer release, it runs `opencode plugin update om-memory-system` in the background. Set `OMMS_DISABLE_UPDATE_CHECK=1` to turn the check and the update off. See [Updating and upgrading](upgrading.md#updating-omms). +OpenCode 2.2 and later store each update in a new folder, `~/.cache/opencode/npm/om-memory-system@latest//`, about 900 MB each. When the OpenCode background service starts, OMMS removes the older OMMS folders in the background. It keeps the running folder, every newer folder, the folder in `~/.omms/runtime.json`, and any folder without a readable version. It removes nothing while the running folder is less than 24 hours old. A folder that OMMS cannot remove stays. Each pass writes one `OpenCode cache clean-up` log record with counts only. + The footer reads only the health route and the npm registry's `latest` version. It sends nothing about your sessions. ### Recall rows diff --git a/docs/tdr/071-opencode-update-live-check-findings.md b/docs/tdr/071-opencode-update-live-check-findings.md new file mode 100644 index 00000000..8be9cb76 --- /dev/null +++ b/docs/tdr/071-opencode-update-live-check-findings.md @@ -0,0 +1,70 @@ +# TDR-071: The live check of the OpenCode update found four faults + +**Date:** 2026-11-07 +**Status:** Accepted +**Deciders:** Aizat Hawari +**Tags:** opencode, pi, self-update, cache, live-check + +## Context + +The change `pi-opencode-auto-update` (ADR-044) makes Pi and OpenCode run their own update command. A live check on 2026-11-07 used OpenCode 2.2.5 in a scratch home folder. It found four faults. Unit tests had passed because they used the folder layout and the exit code that the code expected. + +### Root Cause Analysis + +1. **Every OpenCode install counted as pinned.** OpenCode 2.2 stores each plugin install in a new `` folder between `om-memory-system@latest/` and `node_modules/`. The pin check read the folder just above `node_modules`. It saw a number, not `om-memory-system@latest`, so it skipped the update every time. +2. **A restart did not load the new copy.** The OpenCode background service keeps the old copy loaded after the TUI restarts. Only `opencode service restart` loads the new copy. The first toast said only "restart OpenCode". +3. **Each update added about 900 MB.** OpenCode never removes old `` folders. +4. **`installed` could be wrong.** OMMS recorded `installed` from the exit code alone. The host command can exit with 0 when npm's lock already lists the version and nothing is installed. The footer then said `installed, restart`, and a restart loaded the old copy again. + +## Decision + +- `unpinnedCacheFolder` in `src/services/host-self-update.ts` skips an all-digit folder name and reads the folder above it. +- The restart toast says "Restart OpenCode and run opencode service restart". +- When the OpenCode background service starts, OMMS removes older OMMS copies in `~/.cache/opencode/npm/om-memory-system@latest//`. It keeps the running copy, newer copies, the copy in `~/.omms/runtime.json`, and copies without a readable version. It removes nothing while the running copy is less than 24 hours old. A copy that cannot be removed stays. One `OpenCode cache clean-up` log record holds the counts. +- OMMS shows `installed, restart` only after it finds the new version in an installed copy on disk. Otherwise it shows `· available` and the update command. This applies to Pi and OpenCode. + +## Consequences + +### Positive + +- The background update runs on OpenCode 2.2. +- The user sees the full restart steps. +- Each release no longer adds 900 MB that stays for good. +- `installed, restart` is true when it appears. + +### Negative + +- The clean-up depends on OpenCode's cache layout. A new layout turns the clean-up into a no-op. The install check reads the running copy only. +- A TUI or web app that runs an old copy for more than 24 hours can fail to load a module until it restarts. + +### Neutral + +- Pi updates in place and needs no clean-up. + +## Alternatives Considered + +| Option | Rejected Because | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| Check the install inside the detached script | The script is a string with no unit tests, and it would repeat the host paths | +| Clean up from the TUI | A TUI can start on the new copy while the service still runs the old copy and loads from it later | +| Tell the user to delete old folders by hand | The disk fills before the user notices | + +## How to Recognise / Handle This Again + +1. Symptom: OMMS never starts an update on OpenCode, or the footer shows `installed, restart` and the version does not change after a restart. +2. Check the path of the running module. Compare it with the folders under `~/.cache/opencode/npm/om-memory-system@latest/`. +3. Read `package.json` in each folder. Run `opencode service restart` when the newest folder holds the new version. +4. For disk use, look for the `OpenCode cache clean-up` record in the log. It shows how many folders OMMS removed and kept. + +## Revisit Triggers + +- OpenCode changes its plugin cache layout again. +- OpenCode starts to remove old plugin folders itself. +- OpenCode reloads plugins in the background service without `opencode service restart`. + +## References + +- [ADR-044](../adr/044-hosts-update-omms-with-their-own-command.md) +- [Updating and upgrading](../upgrading.md#updating-omms) +- `openspec/changes/archive/2026-11-07-pi-opencode-auto-update/` +- `src/services/host-self-update.ts` diff --git a/docs/tdr/README.md b/docs/tdr/README.md index cae4e132..74b22d07 100644 --- a/docs/tdr/README.md +++ b/docs/tdr/README.md @@ -92,6 +92,7 @@ TDRs capture **implementation-level technical decisions** such as platform-speci | [068](./068-the-claude-recall-parser-fixes-the-space-attribute-order.md) | The Claude recall parser fixes the space attribute order | Accepted | 2026-11-06 | | [069](./069-the-claude-memory-tool-routes-space-flags-to-the-space-command.md) | The Claude memory tool routes space transfer flags to the space command | Accepted | 2026-11-07 | | [070](./070-the-pi-recall-row-parser-rejected-the-space-attribute.md) | The Pi recall row parser rejected the space attribute | Accepted | 2026-11-07 | +| [071](./071-opencode-update-live-check-findings.md) | The live check of the OpenCode update found four faults | Accepted | 2026-11-07 | ## Status values diff --git a/docs/upgrading.md b/docs/upgrading.md index 6f818758..72e4f53a 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -22,11 +22,14 @@ is installing in the background.` When it ends, the OMMS footer label shows one of these: - `omms: ✔ · installed, restart`, with one message that tells you to - restart the agent. The new copy loads only after you restart Pi or OpenCode. + restart the agent. OMMS shows this only after it finds the new version on + disk. The new copy loads only after you restart Pi or OpenCode. OMMS never restarts the agent. OpenCode's background service keeps the old copy in memory. For OpenCode, also run `opencode service restart`. - `omms: ✔ · available`, with one message that names the update - command. This means the background update failed. Run the command yourself. + command. This means the background update failed, or the update command + ended with success but the new version is not on disk. Run the command + yourself. OMMS does not run the update for these installs. The notice shows ` available` and the command to run: @@ -39,6 +42,21 @@ Set `OMMS_DISABLE_UPDATE_CHECK=1` to turn off both the npm check and the update. The check reads the npm registry. It sends no session content. `opencode plugin check` also lists a new OpenCode release. +OpenCode 2.2 and later store each plugin install in a new folder: +`~/.cache/opencode/npm/om-memory-system@latest//`. Each folder holds +about 900 MB. OpenCode never removes the old folders. When the OpenCode +background service starts, OMMS removes the older OMMS folders in the background. +OMMS keeps these folders: + +- The folder that runs now, and every newer folder. +- The folder that `~/.omms/runtime.json` records. +- Any folder without a readable version. + +OMMS removes nothing while the running folder is less than 24 hours old. A +folder that OMMS cannot remove stays. Each pass writes one `OpenCode cache +clean-up` record to the log, with counts only. Pi updates its copy in place, so +Pi needs no clean-up. + The web page tells you too. Its sidebar header shows the running version. When npm has a newer release, an **Update** button shows in the sidebar footer on this computer. Its dialog lists the command for each host. **Update web app** installs the release globally with npm and restarts the web app on it. See [Web UI: Update button](web-ui.md#update-button). [UPDATES.md](../UPDATES.md) is a one-page summary for every host. diff --git a/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/.openspec.yaml b/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/.openspec.yaml new file mode 100644 index 00000000..e582ee4a --- /dev/null +++ b/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-07 diff --git a/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/design.md b/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/design.md new file mode 100644 index 00000000..852eaab2 --- /dev/null +++ b/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/design.md @@ -0,0 +1,64 @@ +## Context + +`pi-opencode-auto-update` runs the host update command in a detached process and writes `outcome` to a marker. The plugin reads the marker and shows `installed, restart`. The outcome comes from the exit code alone. + +Pi updates its copy in place: `~/.pi/agent/npm/node_modules/om-memory-system/`. OpenCode 2.2 writes each update to a new folder: `~/.cache/opencode/npm/om-memory-system@latest//node_modules/om-memory-system/`. The timestamp is milliseconds since 1970. OpenCode loads the newest folder at the next start and never removes old ones. + +The OpenCode background service runs the server plugin (`src/v2/plugin.ts`). It can run for days. The TUI loads its own modules when it starts. The web app and the terminal command run the copy that `~/.omms/runtime.json` records, which can be an OpenCode cache copy. + +## Goals / Non-Goals + +**Goals:** + +- `installed` means the new version is on disk. +- Old OpenCode copies do not fill the disk. +- No running process loses files it still needs. + +**Non-Goals:** + +- Removing copies that OpenCode or Pi install for other plugins. +- Removing Pi copies. Pi updates in place. +- Removing copies on a schedule. The clean-up runs when the service starts. + +## Decisions + +### 1. Confirm in the plugin, not in the detached script + +The plugin reads the copies on disk when it reads the marker. When the marker says `installed` and no copy holds the new version, the plugin treats the status as `failed`. The detached script stays as it is. + +Alternative considered: check in the detached script. Rejected: the script is a string, so the check would have no unit tests, and the host paths would be duplicated. + +### 2. One helper lists the copies + +`src/services/host-copies.ts` finds the package root of the running copy (the folder under `node_modules/om-memory-system/`). When the folder above `node_modules` is a timestamp folder, it also lists every sibling timestamp folder with the version in its `package.json`. Both the install check and the clean-up use this list. + +### 3. Clean-up rules + +The clean-up runs once in the OpenCode background service, after start, without waiting. It removes a sibling timestamp folder only when all are true: + +- Its timestamp is older than the timestamp of the running folder. +- Its version is older than the running version. +- It is not the copy that `~/.omms/runtime.json` records. +- The running folder was installed at least 24 hours before now. A TUI or web app that started before the update has had a day to restart. + +A folder that cannot be removed (for example a locked file on Windows) stays. The pass logs one `OpenCode cache clean-up` record with the number removed and kept. It never throws. + +Alternative considered: clean up from the TUI. Rejected: a TUI can start on the new copy while the service still runs the old copy and loads modules from it later. + +### 4. Fixed values + +24 hours is fixed by this design. It is a constant in `host-copies.ts`. + +## Risks / Trade-offs + +- [A TUI or web app runs an old copy for more than 24 hours after the update] → It can fail to load a module until it restarts. The web app records its copy in `runtime.json`, which the clean-up keeps. +- [OpenCode changes its cache layout again] → The clean-up finds no timestamp folder and does nothing. The install check reads the running copy only. +- [A copy without a readable `package.json`] → It has no version, so it stays. + +## Migration Plan + +No data changes. The first service start after the release removes old copies that match the rules. + +## Open Questions + +None. diff --git a/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/proposal.md b/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/proposal.md new file mode 100644 index 00000000..d21b08b3 --- /dev/null +++ b/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/proposal.md @@ -0,0 +1,32 @@ +## Why + +The live check of `pi-opencode-auto-update` found two gaps: + +- The background update records `installed` when the host command exits with 0. A command that exits with 0 but installs nothing still makes the footer say `installed, restart`. A restart then loads the old copy again, and the user sees the same message with no way out. +- Each OpenCode update adds a new copy of OMMS, about 900 MB, under `~/.cache/opencode/npm/om-memory-system@latest//`. OpenCode never removes the old copies, so the disk fills up with each release. + +## What Changes + +- Before OMMS reports `installed`, it reads the installed copy and confirms that it holds the new version. When it does not, OMMS reports the update as failed: the footer keeps `· available` and the message names the update command. This applies to Pi and OpenCode. +- When the OpenCode background service starts OMMS from a timestamped cache folder, OMMS removes older OMMS copies in the same cache folder. It keeps the copy that runs, every newer copy, the copy that `~/.omms/runtime.json` records, and every copy installed less than 24 hours before the running copy. A copy that cannot be removed stays. Each pass logs one record with counts only. +- Docs say that OMMS removes old OpenCode copies. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `opencode-v2-adapter`: the restart notice requires a confirmed install; new requirement for removing old cache copies. +- `pi-agent-adapter`: the restart notice requires a confirmed install. + +## Impact + +- `src/services/host-self-update.ts`: confirm the installed version before `installed`. +- New `src/services/host-copies.ts`: list the installed copies and remove old OpenCode copies. +- `src/adapters/pi/self-update.ts`, `src/adapters/opencode/tui-status.ts`: use the confirmed status. +- `src/v2/plugin.ts`: start the clean-up in the background service, without waiting for it. +- Tests for each part. `docs/upgrading.md`, `docs/opencode-adapter.md`, `UPDATES.md`, one ADR and one TDR. +- No new dependencies. No network calls. diff --git a/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/specs/opencode-v2-adapter/spec.md b/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/specs/opencode-v2-adapter/spec.md new file mode 100644 index 00000000..0786c8ba --- /dev/null +++ b/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/specs/opencode-v2-adapter/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: OpenCode reports an installed update only when the new copy is on disk + +When the background update exits with success, OMMS SHALL read the installed OMMS copies before it shows `· installed, restart`. When no installed copy holds the new version, OMMS SHALL treat the update as failed: the footer SHALL keep `· available` and one toast SHALL name `opencode plugin update om-memory-system`. + +#### Scenario: The update command changed nothing + +- **WHEN** the background update for 4.3.0 exits with success and no installed copy holds 4.3.0 +- **THEN** the footer SHALL show `omms: ✔ · 4.3.0 available` +- **AND** one toast SHALL name `opencode plugin update om-memory-system` + +#### Scenario: The new copy is on disk + +- **WHEN** the background update for 4.3.0 exits with success and a timestamped cache copy holds 4.3.0 +- **THEN** the footer SHALL show `omms: ✔ · 4.3.0 installed, restart` + +### Requirement: OpenCode removes old OMMS cache copies + +When the OpenCode background service starts OMMS from a timestamped cache folder, OMMS SHALL remove each older OMMS copy in the same cache folder, without delaying the service. A copy that cannot be removed SHALL stay. Each pass SHALL write one `OpenCode cache clean-up` log record with counts only. + +#### Scenario: An old copy is removed + +- **WHEN** the service runs 4.3.0 from a folder installed 2 days ago and the cache holds 4.2.0 in an older folder +- **THEN** OMMS SHALL remove the 4.2.0 folder + +#### Scenario: The update is recent + +- **WHEN** the running folder was installed 3 hours ago +- **THEN** OMMS SHALL remove no folder + +#### Scenario: The recorded copy is kept + +- **WHEN** `~/.omms/runtime.json` records the 4.2.0 folder +- **THEN** OMMS SHALL keep the 4.2.0 folder + +#### Scenario: A folder cannot be removed + +- **WHEN** the removal of an old folder fails +- **THEN** the folder SHALL stay, the service SHALL keep working, and the log record SHALL count it as kept + +### Requirement: The OpenCode cache clean-up keeps copies that may still run + +The clean-up SHALL keep the running copy, every newer copy, the copy that `~/.omms/runtime.json` records, and every copy without a readable version. It SHALL remove no copy while the running copy is less than 24 hours old. + +#### Scenario: A newer copy is kept + +- **WHEN** the service runs 4.3.0 and the cache also holds 4.4.0 in a newer folder +- **THEN** OMMS SHALL keep the 4.4.0 folder + +#### Scenario: A copy without a version is kept + +- **WHEN** an older folder has no readable `package.json` +- **THEN** OMMS SHALL keep that folder diff --git a/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/specs/pi-agent-adapter/spec.md b/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/specs/pi-agent-adapter/spec.md new file mode 100644 index 00000000..887eabaf --- /dev/null +++ b/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/specs/pi-agent-adapter/spec.md @@ -0,0 +1,11 @@ +## ADDED Requirements + +### Requirement: Pi reports an installed update only when the new copy is on disk + +When the background update exits with success, the Pi adapter SHALL read the installed OMMS copy before it shows `· installed, restart`. When the installed copy does not hold the new version, the adapter SHALL treat the update as failed: the footer label SHALL add `· available` and one notification SHALL name `pi update npm:om-memory-system`. + +#### Scenario: The update command changed nothing + +- **WHEN** the background update for 4.3.0 exits with success and the installed copy still holds 4.2.0 +- **THEN** the footer SHALL show `omms: ✔ · 4.3.0 available` +- **AND** one notification SHALL name `pi update npm:om-memory-system` diff --git a/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/tasks.md b/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/tasks.md new file mode 100644 index 00000000..b0bcb9e1 --- /dev/null +++ b/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/tasks.md @@ -0,0 +1,25 @@ +## 1. Shared copies helper + +- [x] 1.1 Write failing tests in `tests/host-copies.test.ts`: package root from a module path; sibling timestamp copies with versions; a copy without `package.json`; no timestamp folder. +- [x] 1.2 Add `src/services/host-copies.ts` with the copy list and the installed-version check. +- [x] 1.3 Write failing tests for the clean-up: removes older copies; keeps the running, newer, recorded and unreadable copies; keeps everything within 24 hours; a failed removal stays and is counted; one log record with counts. +- [x] 1.4 Add the clean-up to `src/services/host-copies.ts`. + +## 2. Confirmed install + +- [x] 2.1 Write failing tests in `tests/host-self-update.test.ts`: a marker that says `installed` with no copy at the new version reads as `failed`. +- [x] 2.2 Add the check to `src/services/host-self-update.ts` and use it from `src/adapters/pi/self-update.ts` and `src/adapters/opencode/tui-status.ts`. Add tests for each host. + +## 3. OpenCode clean-up wiring + +- [x] 3.1 Start the clean-up from `src/v2/plugin.ts` without waiting, with a test that setup does not wait for it and does not fail when it throws. + +## 4. Docs and records + +- [x] 4.1 Update `docs/upgrading.md`, `docs/opencode-adapter.md` and `UPDATES.md`. +- [x] 4.2 Add an ADR for the host self-update approach and a TDR for the OpenCode 2.2 cache layout and background service. Add both to the folder index files. + +## 5. Verify + +- [x] 5.1 Run each new and changed test file with `bash scripts/run-tests-isolated.sh`. Break the code once and confirm a test fails. +- [x] 5.2 Run `bun run ci:local` and read the exit line. diff --git a/openspec/specs/opencode-v2-adapter/spec.md b/openspec/specs/opencode-v2-adapter/spec.md index 2de9dbfc..9d1ab458 100644 --- a/openspec/specs/opencode-v2-adapter/spec.md +++ b/openspec/specs/opencode-v2-adapter/spec.md @@ -232,3 +232,56 @@ OpenCode's background service keeps the old OMMS copy loaded after the TUI resta - **WHEN** the background update for 4.3.0 exits with success - **THEN** the toast SHALL name `opencode service restart` + +### Requirement: OpenCode reports an installed update only when the new copy is on disk + +When the background update exits with success, OMMS SHALL read the installed OMMS copies before it shows `· installed, restart`. When no installed copy holds the new version, OMMS SHALL treat the update as failed: the footer SHALL keep `· available` and one toast SHALL name `opencode plugin update om-memory-system`. + +#### Scenario: The update command changed nothing + +- **WHEN** the background update for 4.3.0 exits with success and no installed copy holds 4.3.0 +- **THEN** the footer SHALL show `omms: ✔ · 4.3.0 available` +- **AND** one toast SHALL name `opencode plugin update om-memory-system` + +#### Scenario: The new copy is on disk + +- **WHEN** the background update for 4.3.0 exits with success and a timestamped cache copy holds 4.3.0 +- **THEN** the footer SHALL show `omms: ✔ · 4.3.0 installed, restart` + +### Requirement: OpenCode removes old OMMS cache copies + +When the OpenCode background service starts OMMS from a timestamped cache folder, OMMS SHALL remove each older OMMS copy in the same cache folder, without delaying the service. A copy that cannot be removed SHALL stay. Each pass SHALL write one `OpenCode cache clean-up` log record with counts only. + +#### Scenario: An old copy is removed + +- **WHEN** the service runs 4.3.0 from a folder installed 2 days ago and the cache holds 4.2.0 in an older folder +- **THEN** OMMS SHALL remove the 4.2.0 folder + +#### Scenario: The update is recent + +- **WHEN** the running folder was installed 3 hours ago +- **THEN** OMMS SHALL remove no folder + +#### Scenario: The recorded copy is kept + +- **WHEN** `~/.omms/runtime.json` records the 4.2.0 folder +- **THEN** OMMS SHALL keep the 4.2.0 folder + +#### Scenario: A folder cannot be removed + +- **WHEN** the removal of an old folder fails +- **THEN** the folder SHALL stay, the service SHALL keep working, and the log record SHALL count it as kept + +### Requirement: The OpenCode cache clean-up keeps copies that may still run + +The clean-up SHALL keep the running copy, every newer copy, the copy that `~/.omms/runtime.json` records, and every copy without a readable version. It SHALL remove no copy while the running copy is less than 24 hours old. + +#### Scenario: A newer copy is kept + +- **WHEN** the service runs 4.3.0 and the cache also holds 4.4.0 in a newer folder +- **THEN** OMMS SHALL keep the 4.4.0 folder + +#### Scenario: A copy without a version is kept + +- **WHEN** an older folder has no readable `package.json` +- **THEN** OMMS SHALL keep that folder diff --git a/openspec/specs/pi-agent-adapter/spec.md b/openspec/specs/pi-agent-adapter/spec.md index 87ce39c0..b71bec9d 100644 --- a/openspec/specs/pi-agent-adapter/spec.md +++ b/openspec/specs/pi-agent-adapter/spec.md @@ -284,3 +284,13 @@ While a Pi session with a user interface runs, when a background update installs - **WHEN** the footer shows `· 4.3.0 installed, restart` and retrieval starts - **THEN** the footer SHALL show `omms: ↻ · 4.3.0 installed, restart` + +### Requirement: Pi reports an installed update only when the new copy is on disk + +When the background update exits with success, the Pi adapter SHALL read the installed OMMS copy before it shows `· installed, restart`. When the installed copy does not hold the new version, the adapter SHALL treat the update as failed: the footer label SHALL add `· available` and one notification SHALL name `pi update npm:om-memory-system`. + +#### Scenario: The update command changed nothing + +- **WHEN** the background update for 4.3.0 exits with success and the installed copy still holds 4.2.0 +- **THEN** the footer SHALL show `omms: ✔ · 4.3.0 available` +- **AND** one notification SHALL name `pi update npm:om-memory-system` diff --git a/src/adapters/opencode/tui-status.ts b/src/adapters/opencode/tui-status.ts index c8ff754e..3b45b97f 100644 --- a/src/adapters/opencode/tui-status.ts +++ b/src/adapters/opencode/tui-status.ts @@ -3,9 +3,8 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { hostSelfUpdateDeps, + hostUpdateStatus, installedFromNpm, - markerStatus, - readMarkerFile, startHostSelfUpdate, unpinnedCacheFolder, updateNotice, @@ -173,6 +172,7 @@ export async function startInstalledTuiStatus( command: "opencode", args: ["plugin", "update", "om-memory-system"], markerPath, + modulePath, installSource: () => !installedFromNpm(modulePath) ? "local" : unpinnedCacheFolder(modulePath) ? "npm" : "pinned", log: (message, data) => log(message, data), @@ -181,7 +181,7 @@ export async function startInstalledTuiStatus( ...callbacks, currentVersion, selfUpdate: () => startHostSelfUpdate(update), - readUpdateStatus: (version) => markerStatus(readMarkerFile(markerPath), version), + readUpdateStatus: (version) => hostUpdateStatus(update, version), healthUrl: `http://${host.includes(":") ? `[${host}]` : host}:${CONFIG.webServerPort}/api/health`, fetch: globalThis.fetch.bind(globalThis), checkUpdates: process.env.OMMS_DISABLE_UPDATE_CHECK !== "1", diff --git a/src/adapters/pi/self-update.ts b/src/adapters/pi/self-update.ts index b9df97de..3ccafded 100644 --- a/src/adapters/pi/self-update.ts +++ b/src/adapters/pi/self-update.ts @@ -4,8 +4,8 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { hostSelfUpdateDeps, + hostUpdateStatus, installedFromNpm, - markerStatus, startHostSelfUpdate, unpinnedPackageSource, updateNotice, @@ -74,6 +74,7 @@ export function piHostSelfUpdateDeps( command: "pi", args: PI_UPDATE_ARGS, markerPath: join(homedir(), ".omms", "pi-plugin-update.json"), + modulePath, installSource, log, env, @@ -153,7 +154,7 @@ export function createPiSelfUpdate(options: PiSelfUpdateOptions = {}): PiSelfUpd show(deps, update, "available"); return; } - const read = () => markerStatus(deps.readMarker(), update); + const read = () => hostUpdateStatus(deps, update); const status = read(); if (status === "installed") show(deps, update, "installed"); else if (status === "failed") show(deps, update, "available"); diff --git a/src/services/host-copies.ts b/src/services/host-copies.ts new file mode 100644 index 00000000..b5d7578d --- /dev/null +++ b/src/services/host-copies.ts @@ -0,0 +1,172 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { isOlderVersion } from "./version-compare.js"; + +// OpenCode 2.2 installs each update of a plugin to a new folder: +// `/om-memory-system@latest//node_modules/om-memory-system/`. +// It never removes the old ones. This module lists the copies next to the +// running one, so the update check can see whether the new version reached the +// disk and the OpenCode service can remove copies that no process needs. +// It takes paths as arguments and imports no config, so tests need no real home. + +/** The clean-up leaves everything alone until the running copy is this old. */ +export const CACHE_PRUNE_MIN_AGE_MS = 24 * 60 * 60 * 1000; + +const PACKAGE_FOLDER = "/node_modules/om-memory-system"; + +export interface HostCopy { + /** The package folder: `.../node_modules/om-memory-system`. */ + root: string; + /** The `version` in its `package.json`, or null when that cannot be read. */ + version: string | null; + /** The timestamp folder name as a number (install time in ms since 1970). */ + stamp: number; +} + +export interface HostCopies { + root: string; + version: string | null; + /** The running copy's timestamp, or null when its folder is not a timestamp folder. */ + stamp: number | null; + /** Every other timestamp folder in the same cache folder. Empty without a timestamp. */ + siblings: HostCopy[]; +} + +function versionAt(root: string): string | null { + try { + const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { + version?: unknown; + }; + return typeof pkg.version === "string" ? pkg.version : null; + } catch { + return null; + } +} + +/** The same path with one separator and no trailing one, for comparing roots. */ +const samePath = (path: string): string => path.replaceAll("\\", "/").replace(/\/+$/, ""); + +/** + * A key that is equal for two paths to the same folder. The device and inode + * match through a symbolic link or a different letter case; a path that cannot + * be read falls back to its text. + */ +function folderKey(path: string): string { + try { + const { dev, ino } = statSync(path); + if (ino !== 0) return `inode:${dev}:${ino}`; + } catch { + // Compare the text below. + } + return `path:${samePath(path)}`; +} + +/** + * The running copy and, when it sits in a timestamp folder, the other copies in + * the same cache folder. Null when `modulePath` is not inside + * `node_modules/om-memory-system/`. Never throws. + */ +export function listHostCopies(modulePath: string): HostCopies | null { + const normal = modulePath.replaceAll("\\", "/"); + const at = normal.lastIndexOf(`${PACKAGE_FOLDER}/`); + if (at < 0) return null; + // Replacing a backslash with a slash keeps the length, so the index fits the original path. + const root = modulePath.slice(0, at + PACKAGE_FOLDER.length); + const stampFolder = modulePath.slice(0, at); + const parts = stampFolder.replaceAll("\\", "/").split("/"); + const name = parts[parts.length - 1] ?? ""; + const result: HostCopies = { root, version: versionAt(root), stamp: null, siblings: [] }; + if (!/^\d+$/.test(name)) return result; + result.stamp = Number(name); + const cache = stampFolder.slice(0, stampFolder.length - name.length); + try { + const names = readdirSync(cache, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && /^\d+$/.test(entry.name) && entry.name !== name) + .map((entry) => entry.name) + .sort((a, b) => Number(a) - Number(b)); + for (const sibling of names) { + const siblingRoot = join(cache, sibling, "node_modules", "om-memory-system"); + result.siblings.push({ + root: siblingRoot, + version: versionAt(siblingRoot), + stamp: Number(sibling), + }); + } + } catch { + // An unreadable cache folder lists no siblings. + } + return result; +} + +/** + * The versions on disk: the running copy's and each sibling's. Pi updates in + * place, so its running copy shows the new version after `pi update`. OpenCode + * shows it in a new sibling folder. Never throws. + */ +export function installedVersions(modulePath: string): string[] { + const copies = listHostCopies(modulePath); + if (!copies) return []; + const versions = [copies.version, ...copies.siblings.map((copy) => copy.version)]; + return versions.filter((version): version is string => version !== null); +} + +export interface PruneOptions { + modulePath: string; + runningVersion: string; + /** Package folders that must stay, such as the copy `~/.omms/runtime.json` records. */ + keepRoots: string[]; + now: number; + /** Removes a folder and everything in it. Defaults to an asynchronous `rm`, so a large folder does not block the service. */ + remove?: (folder: string) => void | Promise; + log: (message: string, data: Record) => void | Promise; +} + +/** + * Remove old OMMS copies from the running copy's cache folder. A copy goes only + * when its timestamp and its version are both older than the running copy's, + * it is not in `keepRoots`, and the running copy is at least + * `CACHE_PRUNE_MIN_AGE_MS` old. A copy that cannot be removed stays. Logs one + * `OpenCode cache clean-up` record with counts when an older copy was + * considered, so a pass with nothing to do stays silent. Never throws. + */ +export async function pruneOldCacheCopies( + options: PruneOptions +): Promise<{ removed: number; kept: number }> { + const counts = { removed: 0, kept: 0 }; + try { + const copies = listHostCopies(options.modulePath); + if (!copies || copies.stamp === null) return counts; + const older = copies.siblings.filter((copy) => copy.stamp < copies.stamp!); + if (older.length === 0) return counts; + const keep = new Set(options.keepRoots.map(folderKey)); + const old = options.now - copies.stamp >= CACHE_PRUNE_MIN_AGE_MS; + const remove = options.remove ?? ((folder) => rm(folder, { recursive: true, force: true })); + for (const copy of older) { + const removable = + old && + copy.version !== null && + isOlderVersion(copy.version, options.runningVersion) && + !keep.has(folderKey(copy.root)); + if (removable) { + try { + // The timestamp folder holds the whole install, not only the package folder. + await remove(join(copy.root, "..", "..")); + counts.removed++; + continue; + } catch { + // A locked or busy folder stays. + } + } + counts.kept++; + } + try { + void Promise.resolve(options.log("OpenCode cache clean-up", { ...counts })).catch(() => {}); + } catch { + // A failed log write must not change the result. + } + } catch { + // The clean-up is housekeeping. It never breaks the service. + } + return counts; +} diff --git a/src/services/host-self-update.ts b/src/services/host-self-update.ts index 8136f48e..bce7b8de 100644 --- a/src/services/host-self-update.ts +++ b/src/services/host-self-update.ts @@ -1,7 +1,9 @@ import { spawn } from "node:child_process"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; +import { installedVersions } from "./host-copies.js"; import { availableUpdate, latestNpmVersion } from "./update-check.js"; +import { compareVersions } from "./version-compare.js"; // Pi and OpenCode install OMMS from npm, but neither updates its plugins by // itself. At session start OMMS compares the running copy with npm `latest` @@ -57,6 +59,8 @@ export interface HostSelfUpdateDeps { now: () => number; readMarker: () => HostUpdateMarker | null; writeMarker: (value: HostUpdateMarker) => void; + /** The OMMS versions found on disk: the running copy's and, for OpenCode, its cache siblings. */ + installedVersions: () => string[]; /** Start a process that outlives this session. */ spawnDetached: (command: string, args: string[]) => void; log: (message: string, data: Record) => void | Promise; @@ -165,6 +169,35 @@ export function markerStatus( return marker.outcome ?? "pending"; } +/** + * `installed` holds only when a copy on disk has the release (or a newer one, + * when a release landed during the update). The host command can exit with 0 + * and install nothing; that outcome reads as `failed`. Other states pass through. + */ +export function confirmedStatus( + status: HostUpdateStatus | null, + version: string, + installed: string[] +): HostUpdateStatus | null { + if (status !== "installed") return status; + const found = installed.some( + (have) => have === version || (compareVersions(have, version) ?? -1) >= 0 + ); + return found ? "installed" : "failed"; +} + +/** The marker's state for `version`, with `installed` confirmed against the copies on disk. */ +export function hostUpdateStatus( + deps: Pick, + version: string +): HostUpdateStatus | null { + return confirmedStatus( + markerStatus(deps.readMarker(), version), + version, + deps.installedVersions() + ); +} + /** Read a marker file. A missing, unreadable or malformed file is no marker. */ export function readMarkerFile(path: string): HostUpdateMarker | null { try { @@ -287,17 +320,21 @@ export function hostSelfUpdateDeps(options: { command: string; args: string[]; markerPath: string; + /** The file of the running module. The installed copies are found from it. */ + modulePath: string; installSource: () => HostInstallSource; log: HostSelfUpdateDeps["log"]; env?: NodeJS.ProcessEnv; }): HostSelfUpdateDeps { const env = options.env ?? process.env; + const { modulePath, ...rest } = options; return { - ...options, + ...rest, enabled: env.OMMS_DISABLE_UPDATE_CHECK !== "1", fetch: globalThis.fetch.bind(globalThis), now: Date.now, readMarker: () => readMarkerFile(options.markerPath), + installedVersions: () => installedVersions(modulePath), writeMarker: (value) => { mkdirSync(dirname(options.markerPath), { recursive: true }); writeFileSync(options.markerPath, JSON.stringify(value)); diff --git a/src/v2/plugin.ts b/src/v2/plugin.ts index 6c9c49f9..92651779 100644 --- a/src/v2/plugin.ts +++ b/src/v2/plugin.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from "node:url"; import type { Plugin } from "@opencode/plugin/promise/plugin"; import { OmmsPlugin } from "../index.js"; import { loadOpencodeProvider } from "../adapters/opencode/opencode-provider-loader.js"; @@ -5,6 +6,42 @@ import { registerV2Adapter } from "./adapter.js"; import { createV2MemoryBridge } from "./memory-bridge.js"; import { createLegacyClient } from "./legacy-client.js"; +/** + * Remove old OMMS copies from the OpenCode cache. It runs in the background and + * is not awaited, so a slow or failing clean-up never delays or breaks setup. + * `OMMS_DISABLE_UPDATE_CHECK` does not stop it: removing old copies saves disk + * space and installs nothing. It keeps the copy that `~/.omms/runtime.json` names. + */ +function startCacheCleanup(): void { + void (async () => { + const modulePath = fileURLToPath(import.meta.url); + const [ + { installedFromNpm }, + { pruneOldCacheCopies }, + { readRuntimeRecord }, + { ommsDir }, + { packageVersion }, + { log }, + ] = await Promise.all([ + import("../services/host-self-update.js"), + import("../services/host-copies.js"), + import("../services/runtime-record.js"), + import("../services/runtime-handoff.js"), + import("../services/package-version.js"), + import("../services/logger.js"), + ]); + if (!installedFromNpm(modulePath)) return; + const recorded = readRuntimeRecord(ommsDir()); + await pruneOldCacheCopies({ + modulePath, + runningVersion: packageVersion(), + keepRoots: recorded ? [recorded.root] : [], + now: Date.now(), + log: (message, data) => log(message, data), + }); + })().catch(() => {}); +} + const OmmsPluginV2: Plugin = { id: "omms", async setup(ctx) { @@ -22,7 +59,13 @@ const OmmsPluginV2: Plugin = { const { setV2Client } = await loadOpencodeProvider(); setV2Client(legacyClient); - return registerV2Adapter(ctx, legacy, createV2MemoryBridge(ctx.location.directory)); + const adapter = await registerV2Adapter( + ctx, + legacy, + createV2MemoryBridge(ctx.location.directory) + ); + startCacheCleanup(); + return adapter; }, }; diff --git a/tests/host-copies.test.ts b/tests/host-copies.test.ts new file mode 100644 index 00000000..dff701f3 --- /dev/null +++ b/tests/host-copies.test.ts @@ -0,0 +1,360 @@ +import { afterEach, expect, it } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + CACHE_PRUNE_MIN_AGE_MS, + installedVersions, + listHostCopies, + pruneOldCacheCopies, +} from "../src/services/host-copies.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** An empty OpenCode-style cache folder under the system temp folder. */ +function cacheFolder(): string { + const dir = mkdtempSync(join(tmpdir(), "omms-host-copies-")); + tempDirs.push(dir); + const cache = join(dir, "om-memory-system@latest"); + mkdirSync(cache, { recursive: true }); + return cache; +} + +/** Put a copy at `//node_modules/om-memory-system`. A null version writes no package.json. */ +function addCopy(cache: string, stamp: number, version: string | null): string { + const root = join(cache, String(stamp), "node_modules", "om-memory-system"); + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync(join(root, "dist", "plugin.js"), "// copy"); + if (version !== null) { + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "om-memory-system", version }) + ); + } + return root; +} + +const modulePathOf = (root: string): string => join(root, "dist", "plugin.js"); + +const RUNNING = 1_700_000_000_000; +const DAY = CACHE_PRUNE_MIN_AGE_MS; + +it("keeps the minimum age at 24 hours", () => { + expect(CACHE_PRUNE_MIN_AGE_MS).toBe(24 * 60 * 60 * 1000); +}); + +it("lists the running copy and its timestamp siblings with versions", () => { + const cache = cacheFolder(); + const running = addCopy(cache, RUNNING, "4.3.0"); + const older = addCopy(cache, RUNNING - 5_000, "4.2.0"); + const newer = addCopy(cache, RUNNING + 5_000, "4.4.0"); + + const copies = listHostCopies(modulePathOf(running)); + expect(copies).toEqual({ + root: running, + version: "4.3.0", + stamp: RUNNING, + siblings: [ + { root: older, version: "4.2.0", stamp: RUNNING - 5_000 }, + { root: newer, version: "4.4.0", stamp: RUNNING + 5_000 }, + ], + }); +}); + +it("gives a sibling without a readable package.json a null version", () => { + const cache = cacheFolder(); + const running = addCopy(cache, RUNNING, "4.3.0"); + const broken = addCopy(cache, RUNNING - 1, null); + expect(listHostCopies(modulePathOf(running))!.siblings).toEqual([ + { root: broken, version: null, stamp: RUNNING - 1 }, + ]); +}); + +it("ignores sibling folders whose names are not all digits", () => { + const cache = cacheFolder(); + const running = addCopy(cache, RUNNING, "4.3.0"); + mkdirSync(join(cache, "latest")); + mkdirSync(join(cache, "12ab")); + writeFileSync(join(cache, "99999"), "a file, not a folder"); + expect(listHostCopies(modulePathOf(running))!.siblings).toEqual([]); +}); + +it("lists no siblings when the folder above node_modules is not a timestamp", () => { + const dir = mkdtempSync(join(tmpdir(), "omms-host-copies-")); + tempDirs.push(dir); + const root = join(dir, "npm", "node_modules", "om-memory-system"); + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "om-memory-system", version: "4.3.0" }) + ); + mkdirSync(join(dir, "npm", "node_modules", "other"), { recursive: true }); + expect(listHostCopies(modulePathOf(root))).toEqual({ + root, + version: "4.3.0", + stamp: null, + siblings: [], + }); +}); + +it("returns null when the module is not inside node_modules/om-memory-system", () => { + expect(listHostCopies("/work/omms/src/v2/plugin.ts")).toBeNull(); + expect(listHostCopies("/cache/node_modules/other-package/dist/a.js")).toBeNull(); +}); + +it("reads a Windows module path with backslashes", () => { + const path = + "C:\\cache\\om-memory-system@latest\\1700000000000\\node_modules\\om-memory-system\\dist\\a.js"; + const copies = listHostCopies(path); + expect(copies).not.toBeNull(); + expect(copies!.root).toBe( + "C:\\cache\\om-memory-system@latest\\1700000000000\\node_modules\\om-memory-system" + ); + expect(copies!.stamp).toBe(1_700_000_000_000); + // The folder does not exist on this machine, so nothing can be read. + expect(copies!.version).toBeNull(); + expect(copies!.siblings).toEqual([]); +}); + +it("lists the versions on disk: Pi updates in place, OpenCode adds a folder", () => { + // Pi: one root, its package.json now shows the new version. + const dir = mkdtempSync(join(tmpdir(), "omms-host-copies-")); + tempDirs.push(dir); + const piRoot = join(dir, "npm", "node_modules", "om-memory-system"); + mkdirSync(piRoot, { recursive: true }); + writeFileSync( + join(piRoot, "package.json"), + JSON.stringify({ name: "om-memory-system", version: "4.3.0" }) + ); + expect(installedVersions(modulePathOf(piRoot))).toEqual(["4.3.0"]); + + // OpenCode: the running folder holds 4.2.0, a new sibling holds 4.3.0, a third has no version. + const cache = cacheFolder(); + const running = addCopy(cache, RUNNING, "4.2.0"); + addCopy(cache, RUNNING + 10, "4.3.0"); + addCopy(cache, RUNNING + 20, null); + expect(installedVersions(modulePathOf(running)).sort()).toEqual(["4.2.0", "4.3.0"]); +}); + +it("lists no versions for a path outside an npm install and never throws", async () => { + expect(installedVersions("/work/omms/src/v2/plugin.ts")).toEqual([]); + expect(installedVersions("")).toEqual([]); +}); + +/** Run the clean-up for a cache that has a running copy `daysOld` old, plus the given extra copies. */ +async function prune(options: { + runningVersion?: string; + runningAgeMs?: number; + copies?: [stamp: number, version: string | null][]; + keep?: (cache: string) => string[]; + remove?: (folder: string) => void | Promise; + log?: (message: string, data: Record) => void | Promise; +}) { + const cache = cacheFolder(); + const running = addCopy(cache, RUNNING, options.runningVersion ?? "4.3.0"); + const roots = new Map(); + for (const [stamp, version] of options.copies ?? []) + roots.set(stamp, addCopy(cache, stamp, version)); + const logs: { message: string; data: Record }[] = []; + const result = await pruneOldCacheCopies({ + modulePath: modulePathOf(running), + runningVersion: options.runningVersion ?? "4.3.0", + keepRoots: options.keep?.(cache) ?? [], + now: RUNNING + (options.runningAgeMs ?? 2 * DAY), + remove: options.remove, + log: options.log ?? ((message, data) => void logs.push({ message, data })), + }); + return { cache, running, roots, logs, result, stamp: (n: number) => join(cache, String(n)) }; +} + +it("removes an older copy with an older version, and nothing else", async () => { + const t = await prune({ + copies: [ + [RUNNING - 1_000, "4.2.0"], + [RUNNING - 2_000, "4.1.0"], + ], + }); + expect(t.result).toEqual({ removed: 2, kept: 0 }); + expect(existsSync(t.stamp(RUNNING - 1_000))).toBe(false); + expect(existsSync(t.stamp(RUNNING - 2_000))).toBe(false); + // The running folder stays, with the files it needs. + expect(existsSync(join(t.running, "dist", "plugin.js"))).toBe(true); +}); + +it("keeps a newer copy, an unreadable copy and a copy with an equal or newer version", async () => { + const t = await prune({ + copies: [ + [RUNNING + 1_000, "4.4.0"], + [RUNNING - 1_000, null], + [RUNNING - 2_000, "4.3.0"], + [RUNNING - 3_000, "4.5.0"], + [RUNNING + 2_000, "4.0.0"], + ], + }); + // Only the three copies with an older timestamp count as considered; none is removed. + expect(t.result).toEqual({ removed: 0, kept: 3 }); + for (const stamp of [ + RUNNING + 1_000, + RUNNING - 1_000, + RUNNING - 2_000, + RUNNING - 3_000, + RUNNING + 2_000, + ]) { + expect(existsSync(t.stamp(stamp))).toBe(true); + } +}); + +it("keeps the copy that runtime.json records", async () => { + const t = await prune({ + copies: [ + [RUNNING - 1_000, "4.2.0"], + [RUNNING - 2_000, "4.1.0"], + ], + keep: (cache) => [join(cache, String(RUNNING - 1_000), "node_modules", "om-memory-system")], + }); + expect(t.result).toEqual({ removed: 1, kept: 1 }); + expect(existsSync(t.stamp(RUNNING - 1_000))).toBe(true); + expect(existsSync(t.stamp(RUNNING - 2_000))).toBe(false); +}); + +it("treats a recorded root with a trailing slash or backslashes as the same copy", async () => { + const t = await prune({ + copies: [[RUNNING - 1_000, "4.2.0"]], + keep: (cache) => [ + `${join(cache, String(RUNNING - 1_000), "node_modules", "om-memory-system")}/`, + ], + }); + expect(t.result).toEqual({ removed: 0, kept: 1 }); +}); + +it("removes nothing while the running copy is under 24 hours old, and removes at exactly 24 hours", async () => { + const recent = await prune({ copies: [[RUNNING - 1_000, "4.2.0"]], runningAgeMs: DAY - 1 }); + expect(recent.result).toEqual({ removed: 0, kept: 1 }); + expect(existsSync(recent.stamp(RUNNING - 1_000))).toBe(true); + + const exact = await prune({ copies: [[RUNNING - 1_000, "4.2.0"]], runningAgeMs: DAY }); + expect(exact.result).toEqual({ removed: 1, kept: 0 }); + expect(existsSync(exact.stamp(RUNNING - 1_000))).toBe(false); +}); + +it("keeps a folder that cannot be removed, counts it, and still removes the others", async () => { + const attempts: string[] = []; + const t = await prune({ + copies: [ + [RUNNING - 1_000, "4.2.0"], + [RUNNING - 2_000, "4.1.0"], + ], + remove: (folder) => { + attempts.push(folder); + if (folder.endsWith(String(RUNNING - 1_000))) throw new Error("EBUSY"); + }, + }); + expect(attempts).toHaveLength(2); + expect(t.result).toEqual({ removed: 1, kept: 1 }); + expect(existsSync(t.stamp(RUNNING - 1_000))).toBe(true); +}); + +it("removes the timestamp folder, not only the package folder", async () => { + const removed: string[] = []; + const t = await prune({ + copies: [[RUNNING - 1_000, "4.2.0"]], + remove: (folder) => void removed.push(folder), + }); + expect(removed).toEqual([t.stamp(RUNNING - 1_000)]); +}); + +it("writes one record with counts only when an older copy was considered", async () => { + const t = await prune({ + copies: [ + [RUNNING - 1_000, "4.2.0"], + [RUNNING - 2_000, null], + ], + }); + expect(t.logs).toEqual([{ message: "OpenCode cache clean-up", data: { removed: 1, kept: 1 } }]); + // No path or file text in the record. + expect(JSON.stringify(t.logs)).not.toContain(tmpdir()); +}); + +it("writes the record even when the 24 hour wait keeps every older copy", async () => { + const t = await prune({ copies: [[RUNNING - 1_000, "4.2.0"]], runningAgeMs: 1_000 }); + expect(t.logs).toEqual([{ message: "OpenCode cache clean-up", data: { removed: 0, kept: 1 } }]); +}); + +it("writes no record when no older copy exists", async () => { + const none = await prune({ copies: [[RUNNING + 1_000, "4.4.0"]] }); + expect(none.result).toEqual({ removed: 0, kept: 0 }); + expect(none.logs).toEqual([]); +}); + +it("does nothing and writes no record outside a timestamp folder", async () => { + const logs: unknown[] = []; + const result = await pruneOldCacheCopies({ + modulePath: "/work/omms/src/v2/plugin.ts", + runningVersion: "4.3.0", + keepRoots: [], + now: Date.now(), + log: (message) => void logs.push(message), + }); + expect(result).toEqual({ removed: 0, kept: 0 }); + expect(logs).toEqual([]); +}); + +it("removes nothing when the running version cannot be placed", async () => { + const t = await prune({ runningVersion: "unknown", copies: [[RUNNING - 1_000, "4.2.0"]] }); + expect(t.result).toEqual({ removed: 0, kept: 1 }); + expect(existsSync(t.stamp(RUNNING - 1_000))).toBe(true); +}); + +it("never throws when the log fails or the log returns a rejected promise", async () => { + const sync = await prune({ + copies: [[RUNNING - 1_000, "4.2.0"]], + log: () => { + throw new Error("disk full"); + }, + }); + expect(sync.result).toEqual({ removed: 1, kept: 0 }); + + const asyncFail = await prune({ + copies: [[RUNNING - 1_000, "4.2.0"]], + log: () => Promise.reject(new Error("disk full")), + }); + expect(asyncFail.result).toEqual({ removed: 1, kept: 0 }); + // Let the rejected promise settle; an unhandled rejection would fail the run. + await new Promise((resolve) => setTimeout(resolve, 10)); +}); + +it("waits for an asynchronous removal and counts a rejected one as kept", async () => { + let finished = 0; + const t = await prune({ + copies: [ + [RUNNING - 1_000, "4.2.0"], + [RUNNING - 2_000, "4.1.0"], + ], + remove: async (folder) => { + await new Promise((resolve) => setTimeout(resolve, 5)); + if (folder.endsWith(String(RUNNING - 2_000))) throw new Error("EBUSY"); + finished++; + }, + }); + expect(finished).toBe(1); + expect(t.result).toEqual({ removed: 1, kept: 1 }); + expect(t.logs).toEqual([{ message: "OpenCode cache clean-up", data: { removed: 1, kept: 1 } }]); +}); + +it("keeps a recorded copy named through a symbolic link", async () => { + const links = mkdtempSync(join(tmpdir(), "omms-copy-link-")); + const t = await prune({ + copies: [[RUNNING - 1_000, "4.2.0"]], + keep: (cache) => { + const link = join(links, "recorded"); + symlinkSync(join(cache, String(RUNNING - 1_000), "node_modules", "om-memory-system"), link); + return [link]; + }, + }); + expect(t.result).toEqual({ removed: 0, kept: 1 }); + expect(existsSync(t.stamp(RUNNING - 1_000))).toBe(true); +}); diff --git a/tests/host-self-update.test.ts b/tests/host-self-update.test.ts index 07c18916..56bc72aa 100644 --- a/tests/host-self-update.test.ts +++ b/tests/host-self-update.test.ts @@ -1,11 +1,14 @@ import { expect, it } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { HOST_SELF_UPDATE_RETRY_MS, + confirmedStatus, + hostSelfUpdateDeps, hostUpdateRunnerArgs, + hostUpdateStatus, installedFromNpm, markerStatus, readMarkerFile, @@ -28,6 +31,8 @@ function fake(options: { marker?: HostUpdateMarker | null; now?: number; spawnThrows?: boolean; + /** The versions found on disk. Defaults to the new release being there. */ + installed?: string[]; }) { const spawned: { command: string; args: string[] }[] = []; const logs: { message: string; data: Record }[] = []; @@ -51,6 +56,7 @@ function fake(options: { writeMarker: (value) => { marker = value; }, + installedVersions: () => options.installed ?? ["4.3.0"], spawnDetached: (command, args) => { if (options.spawnThrows) throw new Error("ENOENT"); spawned.push({ command, args }); @@ -350,3 +356,51 @@ it("stops watching after the limit and on stop", async () => { await Bun.sleep(20); expect(stoppedReads).toBe(1); }); + +it("confirms an installed outcome only when a copy holds the new version", () => { + expect(confirmedStatus("installed", "4.3.0", ["4.2.0", "4.3.0"])).toBe("installed"); + expect(confirmedStatus("installed", "4.3.0", ["4.2.0"])).toBe("failed"); + expect(confirmedStatus("installed", "4.3.0", [])).toBe("failed"); + // A release that landed during the update still satisfies it. + expect(confirmedStatus("installed", "4.3.0", ["4.3.1"])).toBe("installed"); + // The other states pass through unchanged. + expect(confirmedStatus("pending", "4.3.0", [])).toBe("pending"); + expect(confirmedStatus("failed", "4.3.0", ["4.3.0"])).toBe("failed"); + expect(confirmedStatus(null, "4.3.0", ["4.3.0"])).toBeNull(); +}); + +it("reads the marker and the installed copies together", () => { + const done = (outcome?: "installed" | "failed"): HostUpdateMarker => ({ + version: "4.3.0", + startedAt: 1, + ...(outcome ? { outcome, finishedAt: 2 } : {}), + }); + expect(hostUpdateStatus(fake({ marker: done("installed") }).deps, "4.3.0")).toBe("installed"); + expect( + hostUpdateStatus(fake({ marker: done("installed"), installed: ["4.2.0"] }).deps, "4.3.0") + ).toBe("failed"); + expect(hostUpdateStatus(fake({ marker: done(), installed: [] }).deps, "4.3.0")).toBe("pending"); + expect(hostUpdateStatus(fake({ marker: null }).deps, "4.3.0")).toBeNull(); +}); + +it("builds production inputs that list the versions on disk from the module path", () => { + const dir = mkdtempSync(join(tmpdir(), "omms-host-update-")); + const root = join(dir, "node_modules", "om-memory-system"); + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "om-memory-system", version: "4.3.0" }) + ); + const deps = hostSelfUpdateDeps({ + logLabel: "Test plugin self-update", + currentVersion: "4.2.0", + command: "testhost", + args: [], + markerPath: join(dir, "marker.json"), + modulePath: join(root, "dist", "plugin.js"), + installSource: () => "npm", + log: () => {}, + env: {}, + }); + expect(deps.installedVersions()).toEqual(["4.3.0"]); +}); diff --git a/tests/opencode-tui-status.test.ts b/tests/opencode-tui-status.test.ts index fdc4ca64..9a49a9aa 100644 --- a/tests/opencode-tui-status.test.ts +++ b/tests/opencode-tui-status.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "bun:test"; import { fileURLToPath } from "node:url"; import { startTuiStatus, tuiStatusText } from "../src/adapters/opencode/tui-status.js"; -import type { HostSelfUpdateResult, HostUpdateStatus } from "../src/services/host-self-update.js"; +import { + hostUpdateStatus, + type HostSelfUpdateDeps, + type HostSelfUpdateResult, + type HostUpdateMarker, + type HostUpdateStatus, +} from "../src/services/host-self-update.js"; import { availableUpdate, latestNpmVersion } from "../src/services/update-check.js"; const json = (body: unknown, status = 200) => @@ -270,4 +276,50 @@ describe("OpenCode footer background update", () => { expect(called).toBe(false); expect(texts.at(-1)).toBe("omms: ✔"); }); + + it("shows available and names the command when the marker says installed but no copy has the version", async () => { + // The same status read that startInstalledTuiStatus wires in: marker plus installed copies. + const marker: HostUpdateMarker = { + version: "4.3.0", + startedAt: 1, + outcome: "installed", + finishedAt: 2, + }; + const installedCopies = { versions: ["4.2.0"] }; + const deps = { + readMarker: () => marker, + installedVersions: () => installedCopies.versions, + } as unknown as HostSelfUpdateDeps; + const texts: string[] = []; + const notes: string[] = []; + const stop = startTuiStatus({ + currentVersion: "4.2.0", + healthUrl: "http://127.0.0.1:4747/api/health", + fetch: (async () => json({ status: "ok" })) as any, + setText: (text) => texts.push(text), + notify: (message) => notes.push(message), + selfUpdate: async () => ({ code: "recent", update: "4.3.0" }), + readUpdateStatus: (version) => hostUpdateStatus(deps, version), + }); + await settle(); + stop(); + expect(texts.at(-1)).toBe("omms: ✔ · 4.3.0 available"); + expect(notes).toEqual([AVAILABLE_TEXT]); + + // With the new copy on disk the same read gives installed. + installedCopies.versions = ["4.2.0", "4.3.0"]; + const texts2: string[] = []; + const stop2 = startTuiStatus({ + currentVersion: "4.2.0", + healthUrl: "http://127.0.0.1:4747/api/health", + fetch: (async () => json({ status: "ok" })) as any, + setText: (text) => texts2.push(text), + notify: () => {}, + selfUpdate: async () => ({ code: "recent", update: "4.3.0" }), + readUpdateStatus: (version) => hostUpdateStatus(deps, version), + }); + await settle(); + stop2(); + expect(texts2.at(-1)).toBe("omms: ✔ · 4.3.0 installed, restart"); + }); }); diff --git a/tests/pi-self-update.test.ts b/tests/pi-self-update.test.ts index 7ef4495b..2a841d2f 100644 --- a/tests/pi-self-update.test.ts +++ b/tests/pi-self-update.test.ts @@ -28,11 +28,14 @@ function fakeHost(options: { latest?: string | null; enabled?: boolean; source?: "npm" | "local" | "pinned"; + /** The versions found on disk. Defaults to the new release being there. */ + installed?: string[]; marker?: HostUpdateMarker | null; }) { const spawned: { command: string; args: string[] }[] = []; const logs: { message: string; data: Record }[] = []; let marker = options.marker ?? null; + let installed = options.installed ?? ["4.3.0"]; const deps: HostSelfUpdateDeps = { enabled: options.enabled ?? true, logLabel: "Pi plugin self-update", @@ -50,10 +53,17 @@ function fakeHost(options: { writeMarker: (value) => { marker = value; }, + installedVersions: () => installed, spawnDetached: (command, args) => spawned.push({ command, args }), log: (message, data) => logs.push({ message, data }), }; - return { deps, spawned, logs, setMarker: (value: HostUpdateMarker | null) => (marker = value) }; + return { + deps, + spawned, + logs, + setMarker: (value: HostUpdateMarker | null) => (marker = value), + setInstalled: (value: string[]) => (installed = value), + }; } /** A controller with a fake poll, plus the messages and label changes it produced. */ @@ -339,3 +349,41 @@ it("shares one check between sessions that start while it runs", async () => { expect(fetches).toBe(1); expect(t.host.spawned).toHaveLength(1); }); + +it("treats an installed marker as failed when the new version is not on disk", async () => { + // The marker says installed, but the Pi package still holds 4.2.0. + const t = setup({ + marker: { version: "4.3.0", startedAt: 999_000, outcome: "installed", finishedAt: 999_500 }, + installed: ["4.2.0"], + }); + await t.start(); + expect(t.update.suffix()).toBe(" · 4.3.0 available"); + expect(t.notices.map((n) => n.message)).toEqual([expect.stringContaining(COMMAND)]); + expect(t.notices[0]!.level).toBe("warning"); + expect(t.watches).toHaveLength(0); +}); + +it("confirms the install again each time the poll reads the marker", async () => { + const t = setup(); + await t.start(); + const read = t.watches[0]!.read; + expect(read()).toBe("pending"); + t.host.setMarker({ version: "4.3.0", startedAt: 1, outcome: "installed", finishedAt: 2 }); + t.host.setInstalled(["4.2.0"]); + expect(read()).toBe("failed"); + t.host.setInstalled(["4.3.0"]); + expect(read()).toBe("installed"); +}); + +it("builds production inputs for Pi that read the installed versions from the module path", () => { + const dir = mkdtempSync(join(tmpdir(), "omms-pi-self-update-")); + tempDirs.push(dir); + const root = join(dir, "npm", "node_modules", "om-memory-system"); + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "om-memory-system", version: "4.3.0" }) + ); + const modulePath = join(root, "dist", "x.js"); + expect(piHostSelfUpdateDeps({ env: {}, modulePath }).installedVersions()).toEqual(["4.3.0"]); +}); diff --git a/tests/v2-cache-cleanup.test.ts b/tests/v2-cache-cleanup.test.ts new file mode 100644 index 00000000..85145e1b --- /dev/null +++ b/tests/v2-cache-cleanup.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, expect, it, mock } from "bun:test"; + +// Everything the V2 plugin pulls in is replaced, so setup runs without OpenCode, +// a database or the real ~/.omms. Only the wiring of the cache clean-up is tested. + +const state = { + npm: true, + record: { root: "/cache/om-memory-system@latest/1/node_modules/om-memory-system" } as { + root: string; + } | null, + prune: (async () => ({ removed: 0, kept: 0 })) as (options: any) => Promise, + calls: [] as any[], + logs: [] as { message: string; data: unknown }[], +}; + +mock.module("../src/index.js", () => ({ OmmsPlugin: async () => ({}) })); +mock.module("../src/adapters/opencode/opencode-provider-loader.js", () => ({ + loadOpencodeProvider: async () => ({ setV2Client: () => {} }), +})); +mock.module("../src/v2/adapter.js", () => ({ registerV2Adapter: async () => "adapter" })); +mock.module("../src/v2/memory-bridge.js", () => ({ createV2MemoryBridge: () => ({}) })); +mock.module("../src/v2/legacy-client.js", () => ({ createLegacyClient: () => ({}) })); +mock.module("../src/services/host-self-update.js", () => ({ + installedFromNpm: () => state.npm, +})); +mock.module("../src/services/host-copies.js", () => ({ + pruneOldCacheCopies: (options: any) => { + state.calls.push(options); + return state.prune(options); + }, +})); +mock.module("../src/services/runtime-record.js", () => ({ + readRuntimeRecord: () => state.record, +})); +mock.module("../src/services/runtime-handoff.js", () => ({ ommsDir: () => "/fake/.omms" })); +mock.module("../src/services/package-version.js", () => ({ packageVersion: () => "4.3.0" })); +mock.module("../src/services/logger.js", () => ({ + log: (message: string, data: unknown) => state.logs.push({ message, data }), +})); + +const { default: plugin } = await import("../src/v2/plugin.js"); + +const ctx = { + location: { directory: "/work", project: { directory: "/work" } }, +} as any; + +const until = async (done: () => boolean) => { + for (let i = 0; i < 100 && !done(); i++) await new Promise((resolve) => setTimeout(resolve, 5)); +}; + +beforeEach(() => { + state.npm = true; + state.record = { root: "/cache/om-memory-system@latest/1/node_modules/om-memory-system" }; + state.prune = async () => ({ removed: 0, kept: 0 }); + state.calls = []; + state.logs = []; +}); + +afterEach(() => { + delete process.env.OMMS_DISABLE_UPDATE_CHECK; +}); + +it("starts the clean-up with the running copy, its version and the recorded copy", async () => { + expect(await plugin.setup(ctx)).toBe("adapter"); + await until(() => state.calls.length > 0); + expect(state.calls).toHaveLength(1); + const options = state.calls[0]; + expect(options.modulePath.replaceAll("\\", "/")).toEndWith("/src/v2/plugin.ts"); + expect(options.runningVersion).toBe("4.3.0"); + expect(options.keepRoots).toEqual([state.record!.root]); + expect(typeof options.now).toBe("number"); + // The record goes to the OMMS log. + options.log("OpenCode cache clean-up", { removed: 1, kept: 0 }); + expect(state.logs).toEqual([ + { message: "OpenCode cache clean-up", data: { removed: 1, kept: 0 } }, + ]); +}); + +it("does not wait for the clean-up before setup returns", async () => { + let release!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + let finished = false; + state.prune = async () => { + await gate; + finished = true; + return { removed: 1, kept: 0 }; + }; + const adapter = await plugin.setup(ctx); + expect(adapter).toBe("adapter"); + await until(() => state.calls.length > 0); + // Setup returned while the clean-up is still running. + expect(state.calls).toHaveLength(1); + expect(finished).toBe(false); + release(); + await until(() => finished); + expect(finished).toBe(true); +}); + +it("keeps setup working when the clean-up throws or rejects", async () => { + state.prune = () => { + throw new Error("EBUSY"); + }; + expect(await plugin.setup(ctx)).toBe("adapter"); + await until(() => state.calls.length > 0); + expect(state.calls).toHaveLength(1); + + state.calls = []; + state.prune = () => Promise.reject(new Error("EBUSY")); + expect(await plugin.setup(ctx)).toBe("adapter"); + await until(() => state.calls.length > 0); + expect(state.calls).toHaveLength(1); + // Let a rejection settle; an unhandled one would fail the run. + await new Promise((resolve) => setTimeout(resolve, 20)); +}); + +it("does not run the clean-up when the plugin is not an npm install", async () => { + state.npm = false; + expect(await plugin.setup(ctx)).toBe("adapter"); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(state.calls).toEqual([]); +}); + +it("keeps nothing extra when runtime.json has no record", async () => { + state.record = null; + await plugin.setup(ctx); + await until(() => state.calls.length > 0); + expect(state.calls[0].keepRoots).toEqual([]); +}); + +it("still runs the clean-up when the update check is turned off", async () => { + process.env.OMMS_DISABLE_UPDATE_CHECK = "1"; + await plugin.setup(ctx); + await until(() => state.calls.length > 0); + expect(state.calls).toHaveLength(1); +}); From c8918f2a56b32852775834bcae16694e5bb7aa2c Mon Sep 17 00:00:00 2001 From: Aizat Hawari Date: Sun, 8 Nov 2026 00:34:23 +0000 Subject: [PATCH 6/7] fix: address review on host self-update OpenCode's background service now runs the update check too, so OpenCode without its TUI also updates. The cache clean-up only treats timestamp folders under an om-memory-system cache folder, and logs zero counts. Tests no longer touch the real home folder. --- docs/opencode-adapter.md | 2 +- docs/upgrading.md | 5 +- .../proposal.md | 2 +- src/adapters/opencode/self-update.ts | 60 ++++++++++++++ src/adapters/opencode/tui-status.ts | 17 +--- src/services/host-copies.ts | 15 ++-- src/v2/plugin.ts | 33 ++++++++ tests/host-copies.test.ts | 36 ++++++++- tests/opencode-self-update.test.ts | 79 +++++++++++++++++++ tests/pi-self-update.test.ts | 20 ++++- tests/v2-cache-cleanup.test.ts | 26 +++++- 11 files changed, 263 insertions(+), 32 deletions(-) create mode 100644 src/adapters/opencode/self-update.ts create mode 100644 tests/opencode-self-update.test.ts diff --git a/docs/opencode-adapter.md b/docs/opencode-adapter.md index 9c0821d1..2851de40 100644 --- a/docs/opencode-adapter.md +++ b/docs/opencode-adapter.md @@ -150,7 +150,7 @@ OpenCode 2.0.22 and later load the plugin's `./tui` entry. It shows OMMS's state - `omms: ✘ web app off` when the web app does not answer. Capture and retrieval keep working. - `omms: ✘` when the footer cannot start its status check. - `· available` when npm has a newer release and OMMS does not install it (local checkout, pinned version, or a failed update). A toast also names the update command once per start. -- `· installed, restart` after OMMS installs the newer release in the background and finds the new version on disk. A toast tells you to restart OpenCode and run `opencode service restart`, because the background service keeps the old copy loaded. While the update installs, a toast says so. When the update command ends with success but the new version is not on disk, the footer keeps `· available` and a toast names the update command. +- `· installed, restart` after OMMS installs the newer release in the background and finds the new version on disk. A toast tells you to restart OpenCode and run `opencode service restart`, because the background service keeps the old copy loaded. While the update installs, a toast says so. The background service also runs the update check, so OpenCode without the TUI (for example OpenChamber or `opencode serve`) updates too. It shows no notice. When the update command ends with success but the new version is not on disk, the footer keeps `· available` and a toast names the update command. The plugin checks npm at start and every 6 hours. When npm has a newer release, it runs `opencode plugin update om-memory-system` in the background. Set `OMMS_DISABLE_UPDATE_CHECK=1` to turn the check and the update off. See [Updating and upgrading](upgrading.md#updating-omms). diff --git a/docs/upgrading.md b/docs/upgrading.md index 72e4f53a..5686ea75 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -9,8 +9,9 @@ shows. Pi and OpenCode then install each new release by themselves. When a Pi or OpenCode session starts, OMMS compares its version with npm `latest`. If npm has a newer release (not a prerelease), OMMS runs the host -update command in the background. OpenCode also checks every 6 hours. A second -session does not start another update for the same release within 30 minutes. +update command in the background. OpenCode also checks every 6 hours. The +OpenCode background service runs the same check, so OpenCode without its TUI +(for example OpenChamber) also updates. A second session does not start another update for the same release within 30 minutes. | Agent | Command that OMMS runs. You can run it by hand too. | | ----------- | ----------------------------------------------------------------------- | diff --git a/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/proposal.md b/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/proposal.md index d21b08b3..3870f114 100644 --- a/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/proposal.md +++ b/openspec/changes/archive/2026-11-08-host-update-verify-and-prune/proposal.md @@ -8,7 +8,7 @@ The live check of `pi-opencode-auto-update` found two gaps: ## What Changes - Before OMMS reports `installed`, it reads the installed copy and confirms that it holds the new version. When it does not, OMMS reports the update as failed: the footer keeps `· available` and the message names the update command. This applies to Pi and OpenCode. -- When the OpenCode background service starts OMMS from a timestamped cache folder, OMMS removes older OMMS copies in the same cache folder. It keeps the copy that runs, every newer copy, the copy that `~/.omms/runtime.json` records, and every copy installed less than 24 hours before the running copy. A copy that cannot be removed stays. Each pass logs one record with counts only. +- When the OpenCode background service starts OMMS from a timestamped cache folder, OMMS removes older OMMS copies with older versions in the same cache folder. It keeps the copy that runs, every newer copy, the copy that `~/.omms/runtime.json` records, and every copy without a readable version. It removes nothing while the running copy is less than 24 hours old. A copy that cannot be removed stays. Each pass logs one record with counts only. - Docs say that OMMS removes old OpenCode copies. ## Capabilities diff --git a/src/adapters/opencode/self-update.ts b/src/adapters/opencode/self-update.ts new file mode 100644 index 00000000..278e8b3e --- /dev/null +++ b/src/adapters/opencode/self-update.ts @@ -0,0 +1,60 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + hostSelfUpdateDeps, + installedFromNpm, + unpinnedCacheFolder, + type HostSelfUpdateDeps, +} from "../../services/host-self-update.js"; + +// OpenCode's update inputs, shared by the TUI footer and the background +// service. The service runs the check too, so OpenCode without a TUI (for +// example OpenChamber or `opencode serve`) also updates. The marker file stops +// the two from starting the same update twice. + +/** The background service checks npm at start and then this often, like the footer. */ +export const OPENCODE_SERVICE_CHECK_MS = 6 * 3_600_000; + +/** Production inputs for OpenCode: npm, the OpenCode marker file and a detached `opencode plugin update`. */ +export function opencodeSelfUpdateDeps(options: { + modulePath: string; + currentVersion: string; + log: HostSelfUpdateDeps["log"]; + home?: string; + env?: NodeJS.ProcessEnv; +}): HostSelfUpdateDeps { + const { modulePath } = options; + return hostSelfUpdateDeps({ + logLabel: "OpenCode plugin self-update", + currentVersion: options.currentVersion, + command: "opencode", + args: ["plugin", "update", "om-memory-system"], + markerPath: join(options.home ?? homedir(), ".omms", "opencode-plugin-update.json"), + modulePath, + installSource: () => + !installedFromNpm(modulePath) ? "local" : unpinnedCacheFolder(modulePath) ? "npm" : "pinned", + log: options.log, + ...(options.env ? { env: options.env } : {}), + }); +} + +/** + * Run the update check now and then every `intervalMs`. A failed check never + * throws. The timer does not keep the process alive. Returns a stop function. + */ +export function startOpencodeServiceSelfUpdate(options: { + run: () => unknown; + intervalMs?: number; +}): () => void { + const check = () => { + try { + void Promise.resolve(options.run()).catch(() => {}); + } catch { + // The update is housekeeping. It never breaks the service. + } + }; + check(); + const timer = setInterval(check, options.intervalMs ?? OPENCODE_SERVICE_CHECK_MS); + timer.unref?.(); + return () => clearInterval(timer); +} diff --git a/src/adapters/opencode/tui-status.ts b/src/adapters/opencode/tui-status.ts index 3b45b97f..181c7c89 100644 --- a/src/adapters/opencode/tui-status.ts +++ b/src/adapters/opencode/tui-status.ts @@ -1,12 +1,7 @@ -import { homedir } from "node:os"; -import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { - hostSelfUpdateDeps, hostUpdateStatus, - installedFromNpm, startHostSelfUpdate, - unpinnedCacheFolder, updateNotice, updateSuffix, watchHostUpdate, @@ -14,6 +9,7 @@ import { type HostUpdateStatus, } from "../../services/host-self-update.js"; import { ommsStatusLabel } from "../../services/status-glyphs.js"; +import { opencodeSelfUpdateDeps } from "./self-update.js"; import { availableUpdate, latestNpmVersion } from "../../services/update-check.js"; const UPDATE_COMMAND = "opencode plugin update om-memory-system"; @@ -165,16 +161,9 @@ export async function startInstalledTuiStatus( const host = CONFIG.webServerHost === "0.0.0.0" ? "127.0.0.1" : CONFIG.webServerHost; const currentVersion = packageVersion(); const modulePath = fileURLToPath(import.meta.url); - const markerPath = join(homedir(), ".omms", "opencode-plugin-update.json"); - const update = hostSelfUpdateDeps({ - logLabel: "OpenCode plugin self-update", - currentVersion, - command: "opencode", - args: ["plugin", "update", "om-memory-system"], - markerPath, + const update = opencodeSelfUpdateDeps({ modulePath, - installSource: () => - !installedFromNpm(modulePath) ? "local" : unpinnedCacheFolder(modulePath) ? "npm" : "pinned", + currentVersion, log: (message, data) => log(message, data), }); return startTuiStatus({ diff --git a/src/services/host-copies.ts b/src/services/host-copies.ts index b5d7578d..4ac43947 100644 --- a/src/services/host-copies.ts +++ b/src/services/host-copies.ts @@ -14,6 +14,8 @@ import { isOlderVersion } from "./version-compare.js"; export const CACHE_PRUNE_MIN_AGE_MS = 24 * 60 * 60 * 1000; const PACKAGE_FOLDER = "/node_modules/om-memory-system"; +/** OpenCode names the cache folder after the package and the version spec, such as `om-memory-system@latest`. */ +const CACHE_FOLDER = /^om-memory-system(@.+)?$/; export interface HostCopy { /** The package folder: `.../node_modules/om-memory-system`. */ @@ -27,7 +29,7 @@ export interface HostCopy { export interface HostCopies { root: string; version: string | null; - /** The running copy's timestamp, or null when its folder is not a timestamp folder. */ + /** The running copy's timestamp, or null when its folder is not a timestamp folder in an OpenCode cache. */ stamp: number | null; /** Every other timestamp folder in the same cache folder. Empty without a timestamp. */ siblings: HostCopy[]; @@ -78,8 +80,12 @@ export function listHostCopies(modulePath: string): HostCopies | null { const name = parts[parts.length - 1] ?? ""; const result: HostCopies = { root, version: versionAt(root), stamp: null, siblings: [] }; if (!/^\d+$/.test(name)) return result; - result.stamp = Number(name); const cache = stampFolder.slice(0, stampFolder.length - name.length); + // Only OpenCode's `om-memory-system@` folder holds timestamp copies. A project + // folder with a number for a name is not one, and the clean-up must never delete its siblings. + const cacheName = cache.replaceAll("\\", "/").split("/").filter(Boolean).pop() ?? ""; + if (!CACHE_FOLDER.test(cacheName)) return result; + result.stamp = Number(name); try { const names = readdirSync(cache, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && /^\d+$/.test(entry.name) && entry.name !== name) @@ -127,8 +133,8 @@ export interface PruneOptions { * when its timestamp and its version are both older than the running copy's, * it is not in `keepRoots`, and the running copy is at least * `CACHE_PRUNE_MIN_AGE_MS` old. A copy that cannot be removed stays. Logs one - * `OpenCode cache clean-up` record with counts when an older copy was - * considered, so a pass with nothing to do stays silent. Never throws. + * `OpenCode cache clean-up` record with counts for each pass in a timestamp + * folder, also when the counts are zero. Never throws. */ export async function pruneOldCacheCopies( options: PruneOptions @@ -138,7 +144,6 @@ export async function pruneOldCacheCopies( const copies = listHostCopies(options.modulePath); if (!copies || copies.stamp === null) return counts; const older = copies.siblings.filter((copy) => copy.stamp < copies.stamp!); - if (older.length === 0) return counts; const keep = new Set(options.keepRoots.map(folderKey)); const old = options.now - copies.stamp >= CACHE_PRUNE_MIN_AGE_MS; const remove = options.remove ?? ((folder) => rm(folder, { recursive: true, force: true })); diff --git a/src/v2/plugin.ts b/src/v2/plugin.ts index 92651779..97e2e3e8 100644 --- a/src/v2/plugin.ts +++ b/src/v2/plugin.ts @@ -42,6 +42,38 @@ function startCacheCleanup(): void { })().catch(() => {}); } +let serviceUpdateStarted = false; + +/** + * Check npm for a newer OMMS from the background service too, so OpenCode + * without a TUI (OpenChamber, `opencode serve`) also updates. Once per + * process: setup can run for each project. Not awaited; a failure is ignored. + */ +function startServiceSelfUpdate(): void { + if (serviceUpdateStarted) return; + serviceUpdateStarted = true; + void (async () => { + const modulePath = fileURLToPath(import.meta.url); + const [ + { opencodeSelfUpdateDeps, startOpencodeServiceSelfUpdate }, + { startHostSelfUpdate }, + { packageVersion }, + { log }, + ] = await Promise.all([ + import("../adapters/opencode/self-update.js"), + import("../services/host-self-update.js"), + import("../services/package-version.js"), + import("../services/logger.js"), + ]); + const deps = opencodeSelfUpdateDeps({ + modulePath, + currentVersion: packageVersion(), + log: (message, data) => log(message, data), + }); + startOpencodeServiceSelfUpdate({ run: () => startHostSelfUpdate(deps) }); + })().catch(() => {}); +} + const OmmsPluginV2: Plugin = { id: "omms", async setup(ctx) { @@ -65,6 +97,7 @@ const OmmsPluginV2: Plugin = { createV2MemoryBridge(ctx.location.directory) ); startCacheCleanup(); + startServiceSelfUpdate(); return adapter; }, }; diff --git a/tests/host-copies.test.ts b/tests/host-copies.test.ts index dff701f3..d019a6c4 100644 --- a/tests/host-copies.test.ts +++ b/tests/host-copies.test.ts @@ -284,10 +284,35 @@ it("writes the record even when the 24 hour wait keeps every older copy", async expect(t.logs).toEqual([{ message: "OpenCode cache clean-up", data: { removed: 0, kept: 1 } }]); }); -it("writes no record when no older copy exists", async () => { +it("writes a zero-count record when no older copy exists, so the pass is visible", async () => { const none = await prune({ copies: [[RUNNING + 1_000, "4.4.0"]] }); expect(none.result).toEqual({ removed: 0, kept: 0 }); - expect(none.logs).toEqual([]); + expect(none.logs).toEqual([ + { message: "OpenCode cache clean-up", data: { removed: 0, kept: 0 } }, + ]); +}); + +it("removes nothing when the numeric folders are not an OpenCode cache", async () => { + // A project folder named with digits, next to another one that also holds an older OMMS. + const dir = mkdtempSync(join(tmpdir(), "omms-host-copies-")); + tempDirs.push(dir); + const projects = join(dir, "projects"); + const running = addCopy(projects, 2024, "4.3.0"); + const other = addCopy(projects, 2023, "4.2.0"); + writeFileSync(join(projects, "2023", "keep-me.txt"), "user data"); + expect(listHostCopies(modulePathOf(running))!.stamp).toBeNull(); + const logs: unknown[] = []; + const result = await pruneOldCacheCopies({ + modulePath: modulePathOf(running), + runningVersion: "4.3.0", + keepRoots: [], + now: Date.now(), + log: (message) => void logs.push(message), + }); + expect(result).toEqual({ removed: 0, kept: 0 }); + expect(logs).toEqual([]); + expect(existsSync(other)).toBe(true); + expect(existsSync(join(projects, "2023", "keep-me.txt"))).toBe(true); }); it("does nothing and writes no record outside a timestamp folder", async () => { @@ -351,7 +376,12 @@ it("keeps a recorded copy named through a symbolic link", async () => { copies: [[RUNNING - 1_000, "4.2.0"]], keep: (cache) => { const link = join(links, "recorded"); - symlinkSync(join(cache, String(RUNNING - 1_000), "node_modules", "om-memory-system"), link); + // A junction needs no symbolic-link privilege on Windows; other systems treat it as a symbolic link. + symlinkSync( + join(cache, String(RUNNING - 1_000), "node_modules", "om-memory-system"), + link, + "junction" + ); return [link]; }, }); diff --git a/tests/opencode-self-update.test.ts b/tests/opencode-self-update.test.ts new file mode 100644 index 00000000..5162545d --- /dev/null +++ b/tests/opencode-self-update.test.ts @@ -0,0 +1,79 @@ +import { expect, it } from "bun:test"; +import { + OPENCODE_SERVICE_CHECK_MS, + opencodeSelfUpdateDeps, + startOpencodeServiceSelfUpdate, +} from "../src/adapters/opencode/self-update.js"; + +const NPM_PATH = + "/home/u/.cache/opencode/npm/om-memory-system@latest/1794091868583/node_modules/om-memory-system/dist/v2/plugin.js"; + +it("builds the OpenCode update inputs from the module path and home folder", () => { + const deps = opencodeSelfUpdateDeps({ + modulePath: NPM_PATH, + currentVersion: "4.2.0", + home: "/home/u", + log: () => {}, + env: {}, + }); + expect(deps.logLabel).toBe("OpenCode plugin self-update"); + expect(deps.command).toBe("opencode"); + expect(deps.args).toEqual(["plugin", "update", "om-memory-system"]); + expect(deps.markerPath.replaceAll("\\", "/")).toBe("/home/u/.omms/opencode-plugin-update.json"); + expect(deps.currentVersion).toBe("4.2.0"); + expect(deps.enabled).toBe(true); + expect(deps.installSource()).toBe("npm"); +}); + +it("treats a local checkout and a pinned cache folder as not updatable", () => { + const source = (modulePath: string) => + opencodeSelfUpdateDeps({ modulePath, currentVersion: "4.2.0", log: () => {} }).installSource(); + expect(source("/home/u/dev/omms/dist/v2/plugin.js")).toBe("local"); + expect(source(NPM_PATH.replace("@latest", "@4.2.0"))).toBe("pinned"); +}); + +it("turns the update off with OMMS_DISABLE_UPDATE_CHECK", () => { + const deps = opencodeSelfUpdateDeps({ + modulePath: NPM_PATH, + currentVersion: "4.2.0", + log: () => {}, + env: { OMMS_DISABLE_UPDATE_CHECK: "1" }, + }); + expect(deps.enabled).toBe(false); +}); + +it("checks at once and again at each interval in the background service", async () => { + let runs = 0; + const stop = startOpencodeServiceSelfUpdate({ + run: async () => { + runs++; + }, + intervalMs: 5, + }); + expect(runs).toBe(1); + await Bun.sleep(30); + stop(); + const afterStop = runs; + expect(afterStop).toBeGreaterThan(1); + await Bun.sleep(20); + expect(runs).toBe(afterStop); +}); + +it("keeps running when a check throws or rejects", async () => { + let runs = 0; + const stop = startOpencodeServiceSelfUpdate({ + run: () => { + runs++; + if (runs === 1) throw new Error("boom"); + return Promise.reject(new Error("offline")); + }, + intervalMs: 5, + }); + await Bun.sleep(30); + stop(); + expect(runs).toBeGreaterThan(1); +}); + +it("rechecks every 6 hours by default, like the OpenCode footer", () => { + expect(OPENCODE_SERVICE_CHECK_MS).toBe(6 * 3_600_000); +}); diff --git a/tests/pi-self-update.test.ts b/tests/pi-self-update.test.ts index 2a841d2f..b1c79bc4 100644 --- a/tests/pi-self-update.test.ts +++ b/tests/pi-self-update.test.ts @@ -1,7 +1,9 @@ import { afterEach, expect, it } from "bun:test"; +import { spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { createPiSelfUpdate, piHostSelfUpdateDeps, @@ -306,15 +308,27 @@ it("finds the install source from the module path and Pi's settings file", () => }); it("reads Pi's settings file from ~/.pi/agent when PI_CODING_AGENT_DIR is not set", () => { - const agentDir = join(homedir(), ".pi", "agent"); + // The default path comes from the home folder, which Bun fixes at process start. A child + // process with its own temporary home keeps the test away from the real ~/.pi. + const home = mkdtempSync(join(tmpdir(), "omms-pi-home-")); + tempDirs.push(home); + const agentDir = join(home, ".pi", "agent"); mkdirSync(agentDir, { recursive: true }); - tempDirs.push(join(homedir(), ".pi")); writeFileSync( join(agentDir, "settings.json"), JSON.stringify({ packages: [{ source: "npm:om-memory-system" }] }) ); + const module = pathToFileURL(join(import.meta.dir, "../src/adapters/pi/self-update.ts")).href; const npmPath = "/home/u/.pi/agent/npm/node_modules/om-memory-system/dist/x.js"; - expect(piHostSelfUpdateDeps({ env: {}, modulePath: npmPath }).installSource()).toBe("npm"); + const script = ` +const { piHostSelfUpdateDeps } = await import(${JSON.stringify(module)}); +const deps = piHostSelfUpdateDeps({ env: {}, modulePath: ${JSON.stringify(npmPath)} }); +console.log("SOURCE:" + deps.installSource()); +`; + const env = { ...process.env, HOME: home, USERPROFILE: home }; + delete env.PI_CODING_AGENT_DIR; + const child = spawnSync(process.execPath, ["-e", script], { env, encoding: "utf8" }); + expect(`${child.stdout}${child.stderr}`).toContain("SOURCE:npm"); }); it("does not run the update again in a later session once it installed", async () => { diff --git a/tests/v2-cache-cleanup.test.ts b/tests/v2-cache-cleanup.test.ts index 85145e1b..b6c0c612 100644 --- a/tests/v2-cache-cleanup.test.ts +++ b/tests/v2-cache-cleanup.test.ts @@ -10,6 +10,7 @@ const state = { } | null, prune: (async () => ({ removed: 0, kept: 0 })) as (options: any) => Promise, calls: [] as any[], + serviceUpdates: [] as string[], logs: [] as { message: string; data: unknown }[], }; @@ -20,15 +21,26 @@ mock.module("../src/adapters/opencode/opencode-provider-loader.js", () => ({ mock.module("../src/v2/adapter.js", () => ({ registerV2Adapter: async () => "adapter" })); mock.module("../src/v2/memory-bridge.js", () => ({ createV2MemoryBridge: () => ({}) })); mock.module("../src/v2/legacy-client.js", () => ({ createLegacyClient: () => ({}) })); -mock.module("../src/services/host-self-update.js", () => ({ - installedFromNpm: () => state.npm, -})); mock.module("../src/services/host-copies.js", () => ({ pruneOldCacheCopies: (options: any) => { state.calls.push(options); return state.prune(options); }, })); +mock.module("../src/adapters/opencode/self-update.js", () => ({ + opencodeSelfUpdateDeps: (options: any) => options, + startOpencodeServiceSelfUpdate: (options: any) => { + options.run(); + return () => {}; + }, +})); +mock.module("../src/services/host-self-update.js", () => ({ + installedFromNpm: () => state.npm, + startHostSelfUpdate: async (deps: any) => { + state.serviceUpdates.push(deps.modulePath); + return { code: "current", update: null }; + }, +})); mock.module("../src/services/runtime-record.js", () => ({ readRuntimeRecord: () => state.record, })); @@ -133,3 +145,11 @@ it("still runs the clean-up when the update check is turned off", async () => { await until(() => state.calls.length > 0); expect(state.calls).toHaveLength(1); }); + +it("starts the background update check once per service, even after several setups", async () => { + await plugin.setup(ctx); + await plugin.setup(ctx); + await until(() => state.serviceUpdates.length > 0); + expect(state.serviceUpdates).toHaveLength(1); + expect(state.serviceUpdates[0]!.replaceAll("\\", "/")).toEndWith("/src/v2/plugin.ts"); +}); From a2dae42f78318c7cae64ff498420e79bcaad5730 Mon Sep 17 00:00:00 2001 From: Aizat Hawari Date: Sun, 8 Nov 2026 01:31:48 +0000 Subject: [PATCH 7/7] fix: address second review round on host self-update The cache clean-up now requires an om-memory-system@ folder, so a project folder named om-memory-system is never pruned. Docs explain pinned OpenCode installs and how to turn the service check off. --- ...071-opencode-update-live-check-findings.md | 2 +- docs/upgrading.md | 9 ++++++-- src/services/host-copies.ts | 8 +++++-- tests/host-copies.test.ts | 22 +++++++++++++++++++ tests/pi-self-update.test.ts | 3 ++- 5 files changed, 38 insertions(+), 6 deletions(-) diff --git a/docs/tdr/071-opencode-update-live-check-findings.md b/docs/tdr/071-opencode-update-live-check-findings.md index e6109248..d7d03214 100644 --- a/docs/tdr/071-opencode-update-live-check-findings.md +++ b/docs/tdr/071-opencode-update-live-check-findings.md @@ -34,7 +34,7 @@ The change `pi-opencode-auto-update` (ADR-045) makes Pi and OpenCode run their o ### Negative -- The clean-up depends on OpenCode's cache layout. A new layout turns the clean-up into a no-op. The install check reads the running copy only. +- The clean-up depends on OpenCode's cache layout. A new layout turns the clean-up into a no-op. The install check reads the running copy and its readable timestamp siblings in the same cache folder. - A TUI or web app that runs an old copy for more than 24 hours can fail to load a module until it restarts. ### Neutral diff --git a/docs/upgrading.md b/docs/upgrading.md index 5686ea75..d2d7ec01 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -37,10 +37,15 @@ available` and the command to run: - A local checkout. OMMS does not load from a `node_modules/om-memory-system/` folder. -- A pinned version. See [Pinning a version](#pinning-a-version). +- A pinned version. OpenCode skips an exact pin, so the command in the notice + does not update it. Install without the version number to receive updates. + See [Pinning a version](#pinning-a-version). Set `OMMS_DISABLE_UPDATE_CHECK=1` to turn off both the npm check and the -update. The check reads the npm registry. It sends no session content. +update. For OpenCode, set it in the environment that starts the background +service, then run `opencode service restart`. A service that is already running +keeps checking npm and updating. The check reads the npm registry. It sends no +session content. `opencode plugin check` also lists a new OpenCode release. OpenCode 2.2 and later store each plugin install in a new folder: diff --git a/src/services/host-copies.ts b/src/services/host-copies.ts index 4ac43947..32692cab 100644 --- a/src/services/host-copies.ts +++ b/src/services/host-copies.ts @@ -14,8 +14,12 @@ import { isOlderVersion } from "./version-compare.js"; export const CACHE_PRUNE_MIN_AGE_MS = 24 * 60 * 60 * 1000; const PACKAGE_FOLDER = "/node_modules/om-memory-system"; -/** OpenCode names the cache folder after the package and the version spec, such as `om-memory-system@latest`. */ -const CACHE_FOLDER = /^om-memory-system(@.+)?$/; +/** + * OpenCode names the cache folder after the package and the version spec, such as + * `om-memory-system@latest`. The `@` is required: a plain `om-memory-system` folder is + * a checkout or project, and its numeric folders are not OpenCode installs. + */ +const CACHE_FOLDER = /^om-memory-system@.+$/; export interface HostCopy { /** The package folder: `.../node_modules/om-memory-system`. */ diff --git a/tests/host-copies.test.ts b/tests/host-copies.test.ts index d019a6c4..8c651026 100644 --- a/tests/host-copies.test.ts +++ b/tests/host-copies.test.ts @@ -315,6 +315,27 @@ it("removes nothing when the numeric folders are not an OpenCode cache", async ( expect(existsSync(join(projects, "2023", "keep-me.txt"))).toBe(true); }); +it("removes nothing under a project folder named like the package, without a version spec", async () => { + // A checkout named om-memory-system with numeric folders is not OpenCode's om-memory-system@latest cache. + const dir = mkdtempSync(join(tmpdir(), "omms-host-copies-")); + tempDirs.push(dir); + const project = join(dir, "om-memory-system"); + const running = addCopy(project, 2024, "4.3.0"); + const other = addCopy(project, 2023, "4.2.0"); + writeFileSync(join(project, "2023", "keep-me.txt"), "user data"); + expect(listHostCopies(modulePathOf(running))!.stamp).toBeNull(); + const result = await pruneOldCacheCopies({ + modulePath: modulePathOf(running), + runningVersion: "4.3.0", + keepRoots: [], + now: Date.now(), + log: () => {}, + }); + expect(result).toEqual({ removed: 0, kept: 0 }); + expect(existsSync(other)).toBe(true); + expect(existsSync(join(project, "2023", "keep-me.txt"))).toBe(true); +}); + it("does nothing and writes no record outside a timestamp folder", async () => { const logs: unknown[] = []; const result = await pruneOldCacheCopies({ @@ -372,6 +393,7 @@ it("waits for an asynchronous removal and counts a rejected one as kept", async it("keeps a recorded copy named through a symbolic link", async () => { const links = mkdtempSync(join(tmpdir(), "omms-copy-link-")); + tempDirs.push(links); const t = await prune({ copies: [[RUNNING - 1_000, "4.2.0"]], keep: (cache) => { diff --git a/tests/pi-self-update.test.ts b/tests/pi-self-update.test.ts index b1c79bc4..99f001c6 100644 --- a/tests/pi-self-update.test.ts +++ b/tests/pi-self-update.test.ts @@ -328,7 +328,8 @@ console.log("SOURCE:" + deps.installSource()); const env = { ...process.env, HOME: home, USERPROFILE: home }; delete env.PI_CODING_AGENT_DIR; const child = spawnSync(process.execPath, ["-e", script], { env, encoding: "utf8" }); - expect(`${child.stdout}${child.stderr}`).toContain("SOURCE:npm"); + expect(child.status).toBe(0); + expect(child.stdout).toContain("SOURCE:npm"); }); it("does not run the update again in a later session once it installed", async () => {