Skip to content

fix(runtime): support Node plugin loader (bun:sqlite + Bun.serve fallbacks)#121

Merged
tickernelz merged 2 commits into
tickernelz:mainfrom
leiverkus:fix/node-plugin-runtime-compat
Jun 5, 2026
Merged

fix(runtime): support Node plugin loader (bun:sqlite + Bun.serve fallbacks)#121
tickernelz merged 2 commits into
tickernelz:mainfrom
leiverkus:fix/node-plugin-runtime-compat

Conversation

@leiverkus

Copy link
Copy Markdown
Contributor

Summary

opencode 1.15.x loads plugins under Node, not Bun — even though the binary embeds Bun internally. The plugin loader uses Node's ESM resolver, which rejects the bun: URL scheme:

ERROR service=plugin path=opencode-mem
  error=Only URLs with a scheme in: file, data, node, and electron are
        supported by the default ESM loader. Received protocol 'bun:'
  failed to load plugin

Verified by strings $(which opencode) | grep "default ESM loader" matching Node's internal/modules/esm/loader.js wording verbatim, and by the error text itself ("Stripping types ..." / "Received protocol 'bun:'" are Node's exact phrasings).

This PR adds Node fallbacks for the two Bun-only call sites so the plugin loads on Node-based opencode without changing behavior under Bun.

Changes

src/services/sqlite/sqlite-bootstrap.ts

getDatabase() resolves the Database class in this order:

  1. Bunbun:sqlite (built-in, fastest, zero-install)
  2. Node ≥22.5node:sqlite DatabaseSync (built-in; matches bun:sqlite's synchronous prepare/run/all/get API used in this codebase)
  3. Fallbackbetter-sqlite3 (peer dep; wire-compatible API, prebuilt platform binaries)

If none resolve, the call throws with an actionable error message pointing at each install path.

src/services/web-server.ts

Bun.serve only exists under Bun. Added a thin serveFetch() adapter:

  • Under Bun → delegates to Bun.serve (unchanged path)
  • Under Node → wraps node:http and adapts between IncomingMessage/ServerResponse and the Web Request/Response primitives the handler already speaks. Streams plumbing uses Readable.toWeb / Readable.fromWeb (Node 18+).

Both paths expose the same minimal { stop(): void } surface, so the WebServer class itself doesn't branch on runtime.

Out of scope

  • src/services/web-server-worker.ts still uses Bun.serve and the Web Worker API. It is dead code — no import or new Worker() references it; web-server.ts:186 even comments // --- HTTP request handling (inlined from web-server-worker.ts) ---. Leaving it untouched to keep this PR minimal. Happy to delete in a follow-up if you'd like.
  • No new runtime deps. better-sqlite3 is only loaded as a last resort and would need to be added as a peer/optional dep if you want to make it part of the official Node story.

Verification

  • npx tsc --noEmit — clean under the existing tsconfig.json
  • npx prettier --check — passes for both touched files
  • getDatabase() under Node 26 → resolves DatabaseSync, prepared statement SELECT 1+1 as x returns { x: 2 }
  • HTTP adapter Request → IncomingMessage → Response → ServerResponse round-trip returns 200 with correct body
  • Bun path unchanged — Bun.serve / bun:sqlite still used when globalThis.Bun is defined

Could not run bun test locally (no Bun installed on this machine — long story involving opencode + Node-only sidecar runtimes). The pre-commit hook expects Bun; I ran its equivalents manually (npx tsc --noEmit + npx prettier --check) and committed with --no-verify. Your CI should fully re-validate.

Real-world impact

Closes #113, which I reported a few days ago after working-memory was the only memory plugin I could get loading on opencode 1.15.10 (anomalyco fork). With this PR, opencode-mem becomes an OSS opencode memory plugin offering semantic search + Web UI that works on Node-based plugin loaders. Especially useful for academic / GDPR-compliance setups that pin to @ai-sdk/openai-compatible providers (GWDG, AcademicCloud, etc.) which the anomalyco/opencode build runs natively.

Happy to iterate on naming, structure, or split into smaller commits — let me know what would land best.

leiverkus added 2 commits June 4, 2026 09:02
…backs)

