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.
- package/CHANGELOG.md +31 -0
- package/README.md +8 -2
- package/bin/symbio.js +19 -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/images.js +13 -1
- 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 +22 -4
- 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-BmRTqm-A.css → index-B_s0WgkW.css} +1 -1
- package/dist/web/assets/index-VV1iU63y.js +55 -0
- package/dist/web/index.html +2 -2
- package/package.json +4 -2
- package/dist/web/assets/index-Cila9fjW.js +0 -53
package/dist/server/inspect.js
CHANGED
|
@@ -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
|
|
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
|
}
|
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
|
+
}
|
|
@@ -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
|
|
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
|
-
|
|
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")
|
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");
|