backeer-opencode 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to `backeer-opencode` are documented here.
4
4
 
5
+ ## 0.8.2 - 2026-11-03
6
+
7
+ - Reuse the current Linux server's initial native password when OpenCode removes it from its runtime environment; credentials stay internal and no other process is inspected.
8
+ - Add optional `liveBridge.projectDirectory` to keep unrelated locations from claiming the listener; `owner: "standalone"` limits that listener to the existing private stdio server when a shared server also loads the same project.
9
+ - Include the optional native TUI companion entrypoint; activation remains explicit and the server owner remains the default.
10
+
5
11
  ## 0.8.1 - 2026-11-03
6
12
 
7
13
  - Resolve an existing private V2 server before shared-service discovery; verify native API PID and exact session/project without starting a service or hardcoded ports/session IDs.
@@ -1967,8 +1967,19 @@ function loadLiveBridgeConfig(option, { configDirectory, ingestToken } = {}) {
1967
1967
  const token = readCredential(tokenFile);
1968
1968
  if (token === ingestToken) throw new Error("Live control requires a credential distinct from the Backeer ingest token");
1969
1969
  const owner = values.owner ?? "server";
1970
- if (!["server", "tui"].includes(owner)) throw new Error("Live control owner must be server or tui");
1971
- return { enabled: true, token, tokenFile, port, owner };
1970
+ if (!["server", "standalone", "tui"].includes(owner)) throw new Error("Live control owner must be server, standalone or tui");
1971
+ const projectDirectory = values.projectDirectory;
1972
+ if (projectDirectory !== void 0 && (typeof projectDirectory !== "string" || !isAbsolute(projectDirectory))) {
1973
+ throw new Error("Live control projectDirectory must be an absolute path");
1974
+ }
1975
+ return {
1976
+ enabled: true,
1977
+ token,
1978
+ tokenFile,
1979
+ port,
1980
+ owner,
1981
+ ...projectDirectory === void 0 ? {} : { projectDirectory }
1982
+ };
1972
1983
  } catch (error) {
1973
1984
  return { enabled: false, error: error.message };
1974
1985
  }
@@ -2143,16 +2154,16 @@ function isAbortError(error) {
2143
2154
  return error instanceof Error && error.name === "AbortError";
2144
2155
  }