opencode 1.15.x loads plugins under Node, not Bun — even though the binary
embeds Bun internally. The plugin loader uses Node's ESM resolver, which
rejects the `bun:` URL scheme with:

  Only URLs with a scheme in: file, data, node, and electron are supported
  by the default ESM loader. Received protocol 'bun:'

Two call sites kept the plugin Bun-only: `require("bun:sqlite")` in
`sqlite-bootstrap.ts` and `Bun.serve(...)` in `web-server.ts`. Both now
detect the runtime and dispatch to the native binding.

## SQLite

`getDatabase()` resolves the Database class in this order:

1. Bun  → `bun:sqlite` (built-in, fastest)
2. Node ≥22.5 → `node:sqlite` `DatabaseSync` (built-in; matches bun:sqlite's
   synchronous prepare/run/all/get API)
3. Fallback → `better-sqlite3` (peer dep; wire-compatible)

If none resolve, the call throws with an actionable message pointing at
each install path.

## Web server

`Bun.serve` only exists under Bun. Under Node we wrap `node:http` and
adapt between IncomingMessage/ServerResponse and the Web Request/Response
primitives that the handler already speaks. The Streams plumbing uses
`Readable.toWeb` / `Readable.fromWeb` (Node 18+).

Both paths expose the same `{ stop(): void }` surface, so the WebServer
class itself doesn't branch on runtime.

## Verification

- `npx tsc --noEmit` — clean under the existing tsconfig
- `getDatabase()` under Node 26 → resolves `DatabaseSync`, prepared
  statement `SELECT 1+1 as x` returns `{ x: 2 }`
- HTTP adapter (Request → IncomingMessage → Response → ServerResponse
  round-trip) returns 200 with correct body
- Bun path unchanged behaviorally — `Bun.serve` / `bun:sqlite` still used
  when `globalThis.Bun` is defined

Closes tickernelz#113
bun:sqlite and better-sqlite3 both expose `db.run(sql)` for executing a
single SQL statement without bindings — used throughout this project for
PRAGMA and `CREATE INDEX` setup in `connection-manager.ts` and
`shard-manager.ts`. `node:sqlite`'s `DatabaseSync` doesn't: that surface
lives on `db.exec(sql)` instead.

Subclass `DatabaseSync` to alias `run(sql)` onto `exec(sql)`. Param-bound
`run(sql, ...params)` is preserved by falling back to a prepared
statement, matching bun:sqlite's behavior even though the codebase
currently only uses the no-bindings form on the database object.

Caught by a Node end-to-end smoke test: the previous version of this
patch resolved DatabaseSync directly, which broke on the first
`db.run("PRAGMA busy_timeout = 5000")` call in connection-manager.

Verification:
- `bun test`: 143 pass / 0 fail (Bun path unchanged)
- `bun run typecheck`: clean
- Node 26 end-to-end: PRAGMA / CREATE INDEX / INSERT / SELECT all round-trip
@leiverkus

Copy link
Copy Markdown
Contributor Author

Update: pushed 8fee004 — found and fixed a follow-on API gap during local Bun/Node verification.

bun:sqlite and better-sqlite3 both expose db.run(sql) (no-bindings form) for PRAGMA and CREATE INDEX setup. node:sqlite's DatabaseSync doesn't — that surface lives on db.exec(sql) instead. The first commit resolved DatabaseSync directly, which broke on the very first db.run("PRAGMA busy_timeout = 5000") call in connection-manager.ts. Subclassed DatabaseSync to alias run(sql)exec(sql); param-bound run(sql, ...params) falls back to a prepared statement (preserved for future callers — codebase currently only uses the no-bindings form on the database object).

Verification post-fix

  • bun test: 143 pass / 0 fail (Bun path unchanged)
  • bun run typecheck: clean
  • bun run build: clean
  • Node 26 E2E (PRAGMA / CREATE TABLE / CREATE INDEX / INSERT via prepared / SELECT all): full round-trip ✓
  • Prettier check: clean

