Add computer switching, crew reporting, job steps and markdown chat

- Bots have a current computer (bots.computer_id), switched from the chat
  header beside the model button or by clicking a computer the bot names
  ([computer:ID]); tasks run on it by default
- search_computers runs one read-only command on all of a bot's computers;
  chat replies know their computers (compose-computers-note)
- The coordinator can use every computer and grants or revokes crew access
  with assign_computer
- Delegated jobs record requested_by_bot_id; the worker's result or failure
  is reported back in the coordinator's conversation
- bots_queue_updates records each model turn of a job, pushed live and shown
  in the working-on panel
- Live relay: every process pushes new messages, job steps and bot changes
  from the database to its own sockets, so work done elsewhere shows live
- Bot messages render as markdown (react-markdown + remark-gfm, no raw HTML)
  with twui components; rail previews are plain text
- Fix header button overflow in the model and computer selects
- Bump @moduletrace/twui to 0.1.2

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 04:44:35 +01:00
co-authored by Claude Opus 5.5
parent 6a2665dae9
commit 7b580d782e
64 changed files with 1792 additions and 86 deletions
@@ -0,0 +1,89 @@
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";
type Params = {
user_id: number | string;
/**
* The user's worker bots.
*/
workers: BUN_SQLITE_COBBLE_BOTS[];
/**
* Every computer the user has (the coordinator's).
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
/**
* Each worker's computers now, by bot id, for the description.
*/
access: Record<number, BUN_SQLITE_COBBLE_COMPUTERS[]>;
};
type Args = {
bot_id: number;
computer_id: number;
access: "grant" | "revoke";
};
/**
* 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.
*/
export default function assignComputerTool({
user_id,
workers,
computers,
access,
}: Params): HarnessTool<Args> {
const crew = workers
.map((bot) => {
const has = access[bot.id || 0] || [];
return `${bot.id}: ${bot.name} (${has.length ? has.map((c) => computerRef({ id: c.id || 0 })).join(", ") : "no computer"})`;
})
.join("; ");
return {
name: "assign_computer",
description: `Give a crew member access to a computer, or take it away. A bot works on its computers and its delegated tasks run on the one it's on. Crew and their computers now: ${crew}. Computers: ${computers
.map((c) => `${computerRef({ id: c.id || 0 })} ${c.name || "Computer"}`)
.join(", ")}.`,
schema: z.object({
bot_id: z.number().int(),
computer_id: z.number().int(),
access: z.enum(["grant", "revoke"]),
}),
run: async ({ args }) => {
const worker = workers.find((bot) => bot.id == args.bot_id);
const computer = computers.find((c) => c.id == args.computer_id);
if (!worker?.id) {
throw new Error(`No crew member has id ${args.bot_id}.`);
}
if (!computer?.id) {
throw new Error(`No computer has id ${args.computer_id}.`);
}
const ref = computerRef({ id: computer.id });
if (args.access == "grant") {
await addBotToComputer({ user_id, bot_id: worker.id, computer_id: computer.id });
return `${worker.name} can use ${ref} now.`;
}
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.`;
},
};
}
+11 -4
View File
@@ -8,6 +8,10 @@ import type { HarnessTool } from "../../types";
type Params = {
user_id: number | string;
/**
* The coordinator: the worker's result comes back to its conversation.
*/
coordinator_id: number | string;
/**
* The coordinator's name, shown in the worker's conversation.
*/
@@ -26,11 +30,13 @@ type Args = {
/**
* Lets the coordinator hand a task to a worker bot. The worker runs it on
* its own main computer (if it has one) and posts the result in its own
* conversation, where a note says who asked.
* the computer it's on (if it has one) and posts the result in its own
* conversation, where a note says who asked; the result (or failure) is
* also reported back in the coordinator's conversation.
*/
export default function delegateTaskTool({
user_id,
coordinator_id,
coordinator_name,
workers,
}: Params): HarnessTool<Args> {
@@ -40,7 +46,7 @@ export default function delegateTaskTool({
return {
name: "delegate_task",
description: `Hand a task to a crew member. They run it in the background and post the result in their own conversation. Write the instructions as a full brief: what to do, where, and how they'll know it's done. Crew: ${crew}.`,
description: `Hand a task to a crew member. They run it in the background and post the result in their own conversation; it's also reported back here when they finish or fail. Write the instructions as a full brief: what to do, where, and how they'll know it's done. Crew: ${crew}.`,
schema: z.object({
bot_id: z.number().int(),
title: z.string().min(1).max(80),
@@ -61,6 +67,7 @@ export default function delegateTaskTool({
title: args.title,
instructions: args.instructions,
computer_id: computers[0]?.id || undefined,
requested_by_bot_id: coordinator_id,
});
await pushBotNotice({
@@ -71,7 +78,7 @@ export default function delegateTaskTool({
}),
});
return `Task #${job_id} is with ${worker.name}. Their result will be in their conversation.`;
return `Task #${job_id} is with ${worker.name}. Their result will be in their conversation and reported back here.`;
},
};
}
+28 -4
View File
@@ -1,7 +1,9 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import type { HarnessTool } from "../../types";
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 startTaskTool from "./start-task-tool";
@@ -9,6 +11,10 @@ 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`).
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
/**
* The user message being answered.
*/
@@ -17,12 +23,15 @@ type Params = {
/**
* The tools a bot replies in chat with: its own (memory, instructions),
* start_task for longer work, and for the coordinator delegate_task to
* the crew. Computers are reached through tasks, never mid-reply.
* start_task for longer work, search_computers to find what's on its
* computers, 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.
*/
export default async function grabBotChatTools({
bot,
user_id,
computers,
message_id,
}: Params): Promise<HarnessTool[]> {
const bot_id = bot.id || 0;
@@ -31,9 +40,10 @@ export default async function grabBotChatTools({
startTaskTool({
user_id,
bot_id,
computers: await grabBotComputers({ bot_id, user_id }),
computers,
message_id,
}),
...(computers.length ? [searchComputersTool({ bot_id, computers })] : []),
];
if (bot.kind != "coordinator") return tools;
@@ -50,10 +60,24 @@ export default async function grabBotChatTools({
tools.push(
delegateTaskTool({
user_id,
coordinator_id: bot_id,
coordinator_name: bot.name || "The coordinator",
workers,
}),
);
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 }));
}
}
return tools;
+2 -2
View File
@@ -7,7 +7,7 @@ type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The bot's computers, its main one first (`grabBotComputers`).
* The bot's computers, the current one first (`grabBotComputers`).
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
/**
@@ -40,7 +40,7 @@ export default function startTaskTool({
name: "start_task",
description: `Queue work that takes a while or needs a computer. It runs in the background and its result is posted in this chat. Write the instructions as a full brief for yourself: what to do, where, and how you'll know it's done. ${
computers.length
? `Computers you can use: ${listed}. Defaults to ${computers[0]!.id}.`
? `Computers you can use: ${listed}. Defaults to ${computers[0]!.id}, the one you're on.`
: `You have no computer, so the task can't run commands or browse.`
}`,
schema: z.object({
@@ -0,0 +1,41 @@
type Params = {
/**
* The sandbox's REST address (no `/mcp`).
*/
base_url: string;
command: string;
timeout_ms: number;
signal?: AbortSignal;
};
/**
* Runs one shell command on a computer over the sandbox's REST API
* (`/v1/shell/exec`), outside any MCP session. Throws when the computer
* can't be reached or refuses the command.
*/
export default async function runComputerCommand({
base_url,
command,
timeout_ms,
signal,
}: Params): Promise<{ output: string; exit_code: number | null }> {
const res = await fetch(`${base_url}/v1/shell/exec`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ command }),
signal: AbortSignal.any([
...(signal ? [signal] : []),
AbortSignal.timeout(timeout_ms),
]),
});
const body: any = await res.json().catch(() => ({}));
if (!res.ok || body?.success === false) {
throw new Error(`The computer refused the command: ${body?.message || res.status}`);
}
return {
output: String(body?.data?.output ?? ""),
exit_code: typeof body?.data?.exit_code == "number" ? body.data.exit_code : null,
};
}
@@ -0,0 +1,82 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import { AppData } from "@/src/data/app-data";
import grabComputerMcpUrl from "@/src/functions/backend/db/computers/grab-computer-mcp-url";
import grabComputerEnvs from "@/src/functions/backend/db/envs/grab-computer-envs";
import computerRef from "@/src/utils/computer-ref";
import redactSecrets from "@/src/utils/redact-secrets";
import type { HarnessTool } from "../../types";
import runComputerCommand from "./run-computer-command";
type Params = {
bot_id: number | string;
/**
* The bot's computers, the current one first (`grabBotComputers`).
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
};
type Args = {
command: string;
};
/**
* Lets a bot look for something across every computer it has: one shell
* command runs on each at once (`runComputerCommand`), and the outputs come
* back labelled with each computer's ref (`computerRef`), secrets redacted,
* so the bot can tell the user exactly which computer holds what.
*/
export default function searchComputersTool({
bot_id,
computers,
}: Params): HarnessTool<Args> {
return {
name: "search_computers",
description: `Run one read-only shell command (bash) on every computer you have at once, to find which one holds something: a file, a running app, a logged-in browser session, a setting. Each computer's output comes back under its ref, e.g. ${computerRef({ id: computers[0]?.id || 1 })}. For a logged-in site, look for its domain in the browser profiles' cookie databases (copy the file first, since the browser locks it) and list only host names, never cookie values. Don't change anything with it; use start_task for that. Output past ${AppData.ComputerSearchMaxOutputChars} characters per computer is cut, so filter in the command.`,
schema: z.object({
command: z.string().min(1).max(4000),
}),
run: async ({ args, signal }) => {
const results = await Promise.all(
computers.map(async (computer) => {
const label = `## ${computerRef({ id: computer.id || 0 })} ${computer.name || "Computer"}`;
try {
const target = await grabComputerMcpUrl({ computer_id: computer.id || 0 });
if (!target) return `${label}\nNot reachable: it has no container.`;
const [result, envs] = await Promise.all([
runComputerCommand({
base_url: target.base_url,
command: args.command,
timeout_ms: AppData.ComputerSearchTimeoutMs,
signal,
}),
grabComputerEnvs({ computer_id: computer.id || 0, bot_id }),
]);
const output = redactSecrets({
text: result.output.trim(),
secrets: envs.filter((env) => env.secret),
});
const cut = output.length > AppData.ComputerSearchMaxOutputChars;
return [
label,
`Exit code: ${result.exit_code ?? "unknown"}`,
output ? output.slice(0, AppData.ComputerSearchMaxOutputChars) : "(no output)",
cut ? `(output cut at ${AppData.ComputerSearchMaxOutputChars} characters)` : "",
]
.filter(Boolean)
.join("\n");
} catch (error) {
return `${label}\nNot reachable: ${error instanceof Error ? error.message : String(error)}`;
}
}),
);
return results.join("\n\n");
},
};
}