symbio 0.7.0 → 0.8.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.
@@ -2,6 +2,7 @@
2
2
  // content does it hold, and does it already have guidance for agents/humans.
3
3
  import { promises as fs, existsSync } from "node:fs";
4
4
  import path from "node:path";
5
+ import { findRepositories, swallowedRepositories } from "./repos.js";
5
6
  const KIND_BY_EXT = {};
6
7
  const add = (kind, exts) => exts.split(" ").forEach((e) => (KIND_BY_EXT[e] = kind));
7
8
  add("documents", ".md .markdown .txt .doc .docx .odt .rtf .pdf .tex .rst .adoc .epub .pages");
@@ -41,6 +42,7 @@ export async function inspectFolder(input) {
41
42
  exists: false,
42
43
  empty: true,
43
44
  isGit: false,
45
+ repos: [],
44
46
  fileCount: 0,
45
47
  totalBytes: 0,
46
48
  truncated: false,
@@ -54,6 +56,13 @@ export async function inspectFolder(input) {
54
56
  return result;
55
57
  result.exists = true;
56
58
  result.isGit = existsSync(path.join(directory, ".git"));
59
+ if (!result.isGit)
60
+ result.repos = await findRepositories(directory);
61
+ else {
62
+ const swallowed = await swallowedRepositories(directory);
63
+ if (swallowed.inner.length)
64
+ result.swallowed = swallowed;
65
+ }
57
66
  const top = await fs.readdir(directory, { withFileTypes: true }).catch(() => []);
58
67
  const visibleTop = top.filter((e) => e.name !== ".symbio");
59
68
  result.empty = visibleTop.length === 0;
@@ -121,7 +130,11 @@ export function describeInspection(i) {
121
130
  `Most common extensions: ${i.topExtensions.map((e) => `${e.ext} (${e.count})`).join(", ") || "none"}.`,
122
131
  `Top level: ${i.topLevel.join(", ")}.`,
123
132
  i.guidance.length ? `Existing guidance: ${i.guidance.join(", ")}.` : "No README, AGENTS.md or docs folder.",
124
- i.isGit ? "It is a git repository." : "It is not a git repository.",
133
+ i.isGit
134
+ ? "It is a git repository."
135
+ : i.repos.length
136
+ ? `It is not a git repository itself; it holds ${i.repos.length} git repositories: ${i.repos.join(", ")}.`
137
+ : "It is not a git repository.",
125
138
  ];
126
139
  return lines.join("\n");
127
140
  }
@@ -4,13 +4,18 @@ import { existsSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { docsPaths } from "./docs.js";
6
6
  import { PROCESS_RULES } from "./guard.js";
7
+ import { listRepositories, projectRepositoriesSync } from "./repos.js";
7
8
  // Seen live: a coordinator read eight 2–4 MB images, its conversation reached 25 MB and the
8
9
  // provider gave up receiving it ("Timed out reading request body"), whatever the model.
9
10
  const KEEP_CONVERSATION_SMALL = `- Keep your conversation small: whatever you read stays in it and is sent to
10
11
  the model again on every turn, until it becomes too large to send. Look at
11
12
  images with Symbio's \`view_image\` tool (a scaled-down copy; ask for a crop to
12
- see details) — reading image files directly is blocked. For a big file, read
13
- the part you need. Reviewing many images is work for a helper.`;
13
+ see details) and read documents (PDF, Word, Excel, PowerPoint) with
14
+ \`read_document\` — reading images and PDFs directly is blocked. For a big file,
15
+ read the part you need. Reviewing many images is work for a helper.
16
+ - Files the human attaches to a message arrive as full paths below it; open
17
+ them with the tools above. They're kept in the project, so you can come back
18
+ to them later.`;
14
19
  // OpenCode's code mode posts "tool catalog changed" notices that list only its own tools and
15
20
  // say the catalog is complete; Symbio's tools still work (seen live: a coordinator kept using
16
21
  // them after such a notice, another gave up without trying).
@@ -147,6 +152,9 @@ ${KEEP_CONVERSATION_SMALL}
147
152
  }
148
153
  /** Where helpers work, for the coordinator's tool list. */