PR is now fully self-tested on both runtimes locally.

@tickernelz tickernelz merged commit d20875a into tickernelz:main Jun 5, 2026
1 check passed
tickernelz pushed a commit that referenced this pull request Jun 7, 2026
Follow-up to #121. `bun:sqlite` and `better-sqlite3` both expose
`db.transaction(fn)` which returns a callable that wraps `fn` in
BEGIN/COMMIT (auto-ROLLBACK on throw). `node:sqlite`'s `DatabaseSync`
has no equivalent.

`handleAddMemory` in `api-handlers.ts` and `client.addMemory` in
`services/client.ts` both use this method to atomically insert a memory
row, so the POST /api/memories endpoint and any auto-capture path crash
under Node with `db.transaction is not a function`. The original PR
caught the `db.run(sql)` gap but missed the transaction gap because the
local E2E smoke test exercised CRUD without atomicity.

Single-mode semantics only (BEGIN); the `.deferred` / `.immediate` /
`.exclusive` variants from better-sqlite3 are not exercised by this
codebase. Best-effort rollback (ignores secondary errors from the
ROLLBACK statement after partial state).

## Verification

- `bun test`: 143 pass / 0 fail (Bun path unchanged)
- `bun run typecheck`: clean
- `bun run build`: clean
- `npx prettier --check`: clean
- Node 26 E2E with commit + rollback:
  - tx() inserts a, b → SELECT returns [{t:"a"},{t:"b"}]
  - txFail() throws after inserting c → SELECT still returns [{t:"a"},{t:"b"}]
- Reproduced and patched the original failure path locally
  (POST /api/memories now returns success after applying this shim).
mzyil added a commit to mzyil/opencode-mem-distributed that referenced this pull request Jun 14, 2026
* fix(embedding): probe API health before marking as warmed up

When embeddingApiUrl and embeddingApiKey are configured, the plugin previously marked itself as ready without verifying the endpoint was reachable. This caused silent failures on subsequent embed() calls.

Add a lightweight probe request during warmup that sends a minimal embedding ('ping') to validate the API is actually responding. If the probe fails, isWarmedUp stays false and isReady() correctly reports the system as not ready.

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(api): prevent data loss on memory update with transactional safety

The handleUpdateMemory function had a critical bug: it deleted the old memory record before generating new embeddings. If the embedding API was unreachable (e.g., LM Studio not running), the exception thrown by embedWithTimeout would prevent the new record from being inserted, causing permanent data loss.

Fix: generate new embeddings FIRST, then atomically delete the old record and insert the updated one within a SQLite transaction.

Additionally:
- Wrap handleAddMemory and client.ts addMemory SQLite operations in transactions for consistency
- Add toBlob helper to both files
- Vector backend updates occur after SQLite transaction completes

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* RADME.md

* fix(runtime): support Node plugin loader (bun:sqlite + Bun.serve fallbacks)

opencode 1.15.x loads plugins under Node, not Bun — even though the binary
embeds Bun internally. The plugin loader uses Node's ESM resolver, which
rejects the `bun:` URL scheme with:

  Only URLs with a scheme in: file, data, node, and electron are supported
  by the default ESM loader. Received protocol 'bun:'

Two call sites kept the plugin Bun-only: `require("bun:sqlite")` in
`sqlite-bootstrap.ts` and `Bun.serve(...)` in `web-server.ts`. Both now
detect the runtime and dispatch to the native binding.

## SQLite

`getDatabase()` resolves the Database class in this order:

1. Bun  → `bun:sqlite` (built-in, fastest)
2. Node ≥22.5 → `node:sqlite` `DatabaseSync` (built-in; matches bun:sqlite's
   synchronous prepare/run/all/get API)
3. Fallback → `better-sqlite3` (peer dep; wire-compatible)

If none resolve, the call throws with an actionable message pointing at
each install path.

## Web server

`Bun.serve` only exists under Bun. Under Node we wrap `node:http` and
adapt between IncomingMessage/ServerResponse and the Web Request/Response
primitives that the handler already speaks. The Streams plumbing uses
`Readable.toWeb` / `Readable.fromWeb` (Node 18+).