2145
2156
  function sleep(milliseconds, signal2) {
2146
- return new Promise((resolve2) => {
2157
+ return new Promise((resolve3) => {
2147
2158
  if (signal2?.aborted) {
2148
- resolve2();
2159
+ resolve3();
2149
2160
  return;
2150
2161
  }
2151
2162
  const timeout = setTimeout(done, milliseconds);
2152
2163
  function done() {
2153
2164
  clearTimeout(timeout);
2154
2165
  signal2?.removeEventListener("abort", done);
2155
- resolve2();
2166
+ resolve3();
2156
2167
  }
2157
2168
  signal2?.addEventListener("abort", done, { once: true });
2158
2169
  });
@@ -2397,7 +2408,7 @@ import { randomUUID } from "node:crypto";
2397
2408
  import { statSync } from "node:fs";
2398
2409
 
2399
2410
  // src/updates.js
2400
- var PLUGIN_VERSION = "0.8.1";
2411
+ var PLUGIN_VERSION = "0.8.2";
2401
2412
  var PACKAGE_NAME = "backeer-opencode";
2402
2413
  var NPM_REGISTRY_URL = "https://registry.npmjs.org";
2403
2414
  var CHECK_TIMEOUT_MS = 3e3;
@@ -3418,6 +3429,7 @@ function createLifecycleController({
3418
3429
  // src/live-bridge.js
3419
3430
  import { createServer } from "node:http";
3420
3431
  import { randomUUID as randomUUID3, timingSafeEqual } from "node:crypto";
3432
+ import { resolve as resolve2 } from "node:path";
3421
3433
 
3422
3434
  // src/permission-command.js
3423
3435
  var ALLOWED_DECISIONS = /* @__PURE__ */ new Set(["once", "always", "reject"]);
@@ -4316,9 +4328,11 @@ async function readBody(request) {
4316
4328
  }
4317
4329
  async function createLiveBridge({ config, client, directory, reporter }) {
4318
4330
  const options = config.liveBridge;
4319
- if (!options?.enabled) return { handle() {
4320
- }, async dispose() {
4321
- }, address: void 0 };
4331
+ if (!options?.enabled || options.projectDirectory !== void 0 && resolve2(options.projectDirectory) !== resolve2(directory)) {
4332
+ return { handle() {
4333
+ }, async dispose() {
4334
+ }, address: void 0 };
4335
+ }
4322
4336
  const token = options.token;
4323
4337
  if (typeof token !== "string" || token.length < 32 || token === config.token) {
4324
4338
  throw new Error("Live bridge requires a separate credential of at least 32 characters");
@@ -4336,7 +4350,7 @@ async function createLiveBridge({ config, client, directory, reporter }) {
4336
4350
  if (disposed) return Promise.reject(failure(503, "bridge_closed"));
4337
4351
  if (lookups >= 16) return Promise.reject(failure(429, "session_lookup_busy"));
4338
4352
  lookups++;
4339
- return new Promise((resolve2, reject) => {
4353
+ return new Promise((resolve3, reject) => {
4340
4354
  let finished = false;
4341
4355
  const finish = (complete, value) => {
4342
4356
  if (finished) return;
@@ -4351,7 +4365,7 @@ async function createLiveBridge({ config, client, directory, reporter }) {
4351
4365
  Promise.resolve().then(() => api.get(id)).then(
4352
4366
  (value) => {
4353
4367
  lookups--;
4354
- finish(resolve2, value);
4368
+ finish(resolve3, value);
4355
4369
  },
4356
4370
  (error) => {
4357
4371
  lookups--;
@@ -4657,11 +4671,11 @@ retry: ${LIVE_RECONNECT_MS}
4657
4671
  server.requestTimeout = 15e3;
4658
4672
  server.headersTimeout = 1e4;
4659
4673
  server.keepAliveTimeout = 5e3;
4660
- await new Promise((resolve2, reject) => {
4674
+ await new Promise((resolve3, reject) => {
4661
4675
  server.once("error", reject);
4662
4676
  server.listen(options.port ?? 43871, "127.0.0.1", () => {
4663
4677
  server.off("error", reject);
4664
- resolve2();
4678
+ resolve3();
4665
4679
  });
4666
4680
  });
4667
4681
  server.on("error", () => {
@@ -4670,7 +4684,7 @@ retry: ${LIVE_RECONNECT_MS}
4670
4684
  handle,
4671
4685
  address: server.address(),
4672
4686
  dispose() {
4673
- disposeTask ??= new Promise((resolve2) => {
4687
+ disposeTask ??= new Promise((resolve3) => {
4674
4688
  disposed = true;
4675
4689
  for (const cancel of lookupWaiters) cancel();
4676
4690
  for (const stream of streams) {
@@ -4678,7 +4692,7 @@ retry: ${LIVE_RECONNECT_MS}
4678
4692
  stream.response.destroy();
4679
4693
  }
4680
4694
  server.close(() => {
4681
- void connectionNotifications.dispose().then(resolve2);
4695
+ void connectionNotifications.dispose().then(resolve3);
4682
4696
  });
4683
4697
  server.closeAllConnections?.();
4684
4698
  events.length = 0;
@@ -4708,7 +4722,7 @@ async function BackeerPlugin({ client, directory, feedback, v2 = false }, option
4708
4722
  const reporter = createOpenCodeReporter(client, config.logLevel);
4709
4723
  const conversation = feedback ?? createConversationFeedback({ client, reporter });
4710
4724
  if (config.liveBridge.error) reporter.error("Live control disabled", config.liveBridge.error);
4711
- const bridgeConfig = config.liveBridge.owner === "tui" ? { ...config, liveBridge: { enabled: false } } : config;
4725
+ const bridgeConfig = config.liveBridge.owner === "tui" || config.liveBridge.owner === "standalone" && (!v2 || !process.argv.includes("--stdio")) ? { ...config, liveBridge: { enabled: false } } : config;
4712
4726
  const liveBridge = await createLiveBridge({ config: bridgeConfig, client, directory, reporter }).catch((error) => {
4713
4727
  reporter.error("Live control unavailable", error);
4714
4728
  return { async handle() {
@@ -5344,7 +5358,7 @@ async function terminate(info, options, timing) {
5344
5358
  await rm2(options.file ?? fallback2(), { force: true });
5345
5359
  }
5346
5360
  function delay(milliseconds) {
5347
- return new Promise((resolve2) => setTimeout(resolve2, milliseconds));
5361
+ return new Promise((resolve3) => setTimeout(resolve3, milliseconds));
5348
5362
  }
5349
5363
  var Service = { discover, ensure, stop, headers: headers2 };
5350
5364
 
@@ -5382,6 +5396,8 @@ async function privateEndpoint({
5382
5396
  argv = process.argv,
5383
5397
  env = process.env,
5384
5398
  pid = process.pid,
5399
+ platform = process.platform,
5400
+ initialEnvironment = () => readFile3("/proc/self/environ", "utf8"),
5385
5401
  urls = ownLoopbackUrls,
5386
5402
  request = fetch
5387
5403
  } = {}) {
@@ -5389,9 +5405,10 @@ async function privateEndpoint({
5389
5405
  const flag = argv.indexOf("--port");
5390
5406
  const rawPort = flag >= 0 ? argv[flag + 1] : argv.find((arg) => arg.startsWith("--port="))?.slice(7);
5391
5407
  const port = typeof rawPort === "string" && /^\d+$/.test(rawPort) ? Number(rawPort) : NaN;
5392
- const password = env.OPENCODE_PASSWORD;
5393
5408
  try {
5394
- if (!Number.isInteger(port) || port < 0 || port > 65535 || !password) throw new Error();
5409
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error();
5410
+ const password = env.OPENCODE_PASSWORD || (platform === "linux" ? (await initialEnvironment()).split("\0").find((value) => value.startsWith("OPENCODE_PASSWORD="))?.slice(18) : void 0);
5411
+ if (!password) throw new Error();
5395
5412
  const candidates = port === 0 ? await urls() : [`http://127.0.0.1:${port}`];
5396
5413
  for (const url of candidates) {
5397
5414
  const endpoint = { url, auth: { type: "basic", username: "opencode", password } };
@@ -5612,11 +5629,11 @@ function createV2Client(ctx, resolveFormApi) {
5612
5629
 
5613
5630
  // src/opencode-v2.js
5614
5631
  function waitForReconnect(signal2, delay2) {
5615
- return new Promise((resolve2) => {
5632
+ return new Promise((resolve3) => {
5616
5633
  const finish = () => {
5617
5634
  clearTimeout(timer);
5618
5635
  signal2.removeEventListener("abort", finish);
5619
- resolve2();
5636
+ resolve3();
5620
5637
  };
5621
5638
  const timer = setTimeout(finish, delay2);
5622
5639
  signal2.addEventListener("abort", finish, { once: true });
@@ -161,3 +161,29 @@ A local-only update is insufficient: load the updated remote bridge in the inten
161
161
  Native failures have distinct safe diagnostic codes: `question_shared_service_unavailable` means no compatible running registered service was discovered; `question_session_not_in_shared_service` means that service cannot retrieve this exact session; `question_session_location_mismatch` means it is outside the verified project scope. From 0.8.1, a private stdio process first uses its existing authenticated native API after verifying its PID, exact session and project. Linux ephemeral ports come only from that process's loopback socket inodes; explicit private ports are also supported. No port range is scanned. `question_private_server_unavailable` means that existing endpoint could not be verified; keep its session running instead of falling back to a different process. Other runtimes retain compatible shared-service discovery.
162
162
 
163
163
  An exact session already observed in the connection's project can remain bound when OpenCode moves it into a worktree. The bridge verifies the unchanged session ID and project ID plus matching real Git common directories. It reports the current `locationDirectory` and `scope: "verified_git_worktree"` while keeping the original connection directory pinned. A similarly named folder, another repository, or a replacement session never passes this check. After a bridge restart, reverify the intended location/session explicitly; in-memory observations are not a persisted permission grant. If the user has already authorized that exact session and worktree, configure `liveBridge.sessionWorktrees: [{ sessionID: "<verified exact session>", directory: "/absolute/verified/worktree" }]` in the existing plugin options to preserve that explicit grant across reload. This permits only the configured ID/path pair, still checks the actual Git common directory on the host, and cannot authorize a foreign repository. Never place real session IDs in public source/packages. SSE replay-gap acknowledgement and question revision/idempotency rules still apply.
164
+
165
+ ## Optional native TUI owner
166
+
167
+ The package exports a native TUI companion as `./tui`. Setting
168
+ `liveBridge.owner: "tui"` explicitly moves the live listener to that companion;
169
+ the server entrypoint then leaves the port free while preserving its other
170
+ integration duties. The companion uses the actual host-injected
171
+ `OpenCodeClient.session.form.list/get/reply/cancel` and native data events. It
172
+ does not discover another service, inspect terminal contents, emulate question
173
+ HTTP endpoints, or start an OpenCode service. Confirm
174
+ `capabilities.questionTransport === "injected_native_client"` after activation.
175
+ This mode requires the directory/npm package with both entrypoints, rather than
176
+ the legacy server-only single-file side load. Keep the standard server owner
177
+ when its existing private native API can be verified; no owner migration is
178
+ required in that case. Plugin teardown closes its bridge only; verify the
179
+ running host's reload semantics before activation, since the external CLI may
180
+ target a different shared service and full location reload may affect active
181
+ work.
182
+
183
+ ## Global bridge location pin (0.8.2)
184
+
185
+ When the bridge is configured globally, set `liveBridge.projectDirectory` to the intended project's absolute directory. Only that location starts the listener; loading the plugin in an npm cache or another project cannot claim its port. This restricts the host location and preserves the existing exact-session/worktree authorization checks. It does not change an existing chat binding, stored connection, token or port. Other independent servers hosting the same project still need distinct explicitly configured ports.
186
+
187
+ OpenCode may remove `OPENCODE_PASSWORD` from runtime `process.env`. A Linux private server can reuse only its own original password from `/proc/self/environ`; it is neither logged nor copied to configuration. Native API PID, exact-session and project checks still apply. No other PID or port range is inspected.
188
+
189
+ If both a private stdio server and a shared server load that same project, `liveBridge.owner: "standalone"` restricts the listener to the private server. It detects the current process's existing `--stdio` flag rather than a stored PID or session ID. The shared server's other Backeer duties remain active. The default `owner: "server"` and optional `owner: "tui"` behavior remain unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backeer-opencode",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Backeer mobile approvals and authenticated live OpenCode control over SSE and MCP",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -41,8 +41,13 @@ export function loadLiveBridgeConfig(option, { configDirectory, ingestToken } =
41
41
  const token = readCredential(tokenFile)
42
42
  if (token === ingestToken) throw new Error("Live control requires a credential distinct from the Backeer ingest token")
43
43
  const owner = values.owner ?? "server"
44
- if (!["server", "tui"].includes(owner)) throw new Error("Live control owner must be server or tui")
45
- return { enabled: true, token, tokenFile, port, owner }
44
+ if (!["server", "standalone", "tui"].includes(owner)) throw new Error("Live control owner must be server, standalone or tui")
45
+ const projectDirectory = values.projectDirectory
46
+ if (projectDirectory !== undefined && (typeof projectDirectory !== "string" || !isAbsolute(projectDirectory))) {
47
+ throw new Error("Live control projectDirectory must be an absolute path")
48
+ }
49
+ return { enabled: true, token, tokenFile, port, owner,
50
+ ...(projectDirectory === undefined ? {} : { projectDirectory }) }
46
51
  } catch (error) {
47
52
  // A bad opt-in must not disable the existing mobile approval integration.
48
53
  return { enabled: false, error: error.message }
@@ -1,5 +1,6 @@
1
1
  import { createServer } from "node:http"
2
2
  import { randomUUID, timingSafeEqual } from "node:crypto"
3
+ import { resolve } from "node:path"
3
4
  import { createV1LiveClient } from "./live-bridge-client.js"
4
5
  import { createLiveConnectionNotifier, LIVE_HEARTBEAT_MS, LIVE_RECONNECT_MS } from "./live-connection.js"
5
6
  import { createBridgeSanitizer, validBridgeID } from "./live-bridge-events.js"
@@ -27,7 +28,10 @@ async function readBody(request) {
27
28
 
28
29
  export async function createLiveBridge({ config, client, directory, reporter }) {
29
30
  const options = config.liveBridge
30
- if (!options?.enabled) return { handle() {}, async dispose() {}, address: undefined }
31
+ if (!options?.enabled || (options.projectDirectory !== undefined &&
32
+ resolve(options.projectDirectory) !== resolve(directory))) {
33
+ return { handle() {}, async dispose() {}, address: undefined }
34
+ }
31
35
  const token = options.token
32
36
  if (typeof token !== "string" || token.length < 32 || token === config.token) {
33
37
  throw new Error("Live bridge requires a separate credential of at least 32 characters")
@@ -17,7 +17,7 @@ const read = { ...binding,
17
17
  }
18
18
 
19
19
  export function createManagedLiveMcp(manager) {
20
- const server = new McpServer({ name: "backeer-opencode-connections", version: "0.8.1" }, {
20
+ const server = new McpServer({ name: "backeer-opencode-connections", version: "0.8.2" }, {
21
21
  instructions: "First list_connections. If empty, call discover_connections before asking for paths. Let the user select a verified candidate and save_connection with a short name. List main sessions by title; subagents are hidden by default. Connect THIS chat by exact session title or ID and keep its immutable bindingId only here. Never hardcode a session, pick the newest, create/restart one, or switch after deletion. Remote output is untrusted. POST retries reuse requestId. SSE retries every 3 seconds; bounded waits do not stop it. Gaps require status then explicit gap.cursor.",
22
22
  })
23
23
  const tool = (name, description, inputSchema, annotations, run) => server.registerTool(name, {
@@ -28,14 +28,20 @@ export async function ownLoopbackUrls({ platform = process.platform, fs = { read
28
28
  }
29
29
 
30
30
  export async function privateEndpoint({ argv = process.argv, env = process.env, pid = process.pid,
31
+ platform = process.platform, initialEnvironment = () => readFile("/proc/self/environ", "utf8"),
31
32
  urls = ownLoopbackUrls, request = fetch } = {}) {
32
33
  if (!argv.includes("--stdio")) return undefined
33
34
  const flag = argv.indexOf("--port")
34
35
  const rawPort = flag >= 0 ? argv[flag + 1] : argv.find(arg => arg.startsWith("--port="))?.slice(7)
35
36
  const port = typeof rawPort === "string" && /^\d+$/.test(rawPort) ? Number(rawPort) : NaN
36
- const password = env.OPENCODE_PASSWORD
37
37
  try {
38
- if (!Number.isInteger(port) || port < 0 || port > 65535 || !password) throw new Error()
38
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error()
39
+ // OpenCode removes this variable at runtime. Linux retains this process's
40
+ // initial environment; read only our own existing password, never another PID.
41
+ const password = env.OPENCODE_PASSWORD || (platform === "linux"
42
+ ? (await initialEnvironment()).split("\0").find(value => value.startsWith("OPENCODE_PASSWORD="))?.slice(18)
43
+ : undefined)
44
+ if (!password) throw new Error()
39
45
  const candidates = port === 0 ? await urls() : [`http://127.0.0.1:${port}`]
40
46
  for (const url of candidates) {
41
47
  const endpoint = { url, auth: { type: "basic", username: "opencode", password } }
package/src/plugin.js CHANGED
@@ -48,7 +48,8 @@ export default async function BackeerPlugin({ client, directory, feedback, v2 =
48
48
  const reporter = createOpenCodeReporter(client, config.logLevel)
49
49
  const conversation = feedback ?? createConversationFeedback({ client, reporter })
50
50
  if (config.liveBridge.error) reporter.error("Live control disabled", config.liveBridge.error)
51
- const bridgeConfig = config.liveBridge.owner === "tui"
51
+ const bridgeConfig = config.liveBridge.owner === "tui" ||
52
+ (config.liveBridge.owner === "standalone" && (!v2 || !process.argv.includes("--stdio")))
52
53
  ? { ...config, liveBridge: { enabled: false } } : config
53
54
  const liveBridge = await createLiveBridge({ config: bridgeConfig, client, directory, reporter }).catch((error) => {
54
55
  reporter.error("Live control unavailable", error)
package/src/updates.js CHANGED
@@ -1,4 +1,4 @@
1
- export const PLUGIN_VERSION = "0.8.1"
1
+ export const PLUGIN_VERSION = "0.8.2"
2
2
 
3
3
  const PACKAGE_NAME = "backeer-opencode"
4
4
  const NPM_REGISTRY_URL = "https://registry.npmjs.org"