149
154
  function helperFolders(project) {
155
+ const repos = projectRepositoriesSync(project);
156
+ if (repos.length)
157
+ return multiRepoFolders(project, repos);
150
158
  if (!existsSync(path.join(project.directory, ".git"))) {
151
159
  return " Helpers work directly in the project folder, alongside you.";
152
160
  }
@@ -159,7 +167,44 @@ function helperFolders(project) {
159
167
  - **shared**: the project folder itself. What it writes is there at once for
160
168
  you and the next helper; nothing to merge, and it doesn't commit. For steps
161
169
  that produce or check files others use next (pipelines, reviews of outputs).
162
- Helpers in the project folder claim the files they write as shared assets.`;
170
+ Helpers in the project folder claim the files they write as shared assets.${innerRepositories(project)}`;
171
+ }
172
+ /**
173
+ * Repositories inside a project that is a repository itself. Often an outer one was created on
174
+ * top of them (older Symbio versions did): their files aren't in it, so helpers' copies lack them.
175
+ */
176
+ function innerRepositories(project) {
177
+ if (existsSync(path.join(project.directory, ".gitmodules")))
178
+ return "";
179
+ const inner = listRepositories(project.directory);
180
+ if (!inner.length)
181
+ return "";
182
+ return `
183
+ Note: inside the project are other git repositories (${inner.map((r) => `\`${r}\``).join(", ")}),
184
+ with their own history. Their files aren't part of the project's repository, so
185
+ helpers' own copies don't have them, and commits in them don't belong to it.
186
+ Symbio can work in each of these repositories instead (each helper its own copy
187
+ of one): check with sub_repositories. If the outer repository was created by
188
+ Symbio and has no remote, switch (action use) and tell the human; otherwise ask
189
+ them first.`;
190
+ }
191
+ /** A folder that isn't a repository itself but holds several (e.g. client/, server/, each with its own remote). */
192
+ function multiRepoFolders(project, repos) {
193
+ const def = project.helperWorkspace === "shared" ? "shared" : "own";
194
+ return ` This project is made of **${repos.length} git repositories**, each with its own
195
+ history and remote: ${repos.map((r) => `\`${r}\``).join(", ")}. The project folder itself
196
+ ${project.subRepositories
197
+ ? "is a repository created on top of them earlier; leave it alone (don't commit\n there)."
198
+ : "is not a repository; never create one on top of them (\`git init\` there)."}
199
+ Where a helper works (\`workspace\`; this project's default is **${def}**):
200
+ - **own** with \`repo\`: its own copy of that one repository, on its own branch.
201
+ It only sees that repository's **committed** files; you merge its work into
202
+ that repository with merge_work. One helper per repository at a time is
203
+ simplest; for a change across repositories, give each part to its own helper.
204
+ - **shared**: the project folder with every repository, nothing to merge. Give
205
+ it a \`repo\` too when its work is in one of them: git_push / git_pull then
206
+ default to it.
207
+ git_push and git_pull from the project folder take \`repo\` (which repository).`;
163
208
  }