Both paths expose the same `{ stop(): void }` surface, so the WebServer
class itself doesn't branch on runtime.

## Verification

- `npx tsc --noEmit` — clean under the existing tsconfig
- `getDatabase()` under Node 26 → resolves `DatabaseSync`, prepared
  statement `SELECT 1+1 as x` returns `{ x: 2 }`
- HTTP adapter (Request → IncomingMessage → Response → ServerResponse
  round-trip) returns 200 with correct body
- Bun path unchanged behaviorally — `Bun.serve` / `bun:sqlite` still used
  when `globalThis.Bun` is defined

Closes tickernelz#113

* fix(sqlite): shim db.run(sql) on node:sqlite DatabaseSync

bun:sqlite and better-sqlite3 both expose `db.run(sql)` for executing a
single SQL statement without bindings — used throughout this project for
PRAGMA and `CREATE INDEX` setup in `connection-manager.ts` and
`shard-manager.ts`. `node:sqlite`'s `DatabaseSync` doesn't: that surface
lives on `db.exec(sql)` instead.

Subclass `DatabaseSync` to alias `run(sql)` onto `exec(sql)`. Param-bound
`run(sql, ...params)` is preserved by falling back to a prepared
statement, matching bun:sqlite's behavior even though the codebase
currently only uses the no-bindings form on the database object.

Caught by a Node end-to-end smoke test: the previous version of this
patch resolved DatabaseSync directly, which broke on the first
`db.run("PRAGMA busy_timeout = 5000")` call in connection-manager.

Verification:
- `bun test`: 143 pass / 0 fail (Bun path unchanged)
- `bun run typecheck`: clean
- Node 26 end-to-end: PRAGMA / CREATE INDEX / INSERT / SELECT all round-trip

* chore: release v2.15.0

* fix(sqlite): shim db.transaction(fn) on node:sqlite DatabaseSync

Follow-up to tickernelz#121. `bun:sqlite` and `better-sqlite3` both expose
`db.transaction(fn)` which returns a callable that wraps `fn` in
BEGIN/COMMIT (auto-ROLLBACK on throw). `node:sqlite`'s `DatabaseSync`
has no equivalent.

`handleAddMemory` in `api-handlers.ts` and `client.addMemory` in
`services/client.ts` both use this method to atomically insert a memory
row, so the POST /api/memories endpoint and any auto-capture path crash
under Node with `db.transaction is not a function`. The original PR
caught the `db.run(sql)` gap but missed the transaction gap because the
local E2E smoke test exercised CRUD without atomicity.

Single-mode semantics only (BEGIN); the `.deferred` / `.immediate` /
`.exclusive` variants from better-sqlite3 are not exercised by this
codebase. Best-effort rollback (ignores secondary errors from the
ROLLBACK statement after partial state).

## Verification

- `bun test`: 143 pass / 0 fail (Bun path unchanged)
- `bun run typecheck`: clean
- `bun run build`: clean
- `npx prettier --check`: clean
- Node 26 E2E with commit + rollback:
  - tx() inserts a, b → SELECT returns [{t:"a"},{t:"b"}]
  - txFail() throws after inserting c → SELECT still returns [{t:"a"},{t:"b"}]
- Reproduced and patched the original failure path locally
  (POST /api/memories now returns success after applying this shim).

* fix(api): show user-scope memories in /api/memories listing

`handleListMemories` without a tag filter only walked project-scope shards
and discarded any row whose `container_tag` didn't include the literal
substring `_project_`. User-scope memories (canonical tag format
`opencode_user_<sha16>`) were therefore structurally invisible:

- The /api/memories endpoint returned `items: []` even when user-scope
  memories existed in the store
- `/api/stats` already counted both scopes correctly (see lines 742-743),
  so the listing endpoint disagreed with the stats endpoint about what
  exists
- The Web UI navigation, which is built on /api/memories, had no way to
  surface user-scope memories at all

