symbio 0.5.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,29 @@ 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
+
19
+ ## [0.6.0]
20
+
21
+ - Azure DevOps connections: add an organization with a personal access token in Settings; agents
22
+ push and pull through it (HTTPS remotes of that organization) and call its REST API with the new
23
+ `azure_devops_api` tool, e.g. to open pull requests. The token never reaches the agent and only
24
+ goes to the connection's own organization.
25
+ - Credentials rule clarified: Symbio's own secrets stay off-limits to agents, but a project's own
26
+ credentials (connection strings, `.env`…) and ones the human gives may be used for the task;
27
+ agents must not copy them into anything committed or shared, and only read from systems the
28
+ human calls read-only or production. Agents had refused legitimate work (querying a database
29
+ with the app's connection string) under the old blanket rule.
30
+
8
31
  ## [0.5.0]
9
32
 
10
33
  - Docs tab: a folder tree instead of a flat list; search across all docs (any case or accent) that
package/README.md CHANGED
@@ -77,11 +77,16 @@ place. Here Symbio was restarted mid-task, and it told the helper to pick up whe
77
77
  - **You stay in control.** Questions, plan approvals, permission requests (like installing
78
78
  software) and deploy approvals collect in a **Needs you** inbox. Pause one team or everything at
79
79
  once.
80
- - **Connections without exposing secrets.** Give agents a GitHub account or an SSH server. They
81
- ask Symbio to push, call the API or deploy, and Symbio does it with credentials the agents never
80
+ - **Connections without exposing secrets.** Give agents a GitHub account, an Azure DevOps
81
+ organization or an SSH server. They ask Symbio to push, open pull requests, call the API or deploy, and Symbio does it with credentials the agents never
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
@@ -142,7 +153,7 @@ itself. Whoever gets in controls your agents, so keep the link and code to yours
142
153
  - **Folder:** browse for or paste a folder. An empty folder starts a new project; an existing
143
154
  one is studied first. Symbio offers to set up git if the folder doesn't have it.
144
155
  - **Setup:** choose where the project documentation goes (in the project, or private), and which
145
- connections (GitHub, servers) the agents may use.
156
+ connections (GitHub, Azure DevOps, servers) the agents may use.
146
157
  - **Models:** pick the models agents may use, and the one core runs on (the crown).
147
158
  3. **Talk to core.** Describe what you want. core asks questions, proposes a plan, and waits for
148
159
  your approval in the **Plan** tab (it shows a *Review* badge).
@@ -159,7 +170,7 @@ itself. Whoever gets in controls your agents, so keep the link and code to yours
159
170
  | **core** | The project's lead agent. It plans, delegates, reviews and merges; it coordinates rather than doing the work itself. |
160
171
  | **Helper** | An agent core creates for a piece of work. It works in its own git worktree (branch `symbio/<name>`) or, if the project is set to, in the project folder itself. |
161
172
  | **Shared asset** | Something more than one agent could change. Access goes through core: request, grant, release. |
162
- | **Connection** | A GitHub account or SSH server agents can use through Symbio, without ever seeing its credentials. |
173
+ | **Connection** | A GitHub account, Azure DevOps organization or SSH server agents can use through Symbio, without ever seeing its credentials. |
163
174
  | **Allowed models** | The models a project's agents may run on. If one fails, agents fall back to the next. |
164
175
 
165
176
  ## Where your data lives
@@ -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
+ }
@@ -1,14 +1,22 @@
1
- // Global connections (set once in Settings, used by any project): GitHub accounts
2
- // and servers reachable over SSH. Public details live in ~/.symbio/connections.json;
3
- // secrets live in the encrypted vault and are never returned by the API.
1
+ // Global connections (set once in Settings, used by any project): GitHub accounts,
2
+ // Azure DevOps organizations and servers reachable over SSH. Public details live in
3
+ // ~/.symbio/connections.json; secrets live in the encrypted vault and are never
4
+ // returned by the API.
4
5
  import { promises as fs, readFileSync } from "node:fs";
5
6
  import os from "node:os";
6
7
  import path from "node:path";
7
8
  import { SecretVault } from "./secrets.js";
8
9
  import { sshExec } from "./ssh.js";
9
10
  import { globalDir, newId } from "./store.js";
