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.
@@ -1,7 +1,7 @@
1
1
  // Actions agents take through connections (SSH commands and uploads, git push/pull,
2
- // GitHub API). Symbio runs them itself with credentials from the vault — agents
3
- // only see results. Connections set to "ask" queue an approval for the human; the
4
- // result is delivered to the agent as a message once approved.
2
+ // GitHub and Azure DevOps APIs). Symbio runs them itself with credentials from the
3
+ // vault — agents only see results. Connections set to "ask" queue an approval for
4
+ // the human; the result is delivered to the agent as a message once approved.
5
5
  import { execFile } from "node:child_process";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { promises as fs, existsSync, realpathSync } from "node:fs";
@@ -28,13 +28,16 @@ export class RemoteOps {
28
28
  }
29
29
  describeFor(project) {
30
30
  const list = this.enabled(project);
31
- if (list.length === 0)
32
- return "This project has no connections. If the work needs GitHub or a server, tell the human to add one in the project's sidebar (Connections).";
31
+ if (list.length === 0) {
32
+ return "This project has no connections. If the work needs GitHub, Azure DevOps or a server, tell the human to add one in the project's Settings (Connections).";
33
+ }
33
34
  return list
34
35
  .map(({ conn, link }) => {
35
36
  const what = conn.type === "github"
36
37
  ? `GitHub${conn.account ? ` account ${conn.account}` : ""} — git_push, git_pull, github_api`
37
- : `server ${conn.username}@${conn.host} — remote_exec, remote_upload`;
38
+ : conn.type === "azure"
39
+ ? `Azure DevOps organization ${conn.organization}${conn.account ? ` (as ${conn.account})` : ""} — git_push, git_pull, azure_devops_api`
40
+ : `server ${conn.username}@${conn.host} — remote_exec, remote_upload`;
38
41
  return `- "${conn.name}": ${what} (${link.policy === "ask" ? "each action needs the human's approval" : "allowed without approval"})`;
39
42
  })
40
43
  .join("\n");
@@ -43,7 +46,7 @@ export class RemoteOps {
43
46
  async request(project, agent, action, reason) {
44
47
  const { conn, link } = this.resolve(project, action.connectionId, action.kind);
45
48
  const summary = this.summarize(conn, action, agent);
46
- const readOnly = action.kind === "github_api" && action.method.toUpperCase() === "GET";
49
+ const readOnly = (action.kind === "github_api" || action.kind === "azure_devops_api") && action.method.toUpperCase() === "GET";
47
50
  if (link.policy === "allow" || readOnly) {
48
51
  return this.execute(project, agent, conn, action, summary, reason);
49
52
  }
@@ -106,9 +109,17 @@ export class RemoteOps {
106
109
  const names = this.enabled(project).map(({ conn }) => conn.name).join(", ") || "none";
107
110
  throw new Error(`No connection "${connectionRef}" in this project. Available: ${names}.`);
108
111
  }
109
- const needs = kind.startsWith("remote_") ? "server" : "github";
110
- if (found.conn.type !== needs)
111
- throw new Error(`"${found.conn.name}" is a ${found.conn.type} connection; ${kind} needs a ${needs} connection.`);
112
+ const needs = kind.startsWith("remote_")
113
+ ? ["server"]
114
+ : kind === "github_api"
115
+ ? ["github"]
116
+ : kind === "azure_devops_api"
117
+ ? ["azure"]
118
+ : ["github", "azure"]; // git_push / git_pull
119
+ if (!needs.includes(found.conn.type)) {
120
+ const label = (t) => (t === "azure" ? "Azure DevOps" : t === "github" ? "GitHub" : "server");
121
+ throw new Error(`"${found.conn.name}" is a ${label(found.conn.type)} connection; ${kind} needs a ${needs.map(label).join(" or ")} connection.`);
122
+ }
112
123
  return found;
113
124
  }
114
125
  summarize(conn, a, agent) {
@@ -123,6 +134,8 @@ export class RemoteOps {
123
134
  return `git pull ${a.remote ?? "origin"} ${a.branch ?? "(current branch)"} into ${agent.name}'s workspace using ${conn.name}`;
124
135
  case "github_api":
125
136
  return `GitHub API ${a.method.toUpperCase()} ${a.path} using ${conn.name}`;
137
+ case "azure_devops_api":
138
+ return `Azure DevOps API ${a.method.toUpperCase()} ${a.path} using ${conn.name}`;
126
139
  }
127
140
  }
128
141
  async execute(project, agent, conn, a, summary, reason) {
@@ -163,12 +176,11 @@ export class RemoteOps {
163
176
  }
164
177
  case "git_push":
165
178
  case "git_pull": {
166
- const token = await this.connections.githubToken(conn);
179
+ const gitConn = conn;
180
+ const token = await this.connections.githubToken(gitConn);
167
181
  const remote = a.remote ?? "origin";
168
182
  const url = remote.startsWith("https://") ? remote : await gitOut(agent.directory, ["remote", "get-url", remote]);
169
- if (!/^https:\/\/github\.com\//i.test(url)) {
170
- throw new Error(`The remote must be a GitHub HTTPS URL (got ${url || "nothing"}). Add one with: git remote add origin https://github.com/<owner>/<repo>.git`);
171
- }
183
+ const auth = gitAuth(gitConn, url, token);
172
184
  const branch = a.branch ?? (await gitOut(agent.directory, ["rev-parse", "--abbrev-ref", "HEAD"]));
173
185
  // The agent controls this repository, so its config and hooks are untrusted: a hook
174
186
  // would see the token, a proxy or URL rewrite would send it elsewhere.
@@ -177,7 +189,7 @@ export class RemoteOps {
177
189
  const safe = ["-c", `core.hooksPath=${noHooks}`, "-c", "http.proxy=", "-c", "http.sslVerify=true", "-c", "credential.helper="];
178
190
  // The token goes in an HTTP header via environment-only git config: never in
179
191
  // the command line, the repo config or the agent's view.
180
- const header = `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`;
192
+ const { headerKey, header } = auth;
181
193
  const git = (args, withToken) => run("git", [...safe, ...args], {
182
194
  cwd: agent.directory,
183
195
  windowsHide: true,
@@ -185,9 +197,7 @@ export class RemoteOps {
185
197
  env: {
186
198
  ...process.env,
187
199
  GIT_TERMINAL_PROMPT: "0",
188
- ...(withToken
189
- ? { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader", GIT_CONFIG_VALUE_0: header }
190
- : {}),
200
+ ...(withToken ? { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: headerKey, GIT_CONFIG_VALUE_0: header } : {}),
191
201
  },
192
202
  }).catch((err) => {
193
203
  throw new Error(scrub(String(err.stderr || err.message), token));
@@ -201,8 +211,11 @@ export class RemoteOps {
201
211
  const merged = await git(["merge", "--no-edit", "FETCH_HEAD"], false);
202
212
  return scrub(`${fetched.stdout}${fetched.stderr}${merged.stdout}${merged.stderr}`.trim() || "done", token);
203
213
  }
204
- case "github_api": {
205
- const data = await this.connections.githubRequest(conn, a.method.toUpperCase(), a.path, a.body);
214
+ case "github_api":
215
+ case "azure_devops_api": {
216
+ const data = a.kind === "github_api"
217
+ ? await this.connections.githubRequest(conn, a.method.toUpperCase(), a.path, a.body)
218
+ : await this.connections.azureRequest(conn, a.method.toUpperCase(), a.path, a.body);
206
219
  const text = JSON.stringify(data, null, 2) ?? "(no content)";
207
220
  return text.length > 20_000 ? `${text.slice(0, 20_000)}\n… (truncated)` : text;
208
221
  }
@@ -258,7 +271,7 @@ export async function refuseRiskyGitConfig(dir) {
258
271
  .filter(([scope, key]) => (scope === "local" || scope === "worktree") && key && RISKY_GIT_CONFIG.some((re) => re.test(key.toLowerCase())))
259
272
  .map(([, key]) => key);
260
273
  if (risky.length) {
261
- throw new Error(`Refusing to use GitHub credentials: this repository's git config sets ${risky.join(", ")}, which could redirect or capture them. Remove ${risky.length > 1 ? "these settings" : "it"} (git config --unset <name>) and try again.`);
274
+ throw new Error(`Refusing to use the connection's credentials: this repository's git config sets ${risky.join(", ")}, which could redirect or capture them. Remove ${risky.length > 1 ? "these settings" : "it"} (git config --unset <name>) and try again.`);
262
275
  }
263
276
  }
264
277
  async function gitOut(cwd, args) {
@@ -266,6 +279,35 @@ async function gitOut(cwd, args) {
266
279
  return stdout.trim();
267
280
  }
268
281
  function scrub(text, secret) {
269
- const b64 = Buffer.from(`x-access-token:${secret}`).toString("base64");
270
- return text.split(secret).join("***").split(b64).join("***");
282
+ let out = text.split(secret).join("***");
283
+ for (const user of ["x-access-token:", ":"])
284
+ out = out.split(Buffer.from(`${user}${secret}`).toString("base64")).join("***");
285
+ return out;
286
+ }
287
+ /**
288
+ * How to send a connection's token to a git remote, after checking the remote belongs to it:
289
+ * a GitHub HTTPS URL for GitHub; for Azure DevOps, a dev.azure.com (or older visualstudio.com)
290
+ * URL of the connection's own organization, so its token never goes to someone else's.
291
+ */
292
+ export function gitAuth(conn, url, token) {
293
+ if (conn.type === "github") {
294
+ if (!/^https:\/\/github\.com\//i.test(url)) {
295
+ throw new Error(`The remote must be a GitHub HTTPS URL (got ${url || "nothing"}). Add one with: git remote add origin https://github.com/<owner>/<repo>.git`);
296
+ }
297
+ return { headerKey: "http.https://github.com/.extraheader", header: `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}` };
298
+ }
299
+ const devAzure = /^https:\/\/(?:[^@/]+@)?dev\.azure\.com\/([^/]+)\//i.exec(url);
300
+ const legacy = /^https:\/\/(?:[^@/]+@)?([^./]+)\.visualstudio\.com\//i.exec(url);
301
+ if (!devAzure && !legacy) {
302
+ throw new Error(`The remote must be an Azure DevOps HTTPS URL (got ${url || "nothing"}). ` +
303
+ (/^git@|^ssh:/i.test(url)
304
+ ? "It's an SSH remote; switch it to HTTPS: git remote set-url origin https://dev.azure.com/<organization>/<project>/_git/<repository>"
305
+ : "Add one with: git remote add origin https://dev.azure.com/<organization>/<project>/_git/<repository>"));
306
+ }
307
+ const org = decodeURIComponent((devAzure ?? legacy)[1]);
308
+ if (org.toLowerCase() !== conn.organization.toLowerCase()) {
309
+ throw new Error(`This remote belongs to the Azure DevOps organization "${org}", but "${conn.name}" is for "${conn.organization}". Use a connection for "${org}".`);
310
+ }
311
+ const host = devAzure ? "https://dev.azure.com/" : `https://${legacy[1]}.visualstudio.com/`;
312
+ return { headerKey: `http.${host}.extraheader`, header: `Authorization: Basic ${Buffer.from(`:${token}`).toString("base64")}` };
271
313
  }
@@ -0,0 +1,254 @@
1
+ // Keeping Symbio up to date: checks npm for a newer version and, when the human allows it,
2
+ // updates while the team is idle. The install itself happens in bin/update-runner.mjs, a separate
3
+ // process that waits for Symbio to exit (OpenCode and native modules lock their files while
4
+ // running on Windows), installs, puts the old version back if that fails, and starts Symbio again.
5
+ import { spawn } from "node:child_process";
6
+ import { existsSync, promises as fs, realpathSync } from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
11
+ /** A global npm install lives in <prefix>/node_modules/symbio (lib/node_modules on Unix); anything else, like `npm link` to a clone, is a development copy. */
12
+ export function installInfo(root = packageRoot) {
13
+ const real = (() => {
14
+ try {
15
+ return realpathSync(root);
16
+ }
17
+ catch {
18
+ return root;
19
+ }
20
+ })();
21
+ const nodeModules = path.dirname(real);
22
+ if (path.basename(nodeModules) !== "node_modules" || existsSync(path.join(real, ".git")))
23
+ return { kind: "dev", root: real, prefix: null };
24
+ const prefix = process.platform === "win32" ? path.dirname(nodeModules) : path.dirname(path.dirname(nodeModules));
25
+ return { kind: "npm", root: real, prefix };
26
+ }
27
+ /** 1 if a > b, -1 if a < b, 0 if equal. Pre-releases ("0.3.0-beta.1") sort before their release. */
28
+ export function compareVersions(a, b) {
29
+ const parse = (v) => {
30
+ const [main, pre] = v.replace(/^v/, "").split("-", 2);
31
+ return { nums: main.split(".").map((n) => Number(n) || 0), pre: pre ?? null };
32
+ };
33
+ const x = parse(a);
34
+ const y = parse(b);
35
+ for (let i = 0; i < 3; i++)
36
+ if ((x.nums[i] ?? 0) !== (y.nums[i] ?? 0))
37
+ return (x.nums[i] ?? 0) > (y.nums[i] ?? 0) ? 1 : -1;
38
+ if (x.pre === y.pre)
39
+ return 0;
40
+ if (x.pre === null)
41
+ return 1;
42
+ if (y.pre === null)
43
+ return -1;
44
+ return x.pre > y.pre ? 1 : -1;
45
+ }
46
+ // Overridable for tests (a local registry and package file); never set in normal use.
47
+ const REGISTRY = (process.env.SYMBIO_UPDATE_REGISTRY ?? "https://registry.npmjs.org").replace(/\/+$/, "");
48
+ /** The version npm installs as `symbio@latest`. */
49
+ export async function latestVersion() {
50
+ const res = await fetch(`${REGISTRY}/symbio/latest`, { signal: AbortSignal.timeout(15_000), headers: { accept: "application/json" } });
51
+ if (!res.ok)
52
+ throw new Error(`npm registry answered ${res.status}`);
53
+ const data = (await res.json());
54
+ if (!data.version)
55
+ throw new Error("npm registry gave no version");
56
+ return data.version;
57
+ }
58
+ /** Changelog entries newer than `current`, from the latest version's CHANGELOG.md (best effort). */
59
+ export async function changesSince(current, latest) {
60
+ const url = process.env.SYMBIO_UPDATE_CHANGELOG ?? `https://raw.githubusercontent.com/linkzy/symbio/v${latest}/CHANGELOG.md`;
61
+ const res = await fetch(url, { signal: AbortSignal.timeout(15_000) }).catch(() => null);
62
+ if (!res?.ok)
63
+ return [];
64
+ return parseChangelog(await res.text()).filter((s) => compareVersions(s.version, current) > 0);
65
+ }
66
+ export function parseChangelog(text) {
67
+ const out = [];
68
+ let section = null;
69
+ for (const line of text.replace(/\r/g, "").split("\n")) {
70
+ const head = /^## \[(\d+\.\d+\.\d+[^\]]*)\]/.exec(line);
71
+ if (head) {
72
+ section = { version: head[1], items: [] };
73
+ out.push(section);
74
+ }
75
+ else if (/^## /.test(line))
76
+ section = null;
77
+ else if (section && /^- /.test(line))
78
+ section.items.push(line.slice(2).trim());
79
+ else if (section && /^\s+\S/.test(line) && section.items.length)
80
+ section.items[section.items.length - 1] += ` ${line.trim()}`;
81
+ }
82
+ return out;
83
+ }
84
+ const CHECK_EVERY_MS = 6 * 60 * 60_000;
85
+ const TICK_MS = 5 * 60_000;
86
+ export const QUIET_MS = 10 * 60_000; // the team must have been quiet this long before an automatic update
87
+ const FAILED_PAUSE_MS = 24 * 60 * 60_000; // after a failed update, don't retry the same version for a day
88
+ export class SelfUpdate {
89
+ host;
90
+ latest = null;
91
+ checkedAt = null;
92
+ error = null;
93
+ notes = [];
94
+ mode = "auto";
95
+ skip = null;
96
+ lastResult = null;
97
+ applying = false;
98
+ timer = null;
99
+ settingsFile;
100
+ resultFile;
101
+ install;
102
+ constructor(host, install = installInfo()) {
103
+ this.host = host;
104
+ this.install = install;
105
+ this.settingsFile = path.join(host.home, "updates.json");
106
+ this.resultFile = path.join(host.home, "update-result.json");
107
+ }
108
+ async load() {
109
+ const settings = JSON.parse(await fs.readFile(this.settingsFile, "utf8").catch(() => "{}"));
110
+ if (settings.mode === "auto" || settings.mode === "notify" || settings.mode === "off")
111
+ this.mode = settings.mode;
112
+ this.skip = settings.skip ?? null;
113
+ this.lastResult = JSON.parse(await fs.readFile(this.resultFile, "utf8").catch(() => "null"));
114
+ // The runner wrote this before starting us again: tell the humans once.
115
+ if (this.lastResult && !this.lastResult.seen) {
116
+ const r = this.lastResult;
117
+ if (r.ok)
118
+ await this.host.announce(`Symbio was updated from ${r.from} to ${r.to}.`).catch(() => { });
119
+ else {
120
+ await this.host.announce(`Symbio couldn't update to ${r.to} and is still on ${this.host.version}: ${r.error}`).catch(() => { });
121
+ this.skip = { version: r.to, until: Date.now() + FAILED_PAUSE_MS };
122
+ await this.saveSettings();
123
+ }
124
+ r.seen = true;
125
+ await fs.writeFile(this.resultFile, JSON.stringify(r, null, 2)).catch(() => { });
126
+ }
127
+ }
128
+ start() {
129
+ void this.check().catch(() => { });
130
+ this.timer ??= setInterval(() => void this.tick().catch((err) => console.error("[update]", err.message)), TICK_MS);
131
+ }
132
+ stop() {
133
+ if (this.timer)
134
+ clearInterval(this.timer);
135
+ this.timer = null;
136
+ }
137
+ state() {
138
+ const available = !!this.latest && compareVersions(this.latest, this.host.version) > 0;
139
+ return {
140
+ current: this.host.version,
141
+ latest: this.latest,
142
+ available,
143
+ checkedAt: this.checkedAt,
144
+ error: this.error,
145
+ mode: this.mode,
146
+ install: this.install.kind,
147
+ busy: this.host.busy(),
148
+ waiting: available ? this.blocker(false) : null,
149
+ notes: this.notes,
150
+ lastResult: this.lastResult,
151
+ };
152
+ }
153
+ async setMode(mode) {
154
+ this.mode = mode;
155
+ await this.saveSettings();
156
+ }
157
+ async check() {
158
+ if (this.mode === "off" && this.checkedAt)
159
+ return this.state();
160
+ try {
161
+ this.latest = await latestVersion();
162
+ this.error = null;
163
+ this.notes = compareVersions(this.latest, this.host.version) > 0 ? await changesSince(this.host.version, this.latest) : [];
164
+ }
165
+ catch (err) {
166
+ this.error = `Couldn't check for updates: ${err.message}`;
167
+ }
168
+ this.checkedAt = Date.now();
169
+ return this.state();
170
+ }
171
+ /** Why the update can't be applied now (null = it can). `forced`: the human asked, so only hard blockers count. */
172
+ blocker(forced) {
173
+ if (this.install.kind === "dev")
174
+ return "This Symbio runs from a development folder; update it with git pull, npm install and npm run build.";
175
+ if (this.applying)
176
+ return "Already updating.";
177
+ if (!forced && this.mode === "notify")
178
+ return "Updates are set to notify only: update from Settings or with `sym update`.";
179
+ if (!forced && this.mode === "off")
180
+ return "Automatic updates are off: update from Settings or with `sym update`.";
181
+ if (!forced && this.skip && this.latest === this.skip.version && Date.now() < this.skip.until)
182
+ return `The update to ${this.skip.version} failed recently; it will be retried tomorrow.`;
183
+ if (!forced && this.host.remoteActive())
184
+ return "Remote access is on (updating would end it); waiting until it's off.";
185
+ if (!forced) {
186
+ const busy = this.host.busy();
187
+ if (busy)
188
+ return `Waiting for the team to be idle (${busy}).`;
189
+ }
190
+ return null;
191
+ }
192
+ /** Automatic updates: check now and then; apply when allowed and the team has been quiet. */
193
+ async tick() {
194
+ if (this.mode === "off" || this.install.kind === "dev")
195
+ return;
196
+ if (!this.checkedAt || Date.now() - this.checkedAt > CHECK_EVERY_MS)
197
+ await this.check();
198
+ const s = this.state();
199
+ if (s.available && this.mode === "auto" && !this.blocker(false))
200
+ await this.apply(false);
201
+ }
202
+ /**
203
+ * Hands over to the runner and stops Symbio. `forced` (the human asked): applies even with
204
+ * agents working (they're interrupted and resumed after the restart).
205
+ */
206
+ async apply(forced) {
207
+ if (!this.latest || compareVersions(this.latest, this.host.version) <= 0)
208
+ await this.check();
209
+ if (!this.latest || compareVersions(this.latest, this.host.version) <= 0)
210
+ throw new Error(`Symbio ${this.host.version} is up to date.`);
211
+ const blocked = this.blocker(forced);
212
+ if (blocked)
213
+ throw new Error(blocked);
214
+ this.applying = true;
215
+ const to = this.latest;
216
+ try {
217
+ await this.host.backup().catch((err) => console.error("[update] backup failed:", err.message));
218
+ await this.host.announce(`Symbio is updating from ${this.host.version} to ${to}; it'll be back in a minute or two.`).catch(() => { });
219
+ await this.runRunner({ waitPid: process.pid, to, start: true });
220
+ }
221
+ catch (err) {
222
+ this.applying = false;
223
+ throw err;
224
+ }
225
+ setTimeout(() => void this.host.shutdown(), 300);
226
+ return `Updating to ${to}: Symbio restarts in a minute or two.`;
227
+ }
228
+ /** Starts the runner from a copy in the temp folder (npm replaces the package folder). */
229
+ async runRunner(opts) {
230
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "symbio-update-"));
231
+ const runner = path.join(dir, "update-runner.mjs");
232
+ await fs.copyFile(path.join(this.install.root, "bin", "update-runner.mjs"), runner);
233
+ const job = path.join(dir, "job.json");
234
+ await fs.writeFile(job, JSON.stringify({
235
+ waitPid: opts.waitPid,
236
+ prefix: this.install.prefix,
237
+ spec: process.env.SYMBIO_UPDATE_SPEC ?? `symbio@${opts.to}`,
238
+ from: this.host.version,
239
+ to: opts.to,
240
+ root: this.install.root,
241
+ home: this.host.home,
242
+ port: this.host.port,
243
+ start: opts.start,
244
+ resultFile: this.resultFile,
245
+ logFile: path.join(this.host.home, "logs", "update.log"),
246
+ }));
247
+ const child = spawn(process.execPath, [runner, job], { detached: true, stdio: "ignore", windowsHide: true });
248
+ child.unref();
249
+ }
250
+ async saveSettings() {
251
+ await fs.mkdir(path.dirname(this.settingsFile), { recursive: true });
252
+ await fs.writeFile(this.settingsFile, JSON.stringify({ mode: this.mode, skip: this.skip }, null, 2));
253
+ }
254
+ }
@@ -229,6 +229,33 @@ export class Team {
229
229
  this.recycling = false;
230
230
  }
231
231
  }
232
+ /**
233
+ * What would be interrupted by a restart right now (null = nothing): an agent working or waiting
234
+ * for an answer, a permission request, or activity in the last `quietMs`.
235
+ */
236
+ busyReason(quietMs) {
237
+ const now = Date.now();
238
+ for (const project of this.store.list()) {
239
+ for (const agent of project.agents.filter(active)) {
240
+ if (agent.status === "busy" || agent.status === "stuck")
241
+ return `${agent.name} in ${project.name} is working`;
242
+ if (agent.status === "waiting")
243
+ return `${agent.name} in ${project.name} is waiting for an answer`;
244
+ if (now - agent.lastActivity < quietMs)
245
+ return `${agent.name} in ${project.name} was active in the last ${Math.round(quietMs / 60_000)} minutes`;
246
+ }
247
+ }
248
+ if (this.permissions.size)
249
+ return "a permission request is waiting for an answer";
250
+ return null;
251
+ }
252
+ /** A note for the human in every project's Messages (e.g. "Symbio was updated"). */
253
+ async announce(text) {
254
+ for (const project of this.store.list()) {
255
+ await this.log(project, { from: "symbio", to: "human", kind: "system", text });
256
+ bus.publish({ type: "project.updated", projectId: project.id });
257
+ }
258
+ }
232
259
  stopWatchdog() {
233
260
  if (this.watchdog)
234
261
  clearInterval(this.watchdog);
@@ -330,20 +330,20 @@ const remoteUpload = {
330
330
  run: ({ connection, local_path, remote_path, reason }, { team, project, agent }) => team.remote.request(project, agent, { kind: "remote_upload", connectionId: String(connection), localPath: String(local_path), remotePath: String(remote_path) }, String(reason)),
331
331
  };
332
332
  const gitFields = {
333
- connection: str("GitHub connection name"),
334
- remote: str("Remote name or https://github.com/… URL (default: origin)"),
333
+ connection: str("GitHub or Azure DevOps connection name"),
334
+ remote: str("Remote name or HTTPS URL (github.com, dev.azure.com) (default: origin)"),
335
335
  branch: str("Branch (default: your current branch)"),
336
336
  reason: reasonField,
337
337
  };
338
338
  const gitPush = {
339
339
  name: "git_push",
340
- description: "Push committed work from your working folder to GitHub (the token is applied by Symbio; plain `git push` has no credentials).",
340
+ description: "Push committed work from your working folder to GitHub or Azure DevOps (the token is applied by Symbio; plain `git push` may have no credentials).",
341
341
  inputSchema: obj(gitFields, ["connection", "reason"]),
342
342
  run: ({ connection, remote, branch, reason }, { team, project, agent }) => team.remote.request(project, agent, { kind: "git_push", connectionId: String(connection), remote: remote ? String(remote) : null, branch: branch ? String(branch) : null }, String(reason)),
343
343
  };
344
344
  const gitPull = {
345
345
  name: "git_pull",
346
- description: "Pull from GitHub into your working folder (merge, no rebase).",
346
+ description: "Pull from GitHub or Azure DevOps into your working folder (merge, no rebase).",
347
347
  inputSchema: obj(gitFields, ["connection", "reason"]),
348
348
  run: ({ connection, remote, branch, reason }, { team, project, agent }) => team.remote.request(project, agent, { kind: "git_pull", connectionId: String(connection), remote: remote ? String(remote) : null, branch: branch ? String(branch) : null }, String(reason)),
349
349
  };
@@ -354,11 +354,29 @@ const githubApi = {
354
354
  connection: str("GitHub connection name"),
355
355
  method: { type: "string", enum: ["GET", "POST", "PATCH", "PUT", "DELETE"] },
356
356
  path: str("API path, e.g. /user/repos"),
357
- body: { type: "object", description: "JSON body for POST/PATCH/PUT" },
357
+ // Some endpoints take a list (e.g. Azure DevOps ref updates), others an object.
358
+ body: { anyOf: [{ type: "object" }, { type: "array" }], description: "JSON body for POST/PATCH/PUT (an object, or a list where the API expects one)" },
358
359
  reason: reasonField,
359
360
  }, ["connection", "method", "path", "reason"]),
360
361
  run: ({ connection, method, path, body, reason }, { team, project, agent }) => team.remote.request(project, agent, { kind: "github_api", connectionId: String(connection), method: String(method), path: String(path), body }, String(reason)),
361
362
  };
363
+ const azureDevopsApi = {
364
+ name: "azure_devops_api",
365
+ description: "Call the Azure DevOps REST API of the connection's organization. The path is relative to https://dev.azure.com/<organization>/ " +
366
+ "and api-version is added if missing. E.g. GET <project>/_apis/git/repositories to find a repository's id; " +
367
+ "POST <project>/_apis/git/repositories/<repo>/pullrequests with {sourceRefName: 'refs/heads/<branch>', targetRefName: " +
368
+ "'refs/heads/main', title, description} to open a pull request (push the branch first with git_push); " +
369
+ "GET <project>/_apis/git/repositories/<repo>/pullrequests?searchCriteria.status=active to list them. Read-only GET calls never need approval.",
370
+ inputSchema: obj({
371
+ connection: str("Azure DevOps connection name"),
372
+ method: { type: "string", enum: ["GET", "POST", "PATCH", "PUT", "DELETE"] },
373
+ path: str("Path inside the organization, e.g. MyProject/_apis/git/repositories"),
374
+ // Some endpoints take a list (e.g. Azure DevOps ref updates), others an object.
375
+ body: { anyOf: [{ type: "object" }, { type: "array" }], description: "JSON body for POST/PATCH/PUT (an object, or a list where the API expects one)" },
376
+ reason: reasonField,
377
+ }, ["connection", "method", "path", "reason"]),
378
+ run: ({ connection, method, path, body, reason }, { team, project, agent }) => team.remote.request(project, agent, { kind: "azure_devops_api", connectionId: String(connection), method: String(method), path: String(path), body }, String(reason)),
379
+ };
362
380
  // ---- rules register (only when the project keeps one)
363
381
  function registerOn(project) {
364
382
  if (!project.rulesRegister)
@@ -460,6 +478,7 @@ const COMMON = [
460
478
  gitPush,
461
479
  gitPull,
462
480
  githubApi,
481
+ azureDevopsApi,
463
482
  sendMessage,
464
483
  askHuman,
465
484
  listTeam,
@@ -74,6 +74,23 @@ export const AGENT_UPDATES = [
74
74
  "leave reviewing many images to a helper. If a conversation still becomes too large, Symbio moves that agent to a fresh " +
75
75
  "session with a handoff note instead of trying other models.",
76
76
  },
77
+ {
78
+ id: "2026-11-06-credentials",
79
+ text: "Credentials, clarified: the old rule \"never look for credentials or put secrets in commands\" was meant for Symbio's own " +
80
+ "secrets (its vault, connections, SSH keys), and it stopped agents from doing legitimate work. Now: Symbio's secrets stay " +
81
+ "off-limits (use connections only through the tools). The project's own credentials (connection strings, .env, Web.config…) " +
82
+ "and credentials the human gives you may be used for the task, e.g. querying a database the work needs. Don't spread them: " +
83
+ "never put one into anything committed or shared (new files, docs, the rules register, the plan, messages to other agents), " +
84
+ "and prefer commands that read it from the config file. If the human says a system is a read-only mirror or production, only " +
85
+ "read from it unless they explicitly OK changes. If you refused such a task before, you can do it now.",
86
+ },
87
+ {
88
+ id: "2026-11-06-azure-devops",
89
+ text: "Azure DevOps connections: the human can now add an Azure DevOps organization (with a personal access token) in Settings and " +
90
+ "enable it per project. git_push and git_pull work with it (HTTPS remotes of that organization; SSH remotes must be switched to " +
91
+ "HTTPS), and the new azure_devops_api tool calls its REST API, e.g. to open a pull request after pushing the branch. Reading " +
92
+ "(GET) never needs approval. If a push or a pull request failed for lack of credentials, ask the human to add the connection.",
93
+ },
77
94
  ];
78
95
  /** Entries a project hasn't been told about yet. */
79
96
  export function unseenUpdates(seen) {