Reproduction (current main, before this patch):

```
POST /api/memories  body: { "content": "...", "containerTag": "opencode_user_xxx" }
   → { success: true, data: { id: "mem_..." } }
GET /api/stats
   → { byScope: { user: 1, project: 0 }, byType: { fact: 1 } }
GET /api/memories                      ← BUG: hides the row above
   → { items: [], total: 0 }
GET /api/search?q=...                  ← finds it (unaffected)
   → { items: [{ id, content, ... }] }
```

Fix: iterate both project- and user-scope shards in the no-tag path and
widen the defense-in-depth filter to match both canonical scope markers
(`_project_` or `_user_`). Behavior with a tag filter is unchanged
because `extractScopeFromTag` still resolves the requested scope.

Verification:
- `bun test`: 143 pass / 0 fail (no existing tests regressed)
- `bun run typecheck`: clean
- `bun run build`: clean
- `npx prettier --check`: clean
- Manually reproduced the bug on v2.15.0 and confirmed this patch resolves
  it: user-scope memories now appear in /api/memories the same way they
  appear in /api/stats and /api/search.

Note: `handleListTags` (line 105) is intentionally project-only by its
return-shape contract (`{ project: TagInfo[] }`) — that's a separate
design decision, not touched here. If user-scope tag listing is wanted, it
should be a new endpoint or a contract-breaking response-shape change,
neither of which fits this small fix.

* feat(api): export canonical tag helpers via `opencode-mem/tags` subpath

Adds a stable subpath export so third-party plugins can compute canonical
container tags without reverse-engineering the format from the source.

```ts
import { getProjectTagInfo, getUserTagInfo, getTags } from "opencode-mem/tags";

const projectTag = getProjectTagInfo(process.cwd()).tag;
//   → "opencode_project_<sha16-of-git-remote-or-path>"

const userTag = getUserTagInfo().tag;
//   → "opencode_user_<sha16-of-git-email>"
```

## Why

The canonical format encodes meaning that several handlers depend on:

- `handleListMemories` (no-tag path) and `handleStats` `byScope` recognize
  scope via the literal `_project_` / `_user_` substring
- Auto-capture writes use these exact tag shapes
- `extractScopeFromTag` parses them with the same convention

Third-party plugins that hand-roll their own tags can land in a shadow
shard whose `container_tag` doesn't match either substring. Those rows
become invisible to listing endpoints and miscount in stats — easy to do
by accident because the docs and helpers were internal-only.

Exposing the helpers via a stable subpath turns this into intentional
public API. The functions are already exported at the source level; this
PR just makes them reachable via package resolution.

## What's in the subpath

```
TagInfo                         (interface)

getProjectTagInfo(directory)    → TagInfo with `opencode_project_<sha>`
getUserTagInfo()                → TagInfo with `opencode_user_<sha>`
getTags(directory)              → { user, project }

getProjectIdentity(directory)   → git-remote-or-path
getProjectRoot(directory)       → repo root or directory
getProjectName(directory)       → human-readable name

getGitEmail()                   → git config user.email
getGitName()                    → git config user.name
getGitRepoUrl(directory)        → remote.origin.url
getGitCommonDir(directory)      → `git rev-parse --git-common-dir`
getGitTopLevel(directory)       → repo top-level
```

The main entries are the three tag helpers; the git/path utilities come
along for free since they're in the same module.

## Verification

- `bun test`: 143 pass / 0 fail
- `bun run typecheck`: clean
- `bun run build`: clean
- `npx prettier --check`: clean
- Subpath resolution verified: `node -e "import('opencode-mem/tags')..."` returns
  all 11 helpers including the three documented entry points.

## Use case

I needed this while building a sibling plugin that exposes opencode-mems
`/api/search` + `POST /api/memories` as opencode LLM tools, so the agent
can do explicit `memory_search(query)` / `memory_remember(...)` calls
mid-conversation (complement to the existing implicit `injectOn: "first"`
path). Without canonical tag helpers, every such plugin would either
hand-roll the convention (and silently end up in shadow shards — I did
exactly that on first try) or copy-paste the internal `tags.ts` code.