164
209
  function onboarding(project) {
165
210
  if (project.origin === "new") {
@@ -268,14 +313,18 @@ assets. Your tasks come from the coordinator.
268
313
  - Stay within your purpose and task. If something outside it needs doing, tell
269
314
  the coordinator instead of doing it.
270
315
  - ${isolated
271
- ? `You work in your own isolated copy of the project, on branch \`${agent.branch}\`.
316
+ ? `You work in your own isolated copy of ${agent.repo ? `the "${agent.repo}" repository (one of the project's several repositories)` : "the project"}, on branch \`${agent.branch}\`.
272
317
  Commit your work there with clear messages; never merge or push — the
273
318
  coordinator merges your work into the project.`
274
319
  : `You work directly in the project folder, alongside the coordinator and other
275
320
  agents: what you write is there for them at once, and theirs for you. Before
276
321
  writing files others may also write, claim them as a shared asset (below).
277
322
  Don't commit, merge or push unless the coordinator asks; don't touch files or
278
- processes that aren't yours.`}
323
+ processes that aren't yours.${agent.repo
324
+ ? `
325
+ The project folder holds several git repositories; yours is \`${agent.repo}\`. Commit
326
+ inside it (never \`git init\` the project folder); git_push / git_pull use it by default.`
327
+ : ""}`}
279
328
  - When you finish a task, or are blocked, report it once: with \`update_task\`
280
329
  for plan tasks (see below), otherwise with \`send_message\` to the coordinator
281
330
  (what you did, where, anything they should know). Then end your turn; new
@@ -129,9 +129,9 @@ export class RemoteOps {
129
129
  case "remote_upload":
130
130
  return `upload ${a.localPath} to ${conn.name}:${a.remotePath}`;
131
131
  case "git_push":
132
- return `git push ${a.remote ?? "origin"} ${a.branch ?? "(current branch)"} from ${agent.name}'s workspace using ${conn.name}`;
132
+ return `git push ${a.remote ?? "origin"} ${a.branch ?? "(current branch)"} from ${a.repo ? `the ${a.repo} repository` : `${agent.name}'s workspace`} using ${conn.name}`;
133
133
  case "git_pull":
134
- return `git pull ${a.remote ?? "origin"} ${a.branch ?? "(current branch)"} into ${agent.name}'s workspace using ${conn.name}`;
134
+ return `git pull ${a.remote ?? "origin"} ${a.branch ?? "(current branch)"} into ${a.repo ? `the ${a.repo} repository` : `${agent.name}'s workspace`} using ${conn.name}`;
135
135
  case "github_api":
136
136
  return `GitHub API ${a.method.toUpperCase()} ${a.path} using ${conn.name}`;
137
137
  case "azure_devops_api":
@@ -179,19 +179,20 @@ export class RemoteOps {
179
179
  const gitConn = conn;
180
180
  const token = await this.connections.githubToken(gitConn);
181
181
  const remote = a.remote ?? "origin";
182
- const url = remote.startsWith("https://") ? remote : await gitOut(agent.directory, ["remote", "get-url", remote]);
182
+ const cwd = a.repo ? path.join(agent.directory, ...a.repo.split("/")) : agent.directory;
183
+ const url = remote.startsWith("https://") ? remote : await gitOut(cwd, ["remote", "get-url", remote]);
183
184
  const auth = gitAuth(gitConn, url, token);
184
- const branch = a.branch ?? (await gitOut(agent.directory, ["rev-parse", "--abbrev-ref", "HEAD"]));
185
+ const branch = a.branch ?? (await gitOut(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]));
185
186
  // The agent controls this repository, so its config and hooks are untrusted: a hook
186
187
  // would see the token, a proxy or URL rewrite would send it elsewhere.
187
- await refuseRiskyGitConfig(agent.directory);
188
+ await refuseRiskyGitConfig(cwd);
188
189
  const noHooks = path.join(os.tmpdir(), `symbio-no-hooks-${randomUUID()}`); // never exists
189
190
  const safe = ["-c", `core.hooksPath=${noHooks}`, "-c", "http.proxy=", "-c", "http.sslVerify=true", "-c", "credential.helper="];
190
191
  // The token goes in an HTTP header via environment-only git config: never in
191
192
  // the command line, the repo config or the agent's view.
192
193
  const { headerKey, header } = auth;
193
194
  const git = (args, withToken) => run("git", [...safe, ...args], {
194
- cwd: agent.directory,
195
+ cwd,
195
196
  windowsHide: true,
196
197
  timeout: 5 * 60_000,
197
198
  env: {
@@ -0,0 +1,136 @@
1
+ // Projects made of several git repositories: a folder (not itself a repository) whose subfolders
2
+ // are, e.g. game/client, game/server, game/admin, each with its own remote. Symbio works inside
3
+ // each of them instead of creating a repository of its own on top.
4
+ import { existsSync, readdirSync } from "node:fs";
5
+ import path from "node:path";
6
+ import { git } from "./git.js";
7
+ // Never repositories of the project's own (tooling, dependencies, Symbio's data).
8
+ const SKIP = new Set([".git", ".symbio", "node_modules", ".venv", "venv", "__pycache__", "bin", "obj", "dist", "build", "target", ".next", ".cache", ".idea", ".vs", ".vscode"]);
9
+ /** Git repositories in subfolders (up to `depth` levels down), as relative paths with "/". A repository's own subfolders aren't searched. */
10
+ export function listRepositories(dir, depth = 2) {
11
+ const found = [];
12
+ const walk = (abs, rel, left) => {
13
+ let entries = [];
14
+ try {
15
+ entries = readdirSync(abs, { withFileTypes: true });
16
+ }
17
+ catch {
18
+ return;
19
+ }
20
+ for (const e of entries) {
21
+ if (!e.isDirectory() || SKIP.has(e.name) || e.name.startsWith("."))
22
+ continue;
23
+ const childAbs = path.join(abs, e.name);
24
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
25
+ if (existsSync(path.join(childAbs, ".git")))
26
+ found.push(childRel);
27
+ else if (left > 1)
28
+ walk(childAbs, childRel, left - 1);
29
+ }
30
+ };
31
+ walk(dir, "", depth);
32
+ return found.sort((a, b) => a.localeCompare(b));
33
+ }
34
+ export const findRepositories = async (dir, depth = 2) => listRepositories(dir, depth);
35
+ export const isGitDir = (dir) => existsSync(path.join(dir, ".git"));
36
+ /**
37
+ * The repositories a project works in, when there are several: the subfolders' repositories when
38
+ * the folder isn't one itself, or when the project was switched to them (subRepositories). Empty
39
+ * for a single repository, or no git.
40
+ */
41
+ export function projectRepositoriesSync(project) {
42
+ if (isGitDir(project.directory) && !project.subRepositories)
43
+ return [];
44
+ return listRepositories(project.directory);
45
+ }
46
+ export const projectRepositories = async (project) => projectRepositoriesSync(project);
47
+ /** The repository an agent's git work happens in: its chosen sub-repository, or the project folder. */
48
+ export function repoRoot(project, agent) {
49
+ return agent.repo ? path.join(project.directory, ...agent.repo.split("/")) : project.directory;
50
+ }
51
+ /** A repository the agent named (relative to the project), checked to be one of the project's. */
52
+ export async function resolveRepository(project, repo) {
53
+ const rel = repo.trim().replace(/\\/g, "/").replace(/^\.\/|\/+$/g, "");
54
+ const repos = await projectRepositories(project);
55
+ if (!repos.includes(rel)) {
56
+ throw new Error(repos.length
57
+ ? `"${repo}" isn't one of this project's repositories: ${repos.join(", ")}.`
58
+ : "This project isn't made of several repositories, so there's no repository to choose.");
59
+ }
60
+ return rel;
61
+ }
62
+ /**
63
+ * A repository at the top of the project that contains other repositories as bare pointers
64
+ * (gitlinks with no .gitmodules): typically one Symbio created over a folder of repositories,
65
+ * before it knew to look for them. Returns those inner repositories, or [] when it isn't the case.
66
+ */
67
+ export async function swallowedRepositories(dir) {
68
+ if (!isGitDir(dir) || existsSync(path.join(dir, ".gitmodules")))
69
+ return { inner: [], createdBySymbio: false };
70
+ const inner = await findRepositories(dir);
71
+ if (!inner.length)
72
+ return { inner: [], createdBySymbio: false };
73
+ const first = await git(dir, "log", "--reverse", "--format=%s", "--max-count=1").catch(() => "");
74
+ const root = (await git(dir, "rev-list", "--max-parents=0", "HEAD").catch(() => "")).split("\n")[0];
75
+ const firstSubject = root ? await git(dir, "log", "-1", "--format=%s", root).catch(() => first) : first;
76
+ return { inner, createdBySymbio: firstSubject.trim() === "Initial snapshot (created by Symbio)" };
77
+ }
78
+ const samePath = (a, b) => {
79
+ const norm = (p) => (process.platform === "win32" ? path.resolve(p).toLowerCase() : path.resolve(p));
80
+ return norm(a) === norm(b);
81
+ };
82
+ /**
83
+ * Which repository git_push / git_pull act on, relative to the project folder (null = the
84
+ * agent's own folder). Helpers in their own copy push that copy. Agents in the project folder of
85
+ * a project made of several repositories name one (shared helpers default to the one they were given).
86
+ */
87
+ export async function gitRepoFor(project, agent, repo) {
88
+ if (!samePath(agent.directory, project.directory)) {
89
+ if (repo)
90
+ throw new Error("You work in your own copy of a repository: push and pull apply to it, without repo.");
91
+ return null;
92
+ }
93
+ if (repo)
94
+ return resolveRepository(project, repo);
95
+ const repos = await projectRepositories(project);
96
+ if (!repos.length)
97
+ return null;
98
+ if (agent.repo)
99
+ return agent.repo;
100
+ throw new Error(`This project is made of several repositories (${repos.join(", ")}); say which one with repo.`);
101
+ }
102
+ /**
103
+ * What the coordinator needs to decide whether to switch a project to its inner repositories:
104
+ * who created the outer one, whether it has a remote, its history, and helpers working in copies of it.
105
+ */
106
+ export async function describeOuterRepository(project) {
107
+ const dir = project.directory;
108
+ const { inner, createdBySymbio } = await swallowedRepositories(dir);
109
+ if (!inner.length) {
110
+ return project.subRepositories
111
+ ? "Symbio works in this project's inner repositories, but none were found now."
112
+ : "The project folder doesn't contain other git repositories (or tracks them as submodules): nothing to switch.";
113
+ }
114
+ const remotes = (await git(dir, "remote").catch(() => "")).split("\n").map((r) => r.trim()).filter(Boolean);
115
+ const count = Number(await git(dir, "rev-list", "--count", "HEAD").catch(() => "0")) || 0;
116
+ const recent = (await git(dir, "log", "-5", "--format=%s").catch(() => "")).split("\n").filter(Boolean);
117
+ const copies = project.agents.filter((a) => a.role === "worker" && a.status !== "stopped" && a.branch && !a.repo);
118
+ return [
119
+ `The project folder is a git repository that contains ${inner.length} other repositories (${inner.join(", ")}) without tracking ` +
120
+ "their files (bare pointers, not submodules).",
121
+ createdBySymbio
122
+ ? "The outer repository was created by Symbio when the project was added (its first commit is Symbio's snapshot)."
123
+ : "The outer repository was not created by Symbio: someone made it on purpose.",
124
+ remotes.length ? `Its remotes: ${remotes.join(", ")}.` : "It has no remote.",
125
+ `Its history: ${count} commit(s)${recent.length ? `; latest: ${recent.map((s) => `"${s}"`).join(", ")}` : ""}.`,
126
+ copies.length
127
+ ? `Helpers with their own copy of the outer repository: ${copies.map((a) => `${a.name} (${a.status})`).join(", ")}.`
128
+ : "No active helper has its own copy of the outer repository.",
129
+ project.subRepositories
130
+ ? "Now: Symbio works in the inner repositories and leaves the outer one alone."
131
+ : "Now: Symbio works in the outer repository, so helpers' own copies have the inner repositories as empty folders.",
132
+ "Switching (action use) changes nothing on disk: the outer .git and every file stay where they are. From then on a helper's own " +
133
+ "copy comes from one inner repository (spawn_agent repo), merges and pushes go to it, and the outer repository is left alone. " +
134
+ "Action stop switches back.",
135
+ ].join("\n");
136
+ }
@@ -45,15 +45,26 @@ export function compareVersions(a, b) {
45
45
  }
46
46
  // Overridable for tests (a local registry and package file); never set in normal use.
47
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() {
48
+ /** The version npm installs as `symbio@latest`, and the Node.js it needs. */
49
+ export async function latestRelease() {
50
50
  const res = await fetch(`${REGISTRY}/symbio/latest`, { signal: AbortSignal.timeout(15_000), headers: { accept: "application/json" } });
51
51
  if (!res.ok)
52
52
  throw new Error(`npm registry answered ${res.status}`);
53
53
  const data = (await res.json());
54
54
  if (!data.version)
55
55
  throw new Error("npm registry gave no version");
56
- return data.version;
56
+ return { version: data.version, node: data.engines?.node ?? null };
57
+ }
58
+ export async function latestVersion() {
59
+ return (await latestRelease()).version;
60
+ }
61
+ /**
62
+ * Whether this Node.js meets a release's requirement (">=22", ">=20.9.0"; anything fancier is
63
+ * assumed met). npm only warns about it, and the new version would then fail to start.
64
+ */
65
+ export function nodeSatisfies(range, version = process.versions.node) {
66
+ const m = range && /^\s*>=\s*v?(\d+(?:\.\d+){0,2})\s*$/.exec(range);
67
+ return !m || compareVersions(version, m[1].split(".").concat(["0", "0"]).slice(0, 3).join(".")) >= 0;
57
68
  }
58
69
  /** Changelog entries newer than `current`, from the latest version's CHANGELOG.md (best effort). */
59
70
  export async function changesSince(current, latest) {
@@ -88,6 +99,8 @@ const FAILED_PAUSE_MS = 24 * 60 * 60_000; // after a failed update, don't retry
88
99
  export class SelfUpdate {
89
100
  host;
90
101
  latest = null;
102
+ /** The Node.js the latest version needs (its package.json "engines"). */
103
+ latestNode = null;
91
104
  checkedAt = null;
92
105
  error = null;
93
106
  notes = [];
@@ -158,7 +171,9 @@ export class SelfUpdate {
158
171
  if (this.mode === "off" && this.checkedAt)
159
172
  return this.state();
160
173
  try {
161
- this.latest = await latestVersion();
174
+ const release = await latestRelease();
175
+ this.latest = release.version;
176
+ this.latestNode = release.node;
162
177
  this.error = null;
163
178
  this.notes = compareVersions(this.latest, this.host.version) > 0 ? await changesSince(this.host.version, this.latest) : [];
164
179
  }
@@ -172,6 +187,9 @@ export class SelfUpdate {
172
187
  blocker(forced) {
173
188
  if (this.install.kind === "dev")
174
189
  return "This Symbio runs from a development folder; update it with git pull, npm install and npm run build.";
190
+ if (!nodeSatisfies(this.latestNode)) {
191
+ return `Symbio ${this.latest} needs Node.js ${this.latestNode}, and this machine has ${process.versions.node}: update Node.js first (https://nodejs.org), then update Symbio.`;
192
+ }
175
193
  if (this.applying)
176
194
  return "Already updating.";
177
195
  if (!forced && this.mode === "notify")
@@ -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, imageReadRefusal, wildcardMatch } from "./guard.js";
8
+ import { AGENT_PERMISSIONS, readRefusal, 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,6 +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 { listRepositories, projectRepositories, repoRoot, resolveRepository } from "./repos.js";
20
21
  import { changedFiles, createWorkspace, inProjectFolder, isGitRepo, leaveWorkspace, mergeWorkspace, reattachWorkspace, removeWorkspace, restoreWorkspace, syncWorkspace } from "./workspaces.js";
21
22
  const STUCK_AFTER_MS = Number(process.env.SYMBIO_STUCK_MINUTES ?? 10) * 60_000;
22
23
  const WATCHDOG_INTERVAL_MS = 30_000;
@@ -151,9 +152,9 @@ export class Team {
151
152
  const buildBytes = folders.reduce((s, f) => s + f.buildBytes, 0);
152
153
  const parts = [];
153
154
  if (branches.length) {
154
- parts.push(`Work that exists only on this machine (not on the remote "${remote}"):\n` +
155
+ parts.push(`Work that exists only on this machine (not on ${remote === "each repository's remote" ? "each repository's remote" : `the remote "${remote}"`}):\n` +
155
156
  branches.map((b) => `- ${b.branch}: ${b.commits} commit(s), latest "${b.latest}"`).join("\n") +
156
- `\nIf it's ready to share, push it (git push ${remote} <branch>); if a branch shouldn't go to the remote, tell the human.`);
157
+ `\nIf it's ready to share, push it (git_push; in a project of several repositories, with repo); if a branch shouldn't go to the remote, tell the human.`);
157
158
  }
158
159
  if (buildBytes > BUILD_ALERT_BYTES) {
159
160
  parts.push(`Helpers' folders hold ${gb(buildBytes)} of build outputs:\n` +
@@ -298,6 +299,40 @@ export class Team {
298
299
  "project folder stay there.", false);
299
300
  }
300
301
  /** The human turned the rules register on or off; every brief mentions it, and core is told. */
302
+ /**
303
+ * Works in the inner repositories of a repository created over them (on), or back in the outer
304
+ * one (off). Refused while active helpers have copies of the repository being left: their work
305
+ * would have nowhere to be merged.
306
+ */
307
+ async useSubRepositories(project, on, by) {
308
+ if (!!project.subRepositories === on) {
309
+ return on ? "Symbio already works in the inner repositories." : "Symbio already works in the outer repository.";
310
+ }
311
+ if (on && !listRepositories(project.directory).length)
312
+ throw new Error("The project folder doesn't contain other git repositories.");
313
+ const leaving = project.agents.filter((a) => a.role === "worker" && a.status !== "stopped" && a.branch && (on ? !a.repo : !!a.repo));
314
+ if (leaving.length) {
315
+ throw new Error(`${leaving.map((a) => a.name).join(", ")} ${leaving.length === 1 ? "has its" : "have their"} own copy of ${on ? "the outer repository" : "an inner repository"}. ` +
316
+ `Merge their work, then move them to the project folder (${by === "the human" ? "from their chat" : "move_to_project_folder"}) or stop them, and switch again.`);
317
+ }
318
+ project.subRepositories = on;
319
+ await this.store.update(project);
320
+ bus.publish({ type: "project.updated", projectId: project.id });
321
+ for (const agent of project.agents.filter(active))
322
+ await this.refreshInstructions(project, agent).catch(() => { });
323
+ const repos = listRepositories(project.directory);
324
+ await this.log(project, {
325
+ from: "symbio",
326
+ to: "human",
327
+ kind: "system",
328
+ text: on
329
+ ? `Symbio now works in this project's repositories (${repos.join(", ")}) instead of the repository on top of them (switched by ${by}). Nothing on disk changed; Settings → Team switches back.`
330
+ : `Symbio works in the project folder's own repository again (switched by ${by}).`,
331
+ });
332
+ return on
333
+ ? `Done: helpers' own copies now come from one of ${repos.join(", ")} (spawn_agent repo), merges and pushes go there. Your instructions were updated.`
334
+ : "Done: Symbio works in the outer repository again. Your instructions were updated.";
335
+ }
301
336
  async setRulesRegister(project, on) {
302
337
  if (!!project.rulesRegister === on)
303
338
  return;
@@ -534,7 +569,15 @@ export class Team {
534
569
  if (!project.allowedModels.includes(model)) {
535
570
  throw new Error(`Model "${model}" is not allowed for this project. Allowed: ${project.allowedModels.join(", ")}`);
536
571
  }
537
- const workspace = await createWorkspace(project, name, opts.workspace ?? project.helperWorkspace ?? "own");
572
+ // A folder of several repositories: an own copy comes from one of them, so it must be named.
573
+ const mode = opts.workspace ?? project.helperWorkspace ?? "own";
574
+ const repo = opts.repo?.trim() ? await resolveRepository(project, opts.repo) : undefined;
575
+ const repos = await projectRepositories(project);
576
+ if (repos.length && mode === "own" && !repo) {
577
+ throw new Error(`This project is made of several repositories (${repos.join(", ")}). Say which one the helper works in with repo ` +
578
+ "(it gets its own copy of that repository), or use workspace \"shared\" to have it work in the project folder.");
579
+ }
580
+ const workspace = await createWorkspace(project, name, mode, repo);
538
581
  const client = this.client();
539
582
  await this.registerTools(project, workspace.directory);
540
583
  const session = await client.createSession({
@@ -550,6 +593,7 @@ export class Team {
550
593
  purpose: opts.purpose,
551
594
  sessionId: session.id,
552
595
  directory: workspace.directory,
596
+ ...(repo ? { repo } : {}),
553
597
  branch: workspace.branch,
554
598
  base: workspace.base,
555
599
  workSince: Date.now(),
@@ -567,8 +611,8 @@ export class Team {
567
611
  else
568
612
  await this.deliver(project, coordinator, agent, "request", opts.task, { protocol: true });
569
613
  const where = workspace.branch
570
- ? `It works in its own copy of the project on branch ${workspace.branch} (${workspace.directory}); merge its work with merge_work.`
571
- : (await isGitRepo(project.directory))
614
+ ? `It works in its own copy of ${repo ? `the "${repo}" repository` : "the project"} on branch ${workspace.branch} (${workspace.directory}); merge its work with merge_work.`
615
+ : (await isGitRepo(project.directory)) || repos.length
572
616
  ? "It works in the project folder itself, alongside you: what it writes is there at once, and there is nothing to merge. " +
573
617
  "Shared-asset grants keep it from colliding with others; it doesn't commit unless you ask."
574
618
  : "It works directly in the project folder (no git), so rely on shared-asset grants to avoid conflicts.";
@@ -783,7 +827,9 @@ export class Team {
783
827
  async mergeWork(project, agent, force) {
784
828
  const { committed, uncommitted } = await changedFiles(agent);
785
829
  const reg = await this.assets(project);
786
- const violations = reg.violations([...committed, ...uncommitted], agent.id, agent.workSince ?? agent.createdAt);
830
+ // Asset patterns are relative to the project folder; a sub-repository's paths are relative to it.
831
+ const inProject = (f) => (agent.repo ? `${agent.repo}/${f}` : f);
832
+ const violations = reg.violations([...committed, ...uncommitted].map(inProject), agent.id, agent.workSince ?? agent.createdAt);
787
833
  if (violations.length && !force) {
788
834
  const list = violations.map((v) => `- "${v.asset.name}": ${v.files.join(", ")}`).join("\n");
789
835
  return `Not merged: ${agent.name} changed shared assets it was not granted:\n${list}\nReview the changes, then grant the asset and merge again, ask ${agent.name} to revert them, or merge with force=true.`;
@@ -983,7 +1029,9 @@ export class Team {
983
1029
  }
984
1030
  if (project.agents.some((a) => a.branch)) {
985
1031
  await fs.rm(path.join(globalDir, "worktrees", project.id), { recursive: true, force: true }).catch(() => { });
986
- await git(project.directory, "worktree", "prune").catch(() => { });
1032
+ const roots = new Set(project.agents.filter((a) => a.branch).map((a) => repoRoot(project, a)));
1033
+ for (const root of roots)
1034
+ await git(root, "worktree", "prune").catch(() => { });
987
1035
  }
988
1036
  this.registers.delete(project.id);
989
1037
  this.plans.delete(project.id);
@@ -1002,7 +1050,7 @@ export class Team {
1002
1050
  const action = String(req.action ?? "");
1003
1051
  const resources = Array.isArray(req.resources) ? req.resources.map(String) : [];
1004
1052
  // Image files: refused at once, with what to use instead (never a question for the human).
1005
- const refusal = imageReadRefusal(action, resources);
1053
+ const refusal = readRefusal(action, resources);
1006
1054
  if (refusal) {
1007
1055
  await this.client()
1008
1056
  .request("POST", `/api/session/${agent.sessionId}/permission/${req.id}/reply`, { decision: "reject", message: refusal })
@@ -1339,6 +1387,12 @@ export class Team {
1339
1387
  await this.log(project, { from: "symbio", to: coordinator.id, kind: "system", text: note });
1340
1388
  project.updatesSeen = [...(project.updatesSeen ?? []), ...updates.map((u) => u.id)];
1341
1389
  await this.store.update(project);
1390
+ // Some changes ask for action in the projects they apply to: those wake core up with a task.
1391
+ for (const u of updates) {
1392
+ const task = await u.task?.(project).catch(() => null);
1393
+ if (task)
1394
+ await this.system(project, coordinator, task);
1395
+ }
1342
1396
  }
1343
1397
  /** Private docs aren't visible to OpenCode on its own; inject them, but only when they changed. */
1344
1398
  async syncPrivateDocs(project, agent) {
@@ -1554,11 +1608,19 @@ export class Team {
1554
1608
  setStatus(project, agent, status) {
1555
1609
  if (agent.status === status)
1556
1610
  return;
1611
+ // A message arriving while the agent waits on the human (a question, a permission request) is
1612
+ // queued until it's answered. Turning "busy" here hid the question from the chat and let the
1613
+ // watchdog interrupt it as a hang ten minutes later.
1614
+ if (status === "busy" && agent.status === "waiting" && this.waitsOnHuman(project, agent))
1615
+ return;
1557
1616
  agent.status = status;
1558
1617
  agent.lastActivity = Date.now();
1559
1618
  bus.publish({ type: "agent.status", projectId: project.id, agentId: agent.id, status });
1560
1619
  void this.store.update(project);
1561
1620
  }
1621
+ waitsOnHuman(project, agent) {
1622
+ return this.pendingQuestion.has(agent.id) || this.permissionRequests(project).some((p) => p.agentId === agent.id);
1623
+ }
1562
1624
  /** An agent that has been "busy" without any event for too long is hung: interrupt and nudge it. */
1563
1625
  async checkStuck() {
1564
1626
  if (this.runtime.state !== "running")
@@ -1568,6 +1630,14 @@ export class Team {
1568
1630
  for (const agent of project.agents) {
1569
1631
  if (agent.status !== "busy" || now - agent.lastActivity < STUCK_AFTER_MS)
1570
1632
  continue;
1633
+ // Silent because it's waiting for the human's answer (an event may have been missed): not a hang.
1634
+ const forms = await this.forms(agent).catch(() => []);
1635
+ if (forms.length) {
1636
+ if (forms[0]?.id)
1637
+ this.pendingQuestion.set(agent.id, String(forms[0].id));
1638
+ this.setStatus(project, agent, "waiting");
1639
+ continue;
1640
+ }
1571
1641
  const minutes = Math.round((now - agent.lastActivity) / 60_000);
1572
1642
  console.warn(`[watchdog] ${project.name}/${agent.name} silent for ${minutes} min, interrupting`);
1573
1643
  this.setStatus(project, agent, "stuck");