symbio 0.6.0 → 0.7.0

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
@@ -5,6 +5,17 @@ versions follow [Semantic Versioning](https://semver.org/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.7.0]
9
+
10
+ - Updates: `sym update` installs the latest version and restarts Symbio once the team is idle
11
+ (`--now` doesn't wait). Symbio also checks for new versions and, by default, updates itself while
12
+ no agent is working and remote access is off: it backs up first, keeps (or restores) the old
13
+ version if the install fails and doesn't retry it for a day, and tells you in Messages. Settings →
14
+ Updates: automatic, tell me, or off, and what's new; a badge in the top bar when a version is
15
+ available.
16
+ - Docs brought up to date; the roadmap is now a priority list with collected ideas; `sym doctor`
17
+ prints Symbio's version.
18
+
8
19
  ## [0.6.0]
9
20
 
10
21
  - Azure DevOps connections: add an organization with a personal access token in Settings; agents
package/README.md CHANGED
@@ -82,6 +82,11 @@ place. Here Symbio was restarted mid-task, and it told the helper to pick up whe
82
82
  see. Each connection can require your approval for every action.
83
83
  - **Resilient.** A supervisor restarts OpenCode if it crashes, a watchdog unsticks hung agents, and
84
84
  when a model fails (quota, outage) the agent switches to the next allowed model and carries on.
85
+ - **Long-running teams stay healthy.** Agents can continue in a fresh session with a handoff note
86
+ (when you ask, after each finished task, or automatically when a conversation grows too large to
87
+ send). A daily check backs up Symbio's state and tells you and core about work that exists only
88
+ on this machine and build outputs filling the disk.
89
+ - **Costs in view.** Tokens and estimated cost per agent and model, with prices you can set.
85
90
  - **Safety rails.** Agents can't kill processes by name (which would take down your terminals and
86
91
  editors), can't read your SSH keys or Symbio's secret store, and must ask before installing
87
92
  software. They look at images through scaled-down copies, so a few large pictures can't make a
@@ -123,6 +128,12 @@ on your computer, including the agents, from using Symbio's API behind your back
123
128
  | `sym doctor` | Check Node.js, git, OpenCode, the port and model providers. |
124
129
  | `sym run` | Run in the foreground, with the log in the terminal. |
125
130
  | `sym remote` | Open a temporary link and access code to use Symbio from another device (see below). `sym remote off` closes it. |
131
+ | `sym update` | Install the latest version and restart Symbio, once no agent is working (`--now` doesn't wait). |
132
+
133
+ **Updates.** Symbio checks for new versions and, by default, installs them by itself while the team
134
+ is idle: never while an agent is working or waiting for you, or while remote access is on. It backs
135
+ up first, restarts in a minute or two, and tells you in Messages. In Settings → Updates you can
136
+ choose to be only told instead, or turn checking off, and see what's new.
126
137
 
127
138
  **From another device.** `sym remote` opens a temporary Cloudflare link to your Symbio and prints it
128
139
  with an access code. Open the link anywhere (phone, another computer), enter the code, and you're
@@ -171,8 +182,13 @@ itself. Whoever gets in controls your agents, so keep the link and code to yours
171
182
  | `~/.symbio/secrets.enc` | Connection secrets, encrypted with a key kept in your OS credential store |
172
183
  | `~/.symbio/worktrees/` | Helpers' working copies |
173
184
  | `~/.symbio/backups/` | Daily backups of Symbio's state (the last 7 days) |
185
+ | `~/.symbio/approvals/` | Remote actions waiting for (or decided by) your approval |
186
+ | `~/.symbio/prices.json` | Model prices used for cost estimates |
187
+ | `~/.symbio/ui-token` | The key that unlocks the UI (it's in the link Symbio opens) |
188
+ | `~/.symbio/logs/symbio.log` | Symbio's log when started in the background (`sym logs`); `update.log` next to it for updates |
189
+ | `~/.symbio/updates.json` | Your update setting (automatic, tell me, off) |
174
190
  | `<project>/.symbio/` | Project state: agents, plan, message log, shared assets (git-ignored automatically) |
175
- | `<project>/AGENTS.md`, `<project>/docs/` | Project documentation for agents (unless you chose *private*) |
191
+ | `<project>/AGENTS.md`, `<project>/docs/` | Project documentation for agents (unless you chose *private*), and `docs/rules.json` if the rules register is on |
176
192
  | OpenCode's database | The conversations themselves. Every agent is an OpenCode session, so you can also open one with `opencode -s <session-id>`. |
177
193
 
178
194
  Symbio never deletes your project files. Removing a project from Symbio only forgets it, and can
@@ -193,13 +209,17 @@ All optional, set as environment variables:
193
209
  | `SYMBIO_STUCK_MINUTES` | `10` | Silence before the watchdog interrupts a working agent |
194
210
  | `SYMBIO_RECYCLE_MB` | `1500` | Restart OpenCode above this memory use once every agent is idle |
195
211
  | `SYMBIO_RECYCLE_HARD_MB` | `3000` | Restart OpenCode above this memory use regardless (agents resume) |
212
+ | `SYMBIO_MEMORY_CHECK_SECONDS` | `60` | How often OpenCode's memory use is checked |
213
+ | `SYMBIO_BACKUP_DAYS` | `7` | How many daily backups of Symbio's state to keep |
214
+ | `SYMBIO_BUILD_ALERT_GB` | `2` | Build outputs in a helper's folder above this size are reported by the daily check |
196
215
 
197
216
  ## Status
198
217
 
199
- Symbio is young (version 0.1) and changing quickly. The core workflow works end to end: projects,
200
- core and helpers, messaging, plan and kanban, shared assets, connections, providers, and recovery.
201
- Planned next: npm packaging with first-run checks, an agent graph, and screenshots for agents so
202
- they can see what they build. See [docs/roadmap.md](docs/roadmap.md).
218
+ Symbio is young and changing quickly, shaped by people using it for real work. The core workflow
219
+ works end to end: projects, core and helpers, messaging, plan and kanban, shared assets, docs and
220
+ the rules register, connections (GitHub, Azure DevOps, servers), providers, costs and recovery.
221
+ What's planned and what's open for help is in [docs/roadmap.md](docs/roadmap.md); what changed in
222
+ each version is in [CHANGELOG.md](CHANGELOG.md).
203
223
 
204
224
  ## Documentation
205
225
 
package/bin/symbio.js CHANGED
@@ -9,9 +9,10 @@
9
9
  // sym doctor check that everything Symbio needs is in place
10
10
  // sym run run in the foreground (for debugging)
11
11
  // sym remote open a temporary link to use Symbio from another device (sym remote off to close it)
12
+ // sym update install the latest version (waits for an idle team; --now doesn't)
12
13
 
13
- import { execFileSync, exec, spawn } from "node:child_process";
14
- import { existsSync, mkdirSync, openSync, readFileSync } from "node:fs";
14
+ import { execFileSync, exec, spawn, spawnSync } from "node:child_process";
15
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, writeFileSync } from "node:fs";
15
16
  import os from "node:os";
16
17
  import path from "node:path";
17
18
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -33,8 +34,13 @@ const token = () => {
33
34
  };
34
35
  const link = () => `${BASE}/?token=${token()}`;
35
36
 
36
- async function api(method, p) {
37
- const res = await fetch(BASE + p, { method, headers: { "x-symbio-token": token() }, signal: AbortSignal.timeout(3000) });
37
+ async function api(method, p, body) {
38
+ const res = await fetch(BASE + p, {
39
+ method,
40
+ headers: { "x-symbio-token": token(), ...(body === undefined ? {} : { "content-type": "application/json" }) },
41
+ body: body === undefined ? undefined : JSON.stringify(body),
42
+ signal: AbortSignal.timeout(body === undefined ? 3000 : 30_000),
43
+ });
38
44
  return { status: res.status, body: await res.json().catch(() => null) };
39
45
  }
40
46
 
@@ -164,6 +170,7 @@ async function doctor() {
164
170
  console.log(`${pass ? "✔" : "✘"} ${label}${!pass && fix ? `\n → ${fix}` : ""}`);
165
171
  if (!pass) ok = false;
166
172
  };
173
+ console.log(`Symbio ${pkg.version}`);
167
174
  const [major] = process.versions.node.split(".").map(Number);
168
175
  check(major >= 22, `Node.js ${process.versions.node}`, "Symbio needs Node.js 22 or newer: https://nodejs.org");
169
176
  let gitVersion = "";
@@ -189,6 +196,82 @@ async function doctor() {
189
196
  if (!ok) process.exitCode = 1;
190
197
  }
191
198
 
199
+ /**
200
+ * Installs the latest version. Running: Symbio hands over to its updater and restarts (waiting
201
+ * for an idle team unless --now). Stopped: the updater runs here, and Symbio stays stopped.
202
+ */
203
+ async function update() {
204
+ const now = process.argv.includes("--now");
205
+ const self = await import(pathToFileURL(path.join(root, "dist", "server", "self-update.js")).href);
206
+ const install = self.installInfo(root);
207
+ if (install.kind === "dev") {
208
+ console.log(`This Symbio runs from a development folder (${install.root}). Update it there: git pull, npm install, npm run build.`);
209
+ return;
210
+ }
211
+ let latest;
212
+ try {
213
+ latest = await self.latestVersion();
214
+ } catch (err) {
215
+ console.error(`Couldn't check for updates: ${err.message}`);
216
+ process.exit(1);
217
+ }
218
+ if (self.compareVersions(latest, pkg.version) <= 0) return console.log(`Symbio ${pkg.version} is up to date.`);
219
+ console.log(`Symbio ${latest} is available (you have ${pkg.version}).`);
220
+ const resultFile = path.join(HOME, "update-result.json");
221
+ const before = existsSync(resultFile) ? readFileSync(resultFile, "utf8") : "";
222
+
223
+ if ((await state()) === "running") {
224
+ const st = (await api("GET", "/api/update")).body;
225
+ if (st?.busy && !now) {
226
+ console.log(`Not now: ${st.busy}.\nRun "sym update" again when the team is idle, or "sym update --now" (agents that are working are interrupted and continue after the restart).`);
227
+ process.exitCode = 1;
228
+ return;
229
+ }
230
+ const res = await api("POST", "/api/update/apply", { force: true });
231
+ if (res.status !== 200) {
232
+ console.error(res.body?.error ?? `Symbio answered ${res.status}`);
233
+ process.exit(1);
234
+ }
235
+ process.stdout.write("Updating: Symbio stops, installs the new version and starts again");
236
+ for (let i = 0; i < 300; i++) {
237
+ await sleep(1000);
238
+ const after = existsSync(resultFile) ? readFileSync(resultFile, "utf8") : "";
239
+ if (after && after !== before && (await state()) === "running") break;
240
+ if (i % 3 === 0) process.stdout.write(".");
241
+ }
242
+ console.log("");
243
+ } else {
244
+ // Not running: install right here, from a copy of the updater (npm replaces this folder).
245
+ const dir = mkdtempSync(path.join(os.tmpdir(), "symbio-update-"));
246
+ const runner = path.join(dir, "update-runner.mjs");
247
+ copyFileSync(path.join(root, "bin", "update-runner.mjs"), runner);
248
+ const job = path.join(dir, "job.json");
249
+ writeFileSync(
250
+ job,
251
+ JSON.stringify({
252
+ prefix: install.prefix,
253
+ spec: process.env.SYMBIO_UPDATE_SPEC ?? `symbio@${latest}`,
254
+ from: pkg.version,
255
+ to: latest,
256
+ root: install.root,
257
+ home: HOME,
258
+ port: PORT,
259
+ start: false,
260
+ resultFile,
261
+ logFile: path.join(HOME, "logs", "update.log"),
262
+ }),
263
+ );
264
+ console.log("Installing…");
265
+ spawnSync(process.execPath, [runner, job], { stdio: "inherit", windowsHide: true });
266
+ }
267
+ const result = existsSync(resultFile) ? JSON.parse(readFileSync(resultFile, "utf8")) : null;
268
+ if (result?.ok && result.to === latest) console.log(`Updated to ${latest}.${(await state()) === "running" ? ` Symbio is running: ${link()}` : ' Start it with "sym".'}`);
269
+ else {
270
+ console.error(`The update didn't complete${result?.error ? `: ${result.error}` : "."} Details: ${path.join(HOME, "logs", "update.log")}`);
271
+ process.exitCode = 1;
272
+ }
273
+ }
274
+
192
275
  function help() {
193
276
  console.log(`Symbio ${pkg.version} — run a team of AI agents on your project, from your browser.
194
277
 
@@ -203,6 +286,7 @@ Usage: sym [command]
203
286
  run run in the foreground (logs in this terminal)
204
287
  remote open a temporary link + access code to use Symbio from another device
205
288
  remote off close that link
289
+ update install the latest version (when the team is idle; --now to not wait)
206
290
  --version print the version
207
291
 
208
292
  Environment: SYMBIO_PORT (default 4310), SYMBIO_HOME (default ~/.symbio), SYMBIO_NO_OPEN=1.`);
@@ -230,6 +314,9 @@ switch (cmd) {
230
314
  if (process.argv[3] === "off") await remoteOff();
231
315
  else await remoteOn();
232
316
  break;
317
+ case "update":
318
+ await update();
319
+ break;
233
320
  case "logs":
234
321
  logs();
235
322
  break;
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ // Installs a Symbio update while Symbio is stopped, then starts it again. Runs as its own process,
3
+ // from a copy outside the package folder (which npm replaces): Symbio hands over and exits, or
4
+ // `sym update` runs it when Symbio isn't running. It can't install in place: on Windows, OpenCode
5
+ // and the native modules keep their files locked while they run.
6
+ //
7
+ // Usage: node update-runner.mjs <job.json>
8
+ // job: { waitPid?, prefix, spec, from, to, root, home, port, start, resultFile, logFile }
9
+
10
+ import { spawn, spawnSync } from "node:child_process";
11
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import path from "node:path";
13
+
14
+ const job = JSON.parse(readFileSync(process.argv[2], "utf8"));
15
+ mkdirSync(path.dirname(job.logFile), { recursive: true });
16
+ const log = (line) => appendFileSync(job.logFile, `${new Date().toISOString()} ${line}\n`);
17
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
18
+ const alive = (pid) => {
19
+ try {
20
+ process.kill(pid, 0); // signal 0: only checks the process exists
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ };
26
+
27
+ /** npm install -g <spec> into the prefix Symbio was installed in. */
28
+ function install(spec) {
29
+ log(`npm install -g ${spec} (prefix ${job.prefix})`);
30
+ const res = spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", ["install", "-g", spec, "--no-fund", "--no-audit"], {
31
+ encoding: "utf8",
32
+ shell: process.platform === "win32",
33
+ windowsHide: true,
34
+ timeout: 10 * 60_000,
35
+ env: { ...process.env, npm_config_prefix: job.prefix },
36
+ });
37
+ const output = `${res.stdout ?? ""}${res.stderr ?? ""}`.trim();
38
+ if (output) log(output.split("\n").slice(-30).join("\n"));
39
+ return { ok: res.status === 0, output: res.error ? res.error.message : output };
40
+ }
41
+
42
+ /** The installed package is the old version and can start. */
43
+ function intact() {
44
+ try {
45
+ const pkg = JSON.parse(readFileSync(path.join(job.root, "package.json"), "utf8"));
46
+ return pkg.version === job.from && existsSync(path.join(job.root, "bin", "symbio.js")) && existsSync(path.join(job.root, "dist", "server", "index.js"));
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ const result = { from: job.from, to: job.to, ok: false, error: null, finishedAt: 0, seen: false };
53
+ try {
54
+ log(`Updating Symbio ${job.from} → ${job.to}`);
55
+ if (job.waitPid) {
56
+ for (let i = 0; i < 180 && alive(job.waitPid); i++) await sleep(500);
57
+ if (alive(job.waitPid)) throw new Error("Symbio didn't stop within 90 seconds; nothing was changed.");
58
+ }
59
+ const done = install(job.spec);
60
+ if (done.ok) {
61
+ result.ok = true;
62
+ log("Installed.");
63
+ } else {
64
+ result.error = `npm couldn't install ${job.to}: ${done.output.split("\n").slice(-6).join(" ").slice(0, 600)}`;
65
+ log(result.error);
66
+ // npm often fails before touching anything (no network, a lock); only if the old version
67
+ // was damaged, put it back so Symbio still starts.
68
+ if (intact()) log(`${job.from} is still in place.`);
69
+ else {
70
+ const back = install(`symbio@${job.from}`);
71
+ log(back.ok ? `Reinstalled ${job.from}.` : `Couldn't reinstall ${job.from} either; run: npm install -g symbio`);
72
+ if (!back.ok) result.error += ` Reinstalling ${job.from} failed too; run "npm install -g symbio".`;
73
+ }
74
+ }
75
+ } catch (err) {
76
+ result.error = err.message;
77
+ log(`Failed: ${err.message}`);
78
+ }
79
+ result.finishedAt = Date.now();
80
+ writeFileSync(job.resultFile, JSON.stringify(result, null, 2));
81
+
82
+ if (job.start) {
83
+ log("Starting Symbio.");
84
+ // `sym start` from the (possibly new) package, in the background like a normal start.
85
+ const child = spawn(process.execPath, [path.join(job.root, "bin", "symbio.js"), "start"], {
86
+ detached: true,
87
+ stdio: "ignore",
88
+ windowsHide: true,
89
+ env: { ...process.env, SYMBIO_NO_OPEN: "1", SYMBIO_HOME: job.home, SYMBIO_PORT: String(job.port) },
90
+ });
91
+ child.unref();
92
+ }
@@ -14,10 +14,18 @@ export const backupsDir = () => path.join(globalDir, "backups");
14
14
  * Returns the folder, or null when today's backup already exists.
15
15
  */
16
16
  export async function dailyBackup(projects, now = new Date()) {
17
- const day = now.toISOString().slice(0, 10);
18
- const dest = path.join(backupsDir(), day);
17
+ const dest = path.join(backupsDir(), now.toISOString().slice(0, 10));
19
18
  if (existsSync(dest))
20
19
  return null;
20
+ return writeBackup(projects, dest);
21
+ }
22
+ /** A backup on demand, e.g. right before an update ("2026-11-06-before-0.7.0"); pruned like the daily ones. */
23
+ export async function labeledBackup(projects, label, now = new Date()) {
24
+ const dest = path.join(backupsDir(), `${now.toISOString().slice(0, 10)}-${label.replace(/[^\w.-]+/g, "-")}`);
25
+ await fs.rm(dest, { recursive: true, force: true });
26
+ return writeBackup(projects, dest);
27
+ }
28
+ async function writeBackup(projects, dest) {
21
29
  const tmp = `${dest}.partial`;
22
30
  await fs.rm(tmp, { recursive: true, force: true });
23
31
  for (const p of projects) {
@@ -32,9 +40,10 @@ export async function dailyBackup(projects, now = new Date()) {
32
40
  }
33
41
  await fs.mkdir(tmp, { recursive: true });
34
42
  await fs.rename(tmp, dest);
35
- // Keep the newest days only.
36
- const days = (await fs.readdir(backupsDir())).filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort();
37
- for (const old of days.slice(0, Math.max(0, days.length - BACKUP_KEEP))) {
43
+ // Keep the last BACKUP_KEEP days that have backups (daily and labeled alike).
44
+ const entries = (await fs.readdir(backupsDir())).filter((d) => /^\d{4}-\d{2}-\d{2}(-[\w.-]+)?$/.test(d));
45
+ const keepDays = [...new Set(entries.map((d) => d.slice(0, 10)))].sort().slice(-BACKUP_KEEP);
46
+ for (const old of entries.filter((d) => !keepDays.includes(d.slice(0, 10)))) {
38
47
  await fs.rm(path.join(backupsDir(), old), { recursive: true, force: true }).catch(() => { });
39
48
  }
40
49
  return dest;
@@ -1,6 +1,6 @@
1
1
  import { serve } from "@hono/node-server";
2
2
  import { Hono } from "hono";
3
- import { promises as fs, existsSync } from "node:fs";
3
+ import { promises as fs, existsSync, readFileSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { exec } from "node:child_process";
@@ -16,6 +16,8 @@ import { OpencodeRuntime } from "./opencode/runtime.js";
16
16
  import { Store, globalDir } from "./store.js";
17
17
  import { Team } from "./team.js";
18
18
  import { removeOrphanWorkspaces } from "./workspaces.js";
19
+ import { packageRoot, QUIET_MS, SelfUpdate } from "./self-update.js";
20
+ import { labeledBackup } from "./housekeeping.js";
19
21
  // Background work (timers, event handlers) must never take the whole app down: an error there
20
22
  // (e.g. OpenCode restarting mid-call) is logged and the next tick tries again.
21
23
  process.on("unhandledRejection", (err) => console.error("[unhandled]", err instanceof Error ? err.stack ?? err.message : err));
@@ -72,6 +74,23 @@ async function main() {
72
74
  team.markInterrupted("Symbio was stopped and has been restarted");
73
75
  const uiToken = loadUiToken(globalDir);
74
76
  const remote = new RemoteAccess(path.join(globalDir, "bin"));
77
+ // Updates: checked now and then; applied when allowed and the team is idle (see self-update.ts).
78
+ const version = JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8")).version;
79
+ const updates = new SelfUpdate({
80
+ version,
81
+ home: globalDir,
82
+ port: PORT,
83
+ busy: () => team.busyReason(QUIET_MS),
84
+ remoteActive: () => remote.active,
85
+ backup: async () => {
86
+ await labeledBackup(store.list(), `before-${updates.state().latest ?? "update"}`);
87
+ },
88
+ announce: (text) => team.announce(text),
89
+ shutdown: async () => {
90
+ await shutdown?.();
91
+ },
92
+ });
93
+ await updates.load();
75
94
  remote.onChange = () => bus.publish({ type: "remote.updated" });
76
95
  const remoteRequests = new WeakSet(); // requests that came through the tunnel
77
96
  const app = new Hono();
@@ -123,6 +142,25 @@ async function main() {
123
142
  setTimeout(() => void shutdown?.(), 200);
124
143
  return c.json({ ok: true });
125
144
  });
145
+ // Updates: state and settings for the UI; applying stops Symbio (never through remote access).
146
+ app.get("/api/update", (c) => c.json(updates.state()));
147
+ app.post("/api/update/check", async (c) => c.json(await updates.check()));
148
+ app.put("/api/update/settings", async (c) => {
149
+ const { mode } = await c.req.json();
150
+ if (mode !== "auto" && mode !== "notify" && mode !== "off")
151
+ return c.json({ error: "mode must be auto, notify or off" }, 400);
152
+ await updates.setMode(mode);
153
+ return c.json(updates.state());
154
+ });
155
+ app.post("/api/update/apply", async (c) => {
156
+ const { force } = await c.req.json().catch(() => ({ force: false }));
157
+ try {
158
+ return c.json({ message: await updates.apply(force !== false) });
159
+ }
160
+ catch (err) {
161
+ return c.json({ error: err.message }, 400);
162
+ }
163
+ });
126
164
  // Remote access: status for everyone (the code only locally), start locally, stop from anywhere.
127
165
  app.get("/api/remote", (c) => c.json(remote.status(!remoteRequests.has(c.req.raw))));
128
166
  app.post("/api/remote/start", async (c) => {
@@ -164,10 +202,12 @@ async function main() {
164
202
  .then(() => console.log(`OpenCode ${runtime.version} ready`))
165
203
  .catch((err) => console.error("OpenCode failed to start:", err.message));
166
204
  team.startWatchdog();
205
+ updates.start();
167
206
  shutdown = async () => {
168
207
  remote.stop("Symbio stopped");
169
208
  team.shuttingDown = true;
170
209
  team.stopWatchdog();
210
+ updates.stop();
171
211
  await runtime.stop();
172
212
  process.exit(0);
173
213
  };
@@ -37,7 +37,7 @@ export function isTunnelRequest(headers, tunnelHost) {
37
37
  }
38
38
  // Never through the tunnel: the agents' tools, debugging, shutting Symbio down, starting a
39
39
  // tunnel, and the native folder dialog (it would open on this computer's screen).
40
- const NEVER_REMOTE = [/^\/mcp(\/|$)/, /^\/api\/debug(\/|$)/, /^\/api\/shutdown$/, /^\/api\/remote\/start$/, /^\/api\/pick-folder$/];
40
+ const NEVER_REMOTE = [/^\/mcp(\/|$)/, /^\/api\/debug(\/|$)/, /^\/api\/shutdown$/, /^\/api\/remote\/start$/, /^\/api\/pick-folder$/, /^\/api\/update\/apply$/];
41
41
  /** What to do with a request that came through the tunnel. */
42
42
  export function remoteDecision(req) {
43
43
  if (!req.active)