A public subpath makes opencode-mem easier to build on top of and
encourages an ecosystem of tool-style plugins around the same data.

* chore: release v2.16.0

* feat(embedding): migrate @xenova/transformers → @huggingface/transformers@^4

Retry of tickernelz#90 (reverted in 8fb0836) on the v4 line. Per maintainer guidance
in the discussion issue: the revert was specific to @huggingface/transformers
@4.0.1's native ONNX runtime crashing under Windows + Bun, not a permanent
rejection. v4.2.0 ships a newer onnxruntime-node and uses sharp@^0.34.x.

## Why

`@xenova/transformers@2.17.2` is frozen and pins `sharp@^0.32.0`, which builds
its native binary via a postinstall script. OpenCode installs plugins with
lifecycle scripts skipped, so on macOS ARM64 the sharp binary is never created
and the plugin throws at load (tickernelz#94, tickernelz#97). `@huggingface/transformers@4.2.0`
pins `sharp@^0.34.5`, whose native binaries ship as prebuilt `@img/*` optional
packages (no install script) — so the backend installs cleanly under a
script-skipping plugin installer.

## Changes (minimal — mirrors tickernelz#90)

- `package.json`: `@xenova/transformers@^2.17.2` → `@huggingface/transformers@^4.2.0`
- `src/services/embedding.ts`: package specifier + types (`pipeline`/`env` API
  is identical). Lazy dynamic import is preserved — the specifier is still
  built from a non-literal array so the plugin-loader bundle never eagerly
  traverses the embedding stack.
- guard tests flipped to expect the new backend (and still forbid the old one)
- `scripts/verify-embedding-backend.mjs`: reproducible feature-extraction smoke
- `.github/workflows/embedding-backend.yml`: CI matrix (ubuntu/macOS/windows)

## Verification

Addresses each requested check:

- **lazy loading preserved** — `plugin-bundle-boundary` test still passes
  (no transformer internals in the plugin-loader bundle)
- **`bun install --ignore-scripts`** — prebuilt `@img/sharp-*` + `onnxruntime-node`
  binaries present without postinstall (asserted in CI)
- **plugin import/load** — `bun run build` + full `bun test` (143 pass) green
- **real feature-extraction call** — `verify-embedding-backend.mjs` loads the
  ONNX runtime and embeds (EN + non-EN), checks dims + L2 norm
- **macOS ARM64** — verified locally (bun + node)
- **Windows + Bun** — covered by the CI matrix (`windows-latest`), since the
  prior revert was motivated by native ONNX crashes there. This is the gate
  that should be green before merge.

I can only verify macOS ARM64 on my own hardware; the Windows/Linux evidence
comes from the CI matrix in this PR so it's reproducible rather than a claim.
If the Windows job is green here, the v4.0.1-era regression is resolved.

Closes tickernelz#94, closes tickernelz#97.

* ci(embedding): scope workflow to embedding backend only

The first run surfaced two PRE-EXISTING cross-platform issues unrelated to
this migration:
- `bun run build` uses `cp -r`, which fails on Windows (`cp: illegal option -- r`)
- one config test asserts a macOS-shaped storagePath, fails on Linux

Both blocked the embedding smoke (the actual proof) from running on
windows/ubuntu. They aren't this PR's concern — the build script and that
test predate it. Dropping `build` + full `bun test` from this matrix so it
proves exactly one thing: the @huggingface backend installs (script-free)
and runs a real feature-extraction call on ubuntu/macOS/windows under both
Bun and Node. tsc itself compiled fine on Windows; sharp-win32-x64 prebuilt
was present; the ONNX runtime crash that motivated the original revert did
NOT occur.

* chore: release v2.17.0

* fix(embedding): wrap tags with "Topics:" template and inline them into summary

The plugin already encodes tags into a separate tagsVector and blends it with
contentSim at 0.6/0.4 weight in VectorSearch#searchInShard. Two issues weaken
that boost in practice:

1. tagsVector is built from a bare comma list (`tags.join(", ")`). That format
   sits outside the multilingual-e5 training distribution, so the resulting
   vector drifts toward unrelated chatter and the 0.4 tag channel never reaches
   its potential. Wrapping the tags in a "Topics: ..." sentence yields a much
   more discriminative vector.

2. contentVector never sees the tags at all, so a query that mentions a tag
   keyword has to land via tagsSim alone. Appending a "Tags: a, b, c" footer
   to the persisted summary makes the 0.6 content channel also contribute,
   noticeably reducing the variance of recall on precise lookups (e.g. a
   single API name in a long-tail domain).

Verified locally on v2.17.0 by writing matching memories before/after the
patch and observing similarity scores climb from a tight 80-86 band into a
clearly separated 86-91 band, with tag-only memories now outranking unrelated
content matches that previously ranked first.

* fix(auto-capture): release captured=2 lock on abort to prevent stuck prompts

When performAutoCapture claims a prompt by transitioning its captured
column from 0 to 2, only two paths actually clean up that lock:

* summaryResult.type === skip calls deletePrompt
* 
esult.success === true calls markAsCaptured

Every other early return or thrown exception (no AI response yet, missing
client, no assistant messages, empty extracted content, LLM error, network
failure, plugin restart in the middle of an LLM call, addMemory failure)
leaves the row pinned at captured=2 with no recovery path. The startup
`UPDATE captured = 0 WHERE captured = 2` only resets the lock; it does
not retry the affected prompt because `getLastUncapturedPrompt` only
returns the *latest* uncaptured prompt for the *current* session.

In practice this means a single transient capture failure permanently
loses the conversation summary for that prompt, and across restarts the
problem accumulates as more prompts are silently dropped.

This change introduces a `releaseClaim` helper on `UserPromptManager`
that conditionally moves rows from `captured = 2` back to
`captured = 0`. `performAutoCapture` then tracks the currently
claimed prompt id in a local variable, clears it on the two terminal
paths, and releases the claim from the existing `finally` block on
every other exit path. The release itself is wrapped in a try/catch so
that database errors during cleanup are logged but do not mask the
original capture failure.

Tests
-----
Added tests/user-prompt-manager-claim.test.ts covering:

* claim transitions captured 0 -> 2 and is mutually exclusive
* release transitions captured 2 -> 0 and re-exposes the prompt
* release is a no-op for captured=1 (success) and captured=0 (already
  pending) so a successful capture is never rolled back
* full claim -> release -> re-claim retry cycle

All 6 new tests pass under bun test. Pre-existing unrelated failures on
upstream/main (project-scope, plugin-loader contract, vector backends,
user-profile manager) are unchanged.

* fix: prevent CLOSE_WAIT accumulation and port unrelease on Windows

- Replace server.close() with closeAllConnections() + close() to
  destroy client sockets before stopping the server
- Add reuseAddr: true to server.listen() to allow immediate
  port rebinding even when OS TCP stack holds old socket
- Set server.timeout / keepAliveTimeout / headersTimeout to
  auto-destroy idle/unresponsive connections
- Set Connection: close header to prevent Chrome keep-alive
  CLOSE_WAIT accumulation on the web UI client
- Replace beforeExit handler with exit handler (fires on
  process.exit() which is how opencode exits on Windows)
- Extract cleanupPlugin() for shared shutdown logic

* fix: handle malformed JSON in tool call arguments with extractFirstJSON and retry feedback

* test: keep profile runtime mock shutdown-compatible

* chore: release v2.17.1

---------

Co-authored-by: High-cla <high-cla@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Co-authored-by: alhattami0 <alhattamieyad@gmail.com>
Co-authored-by: Patrick Leiverkus <leiverkus@gmail.com>
Co-authored-by: Zhafron Kautsar <zhafronadani@gmail.com>
Co-authored-by: junyuyuan <junyuyuan@users.noreply.github.com>
Co-authored-by: 张鹏 <peng.zhang@baishan.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plugin fails to load on v2.14.3: require("bun:sqlite") in ESM scope in sqlite-bootstrap.js

2 participants