symbio 0.7.1 → 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.
@@ -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
+ }
@@ -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");
@@ -4,6 +4,8 @@
4
4
  import { describeTask, TASK_STATUSES } from "./plan.js";
5
5
  import { CONFIRMATIONS, listRules, saveArea, saveRule, saveSource, STATUSES } from "./rules.js";
6
6
  import { DEFAULT_IMAGE_SIZE, viewImage } from "./images.js";
7
+ import { readDocument } from "./documents.js";
8
+ import { describeOuterRepository, gitRepoFor } from "./repos.js";
7
9
  const str = (description) => ({ type: "string", description });
8
10
  function obj(properties, required = []) {
9
11
  return { type: "object", properties, required, additionalProperties: false };
@@ -45,7 +47,7 @@ const listTeam = {
45
47
  inputSchema: obj({}),
46
48
  run(_args, { project }) {
47
49
  return project.agents
48
- .map((a) => `- ${a.name} (${a.role}${a.status === "stopped" ? ", stopped" : `, ${a.status}`}) — ${a.purpose ?? "coordinates the project"} · model ${a.model}`)
50
+ .map((a) => `- ${a.name} (${a.role}${a.status === "stopped" ? ", stopped" : `, ${a.status}`}) — ${a.purpose ?? "coordinates the project"}${a.repo ? ` · repository ${a.repo}` : ""} · model ${a.model}`)
49
51
  .join("\n");
50
52
  },
51
53
  };
@@ -102,10 +104,12 @@ const spawnAgent = {
102
104
  task: str("Its first task as text (when there is no plan task), or extra instructions for task_id"),
103
105
  model: str("Optional provider/model from the allowed list; defaults to your own model"),
104
106
  workspace: { type: "string", enum: ["own", "shared"], description: "Where it works (git projects); defaults to the project's setting" },
107
+ repo: str("In a project made of several repositories: the one it works in (e.g. 'server'); its own copy comes from it"),
105
108
  }, ["name", "purpose"]),
106
109
  async run(args, { team, project, agent }) {
107
110
  return team.spawnWorker(project, agent, {
108
111
  workspace: args.workspace === "own" || args.workspace === "shared" ? args.workspace : undefined,
112
+ repo: args.repo ? String(args.repo) : undefined,
109
113
  name: String(args.name),
110
114
  purpose: String(args.purpose),
111
115
  task: args.task ? String(args.task) : undefined,
@@ -333,19 +337,32 @@ const gitFields = {
333
337
  connection: str("GitHub or Azure DevOps connection name"),
334
338
  remote: str("Remote name or HTTPS URL (github.com, dev.azure.com) (default: origin)"),
335
339
  branch: str("Branch (default: your current branch)"),
340
+ repo: str("In a project made of several repositories, working in the project folder: which one (e.g. 'server')"),
336
341
  reason: reasonField,
337
342
  };
338
343
  const gitPush = {
339
344
  name: "git_push",
340
345
  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
346
  inputSchema: obj(gitFields, ["connection", "reason"]),
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)),
347
+ run: async ({ connection, remote, branch, repo, reason }, { team, project, agent }) => team.remote.request(project, agent, {
348
+ kind: "git_push",
349
+ connectionId: String(connection),
350
+ remote: remote ? String(remote) : null,
351
+ branch: branch ? String(branch) : null,
352
+ repo: await gitRepoFor(project, agent, repo ? String(repo) : undefined),
353
+ }, String(reason)),
343
354
  };
344
355
  const gitPull = {
345
356
  name: "git_pull",
346
357
  description: "Pull from GitHub or Azure DevOps into your working folder (merge, no rebase).",
347
358
  inputSchema: obj(gitFields, ["connection", "reason"]),
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)),
359
+ run: async ({ connection, remote, branch, repo, reason }, { team, project, agent }) => team.remote.request(project, agent, {
360
+ kind: "git_pull",
361
+ connectionId: String(connection),
362
+ remote: remote ? String(remote) : null,
363
+ branch: branch ? String(branch) : null,
364
+ repo: await gitRepoFor(project, agent, repo ? String(repo) : undefined),
365
+ }, String(reason)),
349
366
  };
