Add workspaces, conversation sessions, app-managed computers and pictures

- Rail: Add icon with stacked form modals (bot, workspace, computer),
  coordinator, recent bots with "See all bots", Workspaces section, and
  a workspace mode listing only its crew
- Workspaces: page for context, bots (per-workspace instructions and
  memory), computers (default, link, unlink), models and delete;
  conversations scoped per bot per workspace
- Sessions: conversations split into searchable, reopenable sessions
  with carry-over summaries; sessions and new-session header icons
- Computers: created and provisioned over the docker socket, home dir
  kept at DATA_DIR/computers/<id> after delete; super admins only
- Models: workspace settings win over a bot's own, then the defaults
- Pictures for bots and workspaces; bots editable from the header
- Coordinator tools: update_workspace, set_bot_workspace_instructions

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 08:39:33 +01:00
co-authored by Claude Opus 5.5
parent 56daf0c462
commit 0ce61cdb49
223 changed files with 8166 additions and 585 deletions
+3 -10
View File
@@ -1,7 +1,6 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import addBotToComputer from "@/src/functions/backend/db/computers/add-bot-to-computer";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import removeBotFromComputer from "@/src/functions/backend/db/computers/remove-bot-from-computer";
import computerRef from "@/src/utils/computer-ref";
import type { HarnessTool } from "../../types";
@@ -30,8 +29,8 @@ type Args = {
/**
* Lets the coordinator give a crew member access to one of the user's
* computers (`bot_computers`), or take a direct grant away. Access that
* comes through a workspace stays, and the result says so.
* computers in its own chat (`bot_computers`), or take it away. In a
* workspace, bots use the workspace's computers instead.
*/
export default function assignComputerTool({
user_id,
@@ -77,13 +76,7 @@ export default function assignComputerTool({
await removeBotFromComputer({ user_id, bot_id: worker.id, computer_id: computer.id });
const still = (await grabBotComputers({ bot_id: worker.id, user_id })).some(
(c) => c.id == computer.id,
);
return still
? `Removed ${worker.name}'s own access to ${ref}, but it still has it through a workspace.`
: `${worker.name} can't use ${ref} any more.`;
return `${worker.name} can't use ${ref} in its own chat any more. In a workspace it still uses the workspace's computers.`;
},
};
}
+19 -2
View File
@@ -4,6 +4,7 @@ import pushBotNotice from "@/src/bots-queue/push-bot-notice";
import enqueueBotJob from "@/src/functions/backend/db/bots-queue/enqueue-bot-job";
import insertBotMessage from "@/src/functions/backend/db/bots/insert-bot-message";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import ensureBotSession from "@/src/functions/backend/db/bot-sessions/ensure-bot-session";
import type { HarnessTool } from "../../types";
type Params = {
@@ -12,12 +13,21 @@ type Params = {
* The coordinator: the worker's result comes back to its conversation.
*/
coordinator_id: number | string;
/**
* The coordinator's session the task is handed over from.
*/
session_id: number | string;
/**
* The workspace the coordinator is working in: the worker works on it
* there, in its conversation in the workspace.
*/
workspace_id?: number | string | null;
/**
* The coordinator's name, shown in the worker's conversation.
*/
coordinator_name: string;
/**
* The user's worker bots.
* The worker bots it can hand work to (in a workspace, its members).
*/
workers: BUN_SQLITE_COBBLE_BOTS[];
};
@@ -37,6 +47,8 @@ type Args = {
export default function delegateTaskTool({
user_id,
coordinator_id,
session_id,
workspace_id,
coordinator_name,
workers,
}: Params): HarnessTool<Args> {
@@ -59,21 +71,26 @@ export default function delegateTaskTool({
throw new Error(`No crew member has id ${args.bot_id}. Crew: ${crew}.`);
}
const computers = await grabBotComputers({ bot_id: worker.id, user_id });
const computers = await grabBotComputers({ bot_id: worker.id, user_id, workspace_id });
const worker_session = await ensureBotSession({ bot_id: worker.id, workspace_id });
const job_id = await enqueueBotJob({
user_id,
bot_id: worker.id,
session_id: Number(worker_session.id),
title: args.title,
instructions: args.instructions,
computer_id: computers[0]?.id || undefined,
requested_by_bot_id: coordinator_id,
requested_by_session_id: session_id,
});
await pushBotNotice({
bot_id: worker.id,
message: await insertBotMessage({
bot_id: worker.id,
session_id: Number(worker_session.id),
workspace_id: workspace_id ? Number(workspace_id) : null,
text: `${coordinator_name} asked me to: **${args.title}**`,
}),
});
+64 -31
View File
@@ -7,15 +7,27 @@ import searchComputersTool from "../computer/search-computers-tool";
import assignComputerTool from "./assign-computer-tool";
import delegateTaskTool from "./delegate-task-tool";
import grabBotTools from "./grab-bot-tools";
import updateWorkspaceTool from "./update-workspace-tool";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import setWorkspaceInstructionsTool from "./set-workspace-instructions-tool";
import startTaskTool from "./start-task-tool";
type Params = {
bot: BUN_SQLITE_COBBLE_BOTS;
user_id: number | string;
/**
* The bot's computers, the current one first (`grabBotComputers`).
* The bot's computers where it runs, the current one first
* (`grabBotComputers`).
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
/**
* The session being answered; tasks report back to it.
*/
session_id: number | string;
/**
* The session's workspace, if any.
*/
workspace_id?: number | string | null;
/**
* The user message being answered.
*/
@@ -27,32 +39,37 @@ type Params = {
};
/**
* The tools a bot replies in chat with: its own (memory, instructions),
* start_task for longer work, search_computers to find what's on its
* computers, ask_user_to_take_over when only the user can do something on
* one, and for the coordinator delegate_task and assign_computer
* (which computers each crew member can use). Anything
* else on a computer goes through a task, never mid-reply.
* The tools a bot replies in chat with: its own (memory, instructions, and
* in a workspace switching its default computer), start_task for longer
* work, search_computers to find what's on its computers,
* ask_user_to_take_over when only the user can do something on one, and
* for the coordinator delegate_task (to the workspace's crew in a
* workspace) plus, in its own chat, assign_computer (which computers each
* crew member can use) or, in a workspace, set_bot_workspace_instructions.
* Anything else on a computer goes through a task, never mid-reply.
*/
export default async function grabBotChatTools({
bot,
user_id,
computers,
session_id,
workspace_id,
message_id,
onTakeOver,
}: Params): Promise<HarnessTool[]> {
const bot_id = bot.id || 0;
const tools = [
...grabBotTools({ user_id, bot_id }),
...grabBotTools({ user_id, bot_id, workspace_id, computers }),
startTaskTool({
user_id,
bot_id,
session_id,
computers,
message_id,
}),
...(computers.length
? [
searchComputersTool({ bot_id, computers }),
searchComputersTool({ computers, workspace_id }),
askToTakeOverTool({ computers, onAsk: (params) => onTakeOver?.(params) }),
]
: []),
@@ -60,36 +77,52 @@ export default async function grabBotChatTools({
if (bot.kind != "coordinator") return tools;
const workspaces = (await grabUserWorkspaces({ user_id })).filter((w) => !w.archived);
if (workspaces.length) {
tools.push(updateWorkspaceTool({ user_id, workspaces, workspace_id }));
}
const workers =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT * FROM bots WHERE user_id = ? AND kind = 'worker' ORDER BY id ASC`,
values: [user_id],
sql: workspace_id
? `SELECT b.* FROM bots b JOIN workspace_bots wb ON wb.bot_id = b.id AND wb.workspace_id = ?
WHERE b.user_id = ? AND b.kind = 'worker' ORDER BY b.id ASC`
: `SELECT * FROM bots WHERE user_id = ? AND kind = 'worker' ORDER BY id ASC`,
values: workspace_id ? [workspace_id, user_id] : [user_id],
})
).payload || [];
if (workers.length) {
tools.push(
delegateTaskTool({
user_id,
coordinator_id: bot_id,
coordinator_name: bot.name || "The coordinator",
workers,
}),
if (!workers.length) return tools;
tools.push(
delegateTaskTool({
user_id,
coordinator_id: bot_id,
session_id,
workspace_id,
coordinator_name: bot.name || "The coordinator",
workers,
}),
);
if (workspace_id) {
tools.push(setWorkspaceInstructionsTool({ user_id, workspace_id, workers }));
return tools;
}
if (computers.length) {
const access = Object.fromEntries(
await Promise.all(
workers.map(async (worker) => [
worker.id || 0,
await grabBotComputers({ bot_id: worker.id || 0, user_id }),
]),
),
);
if (computers.length) {
const access = Object.fromEntries(
await Promise.all(
workers.map(async (worker) => [
worker.id || 0,
await grabBotComputers({ bot_id: worker.id || 0, user_id }),
]),
),
);
tools.push(assignComputerTool({ user_id, workers, computers, access }));
}
tools.push(assignComputerTool({ user_id, workers, computers, access }));
}
return tools;
+24 -5
View File
@@ -1,19 +1,38 @@
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import type { HarnessTool } from "../../types";
import setWorkspaceComputerTool from "./set-workspace-computer-tool";
import updateInstructionsTool from "./update-instructions-tool";
import updateMemoryTool from "./update-memory-tool";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The workspace the run is in: memory and instructions save there by
* default, and the bot can switch its default computer.
*/
workspace_id?: number | string | null;
/**
* The bot's computers where it runs, the current one first.
*/
computers?: BUN_SQLITE_COBBLE_COMPUTERS[];
};
/**
* The tools a bot works with, bound to that bot. Computer, delegation and
* media tools join this list as they are built.
* The tools a bot works with on every run, bound to that bot and where it
* works.
*/
export default function grabBotTools({ user_id, bot_id }: Params): HarnessTool[] {
export default function grabBotTools({
user_id,
bot_id,
workspace_id,
computers = [],
}: Params): HarnessTool[] {
return [
updateMemoryTool({ user_id, bot_id }),
updateInstructionsTool({ user_id, bot_id }),
updateMemoryTool({ user_id, bot_id, workspace_id }),
updateInstructionsTool({ user_id, bot_id, workspace_id }),
...(workspace_id && computers.length > 1
? [setWorkspaceComputerTool({ user_id, workspace_id, computers })]
: []),
];
}
@@ -0,0 +1,49 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import setWorkspaceComputer from "@/src/functions/backend/db/workspaces/set-workspace-computer";
import computerRef from "@/src/utils/computer-ref";
import type { HarnessTool } from "../../types";
type Params = {
user_id: number | string;
workspace_id: number | string;
/**
* The workspace's computers, the default first.
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
};
type Args = {
computer_id: number;
};
/**
* Lets a bot working in a workspace switch the workspace's default
* computer, where its tasks run from then on.
*/
export default function setWorkspaceComputerTool({
user_id,
workspace_id,
computers,
}: Params): HarnessTool<Args> {
const listed = computers
.map((c) => `${computerRef({ id: c.id || 0 })} ${c.name || "Computer"}`)
.join(", ");
return {
name: "set_workspace_computer",
description: `Make another of this workspace's computers its default, where tasks here run from now on (for the whole crew). Do it when the work needs that computer; say so to the user. Computers: ${listed}. The default now is ${computerRef({ id: computers[0]?.id || 0 })}.`,
schema: z.object({
computer_id: z.number().int(),
}),
run: async ({ args }) => {
const { computer } = await setWorkspaceComputer({
user_id,
workspace_id,
computer_id: args.computer_id,
});
return `${computerRef({ id: computer.id || 0 })} is the workspace's default computer now. Tasks you start from here on run there.`;
},
};
}
@@ -0,0 +1,56 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import updateWorkspaceBot from "@/src/functions/backend/db/workspaces/update-workspace-bot";
import type { HarnessTool } from "../../types";
type Params = {
user_id: number | string;
workspace_id: number | string;
/**
* The workspace's crew members (not the coordinator).
*/
workers: BUN_SQLITE_COBBLE_BOTS[];
};
type Args = {
bot_id: number;
instructions: string;
};
/**
* Lets the coordinator set a crew member's instructions for this
* workspace: how that bot should do its job here.
*/
export default function setWorkspaceInstructionsTool({
user_id,
workspace_id,
workers,
}: Params): HarnessTool<Args> {
const crew = workers.map((bot) => `${bot.id}: ${bot.name}`).join("; ");
return {
name: "set_bot_workspace_instructions",
description: `Replace a crew member's instructions for this workspace with this full text (markdown): how they should do their job here, on top of their own instructions. Use it when the user tells you how someone should work in this workspace. It replaces what they had for it, so keep what still applies. Crew here: ${crew}.`,
schema: z.object({
bot_id: z.number().int(),
instructions: z.string().trim().max(AppData.PromptMaxLength),
}),
run: async ({ args }) => {
const worker = workers.find((bot) => bot.id == args.bot_id);
if (!worker?.id) {
throw new Error(`No crew member here has id ${args.bot_id}. Crew here: ${crew}.`);
}
await updateWorkspaceBot({
user_id,
workspace_id,
bot_id: worker.id,
system_prompt: args.instructions,
});
return `${worker.name}'s instructions for this workspace are saved. They apply from their next run here.`;
},
};
}
+6
View File
@@ -6,6 +6,10 @@ import type { HarnessTool } from "../../types";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The session the task is started from; its result is posted there.
*/
session_id: number | string;
/**
* The bot's computers, the current one first (`grabBotComputers`).
*/
@@ -29,6 +33,7 @@ type Args = {
export default function startTaskTool({
user_id,
bot_id,
session_id,
computers,
message_id,
}: Params): HarnessTool<Args> {
@@ -54,6 +59,7 @@ export default function startTaskTool({
const job_id = await enqueueBotJob({
user_id,
bot_id,
session_id,
title: args.title,
instructions: args.instructions,
message_id,
@@ -1,33 +1,61 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import updateBotSystemPrompt from "@/src/functions/backend/db/bots/update-bot-system-prompt";
import updateWorkspaceBot from "@/src/functions/backend/db/workspaces/update-workspace-bot";
import type { HarnessTool } from "../../types";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The workspace the run is in: then the bot rewrites its instructions
* for the workspace unless it picks `core`.
*/
workspace_id?: number | string | null;
};
type Args = {
instructions: string;
scope?: "workspace" | "core";
};
/**
* Lets a bot rewrite its own core instructions as it adapts to the user.
* Lets a bot rewrite its own instructions as it adapts to the user: its
* core instructions, or in a workspace (by default) its instructions for
* that workspace.
*/
export default function updateInstructionsTool({
user_id,
bot_id,
}: Params): HarnessTool<{ instructions: string }> {
workspace_id,
}: Params): HarnessTool<Args> {
const instructions = z.string().trim().max(AppData.PromptMaxLength);
return {
name: "update_instructions",
description: `Replace your core instructions with this full text (markdown): the start of your prompt, before any workspace or memory. Change them only to work better for this user, such as a new standing rule they gave you. Keep everything that still applies.`,
schema: z.object({
instructions: z.string().trim().min(1).max(AppData.PromptMaxLength),
}),
description: workspace_id
? `Replace your instructions with this full text (markdown). scope "workspace" (the default) is your instructions for this workspace, under "# Your instructions in ..."; "core" is your core instructions, the start of your prompt in every workspace and your own chat. Change them only to work better for this user, such as a new standing rule they gave you. Keep everything that still applies.`
: `Replace your core instructions with this full text (markdown): the start of your prompt, before any workspace or memory. Change them only to work better for this user, such as a new standing rule they gave you. Keep everything that still applies.`,
schema: workspace_id
? z.object({ instructions, scope: z.enum(["workspace", "core"]).default("workspace") })
: z.object({ instructions: instructions.min(1) }),
run: async ({ args }) => {
if (workspace_id && args.scope != "core") {
await updateWorkspaceBot({
user_id,
workspace_id,
bot_id,
system_prompt: args.instructions,
});
return `Instructions for this workspace saved. They apply from your next run here.`;
}
await updateBotSystemPrompt({
user_id,
bot_id,
system_prompt: args.instructions,
});
return `Instructions saved. They apply from your next run.`;
return `Core instructions saved. They apply from your next run, everywhere.`;
},
};
}
+29 -7
View File
@@ -1,29 +1,51 @@
import { z } from "zod";
import { AppData } from "@/src/data/app-data";
import updateBotMemory from "@/src/functions/backend/db/bots/update-bot-memory";
import updateWorkspaceBot from "@/src/functions/backend/db/workspaces/update-workspace-bot";
import type { HarnessTool } from "../../types";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The workspace the run is in: then the bot rewrites its memory for
* the workspace unless it picks `core`.
*/
workspace_id?: number | string | null;
};
type Args = {
memory: string;
scope?: "workspace" | "core";
};
/**
* Lets a bot rewrite its own memory (shown to it under `# Memory`).
* Lets a bot rewrite its own memory: its core memory (under `# Memory`),
* or in a workspace (by default) its memory for that workspace.
*/
export default function updateMemoryTool({
user_id,
bot_id,
}: Params): HarnessTool<{ memory: string }> {
workspace_id,
}: Params): HarnessTool<Args> {
const memory = z.string().max(AppData.PromptMaxLength);
return {
name: "update_memory",
description: `Replace your memory with this full text (markdown). It's in your prompt under "# Memory" on every run. Keep what you must always remember: the user's preferences, facts about their work, decisions made. Anything you leave out is forgotten, so include the parts that still matter.`,
schema: z.object({
memory: z.string().max(AppData.PromptMaxLength),
}),
description: workspace_id
? `Replace a memory with this full text (markdown). scope "workspace" (the default) is your memory for this workspace, under "# Memory in ..."; "core" is your core memory, under "# Memory", in every workspace and your own chat. Keep what you must always remember: the user's preferences, facts about their work, decisions made. Anything you leave out is forgotten, so include the parts that still matter.`
: `Replace your memory with this full text (markdown). It's in your prompt under "# Memory" on every run. Keep what you must always remember: the user's preferences, facts about their work, decisions made. Anything you leave out is forgotten, so include the parts that still matter.`,
schema: workspace_id
? z.object({ memory, scope: z.enum(["workspace", "core"]).default("workspace") })
: z.object({ memory }),
run: async ({ args }) => {
if (workspace_id && args.scope != "core") {
await updateWorkspaceBot({ user_id, workspace_id, bot_id, memory: args.memory });
return `Memory for this workspace saved.`;
}
await updateBotMemory({ user_id, bot_id, memory: args.memory });
return `Memory saved.`;
return `Core memory saved.`;
},
};
}
@@ -0,0 +1,63 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import updateWorkspace from "@/src/functions/backend/db/workspaces/update-workspace";
import type { HarnessTool } from "../../types";
type Params = {
user_id: number | string;
/**
* The user's workspaces the coordinator may change.
*/
workspaces: BUN_SQLITE_COBBLE_WORKSPACES[];
/**
* The workspace the conversation is in: the one changed when no id is
* given.
*/
workspace_id?: number | string | null;
};
type Args = {
workspace_id?: number;
name?: string;
description?: string;
context?: string;
};
/**
* Lets the coordinator change a workspace's record: its name, its
* one-line description and the context every bot working there gets.
*/
export default function updateWorkspaceTool({ user_id, workspaces, workspace_id }: Params): HarnessTool<Args> {
const list = workspaces.map((w) => `${w.id}: ${w.name}`).join("; ");
return {
name: "update_workspace",
description: `Change a workspace: its name, its one-line description, or its context (markdown every bot working there gets in its prompt). Only the fields you give change; \`context\` replaces the whole text, so keep what still applies. Use it when the user asks, or to record something every bot in that workspace must know.${workspace_id ? " Leave out workspace_id for this workspace." : ""} Workspaces: ${list}.`,
schema: z.object({
workspace_id: z.number().int().optional(),
name: z.string().trim().min(1).max(60).optional(),
description: z.string().trim().max(240).optional(),
context: z.string().trim().max(AppData.PromptMaxLength).optional(),
}),
run: async ({ args }) => {
const target = workspaces.find((w) => w.id == (args.workspace_id || workspace_id));
if (!target?.id) {
throw new Error(`No workspace has id ${args.workspace_id ?? "(none given)"}. Workspaces: ${list}.`);
}
if (args.name === undefined && args.description === undefined && args.context === undefined) {
throw new Error(`Give a name, a description or a context to change.`);
}
await updateWorkspace({
user_id,
workspace_id: target.id,
data: { name: args.name, description: args.description, system_prompt: args.context },
});
return `${args.name || target.name} is saved. Bots there get the change from their next run.`;
},
};
}
@@ -12,10 +12,14 @@ import writeComputerEnv from "./write-computer-env";
type Params = {
computer_id: number | string;
/**
* Screenshots are read with this bot's media reading model, and only
* workspaces it shares with the computer add envs.
* Screenshots are read with this bot's media reading model.
*/
bot_id?: number | string;
/**
* The workspace the run is in: its envs apply, and its models read
* screenshots.
*/
workspace_id?: number | string | null;
signal?: AbortSignal;
};
@@ -45,6 +49,7 @@ export type OpenComputer = {
export default async function openComputerTools({
computer_id,
bot_id,
workspace_id,
signal,
}: Params): Promise<OpenComputer> {
const target = await grabComputerMcpUrl({ computer_id });
@@ -53,7 +58,7 @@ export default async function openComputerTools({
throw new Error(`Computer #${computer_id} has no container to connect to.`);
}
const envs = await grabComputerEnvs({ computer_id, bot_id });
const envs = await grabComputerEnvs({ computer_id, workspace_id });
const paths = await writeComputerEnv({ base_url: target.base_url, envs, signal });
const secrets = envs.filter((env) => env.secret);
const redact = (text: string) => redactSecrets({ text, secrets });
@@ -71,6 +76,7 @@ export default async function openComputerTools({
focus: "This is a screenshot of a computer screen. Describe the page layout, text, buttons and form fields, and any errors or popups.",
signal,
bot_id,
workspace_id,
}),
});
@@ -9,7 +9,10 @@ import type { HarnessTool } from "../../types";
import runComputerCommand from "./run-computer-command";
type Params = {
bot_id: number | string;
/**
* The workspace the run is in: its envs apply.
*/
workspace_id?: number | string | null;
/**
* The bot's computers, the current one first (`grabBotComputers`).
*/
@@ -27,7 +30,7 @@ type Args = {
* so the bot can tell the user exactly which computer holds what.
*/
export default function searchComputersTool({
bot_id,
workspace_id,
computers,
}: Params): HarnessTool<Args> {
return {
@@ -53,7 +56,7 @@ export default function searchComputersTool({
timeout_ms: AppData.ComputerSearchTimeoutMs,
signal,
}),
grabComputerEnvs({ computer_id: computer.id || 0, bot_id }),
grabComputerEnvs({ computer_id: computer.id || 0, workspace_id }),
]);
const output = redactSecrets({