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 +11 -0
- package/README.md +25 -5
- package/bin/symbio.js +91 -4
- package/bin/update-runner.mjs +92 -0
- package/dist/server/housekeeping.js +14 -5
- package/dist/server/index.js +41 -1
- package/dist/server/remote-access.js +1 -1
- package/dist/server/self-update.js +254 -0
- package/dist/server/team.js +27 -0
- package/dist/web/assets/{index-CtcmbMmw.css → index-BmRTqm-A.css} +1 -1
- package/dist/web/assets/index-Cila9fjW.js +53 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-CmYLMQvL.js +0 -53
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
|
|
200
|
-
core and helpers, messaging, plan and kanban, shared assets,
|
|
201
|
-
|
|
202
|
-
|
|
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, {
|
|
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
|
|
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
|
|
36
|
-
const
|
|
37
|
-
|
|
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;
|
package/dist/server/index.js
CHANGED
|
@@ -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)
|