symbio 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +8 -2
- package/dist/server/api.js +77 -3
- package/dist/server/attachments.js +104 -0
- package/dist/server/documents.js +166 -0
- package/dist/server/guard.js +20 -7
- package/dist/server/housekeeping.js +24 -6
- package/dist/server/inspect.js +14 -1
- package/dist/server/prompts.js +54 -5
- package/dist/server/remote.js +7 -6
- package/dist/server/repos.js +136 -0
- package/dist/server/self-update.js +2 -1
- package/dist/server/team.js +79 -9
- package/dist/server/tools.js +45 -3
- package/dist/server/updates.js +34 -1
- package/dist/server/workspaces.js +37 -28
- package/dist/web/assets/index-1TakGjpe.js +55 -0
- package/dist/web/assets/index-CSPrpGKc.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/dist/web/assets/index-BmRTqm-A.css +0 -1
- package/dist/web/assets/index-Cila9fjW.js +0 -53
package/dist/server/prompts.js
CHANGED
|
@@ -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)
|
|
13
|
-
|
|
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
|
package/dist/server/remote.js
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
+
}
|
|
@@ -92,7 +92,8 @@ export function parseChangelog(text) {
|
|
|
92
92
|
}
|
|
93
93
|
return out;
|
|
94
94
|
}
|
|
95
|
-
|
|
95
|
+
// A tiny registry lookup. Every 6 hours was too slow: a release could wait most of a day.
|
|
96
|
+
const CHECK_EVERY_MS = 60 * 60_000;
|
|
96
97
|
const TICK_MS = 5 * 60_000;
|
|
97
98
|
export const QUIET_MS = 10 * 60_000; // the team must have been quiet this long before an automatic update
|
|
98
99
|
const FAILED_PAUSE_MS = 24 * 60 * 60_000; // after a failed update, don't retry the same version for a day
|
package/dist/server/team.js
CHANGED
|
@@ -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,
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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");
|
package/dist/server/tools.js
CHANGED
|
@@ -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, {
|
|
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, {
|
|
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,
|
package/dist/server/updates.js
CHANGED
|
@@ -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) {
|