From 4e66c1f36baa06c2f38fca657c7346e1a9b3338a Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Tue, 27 Oct 2026 13:35:43 +0200 Subject: [PATCH 1/6] Auto-detect global sidecarDir default for opencode-lossless Resolver auto-detects the global OpenCode data dir; explicit sidecarDir wins. Adds startup warn line and diagnose line; docs sweep plus patch changeset. --- .changeset/opencode-sidecar-global-default.md | 5 + apps/docs/components/seo/lcm-faqs.ts | 4 +- apps/docs/components/seo/opencode-faqs.ts | 6 +- .../guides/configuration/opencode.mdx | 10 +- .../guides/troubleshooting/opencode.mdx | 6 +- .../_snippets/reference/command/opencode.mdx | 22 +-- .../_snippets/reference/config/opencode.mdx | 18 +- packages/opencode/README.md | 24 +-- .../skills/opencode-lossless/SKILL.md | 46 ++--- packages/opencode/src/default-sidecar-dir.ts | 79 +++++++++ packages/opencode/src/lifecycle.ts | 53 ++---- packages/opencode/src/plugin.ts | 166 +++++++----------- .../opencode/test/default-sidecar-dir.test.ts | 141 +++++++++++++++ packages/opencode/test/lifecycle.test.ts | 8 +- 14 files changed, 372 insertions(+), 216 deletions(-) create mode 100644 .changeset/opencode-sidecar-global-default.md create mode 100644 packages/opencode/src/default-sidecar-dir.ts create mode 100644 packages/opencode/test/default-sidecar-dir.test.ts diff --git a/.changeset/opencode-sidecar-global-default.md b/.changeset/opencode-sidecar-global-default.md new file mode 100644 index 0000000..f1dd413 --- /dev/null +++ b/.changeset/opencode-sidecar-global-default.md @@ -0,0 +1,5 @@ +--- +"opencode-lossless": patch +--- + +Change the default `sidecarDir` to the auto-detected global path. The plugin uses `XDG_DATA_HOME` with a `~/.local/share` fallback, uses `LOCALAPPDATA` with an `APPDATA` fallback and then a `/AppData/Local` fallback on Windows, and uses `./.lossless` when no home resolves. To keep per-project storage, set `"sidecarDir": "./.lossless"`. After upgrading, run `/lossless-migrate` once per project that used `./.lossless`. The plugin never moves sidecars. diff --git a/apps/docs/components/seo/lcm-faqs.ts b/apps/docs/components/seo/lcm-faqs.ts index 57ac40a..5964139 100644 --- a/apps/docs/components/seo/lcm-faqs.ts +++ b/apps/docs/components/seo/lcm-faqs.ts @@ -17,7 +17,7 @@ export const SHARED_FAQS = { lossy: { question: "Is it lossy?", answer: - "The projection is lossy by construction, because the model sees a smaller view. The store is not. Every message is held whole, and `lcm_expand_query` reads the stored bytes back by address.", + "The projection is lossy by construction, because the model sees a smaller view. The store is not. The store holds every message whole, and `lcm_expand_query` reads the stored bytes back by address.", }, caching: { question: "Does it break provider prompt caching?", @@ -55,7 +55,7 @@ export const LCM_FAQS: readonly LcmFaq[] = [ { question: "Where does the data live?", answer: - "One SQLite file per session in the adapter's sidecar directory. Pi uses `~/.pi/agent/lcm/`; OpenCode uses the configured `sidecarDir` (default `./.lossless`, resolved from the server working directory). The directory is 0700; the database, WAL, and shared-memory files are 0600. Metrics sit beside the stores. Nothing leaves the machine except the provider calls the summarizer and reader make.", + "One SQLite file per session in the adapter's sidecar directory. Pi uses `~/.pi/agent/lcm/`. OpenCode uses the configured `sidecarDir`. The default is a global auto-detected path. [Config](/reference/opencode/config) lists the exact paths. The directory is 0700. The database, WAL, and shared-memory files are 0600. Metrics files are beside the stores. Nothing leaves the machine except the provider calls the summarizer and reader make.", }, SHARED_FAQS.caching, { diff --git a/apps/docs/components/seo/opencode-faqs.ts b/apps/docs/components/seo/opencode-faqs.ts index bc5bb24..3b92b0d 100644 --- a/apps/docs/components/seo/opencode-faqs.ts +++ b/apps/docs/components/seo/opencode-faqs.ts @@ -12,12 +12,12 @@ export const OPENCODE_FAQS: readonly LcmFaq[] = [ { question: "Do I have to configure anything?", answer: - "No. The defaults (swap at 70%, re-cut at 85%, keep 20,000 tokens verbatim) work with no options. Configuration is the plugin's `options` in `opencode.json`; there is no separate config file or interactive settings panel. `sidecarDir` defaults to `./.lossless`, resolved from the server working directory.", + 'No. The defaults (swap at 70%, re-cut at 85%, keep 20,000 tokens verbatim) work with no options. Configuration is the plugin\'s `options` in `opencode.json`. There is no separate config file or interactive settings panel. `sidecarDir` defaults to a global auto-detected path (see [Config](/reference/opencode/config)). Set it to `"./.lossless"` to keep sidecars inside the project.', }, { question: "Where does the data live?", answer: - "One SQLite sidecar per session under `sidecarDir` (default `./.lossless`, resolved from the OpenCode server working directory). The directory is 0700; the database, WAL, and shared-memory files are 0600. Nothing leaves the machine except the provider calls the summarizer and reader make.", + "One SQLite sidecar per session under `sidecarDir`. The default is a global auto-detected path. [Config](/reference/opencode/config) lists the exact paths. The directory is 0700. The database, WAL, and shared-memory files are 0600. Nothing leaves the machine except the provider calls the summarizer and reader make.", }, { question: "Does it replace OpenCode compaction?", @@ -32,7 +32,7 @@ export const OPENCODE_FAQS: readonly LcmFaq[] = [ { question: "Can the model read a past OpenCode session?", answer: - 'Yes. `lcm_grep` scans the global sidecar directory, so `"sessions"` and `"all_sessions"` are equivalent today. The reader runs in a child session limited to the recall tools, and its transcript sidecar is removed after it answers.', + 'Yes. `lcm_grep` scans the global sidecar directory, so `"sessions"` and `"all_sessions"` are equivalent today. The reader runs in a child session limited to the recall tools, and the plugin removes its transcript sidecar after it answers.', }, SHARED_FAQS.caching, { diff --git a/apps/docs/content/_snippets/guides/configuration/opencode.mdx b/apps/docs/content/_snippets/guides/configuration/opencode.mdx index c587913..24462ba 100644 --- a/apps/docs/content/_snippets/guides/configuration/opencode.mdx +++ b/apps/docs/content/_snippets/guides/configuration/opencode.mdx @@ -26,6 +26,12 @@ The threshold vocabularies are the same: ratios, `smartZone`, and token pairs. ` `summarizerTokensPerCall` and `summarizerTokensPerPass` bound a pass. `summarizerTokensPerHeal` bounds an on-demand `/lossless-heal --apply` repair. A malformed value keeps its default. -A heal reads source through windows derived from `summaryTokens` (8x, clamped 3,000 through 24,000 and to `summarizerTokensPerCall`). A leaf that fits one window is sent once; a larger leaf maps up to eight windows and reduces them once. Set `healSourceTokens` to change the window. A source beyond eight windows is blocked with no model call, not retried at the same size; a mechanical node gets one improvement. Raising the window makes the next run attempt it again. +A heal reads source through windows derived from `summaryTokens` (8x, clamped 3,000 through 24,000 and to `summarizerTokensPerCall`). A leaf that fits one window is sent once. A larger leaf maps up to eight windows and reduces them once. Set `healSourceTokens` to change the window. A source beyond eight windows is blocked with no model call, not retried at the same size. A mechanical node gets one improvement. Raising the window makes the next run attempt it again. -`assembly: false` keeps the mirror and recall tools and leaves compaction to the host. Edit the options and reload the plugin to change them; there is no live settings panel. [Config](/reference/opencode/config) lists every key and default. +`assembly: false` keeps the mirror and recall tools and leaves compaction to the host. Edit the options and reload the plugin to change them. There is no live settings panel. [Config](/reference/opencode/config) lists every key and default. + +To keep sidecars inside the project, set `sidecarDir` to `"./.lossless"`. Without this setting, the plugin stores sidecars in one global directory that holds sessions from every project. The global directory uses `XDG_DATA_HOME` with a `~/.local/share` fallback on Linux and macOS, and `LOCALAPPDATA` with an `APPDATA` fallback and then a `/AppData/Local` fallback on Windows. To use a different location, set `sidecarDir` to a path outside the global directory. [Config](/reference/opencode/config) lists the exact default paths. + +To remove old sidecars from the shared directory, run `/lossless-gc`. Orphan sidecars accumulate there because the directory is shared. + +After upgrading a project that stored sidecars in `./.lossless`, run `/lossless-migrate` once in that project. The plugin never moves sidecars, and the startup warning names both paths. diff --git a/apps/docs/content/_snippets/guides/troubleshooting/opencode.mdx b/apps/docs/content/_snippets/guides/troubleshooting/opencode.mdx index 504c5cf..4998f31 100644 --- a/apps/docs/content/_snippets/guides/troubleshooting/opencode.mdx +++ b/apps/docs/content/_snippets/guides/troubleshooting/opencode.mdx @@ -2,11 +2,11 @@ Start with `/lossless-status` and `/lossless-report`. | Symptom | First check | | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| No stored history | `opencode.json` lists the plugin, `enabled` is not false, and `sidecarDir` is writable. A relative `sidecarDir` resolves from the server working directory; prefer an absolute path. | +| No stored history | `opencode.json` lists the plugin, `enabled` is not false, and `sidecarDir` is writable. An explicit `sidecarDir` resolves from the server working directory when relative. Prefer an absolute path. When `sidecarDir` is unset, the plugin uses the global default. [Config](/reference/opencode/config) lists the exact default paths. | | Nothing has been swapped | `assembly` is true, the request is above `swapAtRatio`, and `metrics.jsonl` has a `projection` row with `action: bail`. | -| The model sees raw history after a swap | The `reason` on the last projection row. `unaligned-outgoing` means the host draft and the stored history did not line up; the adapter fails closed rather than guessing. | +| The model sees raw history after a swap | The `reason` on the last projection row. `unaligned-outgoing` means the host draft and the stored history did not line up. The adapter fails closed rather than guessing. | | A reader times out | Raise `readerTimeoutMs` or narrow the query. The reader has no per-child token or step cap, so the timeout is the bound. | | The summary looks estimated | The host's `generate.text` returns text without usage, so summarizer spend is estimated from characters. Main-session and reader usage are exact. | | A summary is mechanically truncated | Run `/lossless-heal` to count the tainted nodes, then `/lossless-heal --apply` to re-summarize them from stored source within `summarizerTokensPerHeal`. | -`/lossless-diagnose` builds a redacted block with versions, store facts, the effective options, and this session's metrics; `/lossless-diagnose --save lossless-diagnose.txt` writes it at mode 0600. [Config](/reference/opencode/config) lists every option. The package README's live loop is the reproduction path. +`/lossless-diagnose` builds a redacted block with versions, store facts, the effective options, and this session's metrics. `/lossless-diagnose --save lossless-diagnose.txt` writes it at mode 0600. [Config](/reference/opencode/config) lists every option. The package README's live loop is the reproduction path. diff --git a/apps/docs/content/_snippets/reference/command/opencode.mdx b/apps/docs/content/_snippets/reference/command/opencode.mdx index e3e905d..541af8e 100644 --- a/apps/docs/content/_snippets/reference/command/opencode.mdx +++ b/apps/docs/content/_snippets/reference/command/opencode.mdx @@ -11,11 +11,11 @@ /lossless-gc [--older-than ] [--session ] [--apply] ``` -Ten commands. `/lossless-pass` is absent with `assembly: false`; `/lossless-heal` stays available. +There are ten commands. `/lossless-pass` is absent with `assembly: false`. `/lossless-heal` stays available. ## Settings -There is no interactive settings panel in V2 scope; the host exposes a TUI extension point, so a separate TUI plugin could reuse the same command core later. Edit the plugin's `options` in `opencode.json` and reload the plugin. [Config](/reference/opencode/config) lists every key. +There is no interactive settings panel in V2 scope. The host exposes a TUI extension point, so a separate TUI plugin could reuse the same command core later. Edit the plugin's `options` in `opencode.json` and reload the plugin. [Config](/reference/opencode/config) lists every key. ## status @@ -23,7 +23,7 @@ There is no interactive settings panel in V2 scope; the host exposes a TUI exten /lossless-status ``` -Message and compaction-summary counts for the live sidecars. A command cannot return text directly, so the status arrives as a prompt the loop answers from. +Message and compaction-summary counts for the live sidecars. A command cannot return text directly, so the status arrives as a prompt that the loop answers. ## report @@ -31,7 +31,7 @@ Message and compaction-summary counts for the live sidecars. A command cannot re /lossless-report ``` -Projection, checkpoint, pass, summary, heal, recall, and reader rows for this session. It says so when the session has no rows. +Projection, checkpoint, pass, summary, heal, recall, and reader rows for this session. When the session has no rows, it says so. ## pass @@ -39,7 +39,7 @@ Projection, checkpoint, pass, summary, heal, recall, and reader rows for this se /lossless-pass ``` -One DAG pass over the stored span, on demand. Mounted only with `assembly` on. +It runs one DAG pass over the stored span on demand. The plugin mounts it only with `assembly` on. ## heal @@ -49,7 +49,7 @@ One DAG pass over the stored span, on demand. Mounted only with `assembly` on. /lossless-heal --apply --max-nodes 8 ``` -A non-clean summary is a candidate. A mechanical level-3 node starts with `[lcm:truncated]`; a clipped leaf or a derived parent may not. Heal reads the exact source rows that node points at, re-summarizes the tainted node, then recomputes every ancestor whose child changed. It rejects another level-3 answer, an empty answer, or an answer over twice the target, so a failed repair keeps the old text. `--max-nodes` bounds one run; `summarizerTokensPerHeal` bounds its spend. The dry run makes no provider call. +A non-clean summary is a candidate. A mechanical level-3 node starts with `[lcm:truncated]`. A clipped leaf or a derived parent may not. Heal reads the exact source rows that node points at, re-summarizes the tainted node, then recomputes every ancestor whose child changed. It rejects another level-3 answer, an empty answer, or an answer over twice the target, so a failed repair keeps the old text. `--max-nodes` bounds one run. `summarizerTokensPerHeal` bounds its spend. The dry run makes no provider call. ## backup @@ -65,7 +65,7 @@ Copies this session's sidecar to `.backup-`, or to the path /lossless-export [path] ``` -Writes the verbatim transcript and summaries as JSONL at mode 0600. The default lands beside the sidecars. +Writes the verbatim transcript and summaries as JSONL at mode 0600. Without a path, it writes beside the sidecars. ## import @@ -73,7 +73,7 @@ Writes the verbatim transcript and summaries as JSONL at mode 0600. The default /lossless-import ``` -Restores rows from a file written by `/lossless-export`. Refused while a pass is running, or into a store that already holds foreign messages. +Restores rows from a file written by `/lossless-export`. It refuses the import while a pass is running or when the store already holds foreign messages. ## gc @@ -82,7 +82,7 @@ Restores rows from a file written by `/lossless-export`. Refused while a pass is /lossless-gc --apply # export each transcript, then delete ``` -The host plugin API cannot list sessions, so `gc` selects by sidecar age (default 30 days) or an explicit id. It never touches the current session and exports each transcript before deleting it. +The host plugin API cannot list sessions, so `gc` selects by sidecar age (default 30 days) or an explicit id. It never deletes the current session. It exports each transcript before deleting it. ## migrate @@ -91,7 +91,7 @@ The host plugin API cannot list sessions, so `gc` selects by sidecar age (defaul /lossless-migrate --apply # upgrade them in place ``` -The only path that writes to a past session's sidecar, and the reason cross-session search can read sidecars written by older builds. +It is the only path that writes to a past session's sidecar. It lets cross-session search read sidecars written by older builds. ## diagnose @@ -101,4 +101,4 @@ The only path that writes to a past session's sidecar, and the reason cross-sess /lossless-diagnose --repair # rebuild the index and repair integrity ``` -One redacted, pasteable block: versions, store facts, the effective options, and this session's metrics. `--save` writes the exact bytes at mode 0600; `--repair` rebuilds the index and repairs integrity before the bundle is built. +One redacted, pasteable block: versions, store facts, the effective options, and this session's metrics. Its first line names the sidecar directory and its source (`explicit`, `fallback`, or the `default:*` auto-detected source). `--save` writes the exact bytes at mode 0600. `--repair` rebuilds the index and repairs integrity before the bundle is built. diff --git a/apps/docs/content/_snippets/reference/config/opencode.mdx b/apps/docs/content/_snippets/reference/config/opencode.mdx index fa67fdc..64c37ed 100644 --- a/apps/docs/content/_snippets/reference/config/opencode.mdx +++ b/apps/docs/content/_snippets/reference/config/opencode.mdx @@ -1,27 +1,27 @@ -The config is the plugin's `options` object in `opencode.json`; there is no separate config file. +The config is the plugin's `options` object in `opencode.json`. There is no separate config file. ## Keys | Key | Type | Default | Range | Effect | | ------------------ | ------------------ | --------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `enabled` | boolean | `true` | `true` or `false` | Master flag. `false` registers nothing. | -| `sidecarDir` | string | `"./.lossless"` | any path | One SQLite file per session; resolved from the server working directory. | +| `sidecarDir` | string | global auto-detected path | any path | One SQLite file per session. An explicit value overrides the default. A blank value falls back to the default. The default is absolute `XDG_DATA_HOME/opencode/lossless`, else `~/.local/share/opencode/lossless`. On Windows the default is `%LOCALAPPDATA%/opencode/lossless`, else `%APPDATA%/opencode/lossless`, else `/AppData/Local/opencode/lossless`. With no resolvable home, the default is `./.lossless` (source `fallback`). The global default holds sessions from every project. The plugin never moves existing sidecars. | | `assembly` | boolean | `true` | `true` or `false` | Build the DAG, project the frontier, and return the checkpoint. `false` keeps the mirror and recall tools. | -| `summarizer` | string or string[] | `"auto"` | one id, or an ordered chain | Summarizer chain. `auto` is the session model; a concrete id falls back to the session model because the host has no synchronous model lookup. | +| `summarizer` | string or string[] | `"auto"` | one id, or an ordered chain | Summarizer chain. `auto` is the session model. A concrete id falls back to the session model because the host has no synchronous model lookup. | | `summarizerTokensPerCall` | number | `60000` | `>= 1` | Ceiling on one summarizer request, estimate plus output cap, refused before the provider is called. | | `summarizerTokensPerPass` | number | `250000` | `>= 1` | Ceiling on one pass across its calls. | | `summarizerTokensPerHeal` | number | `120000` | `>= 1` | Ceiling on one `/lossless-heal --apply` repair run. | -| `swapAtRatio` | number | `0.7` | `> 0`, `<= 1` | Swap line as a share of the window; the engine clamps it to 0.9. | +| `swapAtRatio` | number | `0.7` | `> 0`, `<= 1` | Swap line as a share of the window. The engine clamps it to 0.9. | | `recutAtRatio` | number | `0.85` | `> 0`, `<= 1` | Re-cut line as a share of the window. | -| `smartZone` | number | unset | `> 0` | Swap at this many tokens; re-cut at `x 1.18`. | +| `smartZone` | number | unset | `> 0` | Swap at this many tokens. Re-cut at `x 1.18`. | | `zones` | object | unset | map of `provider/model` to thresholds | Per-model threshold overrides. | | `keepRecentTokens` | number | `20000` | `>= 1` | Tokens kept verbatim after the cut. | | `summaryTokens` | number | `1500` | `>= 1` | Target size of one summary. | | `healSourceTokens` | number | derived | `1` to `24000` | Source tokens one heal map window may read. Defaults to `8 x summaryTokens`, clamped to `3000` through `24000` and to `summarizerTokensPerCall`. A larger leaf uses up to eight map windows and one reduce call. | | `contextWindow` | number | `128000` | `>= 1` | Window used when the host catalogue cannot name the model's limit. | -| `readerTimeoutMs` | number | `120000` | `>= 1` | How long one `lcm_expand_query` child may run before it is interrupted. | +| `readerTimeoutMs` | number | `120000` | `>= 1` | How long one `lcm_expand_query` child may run before the timeout interrupts it. | -`zones` accepts only the five threshold keys: `smartZone`, `swapAtTokens`, `recutAtTokens`, `swapAtRatio`, `recutAtRatio`. Global token pairs are not options; set them per model under `zones`. Anything else is dropped. +`zones` accepts only the five threshold keys: `smartZone`, `swapAtTokens`, `recutAtTokens`, `swapAtRatio`, `recutAtRatio`. Global token pairs are not options. Set them per model under `zones`. Anything else is dropped. ## Threshold resolution @@ -35,10 +35,10 @@ Explicit tokens win over `smartZone`, which wins over ratios. Per-model `zones` ## Parse rules -- A value of the wrong type or range keeps its default; the plugin load never fails on options. +- A value of the wrong type or range keeps its default. The plugin load never fails on options. - `enabled` disables everything only when it is exactly `false`. - `assembly` stays on unless it is exactly `false`. - `summarizer` accepts one string or an array of strings. An empty or malformed value falls back to `auto`. -- The three summarizer token budgets must be positive integers; a malformed value keeps the default. +- The three summarizer token budgets must be positive integers. A malformed value keeps the default. - `zones` entries with no valid threshold key are dropped. `swapAtTokens` and `recutAtTokens` are accepted there only, not at the top level. - The options are read once at setup. Edit `opencode.json` and reload the plugin to change them. diff --git a/packages/opencode/README.md b/packages/opencode/README.md index 6f631a2..d655401 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -2,12 +2,12 @@ **Lossless Context Management (LCM) for OpenCode v2. Long sessions keep working past the point where the model would forget, and every verbatim message stays searchable and readable.** -Install it, and the adapter mirrors every prompt, assistant reply, and tool exchange into a per-session SQLite sidecar. The model reads that history back through `lcm_grep`, `lcm_describe`, and `lcm_expand`; with `assembly` true (the default) it also gets `lcm_expand_query`, which answers through a child session without pulling the originals into the parent. With assembly on, the adapter also owns the context boundary: past the swap point the DAG projection replaces the aged history, and when the host compacts, the adapter hands it the frontier as the checkpoint instead of letting it summarize. +Install it, and the adapter mirrors every prompt, assistant reply, and tool exchange into a per-session SQLite sidecar. The model reads that history back through `lcm_grep`, `lcm_describe`, and `lcm_expand`. With `assembly` true (the default) it also gets `lcm_expand_query`, which answers through a child session without pulling the originals into the parent. With assembly on, the adapter also owns the context boundary. Past the swap point the DAG projection replaces the aged history, and when the host compacts, the adapter hands it the frontier as the checkpoint instead of letting it summarize. This package is the OpenCode v2 adapter over [`lossless-core`](https://npmx.dev/package/lossless-core), the engine that ingests the history and builds the summaries. Verified against OpenCode v2 in -live sessions: the plugin loads from its directory, all mirrored text matches +live sessions. The plugin loads from its directory, all mirrored text matches verbatim, and host compactions store the DAG frontier as their checkpoint. ## Install @@ -20,14 +20,16 @@ The equivalent entry in `opencode.json`: ```json { - "plugins": [{ "package": "opencode-lossless", "options": { "sidecarDir": "./.lossless" } }] + "plugins": [{ "package": "opencode-lossless" }] } ``` +To keep sidecars inside the project, set `sidecarDir` to `"./.lossless"`. + ## Quick start -Zero config required: passive mirroring starts immediately, and swaps begin -once context crosses the swap point. Eleven commands, all `lossless-*`: +The plugin needs no configuration. Passive mirroring starts immediately, and swaps begin +once context crosses the swap point. The commands all start with `lossless-*`: ```sh /lossless-status # message and compaction summary counts per live sidecar @@ -43,7 +45,7 @@ once context crosses the swap point. Eleven commands, all `lossless-*`: /lossless-dump # read-only dump of this session's sidecar; --include-removed keeps removed rows, --save writes mode 0600 ``` -The model gets four recall tools: `lcm_grep` (search this session, `scope` +The model gets recall tools: `lcm_grep` (search this session, `scope` reaches past sessions, `role`, `date_start`, `date_end`, and `tool_name` filter rows), `lcm_describe` (inspect one summary without expanding it), `lcm_expand` (read the originals a summary covers, `anchor` centers @@ -61,12 +63,12 @@ originals never enter the parent's context). } ``` -`sidecarDir` holds one SQLite file per session (0700 dir, 0600 files; delete -one to forget that session). `assembly: false` leaves compaction to the host -and drops `/lossless-pass` and `lcm_expand_query`. Every other key (thresholds, -summarizer chain and budgets, heal window, reader timeout) lives on the +The example stores sidecars in the project. Without `sidecarDir`, the plugin uses the global auto-detected path, which holds sessions from every project. The [config reference](https://stainless-code.com/lossless/reference/opencode/config) lists the exact default paths. `sidecarDir` holds one SQLite file per session (0700 dir, 0600 files). +Delete one file to forget that session. `assembly: false` leaves compaction to the host +and drops `/lossless-pass` and `lcm_expand_query`. The [config reference](https://stainless-code.com/lossless/reference/opencode/config) -with its default. +documents every other key and its default: thresholds, +summarizer chain and budgets, heal window, and reader timeout. ## Verify diff --git a/packages/opencode/skills/opencode-lossless/SKILL.md b/packages/opencode/skills/opencode-lossless/SKILL.md index dc9de9d..a05b99e 100644 --- a/packages/opencode/skills/opencode-lossless/SKILL.md +++ b/packages/opencode/skills/opencode-lossless/SKILL.md @@ -14,7 +14,7 @@ sources: # opencode-lossless -Passive mirroring starts with zero config: every prompt, assistant reply, and tool exchange lands in a per-session SQLite sidecar. Past the swap point the DAG projection replaces aged history, and on host compaction the adapter hands over the frontier as the checkpoint instead of a summary. +Passive mirroring starts with no configuration. The adapter stores every prompt, assistant reply, and tool exchange in a per-session SQLite sidecar. Past the swap point the DAG projection replaces aged history, and on host compaction the adapter hands over the frontier as the checkpoint instead of a summary. ## Install @@ -25,22 +25,24 @@ opencode plugin add opencode-lossless Equivalent `opencode.json` entry: ```json -{ "plugins": [{ "package": "opencode-lossless", "options": { "sidecarDir": "./.lossless" } }] } +{ "plugins": [{ "package": "opencode-lossless" }] } ``` +To keep sidecars inside the project, set `sidecarDir` to `"./.lossless"`. + ## Commands -- `/lossless-status` — message and compaction summary counts per live sidecar. -- `/lossless-report` — this session's metrics: projection, checkpoint, pass, recall, reader. -- `/lossless-pass` — run one DAG pass over the aged span now, without waiting for the swap. -- `/lossless-heal` — dry-run repair of tainted summaries; `--apply` spends model calls. -- `/lossless-backup` — copy this session's sidecar beside it. -- `/lossless-export` — write the verbatim transcript and summaries as JSONL (mode 0600). -- `/lossless-import` — restore rows from an export. -- `/lossless-diagnose` — redacted, pasteable diagnostics block; `--save` writes it at mode 0600. -- `/lossless-migrate` — upgrade older-schema sidecars in place (dry-run by default). -- `/lossless-gc` — list old sidecars, or export and delete them (never the current session). -- `/lossless-dump` prints a read-only dump of the session sidecar. `--include-removed` keeps removed-branch rows and `--save` writes the output to a file at mode 0600. +- `/lossless-status`, message and compaction summary counts per live sidecar. +- `/lossless-report`, this session's metrics: projection, checkpoint, pass, recall, reader. +- `/lossless-pass`, run one DAG pass over the aged span now, without waiting for the swap. +- `/lossless-heal`, dry-run repair of tainted summaries. `--apply` spends model calls. +- `/lossless-backup`, copy this session's sidecar beside it. +- `/lossless-export`, write the verbatim transcript and summaries as JSONL (mode 0600). +- `/lossless-import`, restore rows from an export. +- `/lossless-diagnose`, redacted, pasteable diagnostics block. `--save` writes it at mode 0600. +- `/lossless-migrate`, upgrade older-schema sidecars in place (dry-run by default). +- `/lossless-gc`, list old sidecars, or export and delete them (never the current session). +- `/lossless-dump`, read-only dump of the session sidecar. `--include-removed` keeps removed-branch rows, `--save` writes the output to a file at mode 0600. ## Recall tools @@ -55,9 +57,11 @@ Equivalent `opencode.json` entry: { "enabled": true, "sidecarDir": "./.lossless", "assembly": true } ``` -- `sidecarDir` holds one SQLite file per session (0700 dir, 0600 files; delete one to forget that session). -- `assembly: true` (default) owns the context boundary: projection swaps aged history and compaction stores the DAG frontier. -- Every other key (swap thresholds, summarizer chain and budgets, heal window, reader timeout) lives on the config reference with its default: https://stainless-code.com/lossless/reference/opencode/config +The example stores sidecars in the project. Without `sidecarDir`, the plugin uses the global auto-detected path, which holds sessions from every project. The config reference lists the exact paths at https://stainless-code.com/lossless/reference/opencode/config + +- `sidecarDir` holds one SQLite file per session (0700 dir, 0600 files). Delete one to forget that session. After upgrading a project that used `./.lossless`, run `/lossless-migrate` once in that project. +- `assembly: true` (default) owns the context boundary. Projection swaps aged history and compaction stores the DAG frontier. +- The config reference documents every other key and its default: swap thresholds, summarizer chain and budgets, heal window, and reader timeout. See https://stainless-code.com/lossless/reference/opencode/config ## Verify @@ -71,8 +75,8 @@ bun run opencode:compact # compaction gate, makes real model calls ## Common mistakes -- **`assembly: false` drops `/lossless-pass` and `lcm_expand_query`** — keep the default `true` unless the host must own compaction; with `false`, compaction falls back to the host. -- **`scope` equivalence** — `scope: "session"` (default) reads this session only; pass `scope: "sessions"` or `"all_sessions"` to reach past sessions. -- **Reader is session-scoped** — `lcm_expand_query` returns findings only through its child session, never the raw transcript; ask a question it can answer from the sources, not for a dump. -- **Forgetting is file deletion** — remove that session's sidecar file; there is no delete tool. -- **Expecting host compaction to summarize** — with assembly on, the checkpoint is the DAG frontier, not a prose summary; read it with `lcm_describe`, not by asking the host. +- **`assembly: false` drops `/lossless-pass` and `lcm_expand_query`.** Keep the default `true` unless the host must own compaction. With `false`, compaction falls back to the host. +- **`scope` equivalence.** `scope: "session"` (default) reads this session only. Pass `scope: "sessions"` or `"all_sessions"` to reach past sessions. +- **Reader is session-scoped.** `lcm_expand_query` returns findings only through its child session, never the raw transcript. Ask a question it can answer from the sources, not for a dump. +- **Forgetting is file deletion.** Remove that session's sidecar file. There is no delete tool. +- **Expecting host compaction to summarize.** With assembly on, the checkpoint is the DAG frontier, not a prose summary. Read it with `lcm_describe`, not by asking the host. diff --git a/packages/opencode/src/default-sidecar-dir.ts b/packages/opencode/src/default-sidecar-dir.ts new file mode 100644 index 0000000..485f5cf --- /dev/null +++ b/packages/opencode/src/default-sidecar-dir.ts @@ -0,0 +1,79 @@ +import { homedir as osHomedir } from "node:os"; +import { join, posix, win32 } from "node:path"; + +export type DefaultSidecarSource = + | "default:XDG_DATA_HOME" + | "default:home" + | "default:LOCALAPPDATA" + | "default:APPDATA" + | "fallback"; + +export type DefaultSidecarEnv = Readonly>; + +export interface ResolveDefaultSidecarDirInput { + readonly env: DefaultSidecarEnv; + readonly homedir: string | undefined; + readonly platform: string; +} + +export interface ResolvedDefaultSidecarDir { + readonly dir: string; + readonly source: DefaultSidecarSource; +} + +/** This path stays relative. It resolves from the server working directory. */ +export const FALLBACK_SIDECAR_DIR = "./.lossless"; + +function nonBlank(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : trimmed; +} + +function isAbsoluteAny(path: string): boolean { + return posix.isAbsolute(path) || win32.isAbsolute(path); +} + +export function resolveDefaultSidecarDir( + input: ResolveDefaultSidecarDirInput, +): ResolvedDefaultSidecarDir { + const env = input.env; + const xdg = nonBlank(env["XDG_DATA_HOME"]); + if (xdg !== undefined && isAbsoluteAny(xdg)) { + return { dir: join(xdg, "opencode", "lossless"), source: "default:XDG_DATA_HOME" }; + } + const home = nonBlank(input.homedir); + if (home !== undefined) { + if (input.platform === "win32") { + const local = nonBlank(env["LOCALAPPDATA"]); + if (local !== undefined) { + return { dir: join(local, "opencode", "lossless"), source: "default:LOCALAPPDATA" }; + } + const roaming = nonBlank(env["APPDATA"]); + if (roaming !== undefined) { + return { dir: join(roaming, "opencode", "lossless"), source: "default:APPDATA" }; + } + return { + dir: join(home, "AppData", "Local", "opencode", "lossless"), + source: "default:home", + }; + } + return { + dir: join(home, ".local", "share", "opencode", "lossless"), + source: "default:home", + }; + } + return { dir: FALLBACK_SIDECAR_DIR, source: "fallback" }; +} + +export function defaultSidecarDir(): ResolvedDefaultSidecarDir { + try { + return resolveDefaultSidecarDir({ + env: process.env, + homedir: osHomedir(), + platform: process.platform, + }); + } catch { + return { dir: FALLBACK_SIDECAR_DIR, source: "fallback" }; + } +} diff --git a/packages/opencode/src/lifecycle.ts b/packages/opencode/src/lifecycle.ts index 40ddd1b..00ccb29 100644 --- a/packages/opencode/src/lifecycle.ts +++ b/packages/opencode/src/lifecycle.ts @@ -1,9 +1,3 @@ -/** Store lifecycle commands for the OpenCode adapter: backup, export, import, - * diagnose, migrate, heal, and a manual gc. They all read the same per-session - * sidecars the recall tools use, so they are registered with or without - * `assembly`. The cockpit is not here in V2 scope: the host exposes a - * TUI extension point, so a separate TUI plugin could reuse the same command core later. */ - import { appendFileSync, chmodSync, @@ -51,19 +45,16 @@ export interface LifecycleOptions extends SummarizerOptions { readonly swapAtRatio: number; readonly recutAtRatio: number; readonly keepRecentTokens: number; + readonly sidecarSource?: string; readonly healSourceTokens?: number; readonly smartZone?: number; readonly zones?: Readonly>; - /** Reserved seam for the shared redactor. The lifecycle reader never sets - * it, so the adapter always redacts; wiring `false` through will skip. */ readonly redactSecrets?: boolean; } export interface HealRunRequest { readonly sessionID: string; readonly store: LcmStore; - /** The store's own first and last entry id, which is the span every call in - * the run summarizes. */ readonly span: { readonly first: string; readonly last: string }; readonly dryRun: boolean; readonly maxNodes?: number; @@ -76,12 +67,7 @@ export interface LifecycleDeps { readonly host: () => string; readonly prompt: (sessionID: string, text: string) => Promise; readonly record: (row: Record) => void; - /** Run the bounded repair loop over this session's sidecar. A dry run reads - * the plan and must not call a model. */ readonly heal: (request: HealRunRequest) => Promise; - /** The session's model, when the host can name it. Diagnose uses it to - * report the window and the effective thresholds; the commands still work - * without it. */ readonly model?: (sessionID: string) => Promise; readonly window?: (model: ModelRef) => Promise; } @@ -105,8 +91,6 @@ function valueOf(args: readonly string[], name: string): string | undefined { function currentStore(deps: LifecycleDeps, sessionID: string): LcmStore | undefined { if (!existsSync(deps.sidecars.pathFor(sessionID))) return undefined; - // The adapter's own map owns the handle, so a command does not leak a - // store it opened only to read one block. return deps.sidecars.for(sessionID); } @@ -117,8 +101,7 @@ function chmodFile(path: string): void { } function writeDumpTarget(target: string, text: string): void { - // Unique temp with exclusive creation, then an atomic rename: a planted - // predictable path is never touched and the destination swaps at once. + // Exclusive create plus atomic rename. The code never touches a planted path. const dir = dirname(target); const base = basename(target); let tmp = ""; @@ -133,9 +116,7 @@ function writeDumpTarget(target: string, text: string): void { break; } catch (error) { if ((error as { code?: string })?.code === "EEXIST") continue; - // A write can create the private file and then fail (disk full), so - // remove the candidate before surfacing the error. EEXIST keeps its - // continue above and never unlinks a pre-existing collision. + // Only unlink what `wx` created. An EEXIST collision belongs to someone else. try { unlinkSync(candidate); } catch {} @@ -260,8 +241,6 @@ export function importCommand(deps: LifecycleDeps): CommandDefinition { } export function diagnoseCommand(deps: LifecycleDeps): CommandDefinition { - // The lifecycle reader never sets `redactSecrets`, so this always redacts; - // the flag check lives in the shared adapter, not at each call site. const redact = makeRedactConfigAdapter(deps.options); return { name: "lossless-diagnose", @@ -291,8 +270,6 @@ export function diagnoseCommand(deps: LifecycleDeps): CommandDefinition { liveSidecarPaths: livePaths, sidecarExtension: deps.sidecars.extension, }); - // The bundle is a diagnostic, not a probe: a host model lookup that - // fails says less, it does not fail the command. let model: ModelRef | undefined; try { model = await deps.model?.(input.sessionID); @@ -375,6 +352,7 @@ export function diagnoseCommand(deps: LifecycleDeps): CommandDefinition { home: homedir(), }; const bundle = buildDiagnoseBundle(inputBundle); + const body = `sidecar: ${deps.sidecars.dir} (${deps.options.sidecarSource ?? "explicit"})\n${bundle}`; if (args.includes("--save") && !valueOf(args, "--save")) { await deps.prompt( input.sessionID, @@ -385,15 +363,15 @@ export function diagnoseCommand(deps: LifecycleDeps): CommandDefinition { const save = valueOf(args, "--save"); if (save !== undefined) { const target = resolve(save); - writeFileSync(target, `${bundle}\n`, { encoding: "utf8", mode: 0o600 }); + writeFileSync(target, `${body}\n`, { encoding: "utf8", mode: 0o600 }); chmodFile(target); await deps.prompt( input.sessionID, - `LCM diagnose: wrote ${Buffer.byteLength(bundle, "utf8")} bytes to ${target} (mode 0600).`, + `LCM diagnose: wrote ${Buffer.byteLength(body, "utf8")} bytes to ${target} (mode 0600).`, ); return; } - await deps.prompt(input.sessionID, bundle); + await deps.prompt(input.sessionID, body); } catch (error) { await deps.prompt( input.sessionID, @@ -411,8 +389,6 @@ export function migrateCommand(deps: LifecycleDeps): CommandDefinition { execute: async (input) => { const args = argsOf(input.prompt.text); const apply = args.includes("--apply"); - // A store this process holds open is already current: its constructor - // migrated it. Opening a second writer would only race the live session. const live = new Set(deps.sidecars.ids()); const currentPath = deps.sidecars.pathFor(input.sessionID); const refs = [ @@ -497,7 +473,6 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { } const sessionId = valueOf(args, "--session"); const live = new Set(deps.sidecars.ids()); - // Disk stems are sanitized session ids, so compare those, not raw ids. const liveStems = new Set([...live].map((id) => sanitizeSidecarId(id))); if (sessionId !== undefined && live.has(sessionId)) { await deps.prompt( @@ -526,8 +501,8 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { for (const ref of selected) { try { const stat = statSync(ref.path); - // A write can land in the WAL while the database file's mtime stays - // put. The newest of the three files is the sidecar's last activity. + // A write can go to the WAL while the database file mtime stays unchanged. + // The newest of the three files is the sidecar's last activity. let mtimeMs = stat.mtimeMs; for (const suffix of ["-wal", "-shm"]) { try { @@ -537,10 +512,7 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { if (sessionId !== undefined || mtimeMs <= cutoff) { candidates.push({ ref, stat, mtimeMs }); } - } catch { - // A sidecar that vanished between the listing and the stat is not a - // candidate. - } + } catch {} } if (candidates.length === 0) { await deps.prompt( @@ -561,7 +533,6 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { return; } const exportTarget = join(deps.sidecars.dir, `lossless-export-gc-${STAMP()}.jsonl`); - // The export file is created on the first real write, so a no-op gc leaves no file. let removed = 0; let exported = 0; let failed = 0; @@ -652,8 +623,6 @@ export function healCommand(deps: LifecycleDeps): CommandDefinition { }; } -/** Read-only dump of this session's sidecar plus its metrics, opened with - * `queryOnly` so a dump never mutates the store. */ export function dumpCommand(deps: LifecycleDeps): CommandDefinition { const redact = makeRedactConfigAdapter(deps.options); return { @@ -710,8 +679,6 @@ export function dumpCommand(deps: LifecycleDeps): CommandDefinition { }; } -/** The compact result a command prompt carries. No summary or message text - * leaves the store; the counts are the whole answer. */ export function healReport(outcome: HealOutcome): string { if (outcome.dryRun) { return outcome.counts.candidates === 0 diff --git a/packages/opencode/src/plugin.ts b/packages/opencode/src/plugin.ts index 4fc445c..13668eb 100644 --- a/packages/opencode/src/plugin.ts +++ b/packages/opencode/src/plugin.ts @@ -1,3 +1,5 @@ +import { readdirSync } from "node:fs"; +import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -18,6 +20,7 @@ import { import { statusCommand, statusLine, passCommand, reportCommand } from "./command.ts"; import { createCheckpointWriter } from "./compaction.ts"; import { createContextProjector, type ProjectionOptions } from "./context.ts"; +import { defaultSidecarDir, FALLBACK_SIDECAR_DIR } from "./default-sidecar-dir.ts"; import type { ModelRef, OpencodeContext, Registration, SessionMessageInfo } from "./host.ts"; import { compactionMark, mirror, mirrorBusEvent, toolEntry, userEntry } from "./ingest.ts"; import { @@ -42,85 +45,49 @@ import { registerExpandQueryTool, registerRecallTools } from "./tools.ts"; export const PLUGIN_ID = "opencode-lossless"; -/** Options read from the plugin's `plugins[].options` entry. Every field is - * optional; an unknown or malformed value keeps its default rather than - * failing the plugin load. */ export interface OpencodeLosslessOptions { - /** Master flag. Nothing registers, mirrors, or mounts until true, unless - * the flag is absent, in which case listing the plugin is intent enough. - * @default true */ + /** @default true */ readonly enabled?: boolean; - /** Directory for one sidecar file per session. Created when missing. - * @default "./.lossless" (resolved from the server working directory) */ + /** @default global sidecar dir, `./.lossless` only as fallback */ readonly sidecarDir?: string; - /** Wire the context projection, the blocking checkpoint, the reader, and the - * on-demand `lossless-pass` command. `false` leaves compaction to the host and - * registers only the mirror, the three recall tools, and the commands. - * @default true + /** @default true * @example { assembly: false } */ readonly assembly?: boolean; - /** Models the pass tries for its summaries, in order. `auto` is the session - * model; this host has no synchronous model lookup, so a concrete id falls - * back to the session model. - * @default "auto" */ + /** @default "auto" */ readonly summarizer?: string | string[]; - /** Tokens one summarizer request may cost, estimate plus output cap, before - * it is refused rather than paid for. - * @default 60000 */ + /** @default 60000 */ readonly summarizerTokensPerCall?: number; - /** Tokens one pass may spend across all of its calls, counted from what the - * provider reported. - * @default 250000 */ + /** @default 250000 */ readonly summarizerTokensPerPass?: number; - /** Tokens one `/lossless-heal --apply` run may spend across all of its calls. - * A dry run spends nothing. - * @default 120000 */ + /** @default 120000 */ readonly summarizerTokensPerHeal?: number; - /** Tool names whose spans compact without a model call. Empty keeps the - * LLM path. - * @default [] */ + /** @default [] */ readonly mechanicalTools?: readonly string[]; - /** The occupancy share at which a request swaps in the projection. The - * engine clamps it to 0.9 of the window. - * @default 0.7 */ + /** @default 0.7 */ readonly swapAtRatio?: number; - /** The occupancy share at which a new cut replaces the pinned one. - * @default 0.85 */ + /** @default 0.85 */ readonly recutAtRatio?: number; - /** Absolute token count where model quality starts to degrade, used in - * place of `swapAtRatio`. The recut is derived from it. - * @default unset */ + /** @default unset */ readonly smartZone?: number; - /** Per-model threshold overrides, keyed `provider/model`. */ readonly zones?: Readonly>; - /** Tokens the projection keeps verbatim after the cut. - * @default 20000 */ + /** @default 20000 */ readonly keepRecentTokens?: number; - /** Tokens one summary aims for. - * @default 1500 */ + /** @default 1500 */ readonly summaryTokens?: number; - /** Estimated tokens one heal node may read as source. Defaults to - * `8 * summaryTokens`, clamped to 3,000 through 24,000 and to - * `summarizerTokensPerCall`. It is a whole-node window, not a per-message - * clip. Raise it to repair summaries over unusually large source messages. - * @default 8 x summaryTokens, clamped 3000..24000 */ + /** @default 8 x summaryTokens, clamped 3000..24000 */ readonly healSourceTokens?: number; - /** Window used when the host catalogue cannot name this model's limit. - * @default 128000 */ + /** @default 128000 */ readonly contextWindow?: number; - /** How long one `lcm_expand_query` child may run before it is interrupted. - * @default 120000 */ + /** @default 120000 */ readonly readerTimeoutMs?: number; } -const DEFAULT_SIDECAR_DIR = "./.lossless"; const DEFAULT_SWAP_RATIO = 0.7; const DEFAULT_RECUT_RATIO = 0.85; const DEFAULT_KEEP_RECENT_TOKENS = 20_000; const DEFAULT_SUMMARY_TOKENS = 1500; const DEFAULT_CONTEXT_WINDOW = 128_000; -/** The extension's own version, from the manifest that ships it. */ export function pluginVersion(): string { return ( packageVersionNear(fileURLToPath(import.meta.url), "opencode-lossless") ?? @@ -132,28 +99,13 @@ export function pluginLabel(): string { return `opencode-lossless ${pluginVersion()}`; } -/** Wire lossless memory into an OpenCode v2 plugin context: mirror prompts, - * assistant text, tool traffic, and compaction boundaries into per-session - * sidecars, and register the recall tools plus the `/lossless-status`, - * `/lossless-report`, `/lossless-pass`, and `/lossless-heal` commands. With - * `assembly` (the default), also register the context projection and the blocking checkpoint - * (`session.compaction`); `/lossless-pass` runs one DAG pass over the session's - * stored span on demand. The host stops summarizing for itself once the sidecar - * covers the aged span: it stores the DAG frontier as its checkpoint, and the - * next request's projection continues from it. The sidecar keeps every address - * the reader can name. Resolves to the cleanup that unregisters everything and - * closes the sidecars. The context is the local structural subset - * (`./host.ts`), so the real host context flows in without the bundle naming - * the host SDK. `rawOptions` is validated at runtime; a malformed value - * degrades to defaults rather than throwing into the host load. */ export async function setupOpencodeLossless( ctx: OpencodeContext, rawOptions?: unknown, ): Promise<() => Promise> { const options = readOptions(rawOptions); if (options.enabled === false) return async () => undefined; - // The plugin reader never sets `redactSecrets`, so this always redacts; - // the flag check lives in the shared adapter, not at each call site. + logSidecarDir(options.sidecarDir, options.sidecarSource); const redact = makeRedactConfigAdapter(options); const sidecars = new Sidecars(options.sidecarDir); const registrations: Registration[] = []; @@ -180,8 +132,6 @@ export async function setupOpencodeLossless( registrations.push( await ctx.session.hook("prompt", (event) => { - // A retired reader has no session and no store. Late bus traffic for it - // must not reopen the sidecar that was just deleted. if (reader.isReader(event.sessionID) && !reader.isActive(event.sessionID)) return; try { mirror(sidecars.for(event.sessionID), userEntry(event.sessionID, event)); @@ -202,8 +152,6 @@ export async function setupOpencodeLossless( ); registrations.push( await ctx.session.hook("compaction", async (event) => { - // A reader child is a leaf: its transcript is never compacted and its - // sidecar never becomes a checkpoint. if (reader.isReader(event.sessionID)) return; try { mirror(sidecars.for(event.sessionID), compactionMark(event.sessionID, event)); @@ -216,7 +164,6 @@ export async function setupOpencodeLossless( registrations.push( await ctx.tool.transform((editor) => { registerRecallTools(editor, { - // A reader child reads the parent's sidecar, never its own. store: (sessionId) => sessionId === undefined ? undefined : sidecars.peek(reader.storeSession(sessionId)), redact, @@ -260,6 +207,7 @@ export async function setupOpencodeLossless( sidecars, options: { assembly: options.assembly, + sidecarSource: options.sidecarSource, swapAtRatio: options.swapAtRatio, recutAtRatio: options.recutAtRatio, keepRecentTokens: options.keepRecentTokens, @@ -281,10 +229,9 @@ export async function setupOpencodeLossless( if (healingSessions.has(sessionID)) { throw new Error("a heal is already running for this session"); } - // A heal can outlive the plugin instance that started it: the host - // reloads plugins when its watched files change. Sidecars held by the - // plugin are closed on that reload, so the repair owns a separate - // connection for the duration of the run. + // A heal can outlive the plugin instance that started it. The host + // reloads plugins on watched-file changes and closes sidecars, so the run + // owns a separate connection for its duration. const runStore = new LcmStore(sidecars.pathFor(sessionID)); if (runStore.hasLiveRun()) { runStore.close(); @@ -357,8 +304,8 @@ export async function setupOpencodeLossless( const projector = createContextProjector({ ctx, options, sidecars, windowOf }); registrations.push( await ctx.session.hook("context", (event) => { - // The reader child gets one system part, its instructions, and nothing - // else: the agent prompt and the parent's projection both stay out. + // The reader child gets its instructions as the only system part. + // The agent prompt and the parent projection stay out. if (reader.isReader(event.sessionID)) { reader.resetSystem(event.system); return; @@ -383,7 +330,6 @@ export async function setupOpencodeLossless( const mapped = storedEntries(sessionID, messages); const model = sessionModel(messages); if (model === undefined) { - // An empty history needs no model and no pass. if (mapped.entries.length === 0) { return { kind: "nothing", reason: "no-entries", ingested: 0 }; } @@ -441,8 +387,6 @@ export async function setupOpencodeLossless( data: event.data, }); } catch (error) { - // One bad event must not end the subscription for the rest of the - // process. The hooks and tools keep working either way. if (sessionId !== undefined) failMirror(sessionId, error); } }; @@ -460,16 +404,14 @@ export async function setupOpencodeLossless( handle(next.value); } } catch { - // The host documents the event stream as volatile. Resubscribe below; - // assistant text has no other boundary in this host. + // The host documents the event stream as volatile. Resubscribe below. + // Assistant text has no other boundary in this host. } finally { current = undefined; if (iterator !== undefined) { try { await iterator.return?.(); - } catch { - // The stream is already gone; the next subscription starts clean. - } + } catch {} } } if (stopped) return; @@ -486,10 +428,7 @@ export async function setupOpencodeLossless( if (wake !== undefined) clearTimeout(wake); try { await current?.return?.(); - } catch { - // The pump is stopping either way; a stream that cannot close must not - // leave the registrations and stores open. - } + } catch {} for (const registration of registrations.splice(0).reverse()) { await registration.dispose(); } @@ -500,6 +439,7 @@ export async function setupOpencodeLossless( export interface RuntimeOptions extends ProjectionOptions { readonly enabled: boolean; readonly sidecarDir: string; + readonly sidecarSource: string; readonly assembly: boolean; readonly readerTimeoutMs: number; readonly healSourceTokens?: number; @@ -517,9 +457,11 @@ export function readOptions(raw: unknown): RuntimeOptions { const zones = readZones(record.zones); const healSourceTokens = readHealSourceTokens(record.healSourceTokens); const mechanicalTools = readMechanicalTools(record.mechanicalTools); + const sidecar = readSidecarDir(record.sidecarDir); return { enabled: typeof record.enabled === "boolean" ? record.enabled : true, - sidecarDir: typeof record.sidecarDir === "string" ? record.sidecarDir : DEFAULT_SIDECAR_DIR, + sidecarDir: sidecar.dir, + sidecarSource: sidecar.source, assembly: record.assembly !== false, swapAtRatio, recutAtRatio, @@ -541,6 +483,31 @@ export function readOptions(raw: unknown): RuntimeOptions { }; } +function readSidecarDir(raw: unknown): { dir: string; source: string } { + if (typeof raw === "string" && raw.trim().length > 0) { + return { dir: raw, source: "explicit" }; + } + const resolved = defaultSidecarDir(); + return { dir: resolved.dir, source: resolved.source }; +} + +function logSidecarDir(dir: string, source: string): void { + try { + console.log(`LCM sidecar dir: ${dir} (${source})`); + } catch {} + if (source === "explicit" || source === "fallback") return; + try { + if (resolve(FALLBACK_SIDECAR_DIR) === resolve(dir)) return; + const entries = readdirSync(FALLBACK_SIDECAR_DIR); + if (entries.length === 0) return; + console.warn( + `LCM sidecar: legacy ${resolve(FALLBACK_SIDECAR_DIR)} holds ${entries.length} file(s) ` + + `and is not read. Sidecars now live in ${dir} (${source}). Nothing was moved. ` + + `To keep the old dir set sidecarDir to "./.lossless". See /lossless-migrate.`, + ); + } catch {} +} + function readSummarizer(raw: unknown): string | string[] | undefined { if (typeof raw === "string") return raw.trim().length === 0 ? undefined : raw; if (!Array.isArray(raw)) return undefined; @@ -548,8 +515,6 @@ function readSummarizer(raw: unknown): string | string[] | undefined { return chain.length === 0 ? undefined : chain; } -/** Tool names that skip the model. One name or a list; blanks and repeats - * drop, and an empty result keeps the LLM path. */ function readMechanicalTools(raw: unknown): string[] | undefined { const list = typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : undefined; if (list === undefined) return undefined; @@ -576,16 +541,12 @@ function positiveInteger(raw: unknown): number | undefined { return value !== undefined && Number.isInteger(value) ? value : undefined; } -/** A source window above the hard ceiling is a malformed override, not a - * request for a prompt the summarizer call cap cannot carry. Falling back to - * the derived default is safer than clamping a typo into a paid call. */ +/** Above the call cap a value is a typo, not a paid call. Fall back instead of clamping. */ function readHealSourceTokens(raw: unknown): number | undefined { const value = positiveInteger(raw); return value !== undefined && value <= HEAL_SOURCE_MAX_TOKENS ? value : undefined; } -/** A share of the window: above zero and at most one. Anything else keeps the - * default rather than throwing, the way the other options read. */ function readRatio(raw: unknown): number | undefined { const value = positiveNumber(raw); return value !== undefined && value <= 1 ? value : undefined; @@ -622,9 +583,6 @@ function readZones(raw: unknown): Record | undefined { return Object.keys(zones).length === 0 ? undefined : zones; } -/** The model the session last ran under, read from the stored messages so the - * pass needs no extra host call. A model switch or a compaction carries one, - * and an assistant message carries the one it answered with. */ function sessionModel(messages: readonly SessionMessageInfo[]): ModelRef | undefined { for (let index = messages.length - 1; index >= 0; index--) { const model = messages[index]?.model; @@ -643,10 +601,6 @@ function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error); } -/** The plugin definition the host loads. Options come from the config entry, - * so the default export reads them late, at setup time. The setup parameter - * is the local context subset; the host's richer context satisfies it, which - * `test/host-compat.test.ts` pins against the real host types. */ const definition = { id: PLUGIN_ID, setup: (ctx: OpencodeContext) => setupOpencodeLossless(ctx, ctx.options), diff --git a/packages/opencode/test/default-sidecar-dir.test.ts b/packages/opencode/test/default-sidecar-dir.test.ts new file mode 100644 index 0000000..3f12aef --- /dev/null +++ b/packages/opencode/test/default-sidecar-dir.test.ts @@ -0,0 +1,141 @@ +import { join } from "node:path"; + +import { describe, expect, it } from "vite-plus/test"; + +import { + defaultSidecarDir, + resolveDefaultSidecarDir, + type DefaultSidecarEnv, +} from "../src/default-sidecar-dir.ts"; +import { readOptions } from "../src/plugin.ts"; + +function resolve( + env: DefaultSidecarEnv, + homedir: string | undefined, + platform: string, +): { dir: string; source: string } { + return resolveDefaultSidecarDir({ env, homedir, platform }); +} + +describe("resolveDefaultSidecarDir", () => { + it("prefers a non-blank absolute XDG_DATA_HOME", () => { + expect(resolve({ XDG_DATA_HOME: "/data" }, "/home/u", "linux")).toEqual({ + dir: join("/data", "opencode", "lossless"), + source: "default:XDG_DATA_HOME", + }); + }); + + it("treats an empty XDG_DATA_HOME as unset", () => { + expect(resolve({ XDG_DATA_HOME: "" }, "/home/u", "linux")).toEqual({ + dir: join("/home/u", ".local", "share", "opencode", "lossless"), + source: "default:home", + }); + }); + + it("treats a whitespace-only XDG_DATA_HOME as unset", () => { + expect(resolve({ XDG_DATA_HOME: " " }, "/home/u", "linux")).toEqual({ + dir: join("/home/u", ".local", "share", "opencode", "lossless"), + source: "default:home", + }); + }); + + it("ignores a relative XDG_DATA_HOME", () => { + expect(resolve({ XDG_DATA_HOME: "rel/path" }, "/home/u", "linux")).toEqual({ + dir: join("/home/u", ".local", "share", "opencode", "lossless"), + source: "default:home", + }); + }); + + it("falls back to the home share dir on darwin", () => { + expect(resolve({}, "/Users/u", "darwin")).toEqual({ + dir: join("/Users/u", ".local", "share", "opencode", "lossless"), + source: "default:home", + }); + }); + + it("falls back to the home share dir on linux", () => { + expect(resolve({}, "/home/u", "linux")).toEqual({ + dir: join("/home/u", ".local", "share", "opencode", "lossless"), + source: "default:home", + }); + }); + + it("prefers LOCALAPPDATA on win32", () => { + const local = "C:\\Users\\u\\AppData\\Local"; + expect( + resolve( + { LOCALAPPDATA: local, APPDATA: "C:\\Users\\u\\AppData\\Roaming" }, + "C:\\Users\\u", + "win32", + ), + ).toEqual({ + dir: join(local, "opencode", "lossless"), + source: "default:LOCALAPPDATA", + }); + }); + + it("uses APPDATA on win32 when LOCALAPPDATA is unset", () => { + const roaming = "C:\\Users\\u\\AppData\\Roaming"; + expect(resolve({ APPDATA: roaming }, "C:\\Users\\u", "win32")).toEqual({ + dir: join(roaming, "opencode", "lossless"), + source: "default:APPDATA", + }); + }); + + it("treats a blank LOCALAPPDATA as unset on win32", () => { + const roaming = "C:\\Users\\u\\AppData\\Roaming"; + expect(resolve({ LOCALAPPDATA: " ", APPDATA: roaming }, "C:\\Users\\u", "win32")).toEqual({ + dir: join(roaming, "opencode", "lossless"), + source: "default:APPDATA", + }); + }); + + it("uses home AppData Local on win32 when no env dir is set", () => { + expect(resolve({}, "C:\\Users\\u", "win32")).toEqual({ + dir: join("C:\\Users\\u", "AppData", "Local", "opencode", "lossless"), + source: "default:home", + }); + }); + + it("treats blank win32 env dirs as unset and uses home AppData Local", () => { + expect(resolve({ LOCALAPPDATA: "", APPDATA: " " }, "C:\\Users\\u", "win32")).toEqual({ + dir: join("C:\\Users\\u", "AppData", "Local", "opencode", "lossless"), + source: "default:home", + }); + }); + + it("falls back to ./.lossless on win32 with no home", () => { + expect(resolve({}, undefined, "win32")).toEqual({ + dir: "./.lossless", + source: "fallback", + }); + }); + + it("falls back to ./.lossless with no home on posix", () => { + expect(resolve({}, undefined, "linux")).toEqual({ + dir: "./.lossless", + source: "fallback", + }); + }); +}); + +describe("readOptions sidecarDir", () => { + it("passes an explicit relative dir through byte identical", () => { + const options = readOptions({ sidecarDir: "./.lossless" }); + expect(options.sidecarDir).toBe("./.lossless"); + expect(options.sidecarSource).toBe("explicit"); + }); + + it("passes an explicit absolute dir through byte identical", () => { + const options = readOptions({ sidecarDir: "/tmp/lcm-abs" }); + expect(options.sidecarDir).toBe("/tmp/lcm-abs"); + expect(options.sidecarSource).toBe("explicit"); + }); + + it("falls back to the default for a blank dir", () => { + const expected = defaultSidecarDir(); + const options = readOptions({ sidecarDir: " " }); + expect(options.sidecarDir).toBe(expected.dir); + expect(options.sidecarSource).toBe(expected.source); + }); +}); diff --git a/packages/opencode/test/lifecycle.test.ts b/packages/opencode/test/lifecycle.test.ts index b3b4a6c..022a9e1 100644 --- a/packages/opencode/test/lifecycle.test.ts +++ b/packages/opencode/test/lifecycle.test.ts @@ -30,8 +30,6 @@ function seed(sidecars: Sidecars, sessionID: string, text: string): void { ]); } -/** A store the adapter has not opened: gc's candidate set is what is on disk, - * not what this process holds. */ function seedDetached(sidecars: Sidecars, sessionID: string, text: string): void { const store = new LcmStore(sidecars.pathFor(sessionID)); try { @@ -147,9 +145,11 @@ describe("lifecycle commands", () => { const text = await run(diagnoseCommand, h, "/lossless-diagnose --repair"); expect(text).toContain("LCM diagnose bundle"); expect(text).toContain("messages: 1"); + const [first, ...rest] = text.split("\n"); + expect(first).toBe(`sidecar: ${h.dir} (explicit)`); + expect(rest.join("\n")).not.toContain(h.dir); expect(text).toContain("model: test/model, window: 100.0k"); expect(text).toContain("rebuilt the full-text index"); - expect(text).not.toContain(h.dir); } finally { h.sidecars.close(); } @@ -178,8 +178,6 @@ describe("lifecycle commands", () => { try { seed(h.sidecars, SESSION, "the current session"); seedDetached(h.sidecars, "ses_retained", "the retained session"); - // Restart: drop every open handle, then diagnose through a fresh cache - // that holds only the diagnosing session open. h.sidecars.close(); const fresh = new Sidecars(h.dir); try { From 9418fa6fbc8501059f87082e1c1a96bf61d468dc Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Tue, 27 Oct 2026 13:51:10 +0200 Subject: [PATCH 2/6] Address round-1 bot findings: platform-aware XDG check, diagnose path masking, gc shared-dir warning, migrate guidance --- .changeset/opencode-sidecar-global-default.md | 2 +- .../guides/configuration/opencode.mdx | 2 +- .../_snippets/reference/command/opencode.mdx | 2 +- .../skills/opencode-lossless/SKILL.md | 2 +- packages/opencode/src/default-sidecar-dir.ts | 6 ++--- packages/opencode/src/lifecycle.ts | 11 +++++++++- .../opencode/test/default-sidecar-dir.test.ts | 22 +++++++++++++++++++ packages/opencode/test/lifecycle.test.ts | 7 ++++-- 8 files changed, 44 insertions(+), 10 deletions(-) diff --git a/.changeset/opencode-sidecar-global-default.md b/.changeset/opencode-sidecar-global-default.md index f1dd413..37f8e6a 100644 --- a/.changeset/opencode-sidecar-global-default.md +++ b/.changeset/opencode-sidecar-global-default.md @@ -2,4 +2,4 @@ "opencode-lossless": patch --- -Change the default `sidecarDir` to the auto-detected global path. The plugin uses `XDG_DATA_HOME` with a `~/.local/share` fallback, uses `LOCALAPPDATA` with an `APPDATA` fallback and then a `/AppData/Local` fallback on Windows, and uses `./.lossless` when no home resolves. To keep per-project storage, set `"sidecarDir": "./.lossless"`. After upgrading, run `/lossless-migrate` once per project that used `./.lossless`. The plugin never moves sidecars. +Change the default `sidecarDir` to the auto-detected global path. The plugin uses `XDG_DATA_HOME` with a `~/.local/share` fallback, uses `LOCALAPPDATA` with an `APPDATA` fallback and then a `/AppData/Local` fallback on Windows, and uses `./.lossless` when no home resolves. To keep per-project storage, set `"sidecarDir": "./.lossless"`. After upgrading a project that used `./.lossless`, keep that setting to keep its history: `/lossless-migrate` upgrades in place in the configured directory only (dry run by default) and moves nothing, so it cannot recover history left in `./.lossless`. To carry history across, export per session under the old config and import into an empty store under the new one, or copy the `.sqlite` files across and then migrate. The plugin never moves sidecars. diff --git a/apps/docs/content/_snippets/guides/configuration/opencode.mdx b/apps/docs/content/_snippets/guides/configuration/opencode.mdx index 24462ba..dfd6ab8 100644 --- a/apps/docs/content/_snippets/guides/configuration/opencode.mdx +++ b/apps/docs/content/_snippets/guides/configuration/opencode.mdx @@ -34,4 +34,4 @@ To keep sidecars inside the project, set `sidecarDir` to `"./.lossless"`. Withou To remove old sidecars from the shared directory, run `/lossless-gc`. Orphan sidecars accumulate there because the directory is shared. -After upgrading a project that stored sidecars in `./.lossless`, run `/lossless-migrate` once in that project. The plugin never moves sidecars, and the startup warning names both paths. +After upgrading a project that stored sidecars in `./.lossless`, keep `sidecarDir` set to `"./.lossless"` to keep reading that history. `/lossless-migrate` upgrades sidecars in place in the configured directory only: it is a dry run by default, moves nothing between directories, and cannot recover history left behind in `./.lossless`. To carry history to a new directory, run `/lossless-export` per session under the old config and `/lossless-import` each file into an empty store under the new config, or copy the `.sqlite` files across and then run `/lossless-migrate --apply`. The plugin never moves sidecars, and the startup warning names both paths. diff --git a/apps/docs/content/_snippets/reference/command/opencode.mdx b/apps/docs/content/_snippets/reference/command/opencode.mdx index 541af8e..a1e69c7 100644 --- a/apps/docs/content/_snippets/reference/command/opencode.mdx +++ b/apps/docs/content/_snippets/reference/command/opencode.mdx @@ -82,7 +82,7 @@ Restores rows from a file written by `/lossless-export`. It refuses the import w /lossless-gc --apply # export each transcript, then delete ``` -The host plugin API cannot list sessions, so `gc` selects by sidecar age (default 30 days) or an explicit id. It never deletes the current session. It exports each transcript before deleting it. +The host plugin API cannot list sessions, so `gc` selects by sidecar age (default 30 days) or an explicit id. It never deletes the current session. It exports each transcript before deleting it. It only excludes sidecars open in this process, so against the shared global directory `--apply` can delete a sidecar that is live in another process. Dry run first, and note `--older-than 0` makes every other sidecar eligible. ## migrate diff --git a/packages/opencode/skills/opencode-lossless/SKILL.md b/packages/opencode/skills/opencode-lossless/SKILL.md index a05b99e..0ad2978 100644 --- a/packages/opencode/skills/opencode-lossless/SKILL.md +++ b/packages/opencode/skills/opencode-lossless/SKILL.md @@ -59,7 +59,7 @@ To keep sidecars inside the project, set `sidecarDir` to `"./.lossless"`. The example stores sidecars in the project. Without `sidecarDir`, the plugin uses the global auto-detected path, which holds sessions from every project. The config reference lists the exact paths at https://stainless-code.com/lossless/reference/opencode/config -- `sidecarDir` holds one SQLite file per session (0700 dir, 0600 files). Delete one to forget that session. After upgrading a project that used `./.lossless`, run `/lossless-migrate` once in that project. +- `sidecarDir` holds one SQLite file per session (0700 dir, 0600 files). Delete one to forget that session. After upgrading a project that used `./.lossless`, keep `sidecarDir` set to `"./.lossless"` to keep that history: `/lossless-migrate` works in place in the configured directory only (dry run by default, moves nothing). To carry history elsewhere, `/lossless-export` each session under the old config and `/lossless-import` into an empty store under the new one, or copy the `.sqlite` files across and then migrate. - `assembly: true` (default) owns the context boundary. Projection swaps aged history and compaction stores the DAG frontier. - The config reference documents every other key and its default: swap thresholds, summarizer chain and budgets, heal window, and reader timeout. See https://stainless-code.com/lossless/reference/opencode/config diff --git a/packages/opencode/src/default-sidecar-dir.ts b/packages/opencode/src/default-sidecar-dir.ts index 485f5cf..f57beeb 100644 --- a/packages/opencode/src/default-sidecar-dir.ts +++ b/packages/opencode/src/default-sidecar-dir.ts @@ -30,8 +30,8 @@ function nonBlank(value: string | undefined): string | undefined { return trimmed.length === 0 ? undefined : trimmed; } -function isAbsoluteAny(path: string): boolean { - return posix.isAbsolute(path) || win32.isAbsolute(path); +function isAbsoluteForPlatform(path: string, platform: string): boolean { + return platform === "win32" ? win32.isAbsolute(path) : posix.isAbsolute(path); } export function resolveDefaultSidecarDir( @@ -39,7 +39,7 @@ export function resolveDefaultSidecarDir( ): ResolvedDefaultSidecarDir { const env = input.env; const xdg = nonBlank(env["XDG_DATA_HOME"]); - if (xdg !== undefined && isAbsoluteAny(xdg)) { + if (xdg !== undefined && isAbsoluteForPlatform(xdg, input.platform)) { return { dir: join(xdg, "opencode", "lossless"), source: "default:XDG_DATA_HOME" }; } const home = nonBlank(input.homedir); diff --git a/packages/opencode/src/lifecycle.ts b/packages/opencode/src/lifecycle.ts index 00ccb29..473905a 100644 --- a/packages/opencode/src/lifecycle.ts +++ b/packages/opencode/src/lifecycle.ts @@ -135,6 +135,15 @@ function writeDumpTarget(target: string, text: string): void { } } +function isAbsolutePath(value: string): boolean { + return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value); +} + +function maskSidecarDir(dir: string, home: string): string { + if (dir === home || dir.startsWith(`${home}/`)) return `~${dir.slice(home.length)}`; + if (isAbsolutePath(dir)) return ""; + return dir; +} function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -352,7 +361,7 @@ export function diagnoseCommand(deps: LifecycleDeps): CommandDefinition { home: homedir(), }; const bundle = buildDiagnoseBundle(inputBundle); - const body = `sidecar: ${deps.sidecars.dir} (${deps.options.sidecarSource ?? "explicit"})\n${bundle}`; + const body = `sidecar: ${maskSidecarDir(deps.sidecars.dir, inputBundle.home)} (${deps.options.sidecarSource ?? "explicit"})\n${bundle}`; if (args.includes("--save") && !valueOf(args, "--save")) { await deps.prompt( input.sessionID, diff --git a/packages/opencode/test/default-sidecar-dir.test.ts b/packages/opencode/test/default-sidecar-dir.test.ts index 3f12aef..4c731db 100644 --- a/packages/opencode/test/default-sidecar-dir.test.ts +++ b/packages/opencode/test/default-sidecar-dir.test.ts @@ -46,6 +46,28 @@ describe("resolveDefaultSidecarDir", () => { }); }); + it("ignores a Windows-style XDG_DATA_HOME on posix", () => { + expect(resolve({ XDG_DATA_HOME: "C:\\data" }, "/home/u", "linux")).toEqual({ + dir: join("/home/u", ".local", "share", "opencode", "lossless"), + source: "default:home", + }); + }); + + it("ignores a relative XDG_DATA_HOME on win32", () => { + const home = "C:\\Users\\u"; + expect(resolve({ XDG_DATA_HOME: "rel\\path" }, home, "win32")).toEqual({ + dir: join(home, "AppData", "Local", "opencode", "lossless"), + source: "default:home", + }); + }); + + it("accepts a posix XDG_DATA_HOME on win32 under the win32 rule", () => { + expect(resolve({ XDG_DATA_HOME: "/data" }, "C:\\Users\\u", "win32")).toEqual({ + dir: join("/data", "opencode", "lossless"), + source: "default:XDG_DATA_HOME", + }); + }); + it("falls back to the home share dir on darwin", () => { expect(resolve({}, "/Users/u", "darwin")).toEqual({ dir: join("/Users/u", ".local", "share", "opencode", "lossless"), diff --git a/packages/opencode/test/lifecycle.test.ts b/packages/opencode/test/lifecycle.test.ts index 022a9e1..f1afa5e 100644 --- a/packages/opencode/test/lifecycle.test.ts +++ b/packages/opencode/test/lifecycle.test.ts @@ -1,5 +1,5 @@ import { existsSync, mkdtempSync, readdirSync, readFileSync, statSync, utimesSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { LcmStore, ingestEntries } from "lossless-core"; @@ -146,7 +146,10 @@ describe("lifecycle commands", () => { expect(text).toContain("LCM diagnose bundle"); expect(text).toContain("messages: 1"); const [first, ...rest] = text.split("\n"); - expect(first).toBe(`sidecar: ${h.dir} (explicit)`); + const home = homedir(); + const maskedDir = + h.dir === home || h.dir.startsWith(`${home}/`) ? `~${h.dir.slice(home.length)}` : ""; + expect(first).toBe(`sidecar: ${maskedDir} (explicit)`); expect(rest.join("\n")).not.toContain(h.dir); expect(text).toContain("model: test/model, window: 100.0k"); expect(text).toContain("rebuilt the full-text index"); From 1dc3e74a00a5a145fffd44073615813dabc68840 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Tue, 27 Oct 2026 13:59:01 +0200 Subject: [PATCH 3/6] Fix WAL-unsafe sidecar copy advice: stop server and copy sqlite plus wal and shm --- .changeset/opencode-sidecar-global-default.md | 2 +- apps/docs/content/_snippets/guides/configuration/opencode.mdx | 2 +- packages/opencode/skills/opencode-lossless/SKILL.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/opencode-sidecar-global-default.md b/.changeset/opencode-sidecar-global-default.md index 37f8e6a..726012c 100644 --- a/.changeset/opencode-sidecar-global-default.md +++ b/.changeset/opencode-sidecar-global-default.md @@ -2,4 +2,4 @@ "opencode-lossless": patch --- -Change the default `sidecarDir` to the auto-detected global path. The plugin uses `XDG_DATA_HOME` with a `~/.local/share` fallback, uses `LOCALAPPDATA` with an `APPDATA` fallback and then a `/AppData/Local` fallback on Windows, and uses `./.lossless` when no home resolves. To keep per-project storage, set `"sidecarDir": "./.lossless"`. After upgrading a project that used `./.lossless`, keep that setting to keep its history: `/lossless-migrate` upgrades in place in the configured directory only (dry run by default) and moves nothing, so it cannot recover history left in `./.lossless`. To carry history across, export per session under the old config and import into an empty store under the new one, or copy the `.sqlite` files across and then migrate. The plugin never moves sidecars. +Change the default `sidecarDir` to the auto-detected global path. The plugin uses `XDG_DATA_HOME` with a `~/.local/share` fallback, uses `LOCALAPPDATA` with an `APPDATA` fallback and then a `/AppData/Local` fallback on Windows, and uses `./.lossless` when no home resolves. To keep per-project storage, set `"sidecarDir": "./.lossless"`. After upgrading a project that used `./.lossless`, keep that setting to keep its history: `/lossless-migrate` upgrades in place in the configured directory only (dry run by default) and moves nothing, so it cannot recover history left in `./.lossless`. To carry history across, export per session under the old config and import into an empty store under the new one, or stop the server first and copy the `.sqlite`, `-wal`, and `-shm` files across and then migrate. The plugin never moves sidecars. diff --git a/apps/docs/content/_snippets/guides/configuration/opencode.mdx b/apps/docs/content/_snippets/guides/configuration/opencode.mdx index dfd6ab8..85bd907 100644 --- a/apps/docs/content/_snippets/guides/configuration/opencode.mdx +++ b/apps/docs/content/_snippets/guides/configuration/opencode.mdx @@ -34,4 +34,4 @@ To keep sidecars inside the project, set `sidecarDir` to `"./.lossless"`. Withou To remove old sidecars from the shared directory, run `/lossless-gc`. Orphan sidecars accumulate there because the directory is shared. -After upgrading a project that stored sidecars in `./.lossless`, keep `sidecarDir` set to `"./.lossless"` to keep reading that history. `/lossless-migrate` upgrades sidecars in place in the configured directory only: it is a dry run by default, moves nothing between directories, and cannot recover history left behind in `./.lossless`. To carry history to a new directory, run `/lossless-export` per session under the old config and `/lossless-import` each file into an empty store under the new config, or copy the `.sqlite` files across and then run `/lossless-migrate --apply`. The plugin never moves sidecars, and the startup warning names both paths. +After upgrading a project that stored sidecars in `./.lossless`, keep `sidecarDir` set to `"./.lossless"` to keep reading that history. `/lossless-migrate` upgrades sidecars in place in the configured directory only: it is a dry run by default, moves nothing between directories, and cannot recover history left behind in `./.lossless`. To carry history to a new directory, run `/lossless-export` per session under the old config and `/lossless-import` each file into an empty store under the new config, or stop the server first and copy the `.sqlite`, `-wal`, and `-shm` files across and then run `/lossless-migrate --apply`. The plugin never moves sidecars, and the startup warning names both paths. diff --git a/packages/opencode/skills/opencode-lossless/SKILL.md b/packages/opencode/skills/opencode-lossless/SKILL.md index 0ad2978..eee407e 100644 --- a/packages/opencode/skills/opencode-lossless/SKILL.md +++ b/packages/opencode/skills/opencode-lossless/SKILL.md @@ -59,7 +59,7 @@ To keep sidecars inside the project, set `sidecarDir` to `"./.lossless"`. The example stores sidecars in the project. Without `sidecarDir`, the plugin uses the global auto-detected path, which holds sessions from every project. The config reference lists the exact paths at https://stainless-code.com/lossless/reference/opencode/config -- `sidecarDir` holds one SQLite file per session (0700 dir, 0600 files). Delete one to forget that session. After upgrading a project that used `./.lossless`, keep `sidecarDir` set to `"./.lossless"` to keep that history: `/lossless-migrate` works in place in the configured directory only (dry run by default, moves nothing). To carry history elsewhere, `/lossless-export` each session under the old config and `/lossless-import` into an empty store under the new one, or copy the `.sqlite` files across and then migrate. +- `sidecarDir` holds one SQLite file per session (0700 dir, 0600 files). Delete one to forget that session. After upgrading a project that used `./.lossless`, keep `sidecarDir` set to `"./.lossless"` to keep that history: `/lossless-migrate` works in place in the configured directory only (dry run by default, moves nothing). To carry history elsewhere, `/lossless-export` each session under the old config and `/lossless-import` into an empty store under the new one, or stop the server first and copy the `.sqlite`, `-wal`, and `-shm` files across and then migrate. - `assembly: true` (default) owns the context boundary. Projection swaps aged history and compaction stores the DAG frontier. - The config reference documents every other key and its default: swap thresholds, summarizer chain and budgets, heal window, and reader timeout. See https://stainless-code.com/lossless/reference/opencode/config From 3531768f51bd2801677a1066502b4e5bb5b1aa57 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Tue, 27 Oct 2026 14:08:47 +0200 Subject: [PATCH 4/6] Harden lossless-gc with soft guards: 60m grace, older-than 0 refusal, session force ladder, pre-delete re-stat --- .changeset/gc-soft-guards.md | 5 + .../_snippets/reference/command/opencode.mdx | 12 +- packages/opencode/src/lifecycle.ts | 206 +++++++++++++-- packages/opencode/test/lifecycle.test.ts | 242 +++++++++++++++++- 4 files changed, 442 insertions(+), 23 deletions(-) create mode 100644 .changeset/gc-soft-guards.md diff --git a/.changeset/gc-soft-guards.md b/.changeset/gc-soft-guards.md new file mode 100644 index 0000000..292b68f --- /dev/null +++ b/.changeset/gc-soft-guards.md @@ -0,0 +1,5 @@ +--- +"opencode-lossless": patch +--- + +Harden `lossless-gc` against deleting sidecars that are live in another process. Sweeps skip stores active within the last 60 minutes and name them, bare `--older-than 0` without `--session` is refused, `--session` on a recently active store needs `--force`, and candidates younger than 24 hours carry a warning. diff --git a/apps/docs/content/_snippets/reference/command/opencode.mdx b/apps/docs/content/_snippets/reference/command/opencode.mdx index a1e69c7..c3525f7 100644 --- a/apps/docs/content/_snippets/reference/command/opencode.mdx +++ b/apps/docs/content/_snippets/reference/command/opencode.mdx @@ -8,7 +8,7 @@ /lossless-import /lossless-diagnose [--save ] [--repair] /lossless-migrate [--apply] -/lossless-gc [--older-than ] [--session ] [--apply] +/lossless-gc [--older-than ] [--session ] [--force] [--apply] ``` There are ten commands. `/lossless-pass` is absent with `assembly: false`. `/lossless-heal` stays available. @@ -80,10 +80,20 @@ Restores rows from a file written by `/lossless-export`. It refuses the import w ```text /lossless-gc # dry-run list /lossless-gc --apply # export each transcript, then delete +/lossless-gc --session --apply --force # delete one recently active store ``` The host plugin API cannot list sessions, so `gc` selects by sidecar age (default 30 days) or an explicit id. It never deletes the current session. It exports each transcript before deleting it. It only excludes sidecars open in this process, so against the shared global directory `--apply` can delete a sidecar that is live in another process. Dry run first, and note `--older-than 0` makes every other sidecar eligible. +Safety facts. The default sweep never deletes a store active within the last 60 minutes, measured by the newest mtime across the `.sqlite`, `-wal`, and `-shm` files; future mtimes count as age 0. Bare `--older-than 0` without `--session` is refused before any I/O. `--session ` on a store active within 60 minutes is refused unless `--force` is also passed; `--force` without `--session` is refused. Candidates younger than 24 hours carry a one-line warning naming up to 5 ids. Every sweep re-stats each candidate immediately before export plus delete, and a store that received a write in between is skipped and named. Skipped stores are counted and named in dry-run and apply output. + +Reopen the guard decision if any one of the following arrives: + +1. A real incident report: gc deleted a live second-process sidecar and the export plus import path did not recover it cleanly. +2. A reliable liveness primitive: the host API gains session listing or leases, or the engine adopts a cross-process lock both writers honor. +3. Telemetry or user reports showing `--older-than 0` or `--session` used routinely in multi-server setups. +4. A change that makes the default unsafe: for example lowering the default cutoff toward hours, running gc on a timer without an operator reading output, or sharing one sidecar directory across machines over a network filesystem. + ## migrate ```text diff --git a/packages/opencode/src/lifecycle.ts b/packages/opencode/src/lifecycle.ts index 473905a..aad973b 100644 --- a/packages/opencode/src/lifecycle.ts +++ b/packages/opencode/src/lifecycle.ts @@ -464,6 +464,36 @@ export function migrateCommand(deps: LifecycleDeps): CommandDefinition { }; } +export const GC_RECENCY_GRACE_MINUTES = 60; +export const GC_WARN_YOUNG_HOURS = 24; +const GC_GRACE_MS = GC_RECENCY_GRACE_MINUTES * 60_000; +const GC_YOUNG_MS = GC_WARN_YOUNG_HOURS * 3_600_000; + +function maxSidecarMtime(path: string): { stat: Stats; mtimeMs: number } | undefined { + try { + const stat = statSync(path); + let mtimeMs = stat.mtimeMs; + for (const suffix of ["-wal", "-shm"] as const) { + try { + mtimeMs = Math.max(mtimeMs, statSync(`${path}${suffix}`).mtimeMs); + } catch {} + } + return { stat, mtimeMs }; + } catch { + return undefined; + } +} + +function gcYoungWarning(ids: readonly string[]): string | undefined { + if (ids.length === 0) return undefined; + const shown = ids.slice(0, 5).join(", "); + return `Note: ${ids.length} sidecar(s) younger than 24 hour(s): ${shown}${ids.length > 5 ? ", ..." : ""}. Confirm no other opencode process is using them before --apply.`; +} + +function gcSkippedLine(ids: readonly string[]): string { + return `Skipped ${ids.length} sidecar(s) active within the last 60 minute(s); pass --session to target one explicitly: ${ids.join(", ")}.`; +} + export function gcCommand(deps: LifecycleDeps): CommandDefinition { return { name: "lossless-gc", @@ -472,6 +502,7 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { execute: async (input) => { const args = argsOf(input.prompt.text); const apply = args.includes("--apply"); + const force = args.includes("--force"); if (args.includes("--session") && valueOf(args, "--session") === undefined) { await deps.prompt(input.sessionID, "LCM gc: --session takes a session id."); return; @@ -481,9 +512,16 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { return; } const sessionId = valueOf(args, "--session"); + if (force && sessionId === undefined) { + await deps.prompt(input.sessionID, "LCM gc: --force needs --session ."); + return; + } const live = new Set(deps.sidecars.ids()); const liveStems = new Set([...live].map((id) => sanitizeSidecarId(id))); - if (sessionId !== undefined && live.has(sessionId)) { + if ( + sessionId !== undefined && + (live.has(sessionId) || liveStems.has(sanitizeSidecarId(sessionId))) + ) { await deps.prompt( input.sessionID, `LCM gc: session ${sessionId} is open in this process; gc never deletes an open sidecar.`, @@ -496,8 +534,17 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { await deps.prompt(input.sessionID, "LCM gc: --older-than takes a number of days."); return; } + if (days <= 0 && sessionId === undefined) { + await deps.prompt( + input.sessionID, + "LCM gc: --older-than 0 needs --session . Without --session it would match every other sidecar, including ones live in another process.", + ); + return; + } const refs = deps.sidecars.list(input.sessionID).filter((ref) => !liveStems.has(ref.id)); - const selected = sessionId === undefined ? refs : refs.filter((ref) => ref.id === sessionId); + const sanitizedSession = sessionId === undefined ? undefined : sanitizeSidecarId(sessionId); + const selected = + sanitizedSession === undefined ? refs : refs.filter((ref) => ref.id === sanitizedSession); if (sessionId !== undefined && selected.length === 0) { await deps.prompt( input.sessionID, @@ -505,28 +552,125 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { ); return; } - const cutoff = Date.now() - days * 86_400_000; - const candidates: Array<{ ref: SidecarRef; stat: Stats; mtimeMs: number }> = []; + const now = Date.now(); + const ageMsOf = (mtimeMs: number): number => Math.max(0, now - mtimeMs); + const cutoff = now - days * 86_400_000; + const entries: Array<{ ref: SidecarRef; stat: Stats; mtimeMs: number }> = []; for (const ref of selected) { - try { - const stat = statSync(ref.path); - // A write can go to the WAL while the database file mtime stays unchanged. - // The newest of the three files is the sidecar's last activity. - let mtimeMs = stat.mtimeMs; - for (const suffix of ["-wal", "-shm"]) { + const found = maxSidecarMtime(ref.path); + if (found !== undefined) entries.push({ ref, ...found }); + } + if (sessionId !== undefined) { + if (entries.length === 0) { + await deps.prompt( + input.sessionID, + `LCM gc: nothing eligible (${refs.length} other sidecar(s), none older than ${days} day(s)).`, + ); + return; + } + const target = entries[0]!; + if (!force && ageMsOf(target.mtimeMs) < GC_GRACE_MS) { + const minutes = Math.floor(ageMsOf(target.mtimeMs) / 60_000); + await deps.prompt( + input.sessionID, + `LCM gc: session ${sessionId} was active ${minutes} minute(s) ago. It may be live in another process. Re-run with --session ${sessionId} --force to delete it anyway.`, + ); + return; + } + const candidates = entries; + const lines = candidates.map(({ ref, stat, mtimeMs }) => { + const ageDays = Math.max(0, (Date.now() - mtimeMs) / 86_400_000); + return ` ${ref.id}: ${(stat.size / 1024).toFixed(0)} KB, ${ageDays.toFixed(1)} day(s) old`; + }); + const young = gcYoungWarning( + candidates + .filter(({ mtimeMs }) => ageMsOf(mtimeMs) < GC_YOUNG_MS) + .map(({ ref }) => ref.id), + ); + if (!apply) { + await deps.prompt( + input.sessionID, + `LCM gc dry-run: ${candidates.length} sidecar(s) removable:\n${lines.join("\n")}${young === undefined ? "" : `\n${young}`}\nRun \`/lossless-gc --apply\` to export then delete them. Backups are never touched.`, + ); + return; + } + const fresh = maxSidecarMtime(target.ref.path); + if ( + fresh !== undefined && + !force && + Math.max(0, Date.now() - fresh.mtimeMs) < GC_GRACE_MS + ) { + const minutes = Math.floor(Math.max(0, Date.now() - fresh.mtimeMs) / 60_000); + await deps.prompt( + input.sessionID, + `LCM gc: session ${sessionId} was active ${minutes} minute(s) ago. It may be live in another process. Re-run with --session ${sessionId} --force to delete it anyway.`, + ); + return; + } + const exportTarget = join(deps.sidecars.dir, `lossless-export-gc-${STAMP()}.jsonl`); + let removed = 0; + let exported = 0; + let failed = 0; + for (const { ref } of candidates) { + let sizeBytes = target.stat.size; + try { + const fresh = maxSidecarMtime(ref.path); + if (fresh === undefined) throw new Error("sidecar unreadable before delete"); + sizeBytes = fresh.stat.size; + if (!force && Math.max(0, Date.now() - fresh.mtimeMs) < GC_GRACE_MS) { + const minutes = Math.floor(Math.max(0, Date.now() - fresh.mtimeMs) / 60_000); + await deps.prompt( + input.sessionID, + `LCM gc: session ${sessionId} was active ${minutes} minute(s) ago. It may be live in another process. Re-run with --session ${sessionId} --force to delete it anyway.`, + ); + return; + } + const store = new LcmStore(ref.path, { queryOnly: true }); + let rows = ""; try { - mtimeMs = Math.max(mtimeMs, statSync(`${ref.path}${suffix}`).mtimeMs); - } catch {} - } - if (sessionId !== undefined || mtimeMs <= cutoff) { - candidates.push({ ref, stat, mtimeMs }); + rows = exportTranscript(store).toJSONL(); + } finally { + store.close(); + } + if (rows.length > 0) { + appendFileSync(exportTarget, `${rows}\n`, { encoding: "utf8", mode: 0o600 }); + exported++; + } + deps.sidecars.drop(ref.id); + removed++; + deps.record({ + kind: "gc", + action: "deleted", + session: ref.id, + sizeBytes, + exportedTo: exportTarget, + }); + } catch (error) { + failed++; + deps.record({ + kind: "gc", + action: "failed", + session: ref.id, + error: error instanceof Error ? error.message.slice(0, 300) : String(error), + }); } - } catch {} + } + if (exported > 0) chmodFile(exportTarget); + await deps.prompt( + input.sessionID, + `LCM gc: removed ${removed} of ${candidates.length} sidecar(s); ${exported} transcript(s) exported to ${exportTarget}${failed === 0 ? "" : `; ${failed} skipped (migrate or inspect first)`}.${young === undefined ? "" : ` ${young}`}`, + ); + return; } + const candidates = entries.filter( + ({ mtimeMs }) => mtimeMs <= cutoff && ageMsOf(mtimeMs) >= GC_GRACE_MS, + ); + const skipped = entries.filter(({ mtimeMs }) => ageMsOf(mtimeMs) < GC_GRACE_MS); + const skippedIds = skipped.map(({ ref }) => ref.id); if (candidates.length === 0) { await deps.prompt( input.sessionID, - `LCM gc: nothing eligible (${refs.length} other sidecar(s), none older than ${days} day(s)).`, + `LCM gc: nothing eligible (${refs.length} other sidecar(s), none older than ${days} day(s)).${skipped.length === 0 ? "" : ` ${skipped.length} sidecar(s) skipped as active within the last 60 minute(s).`}`, ); return; } @@ -534,10 +678,13 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { const ageDays = Math.max(0, (Date.now() - mtimeMs) / 86_400_000); return ` ${ref.id}: ${(stat.size / 1024).toFixed(0)} KB, ${ageDays.toFixed(1)} day(s) old`; }); + const young = gcYoungWarning( + candidates.filter(({ mtimeMs }) => ageMsOf(mtimeMs) < GC_YOUNG_MS).map(({ ref }) => ref.id), + ); if (!apply) { await deps.prompt( input.sessionID, - `LCM gc dry-run: ${candidates.length} sidecar(s) removable:\n${lines.join("\n")}\nRun \`/lossless-gc --apply\` to export then delete them. Backups are never touched.`, + `LCM gc dry-run: ${candidates.length} sidecar(s) removable:\n${lines.join("\n")}${young === undefined ? "" : `\n${young}`}${skipped.length === 0 ? "" : `\n${gcSkippedLine(skippedIds)}`}\nRun \`/lossless-gc --apply\` to export then delete them. Backups are never touched.`, ); return; } @@ -545,8 +692,24 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { let removed = 0; let exported = 0; let failed = 0; - for (const { ref, stat } of candidates) { + const restatSkipped: string[] = []; + for (const { ref } of candidates) { try { + const fresh = maxSidecarMtime(ref.path); + if (fresh === undefined) { + failed++; + deps.record({ + kind: "gc", + action: "failed", + session: ref.id, + error: "sidecar unreadable before delete; skipped", + }); + continue; + } + if (Math.max(0, Date.now() - fresh.mtimeMs) < GC_GRACE_MS) { + restatSkipped.push(ref.id); + continue; + } const store = new LcmStore(ref.path, { queryOnly: true }); let rows = ""; try { @@ -564,7 +727,7 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { kind: "gc", action: "deleted", session: ref.id, - sizeBytes: stat.size, + sizeBytes: fresh.stat.size, exportedTo: exportTarget, }); } catch (error) { @@ -578,9 +741,10 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { } } if (exported > 0) chmodFile(exportTarget); + const allSkipped = [...skippedIds, ...restatSkipped]; await deps.prompt( input.sessionID, - `LCM gc: removed ${removed} of ${candidates.length} sidecar(s); ${exported} transcript(s) exported to ${exportTarget}${failed === 0 ? "" : `; ${failed} skipped (migrate or inspect first)`}.`, + `LCM gc: removed ${removed} of ${candidates.length} sidecar(s); ${exported} transcript(s) exported to ${exportTarget}${failed === 0 ? "" : `; ${failed} skipped (migrate or inspect first)`}.${young === undefined ? "" : ` ${young}`}${allSkipped.length === 0 ? "" : ` ${gcSkippedLine(allSkipped)}`}`, ); }, }; diff --git a/packages/opencode/test/lifecycle.test.ts b/packages/opencode/test/lifecycle.test.ts index f1afa5e..5625be7 100644 --- a/packages/opencode/test/lifecycle.test.ts +++ b/packages/opencode/test/lifecycle.test.ts @@ -1,4 +1,12 @@ -import { existsSync, mkdtempSync, readdirSync, readFileSync, statSync, utimesSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + statSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; @@ -41,6 +49,15 @@ function seedDetached(sidecars: Sidecars, sessionID: string, text: string): void } } +function ageAll(sidecars: Sidecars, sessionID: string, date: Date): void { + const base = sidecars.pathFor(sessionID); + for (const path of [base, `${base}-wal`, `${base}-shm`]) { + try { + utimesSync(path, date, date); + } catch {} + } +} + interface Harness { readonly dir: string; readonly sidecars: Sidecars; @@ -241,4 +258,227 @@ describe("lifecycle commands", () => { h.sidecars.close(); } }); + + it("gc grace skips a recently active store while an old one stays listed", async () => { + const h = harness(); + try { + seed(h.sidecars, SESSION, "the current session"); + seedDetached(h.sidecars, "ses_gc_old", "the old alpha"); + seedDetached(h.sidecars, "ses_gc_fresh", "the fresh beta"); + ageAll(h.sidecars, "ses_gc_old", new Date(0)); + ageAll(h.sidecars, "ses_gc_fresh", new Date(Date.now() - 30 * 60_000)); + + const dry = await run(gcCommand, h, "/lossless-gc --older-than 0.01"); + expect(dry).toContain("ses_gc_old"); + expect(dry).toContain("Skipped 1 sidecar(s) active within the last 60 minute(s)"); + expect(dry).toContain("ses_gc_fresh"); + expect(existsSync(h.sidecars.pathFor("ses_gc_old"))).toBe(true); + expect(existsSync(h.sidecars.pathFor("ses_gc_fresh"))).toBe(true); + } finally { + h.sidecars.close(); + } + }); + + it("gc refuses bare --older-than 0 without --session before any I/O", async () => { + const h = harness(); + try { + seed(h.sidecars, SESSION, "the current session"); + seedDetached(h.sidecars, "ses_gc_old", "the old session"); + ageAll(h.sidecars, "ses_gc_old", new Date(0)); + for (const raw of ["0", "0d"]) { + const text = await run(gcCommand, h, `/lossless-gc --older-than ${raw} --apply`); + expect(text).toBe( + "LCM gc: --older-than 0 needs --session . Without --session it would match every other sidecar, including ones live in another process.", + ); + } + expect(existsSync(h.sidecars.pathFor("ses_gc_old"))).toBe(true); + expect(readdirSync(h.dir).some((name) => name.startsWith("lossless-export-gc-"))).toBe(false); + expect(h.records.length).toBe(0); + } finally { + h.sidecars.close(); + } + }); + + it("gc --older-than 0 with --session targets only that id", async () => { + const h = harness(); + try { + seed(h.sidecars, SESSION, "the current session"); + seedDetached(h.sidecars, "ses_gc_a", "the alpha transcript"); + seedDetached(h.sidecars, "ses_gc_b", "the beta transcript"); + ageAll(h.sidecars, "ses_gc_a", new Date(0)); + ageAll(h.sidecars, "ses_gc_b", new Date(0)); + + const applied = await run( + gcCommand, + h, + "/lossless-gc --older-than 0 --session ses_gc_a --apply", + ); + expect(applied).toContain("removed 1 of 1"); + expect(existsSync(h.sidecars.pathFor("ses_gc_a"))).toBe(false); + expect(existsSync(h.sidecars.pathFor("ses_gc_b"))).toBe(true); + const exported = applied.split("exported to ")[1]?.split(" ")[0]?.replace(/\.$/u, ""); + expect(exported).toBeDefined(); + expect(readFileSync(exported!, "utf8")).toContain("the alpha transcript"); + } finally { + h.sidecars.close(); + } + }); + + it("gc --session on a recently active store refuses without --force", async () => { + const h = harness(); + try { + seed(h.sidecars, SESSION, "the current session"); + seedDetached(h.sidecars, "ses_gc_fresh", "the fresh session"); + + const applied = await run(gcCommand, h, "/lossless-gc --session ses_gc_fresh --apply"); + expect(applied).toContain("LCM gc: session ses_gc_fresh was active"); + expect(applied).toContain("minute(s) ago"); + expect(applied).toContain("--force"); + const dry = await run(gcCommand, h, "/lossless-gc --session ses_gc_fresh"); + expect(dry).toContain("LCM gc: session ses_gc_fresh was active"); + expect(existsSync(h.sidecars.pathFor("ses_gc_fresh"))).toBe(true); + expect(readdirSync(h.dir).some((name) => name.startsWith("lossless-export-gc-"))).toBe(false); + expect(h.records.some((row) => row.kind === "gc" && row.action === "deleted")).toBe(false); + } finally { + h.sidecars.close(); + } + }); + + it("gc --session --force exports then deletes exactly that fresh store", async () => { + const h = harness(); + try { + seed(h.sidecars, SESSION, "the current session"); + seedDetached(h.sidecars, "ses_gc_fresh", "the fresh force transcript"); + seedDetached(h.sidecars, "ses_gc_other", "the other transcript"); + ageAll(h.sidecars, "ses_gc_other", new Date(0)); + + const applied = await run( + gcCommand, + h, + "/lossless-gc --session ses_gc_fresh --force --apply", + ); + expect(applied).toContain("removed 1 of 1"); + expect(existsSync(h.sidecars.pathFor("ses_gc_fresh"))).toBe(false); + expect(existsSync(h.sidecars.pathFor("ses_gc_other"))).toBe(true); + const exported = applied.split("exported to ")[1]?.split(" ")[0]?.replace(/\.$/u, ""); + expect(exported).toBeDefined(); + expect(readFileSync(exported!, "utf8")).toContain("the fresh force transcript"); + expect( + h.records.some( + (row) => row.kind === "gc" && row.action === "deleted" && row.session === "ses_gc_fresh", + ), + ).toBe(true); + } finally { + h.sidecars.close(); + } + }); + + it("gc --force without --session is refused", async () => { + const h = harness(); + try { + seed(h.sidecars, SESSION, "the current session"); + seedDetached(h.sidecars, "ses_gc_old", "the old session"); + ageAll(h.sidecars, "ses_gc_old", new Date(0)); + + const text = await run(gcCommand, h, "/lossless-gc --force --apply"); + expect(text).toBe("LCM gc: --force needs --session ."); + expect(existsSync(h.sidecars.pathFor("ses_gc_old"))).toBe(true); + expect(h.records.length).toBe(0); + } finally { + h.sidecars.close(); + } + }); + + it("gc warns about candidates younger than 24 hours and stays quiet for older ones", async () => { + const young = harness(); + try { + seed(young.sidecars, SESSION, "the current session"); + seedDetached(young.sidecars, "ses_gc_young", "the young transcript"); + seedDetached(young.sidecars, "ses_gc_old", "the old transcript"); + ageAll(young.sidecars, "ses_gc_young", new Date(Date.now() - 5 * 3_600_000)); + ageAll(young.sidecars, "ses_gc_old", new Date(0)); + + const dry = await run(gcCommand, young, "/lossless-gc --older-than 0.01"); + expect(dry).toContain("younger than 24 hour(s)"); + expect(dry).toContain("ses_gc_young"); + } finally { + young.sidecars.close(); + } + const oldOnly = harness(); + try { + seed(oldOnly.sidecars, SESSION, "the current session"); + seedDetached(oldOnly.sidecars, "ses_gc_old", "the old transcript"); + ageAll(oldOnly.sidecars, "ses_gc_old", new Date(0)); + + const dry = await run(gcCommand, oldOnly, "/lossless-gc --older-than 30"); + expect(dry).not.toContain("younger than 24 hour(s)"); + } finally { + oldOnly.sidecars.close(); + } + }); + + it("gc treats future mtimes as age 0: skipped by sweep, listed as 0.0 with --force", async () => { + const h = harness(); + try { + seed(h.sidecars, SESSION, "the current session"); + seedDetached(h.sidecars, "ses_gc_fut", "the future transcript"); + ageAll(h.sidecars, "ses_gc_fut", new Date(Date.now() + 3_600_000)); + + const dry = await run(gcCommand, h, "/lossless-gc --older-than 30"); + expect(dry).toContain("nothing eligible"); + expect(dry).toContain("skipped as active within the last 60 minute(s)"); + expect(existsSync(h.sidecars.pathFor("ses_gc_fut"))).toBe(true); + + const forced = await run(gcCommand, h, "/lossless-gc --session ses_gc_fut --force"); + expect(forced).toContain("ses_gc_fut"); + expect(forced).toContain("0.0 day(s) old"); + expect(existsSync(h.sidecars.pathFor("ses_gc_fut"))).toBe(true); + } finally { + h.sidecars.close(); + } + }); + + it("gc grace uses the newest of sqlite, wal, and shm", async () => { + const h = harness(); + try { + seed(h.sidecars, SESSION, "the current session"); + seedDetached(h.sidecars, "ses_gc_old", "the old alpha"); + seedDetached(h.sidecars, "ses_gc_wal", "the wal transcript"); + ageAll(h.sidecars, "ses_gc_old", new Date(0)); + ageAll(h.sidecars, "ses_gc_wal", new Date(0)); + writeFileSync(`${h.sidecars.pathFor("ses_gc_wal")}-wal`, "fresh wal bytes"); + + const dry = await run(gcCommand, h, "/lossless-gc --older-than 30"); + expect(dry).toContain("ses_gc_old"); + expect(dry).toContain("Skipped 1 sidecar(s) active within the last 60 minute(s)"); + expect(dry).toContain("ses_gc_wal"); + + const applied = await run(gcCommand, h, "/lossless-gc --older-than 30 --apply"); + expect(applied).toContain("removed 1 of 1"); + expect(existsSync(h.sidecars.pathFor("ses_gc_wal"))).toBe(true); + expect(existsSync(h.sidecars.pathFor("ses_gc_old"))).toBe(false); + } finally { + h.sidecars.close(); + } + }); + + it("gc docs state the grace, the refusal, and the force ladder", async () => { + const { readFileSync: read } = await import("node:fs"); + const { join: joinPath } = await import("node:path"); + const doc = read( + joinPath( + new URL(".", import.meta.url).pathname, + "../../../apps/docs/content/_snippets/reference/command/opencode.mdx", + ), + "utf8", + ); + expect(doc).toContain( + "It only excludes sidecars open in this process, so against the shared global directory", + ); + expect(doc).toContain("60 minutes"); + expect(doc).toContain("--older-than 0"); + expect(doc).toContain("--force"); + expect(doc).toContain("24 hours"); + expect(doc).toContain("Reopen the guard decision"); + }); }); From 0efbacb6a600d8928b9d455a2b97228e36a58bef Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Tue, 27 Oct 2026 14:20:11 +0200 Subject: [PATCH 5/6] Fix round-5 bot findings plus gauntlet polish: docs-test fileURLToPath path, empty-sweep skipped ids, relative win32 env, UNC diagnose masking, JSDoc, message fixes --- .changeset/gc-soft-guards.md | 2 +- .../_snippets/reference/command/opencode.mdx | 14 ++++++------- packages/opencode/src/default-sidecar-dir.ts | 6 ++++-- packages/opencode/src/lifecycle.ts | 21 +++++++++++-------- packages/opencode/src/plugin.ts | 2 ++ .../opencode/test/default-sidecar-dir.test.ts | 18 ++++++++++++++++ packages/opencode/test/lifecycle.test.ts | 18 +++++++++------- 7 files changed, 54 insertions(+), 27 deletions(-) diff --git a/.changeset/gc-soft-guards.md b/.changeset/gc-soft-guards.md index 292b68f..7f44023 100644 --- a/.changeset/gc-soft-guards.md +++ b/.changeset/gc-soft-guards.md @@ -2,4 +2,4 @@ "opencode-lossless": patch --- -Harden `lossless-gc` against deleting sidecars that are live in another process. Sweeps skip stores active within the last 60 minutes and name them, bare `--older-than 0` without `--session` is refused, `--session` on a recently active store needs `--force`, and candidates younger than 24 hours carry a warning. +Harden `lossless-gc` against deleting sidecars that are live in another process. Sweeps skip stores active within the last 60 minutes and name them. Bare `--older-than 0` without `--session` is refused. `--session` on a store active within 60 minutes needs `--force`. Candidates younger than 24 hours carry a warning. diff --git a/apps/docs/content/_snippets/reference/command/opencode.mdx b/apps/docs/content/_snippets/reference/command/opencode.mdx index c3525f7..9bdc823 100644 --- a/apps/docs/content/_snippets/reference/command/opencode.mdx +++ b/apps/docs/content/_snippets/reference/command/opencode.mdx @@ -83,16 +83,16 @@ Restores rows from a file written by `/lossless-export`. It refuses the import w /lossless-gc --session --apply --force # delete one recently active store ``` -The host plugin API cannot list sessions, so `gc` selects by sidecar age (default 30 days) or an explicit id. It never deletes the current session. It exports each transcript before deleting it. It only excludes sidecars open in this process, so against the shared global directory `--apply` can delete a sidecar that is live in another process. Dry run first, and note `--older-than 0` makes every other sidecar eligible. +The host plugin API cannot list sessions, so `gc` selects by sidecar age (default 30 days) or an explicit id. It never deletes the current session. It exports each transcript before deleting it. It only excludes sidecars open in this process, so against the shared global directory `--apply` can delete a sidecar that is live in another process. Run a dry run first, and bare `--older-than 0` without `--session` is refused. -Safety facts. The default sweep never deletes a store active within the last 60 minutes, measured by the newest mtime across the `.sqlite`, `-wal`, and `-shm` files; future mtimes count as age 0. Bare `--older-than 0` without `--session` is refused before any I/O. `--session ` on a store active within 60 minutes is refused unless `--force` is also passed; `--force` without `--session` is refused. Candidates younger than 24 hours carry a one-line warning naming up to 5 ids. Every sweep re-stats each candidate immediately before export plus delete, and a store that received a write in between is skipped and named. Skipped stores are counted and named in dry-run and apply output. +The default sweep applies these guards. It never deletes a store active within the last 60 minutes, measured by the newest mtime across the `.sqlite`, `-wal`, and `-shm` files. Future mtimes count as age 0. Bare `--older-than 0` without `--session` is refused before any I/O. `--session ` on a store active within 60 minutes is refused unless `--force` is also passed. `--force` without `--session` is refused. Candidates younger than 24 hours carry a one-line warning naming up to 5 ids. Every sweep re-stats each candidate immediately before export then delete. The sweep skips a store that received a write in between and names it. Skipped stores are counted and named in dry-run and apply output. -Reopen the guard decision if any one of the following arrives: +Revisit these guards if any of the following holds: -1. A real incident report: gc deleted a live second-process sidecar and the export plus import path did not recover it cleanly. -2. A reliable liveness primitive: the host API gains session listing or leases, or the engine adopts a cross-process lock both writers honor. -3. Telemetry or user reports showing `--older-than 0` or `--session` used routinely in multi-server setups. -4. A change that makes the default unsafe: for example lowering the default cutoff toward hours, running gc on a timer without an operator reading output, or sharing one sidecar directory across machines over a network filesystem. +1. A real incident report shows gc deleted a live second-process sidecar and the export and import path did not recover it cleanly. +2. A reliable liveness check exists. The host API gains session listing or leases, or the engine adopts a cross-process lock that both writers honor. +3. Telemetry or user reports show `--older-than 0` or `--session` used routinely in multi-server setups. +4. A change makes the default unsafe. For example, the default cutoff drops toward hours, gc runs on a timer without an operator reading output, or one sidecar directory is shared across machines over a network filesystem. ## migrate diff --git a/packages/opencode/src/default-sidecar-dir.ts b/packages/opencode/src/default-sidecar-dir.ts index f57beeb..5570347 100644 --- a/packages/opencode/src/default-sidecar-dir.ts +++ b/packages/opencode/src/default-sidecar-dir.ts @@ -34,6 +34,7 @@ function isAbsoluteForPlatform(path: string, platform: string): boolean { return platform === "win32" ? win32.isAbsolute(path) : posix.isAbsolute(path); } +/** Pure resolver for the default sidecar directory. Only absolute env dirs win; anything else falls through toward home. */ export function resolveDefaultSidecarDir( input: ResolveDefaultSidecarDirInput, ): ResolvedDefaultSidecarDir { @@ -46,11 +47,11 @@ export function resolveDefaultSidecarDir( if (home !== undefined) { if (input.platform === "win32") { const local = nonBlank(env["LOCALAPPDATA"]); - if (local !== undefined) { + if (local !== undefined && isAbsoluteForPlatform(local, input.platform)) { return { dir: join(local, "opencode", "lossless"), source: "default:LOCALAPPDATA" }; } const roaming = nonBlank(env["APPDATA"]); - if (roaming !== undefined) { + if (roaming !== undefined && isAbsoluteForPlatform(roaming, input.platform)) { return { dir: join(roaming, "opencode", "lossless"), source: "default:APPDATA" }; } return { @@ -66,6 +67,7 @@ export function resolveDefaultSidecarDir( return { dir: FALLBACK_SIDECAR_DIR, source: "fallback" }; } +/** Resolve the default from the live process. Never throws: anything odd falls back to {@link FALLBACK_SIDECAR_DIR}. */ export function defaultSidecarDir(): ResolvedDefaultSidecarDir { try { return resolveDefaultSidecarDir({ diff --git a/packages/opencode/src/lifecycle.ts b/packages/opencode/src/lifecycle.ts index aad973b..9ad8b25 100644 --- a/packages/opencode/src/lifecycle.ts +++ b/packages/opencode/src/lifecycle.ts @@ -45,6 +45,7 @@ export interface LifecycleOptions extends SummarizerOptions { readonly swapAtRatio: number; readonly recutAtRatio: number; readonly keepRecentTokens: number; + /** Where the sidecar dir came from. Diagnose prints it. `explicit` when the option is set, else the default source. */ readonly sidecarSource?: string; readonly healSourceTokens?: number; readonly smartZone?: number; @@ -136,7 +137,7 @@ function writeDumpTarget(target: string, text: string): void { } function isAbsolutePath(value: string): boolean { - return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value); + return value.startsWith("/") || value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value); } function maskSidecarDir(dir: string, home: string): string { @@ -464,7 +465,9 @@ export function migrateCommand(deps: LifecycleDeps): CommandDefinition { }; } +/** A `--session` target younger than this is treated as possibly live in another process. */ export const GC_RECENCY_GRACE_MINUTES = 60; +/** Candidates younger than this carry a warning naming up to 5 ids. */ export const GC_WARN_YOUNG_HOURS = 24; const GC_GRACE_MS = GC_RECENCY_GRACE_MINUTES * 60_000; const GC_YOUNG_MS = GC_WARN_YOUNG_HOURS * 3_600_000; @@ -491,14 +494,14 @@ function gcYoungWarning(ids: readonly string[]): string | undefined { } function gcSkippedLine(ids: readonly string[]): string { - return `Skipped ${ids.length} sidecar(s) active within the last 60 minute(s); pass --session to target one explicitly: ${ids.join(", ")}.`; + return `Skipped ${ids.length} sidecar(s) active within the last 60 minute(s). Pass --session to target one explicitly: ${ids.join(", ")}.`; } export function gcCommand(deps: LifecycleDeps): CommandDefinition { return { name: "lossless-gc", description: - "List old sidecars, or with --apply export and delete them. Never touches this session.", + "List old sidecars, or with --apply export and delete them. It never touches this session.", execute: async (input) => { const args = argsOf(input.prompt.text); const apply = args.includes("--apply"); @@ -524,7 +527,7 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { ) { await deps.prompt( input.sessionID, - `LCM gc: session ${sessionId} is open in this process; gc never deletes an open sidecar.`, + `LCM gc: session ${sessionId} is open in this process. The command never deletes an open sidecar.`, ); return; } @@ -548,7 +551,7 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { if (sessionId !== undefined && selected.length === 0) { await deps.prompt( input.sessionID, - `LCM gc: no sidecar for session ${sessionId} (the current session is never a candidate).`, + `LCM gc: no sidecar for session ${sessionId}. The current session is never a candidate.`, ); return; } @@ -564,7 +567,7 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { if (entries.length === 0) { await deps.prompt( input.sessionID, - `LCM gc: nothing eligible (${refs.length} other sidecar(s), none older than ${days} day(s)).`, + `LCM gc: nothing eligible for session ${sessionId} (sidecar unreadable).`, ); return; } @@ -658,7 +661,7 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { if (exported > 0) chmodFile(exportTarget); await deps.prompt( input.sessionID, - `LCM gc: removed ${removed} of ${candidates.length} sidecar(s); ${exported} transcript(s) exported to ${exportTarget}${failed === 0 ? "" : `; ${failed} skipped (migrate or inspect first)`}.${young === undefined ? "" : ` ${young}`}`, + `LCM gc: removed ${removed} of ${candidates.length} sidecar(s). ${exported} transcript(s) exported to ${exportTarget}${failed === 0 ? "" : `. ${failed} skipped. Migrate or inspect first`}.${young === undefined ? "" : ` ${young}`}`, ); return; } @@ -670,7 +673,7 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { if (candidates.length === 0) { await deps.prompt( input.sessionID, - `LCM gc: nothing eligible (${refs.length} other sidecar(s), none older than ${days} day(s)).${skipped.length === 0 ? "" : ` ${skipped.length} sidecar(s) skipped as active within the last 60 minute(s).`}`, + `LCM gc: nothing eligible. ${refs.length} other sidecar(s), none older than ${days} day(s).${skippedIds.length === 0 ? "" : ` ${gcSkippedLine(skippedIds)}`}`, ); return; } @@ -744,7 +747,7 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { const allSkipped = [...skippedIds, ...restatSkipped]; await deps.prompt( input.sessionID, - `LCM gc: removed ${removed} of ${candidates.length} sidecar(s); ${exported} transcript(s) exported to ${exportTarget}${failed === 0 ? "" : `; ${failed} skipped (migrate or inspect first)`}.${young === undefined ? "" : ` ${young}`}${allSkipped.length === 0 ? "" : ` ${gcSkippedLine(allSkipped)}`}`, + `LCM gc: removed ${removed} of ${candidates.length} sidecar(s). ${exported} transcript(s) exported to ${exportTarget}${failed === 0 ? "" : `. ${failed} skipped. Migrate or inspect first`}.${young === undefined ? "" : ` ${young}`}${allSkipped.length === 0 ? "" : ` ${gcSkippedLine(allSkipped)}`}`, ); }, }; diff --git a/packages/opencode/src/plugin.ts b/packages/opencode/src/plugin.ts index 13668eb..f7b7c12 100644 --- a/packages/opencode/src/plugin.ts +++ b/packages/opencode/src/plugin.ts @@ -99,6 +99,7 @@ export function pluginLabel(): string { return `opencode-lossless ${pluginVersion()}`; } +/** Wire lossless memory into an OpenCode v2 plugin context: mirror traffic into per-session sidecars and register recall tools plus lifecycle commands. Resolves to the cleanup that unregisters everything and closes the sidecars. */ export async function setupOpencodeLossless( ctx: OpencodeContext, rawOptions?: unknown, @@ -439,6 +440,7 @@ export async function setupOpencodeLossless( export interface RuntimeOptions extends ProjectionOptions { readonly enabled: boolean; readonly sidecarDir: string; + /** Where the sidecar dir came from. `explicit` when the option is set, else the default source. */ readonly sidecarSource: string; readonly assembly: boolean; readonly readerTimeoutMs: number; diff --git a/packages/opencode/test/default-sidecar-dir.test.ts b/packages/opencode/test/default-sidecar-dir.test.ts index 4c731db..d02b5cc 100644 --- a/packages/opencode/test/default-sidecar-dir.test.ts +++ b/packages/opencode/test/default-sidecar-dir.test.ts @@ -112,6 +112,24 @@ describe("resolveDefaultSidecarDir", () => { }); }); + it("treats a relative LOCALAPPDATA as unset on win32", () => { + const roaming = "C:\\Users\\u\\AppData\\Roaming"; + expect( + resolve({ LOCALAPPDATA: "rel\\path", APPDATA: roaming }, "C:\\Users\\u", "win32"), + ).toEqual({ + dir: join(roaming, "opencode", "lossless"), + source: "default:APPDATA", + }); + }); + + it("treats relative win32 env dirs as unset and uses home AppData Local", () => { + expect( + resolve({ LOCALAPPDATA: "rel\\local", APPDATA: "rel\\roaming" }, "C:\\Users\\u", "win32"), + ).toEqual({ + dir: join("C:\\Users\\u", "AppData", "Local", "opencode", "lossless"), + source: "default:home", + }); + }); it("uses home AppData Local on win32 when no env dir is set", () => { expect(resolve({}, "C:\\Users\\u", "win32")).toEqual({ dir: join("C:\\Users\\u", "AppData", "Local", "opencode", "lossless"), diff --git a/packages/opencode/test/lifecycle.test.ts b/packages/opencode/test/lifecycle.test.ts index 5625be7..7921848 100644 --- a/packages/opencode/test/lifecycle.test.ts +++ b/packages/opencode/test/lifecycle.test.ts @@ -229,7 +229,7 @@ describe("lifecycle commands", () => { } }); - it("lists and deletes an old sidecar, exporting it first", async () => { + it("gc lists an old sidecar and deletes it after export", async () => { const h = harness(); try { seed(h.sidecars, SESSION, "the current session"); @@ -259,7 +259,7 @@ describe("lifecycle commands", () => { } }); - it("gc grace skips a recently active store while an old one stays listed", async () => { + it("gc grace skips a recently active store and lists an old one", async () => { const h = harness(); try { seed(h.sidecars, SESSION, "the current session"); @@ -389,7 +389,7 @@ describe("lifecycle commands", () => { } }); - it("gc warns about candidates younger than 24 hours and stays quiet for older ones", async () => { + it("gc warns about candidates younger than 24 hours and omits the warning for older ones", async () => { const young = harness(); try { seed(young.sidecars, SESSION, "the current session"); @@ -417,7 +417,7 @@ describe("lifecycle commands", () => { } }); - it("gc treats future mtimes as age 0: skipped by sweep, listed as 0.0 with --force", async () => { + it("gc skips future mtimes in sweeps and lists them as 0.0 days with --force", async () => { const h = harness(); try { seed(h.sidecars, SESSION, "the current session"); @@ -426,7 +426,8 @@ describe("lifecycle commands", () => { const dry = await run(gcCommand, h, "/lossless-gc --older-than 30"); expect(dry).toContain("nothing eligible"); - expect(dry).toContain("skipped as active within the last 60 minute(s)"); + expect(dry).toContain("Skipped 1 sidecar(s) active within the last 60 minute(s)"); + expect(dry).toContain("ses_gc_fut"); expect(existsSync(h.sidecars.pathFor("ses_gc_fut"))).toBe(true); const forced = await run(gcCommand, h, "/lossless-gc --session ses_gc_fut --force"); @@ -462,12 +463,13 @@ describe("lifecycle commands", () => { } }); - it("gc docs state the grace, the refusal, and the force ladder", async () => { + it("gc docs state the grace, the refusal, and the force rule", async () => { const { readFileSync: read } = await import("node:fs"); const { join: joinPath } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); const doc = read( joinPath( - new URL(".", import.meta.url).pathname, + fileURLToPath(new URL(".", import.meta.url)), "../../../apps/docs/content/_snippets/reference/command/opencode.mdx", ), "utf8", @@ -479,6 +481,6 @@ describe("lifecycle commands", () => { expect(doc).toContain("--older-than 0"); expect(doc).toContain("--force"); expect(doc).toContain("24 hours"); - expect(doc).toContain("Reopen the guard decision"); + expect(doc).toContain("Revisit these guards"); }); }); From 936cdb35111e3021f0508bdfb69e3a16fb521385 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Tue, 27 Oct 2026 14:35:01 +0200 Subject: [PATCH 6/6] Add gc verdict additions: skipped metric rows, idle-open docs, trigger 2 reword, force attestation, force-warning pins --- .../_snippets/reference/command/opencode.mdx | 4 +- packages/opencode/src/lifecycle.ts | 32 +++++++++++++++ packages/opencode/test/lifecycle.test.ts | 39 +++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/apps/docs/content/_snippets/reference/command/opencode.mdx b/apps/docs/content/_snippets/reference/command/opencode.mdx index 9bdc823..c84a860 100644 --- a/apps/docs/content/_snippets/reference/command/opencode.mdx +++ b/apps/docs/content/_snippets/reference/command/opencode.mdx @@ -85,12 +85,12 @@ Restores rows from a file written by `/lossless-export`. It refuses the import w The host plugin API cannot list sessions, so `gc` selects by sidecar age (default 30 days) or an explicit id. It never deletes the current session. It exports each transcript before deleting it. It only excludes sidecars open in this process, so against the shared global directory `--apply` can delete a sidecar that is live in another process. Run a dry run first, and bare `--older-than 0` without `--session` is refused. -The default sweep applies these guards. It never deletes a store active within the last 60 minutes, measured by the newest mtime across the `.sqlite`, `-wal`, and `-shm` files. Future mtimes count as age 0. Bare `--older-than 0` without `--session` is refused before any I/O. `--session ` on a store active within 60 minutes is refused unless `--force` is also passed. `--force` without `--session` is refused. Candidates younger than 24 hours carry a one-line warning naming up to 5 ids. Every sweep re-stats each candidate immediately before export then delete. The sweep skips a store that received a write in between and names it. Skipped stores are counted and named in dry-run and apply output. +The default sweep applies these guards. It never deletes a store active within the last 60 minutes, measured by the newest mtime across the `.sqlite`, `-wal`, and `-shm` files. mtime measures activity, not liveness; a session open in another process but idle over 60 minutes is still eligible. Future mtimes count as age 0. Bare `--older-than 0` without `--session` is refused before any I/O. `--session ` on a store active within 60 minutes is refused unless `--force` is also passed. `--force` without `--session` is refused. `--force` means the operator checked that no other process is using the target. Candidates younger than 24 hours carry a one-line warning naming up to 5 ids. Every sweep re-stats each candidate immediately before export then delete. The sweep skips a store that received a write in between and names it. Skipped stores are counted and named in dry-run and apply output. Revisit these guards if any of the following holds: 1. A real incident report shows gc deleted a live second-process sidecar and the export and import path did not recover it cleanly. -2. A reliable liveness check exists. The host API gains session listing or leases, or the engine adopts a cross-process lock that both writers honor. +2. A reliable host owned liveness primitive exists, such as session listing or leases from the host API. An owner scoped lockfile is a separate engine decision with its own design note, so this trigger does not assume the engine already solves cross-process coordination. 3. Telemetry or user reports show `--older-than 0` or `--session` used routinely in multi-server setups. 4. A change makes the default unsafe. For example, the default cutoff drops toward hours, gc runs on a timer without an operator reading output, or one sidecar directory is shared across machines over a network filesystem. diff --git a/packages/opencode/src/lifecycle.ts b/packages/opencode/src/lifecycle.ts index 9ad8b25..f54ccfb 100644 --- a/packages/opencode/src/lifecycle.ts +++ b/packages/opencode/src/lifecycle.ts @@ -573,6 +573,12 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { } const target = entries[0]!; if (!force && ageMsOf(target.mtimeMs) < GC_GRACE_MS) { + deps.record({ + kind: "gc", + action: "skipped", + session: target.ref.id, + reason: "recency-grace", + }); const minutes = Math.floor(ageMsOf(target.mtimeMs) / 60_000); await deps.prompt( input.sessionID, @@ -603,6 +609,12 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { !force && Math.max(0, Date.now() - fresh.mtimeMs) < GC_GRACE_MS ) { + deps.record({ + kind: "gc", + action: "skipped", + session: target.ref.id, + reason: "recency-grace", + }); const minutes = Math.floor(Math.max(0, Date.now() - fresh.mtimeMs) / 60_000); await deps.prompt( input.sessionID, @@ -621,6 +633,12 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { if (fresh === undefined) throw new Error("sidecar unreadable before delete"); sizeBytes = fresh.stat.size; if (!force && Math.max(0, Date.now() - fresh.mtimeMs) < GC_GRACE_MS) { + deps.record({ + kind: "gc", + action: "skipped", + session: ref.id, + reason: "recency-grace", + }); const minutes = Math.floor(Math.max(0, Date.now() - fresh.mtimeMs) / 60_000); await deps.prompt( input.sessionID, @@ -670,6 +688,14 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { ); const skipped = entries.filter(({ mtimeMs }) => ageMsOf(mtimeMs) < GC_GRACE_MS); const skippedIds = skipped.map(({ ref }) => ref.id); + for (const { ref } of skipped) { + deps.record({ + kind: "gc", + action: "skipped", + session: ref.id, + reason: "recency-grace", + }); + } if (candidates.length === 0) { await deps.prompt( input.sessionID, @@ -711,6 +737,12 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { } if (Math.max(0, Date.now() - fresh.mtimeMs) < GC_GRACE_MS) { restatSkipped.push(ref.id); + deps.record({ + kind: "gc", + action: "skipped", + session: ref.id, + reason: "recency-grace", + }); continue; } const store = new LcmStore(ref.path, { queryOnly: true }); diff --git a/packages/opencode/test/lifecycle.test.ts b/packages/opencode/test/lifecycle.test.ts index 7921848..20580b1 100644 --- a/packages/opencode/test/lifecycle.test.ts +++ b/packages/opencode/test/lifecycle.test.ts @@ -274,6 +274,15 @@ describe("lifecycle commands", () => { expect(dry).toContain("ses_gc_fresh"); expect(existsSync(h.sidecars.pathFor("ses_gc_old"))).toBe(true); expect(existsSync(h.sidecars.pathFor("ses_gc_fresh"))).toBe(true); + expect( + h.records.some( + (row) => + row.kind === "gc" && + row.action === "skipped" && + row.session === "ses_gc_fresh" && + row.reason === "recency-grace", + ), + ).toBe(true); } finally { h.sidecars.close(); } @@ -339,6 +348,15 @@ describe("lifecycle commands", () => { expect(existsSync(h.sidecars.pathFor("ses_gc_fresh"))).toBe(true); expect(readdirSync(h.dir).some((name) => name.startsWith("lossless-export-gc-"))).toBe(false); expect(h.records.some((row) => row.kind === "gc" && row.action === "deleted")).toBe(false); + expect( + h.records.some( + (row) => + row.kind === "gc" && + row.action === "skipped" && + row.session === "ses_gc_fresh" && + row.reason === "recency-grace", + ), + ).toBe(true); } finally { h.sidecars.close(); } @@ -358,6 +376,8 @@ describe("lifecycle commands", () => { "/lossless-gc --session ses_gc_fresh --force --apply", ); expect(applied).toContain("removed 1 of 1"); + expect(applied).toContain("younger than 24 hour(s)"); + expect(applied).toContain("ses_gc_fresh"); expect(existsSync(h.sidecars.pathFor("ses_gc_fresh"))).toBe(false); expect(existsSync(h.sidecars.pathFor("ses_gc_other"))).toBe(true); const exported = applied.split("exported to ")[1]?.split(" ")[0]?.replace(/\.$/u, ""); @@ -429,10 +449,20 @@ describe("lifecycle commands", () => { expect(dry).toContain("Skipped 1 sidecar(s) active within the last 60 minute(s)"); expect(dry).toContain("ses_gc_fut"); expect(existsSync(h.sidecars.pathFor("ses_gc_fut"))).toBe(true); + expect( + h.records.some( + (row) => + row.kind === "gc" && + row.action === "skipped" && + row.session === "ses_gc_fut" && + row.reason === "recency-grace", + ), + ).toBe(true); const forced = await run(gcCommand, h, "/lossless-gc --session ses_gc_fut --force"); expect(forced).toContain("ses_gc_fut"); expect(forced).toContain("0.0 day(s) old"); + expect(forced).toContain("younger than 24 hour(s)"); expect(existsSync(h.sidecars.pathFor("ses_gc_fut"))).toBe(true); } finally { h.sidecars.close(); @@ -453,6 +483,15 @@ describe("lifecycle commands", () => { expect(dry).toContain("ses_gc_old"); expect(dry).toContain("Skipped 1 sidecar(s) active within the last 60 minute(s)"); expect(dry).toContain("ses_gc_wal"); + expect( + h.records.some( + (row) => + row.kind === "gc" && + row.action === "skipped" && + row.session === "ses_gc_wal" && + row.reason === "recency-grace", + ), + ).toBe(true); const applied = await run(gcCommand, h, "/lossless-gc --older-than 30 --apply"); expect(applied).toContain("removed 1 of 1");