symbio 0.3.0-beta.2 → 0.4.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 +15 -0
- package/README.md +3 -2
- package/dist/server/api.js +14 -0
- package/dist/server/index.js +3 -0
- package/dist/server/opencode/client.js +1 -0
- package/dist/server/prompts.js +43 -7
- package/dist/server/team.js +143 -41
- package/dist/server/tools.js +25 -5
- package/dist/server/updates.js +18 -0
- package/dist/server/workspaces.js +29 -5
- package/dist/web/assets/index-DhB0EhFQ.js +52 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-CrEYaqhD.js +0 -52
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,21 @@ versions follow [Semantic Versioning](https://semver.org/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.4.0]
|
|
9
|
+
|
|
10
|
+
- Helpers can work in the project folder itself instead of their own copy on a branch: what one
|
|
11
|
+
writes, the next sees at once, with nothing to merge (for pipelines and step-by-step work).
|
|
12
|
+
Settings → Team sets the default for new helpers; core can choose per helper; existing helpers
|
|
13
|
+
can be moved into the project folder from their chat (or by core) once their work is merged.
|
|
14
|
+
- Background processes belong to the agent that started them: in a shared folder, agents only
|
|
15
|
+
see and stop their own, and stopping a helper stops only its processes.
|
|
16
|
+
- Agents are told that OpenCode's "tool catalog changed" notices don't list Symbio's tools, so they
|
|
17
|
+
keep using them instead of reporting them missing.
|
|
18
|
+
- update_task accepts summary as well as note, so review notes aren't lost.
|
|
19
|
+
- Symbio no longer crashes when OpenCode is restarted (to free memory) while it was reading usage;
|
|
20
|
+
errors in background work are logged instead of stopping the app.
|
|
21
|
+
- Long chats: asking for a 200-message page no longer fails.
|
|
22
|
+
|
|
8
23
|
## [0.3.0]
|
|
9
24
|
|
|
10
25
|
- Remote access: `sym remote` (or Settings → Remote access) opens a temporary Cloudflare link with an
|
package/README.md
CHANGED
|
@@ -55,7 +55,8 @@ place. Here Symbio was restarted mid-task, and it told the helper to pick up whe
|
|
|
55
55
|
- **One conversation, a whole team.** You chat with core. It asks what you want, studies an
|
|
56
56
|
existing folder before touching it, and proposes a plan of tasks for you to approve.
|
|
57
57
|
- **Helpers that work in parallel without collisions.** Each helper gets its own git worktree and
|
|
58
|
-
branch
|
|
58
|
+
branch, and core reviews and merges their work. For step-by-step work (each step using the
|
|
59
|
+
previous one's files), helpers can share the project folder instead, with nothing to merge. Helpers are only created when work can really run in
|
|
59
60
|
parallel; one helper is the default.
|
|
60
61
|
- **Agents talk to each other.** Messages go straight from agent to agent through Symbio, and
|
|
61
62
|
delivering a message wakes the recipient. No copy-pasting between terminals.
|
|
@@ -147,7 +148,7 @@ itself. Whoever gets in controls your agents, so keep the link and code to yours
|
|
|
147
148
|
| Term | Meaning |
|
|
148
149
|
|---|---|
|
|
149
150
|
| **core** | The project's lead agent. It plans, delegates, reviews and merges; it coordinates rather than doing the work itself. |
|
|
150
|
-
| **Helper** | An agent core creates for a piece of work. It works in its own git worktree (branch `symbio/<name>`). |
|
|
151
|
+
| **Helper** | An agent core creates for a piece of work. It works in its own git worktree (branch `symbio/<name>`) or, if the project is set to, in the project folder itself. |
|
|
151
152
|
| **Shared asset** | Something more than one agent could change. Access goes through core: request, grant, release. |
|
|
152
153
|
| **Connection** | A GitHub account or SSH server agents can use through Symbio, without ever seeing its credentials. |
|
|
153
154
|
| **Allowed models** | The models a project's agents may run on. If one fails, agents fall back to the next. |
|
package/dist/server/api.js
CHANGED
|
@@ -291,6 +291,8 @@ export function createApi(store, runtime, team, connections) {
|
|
|
291
291
|
api.patch("/projects/:id", async (c) => {
|
|
292
292
|
const project = withProject(c.req.param("id"));
|
|
293
293
|
const body = await c.req.json();
|
|
294
|
+
if (body.helperWorkspace === "own" || body.helperWorkspace === "shared")
|
|
295
|
+
await team.setHelperWorkspace(project, body.helperWorkspace);
|
|
294
296
|
if (body.name?.trim())
|
|
295
297
|
project.name = body.name.trim();
|
|
296
298
|
if (body.renewSessions === "manual" || body.renewSessions === "after-task")
|
|
@@ -501,6 +503,18 @@ export function createApi(store, runtime, team, connections) {
|
|
|
501
503
|
return c.json({ error: err.message }, 400);
|
|
502
504
|
}
|
|
503
505
|
});
|
|
506
|
+
// Moves a helper from its own copy (branch) into the project folder.
|
|
507
|
+
api.post("/projects/:id/agents/:agentId/move-to-project-folder", async (c) => {
|
|
508
|
+
const project = withProject(c.req.param("id"));
|
|
509
|
+
const agent = withAgent(project, c.req.param("agentId"));
|
|
510
|
+
try {
|
|
511
|
+
await team.moveToProjectFolder(project, agent, "moved by the human");
|
|
512
|
+
return c.json(project);
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
return c.json({ error: err.message }, 400);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
504
518
|
// A fresh session for a long-running agent (keeps its identity; the old session stays readable).
|
|
505
519
|
api.post("/projects/:id/agents/:agentId/renew", async (c) => {
|
|
506
520
|
const project = withProject(c.req.param("id"));
|
package/dist/server/index.js
CHANGED
|
@@ -16,6 +16,9 @@ import { OpencodeRuntime } from "./opencode/runtime.js";
|
|
|
16
16
|
import { Store, globalDir } from "./store.js";
|
|
17
17
|
import { Team } from "./team.js";
|
|
18
18
|
import { removeOrphanWorkspaces } from "./workspaces.js";
|
|
19
|
+
// Background work (timers, event handlers) must never take the whole app down: an error there
|
|
20
|
+
// (e.g. OpenCode restarting mid-call) is logged and the next tick tries again.
|
|
21
|
+
process.on("unhandledRejection", (err) => console.error("[unhandled]", err instanceof Error ? err.stack ?? err.message : err));
|
|
19
22
|
const PORT = Number(process.env.SYMBIO_PORT ?? 4310);
|
|
20
23
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
21
24
|
// dist/server/index.js → dist/web ; src/server/index.ts → dist/web (after a build)
|
|
@@ -101,6 +101,7 @@ export class OpencodeClient {
|
|
|
101
101
|
* "older than id" (verified against 2.1.4, see docs/opencode-api.md).
|
|
102
102
|
*/
|
|
103
103
|
async messagePage(sessionId, limit, before) {
|
|
104
|
+
limit = Math.min(limit, 199); // OpenCode refuses more than 200, and we ask for one extra
|
|
104
105
|
const params = new URLSearchParams({ limit: String(limit + 1) }); // one extra tells whether more exist
|
|
105
106
|
if (before)
|
|
106
107
|
params.set("cursor", Buffer.from(JSON.stringify({ id: before, order: "desc", direction: "next" })).toString("base64url"));
|
package/dist/server/prompts.js
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
// Briefings injected into agent sessions as instruction entries.
|
|
2
2
|
// Keep these domain-neutral: projects can be software, writing, research, design, anything.
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
3
5
|
import { docsPaths } from "./docs.js";
|
|
4
6
|
import { PROCESS_RULES } from "./guard.js";
|
|
7
|
+
// OpenCode's code mode posts "tool catalog changed" notices that list only its own tools and
|
|
8
|
+
// say the catalog is complete; Symbio's tools still work (seen live: a coordinator kept using
|
|
9
|
+
// them after such a notice, another gave up without trying).
|
|
10
|
+
const FINDING_TOOLS = `These tools come from the "symbio" tool server. In OpenCode's code mode you call
|
|
11
|
+
them inside \`execute\` as \`tools.symbio.<name>(…)\`, and \`search({ namespace: "symbio" })\`
|
|
12
|
+
finds them. OpenCode's tool catalog notices may list only its own tools, even
|
|
13
|
+
when they say the catalog is complete: Symbio's tools are still there. Never
|
|
14
|
+
conclude they are gone without calling one; if a call really fails, tell the
|
|
15
|
+
human what the error said.`;
|
|
5
16
|
export function coordinatorBrief(project, connections = "") {
|
|
6
17
|
return `# You are "core", the coordinator of a Symbio project
|
|
7
18
|
|
|
@@ -71,17 +82,20 @@ ${docsSection(project)}
|
|
|
71
82
|
marks its tasks in review as done.
|
|
72
83
|
- \`spawn_agent\` — create a helper agent with a name, a purpose, its first task
|
|
73
84
|
(\`task_id\` from the plan, or a description) and optionally a model. ${project.allowedModels.length > 1 ? "Pick cheaper models for simpler work. " : ""}Allowed models: ${project.allowedModels.join(", ")}.
|
|
74
|
-
|
|
75
|
-
it only sees **committed** files, so commit before spawning if it needs recent
|
|
76
|
-
changes.
|
|
85
|
+
${helperFolders(project)}
|
|
77
86
|
- \`send_message\` — message an agent. Delivering a message wakes the recipient.
|
|
78
87
|
- \`list_team\` — who exists, what they do, whether they are working.
|
|
79
|
-
- \`merge_work\` — bring a helper's committed work into the project
|
|
80
|
-
checked against shared-asset grants; ungranted
|
|
88
|
+
- \`merge_work\` — bring a helper's committed work into the project (helpers with
|
|
89
|
+
their own copy). Merges are checked against shared-asset grants; ungranted
|
|
90
|
+
changes are reported to you.
|
|
91
|
+
- \`move_to_project_folder\` — move an idle helper from its own copy into the
|
|
92
|
+
project folder (after its work is merged); it continues in a fresh session.
|
|
81
93
|
- \`stop_agent\` — stop a helper when its area of the project is finished for good.
|
|
82
94
|
- \`declare_asset\`, \`grant_asset\`, \`deny_asset\`, \`list_assets\` — the shared-asset register.
|
|
83
95
|
- \`ask_human\` — reach the human when you are not in a direct conversation.
|
|
84
96
|
|
|
97
|
+
${FINDING_TOOLS}
|
|
98
|
+
|
|
85
99
|
Team members are created with \`spawn_agent\` only. OpenCode's built-in subagent
|
|
86
100
|
tool is fine for quick, read-only lookups, but never use it to do project work:
|
|
87
101
|
Symbio can't see, coordinate or merge what built-in subagents do.
|
|
@@ -121,6 +135,22 @@ ${connectionsSection(connections)}
|
|
|
121
135
|
${PROCESS_RULES}
|
|
122
136
|
`;
|
|
123
137
|
}
|
|
138
|
+
/** Where helpers work, for the coordinator's tool list. */
|
|
139
|
+
function helperFolders(project) {
|
|
140
|
+
if (!existsSync(path.join(project.directory, ".git"))) {
|
|
141
|
+
return " Helpers work directly in the project folder, alongside you.";
|
|
142
|
+
}
|
|
143
|
+
const def = project.helperWorkspace === "shared" ? "shared" : "own";
|
|
144
|
+
return ` Where a helper works (\`workspace\`; this project's default is **${def}**, set by
|
|
145
|
+
the human):
|
|
146
|
+
- **own**: its own isolated copy on its own branch. It only sees **committed**
|
|
147
|
+
files (commit before spawning if it needs recent changes), and its work
|
|
148
|
+
reaches the project when you merge it. For parallel work on the same files.
|
|
149
|
+
- **shared**: the project folder itself. What it writes is there at once for
|
|
150
|
+
you and the next helper; nothing to merge, and it doesn't commit. For steps
|
|
151
|
+
that produce or check files others use next (pipelines, reviews of outputs).
|
|
152
|
+
Helpers in the project folder claim the files they write as shared assets.`;
|
|
153
|
+
}
|
|
124
154
|
function onboarding(project) {
|
|
125
155
|
if (project.origin === "new") {
|
|
126
156
|
return `## Starting point
|
|
@@ -194,7 +224,11 @@ assets. Your tasks come from the coordinator.
|
|
|
194
224
|
? `You work in your own isolated copy of the project, on branch \`${agent.branch}\`.
|
|
195
225
|
Commit your work there with clear messages; never merge or push — the
|
|
196
226
|
coordinator merges your work into the project.`
|
|
197
|
-
:
|
|
227
|
+
: `You work directly in the project folder, alongside the coordinator and other
|
|
228
|
+
agents: what you write is there for them at once, and theirs for you. Before
|
|
229
|
+
writing files others may also write, claim them as a shared asset (below).
|
|
230
|
+
Don't commit, merge or push unless the coordinator asks; don't touch files or
|
|
231
|
+
processes that aren't yours.`}
|
|
198
232
|
- When you finish a task, or are blocked, report it once: with \`update_task\`
|
|
199
233
|
for plan tasks (see below), otherwise with \`send_message\` to the coordinator
|
|
200
234
|
(what you did, where, anything they should know). Then end your turn; new
|
|
@@ -219,7 +253,9 @@ If you are unsure whether something is shared, ask the coordinator.
|
|
|
219
253
|
\`send_message\`, \`ask_human\`, \`list_team\`, \`list_assets\`, \`request_asset\`,
|
|
220
254
|
\`release_asset\`, \`check_messages\`, \`list_tasks\`, \`update_task\`.
|
|
221
255
|
|
|
222
|
-
|
|
256
|
+
${FINDING_TOOLS}
|
|
257
|
+
|
|
258
|
+
Tasks from the plan arrive as "Task T3: …". When you finish one, ${isolated ? "commit and " : ""}call
|
|
223
259
|
\`update_task("T3", "review", <summary of what you did>)\` — that **is** your
|
|
224
260
|
report to the coordinator; don't also send a message. If you are stuck, use
|
|
225
261
|
\`update_task("T3", "blocked", <why>)\`.
|
package/dist/server/team.js
CHANGED
|
@@ -17,7 +17,7 @@ import { accountChildUsage, accountUsage, emptyUsage, sessionUsage } from "./cos
|
|
|
17
17
|
import { AGENT_UPDATES, unseenUpdates, updatesNote } from "./updates.js";
|
|
18
18
|
import { cleanBuildOutputs, dailyBackup, gb, helperFolders, lastBackup, unpushedWork } from "./housekeeping.js";
|
|
19
19
|
import { coordinatorTools, workerTools } from "./tools.js";
|
|
20
|
-
import { changedFiles, createWorkspace, mergeWorkspace, removeWorkspace, restoreWorkspace, syncWorkspace } from "./workspaces.js";
|
|
20
|
+
import { changedFiles, createWorkspace, inProjectFolder, isGitRepo, leaveWorkspace, mergeWorkspace, removeWorkspace, restoreWorkspace, syncWorkspace } from "./workspaces.js";
|
|
21
21
|
const STUCK_AFTER_MS = Number(process.env.SYMBIO_STUCK_MINUTES ?? 10) * 60_000;
|
|
22
22
|
const WATCHDOG_INTERVAL_MS = 30_000;
|
|
23
23
|
// Loop guard: max conversational messages between the same pair in the window (per
|
|
@@ -251,6 +251,23 @@ export class Team {
|
|
|
251
251
|
return true;
|
|
252
252
|
return project.agents.some((a) => a !== except && (a.name === name || a.aliases?.includes(name)));
|
|
253
253
|
}
|
|
254
|
+
/** The human chose where new helpers work; core's brief describes it, so it's told too. */
|
|
255
|
+
async setHelperWorkspace(project, mode) {
|
|
256
|
+
if ((project.helperWorkspace ?? "own") === mode)
|
|
257
|
+
return;
|
|
258
|
+
project.helperWorkspace = mode;
|
|
259
|
+
await this.store.update(project);
|
|
260
|
+
const coordinator = this.coordinator(project);
|
|
261
|
+
if (!coordinator || coordinator.status === "stopped")
|
|
262
|
+
return;
|
|
263
|
+
await this.refreshInstructions(project, coordinator).catch(() => { });
|
|
264
|
+
await this.system(project, coordinator, mode === "shared"
|
|
265
|
+
? "Symbio: the human set this project's helpers to work in the project folder itself. New helpers work there by default " +
|
|
266
|
+
"(what they write is there at once; nothing to merge). Helpers that already have their own copy keep it: when one is idle " +
|
|
267
|
+
"and its work merged, move it with move_to_project_folder if it should share the folder too."
|
|
268
|
+
: "Symbio: the human set this project's helpers to work in their own copy (own branch) by default. Helpers already in the " +
|
|
269
|
+
"project folder stay there.", false);
|
|
270
|
+
}
|
|
254
271
|
/** Renames a helper. Its branch and folder keep their names; the old name keeps working. */
|
|
255
272
|
async renameAgent(project, agent, newName) {
|
|
256
273
|
if (agent.role === "coordinator")
|
|
@@ -356,7 +373,7 @@ export class Team {
|
|
|
356
373
|
this.planChanged(project);
|
|
357
374
|
const assets = task.assets.length ? `\nShared assets involved: ${task.assets.join(", ")} — request them before changing them.` : "";
|
|
358
375
|
await this.deliver(project, coordinator, helper, "request", `Task ${task.id}: ${task.title}\n\n${task.description}${assets}${extra ? `\n\n${extra}` : ""}\n\n` +
|
|
359
|
-
`When it's done, commit your work and call update_task("${task.id}", "review", <summary>) — that is your report. ` +
|
|
376
|
+
`When it's done, ${helper.branch ? "commit your work and " : ""}call update_task("${task.id}", "review", <summary>) — that is your report. ` +
|
|
360
377
|
`If you get stuck, update_task("${task.id}", "blocked", <why>).`, { protocol: true });
|
|
361
378
|
return `Assigned ${task.id} to ${helper.name}.`;
|
|
362
379
|
}
|
|
@@ -372,9 +389,19 @@ export class Team {
|
|
|
372
389
|
const coordinator = this.coordinator(project);
|
|
373
390
|
if (coordinator && agent.role !== "coordinator" && (status === "review" || status === "blocked")) {
|
|
374
391
|
await this.deliver(project, agent, coordinator, status === "review" ? "task-review" : "task-blocked", `${task.id} "${task.title}" → ${status}${note ? `: ${note}` : ""}` +
|
|
375
|
-
(status === "review"
|
|
392
|
+
(status === "review"
|
|
393
|
+
? agent.branch
|
|
394
|
+
? `\nMerge with merge_work("${agent.name}") when it looks right.`
|
|
395
|
+
: `\nIts work is already in the project folder; check it there, then mark the task done (update_task) or send it back.`
|
|
396
|
+
: ""));
|
|
376
397
|
return `${task.id} is now ${status}. The coordinator has been notified — no separate report needed.`;
|
|
377
398
|
}
|
|
399
|
+
// Helpers without a branch have nothing to merge: the coordinator marking their task done
|
|
400
|
+
// is the moment a fresh session fits (what merging is for the others).
|
|
401
|
+
const helper = task.assignee ? project.agents.find((a) => a.id === task.assignee) : undefined;
|
|
402
|
+
if (agent.role === "coordinator" && status === "done" && helper && !helper.branch && helper.role !== "coordinator") {
|
|
403
|
+
await this.renewAfterTask(project, helper, "its task was done");
|
|
404
|
+
}
|
|
378
405
|
return `${task.id} is now ${task.status}.`;
|
|
379
406
|
}
|
|
380
407
|
/** Resolves MCP callers: the token says which folder, the session says which agent. */
|
|
@@ -460,7 +487,7 @@ export class Team {
|
|
|
460
487
|
if (!project.allowedModels.includes(model)) {
|
|
461
488
|
throw new Error(`Model "${model}" is not allowed for this project. Allowed: ${project.allowedModels.join(", ")}`);
|
|
462
489
|
}
|
|
463
|
-
const workspace = await createWorkspace(project, name);
|
|
490
|
+
const workspace = await createWorkspace(project, name, opts.workspace ?? project.helperWorkspace ?? "own");
|
|
464
491
|
const client = this.client();
|
|
465
492
|
await this.registerTools(project, workspace.directory);
|
|
466
493
|
const session = await client.createSession({
|
|
@@ -494,7 +521,10 @@ export class Team {
|
|
|
494
521
|
await this.deliver(project, coordinator, agent, "request", opts.task, { protocol: true });
|
|
495
522
|
const where = workspace.branch
|
|
496
523
|
? `It works in its own copy of the project on branch ${workspace.branch} (${workspace.directory}); merge its work with merge_work.`
|
|
497
|
-
:
|
|
524
|
+
: (await isGitRepo(project.directory))
|
|
525
|
+
? "It works in the project folder itself, alongside you: what it writes is there at once, and there is nothing to merge. " +
|
|
526
|
+
"Shared-asset grants keep it from colliding with others; it doesn't commit unless you ask."
|
|
527
|
+
: "It works directly in the project folder (no git), so rely on shared-asset grants to avoid conflicts.";
|
|
498
528
|
return [`Created ${name} on ${model} and sent it the task. ${where}`, workspace.warning].filter(Boolean).join("\n");
|
|
499
529
|
}
|
|
500
530
|
async stopAgent(project, agent, reason) {
|
|
@@ -505,8 +535,7 @@ export class Team {
|
|
|
505
535
|
await reg.forget(agent.id);
|
|
506
536
|
this.assetsChanged(project);
|
|
507
537
|
// Helpers have their own folder; the project root is shared, so leave its processes alone.
|
|
508
|
-
|
|
509
|
-
await this.stopAllProcesses(agent);
|
|
538
|
+
await this.stopAllProcesses(project, agent);
|
|
510
539
|
const { committed, uncommitted } = await changedFiles(agent);
|
|
511
540
|
const unmerged = committed.length > 0 || uncommitted.length > 0;
|
|
512
541
|
if (!unmerged)
|
|
@@ -565,7 +594,7 @@ export class Team {
|
|
|
565
594
|
* lossy (compactions), and they inflate OpenCode's memory. The new session starts from
|
|
566
595
|
* a handoff note built from what Symbio knows; the old one stays readable.
|
|
567
596
|
*/
|
|
568
|
-
async renewSession(project, agent, reason) {
|
|
597
|
+
async renewSession(project, agent, reason, opts = {}) {
|
|
569
598
|
if (agent.status === "busy" || agent.status === "waiting" || agent.status === "stuck") {
|
|
570
599
|
throw new Error(`${agent.name} is working; renew its session when it's idle.`);
|
|
571
600
|
}
|
|
@@ -575,7 +604,7 @@ export class Team {
|
|
|
575
604
|
const oldSession = agent.sessionId;
|
|
576
605
|
// Count everything the old session used before leaving it.
|
|
577
606
|
await this.refreshUsage(project, agent).catch(() => { });
|
|
578
|
-
const handoff = await this.handoffNote(project, agent, oldSession);
|
|
607
|
+
const handoff = await this.handoffNote(project, agent, oldSession, opts.moved);
|
|
579
608
|
await this.registerTools(project, agent.directory);
|
|
580
609
|
const session = await client.createSession({
|
|
581
610
|
title: `${project.name} · ${agent.name}`,
|
|
@@ -603,8 +632,54 @@ export class Team {
|
|
|
603
632
|
});
|
|
604
633
|
bus.publish({ type: "project.updated", projectId: project.id });
|
|
605
634
|
}
|
|
635
|
+
/**
|
|
636
|
+
* Moves a helper from its own worktree into the project folder (for pipeline-style work
|
|
637
|
+
* where each step reads the previous step's files). OpenCode ties a session to its folder,
|
|
638
|
+
* so the helper continues in a fresh session there, with the usual handoff note.
|
|
639
|
+
*/
|
|
640
|
+
async moveToProjectFolder(project, agent, reason) {
|
|
641
|
+
if (agent.role === "coordinator")
|
|
642
|
+
throw new Error("core already works in the project folder.");
|
|
643
|
+
if (inProjectFolder(project, agent))
|
|
644
|
+
return `${agent.name} already works in the project folder.`;
|
|
645
|
+
if (agent.status === "stopped")
|
|
646
|
+
throw new Error(`${agent.name} is stopped; restart it first.`);
|
|
647
|
+
if (agent.status === "busy" || agent.status === "waiting" || agent.status === "stuck") {
|
|
648
|
+
throw new Error(`${agent.name} is working; move it when it's idle.`);
|
|
649
|
+
}
|
|
650
|
+
// Its processes run in the folder about to go away (and would keep files open on Windows).
|
|
651
|
+
await this.stopAllProcesses(project, agent);
|
|
652
|
+
await leaveWorkspace(project, agent);
|
|
653
|
+
const branch = agent.branch;
|
|
654
|
+
agent.directory = project.directory;
|
|
655
|
+
agent.branch = null;
|
|
656
|
+
agent.base = null;
|
|
657
|
+
agent.workSince = Date.now();
|
|
658
|
+
await this.store.update(project);
|
|
659
|
+
try {
|
|
660
|
+
await this.renewSession(project, agent, reason, { moved: true });
|
|
661
|
+
}
|
|
662
|
+
catch (err) {
|
|
663
|
+
agent.status = "error";
|
|
664
|
+
agent.problem = `It moved to the project folder but its new session couldn't start (${err.message}). Give it a fresh session to retry.`;
|
|
665
|
+
await this.store.update(project);
|
|
666
|
+
bus.publish({ type: "project.updated", projectId: project.id });
|
|
667
|
+
throw new Error(agent.problem);
|
|
668
|
+
}
|
|
669
|
+
await this.log(project, {
|
|
670
|
+
from: "symbio",
|
|
671
|
+
to: "human",
|
|
672
|
+
kind: "system",
|
|
673
|
+
text: `${agent.name} now works in the project folder (${reason}); its own copy${branch ? ` on ${branch}` : ""} was removed.`,
|
|
674
|
+
});
|
|
675
|
+
const coordinator = this.coordinator(project);
|
|
676
|
+
if (coordinator && reason !== "moved by the coordinator") {
|
|
677
|
+
await this.system(project, coordinator, `Symbio: the human moved ${agent.name} into the project folder. It now works on the project's files directly: nothing of its to merge from now on.`, false);
|
|
678
|
+
}
|
|
679
|
+
return `${agent.name} now works in the project folder, in a fresh session with a handoff note. Its own copy was removed.`;
|
|
680
|
+
}
|
|
606
681
|
/** What an agent needs to carry on in a fresh session, from Symbio's own records. */
|
|
607
|
-
async handoffNote(project, agent, oldSession) {
|
|
682
|
+
async handoffNote(project, agent, oldSession, moved = false) {
|
|
608
683
|
const plan = (await this.plan(project)).plan;
|
|
609
684
|
const reg = await this.assets(project);
|
|
610
685
|
const mine = plan.tasks.filter((t) => t.assignee === agent.id);
|
|
@@ -624,14 +699,21 @@ export class Team {
|
|
|
624
699
|
const cut = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
|
|
625
700
|
const parts = [
|
|
626
701
|
`# Handoff: you are continuing as ${agent.name} in a fresh session`,
|
|
627
|
-
|
|
702
|
+
(moved
|
|
703
|
+
? `You now work in the project folder itself instead of your own copy, so Symbio started a new session there. ` +
|
|
704
|
+
`Nothing else about your role changed. `
|
|
705
|
+
: `Your previous session had grown long, so Symbio started a new one. Nothing about your role changed. `) +
|
|
628
706
|
`This note is what carries over; the project's docs (AGENTS.md, docs/) hold the durable knowledge, ` +
|
|
629
|
-
`and your
|
|
707
|
+
`and your work is in your folder.`,
|
|
630
708
|
agent.purpose ? `**Your purpose:** ${agent.purpose}` : "",
|
|
631
709
|
agent.role === "coordinator"
|
|
632
710
|
? `**Plan:** ${plan.status}${plan.goal ? ` — ${plan.goal}` : ""}\n${plan.tasks.map((t) => `- ${t.id} [${t.status}] ${t.title}${t.assignee ? ` (${nameOf(t.assignee)})` : ""}`).join("\n")}`
|
|
633
711
|
: "",
|
|
634
|
-
agent.role
|
|
712
|
+
agent.role === "coordinator"
|
|
713
|
+
? ""
|
|
714
|
+
: agent.branch
|
|
715
|
+
? `**Your folder:** ${agent.directory} (branch ${agent.branch}).`
|
|
716
|
+
: `**Your folder:** ${agent.directory} (the project folder, shared with the team).`,
|
|
635
717
|
open.length ? `**Your open tasks:**\n${open.map((t) => `- ${t.id} [${t.status}] ${t.title}\n ${cut(t.description, 600)}`).join("\n")}` : "**Open tasks:** none right now; wait for the next one.",
|
|
636
718
|
done.length ? `**Recently finished:**\n${done.map((t) => `- ${t.id} ${t.title}${lastNote(t) ? ` — ${cut(lastNote(t), 300)}` : ""}`).join("\n")}` : "",
|
|
637
719
|
held.length ? `**Shared assets you hold:** ${held.join(", ")} (release them when done).` : "",
|
|
@@ -640,6 +722,17 @@ export class Team {
|
|
|
640
722
|
];
|
|
641
723
|
return parts.filter(Boolean).join("\n\n");
|
|
642
724
|
}
|
|
725
|
+
/** With "after-task" renewal, a helper with no other task under way gets a fresh session. */
|
|
726
|
+
async renewAfterTask(project, agent, reason) {
|
|
727
|
+
const plan = await this.plan(project);
|
|
728
|
+
const renew = project.renewSessions === "after-task" &&
|
|
729
|
+
agent.status === "idle" &&
|
|
730
|
+
!plan.plan.tasks.some((t) => t.assignee === agent.id && t.status !== "done" && t.status !== "todo");
|
|
731
|
+
if (!renew)
|
|
732
|
+
return false;
|
|
733
|
+
await this.renewSession(project, agent, reason).catch((err) => console.error(`[renew] ${agent.name}:`, err.message));
|
|
734
|
+
return true;
|
|
735
|
+
}
|
|
643
736
|
async mergeWork(project, agent, force) {
|
|
644
737
|
const { committed, uncommitted } = await changedFiles(agent);
|
|
645
738
|
const reg = await this.assets(project);
|
|
@@ -663,14 +756,7 @@ export class Team {
|
|
|
663
756
|
}
|
|
664
757
|
if (completed.length)
|
|
665
758
|
this.planChanged(project);
|
|
666
|
-
|
|
667
|
-
project.renewSessions === "after-task" &&
|
|
668
|
-
agent.status === "idle" &&
|
|
669
|
-
!plan.plan.tasks.some((t) => t.assignee === agent.id && t.status !== "done" && t.status !== "todo");
|
|
670
|
-
if (renew) {
|
|
671
|
-
await this.renewSession(project, agent, "its task was merged").catch((err) => console.error(`[renew] ${agent.name}:`, err.message));
|
|
672
|
-
}
|
|
673
|
-
else {
|
|
759
|
+
if (!(completed.length > 0 && (await this.renewAfterTask(project, agent, "its task was merged")))) {
|
|
674
760
|
await this.system(project, agent, "Symbio: your committed work was merged into the project, and your workspace now includes the latest project state.", false);
|
|
675
761
|
}
|
|
676
762
|
return (`${result.summary}\n${result.files.map((f) => `- ${f}`).join("\n")}` +
|
|
@@ -839,8 +925,7 @@ export class Team {
|
|
|
839
925
|
for (const agent of project.agents) {
|
|
840
926
|
if (client) {
|
|
841
927
|
await client.interrupt(agent.sessionId).catch(() => { });
|
|
842
|
-
|
|
843
|
-
await this.stopAllProcesses(agent).catch(() => { });
|
|
928
|
+
await this.stopAllProcesses(project, agent).catch(() => { });
|
|
844
929
|
}
|
|
845
930
|
if (agent.branch)
|
|
846
931
|
await removeWorkspace(project, agent).catch(() => { });
|
|
@@ -1024,37 +1109,53 @@ export class Team {
|
|
|
1024
1109
|
return;
|
|
1025
1110
|
this.usageTimers.set(agent.id, setTimeout(() => {
|
|
1026
1111
|
this.usageTimers.delete(agent.id);
|
|
1027
|
-
|
|
1112
|
+
// OpenCode may be restarting; the next burst of usage events reads the totals again.
|
|
1113
|
+
this.refreshUsage(project, agent).catch(() => { });
|
|
1028
1114
|
}, 3000));
|
|
1029
1115
|
}
|
|
1030
1116
|
// ---------------------------------------------------------------- processes
|
|
1031
|
-
/**
|
|
1032
|
-
|
|
1033
|
-
|
|
1117
|
+
/**
|
|
1118
|
+
* The agent's running background shells. An agent alone in its folder owns everything
|
|
1119
|
+
* there; in the project folder (shared by the coordinator and shared helpers) a shell
|
|
1120
|
+
* belongs to the session that started it, which OpenCode records in its metadata.
|
|
1121
|
+
*/
|
|
1122
|
+
async ownShells(project, agent) {
|
|
1123
|
+
const running = (await this.client().shells(agent.directory)).filter((s) => s.status === "running");
|
|
1124
|
+
if (!inProjectFolder(project, agent))
|
|
1125
|
+
return running;
|
|
1126
|
+
const sessionsOf = (a) => [a.sessionId, ...(a.previousSessions ?? []).map((p) => p.sessionId), ...Object.keys(a.childSessions ?? {})];
|
|
1127
|
+
const mine = new Set(sessionsOf(agent));
|
|
1128
|
+
const others = new Set(project.agents.filter((a) => a.id !== agent.id).flatMap(sessionsOf));
|
|
1129
|
+
return running.filter((s) => {
|
|
1130
|
+
const owner = s.metadata?.sessionID;
|
|
1131
|
+
if (owner)
|
|
1132
|
+
return mine.has(owner) || (agent.role === "coordinator" && !others.has(owner));
|
|
1133
|
+
// No owner recorded (started outside an agent session): the coordinator manages it.
|
|
1134
|
+
return agent.role === "coordinator";
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
/** Background shells the agent started that are still running. */
|
|
1138
|
+
async listProcesses(project, agent) {
|
|
1139
|
+
const shells = await this.ownShells(project, agent);
|
|
1034
1140
|
if (shells.length === 0)
|
|
1035
1141
|
return "You have no background processes running.";
|
|
1036
1142
|
return shells
|
|
1037
1143
|
.map((s) => `- ${s.id}: ${s.command} (pid ${s.pid ?? "?"}, started ${new Date(s.time.started).toLocaleTimeString()})`)
|
|
1038
1144
|
.join("\n");
|
|
1039
1145
|
}
|
|
1040
|
-
/** Stops one background shell — only
|
|
1041
|
-
async stopProcess(agent, shellId) {
|
|
1042
|
-
const
|
|
1043
|
-
const shell = shells.find((s) => s.id === shellId.trim());
|
|
1146
|
+
/** Stops one background shell — only one the agent itself started. */
|
|
1147
|
+
async stopProcess(project, agent, shellId) {
|
|
1148
|
+
const shell = (await this.ownShells(project, agent)).find((s) => s.id === shellId.trim());
|
|
1044
1149
|
if (!shell) {
|
|
1045
|
-
throw new Error(`No background process "${shellId}"
|
|
1150
|
+
throw new Error(`No running background process "${shellId}" of yours. Use list_processes to see yours.`);
|
|
1046
1151
|
}
|
|
1047
|
-
if (shell.status !== "running")
|
|
1048
|
-
return `${shell.id} already ${shell.status}.`;
|
|
1049
1152
|
await this.client().stopShell(agent.directory, shell.id);
|
|
1050
1153
|
return `Stopped ${shell.id} (${shell.command}).`;
|
|
1051
1154
|
}
|
|
1052
1155
|
/** Stops every background shell an agent left running (used when the agent is stopped). */
|
|
1053
|
-
async stopAllProcesses(agent) {
|
|
1054
|
-
const shells = await this.
|
|
1055
|
-
|
|
1056
|
-
.catch(() => []);
|
|
1057
|
-
for (const s of shells.filter((s) => s.status === "running")) {
|
|
1156
|
+
async stopAllProcesses(project, agent) {
|
|
1157
|
+
const shells = await this.ownShells(project, agent).catch(() => []);
|
|
1158
|
+
for (const s of shells) {
|
|
1058
1159
|
await this.client()
|
|
1059
1160
|
.stopShell(agent.directory, s.id)
|
|
1060
1161
|
.catch(() => { });
|
|
@@ -1301,7 +1402,8 @@ export class Team {
|
|
|
1301
1402
|
if (!next) {
|
|
1302
1403
|
if (agent.problem)
|
|
1303
1404
|
return; // already reported
|
|
1304
|
-
|
|
1405
|
+
const problem = `Every allowed model failed recently. Last error (${agent.model}): ${message}`;
|
|
1406
|
+
agent.problem = problem;
|
|
1305
1407
|
this.setStatus(project, agent, "error");
|
|
1306
1408
|
await this.store.update(project);
|
|
1307
1409
|
bus.publish({ type: "project.updated", projectId: project.id });
|
|
@@ -1309,7 +1411,7 @@ export class Team {
|
|
|
1309
1411
|
from: "symbio",
|
|
1310
1412
|
to: "human",
|
|
1311
1413
|
kind: "alert",
|
|
1312
|
-
text: `${agent.name} can't continue: ${
|
|
1414
|
+
text: `${agent.name} can't continue: ${problem}\n\nAdd another model to the project (Models in the sidebar) — stuck agents resume automatically — or use “Retry now” once the quota resets.`,
|
|
1313
1415
|
});
|
|
1314
1416
|
return;
|
|
1315
1417
|
}
|
package/dist/server/tools.js
CHANGED
|
@@ -90,16 +90,20 @@ const releaseAsset = {
|
|
|
90
90
|
// ---- coordinator-only ----
|
|
91
91
|
const spawnAgent = {
|
|
92
92
|
name: "spawn_agent",
|
|
93
|
-
description: "Create a helper agent.
|
|
93
|
+
description: "Create a helper agent. Give it a short name, its purpose, and its first task. In a git project it works either in its own copy " +
|
|
94
|
+
"on its own branch (\"own\": parallel edits, merged with merge_work) or in the project folder itself (\"shared\": its files are " +
|
|
95
|
+
"there at once for the next step, nothing to merge). Without workspace, the project's setting applies.",
|
|
94
96
|
inputSchema: obj({
|
|
95
97
|
name: str("Short unique name, e.g. 'researcher' or 'chapter-writer'"),
|
|
96
98
|
purpose: str("What this agent is responsible for"),
|
|
97
99
|
task_id: str("The plan task to start with (e.g. 'T2'). Preferred when there is a plan."),
|
|
98
100
|
task: str("Its first task as text (when there is no plan task), or extra instructions for task_id"),
|
|
99
101
|
model: str("Optional provider/model from the allowed list; defaults to your own model"),
|
|
102
|
+
workspace: { type: "string", enum: ["own", "shared"], description: "Where it works (git projects); defaults to the project's setting" },
|
|
100
103
|
}, ["name", "purpose"]),
|
|
101
104
|
async run(args, { team, project, agent }) {
|
|
102
105
|
return team.spawnWorker(project, agent, {
|
|
106
|
+
workspace: args.workspace === "own" || args.workspace === "shared" ? args.workspace : undefined,
|
|
103
107
|
name: String(args.name),
|
|
104
108
|
purpose: String(args.purpose),
|
|
105
109
|
task: args.task ? String(args.task) : undefined,
|
|
@@ -176,9 +180,12 @@ const updateTask = {
|
|
|
176
180
|
task_id: str("Task id, e.g. T3"),
|
|
177
181
|
status: { type: "string", enum: [...TASK_STATUSES] },
|
|
178
182
|
note: str("What happened: progress, blocker, or a summary of the finished work"),
|
|
183
|
+
summary: str("Same as note (either name works)"),
|
|
179
184
|
}, ["task_id"]),
|
|
180
|
-
async run({ task_id, status, note }, { team, project, agent }) {
|
|
181
|
-
|
|
185
|
+
async run({ task_id, status, note, summary }, { team, project, agent }) {
|
|
186
|
+
// Agents often call it "summary"; dropping it silently lost their review notes.
|
|
187
|
+
const text = note ?? summary;
|
|
188
|
+
return team.updateTask(project, agent, String(task_id), status, text ? String(text) : undefined);
|
|
182
189
|
},
|
|
183
190
|
};
|
|
184
191
|
const stopAgent = {
|
|
@@ -192,6 +199,18 @@ const stopAgent = {
|
|
|
192
199
|
return team.stopAgent(project, target, reason ? String(reason) : "work finished");
|
|
193
200
|
},
|
|
194
201
|
};
|
|
202
|
+
const moveToProjectFolder = {
|
|
203
|
+
name: "move_to_project_folder",
|
|
204
|
+
description: "Move an idle helper from its own copy (branch) into the project folder, so it works on the project's files directly. " +
|
|
205
|
+
"It continues in a fresh session with a handoff note. Refused while its own copy has work that isn't merged yet.",
|
|
206
|
+
inputSchema: obj({ agent: str("Helper name") }, ["agent"]),
|
|
207
|
+
async run({ agent: name }, { team, project }) {
|
|
208
|
+
const target = team.findAgent(project, name);
|
|
209
|
+
if (!target || target.role === "coordinator")
|
|
210
|
+
throw new Error(`No helper agent named "${name}".`);
|
|
211
|
+
return team.moveToProjectFolder(project, target, "moved by the coordinator");
|
|
212
|
+
},
|
|
213
|
+
};
|
|
195
214
|
const cleanBuildOutputs = {
|
|
196
215
|
name: "clean_build_outputs",
|
|
197
216
|
description: "Free disk space in an idle helper's folder by deleting its build outputs (git-ignored folders such as bin, obj, node_modules). " +
|
|
@@ -270,14 +289,14 @@ const listProcesses = {
|
|
|
270
289
|
name: "list_processes",
|
|
271
290
|
description: "List the background processes (servers, watchers) you started that are still running, with their ids.",
|
|
272
291
|
inputSchema: obj({}),
|
|
273
|
-
run: (_args, { team, agent }) => team.listProcesses(agent),
|
|
292
|
+
run: (_args, { team, project, agent }) => team.listProcesses(project, agent),
|
|
274
293
|
};
|
|
275
294
|
const stopProcess = {
|
|
276
295
|
name: "stop_process",
|
|
277
296
|
description: "Stop a background process you started (by its shell id, e.g. sh_…, shown when you started it or by list_processes). " +
|
|
278
297
|
"This is the only way to stop processes — killing by name or PID is blocked.",
|
|
279
298
|
inputSchema: obj({ shell_id: str("The background shell id, e.g. sh_179a…") }, ["shell_id"]),
|
|
280
|
-
run: ({ shell_id }, { team, agent }) => team.stopProcess(agent, String(shell_id)),
|
|
299
|
+
run: ({ shell_id }, { team, project, agent }) => team.stopProcess(project, agent, String(shell_id)),
|
|
281
300
|
};
|
|
282
301
|
// ---- connections (GitHub, servers): Symbio acts with the stored credentials
|
|
283
302
|
const reasonField = str("Why — the human sees this when approving");
|
|
@@ -365,6 +384,7 @@ const COORDINATOR_ONLY = [
|
|
|
365
384
|
stopAgent,
|
|
366
385
|
mergeWork,
|
|
367
386
|
cleanBuildOutputs,
|
|
387
|
+
moveToProjectFolder,
|
|
368
388
|
declareAsset,
|
|
369
389
|
grantAsset,
|
|
370
390
|
denyAsset,
|
package/dist/server/updates.js
CHANGED
|
@@ -40,6 +40,24 @@ export const AGENT_UPDATES = [
|
|
|
40
40
|
"free disk space in idle helpers' folders with the new clean_build_outputs tool (it only deletes git-ignored build folders " +
|
|
41
41
|
"like bin, obj and node_modules; work is never touched). Symbio also backs up its own state every day.",
|
|
42
42
|
},
|
|
43
|
+
{
|
|
44
|
+
id: "2026-10-30-shared-folder",
|
|
45
|
+
text: "Where helpers work: in a git project a helper can now work in the project folder itself instead of its own copy on its own " +
|
|
46
|
+
"branch. spawn_agent has a workspace option: \"own\" (as before: parallel work, merged with merge_work) or \"shared\" (the " +
|
|
47
|
+
"project folder: what it writes is there at once for you and the next helper; nothing to merge, and it doesn't commit). " +
|
|
48
|
+
"Use shared for step-by-step work where each step uses the previous one's files. Without the option, the project's setting " +
|
|
49
|
+
"applies (the human sets it in Settings; it stays \"own\" unless they change it). A shared helper's finished task arrives for " +
|
|
50
|
+
"review as usual; check its files in the project folder and mark the task done. move_to_project_folder moves an idle helper " +
|
|
51
|
+
"whose work is merged into the project folder (it continues in a fresh session). Background processes now belong to the agent " +
|
|
52
|
+
"that started them: list_processes and stop_process only show and stop your own, even in a shared folder.",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "2026-11-03-tool-catalog",
|
|
56
|
+
text: "Finding Symbio's tools: OpenCode sometimes posts a \"tool catalog has changed\" notice that lists only its own tools and says " +
|
|
57
|
+
"the catalog is complete. Symbio's tools are still there: call them inside execute as tools.symbio.<name>(…), or find them " +
|
|
58
|
+
"with search({ namespace: \"symbio\" }). Never conclude they're gone without calling one; if a call really fails, tell the " +
|
|
59
|
+
"human what the error said.",
|
|
60
|
+
},
|
|
43
61
|
];
|
|
44
62
|
/** Entries a project hasn't been told about yet. */
|
|
45
63
|
export function unseenUpdates(seen) {
|