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 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. core reviews and merges their work. Helpers are only created when work can really run in
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. |
@@ -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"));
@@ -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"));
@@ -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
- In a git project each helper works in its own isolated copy (its own branch);
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. Merges are
80
- checked against shared-asset grants; ungranted changes are reported to you.
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
- : "You work directly in the shared project folder, alongside other agents."}
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
- Tasks from the plan arrive as "Task T3: …". When you finish one, commit and call
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>)\`.
@@ -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" && agent.branch ? `\nMerge with merge_work("${agent.name}") when it looks right.` : ""));
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
- : "It works directly in the project folder (no git), so rely on shared-asset grants to avoid conflicts.";
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
- if (agent.directory !== project.directory)
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
- `Your previous session had grown long, so Symbio started a new one. Nothing about your role changed. ` +
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 committed work is in your folder.`,
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 !== "coordinator" && agent.branch ? `**Your folder:** ${agent.directory} (branch ${agent.branch}).` : "",
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
- const renew = completed.length > 0 &&
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
- if (agent.directory !== project.directory)
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
- void this.refreshUsage(project, agent);
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
- /** Background shells running in the agent's working folder. */
1032
- async listProcesses(agent) {
1033
- const shells = (await this.client().shells(agent.directory)).filter((s) => s.status === "running");
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 ones started in the agent's own working folder. */
1041
- async stopProcess(agent, shellId) {
1042
- const shells = await this.client().shells(agent.directory);
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}" in your folder. Use list_processes to see yours.`);
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.client()
1055
- .shells(agent.directory)
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
- agent.problem = `Every allowed model failed recently. Last error (${agent.model}): ${message}`;
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: ${agent.problem}\n\nAdd another model to the project (Models in the sidebar) — stuck agents resume automatically — or use “Retry now” once the quota resets.`,
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
  }
@@ -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. It gets its own isolated workspace in git projects. Give it a short name, its purpose, and its first task.",
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
- return team.updateTask(project, agent, String(task_id), status, note ? String(note) : undefined);
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,
@@ -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) {