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 +23 -0
- package/README.md +29 -9
- package/bin/symbio.js +91 -4
- package/bin/update-runner.mjs +92 -0
- package/dist/server/connections.js +64 -7
- package/dist/server/housekeeping.js +14 -5
- package/dist/server/index.js +41 -1
- package/dist/server/prompts.js +26 -8
- package/dist/server/remote-access.js +1 -1
- package/dist/server/remote.js +65 -23
- package/dist/server/self-update.js +254 -0
- package/dist/server/team.js +27 -0
- package/dist/server/tools.js +24 -5
- package/dist/server/updates.js +17 -0
- package/dist/web/assets/{index-CtcmbMmw.css → index-BmRTqm-A.css} +1 -1
- package/dist/web/assets/{index-yZ8d_EGH.js → index-Cila9fjW.js} +27 -27
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
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
|
|
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
|
|
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
|
+
}
|
|
@@ -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
|
|
3
|
-
// secrets live in the encrypted vault and are never
|
|
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
|
-
:
|
|
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
|
|
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
|
};
|
package/dist/server/prompts.js
CHANGED
|
@@ -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
|
|
345
|
-
\`git_pull
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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)
|