symbio 0.4.0 → 0.6.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,191 @@
1
+ // Rules register: the project's rules, each with where it came from and how it's met. Kept as
2
+ // JSON next to the project docs so the Docs tab can show it as a filterable index. Agents change
3
+ // it only through Symbio's tools (validated, stable ids), never by editing the file.
4
+ //
5
+ // Domain-neutral on purpose: a "rule" is anything the project must respect (a requirement, a
6
+ // decision, a style rule, a constraint), whatever the project is about.
7
+ import { existsSync, promises as fs } from "node:fs";
8
+ import path from "node:path";
9
+ import { docsRoot } from "./docs.js";
10
+ export const CONFIRMATIONS = ["found", "checked-by-agent", "checked-by-human", "contested"];
11
+ export const STATUSES = ["done", "partial", "missing", "not-applicable"];
12
+ const empty = () => ({ areas: [], sources: [], authorities: [], rules: [] });
13
+ export function registerPath(project) {
14
+ return path.join(docsRoot(project), project.docsLocation === "private" ? "rules.json" : path.join("docs", "rules.json"));
15
+ }
16
+ export async function readRegister(project) {
17
+ const file = registerPath(project);
18
+ if (!existsSync(file))
19
+ return empty();
20
+ const data = JSON.parse(await fs.readFile(file, "utf8"));
21
+ return { areas: data.areas ?? [], sources: data.sources ?? [], authorities: data.authorities ?? [], rules: data.rules ?? [] };
22
+ }
23
+ // Several agents may write at once: one write at a time per file.
24
+ const queues = new Map();
25
+ async function change(project, fn) {
26
+ const file = registerPath(project);
27
+ const run = (queues.get(file) ?? Promise.resolve()).then(async () => {
28
+ const reg = await readRegister(project);
29
+ const result = fn(reg);
30
+ await fs.mkdir(path.dirname(file), { recursive: true });
31
+ const tmp = `${file}.tmp`;
32
+ await fs.writeFile(tmp, JSON.stringify(reg, null, 2) + "\n");
33
+ await fs.rename(tmp, file);
34
+ return result;
35
+ });
36
+ queues.set(file, run.catch(() => { }));
37
+ return run;
38
+ }
39
+ const CODE = /^[A-Za-z0-9][A-Za-z0-9_.]{0,15}$/;
40
+ function code(value, what) {
41
+ const s = String(value ?? "").trim();
42
+ if (!CODE.test(s))
43
+ throw new Error(`${what} must be a short code (letters, digits, dots; up to 16), e.g. "AP" or "MA".`);
44
+ return s;
45
+ }
46
+ const text = (value) => (value === undefined || value === null ? undefined : String(value).trim() || undefined);
47
+ export async function saveArea(project, input) {
48
+ const id = code(input.id, "The area id").toUpperCase();
49
+ const name = text(input.name);
50
+ if (!name)
51
+ throw new Error("Give the area a name.");
52
+ const stages = Array.isArray(input.stages)
53
+ ? input.stages.map((s, i) => {
54
+ const stageName = text(s?.name ?? s);
55
+ if (!stageName)
56
+ throw new Error(`Stage ${i + 1} needs a name.`);
57
+ return { id: text(s?.id) ?? String.fromCharCode(65 + i), name: stageName };
58
+ })
59
+ : undefined;
60
+ return change(project, (reg) => {
61
+ if (reg.sources.some((s) => s.id === id))
62
+ throw new Error(`"${id}" is already a source code; an area prefix must differ from every source code.`);
63
+ const existing = reg.areas.find((a) => a.id === id);
64
+ const area = { id, name, group: text(input.group), description: text(input.description), stages: stages ?? existing?.stages ?? [] };
65
+ if (existing)
66
+ Object.assign(existing, area);
67
+ else
68
+ reg.areas.push(area);
69
+ return `${existing ? "Updated" : "Added"} area ${id} "${name}"${area.stages.length ? ` with stages ${area.stages.map((s) => `${s.id}. ${s.name}`).join("; ")}` : ""}.`;
70
+ });
71
+ }
72
+ export async function saveSource(project, input) {
73
+ const id = code(input.id, "The source id");
74
+ const name = text(input.name);
75
+ if (!name)
76
+ throw new Error("Give the source a name.");
77
+ const authority = input.authority === undefined || input.authority === "" ? undefined : code(input.authority, "The authority");
78
+ return change(project, (reg) => {
79
+ if (reg.areas.some((a) => a.id === id))
80
+ throw new Error(`"${id}" is already an area prefix; a source code must differ from every area prefix.`);
81
+ const source = { id, name, file: text(input.file)?.replace(/\\/g, "/"), authority };
82
+ const existing = reg.sources.find((s) => s.id === id);
83
+ if (existing)
84
+ Object.assign(existing, source);
85
+ else
86
+ reg.sources.push(source);
87
+ const authorityName = text(input.authority_name);
88
+ if (authority && authorityName) {
89
+ const known = reg.authorities.find((a) => a.id === authority);
90
+ if (known)
91
+ known.name = authorityName;
92
+ else
93
+ reg.authorities.push({ id: authority, name: authorityName });
94
+ }
95
+ return `${existing ? "Updated" : "Added"} source ${id} "${name}".`;
96
+ });
97
+ }
98
+ /** Creates a rule (id assigned in its area) or updates one; only the given fields change. */
99
+ export async function saveRule(project, input, by) {
100
+ return change(project, (reg) => {
101
+ const id = text(input.id)?.toUpperCase();
102
+ const existing = id ? reg.rules.find((r) => r.id === id) : undefined;
103
+ if (id && !existing)
104
+ throw new Error(`No rule ${id}. Leave id out to add a new rule (Symbio assigns the next id in its area).`);
105
+ const areaId = text(input.area)?.toUpperCase() ?? existing?.area;
106
+ const area = reg.areas.find((a) => a.id === areaId);
107
+ if (!area) {
108
+ throw new Error(areaId
109
+ ? `No area ${areaId}. Define it first with save_rule_area.${reg.areas.length ? ` Areas: ${reg.areas.map((a) => a.id).join(", ")}.` : ""}`
110
+ : "Say which area the rule belongs to (area).");
111
+ }
112
+ const stage = input.stage === undefined ? existing?.stage : text(input.stage);
113
+ if (stage && !area.stages.some((s) => s.id === stage)) {
114
+ throw new Error(`Area ${area.id} has no stage "${stage}". Stages: ${area.stages.map((s) => s.id).join(", ") || "none (define them with save_rule_area)"}.`);
115
+ }
116
+ const ruleText = input.text === undefined ? existing?.text : text(input.text);
117
+ if (!ruleText)
118
+ throw new Error("Say what the rule is (text).");
119
+ const confirmation = (input.confirmation ?? existing?.confirmation ?? "found");
120
+ if (!CONFIRMATIONS.includes(confirmation))
121
+ throw new Error(`confirmation must be one of: ${CONFIRMATIONS.join(", ")}.`);
122
+ const status = (input.status ?? existing?.status ?? "missing");
123
+ if (!STATUSES.includes(status))
124
+ throw new Error(`status must be one of: ${STATUSES.join(", ")}.`);
125
+ let citations = existing?.citations ?? [];
126
+ if (input.citations !== undefined) {
127
+ if (!Array.isArray(input.citations))
128
+ throw new Error("citations must be a list of {source, location, excerpt}.");
129
+ citations = input.citations.map((c) => {
130
+ const source = code(c?.source, "A citation's source");
131
+ if (!reg.sources.some((s) => s.id === source))
132
+ throw new Error(`Unknown source "${source}". Define it first with save_rule_source.`);
133
+ return { source, location: text(c?.location), excerpt: text(c?.excerpt) };
134
+ });
135
+ }
136
+ const imposedBy = input.imposed_by === undefined ? existing?.imposedBy : text(input.imposed_by);
137
+ const supersededBy = input.superseded_by === undefined ? existing?.supersededBy : text(input.superseded_by)?.toUpperCase();
138
+ if (supersededBy && !reg.rules.some((r) => r.id === supersededBy))
139
+ throw new Error(`No rule ${supersededBy} to supersede this one.`);
140
+ const rule = {
141
+ id: existing?.id ?? nextId(reg, area.id),
142
+ area: area.id,
143
+ stage,
144
+ text: ruleText,
145
+ action: input.action === undefined ? existing?.action : text(input.action),
146
+ citations,
147
+ imposedBy,
148
+ confirmation,
149
+ status,
150
+ current: input.current === undefined ? existing?.current : text(input.current),
151
+ check: input.check === undefined ? existing?.check : text(input.check),
152
+ supersededBy,
153
+ updatedAt: Date.now(),
154
+ updatedBy: by,
155
+ };
156
+ if (existing)
157
+ Object.assign(existing, rule);
158
+ else
159
+ reg.rules.push(rule);
160
+ const unsourced = rule.citations.length === 0 ? " It has no source yet: add one when you find it." : "";
161
+ return `${existing ? "Updated" : "Added"} ${rule.id}.${unsourced}`;
162
+ });
163
+ }
164
+ /** Numbers are never reused: the next one is above every number the area ever had. */
165
+ function nextId(reg, area) {
166
+ const used = reg.rules.filter((r) => r.id.startsWith(`${area}-`)).map((r) => Number(r.id.slice(area.length + 1)) || 0);
167
+ return `${area}-${String(Math.max(0, ...used) + 1).padStart(3, "0")}`;
168
+ }
169
+ /** A compact listing for agents. */
170
+ export async function listRules(project, filter) {
171
+ const reg = await readRegister(project);
172
+ const q = text(filter.query)?.toLowerCase();
173
+ const area = text(filter.area)?.toUpperCase();
174
+ const status = text(filter.status);
175
+ const rules = reg.rules.filter((r) => (!area || r.area === area) &&
176
+ (!status || r.status === status) &&
177
+ (!q || [r.id, r.text, r.current, r.action, ...r.citations.map((c) => `${c.source} ${c.location ?? ""} ${c.excerpt ?? ""}`)].some((s) => s?.toLowerCase().includes(q))));
178
+ const head = [
179
+ `Areas: ${reg.areas.map((a) => `${a.id} (${a.name}${a.stages.length ? `; stages ${a.stages.map((s) => `${s.id}. ${s.name}`).join(", ")}` : ""})`).join(" · ") || "none yet"}`,
180
+ `Sources: ${reg.sources.map((s) => `${s.id} = ${s.name}${s.file ? ` [${s.file}]` : ""}`).join(" · ") || "none yet"}`,
181
+ ];
182
+ if (!rules.length)
183
+ return [...head, reg.rules.length ? "No rules match." : "No rules yet."].join("\n");
184
+ const shown = rules.slice(0, 200);
185
+ return [
186
+ ...head,
187
+ `${rules.length} rule(s)${rules.length > shown.length ? ` (first ${shown.length}; narrow the filter for the rest)` : ""}:`,
188
+ ...shown.map((r) => `- ${r.id}${r.stage ? ` [${r.stage}]` : ""} ${r.status}/${r.confirmation}${r.supersededBy ? ` superseded by ${r.supersededBy}` : ""}: ${r.text.replace(/\s+/g, " ").slice(0, 220)}` +
189
+ (r.citations.length ? ` — ${r.citations.map((c) => `${c.source}${c.location ? ` ${c.location}` : ""}`).join(", ")}` : " — no source")),
190
+ ].join("\n");
191
+ }
@@ -84,6 +84,7 @@ export class Store {
84
84
  allowedModels: input.allowedModels,
85
85
  coordinatorModel: input.coordinatorModel,
86
86
  docsLocation: input.docsLocation,
87
+ rulesRegister: input.rulesRegister,
87
88
  origin: input.origin,
88
89
  inspectionSummary: input.inspectionSummary,
89
90
  kickedOff: false,
@@ -93,6 +94,10 @@ export class Store {
93
94
  createdAt: Date.now(),
94
95
  };
95
96
  project.directory = directory;
97
+ // A reopened project keeps its history and team, but takes the options just chosen in the
98
+ // dialog that have no history behind them.
99
+ if (onDisk && input.rulesRegister !== undefined)
100
+ project.rulesRegister = input.rulesRegister;
96
101
  this.projects.set(project.id, project);
97
102
  await this.saveProject(project);
98
103
  await this.saveGlobal();
@@ -5,7 +5,7 @@ import { randomBytes } from "node:crypto";
5
5
  import { promises as fs } from "node:fs";
6
6
  import path from "node:path";
7
7
  import { AssetRegister } from "./assets.js";
8
- import { AGENT_PERMISSIONS, wildcardMatch } from "./guard.js";
8
+ import { AGENT_PERMISSIONS, imageReadRefusal, wildcardMatch } from "./guard.js";
9
9
  import { bus } from "./bus.js";
10
10
  import { privateDocsInstruction } from "./docs.js";
11
11
  import { parseModel } from "./opencode/client.js";
@@ -17,7 +17,7 @@ import { accountChildUsage, accountUsage, emptyUsage, sessionUsage } from "./cos
17
17
  import { AGENT_UPDATES, unseenUpdates, updatesNote } from "./updates.js";
18
18
  import { cleanBuildOutputs, dailyBackup, gb, helperFolders, lastBackup, unpushedWork } from "./housekeeping.js";
19
19
  import { coordinatorTools, workerTools } from "./tools.js";
20
- import { changedFiles, createWorkspace, inProjectFolder, isGitRepo, leaveWorkspace, mergeWorkspace, removeWorkspace, restoreWorkspace, syncWorkspace } from "./workspaces.js";
20
+ import { changedFiles, createWorkspace, inProjectFolder, isGitRepo, leaveWorkspace, mergeWorkspace, reattachWorkspace, removeWorkspace, restoreWorkspace, syncWorkspace } from "./workspaces.js";
21
21
  const STUCK_AFTER_MS = Number(process.env.SYMBIO_STUCK_MINUTES ?? 10) * 60_000;
22
22
  const WATCHDOG_INTERVAL_MS = 30_000;
23
23
  // Loop guard: max conversational messages between the same pair in the window (per
@@ -85,6 +85,8 @@ export class Team {
85
85
  limitNoticed = new Map(); // pair → when the human was last told
86
86
  failedModels = new Map(); // agentId → model → failedAt
87
87
  fallbackBusy = new Set();
88
+ /** When each agent last got a fresh session because its conversation was too large to send. */
89
+ oversizedAt = new Map();
88
90
  /** agentId → tool call id of an open interactive question. */
89
91
  pendingQuestion = new Map();
90
92
  /** Connection actions (SSH, git, GitHub API) and their approvals. Set at startup. */
@@ -268,6 +270,24 @@ export class Team {
268
270
  : "Symbio: the human set this project's helpers to work in their own copy (own branch) by default. Helpers already in the " +
269
271
  "project folder stay there.", false);
270
272
  }
273
+ /** The human turned the rules register on or off; every brief mentions it, and core is told. */
274
+ async setRulesRegister(project, on) {
275
+ if (!!project.rulesRegister === on)
276
+ return;
277
+ project.rulesRegister = on;
278
+ await this.store.update(project);
279
+ bus.publish({ type: "project.updated", projectId: project.id });
280
+ for (const agent of project.agents.filter(active))
281
+ await this.refreshInstructions(project, agent).catch(() => { });
282
+ const coordinator = this.coordinator(project);
283
+ if (!coordinator || coordinator.status === "stopped")
284
+ return;
285
+ await this.system(project, coordinator, on
286
+ ? "Symbio: the human turned on the rules register for this project. Your instructions now explain it (save_rule, list_rules…). " +
287
+ "Record rules as they come up in the work; if the project already has rules written down somewhere, offer the human to " +
288
+ "bring them into the register as a planned task."
289
+ : "Symbio: the human turned off the rules register for this project. Stop recording rules; what's recorded stays in the file.", false);
290
+ }
271
291
  /** Renames a helper. Its branch and folder keep their names; the old name keeps working. */
272
292
  async renameAgent(project, agent, newName) {
273
293
  if (agent.role === "coordinator")
@@ -954,6 +974,14 @@ export class Team {
954
974
  async onPermissionAsked(project, agent, req) {
955
975
  const action = String(req.action ?? "");
956
976
  const resources = Array.isArray(req.resources) ? req.resources.map(String) : [];
977
+ // Image files: refused at once, with what to use instead (never a question for the human).
978
+ const refusal = imageReadRefusal(action, resources);
979
+ if (refusal) {
980
+ await this.client()
981
+ .request("POST", `/api/session/${agent.sessionId}/permission/${req.id}/reply`, { decision: "reject", message: refusal })
982
+ .catch(() => { });
983
+ return;
984
+ }
957
985
  // Already answered "always" for this kind of request in this project?
958
986
  const allowed = resources.length > 0 && resources.every((r) => project.allowedPatterns.some((a) => a.action === action && wildcardMatch(a.pattern, r)));
959
987
  if (allowed) {
@@ -1236,11 +1264,29 @@ export class Team {
1236
1264
  }
1237
1265
  /** Before starting an agent's turn: make sure its tools and private docs are in place. */
1238
1266
  async prepare(project, agent) {
1267
+ await this.reattach(project, agent);
1239
1268
  await this.ensureTools(project, agent.directory);
1240
1269
  await this.syncPrivateDocs(project, agent);
1241
1270
  }
1271
+ /** A helper whose folder disappeared gets it back from its branch (see reattachWorkspace). */
1272
+ async reattach(project, agent) {
1273
+ if (agent.status === "stopped")
1274
+ return;
1275
+ const recreated = await reattachWorkspace(project, agent).catch((err) => {
1276
+ console.error(`[team] could not recreate ${agent.name}'s folder:`, err.message);
1277
+ return false;
1278
+ });
1279
+ if (!recreated)
1280
+ return;
1281
+ const text = `${agent.name}'s folder (${agent.directory}) had disappeared; Symbio recreated it from its branch ${agent.branch}. ` +
1282
+ "Everything it committed is there; changes it hadn't committed were in the folder and are gone.";
1283
+ await this.log(project, { from: "symbio", to: "human", kind: "system", text });
1284
+ await this.system(project, agent, `Symbio: ${text.replace(`${agent.name}'s folder`, "Your folder")} Check your state before continuing.`, false);
1285
+ }
1242
1286
  async refreshAll() {
1243
1287
  for (const project of this.store.list()) {
1288
+ for (const agent of project.agents.filter(active))
1289
+ await this.reattach(project, agent);
1244
1290
  const dirs = new Set(project.agents.filter(active).map((a) => a.directory));
1245
1291
  for (const dir of dirs) {
1246
1292
  await this.registerTools(project, dir).catch((err) => console.error(`[team] could not register tools for ${project.name} (${dir}):`, err.message));
@@ -1374,6 +1420,47 @@ export class Team {
1374
1420
  bus.publish({ type: "agent.stream", projectId: project.id, agentId: agent.id, event: { type: e.type, data: e.data } });
1375
1421
  }
1376
1422
  }
1423
+ /**
1424
+ * An agent whose conversation got too large to send continues in a fresh session (with the
1425
+ * usual handoff note) and is told why. Once per 15 minutes: if the fresh one fails the same
1426
+ * way, the human is told instead of looping.
1427
+ */
1428
+ async renewOversized(project, agent, message) {
1429
+ const last = this.oversizedAt.get(agent.id) ?? 0;
1430
+ if (Date.now() - last < 15 * 60_000) {
1431
+ if (agent.problem)
1432
+ return;
1433
+ agent.problem = `Its conversation is too large to send, even after a fresh session (${message}).`;
1434
+ this.setStatus(project, agent, "error");
1435
+ await this.store.update(project);
1436
+ bus.publish({ type: "project.updated", projectId: project.id });
1437
+ await this.log(project, { from: "symbio", to: "human", kind: "alert", text: `${agent.name} can't continue: ${agent.problem}` });
1438
+ return;
1439
+ }
1440
+ this.oversizedAt.set(agent.id, Date.now());
1441
+ this.fallbackBusy.add(agent.id);
1442
+ try {
1443
+ agent.problem = null;
1444
+ if (agent.status !== "idle")
1445
+ this.setStatus(project, agent, "idle");
1446
+ await this.renewSession(project, agent, "its conversation had grown too large to send");
1447
+ await this.system(project, agent, "Symbio: your previous conversation had grown too large to send to the model (the provider said: " +
1448
+ `"${message}"), so you continue in this fresh session; the handoff note above has what carries over. ` +
1449
+ "Usually it's images read in full: each one stays in the conversation and is sent again every turn. " +
1450
+ "To look at an image, read a small copy (scaled down to about 1000 px wide) or have a helper review it. " +
1451
+ "Continue where you left off.");
1452
+ }
1453
+ catch (err) {
1454
+ agent.problem = `Its conversation is too large to send (${message}), and a fresh session couldn't start: ${err.message}. Use New session in its chat.`;
1455
+ this.setStatus(project, agent, "error");
1456
+ await this.store.update(project);
1457
+ bus.publish({ type: "project.updated", projectId: project.id });
1458
+ await this.log(project, { from: "symbio", to: "human", kind: "alert", text: `${agent.name} can't continue: ${agent.problem}` });
1459
+ }
1460
+ finally {
1461
+ this.fallbackBusy.delete(agent.id);
1462
+ }
1463
+ }
1377
1464
  /**
1378
1465
  * When an agent's model fails for provider reasons (quota, outage, timeouts),
1379
1466
  * switch it to the next allowed model and tell it to continue.
@@ -1386,6 +1473,15 @@ export class Team {
1386
1473
  const status = Number(error?.status ?? 0);
1387
1474
  if (/interrupt|abort|cancel/i.test(`${type} ${message}`))
1388
1475
  return; // our own interrupts
1476
+ // The conversation itself is too big to send (seen with Copilot: "Timed out reading request
1477
+ // body", after an agent read several large images). Another model won't help; a fresh session will.
1478
+ if (status === 413 || /request body|request size|payload too large|request too large|request entity too large/i.test(message)) {
1479
+ // Still retrying the same oversized request: stop it before moving on.
1480
+ if (interruptFirst)
1481
+ await this.client().interrupt(agent.sessionId).catch(() => { });
1482
+ await this.renewOversized(project, agent, message);
1483
+ return;
1484
+ }
1389
1485
  const providerProblem = type.startsWith("provider") ||
1390
1486
  [401, 402, 403, 404, 408, 429].includes(status) ||
1391
1487
  status >= 500 ||