11
+ /** Organization from what people paste: "org", https://dev.azure.com/org/…, https://org.visualstudio.com/…. */
12
+ export function azureOrganization(value) {
13
+ const v = value.trim();
14
+ const m = /^https?:\/\/(?:[^@/]+@)?dev\.azure\.com\/([^/?#]+)/i.exec(v) ?? /^https?:\/\/(?:[^@/]+@)?([^./]+)\.visualstudio\.com/i.exec(v);
15
+ return decodeURIComponent(m ? m[1] : v.replace(/\/+$/, ""));
16
+ }
10
17
  const SECRET_FIELDS = {
11
18
  github: ["token"],
19
+ azure: ["token"],
12
20
  server: ["privateKey", "passphrase", "password"],
13
21
  };
14
22
  export class Connections {
@@ -37,13 +45,15 @@ export class Connections {
37
45
  return { ...c, secrets };
38
46
  }
39
47
  async create(input) {
40
- if (input.type !== "github" && input.type !== "server")
41
- throw new Error("type must be github or server");
48
+ if (input.type !== "github" && input.type !== "azure" && input.type !== "server")
49
+ throw new Error("type must be github, azure or server");
42
50
  const now = Date.now();
43
51
  const base = { id: newId("con"), name: "", createdAt: now, updatedAt: now, lastTest: null };
44
52
  const conn = input.type === "github"
45
53
  ? { ...base, type: "github", account: null }
46
- : { ...base, type: "server", host: "", port: 22, username: "", auth: "key", keyPath: null };
54
+ : input.type === "azure"
55
+ ? { ...base, type: "azure", organization: "", account: null }
56
+ : { ...base, type: "server", host: "", port: 22, username: "", auth: "key", keyPath: null };
47
57
  this.apply(conn, input);
48
58
  if (!conn.name)
49
59
  throw new Error("Give the connection a name.");
@@ -91,6 +101,14 @@ export class Connections {
91
101
  conn.account = user.login;
92
102
  result = { ok: true, message: `Signed in as ${user.login}${user.name ? ` (${user.name})` : ""}`, time: Date.now() };
93
103
  }
104
+ else if (conn.type === "azure") {
105
+ const data = await this.azureRequest(conn, "GET", "_apis/connectionData", undefined, "7.1-preview");
106
+ const user = data?.authenticatedUser;
107
+ conn.account = user?.providerDisplayName ?? user?.customDisplayName ?? null;
108
+ const projects = await this.azureRequest(conn, "GET", "_apis/projects").catch(() => null);
109
+ const count = Array.isArray(projects?.value) ? ` — ${projects.value.length} project(s) visible` : "";
110
+ result = { ok: true, message: `Signed in to ${conn.organization}${conn.account ? ` as ${conn.account}` : ""}${count}`, time: Date.now() };
111
+ }
94
112
  else {
95
113
  const res = await sshExec(await this.sshTarget(conn), "uname -srm 2>/dev/null || ver", 30_000);
96
114
  const out = (res.stdout || res.stderr).trim().split("\n")[0];
@@ -129,6 +147,40 @@ export class Connections {
129
147
  }
130
148
  return target;
131
149
  }
150
+ /** Calls the Azure DevOps REST API of the connection's organization with its token. */
151
+ async azureRequest(conn, method, apiPath, body, apiVersion = "7.1") {
152
+ const rel = apiPath.trim().replace(/^\/+/, "");
153
+ if (/^[a-z]+:/i.test(rel))
154
+ throw new Error("Give a path inside the organization (e.g. MyProject/_apis/git/repositories), not a full URL.");
155
+ const url = new URL(`https://dev.azure.com/${encodeURIComponent(conn.organization)}/${rel}`);
156
+ if (!url.searchParams.has("api-version"))
157
+ url.searchParams.set("api-version", apiVersion);
158
+ const token = await this.githubToken(conn);
159
+ const res = await fetch(url, {
160
+ method,
161
+ redirect: "manual", // a sign-in redirect means the token was refused
162
+ headers: {
163
+ authorization: `Basic ${Buffer.from(`:${token}`).toString("base64")}`,
164
+ accept: "application/json",
165
+ "user-agent": "symbio",
166
+ ...(body === undefined ? {} : { "content-type": "application/json" }),
167
+ },
168
+ body: body === undefined ? undefined : JSON.stringify(body),
169
+ });
170
+ const text = await res.text();
171
+ // A rejected token gets a sign-in page (203 or a redirect) rather than a 401.
172
+ const html = /^\s*</.test(text) || (res.headers.get("content-type") ?? "").includes("text/html");
173
+ if (res.status === 401 || res.status === 203 || (res.status >= 300 && res.status < 400) || (html && res.ok)) {
174
+ throw new Error(`Azure DevOps rejected the token for "${conn.organization}" (expired, revoked, or without access to this organization?)`);
175
+ }
176
+ const data = text ? safeJson(text) : null;
177
+ if (!res.ok) {
178
+ const msg = data?.message ?? res.statusText;
179
+ throw new Error(`Azure DevOps ${res.status}: ${msg}`);
180
+ }
181
+ return data;
182
+ }
183
+ /** The stored token of a GitHub or Azure DevOps connection. */
132
184
  async githubToken(conn) {
133
185
  const token = await this.vault.get(`${conn.id}:token`);
134
186
  if (!token)
@@ -159,6 +211,8 @@ export class Connections {
159
211
  apply(conn, input) {
160
212
  if (input.name !== undefined)
161
213
  conn.name = input.name.trim();
214
+ if (conn.type === "azure" && input.organization !== undefined)
215
+ conn.organization = azureOrganization(input.organization);
162
216
  if (conn.type === "server") {
163
217
  if (input.host !== undefined)
164
218
  conn.host = input.host.trim();
@@ -173,6 +227,9 @@ export class Connections {
173
227
  }
174
228
  }
175
229
  validate(conn) {
230
+ if (conn.type === "azure" && !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/.test(conn.organization)) {
231
+ throw new Error("Give the organization, e.g. \"contoso\" or https://dev.azure.com/contoso.");
232
+ }
176
233
  if (conn.type === "server") {
177
234
  if (!conn.host)
178
235
  throw new Error("Host is required.");
@@ -189,7 +246,7 @@ export class Connections {
189
246
  if (typeof value === "string" && value.trim())
190
247
  await this.vault.set(`${conn.id}:${field}`, value.trim());
191
248
  }
192
- if (conn.type === "github" && !(await this.vault.has(`${conn.id}:token`)))
249
+ if ((conn.type === "github" || conn.type === "azure") && !(await this.vault.has(`${conn.id}:token`)))
193
250
  throw new Error("The token is required.");
194
251
  if (conn.type === "server") {
195
252
  if (conn.auth === "password" && !(await this.vault.has(`${conn.id}:password`)))
@@ -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
  };
@@ -337,15 +337,33 @@ export function kickoffMessage(project) {
337
337
  : "Symbio: the project was just created on a folder that already has content (see the folder summary in your instructions). Greet the human briefly, mention in one or two sentences what you can see at a glance, and offer to study the folder before planning.";
338
338
  }
339
339
  function connectionsSection(connections) {
340
- return `## Connections (GitHub, servers)
340
+ return `## Connections (GitHub, Azure DevOps, servers)
341
341
 
342
342
  ${connections || "None."}
343
343
 
344
- Use connections only through Symbio's tools: \`list_connections\`, \`git_push\`,
345
- \`git_pull\`, \`github_api\` (GitHub) and \`remote_exec\`, \`remote_upload\` (servers).
346
- Symbio performs the action with the stored credentials — you never see them.
347
- Never ask for credentials, never write secrets into files or commands, and never
348
- look for them on this machine. Remote commands must be non-interactive. Always
349
- give a clear \`reason\`: when a connection needs approval, the human decides based
350
- on it, and the result arrives later as a message.`;
344
+ Use connections only through Symbio's tools: \`list_connections\`; \`git_push\`,
345
+ \`git_pull\` (GitHub or Azure DevOps); \`github_api\` (GitHub); \`azure_devops_api\`
346
+ (Azure DevOps: pull requests, repositories, branches); \`remote_exec\`,
347
+ \`remote_upload\` (servers). Symbio performs the action with the stored
348
+ credentials — you never see them. To open a pull request: push the branch with
349
+ \`git_push\`, then create it with \`github_api\` or \`azure_devops_api\`.
350
+ Remote commands must be non-interactive. Always give a clear \`reason\`: when a
351
+ connection needs approval, the human decides based on it, and the result
352
+ arrives later as a message.
353
+
354
+ ## Credentials
355
+
356
+ - **Symbio's secrets are off-limits:** its vault and connections, SSH keys, its
357
+ UI key. Use connections only through the tools above; never try to read them.
358
+ - **The project's own credentials** (in its config files: connection strings,
359
+ \`.env\`, \`Web.config\`, \`appsettings.json\`…) and **credentials the human gives
360
+ you** may be used for the task, e.g. connecting to a database the work needs.
361
+ A developer on this project would use them too; don't refuse a task over that.
362
+ - **Don't spread them:** never put a credential into anything that is committed
363
+ or shared — new files in the project, docs, the rules register, the plan,
364
+ messages to other agents — and don't print it in your answers. Prefer
365
+ commands that read it from the config file over pasting it into a command line.
366
+ - **Respect the environment:** if the human says a system is a read-only mirror
367
+ or production, only read from it; changing production needs the human's
368
+ explicit OK.`;
351
369
  }
@@ -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)