350
367
  const githubApi = {
351
368
  name: "github_api",
@@ -383,6 +400,18 @@ function registerOn(project) {
383
400
  throw new Error("This project doesn't keep a rules register (the human can turn it on in the project's Settings).");
384
401
  }
385
402
  // ---- images
403
+ const readDocumentTool = {
404
+ name: "read_document",
405
+ description: "Read the text of a document: PDF (by pages), Word (.docx, .odt), Excel (.xlsx, every sheet as tab-separated rows), " +
406
+ "PowerPoint (.pptx, by slide) or a long text file. Returns a part at a time and says how to get the next one. " +
407
+ "Reading PDFs with the read tool is blocked (it would put the whole file in your conversation), and Office files are binary to it.",
408
+ inputSchema: obj({
409
+ path: str("The document (absolute, or relative to your folder)"),
410
+ pages: str('PDF pages to read, e.g. "1-10" or "7" (default: from the first page, as many as fit)'),
411
+ from: { type: "integer", description: "For other documents: the character to start from (given in the previous answer)" },
412
+ }, ["path"]),
413
+ run: (args, { agent }) => readDocument(agent.directory, args),
414
+ };
386
415
  const viewImageTool = {
387
416
  name: "view_image",
388
417
  description: "Look at an image (png, jpg, gif, webp, bmp): returns a scaled-down copy, so it doesn't bloat your conversation. " +
@@ -468,6 +497,7 @@ const saveRuleSourceTool = {
468
497
  };
469
498
  const COMMON = [
470
499
  viewImageTool,
500
+ readDocumentTool,
471
501
  listRulesTool,
472
502
  saveRuleTool,
473
503
  saveRuleAreaTool,
@@ -491,7 +521,19 @@ const COMMON = [
491
521
  listProcesses,
492
522
  stopProcess,
493
523
  ];
524
+ const subRepositories = {
525
+ name: "sub_repositories",
526
+ description: "For a project folder that is a git repository containing other repositories (often created on top of them by an older Symbio): " +
527
+ "check the situation, switch to working in the inner repositories (use), or back to the outer one (stop). Switching changes nothing on disk.",
528
+ inputSchema: obj({ action: { type: "string", enum: ["check", "use", "stop"], description: "check (default), use or stop" } }),
529
+ async run({ action }, { team, project }) {
530
+ if (action === "use" || action === "stop")
531
+ return team.useSubRepositories(project, action === "use", "the coordinator");
532
+ return describeOuterRepository(project);
533
+ },
534
+ };
494
535
  const COORDINATOR_ONLY = [
536
+ subRepositories,
495
537
  submitPlan,
496
538
  addTask,
497
539
  assignTask,
@@ -3,7 +3,9 @@
3
3
  // (it isn't woken up for it), and the human sees the same list in Messages.
4
4
  //
5
5
  // Add an entry whenever a change affects how agents work: tools, rules, messages they
6
- // receive, what Symbio does on their behalf. Ids never change; keep the newest last.
6
+ // receive, what Symbio does on their behalf. Ids never change; keep the newest last. An entry
7
+ // whose change asks for action in some projects can add a task: those coordinators are woken with it.
8
+ import { swallowedRepositories } from "./repos.js";
7
9
  export const AGENT_UPDATES = [
8
10
  {
9
11
  id: "2026-10-30-free-assets",
@@ -91,6 +93,37 @@ export const AGENT_UPDATES = [
91
93
  "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
94
  "(GET) never needs approval. If a push or a pull request failed for lack of credentials, ask the human to add the connection.",
93
95
  },
96
+ {
97
+ id: "2026-11-09-attachments",
98
+ text: "Attachments and documents: the human can now attach files to a message (upload, drag and drop, paste). They arrive as full " +
99
+ "paths below the message and are kept in the project (.symbio/attachments; the human sees them in the Files tab), so you can " +
100
+ "come back to them. Look at images with view_image; read documents (PDF, Word, Excel, PowerPoint) with the new read_document " +
101
+ "tool, which returns their text a part at a time. Reading PDFs with the read tool is now refused like images: it would put the " +
102
+ "whole file in your conversation.",
103
+ },
104
+ {
105
+ id: "2026-11-09-multi-repo",
106
+ text: "Folders of several git repositories (e.g. client/ and server/, each with its own remote, in a folder that isn't a repository " +
107
+ "itself) are now handled as such: spawn_agent takes repo (the helper gets its own copy of that repository, and merge_work merges " +
108
+ "into it), and git_push / git_pull from the project folder take repo. Never git init the project folder over them.",
109
+ async task(project) {
110
+ if (project.subRepositories !== undefined)
111
+ return null;
112
+ const { inner, createdBySymbio } = await swallowedRepositories(project.directory);
113
+ if (!inner.length)
114
+ return null;
115
+ return (`Symbio update, for this project: the project folder is a git repository that contains other repositories (${inner.join(", ")}) ` +
116
+ `without their files${createdBySymbio ? " (Symbio created it on top of them when the project was added)" : ""}. So far helpers' own ` +
117
+ "copies had those repositories as empty folders, their commits went to the outer repository, and pushes had no remote of their own. " +
118
+ "Symbio can now work in each inner repository instead.\n\n" +
119
+ "Look at it with sub_repositories (action check). If the outer repository was created by Symbio and has no remote, switch on " +
120
+ "your own (action use): nothing on disk changes and it can be switched back. Before switching, bring any unmerged work of helpers " +
121
+ "with their own copy of the outer repository into the project (merge_work), then move them to the project folder or stop them. " +
122
+ "Don't interrupt work in progress: if helpers are busy, do it once they've finished. Then tell the human in a sentence or two what " +
123
+ "changed for them. If instead the outer repository has a remote or wasn't created by Symbio, someone may rely on it: explain the " +
124
+ "situation to the human briefly and ask whether to switch.");
125
+ },
126
+ },
94
127
  ];
95
128
  /** Entries a project hasn't been told about yet. */
96
129
  export function unseenUpdates(seen) {