From 6769b46f67e2b534a21eb280c6ee924877035af6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 00:36:52 +0000 Subject: [PATCH 01/24] Turn the web dashboard into a control center The web dashboard could watch a team but not act on it. It now covers the CLI and orchestrator operations used day to day: messaging and broadcast, session and agent pause/resume, release (all, or approved only), add, remove, rename and change role of agents, New Team, session create, relaunch, archive, end, export and delete, plan and knowledge editing, file locks and ownership zones, and guardrail steppers. Agent processes live in the memory of the process that launched them, so the orchestrator's tools move to orchestrator/runtime.ts. The MCP server and the web dashboard both host it, and the web runs the same create_team, add_agent, release_agent, end_session and resume_session code instead of a copy. dashboard/control.ts is the API: every mutation names its session, checks that the slot belongs to it, and returns the broker's answer or an error. A human gate (session config human_gate, on for teams started from the browser, create_team's human_gate for MCP) keeps a fully approved session waiting instead of ending itself. From there the person releases the agents or sends one back with actionable feedback. The broker gains plan item add/edit/delete and a merging config_patch on session update. Messages from the browser use sender id "human". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SP5EAuSkQZm3jj3PTTWDfN --- CLAUDE.md | 2 + README.md | 28 +- broker.ts | 79 +- cli/session.ts | 26 +- dashboard/control.ts | 472 +++++++ dashboard/index.html | 1943 ++++++++++++++++++++------ dashboard/server.ts | 171 ++- docs/web-control-center.md | 99 ++ orchestrator/orchestrator-server.ts | 1934 +------------------------ orchestrator/runtime.ts | 2015 +++++++++++++++++++++++++++ orchestrator/session-control.ts | 7 +- shared/broker-client.ts | 16 + shared/session-ops.ts | 64 + shared/types.ts | 19 + tests/web-control.test.ts | 311 +++++ 15 files changed, 4759 insertions(+), 2427 deletions(-) create mode 100644 dashboard/control.ts create mode 100644 docs/web-control-center.md create mode 100644 orchestrator/runtime.ts create mode 100644 shared/session-ops.ts create mode 100644 tests/web-control.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index f27488f..a74b518 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,8 @@ Peer discovery and messaging MCP channel for Claude Code instances. - `orchestrator/claude-driver.ts` — Long-lived `claude -p --input-format stream-json` session; teammate messages are written to stdin and read mid-turn. `system/init` only arrives after the first message; closing stdin ends the session. - `orchestrator/codex-driver.ts` — `codex app-server` JSON-RPC client (shapes verified against the 0.156.1 schema). - `orchestrator/delivery.ts` — Hands unacked messages to a driver (`retain` poll) and acks them on confirmed hand-off. +- `orchestrator/runtime.ts` — The orchestrator itself: launched processes and drivers, background supervision loops, and the tool implementations (`ORCHESTRATOR_TOOLS`, `callOrchestratorTool`). Hosted by `orchestrator-server.ts` (MCP stdio) and by `dashboard/server.ts`; each host supervises only the agents it launched. +- `dashboard/control.ts` — Web control API (`POST /api/`). Every mutation names its `session_id` and checks the slot belongs to it; lifecycle actions call `callOrchestratorTool`, record edits go to the broker. Human actions use sender id `human`. A session with `config.human_gate` is not auto-ended while agents sit in `approved`. - `orchestrator/supervision.ts` — Driver registry (one driver per slot), non-overlapping loop ticks, release and watchdog verdicts; pure enough to unit-test. - `orchestrator/monitor.ts` — Slot summaries and token accounting. Claude's `result.usage` is per turn (added as is); Codex's `thread/tokenUsage/updated.total` is cumulative (diffed). - `shared/types.ts` — Shared TypeScript types for broker API. diff --git a/README.md b/README.md index 97e1949..1ceecdf 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Session monitoring stats and enforced limits: | Messages Per Agent | Monitor | Per agent | Observe | | Longest Idle | Monitor | Per agent | Observe | -Adjustable from the TUI dashboard (+/- keys) or via the `adjust_guardrail` tool; the web dashboard shows them read-only (its `/api/guardrail` endpoint exists but has no UI yet). +Adjustable from the TUI dashboard (+/- keys), the web dashboard's Stats tab, or the `adjust_guardrail` tool. ## Web Dashboard @@ -219,16 +219,21 @@ Adjustable from the TUI dashboard (+/- keys) or via the `adjust_guardrail` tool; multiagents web [session-id] ``` -Real-time web dashboard on `localhost:7900` with live WebSocket updates. Auto-opens in browser when a team is created via the orchestrator. 6-tab interface: +Web control center on `localhost:7900` with live WebSocket updates. Auto-opens in browser when a team is created via the orchestrator, and starts the broker itself when it is not running. Besides watching, you can run a team from the browser: -- **Agents**: agent cards with connection status, task state, role, summaries, token usage -- **Messages**: live message feed with type badges and sender names -- **Plan**: task progress with completion bar and assignee labels -- **Knowledge**: shared knowledge entries with categories and provenance -- **Files**: file locks and ownership zones -- **Stats**: session metrics (connected agents, working, tokens) and guardrail bars +- **Sessions** (sidebar): New Team, empty session, open, pause/resume, relaunch disconnected agents, export JSON, archive, end, delete (typed confirmation) +- **Agents**: message, pause/resume, change role, rename, send back for revision, release, remove; Add Agent; Release all approved / Release all +- **Messages**: live feed (`Human → agent` for what you send) with a composer for one agent or a broadcast to the team +- **Plan**: add, edit, reassign, change status (pending / in progress / blocked / done), delete +- **Knowledge**: add, edit, delete entries by category +- **Files**: locks with holder and expiry, force unlock, reserve a file for an agent, assign ownership zones +- **Stats**: session metrics and guardrail steppers (changes logged as "Adjusted via web dashboard") -Dark theme, responsive layout, keyboard shortcuts (`1-6` to switch tabs). +**Human gate.** A team started from the browser can wait for you once every agent is approved: instead of ending itself, the session shows *Waiting for human approval* with **Approve & Release** and **Send back for revision** (actionable feedback to a worker, which restarts the review loop). Toggle it per session with the `human gate` chip; MCP's `create_team` takes `human_gate: true`. + +Teams and agents launched from the browser are supervised by the dashboard process (the same orchestrator runtime the MCP server runs), so keep `multiagents web` running while they work; stopping it stops them. Agents another orchestrator launched stay under that orchestrator. See [docs/web-control-center.md](docs/web-control-center.md) for the API. + +Responsive layout, keyboard shortcuts (`1-6` to switch tabs). ## TUI Dashboard @@ -278,6 +283,7 @@ multiagents/ │ ├── broker-client.ts HTTP client for broker API │ ├── broker-url.ts The one place MULTIAGENTS_PORT is resolved │ ├── constants.ts Ports, intervals, thresholds +│ ├── session-ops.ts Session create/export shared by the CLI and the web dashboard │ ├── summarize.ts Git-based activity summary (no API calls) │ └── utils.ts Shared utilities ├── adapters/ @@ -289,7 +295,8 @@ multiagents/ │ ├── gemini-adapter.ts Gemini adapter (tool-result delivery + file inbox) │ └── role-practices.ts Role-specific best practices injection ├── orchestrator/ -│ ├── orchestrator-server.ts Orchestrator MCP server (team management, driver delivery loop) +│ ├── orchestrator-server.ts Orchestrator MCP server (stdio host of runtime.ts) +│ ├── runtime.ts Orchestrator runtime: tools, agent processes, supervision loops (shared with the web dashboard) │ ├── claude-driver.ts ClaudeDriver: long-lived `claude -p --input-format stream-json` session │ ├── codex-driver.ts CodexDriver: persistent codex app-server via JSON-RPC (steer/interrupt) │ ├── delivery.ts Hands unacked messages to drivers, acks on confirmed hand-off @@ -311,6 +318,7 @@ multiagents/ │ └── update.ts `multiagents update` ├── dashboard/ │ ├── server.ts Web dashboard (Bun.serve + WebSocket, localhost:7900) +│ ├── control.ts Web control API (POST /api/*) │ └── index.html Web dashboard UI └── scripts/ ├── version.ts Version bump + release commit/tag diff --git a/broker.ts b/broker.ts index 7413f68..7427ff5 100755 --- a/broker.ts +++ b/broker.ts @@ -62,6 +62,8 @@ import type { KnowledgeListRequest, KnowledgeGetRequest, KnowledgeDeleteRequest, + AddPlanItemRequest, + EditPlanItemRequest, } from "./shared/types.ts"; // --- Configuration --- @@ -879,7 +881,12 @@ function handleUpdateSession(body: UpdateSessionRequest): Session | null { if (body.status !== undefined) { fields.push("status = ?"); values.push(body.status); } if (body.pause_reason !== undefined) { fields.push("pause_reason = ?"); values.push(body.pause_reason); } if (body.paused_at !== undefined) { fields.push("paused_at = ?"); values.push(body.paused_at); } - if (body.config !== undefined) { fields.push("config = ?"); values.push(JSON.stringify(body.config)); } + let config = body.config; + if (body.config_patch !== undefined) { + const current = config ?? parseSnapshot((db.query("SELECT config FROM sessions WHERE id = ?").get(body.id) as { config: string | null } | null)?.config ?? null); + config = { ...current, ...body.config_patch }; + } + if (config !== undefined) { fields.push("config = ?"); values.push(JSON.stringify(config)); } fields.push("last_active_at = ?"); values.push(Date.now()); @@ -1191,6 +1198,8 @@ function handleUpdateGuardrail(body: UpdateGuardrailRequest) { // --- Plans --- +const PLAN_ITEM_STATUSES = new Set(["pending", "in_progress", "blocked", "done"]); + interface CreatePlanRequest { session_id: string; title: string; @@ -1260,6 +1269,64 @@ function handleUpdatePlanItem(body: { item_id: number; status: string; session_i return { ok: true }; } +/** The plan item, provided it belongs to the session's plan. */ +function sessionPlanItem(sessionId: string, itemId: number): { id: number; plan_id: number } | null { + return db.query( + "SELECT pi.id, pi.plan_id FROM plan_items pi JOIN plans p ON p.id = pi.plan_id WHERE pi.id = ? AND p.session_id = ?", + ).get(itemId, sessionId) as { id: number; plan_id: number } | null; +} + +/** Append an item to the session's plan, creating the plan if it has none. */ +function handleAddPlanItem(body: AddPlanItemRequest) { + if (!body.session_id) throw new Error("session_id required"); + if (!body.label || !body.label.trim()) throw new Error("label required"); + const now = Date.now(); + let plan = db.query("SELECT id FROM plans WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(body.session_id) as { id: number } | null; + if (!plan) { + const session = db.query("SELECT name FROM sessions WHERE id = ?").get(body.session_id) as { name: string } | null; + if (!session) throw new Error(`Session ${body.session_id} not found`); + db.run("INSERT INTO plans (session_id, title, created_at, updated_at) VALUES (?, ?, ?, ?)", [body.session_id, body.title ?? session.name, now, now]); + plan = db.query("SELECT id FROM plans WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(body.session_id) as { id: number }; + } + const last = db.query("SELECT MAX(sort_order) AS max FROM plan_items WHERE plan_id = ?").get(plan.id) as { max: number | null }; + db.run( + "INSERT INTO plan_items (plan_id, parent_id, label, status, assigned_to_slot, sort_order) VALUES (?, NULL, ?, 'pending', ?, ?)", + [plan.id, body.label.trim(), body.assigned_to_slot ?? null, (last.max ?? -1) + 1], + ); + db.run("UPDATE plans SET updated_at = ? WHERE id = ?", [now, plan.id]); + return handleGetPlan({ session_id: body.session_id }); +} + +/** Change an item's label, status or assignee (null unassigns). */ +function handleEditPlanItem(body: EditPlanItemRequest) { + const item = sessionPlanItem(body.session_id, body.item_id); + if (!item) return null; + const fields: string[] = []; + const values: any[] = []; + if (body.label !== undefined) { + if (!body.label.trim()) throw new Error("label must not be empty"); + fields.push("label = ?"); values.push(body.label.trim()); + } + if (body.status !== undefined) { + if (!PLAN_ITEM_STATUSES.has(body.status)) throw new Error(`Invalid status "${body.status}"`); + fields.push("status = ?", "completed_at = ?"); values.push(body.status, body.status === "done" ? Date.now() : null); + } + if (body.assigned_to_slot !== undefined) { fields.push("assigned_to_slot = ?"); values.push(body.assigned_to_slot); } + if (fields.length > 0) { + db.run(`UPDATE plan_items SET ${fields.join(", ")} WHERE id = ?`, [...values, item.id]); + db.run("UPDATE plans SET updated_at = ? WHERE id = ?", [Date.now(), item.plan_id]); + } + return handleGetPlan({ session_id: body.session_id }); +} + +function handleDeletePlanItem(body: { session_id: string; item_id: number }) { + const item = sessionPlanItem(body.session_id, body.item_id); + if (!item) return null; + db.run("DELETE FROM plan_items WHERE id = ?", [item.id]); + db.run("UPDATE plans SET updated_at = ? WHERE id = ?", [Date.now(), item.plan_id]); + return handleGetPlan({ session_id: body.session_id }); +} + // --- Message log --- function handleMessageLog(body: { session_id: string } & MessageLogOptions) { @@ -1461,6 +1528,16 @@ Bun.serve({ return Response.json(handleGetPlan(body as { session_id: string })); case "/plan/update-item": return Response.json(handleUpdatePlanItem(body as { item_id: number; status: string; session_id?: string })); + case "/plan/add-item": + return Response.json(handleAddPlanItem(body as AddPlanItemRequest)); + case "/plan/edit-item": { + const edited = handleEditPlanItem(body as EditPlanItemRequest); + return edited ? Response.json(edited) : Response.json({ error: "Plan item not found in session" }, { status: 404 }); + } + case "/plan/delete-item": { + const remaining = handleDeletePlanItem(body as { session_id: string; item_id: number }); + return remaining ? Response.json(remaining) : Response.json({ error: "Plan item not found in session" }, { status: 404 }); + } // --- Message log --- case "/message-log": diff --git a/cli/session.ts b/cli/session.ts index 1e3ce48..8f5caa6 100644 --- a/cli/session.ts +++ b/cli/session.ts @@ -2,10 +2,11 @@ // multiagents — Session Management Commands // ============================================================================ -import { SESSION_DIR, SESSION_FILE } from "../shared/constants.ts"; +import { SESSION_FILE } from "../shared/constants.ts"; import { resolveBrokerPort, brokerUrl } from "../shared/broker-url.ts"; import { BrokerClient } from "../shared/broker-client.ts"; -import { getGitRoot, formatTime, timeSince, slugify } from "../shared/utils.ts"; +import { formatTime, timeSince } from "../shared/utils.ts"; +import { createSessionRecord } from "../shared/session-ops.ts"; import type { SessionFile } from "../shared/types.ts"; import * as readline from "node:readline"; import * as path from "node:path"; @@ -71,26 +72,7 @@ async function create(client: BrokerClient, name?: string): Promise { process.exit(1); } - const projectDir = process.cwd(); - const gitRoot = await getGitRoot(projectDir); - const sessionId = slugify(name); - - const session = await client.createSession({ - id: sessionId, - name, - project_dir: projectDir, - git_root: gitRoot, - }); - - // Write local session file - const sessionDir = path.join(projectDir, SESSION_DIR); - if (!fs.existsSync(sessionDir)) fs.mkdirSync(sessionDir, { recursive: true }); - const sessionFile: SessionFile = { - session_id: session.id, - created_at: new Date().toISOString(), - broker_port: BROKER_PORT, - }; - await Bun.write(path.join(projectDir, SESSION_FILE), JSON.stringify(sessionFile, null, 2)); + const session = await createSessionRecord(client, name, process.cwd(), BROKER_PORT); console.log(`Session created: ${session.name} (${session.id})`); console.log(`Wrote ${SESSION_FILE}`); diff --git a/dashboard/control.ts b/dashboard/control.ts new file mode 100644 index 0000000..e05931d --- /dev/null +++ b/dashboard/control.ts @@ -0,0 +1,472 @@ +// ============================================================================ +// multiagents — Web Dashboard control API +// ============================================================================ +// The mutating half of the web dashboard. Every handler names its session +// explicitly and checks that the slot it touches belongs to that session. +// Agent lifecycle (launch, release, remove, end, resume) goes through the +// orchestrator runtime, the same code the orchestrator MCP tools run; plain +// records (messages, plan, knowledge, locks, guardrails) go to the broker. +// ============================================================================ + +import type { BrokerClient } from "../shared/broker-client.ts"; +import type { AgentLaunchConfig, AgentType, KnowledgeCategory, Session, Slot } from "../shared/types.ts"; +import { safeJsonParse } from "../shared/utils.ts"; +import { createSessionRecord, exportSessionData } from "../shared/session-ops.ts"; +import { callOrchestratorTool, type ToolResult } from "../orchestrator/runtime.ts"; +import { controlSession, broadcastToTeam } from "../orchestrator/session-control.ts"; +import { detectAgent } from "../orchestrator/launcher.ts"; + +/** Sender id of everything a person does from the browser. */ +export const HUMAN_ID = "human"; + +const AGENT_TYPES: AgentType[] = ["claude", "codex", "gemini"]; +const KNOWLEDGE_CATEGORIES: KnowledgeCategory[] = ["decision", "convention", "discovery", "blocker", "context"]; +const PLAN_STATUSES = ["pending", "in_progress", "blocked", "done"] as const; + +export class ApiError extends Error { + constructor(message: string, readonly status = 400) { + super(message); + } +} + +export interface ControlContext { + broker: BrokerClient; + brokerPort: number; +} + +type Body = Record; +type Handler = (ctx: ControlContext, body: Body) => Promise; + +// --- Validation helpers --- + +function str(body: Body, key: string, opts: { optional?: boolean; allowEmpty?: boolean } = {}): string { + const v = body[key]; + if (v === undefined || v === null || v === "") { + if (opts.optional || (opts.allowEmpty && v === "")) return ""; + throw new ApiError(`${key} is required`); + } + if (typeof v !== "string") throw new ApiError(`${key} must be a string`); + return v; +} + +function int(body: Body, key: string): number { + const v = typeof body[key] === "string" ? Number(body[key]) : body[key]; + if (typeof v !== "number" || !Number.isInteger(v)) throw new ApiError(`${key} must be an integer`); + return v; +} + +async function requireSession(ctx: ControlContext, body: Body): Promise { + const id = str(body, "session_id"); + const session = await ctx.broker.getSession(id).catch(() => null); + if (!session) throw new ApiError(`Session "${id}" not found`, 404); + return session; +} + +/** The slot, provided it belongs to the session: a stale tab must not act on another session's agent. */ +async function requireSlot(ctx: ControlContext, session: Session, body: Body): Promise { + const slotId = int(body, "slot_id"); + const slot = (await ctx.broker.listSlots(session.id)).find((s) => s.id === slotId); + if (!slot) throw new ApiError(`Agent slot ${slotId} is not part of session "${session.id}"`, 404); + return slot; +} + +/** Run an orchestrator tool; its failure becomes an API error rather than a quiet success. */ +async function tool(name: string, args: Body): Promise<{ message: string; data?: Record }> { + const result: ToolResult = await callOrchestratorTool(name, args); + const message = result.content.map((c) => c.text).join("\n"); + if (result.isError) throw new ApiError(message.replace(/^Error: /, ""), 422); + return { message, data: result.data }; +} + +/** Peer id that addresses a slot, also when a driver runs it without one. */ +function slotAddress(slot: Slot): string { + return slot.peer_id ?? `__slot_${slot.id}__`; +} + +// --- One launch at a time per key, so a double click cannot spawn agents twice --- + +const inFlight = new Set(); + +async function exclusive(key: string, fn: () => Promise): Promise { + if (inFlight.has(key)) throw new ApiError("The same operation is already running — wait for it to finish", 409); + inFlight.add(key); + try { + return await fn(); + } finally { + inFlight.delete(key); + } +} + +// --- Team workflow --- + +export interface WorkflowOptions { + require_reviewer_approval?: boolean; + require_actionable_feedback?: boolean; + /** Hold the session for a person once everything is approved (human gate). */ + human_release?: boolean; +} + +/** Workflow rules every agent of a web-built team is told up front. */ +export function workflowInstructions(goal: string, workflow: WorkflowOptions): string { + const rules: string[] = []; + if (workflow.require_reviewer_approval) { + rules.push("Work is finished only when a reviewer has explicitly approved it (approve). Do not treat silence as approval."); + } + if (workflow.require_actionable_feedback) { + rules.push("Reviewers: when you request changes, send concrete, actionable feedback (submit_feedback with actionable=true) naming what to change. Workers: revise only against such feedback."); + } + if (workflow.human_release) { + rules.push("A human makes the final call: after approval, wait. Do not start new work, and do not disconnect until you receive a release message."); + } + const parts: string[] = []; + if (goal.trim()) parts.push(`TEAM GOAL:\n${goal.trim()}`); + if (rules.length) parts.push(`WORKFLOW RULES:\n${rules.map((r) => `- ${r}`).join("\n")}`); + return parts.join("\n\n"); +} + +function parseAgentConfig(raw: unknown, index: number): AgentLaunchConfig { + if (!raw || typeof raw !== "object") throw new ApiError(`Agent ${index + 1} is invalid`); + const a = raw as Body; + const agentType = str(a, "agent_type") as AgentType; + if (!AGENT_TYPES.includes(agentType)) throw new ApiError(`Agent ${index + 1}: unknown type "${agentType}"`); + const name = str(a, "name").trim(); + const role = str(a, "role").trim(); + if (!name || !role) throw new ApiError(`Agent ${index + 1}: name and role are required`); + const ownership = Array.isArray(a.file_ownership) + ? a.file_ownership.filter((p): p is string => typeof p === "string" && p.trim() !== "").map((p) => p.trim()) + : []; + return { + agent_type: agentType, + name, + role, + role_description: str(a, "role_description", { optional: true }).trim() || role, + initial_task: str(a, "initial_task", { optional: true }).trim(), + ...(ownership.length ? { file_ownership: ownership } : {}), + }; +} + +// --- Handlers --- + +const AVAILABILITY_TTL_MS = 5 * 60_000; +let availability: { at: number; result: Record } | null = null; + +const handlers: Record = { + // ---- Broker / environment ---- + + async "agents/available"() { + // Detection spawns the CLIs (gemini via npx, up to 15s), so the answer is cached briefly + if (!availability || Date.now() - availability.at > AVAILABILITY_TTL_MS) { + const entries = await Promise.all(AGENT_TYPES.map(async (t) => [t, (await detectAgent(t)).available] as const)); + availability = { at: Date.now(), result: Object.fromEntries(entries) }; + } + return availability.result; + }, + + // ---- Sessions ---- + + async "session/create"(ctx, body) { + const name = str(body, "name").trim(); + const projectDir = str(body, "project_dir").trim(); + if (!name) throw new ApiError("name is required"); + const fs = await import("node:fs"); + if (!fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) { + throw new ApiError(`Project directory "${projectDir}" does not exist`); + } + return await createSessionRecord(ctx.broker, name, projectDir, ctx.brokerPort); + }, + + async "session/control"(ctx, body) { + const session = await requireSession(ctx, body); + const action = str(body, "action"); + if (action !== "pause" && action !== "resume") throw new ApiError(`Unknown action "${action}"`); + const result = await controlSession(session.id, action === "pause" ? "pause_all" : "resume_all", ctx.broker); + if (result.status === "error") throw new ApiError(result.message, 422); + return { message: result.message, affected: result.affected }; + }, + + /** Respawn the session's disconnected, unreleased agents under this host. */ + async "session/relaunch"(ctx, body) { + const session = await requireSession(ctx, body); + return await exclusive(`launch:${session.id}`, () => tool("resume_session", { session_id: session.id })); + }, + + /** Archive the record only (`multiagents session archive`); running agents are untouched. */ + async "session/archive"(ctx, body) { + const session = await requireSession(ctx, body); + await ctx.broker.updateSession({ id: session.id, status: "archived" }); + return { message: `Session "${session.id}" archived.` }; + }, + + /** Stop the agents this host runs, disconnect every slot and archive (end_session). */ + async "session/end"(ctx, body) { + const session = await requireSession(ctx, body); + return await tool("end_session", { session_id: session.id }); + }, + + async "session/delete"(ctx, body) { + const session = await requireSession(ctx, body); + if (body.confirm !== session.id) throw new ApiError("Type the session id to confirm deletion"); + return await tool("delete_session", { session_id: session.id }); + }, + + async "session/human-gate"(ctx, body) { + const session = await requireSession(ctx, body); + const enabled = body.enabled === true; + await ctx.broker.updateSession({ id: session.id, config_patch: { human_gate: enabled } }); + return { message: enabled ? "Human gate on: approved work waits for your release." : "Human gate off: the session ends itself once everything is approved." }; + }, + + // ---- Teams & agents ---- + + async "team/create"(ctx, body) { + const projectDir = str(body, "project_dir").trim(); + const sessionName = str(body, "session_name").trim(); + if (!projectDir.startsWith("/")) throw new ApiError("project_dir must be an absolute path"); + if (!sessionName) throw new ApiError("session_name is required"); + if (!Array.isArray(body.agents) || body.agents.length === 0) throw new ApiError("Add at least one agent"); + const workflow = (body.workflow ?? {}) as WorkflowOptions; + const preamble = workflowInstructions(str(body, "goal", { optional: true }), workflow); + const agents = body.agents.map((a, i) => { + const cfg = parseAgentConfig(a, i); + const task = [preamble, cfg.initial_task ? `YOUR TASK:\n${cfg.initial_task}` : ""].filter(Boolean).join("\n\n"); + if (!task) throw new ApiError(`Agent ${i + 1}: give the team a goal or the agent a task`); + return { ...cfg, initial_task: task }; + }); + const names = new Set(); + for (const a of agents) { + if (names.has(a.name.toLowerCase())) throw new ApiError(`Two agents are named "${a.name}"`); + names.add(a.name.toLowerCase()); + } + const plan = Array.isArray(body.plan) + ? (body.plan as Body[]).filter((p) => typeof p?.label === "string" && (p.label as string).trim()) + .map((p) => ({ label: (p.label as string).trim(), ...(typeof p.agent_name === "string" && p.agent_name ? { agent_name: p.agent_name } : {}) })) + : []; + return await exclusive(`team:${projectDir}`, () => tool("create_team", { + project_dir: projectDir, + session_name: sessionName, + agents, + ...(plan.length ? { plan } : {}), + human_gate: workflow.human_release === true, + })); + }, + + async "agents/add"(ctx, body) { + const session = await requireSession(ctx, body); + if (session.status === "archived") throw new ApiError("The session is archived"); + const cfg = parseAgentConfig(body, 0); + if (!cfg.initial_task) throw new ApiError("initial_task is required"); + const slots = await ctx.broker.listSlots(session.id); + if (slots.some((s) => s.task_state !== "released" && s.display_name?.toLowerCase() === cfg.name.toLowerCase())) { + throw new ApiError(`An agent named "${cfg.name}" is already in the session`); + } + return await exclusive(`launch:${session.id}`, () => tool("add_agent", { session_id: session.id, ...cfg })); + }, + + async "agents/control"(ctx, body) { + const session = await requireSession(ctx, body); + const slot = await requireSlot(ctx, session, body); + const action = str(body, "action"); + if (action !== "pause" && action !== "resume") throw new ApiError(`Unknown action "${action}"`); + const result = await controlSession(session.id, action === "pause" ? "pause_agent" : "resume_agent", ctx.broker, String(slot.id)); + if (result.status === "error") throw new ApiError(result.message, 422); + return { message: result.message }; + }, + + async "agents/release"(ctx, body) { + const session = await requireSession(ctx, body); + const slot = await requireSlot(ctx, session, body); + if (slot.task_state === "released") throw new ApiError(`${slot.display_name ?? `Slot ${slot.id}`} is already released`); + return await tool("release_agent", { session_id: session.id, target: String(slot.id), message: str(body, "message", { optional: true }) || undefined }); + }, + + async "agents/release-all"(ctx, body) { + const session = await requireSession(ctx, body); + return await tool("release_all", { session_id: session.id, approved_only: body.approved_only === true, message: str(body, "message", { optional: true }) || undefined }); + }, + + async "agents/remove"(ctx, body) { + const session = await requireSession(ctx, body); + const slot = await requireSlot(ctx, session, body); + return await tool("remove_agent", { session_id: session.id, target: String(slot.id) }); + }, + + async "agents/rename"(ctx, body) { + const session = await requireSession(ctx, body); + const slot = await requireSlot(ctx, session, body); + const displayName = str(body, "display_name").trim(); + if (!displayName) throw new ApiError("display_name is required"); + await ctx.broker.renamePeer({ peer_id: slotAddress(slot), assigner_id: HUMAN_ID, slot_id: slot.id, display_name: displayName }); + return { message: `Renamed to ${displayName}` }; + }, + + async "agents/role"(ctx, body) { + const session = await requireSession(ctx, body); + const slot = await requireSlot(ctx, session, body); + const role = str(body, "role").trim(); + if (!role) throw new ApiError("role is required"); + const roleDescription = str(body, "role_description", { optional: true }).trim() || slot.role_description || role; + await ctx.broker.setRole({ peer_id: slotAddress(slot), assigner_id: HUMAN_ID, slot_id: slot.id, role, role_description: roleDescription }); + return { message: `${slot.display_name ?? `Slot ${slot.id}`} is now ${role}` }; + }, + + /** Human review verdict "send back": actionable feedback moves the agent to addressing_feedback. */ + async "agents/feedback"(ctx, body) { + const session = await requireSession(ctx, body); + const slot = await requireSlot(ctx, session, body); + if (slot.task_state === "released") throw new ApiError("A released agent cannot take feedback"); + const feedback = str(body, "feedback").trim(); + const result = await ctx.broker.submitFeedback({ + peer_id: HUMAN_ID, + session_id: session.id, + target_slot_id: slot.id, + feedback: `[Human review] ${feedback}`, + actionable: true, + }); + return { message: `Sent back to ${slot.display_name ?? `slot ${slot.id}`} (${result.task_state})` }; + }, + + // ---- Messages ---- + + async "messages/send"(ctx, body) { + const session = await requireSession(ctx, body); + const text = str(body, "text").trim(); + if (!text) throw new ApiError("Message is empty"); + if (body.slot_id === "all") { + const { delivered_to } = await broadcastToTeam(session.id, text, ctx.broker, undefined, HUMAN_ID); + if (delivered_to === 0) throw new ApiError("No connected, unpaused agent to broadcast to", 422); + return { message: `Broadcast to ${delivered_to} agent(s)` }; + } + const slot = await requireSlot(ctx, session, body); + const result = await ctx.broker.sendMessage({ from_id: HUMAN_ID, to_slot_id: slot.id, text, msg_type: "chat", session_id: session.id }); + if (!result.ok) throw new ApiError(result.error ?? "Broker refused the message", 422); + return { message: result.warning ?? `Sent to ${slot.display_name ?? `slot ${slot.id}`}`, warning: result.warning }; + }, + + // ---- Plan ---- + + async "plan/add"(ctx, body) { + const session = await requireSession(ctx, body); + const assigned = body.slot_id === undefined || body.slot_id === null || body.slot_id === "" ? null : (await requireSlot(ctx, session, body)).id; + return await ctx.broker.addPlanItem({ session_id: session.id, label: str(body, "label"), assigned_to_slot: assigned }); + }, + + async "plan/edit"(ctx, body) { + const session = await requireSession(ctx, body); + const edit: Parameters[0] = { session_id: session.id, item_id: int(body, "item_id") }; + if (body.label !== undefined) edit.label = str(body, "label"); + if (body.status !== undefined) { + const status = str(body, "status"); + if (!(PLAN_STATUSES as readonly string[]).includes(status)) throw new ApiError(`Invalid status "${status}"`); + edit.status = status as typeof PLAN_STATUSES[number]; + } + if (body.slot_id !== undefined) { + edit.assigned_to_slot = body.slot_id === null || body.slot_id === "" ? null : (await requireSlot(ctx, session, body)).id; + } + return await ctx.broker.editPlanItem(edit); + }, + + async "plan/delete"(ctx, body) { + const session = await requireSession(ctx, body); + return await ctx.broker.deletePlanItem(session.id, int(body, "item_id")); + }, + + // ---- Knowledge ---- + + async "knowledge/put"(ctx, body) { + const session = await requireSession(ctx, body); + const category = (str(body, "category", { optional: true }) || "context") as KnowledgeCategory; + if (!KNOWLEDGE_CATEGORIES.includes(category)) throw new ApiError(`Invalid category "${category}"`); + return await ctx.broker.putKnowledge({ + session_id: session.id, + key: str(body, "key").trim(), + value: str(body, "value"), + category, + slot_name: "Human", + }); + }, + + async "knowledge/delete"(ctx, body) { + const session = await requireSession(ctx, body); + return await ctx.broker.deleteKnowledge(session.id, str(body, "key")); + }, + + // ---- Files ---- + + /** Take a lock on an agent's behalf (e.g. to reserve a file for it). */ + async "files/acquire"(ctx, body) { + const session = await requireSession(ctx, body); + const slot = await requireSlot(ctx, session, body); + const minutes = body.minutes === undefined ? 10 : Number(body.minutes); + if (!Number.isFinite(minutes) || minutes <= 0) throw new ApiError("minutes must be positive"); + const result = await ctx.broker.acquireFile({ + session_id: session.id, + peer_id: slotAddress(slot), + slot_id: slot.id, + file_path: str(body, "file_path").trim(), + purpose: str(body, "purpose", { optional: true }) || "Reserved via web dashboard", + timeout_ms: Math.round(minutes * 60_000), + }); + if (result.status !== "acquired" && result.status !== "extended") throw new ApiError(result.message, 409); + return result; + }, + + /** Force-unlock: drop a lock whoever holds it. */ + async "files/release"(ctx, body) { + const session = await requireSession(ctx, body); + const lockId = int(body, "lock_id"); + const lock = (await ctx.broker.listFileLocks(session.id)).find((l) => l.id === lockId); + if (!lock) throw new ApiError("Lock not found (it may have expired)", 404); + await ctx.broker.releaseFile({ session_id: session.id, peer_id: lock.held_by_peer, file_path: lock.file_path }); + return { message: `Unlocked ${lock.file_path}` }; + }, + + async "files/ownership"(ctx, body) { + const session = await requireSession(ctx, body); + const slot = await requireSlot(ctx, session, body); + const patterns = Array.isArray(body.patterns) + ? body.patterns.filter((p): p is string => typeof p === "string" && p.trim() !== "").map((p) => p.trim()) + : []; + if (!patterns.length) throw new ApiError("Give at least one path pattern"); + const result = await ctx.broker.assignOwnership({ session_id: session.id, slot_id: slot.id, path_patterns: patterns, assigned_by: HUMAN_ID }) as { ok: boolean; message?: string }; + if (!result.ok) throw new ApiError(result.message ?? "Ownership conflict", 409); + return { message: `Assigned ${patterns.join(", ")}` }; + }, + + // ---- Guardrails ---- + + async "guardrail"(ctx, body) { + const session = await requireSession(ctx, body); + const value = Number(body.value); + if (!Number.isFinite(value) || value < 0) throw new ApiError("value must be a non-negative number"); + const result = await controlSession(session.id, "set_budget", ctx.broker, str(body, "guardrail_id"), value, { + changedBy: "web-dashboard", + reason: str(body, "reason", { optional: true }) || "Adjusted via web dashboard", + }); + if (result.status === "error") throw new ApiError(result.message, 422); + return result.guardrail; + }, +}; + +/** Dispatch POST /api/. */ +export async function handleControl(ctx: ControlContext, action: string, body: Body): Promise { + const handler = handlers[action]; + if (!handler) throw new ApiError(`Unknown API action "${action}"`, 404); + return await handler(ctx, body); +} + +export const CONTROL_ACTIONS = Object.keys(handlers); + +/** GET /api/session/export — the full session as JSON. */ +export async function exportSession(ctx: ControlContext, sessionId: string): Promise { + const session = await ctx.broker.getSession(sessionId).catch(() => null); + if (!session) throw new ApiError(`Session "${sessionId}" not found`, 404); + return await exportSessionData(ctx.broker, sessionId); +} + +/** Whether a session waits on a person: human gate on and every live agent approved. */ +export function awaitingHuman(session: Session | null, slots: Slot[]): boolean { + if (!session || session.status === "archived") return false; + if (safeJsonParse>(session.config, {}).human_gate !== true) return false; + const live = slots.filter((s) => s.task_state !== "released"); + return live.length > 0 && live.every((s) => s.task_state === "approved"); +} diff --git a/dashboard/index.html b/dashboard/index.html index fad8052..a58bfc9 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -3,12 +3,13 @@ - multiagents dashboard + multiagents control center @@ -311,6 +370,7 @@
+ multiagents connecting... ▾ @@ -319,54 +379,188 @@ + Broker
- -
-
Agents 0
-
Messages 0
-
Plan -
-
Knowledge 0
-
Files 0
-
Stats
-
+
+ + + +
+ +
+
+
No session
+
+
+
+ + + + + + +
+
+ + + + +
+
Agents 0
+
Messages 0
+
Plan -
+
Knowledge 0
+
Files 0
+
Stats
+
+ + +
+
+ + + + - -
-
- - - - - + + + + + +
+
-
+
+ + + + + + +
+ diff --git a/dashboard/server.ts b/dashboard/server.ts index ecaec13..6f66bbd 100644 --- a/dashboard/server.ts +++ b/dashboard/server.ts @@ -3,7 +3,9 @@ // multiagents — Web Dashboard Server // ============================================================================ // Standalone Bun.serve() on port 7900. Fetches state from broker (7899) and -// pushes live updates to connected browsers via WebSocket. +// pushes live updates to connected browsers via WebSocket. It also hosts the +// orchestrator runtime, so teams and agents launched from the browser are +// supervised by this process (see dashboard/control.ts for the actions). // // Usage: // bun dashboard/server.ts [session-id] @@ -15,6 +17,8 @@ import { DASHBOARD_REFRESH } from "../shared/constants.ts"; import { resolveBrokerPort, brokerUrl } from "../shared/broker-url.ts"; import type { Session, Slot, Peer, Message, GuardrailState, FileLock, FileOwnership } from "../shared/types.ts"; import type { PlanState } from "../shared/broker-client.ts"; +import { configureRuntime, ensureBroker, startBackgroundLoops, shutdownRuntime, isManagedSession, managedSlotIds } from "../orchestrator/runtime.ts"; +import { handleControl, exportSession, awaitingHuman, ApiError, CONTROL_ACTIONS, HUMAN_ID, type ControlContext } from "./control.ts"; // --- Configuration --- @@ -22,6 +26,10 @@ const WEB_DASHBOARD_PORT = parseInt(process.env.MULTIAGENTS_WEB_PORT ?? "7900", const BROKER_PORT = resolveBrokerPort(); const BROKER_URL = brokerUrl(); const broker = new BrokerClient(BROKER_URL); +const controlCtx: ControlContext = { broker, brokerPort: BROKER_PORT }; + +// Lifecycle actions from the browser run the orchestrator's own code, as the human +configureRuntime({ launchDashboards: false, actor: HUMAN_ID }); // --- Session resolution (same logic as TUI) --- @@ -63,6 +71,16 @@ interface DashboardState { plan: PlanState | null; knowledge: Array<{ key: string; value: string; category: string; created_by_name: string | null; updated_at: number }>; brokerAlive: boolean; + brokerUrl: string; + /** Every session on the broker, for the sidebar */ + sessions: Session[]; + /** Registered peers machine-wide */ + peerCount: number; + /** Slots whose agent process this dashboard launched and supervises */ + managedSlots: number[]; + managed: boolean; + humanGate: boolean; + awaitingHuman: boolean; timestamp: number; } @@ -79,6 +97,13 @@ async function fetchState(sessionId: string | null): Promise { plan: null, knowledge: [], brokerAlive: false, + brokerUrl: BROKER_URL, + sessions: [], + peerCount: 0, + managedSlots: [], + managed: false, + humanGate: false, + awaitingHuman: false, timestamp: Date.now(), }; @@ -88,7 +113,10 @@ async function fetchState(sessionId: string | null): Promise { return state; } - if (!state.brokerAlive || !sessionId) return state; + if (!state.brokerAlive) return state; + + state.sessions = await broker.listSessions().catch(() => []); + if (!sessionId) return state; try { const [session, slots, messages, guardrails, fileLocks, fileOwnership, plan, knowledge] = await Promise.all([ @@ -118,6 +146,11 @@ async function fetchState(sessionId: string | null): Promise { state.fileOwnership = fileOwnership; state.plan = plan; state.knowledge = knowledge; + state.peerCount = peers.length; + state.managedSlots = managedSlotIds(sessionId); + state.managed = isManagedSession(sessionId); + try { state.humanGate = JSON.parse(session?.config ?? "{}").human_gate === true; } catch { /* malformed config */ } + state.awaitingHuman = awaitingHuman(session, slots); } catch (e) { console.error(`[web-dashboard] Fetch error: ${e}`); } @@ -133,6 +166,21 @@ let pollTimer: ReturnType | null = null; let pollGeneration = 0; // Incremented on session switch to kill stale poll loops let userPinnedSession = false; // True when user explicitly selected a session — disables auto-switch +// Fetches overlap (poll ticks and post-mutation refreshes); only a newer fetch may replace what clients saw. +let fetchSeq = 0; +let broadcastSeq = 0; + +async function fetchAndBroadcast(): Promise { + const seq = ++fetchSeq; + const state = await fetchState(sessionId); + if (seq > broadcastSeq) { + broadcastSeq = seq; + currentState = state; + broadcastState(state); + } + return state; +} + function broadcastState(state: DashboardState) { const json = JSON.stringify({ type: "state", data: state }); for (const ws of wsClients) { @@ -161,8 +209,7 @@ async function startPolling() { const gen = ++pollGeneration; // Initial fetch - currentState = await fetchState(sessionId); - broadcastState(currentState); + await fetchAndBroadcast(); // Recursive tick — reads the global `sessionId` each cycle async function tick() { @@ -187,14 +234,13 @@ async function startPolling() { } catch { /* broker down */ } } - currentState = await fetchState(sessionId); - broadcastState(currentState); + await fetchAndBroadcast(); // Stop if superseded if (gen !== pollGeneration) return; // Schedule next tick: slow for completed/archived, fast for active - const nextComplete = isSessionComplete(currentState); + const nextComplete = currentState ? isSessionComplete(currentState) : false; const nextStatus = currentState?.session?.status; const delay = (nextComplete || nextStatus === "archived") ? 3000 : nextStatus === "paused" ? 2000 @@ -210,6 +256,19 @@ async function startPolling() { const sessionArg = process.argv[2]; let sessionId = await resolveSessionId(sessionArg); +function apiError(e: unknown): Response { + let status = e instanceof ApiError ? e.status : 500; + let message = e instanceof Error ? e.message : String(e); + // Surface the broker's own answer rather than the client's wrapper around it + const fromBroker = message.match(/^Broker POST \S+ failed \((\d+)\): (.*)$/s); + if (fromBroker) { + status = Number(fromBroker[1]); + try { message = JSON.parse(fromBroker[2]!).error ?? fromBroker[2]; } catch { message = fromBroker[2]!; } + } + if (status >= 500) console.error(`[web-dashboard] API error: ${message}`); + return Response.json({ ok: false, error: message }, { status }); +} + // --- HTML serving --- const DASHBOARD_DIR = new URL(".", import.meta.url).pathname; @@ -253,36 +312,69 @@ const server = Bun.serve({ } } - // API: guardrail adjust - if (url.pathname === "/api/guardrail" && req.method === "POST") { - const body = await req.json() as { session_id: string; guardrail_id: string; value: number; reason?: string }; + // API: broker status + if (url.pathname === "/api/broker" && req.method === "GET") { + const alive = await broker.isAlive(); + const sessions = alive ? await broker.listSessions().catch(() => []) : []; + const peers = alive ? await broker.listPeers({ scope: "machine", cwd: "/", git_root: null }).catch(() => []) : []; + return Response.json({ + alive, + url: BROKER_URL, + sessions: sessions.length, + active_sessions: sessions.filter((s) => s.status === "active").length, + peers: peers.length, + current_session: sessionId, + }); + } + + // API: session export (download) + if (url.pathname === "/api/session/export" && req.method === "GET") { + const id = url.searchParams.get("session_id"); + if (!id) return Response.json({ error: "session_id required" }, { status: 400 }); try { - const result = await broker.updateGuardrail({ - session_id: body.session_id, - guardrail_id: body.guardrail_id, - new_value: body.value, - reason: body.reason ?? "Adjusted via web dashboard", - changed_by: "web-dashboard", + const data = await exportSession(controlCtx, id); + return new Response(JSON.stringify(data, null, 2), { + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Disposition": `attachment; filename="session-${id.replace(/[^\w.-]/g, "_")}-export.json"`, + }, }); - return Response.json(result); } catch (e) { - return Response.json({ error: String(e) }, { status: 500 }); + return apiError(e); } } - // API: pause/resume session - if (url.pathname === "/api/session/control" && req.method === "POST") { - const body = await req.json() as { session_id: string; action: "pause" | "resume" }; - try { - const result = await broker.updateSession({ - id: body.session_id, - status: body.action === "pause" ? "paused" : "active", - pause_reason: body.action === "pause" ? "Paused via web dashboard" : undefined, - paused_at: body.action === "pause" ? Date.now() : undefined, - }); - return Response.json(result); - } catch (e) { - return Response.json({ error: String(e) }, { status: 500 }); + // API: control actions (send, pause, release, add agent, plan, knowledge, ...) + if (url.pathname.startsWith("/api/") && (req.method === "POST" || req.method === "GET")) { + const action = url.pathname.slice("/api/".length); + if (CONTROL_ACTIONS.includes(action)) { + let body: Record = {}; + if (req.method === "POST") { + try { + body = (await req.json()) as Record; + } catch { + return Response.json({ ok: false, error: "Body must be JSON" }, { status: 400 }); + } + if (!body || typeof body !== "object" || Array.isArray(body)) { + return Response.json({ ok: false, error: "Body must be a JSON object" }, { status: 400 }); + } + } + try { + const result = await handleControl(controlCtx, action, body); + // A team started from the browser is what the user wants to watch next + const created = action === "team/create" ? (result as { data?: { session_id?: string } }).data?.session_id : undefined; + const createdSession = action === "session/create" ? (result as Session).id : undefined; + if (created || createdSession) { + sessionId = (created ?? createdSession)!; + userPinnedSession = true; + await startPolling(); + } else if (req.method === "POST") { + await fetchAndBroadcast(); + } + return Response.json({ ok: true, result }); + } catch (e) { + return apiError(e); + } } } @@ -316,9 +408,26 @@ const server = Bun.serve({ }, }); +// Start the broker if needed (as the orchestrator does) and the supervision +// loops for the agents this dashboard launches +try { + await ensureBroker(broker); +} catch (e) { + console.error(`[web-dashboard] Broker unavailable: ${e instanceof Error ? e.message : e}`); +} +startBackgroundLoops(broker); + // Start polling await startPolling(); +// Agents launched from the browser belong to this process: stop them with it +function shutdown() { + shutdownRuntime(); + process.exit(0); +} +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); + console.error(`[web-dashboard] Listening on http://127.0.0.1:${WEB_DASHBOARD_PORT}`); console.error(`[web-dashboard] Session: ${sessionId ?? "(none — waiting for session)"}`); diff --git a/docs/web-control-center.md b/docs/web-control-center.md new file mode 100644 index 0000000..60bb762 --- /dev/null +++ b/docs/web-control-center.md @@ -0,0 +1,99 @@ +# Web Control Center + +`multiagents web` (localhost:7900) runs a team from the browser. This page maps each +browser action to the code that does the work, so the CLI, the orchestrator MCP tools +and the web share one implementation. + +## Architecture + +``` +Claude Desktop ──MCP stdio──> orchestrator-server.ts ─┐ + ├─> orchestrator/runtime.ts ──> broker (7899) +Browser ──POST /api/*──> dashboard/server.ts │ (launch, supervise, release, + <──WebSocket── └─ dashboard/control.ts ─────┘ end, resume...) + └─ BrokerClient ─────────────> broker (messages, plan, knowledge, locks) +CLI ─────────────────────> shared/session-ops.ts, BrokerClient ──> broker +``` + +Agent processes live in the memory of the process that launched them: the drivers, the +process map, the delivery and respawn loops. So lifecycle actions cannot be done by the +web with broker calls alone. `orchestrator/runtime.ts` holds that state and the tool +implementations; both the MCP server and the web dashboard host it. Agents started from the +browser are supervised by the dashboard process, and agents an MCP orchestrator started stay +with it. Release and remove are recorded in the broker, so the owning process stops its +agent either way. + +## Action map + +All mutations are `POST /api/` with a JSON body that names `session_id` +(`team/create` and `session/create` create one). The answer is `{ ok: true, result }`, or +`{ ok: false, error }` with a 4xx/5xx status. The server checks that every `slot_id` +belongs to the named session. Human actions are sent as `human`. + +| Feature | API action | Implementation | CLI / MCP equivalent | +|---|---|---|---| +| Message an agent | `messages/send` `{slot_id, text}` | `BrokerClient.sendMessage` (by slot) | `multiagents send`, `direct_agent` | +| Broadcast | `messages/send` `{slot_id: "all"}` | `broadcastToTeam` | `broadcast_to_team` | +| Pause / resume session | `session/control` `{action}` | `controlSession(pause_all / resume_all)` | `session pause`, `control_session` | +| Pause / resume agent | `agents/control` `{slot_id, action}` | `controlSession(pause_agent / resume_agent)` | `control_session` | +| Release agent | `agents/release` | runtime `release_agent` | `release_agent` | +| Release all (approved) | `agents/release-all` `{approved_only}` | runtime `release_all` | `release_all` | +| Remove agent | `agents/remove` | runtime `remove_agent` | `remove_agent` | +| Add agent | `agents/add` | runtime `add_agent` | `add_agent` | +| Rename | `agents/rename` | `BrokerClient.renamePeer` | — | +| Change role | `agents/role` | `BrokerClient.setRole` | — | +| Send back for revision | `agents/feedback` | `BrokerClient.submitFeedback` (actionable) | — | +| New team | `team/create` | runtime `create_team` (+ `human_gate`) | `create_team` | +| Create session | `session/create` | `createSessionRecord` | `session create` | +| Relaunch agents | `session/relaunch` | runtime `resume_session` | `resume_session` | +| Archive | `session/archive` | `BrokerClient.updateSession` | `session archive` | +| End session | `session/end` | runtime `end_session` | `end_session` | +| Delete session | `session/delete` `{confirm: }` | runtime `delete_session` | `session delete`, `delete_session` | +| Export | `GET /api/session/export?session_id=` | `exportSessionData` (JSON) | `session export` (Markdown) | +| Human gate | `session/human-gate` `{enabled}` | `updateSession({config_patch})` | `create_team({human_gate})` | +| Plan add / edit / delete | `plan/add`, `plan/edit`, `plan/delete` | broker `/plan/add-item`, `/plan/edit-item`, `/plan/delete-item` | `update_plan` (agents) | +| Knowledge put / delete | `knowledge/put`, `knowledge/delete` | `putKnowledge`, `deleteKnowledge` | `store_knowledge`, `remove_knowledge` | +| Reserve a file | `files/acquire` `{slot_id, file_path, minutes}` | `acquireFile` on the agent's behalf | `acquire_file` (agents) | +| Force unlock | `files/release` `{lock_id}` | `releaseFile` as the holder | — | +| Ownership zone | `files/ownership` `{slot_id, patterns}` | `assignOwnership` | `assign_ownership` | +| Guardrail | `guardrail` `{guardrail_id, value, reason}` | `controlSession(set_budget)` (resumes a guardrail pause) | `adjust_guardrail` | +| Broker status | `GET /api/broker` | `BrokerClient` | `multiagents status` | +| CLI availability | `GET /api/agents/available` | `detectAgent` (cached 5 min) | — | + +## Lifecycle and the human gate + +`idle → working → done_pending_review → addressing_feedback → approved → released` + +`approved` is not `released`. Without a gate, the orchestrator ends a session on its own +30 s after every agent is approved or released. With `config.human_gate` set (on by default +for teams started from the browser) it leaves approved agents waiting. The dashboard then +shows **Waiting for human approval**: + +- **Approve & Release** releases every approved agent (`release_all` with `approved_only`). +- **Send back for revision** sends actionable feedback from `human` to the agent you pick. The + agent moves to `addressing_feedback` and the review loop runs again. + +The workflow checkboxes in New Team become rules in every agent's initial prompt (reviewer +approval required, actionable feedback, wait for the human). The human gate itself is enforced +by the orchestrator. + +## Safety + +- Launches are serialized per session (per project directory for `team/create`), so a + double click cannot spawn agents twice. An agent name already used in the session is refused. +- Destructive actions ask for confirmation in the UI. Delete also needs the session id + typed, and the server checks it (`confirm`). +- Actions capture the session id when they start, and dialogs show it. Switching sessions + mid-way cannot redirect an action. +- State pushes are sequenced: a slow poll that started before a mutation cannot overwrite + the state fetched after it. +- Agent cards show the slot id (stable seat in the session) and the peer id (the running + process's registration) separately. + +## Not covered yet + +- Starting or stopping the broker from the browser. The dashboard starts it if it is down; + stop it with `multiagents kill-broker`. +- Removing an ownership zone (the broker has no endpoint for it). +- Moving agents between hosts. A dashboard that is stopped stops the agents it launched; + **Relaunch disconnected agents** brings them back under the next host. diff --git a/orchestrator/orchestrator-server.ts b/orchestrator/orchestrator-server.ts index ef6d03f..2036d8a 100755 --- a/orchestrator/orchestrator-server.ts +++ b/orchestrator/orchestrator-server.ts @@ -4,6 +4,7 @@ // ============================================================================ // MCP server for Claude Desktop that provides tools to manage a team of // headless AI agents. Exposes create_team, status, broadcast, control, etc. +// The tools themselves live in runtime.ts, shared with the web dashboard. // ============================================================================ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; @@ -12,391 +13,13 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; -import type { Subprocess } from "bun"; -import * as fs from "node:fs"; import { BrokerClient } from "../shared/broker-client.ts"; -import type { AgentLaunchConfig } from "../shared/types.ts"; -import { log, getGitRoot, slugify, formatDuration, safeJsonParse } from "../shared/utils.ts"; -import { - GUARDRAIL_CHECK_INTERVAL, - CONFLICT_CHECK_INTERVAL, - FLAP_WINDOW_MS, - STUCK_THRESHOLD_MS, -} from "../shared/constants.ts"; +import { log } from "../shared/utils.ts"; import { brokerUrl } from "../shared/broker-url.ts"; - -import { detectAgent, launchAgent, relaunchIntoSlot, announceNewMember, buildTeamContext, ENRICHED_PATH } from "./launcher.ts"; -import { getGuide, formatTopicList, type GuideTopic } from "./guide.ts"; -import { monitorProcess, monitorCodexDriver, monitorClaudeDriver, clearSlotTracking, clearAllTracking, type AgentEvent } from "./monitor.ts"; -import { deliverPendingMessages, type DriverSlot } from "./delivery.ts"; -import { RespawnGuard } from "./respawn-guard.ts"; -import type { CodexDriver } from "./codex-driver.ts"; -import type { ClaudeDriver } from "./claude-driver.ts"; -import { getTeamStatus, formatTeamStatusForDisplay } from "./progress.ts"; -import { checkGuardrails, enforceGuardrails } from "./guardrails.ts"; -import { handleAgentCrash, clearAllCrashHistory } from "./recovery.ts"; -import { controlSession, broadcastToTeam, resolveTarget } from "./session-control.ts"; -import { DriverRegistry, nonOverlapping, releaseVerdict, watchdogInbox } from "./supervision.ts"; +import { ORCHESTRATOR_TOOLS, callOrchestratorTool, ensureBroker, startBackgroundLoops, shutdownRuntime } from "./runtime.ts"; const LOG_PREFIX = "orchestrator"; -const BROKER_URL = brokerUrl(); - -// Track active processes per session -const activeProcesses: Map> = new Map(); -// Track pending events to push as notifications -const pendingEvents: AgentEvent[] = []; -// Driver-managed agents (Claude over stream-json, Codex over app-server), one driver per slot -const activeDrivers = new DriverRegistry(); -// One respawn per slot at a time, whichever path (crash, code-0 exit, watchdog, resume) triggers it -const respawnGuard = new RespawnGuard(); - -interface LaunchedAgent { - pid: number; - process: Subprocess; - codexDriver?: CodexDriver; - claudeDriver?: ClaudeDriver; -} - -async function slotIsReleased(slotId: number, brokerClient: BrokerClient): Promise { - try { - return (await brokerClient.getTaskState(slotId)).task_state === "released"; - } catch { - return false; - } -} - -/** Graceful stop: Claude finishes its turn and exits on stdin EOF, Codex is killed. */ -async function closeDriver(state: DriverSlot): Promise { - if (state.kind === "claude") await state.driver.close(); - else await state.driver.kill(); -} - -/** - * Stop every agent of a session. The session is forgotten BEFORE anything is - * killed, so the exit handlers that fire while processes die find no session - * to respawn into. - */ -function stopSession(sessionId: string): { stopped: number } { - activeSessions.delete(sessionId); - const procs = activeProcesses.get(sessionId); - activeProcesses.delete(sessionId); - const drivers = activeDrivers.dropSession(sessionId); - - let stopped = 0; - for (const [slotId, state] of drivers ?? []) { - log(LOG_PREFIX, `Stopping ${state.kind} driver in slot ${slotId}`); - state.driver.kill().catch(() => { /* already dead */ }); - stopped++; - } - for (const [slotId, proc] of procs ?? []) { - if (drivers?.has(slotId)) continue; // a driver's process was killed above - log(LOG_PREFIX, `Stopping agent in slot ${slotId}`); - try { proc.kill(); } catch { /* already dead */ } - stopped++; - } - return { stopped }; -} - -/** - * Track a launched or respawned agent: process map, driver map, monitoring - * and exit handling. Every launch path goes through here so no agent is left - * unmonitored or double-tracked. - */ -function trackLaunch(sessionId: string, slotId: number, result: LaunchedAgent, brokerClient: BrokerClient): void { - let procs = activeProcesses.get(sessionId); - if (!procs) { - procs = new Map(); - activeProcesses.set(sessionId, procs); - } - procs.set(slotId, result.process); - - const state: DriverSlot | null = result.claudeDriver - ? { kind: "claude", driver: result.claudeDriver, busy: false, lastNudge: 0 } - : result.codexDriver - ? { kind: "codex", driver: result.codexDriver, threadId: result.codexDriver.threadId, busy: false, lastNudge: 0 } - : null; - if (!state) { - monitorProcess(result.process, slotId, sessionId, brokerClient, handleEvent); - return; - } - - const displaced = activeDrivers.track(sessionId, slotId, state); - if (displaced) { - log(LOG_PREFIX, `Slot ${slotId} already had a live ${displaced.kind} driver (pid ${displaced.driver.pid}) — stopping it in favour of the new one`); - displaced.driver.kill().catch(() => { /* already dead */ }); - } - if (state.kind === "claude") { - monitorClaudeDriver(state.driver, slotId, sessionId, brokerClient, handleEvent); - } else { - monitorCodexDriver(state.driver, slotId, sessionId, brokerClient, handleEvent); - } - state.driver.onExit((code) => onDriverExit(sessionId, slotId, state, code, brokerClient)); -} - -/** A driver process ended: detach it, mark the slot disconnected, and respawn only if it was not released. */ -function onDriverExit(sessionId: string, slotId: number, state: DriverSlot, code: number, brokerClient: BrokerClient): void { - const replaced = !activeDrivers.detach(sessionId, slotId, state) && activeDrivers.has(sessionId, slotId); - const procs = activeProcesses.get(sessionId); - if (procs?.get(slotId) === state.driver.process) procs.delete(slotId); - if (replaced) { - log(LOG_PREFIX, `${state.kind} driver for slot ${slotId} exited (code ${code}) after being replaced — its slot is live`); - return; - } - - void (async () => { - try { - await brokerClient.updateSlot({ id: slotId, status: "disconnected" }); - } catch { /* broker may be down */ } - - if (await slotIsReleased(slotId, brokerClient)) { - log(LOG_PREFIX, `${state.kind} driver for slot ${slotId} exited after release (code ${code})`); - pendingEvents.push({ type: "agent_released", severity: "info", slotId, sessionId, message: `Slot ${slotId} exited after being released` }); - return; - } - handleEvent({ - type: code === 0 ? "agent_completed" : "agent_crashed", - severity: code === 0 ? "info" : "critical", - slotId, - sessionId, - message: `${state.kind} driver for slot ${slotId} exited with code ${code}`, - data: { exit_code: code }, - }); - })(); -} - -// --- Dashboard auto-launch --- - -const CLI_PATH = new URL("../cli.ts", import.meta.url).pathname; -// Tests and headless hosts run the orchestrator without terminal windows or a browser -const DASHBOARDS_DISABLED = process.env.MULTIAGENTS_NO_DASHBOARD === "1"; - -/** - * Launch the TUI dashboard in a new terminal window. - * Tries macOS Terminal.app first via `open`, falls back to detached process. - */ -function launchDashboard(sessionId: string, projectDir: string): void { - if (DASHBOARDS_DISABLED) return; - try { - const platform = process.platform; - if (platform === "darwin") { - // macOS: open a new Terminal.app tab running the dashboard - const script = `tell application "Terminal" to do script "cd ${projectDir} && bun ${CLI_PATH} dashboard ${sessionId}"`; - Bun.spawn(["osascript", "-e", script], { - stdio: ["ignore", "ignore", "ignore"], - }).unref(); - } else if (platform === "linux") { - // Linux: try common terminal emulators - for (const term of ["gnome-terminal", "xterm", "konsole"]) { - const which = Bun.spawnSync(["which", term], { env: { ...process.env, PATH: ENRICHED_PATH } }); - if (which.exitCode === 0) { - Bun.spawn([term, "--", "bun", CLI_PATH, "dashboard", sessionId], { - cwd: projectDir, - stdio: ["ignore", "ignore", "ignore"], - }).unref(); - break; - } - } - } - log(LOG_PREFIX, `Dashboard launched for session ${sessionId}`); - } catch (e) { - log(LOG_PREFIX, `Dashboard auto-launch failed (non-critical): ${e}`); - } -} - -const WEB_DASHBOARD_PATH = new URL("../dashboard/server.ts", import.meta.url).pathname; - -/** - * Launch the web dashboard as a background Bun process. - * Auto-opens the browser on localhost:7900. - */ -function launchWebDashboard(sessionId: string, projectDir: string): void { - if (DASHBOARDS_DISABLED) return; - try { - const proc = Bun.spawn(["bun", WEB_DASHBOARD_PATH, sessionId], { - cwd: projectDir, - env: { ...process.env, PATH: ENRICHED_PATH }, - stdio: ["ignore", "ignore", "ignore"], - }); - proc.unref(); - log(LOG_PREFIX, `Web dashboard launched for session ${sessionId} (PID ${proc.pid})`); - } catch (e) { - log(LOG_PREFIX, `Web dashboard auto-launch failed (non-critical): ${e}`); - } -} - -// --- Broker lifecycle --- - -async function ensureBroker(brokerClient: BrokerClient): Promise { - if (await brokerClient.isAlive()) { - log(LOG_PREFIX, "Broker already running"); - return; - } - - log(LOG_PREFIX, "Starting broker daemon..."); - const brokerScript = new URL("../broker.ts", import.meta.url).pathname; - const proc = Bun.spawn(["bun", brokerScript], { - stdio: ["ignore", "ignore", "inherit"], - }); - proc.unref(); - - for (let i = 0; i < 30; i++) { - await new Promise((r) => setTimeout(r, 200)); - if (await brokerClient.isAlive()) { - log(LOG_PREFIX, "Broker started"); - return; - } - } - throw new Error("Failed to start broker daemon after 6 seconds"); -} - -// --- Event handler --- - -function handleEvent(event: AgentEvent): void { - log(LOG_PREFIX, `[${event.severity}] ${event.message}`); - - // Auto-respawn on crash (unless flapping or the slot was intentionally released) - if (event.type === "agent_crashed" && event.data && !event.data.is_flapping) { - respawnAfterCrash(event).catch((err) => log(LOG_PREFIX, `Crash handler error: ${err}`)); - } else if (event.type === "agent_completed" && event.data) { - // Codex agents exit with code 0 when they finish their turn, but they may - // not have actually completed their task. Check task_state — if the agent - // hasn't signaled done (still "idle" or "addressing_feedback"), auto-respawn - // with a continuation prompt so it picks up where it left off. - const brokerClient = new BrokerClient(BROKER_URL); - autoRestartIfIncomplete(event.slotId, event.sessionId, brokerClient) - .then((restarted) => { - if (!restarted) { - pendingEvents.push(event); - } - }) - .catch((err) => { - log(LOG_PREFIX, `Auto-restart check error: ${err}`); - pendingEvents.push(event); - }); - } else { - pendingEvents.push(event); - } -} - -async function respawnAfterCrash(event: AgentEvent): Promise { - const sessionMeta = activeSessions.get(event.sessionId); - if (!sessionMeta) return; - const brokerClient = new BrokerClient(BROKER_URL); - - // A released slot exits on purpose (remove_agent, release_agent); respawning it - // would create an unassigned ghost agent that then receives the team's traffic. - if (await slotIsReleased(event.slotId, brokerClient)) { - log(LOG_PREFIX, `Slot ${event.slotId} exited after being released — not respawning`); - return; - } - - const crashEvent = await handleAgentCrash(event.slotId, event.data!.exit_code as number, event.sessionId, brokerClient); - pendingEvents.push(crashEvent); - if (crashEvent.data?.is_flapping) return; - - try { - const result = await respawnGuard.run(event.slotId, async () => { - const { respawnAgent } = await import("./recovery.ts"); - const launched = await respawnAgent(event.sessionId, event.slotId, brokerClient, sessionMeta.projectDir); - trackLaunch(event.sessionId, event.slotId, launched, brokerClient); - return launched; - }); - if (!result) { - log(LOG_PREFIX, `Slot ${event.slotId} is already being respawned — skipping crash respawn`); - return; - } - log(LOG_PREFIX, `Auto-respawned slot ${event.slotId} (PID ${result.pid}${result.codexDriver ? ", CodexDriver" : result.claudeDriver ? ", ClaudeDriver" : ""})`); - pendingEvents.push({ - type: "agent_restarted", - severity: "info", - slotId: event.slotId, - sessionId: event.sessionId, - message: `Slot ${event.slotId} auto-respawned after crash (PID ${result.pid})`, - }); - } catch (err) { - log(LOG_PREFIX, `Failed to auto-respawn slot ${event.slotId}: ${err}`); - } -} - -/** Flap detection for code-0 auto-restarts (separate from crash flap detection). */ -const completionRestartHistory: Map = new Map(); -const COMPLETION_RESTART_LIMIT = 3; // max code-0 restarts in FLAP_WINDOW_MS - -/** Check if a completed agent actually finished its task; if not, respawn it. */ -async function autoRestartIfIncomplete( - slotId: number, - sessionId: string, - brokerClient: BrokerClient, -): Promise { - try { - const taskInfo = await brokerClient.getTaskState(slotId); - const state = taskInfo.task_state; - const name = taskInfo.display_name ?? `Slot ${slotId}`; - - // Only restart if the agent hasn't completed its work - // "working" = actively implementing (was incorrectly "in_progress" before) - if (state === "idle" || state === "working" || state === "addressing_feedback") { - // Flap detection for code-0 exits — prevent infinite restart loops - const now = Date.now(); - const history = completionRestartHistory.get(slotId) ?? []; - const recent = history.filter((t) => now - t < FLAP_WINDOW_MS); - recent.push(now); - completionRestartHistory.set(slotId, recent); - - if (recent.length >= COMPLETION_RESTART_LIMIT) { - log(LOG_PREFIX, `${name} hit code-0 restart limit (${recent.length} in 5 min) — stopping auto-restart`); - pendingEvents.push({ - type: "agent_flapping", - severity: "critical", - slotId, - sessionId, - message: `${name} keeps exiting without completing work (${recent.length} restarts). Stopped auto-restart. Investigate why the agent exits early.`, - }); - return false; - } - - log(LOG_PREFIX, `${name} exited with code 0 but task_state="${state}" — auto-restarting (${recent.length}/${COMPLETION_RESTART_LIMIT})`); - - const sessionMeta = activeSessions.get(sessionId); - if (!sessionMeta) { - log(LOG_PREFIX, `No session metadata for ${sessionId}, cannot restart`); - return false; - } - - const result = await respawnGuard.run(slotId, async () => { - const { respawnAgent } = await import("./recovery.ts"); - const launched = await respawnAgent(sessionId, slotId, brokerClient, sessionMeta.projectDir); - trackLaunch(sessionId, slotId, launched, brokerClient); - return launched; - }); - if (!result) { - log(LOG_PREFIX, `${name} is already being respawned — skipping code-0 restart`); - return true; - } - log(LOG_PREFIX, `Auto-restarted ${name} (PID ${result.pid}${result.codexDriver ? ", CodexDriver" : result.claudeDriver ? ", ClaudeDriver" : ""})`); - - pendingEvents.push({ - type: "agent_restarted", - severity: "info", - slotId, - sessionId, - message: `${name} auto-restarted (was task_state="${state}", exited code 0)`, - }); - return true; - } - - // Agent completed normally (done_pending_review, approved, released) - log(LOG_PREFIX, `${name} completed with task_state="${state}" — no restart needed`); - return false; - } catch (err) { - log(LOG_PREFIX, `Failed to check task state for slot ${slotId}: ${err}`); - return false; - } -} - -/** Track session metadata for restart purposes. */ -const activeSessions: Map = new Map(); // --- MCP Server Setup --- @@ -405,1555 +28,18 @@ const server = new Server( { capabilities: { tools: {} } }, ); -// Tool definitions -server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: "create_team", - description: `Create a new multi-agent team session. Launches headless agents with assigned roles and file ownership. The project_dir will be created if it does not exist, and git will be initialized if needed. - -IMPORTANT — For best results, provide detailed role_descriptions including platform/framework, expertise, and constraints. The richer the description, the better the agent performs. - -Recommended team compositions: -- Feature development: Software Engineer + Code Reviewer + QA Engineer -- UI feature: UI/UX Designer + Software Engineer + Code Reviewer + QA Engineer -- Bug fix: Software Engineer + QA Engineer - -Core roles: 'Software Engineer', 'UI/UX Designer', 'QA Engineer', 'Code Reviewer' -Prefix with platform: 'Android Software Engineer', 'Web QA Engineer', 'iOS UI/UX Designer' +server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: ORCHESTRATOR_TOOLS })); -Each agent receives role-specific best practices, tool discovery hints, and completion criteria automatically based on their role name and description. Agents communicate bidirectionally and loop until all parties approve.`, - inputSchema: { - type: "object" as const, - properties: { - project_dir: { type: "string", description: "Absolute path where agents will work. Will be created if it does not exist." }, - session_name: { type: "string", description: "Short name for this session. Used to generate session_id." }, - agents: { - type: "array", - items: { - type: "object", - properties: { - agent_type: { type: "string", enum: ["claude", "codex", "gemini"] }, - name: { type: "string", description: "Display name for the agent, e.g., 'Alice' or 'Backend Engineer'" }, - role: { type: "string", description: "Core role: 'Software Engineer', 'UI/UX Designer', 'QA Engineer', 'Code Reviewer'. Prefix with platform for specificity: 'Android Software Engineer', 'Web QA Engineer'." }, - role_description: { type: "string", description: "Detailed role brief including platform (web/android/ios/cli/api), framework/stack, specific expertise, constraints, and acceptance criteria. The richer this is, the better the agent performs." }, - initial_task: { type: "string", description: "Specific task with acceptance criteria. Include what 'done' looks like, files to modify, APIs to use, and verification steps." }, - file_ownership: { type: "array", items: { type: "string" }, description: "Glob patterns for files this agent owns exclusively, e.g., 'src/auth/**'" }, - }, - required: ["agent_type", "name", "role", "role_description", "initial_task"], - }, - }, - plan: { - type: "array", - description: "Plan items to track progress. Include review and QA tasks, not just implementation. Example: [{label: 'Implement auth API', agent_name: 'Engineer'}, {label: 'Review auth code', agent_name: 'Reviewer'}, {label: 'Test auth E2E', agent_name: 'QA'}]", - items: { - type: "object", - properties: { - label: { type: "string", description: "What needs to be done" }, - agent_name: { type: "string", description: "Name of the agent assigned to this item" }, - }, - required: ["label"], - }, - }, - }, - required: ["project_dir", "session_name", "agents"], - }, - }, - { - name: "get_team_status", - description: "Get current status of all agents in a multiagents session — their roles, connection state, task state (idle/working/done/addressing_feedback), and what they are working on. Use the session_id returned by create_team.", - inputSchema: { - type: "object" as const, - properties: { - session_id: { type: "string" }, - }, - required: ["session_id"], - }, - }, - { - name: "broadcast_to_team", - description: "Send a message to ALL connected agents in a multiagents session. Use for requirement changes, priority shifts, or announcements that every team member must see.", - inputSchema: { - type: "object" as const, - properties: { - session_id: { type: "string" }, - message: { type: "string" }, - exclude_roles: { type: "array", items: { type: "string" } }, - }, - required: ["session_id", "message"], - }, - }, - { - name: "direct_agent", - description: "Send a direct message to a specific agent by name, role, or slot ID. The agent receives this as a peer message and must respond.", - inputSchema: { - type: "object" as const, - properties: { - session_id: { type: "string" }, - target: { type: "string", description: "Agent name, role, or slot ID" }, - message: { type: "string" }, - }, - required: ["session_id", "target", "message"], - }, - }, - { - name: "add_agent", - description: "Spawn and add a new agent to an existing multiagents session. The agent auto-connects to the broker, receives team context, and gets role-specific best practices injected automatically based on role name and description.", - inputSchema: { - type: "object" as const, - properties: { - session_id: { type: "string" }, - agent_type: { type: "string", enum: ["claude", "codex", "gemini"] }, - name: { type: "string", description: "Display name for the agent" }, - role: { type: "string", description: "Core role: 'Software Engineer', 'UI/UX Designer', 'QA Engineer', 'Code Reviewer'. Prefix with platform for specificity." }, - role_description: { type: "string", description: "Detailed role brief including platform, framework, expertise, constraints. The richer this is, the better the agent performs." }, - initial_task: { type: "string", description: "Specific task with acceptance criteria. Include what 'done' looks like." }, - file_ownership: { type: "array", items: { type: "string" }, description: "Glob patterns for files this agent owns exclusively" }, - }, - required: ["session_id", "agent_type", "name", "role", "role_description", "initial_task"], - }, - }, - { - name: "remove_agent", - description: "Gracefully stop and remove an agent from the session. Kills the CLI process and marks the slot as disconnected.", - inputSchema: { - type: "object" as const, - properties: { - session_id: { type: "string" }, - target: { type: "string", description: "Agent name, role, or slot ID" }, - }, - required: ["session_id", "target"], - }, - }, - { - name: "control_session", - description: "Control a multiagents session. Actions: pause_all (pause every agent), resume_all (resume every agent), pause_agent (pause one by name/role), resume_agent (resume one), extend_budget (add minutes to time limit), set_budget (set exact time limit), status (get control state).", - inputSchema: { - type: "object" as const, - properties: { - session_id: { type: "string" }, - action: { type: "string", enum: ["pause_all", "resume_all", "pause_agent", "resume_agent", "extend_budget", "set_budget", "status"] }, - target: { type: "string", description: "Agent name/role/slot for agent-level actions, or guardrail_id for set_budget" }, - value: { type: "number", description: "New value for budget actions" }, - }, - required: ["session_id", "action"], - }, - }, - { - name: "adjust_guardrail", - description: "View or update multiagents session guardrail limits (e.g. max restarts per agent). Use action='view' to see all guardrails, action='update' to change one.", - inputSchema: { - type: "object" as const, - properties: { - session_id: { type: "string" }, - action: { type: "string", enum: ["view", "update"] }, - guardrail_id: { type: "string" }, - new_value: { type: "number" }, - }, - required: ["session_id", "action"], - }, - }, - { - name: "get_session_log", - description: "Get the inter-agent message history for a multiagents session. Returns timestamped messages between agents with sender/recipient info.", - inputSchema: { - type: "object" as const, - properties: { - session_id: { type: "string" }, - limit: { type: "number" }, - since: { type: "number", description: "Epoch ms timestamp to get messages after" }, - }, - required: ["session_id"], - }, - }, - { - name: "release_agent", - description: "Release a specific agent from the multiagents session, allowing it to disconnect. Only use after their work is approved and complete. Agents cannot self-disconnect — only the orchestrator can release them.", - inputSchema: { - type: "object" as const, - properties: { - session_id: { type: "string" }, - target: { type: "string", description: "Agent name, role, or slot ID" }, - message: { type: "string", description: "Optional release message" }, - }, - required: ["session_id", "target"], - }, - }, - { - name: "release_all", - description: "Release ALL agents in a multiagents session, allowing them to disconnect. Use when the entire team's work is complete and production-grade.", - inputSchema: { - type: "object" as const, - properties: { - session_id: { type: "string" }, - message: { type: "string", description: "Optional release message" }, - }, - required: ["session_id"], - }, - }, - { - name: "end_session", - description: "End a multiagents session: releases and stops all agents, archives the session. Use when the project is complete.", - inputSchema: { - type: "object" as const, - properties: { - session_id: { type: "string" }, - create_pr: { type: "boolean" }, - }, - required: ["session_id"], - }, - }, - { - name: "cleanup_dead_slots", - description: "Remove disconnected/dead slots from a session that will never reconnect. Useful when crashed agents left stale slots behind.", - inputSchema: { - type: "object" as const, - properties: { - session_id: { type: "string" }, - keep_released: { type: "boolean", description: "If true, keep slots in 'released' state (default: false — removes all non-connected)" }, - }, - required: ["session_id"], - }, - }, - { - name: "list_sessions", - description: "List all multiagents sessions (active, paused, archived). Use to discover sessions from previous conversations that can be resumed with resume_session.", - inputSchema: { - type: "object" as const, - properties: { - status_filter: { type: "string", enum: ["all", "active", "paused", "archived"], description: "Filter by session status. Default: all" }, - }, - }, - }, - { - name: "resume_session", - description: "Resume a previously paused or stopped multiagents session. Respawns all disconnected agents with their previous role, context, message history, and plan items. Use list_sessions first to find the session_id.", - inputSchema: { - type: "object" as const, - properties: { - session_id: { type: "string", description: "Session ID to resume (from list_sessions)" }, - agents_to_skip: { type: "array", items: { type: "string" }, description: "Agent names or roles to NOT respawn (optional)" }, - }, - required: ["session_id"], - }, - }, - { - name: "delete_session", - description: "Permanently delete a multiagents session and all its data (slots, messages, plans, file locks). Use when a session is no longer needed. Kills any running agents first. Cannot be undone.", - inputSchema: { - type: "object" as const, - properties: { - session_id: { type: "string", description: "Session ID to delete (from list_sessions)" }, - }, - required: ["session_id"], - }, - }, - { - name: "get_guide", - description: "Get comprehensive documentation and guidance for using multiagents. Call with no topic to see all available topics. Topics: overview, quickstart, roles, workflows, tools, session_lifecycle, troubleshooting, examples, best_practices. Start with 'quickstart' for a step-by-step tutorial.", - inputSchema: { - type: "object" as const, - properties: { - topic: { type: "string", enum: ["overview", "quickstart", "roles", "workflows", "tools", "session_lifecycle", "troubleshooting", "examples", "best_practices"], description: "Guide topic to read. Omit to see the topic list." }, - }, - }, - }, - ], -})); - -// Tool implementations server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; - const brokerClient = new BrokerClient(BROKER_URL); - - try { - switch (name) { - // ---- create_team ---- - case "create_team": { - const { project_dir, session_name, agents, plan: planItems } = args as { - project_dir: string; - session_name: string; - agents: AgentLaunchConfig[]; - plan?: { label: string; agent_name?: string }[]; - }; - - // Detect available agents — check ALL before failing, report what IS available - const unavailable: { name: string; type: string }[] = []; - const availableTypes = new Set(); - for (const agentCfg of agents) { - const detection = await detectAgent(agentCfg.agent_type); - if (!detection.available) { - unavailable.push({ name: agentCfg.name, type: agentCfg.agent_type }); - } else { - availableTypes.add(agentCfg.agent_type); - } - } - if (unavailable.length > 0) { - // Check what IS available on this machine - const allTypes: Array<"claude" | "codex" | "gemini"> = ["claude", "codex", "gemini"]; - for (const t of allTypes) { - const d = await detectAgent(t); - if (d.available) availableTypes.add(t); - } - const availableList = availableTypes.size > 0 - ? `Available agent CLIs on this machine: ${[...availableTypes].join(", ")}.` - : "No agent CLIs found. Install claude, codex, or gemini CLI first."; - const failedList = unavailable.map(u => `${u.name} (${u.type})`).join(", "); - return { - content: [{ - type: "text", - text: `Cannot create team — the following agent CLIs are not installed: ${failedList}.\n\n${availableList}\n\nPlease adjust your team to only use available agent types, or install the missing CLIs.`, - }], - }; - } - - // Generate unique session ID — check existing sessions to avoid collisions - let sessionId = slugify(session_name); - try { - const existing = await brokerClient.listSessions(); - const existingIds = new Set(existing.map((s: any) => s.id)); - if (existingIds.has(sessionId)) { - // Append incrementing suffix until unique - let i = 2; - while (existingIds.has(`${sessionId}-${i}`)) i++; - sessionId = `${sessionId}-${i}`; - } - } catch { /* broker may not support list yet — proceed with base slug */ } - - // Auto-create project directory if it doesn't exist - if (!fs.existsSync(project_dir)) { - fs.mkdirSync(project_dir, { recursive: true }); - } - - // Init git if not already a repo (needed for file coordination, but session works without it) - let gitRoot = await getGitRoot(project_dir); - if (!gitRoot) { - try { - const initProc = Bun.spawnSync(["git", "init"], { cwd: project_dir }); - if (initProc.exitCode === 0) { - gitRoot = project_dir; - } - } catch { /* git not installed — session still works without it */ } - } - - // Create session in broker - await brokerClient.createSession({ - id: sessionId, - name: session_name, - project_dir, - git_root: gitRoot, - }); - - const sessionProcs = new Map(); - activeProcesses.set(sessionId, sessionProcs); - activeSessions.set(sessionId, { projectDir: project_dir }); - - // Launch each agent with staggered delays to prevent race conditions. - // Codex agents share a global skills directory (~/.codex/system-skills/) - // that gets corrupted when multiple instances initialize simultaneously. - // Gemini via npx can also race on package caching. - const launchedAgents: { name: string; slot_id: number; pid: number }[] = []; - const launchedTypes = new Set(); - for (const agentCfg of agents) { - // Stagger if we've already launched an agent of this type - if (launchedTypes.has(agentCfg.agent_type)) { - await new Promise((r) => setTimeout(r, 3000)); - } - launchedTypes.add(agentCfg.agent_type); - - const result = await launchAgent(sessionId, project_dir, agentCfg, brokerClient); - trackLaunch(sessionId, result.slotId, result, brokerClient); - - // Announce to previously launched agents - const slot = await brokerClient.getSlot(result.slotId); - await announceNewMember(sessionId, slot, agentCfg, brokerClient); - - launchedAgents.push({ - name: agentCfg.name, - slot_id: result.slotId, - pid: result.pid, - }); - } - - // Create plan if provided, then broadcast to each agent with their assigned items - let planSummary = ""; - if (planItems && planItems.length > 0) { - const slotByName = new Map(launchedAgents.map((a) => [a.name, a.slot_id])); - const lastAgentSlot = launchedAgents[launchedAgents.length - 1]?.slot_id; - - // Every plan item MUST have an assignee — unassigned items go to - // the last agent in the list (responsible for final verification) - const items = planItems.map((item) => { - const resolved = item.agent_name ? slotByName.get(item.agent_name) : undefined; - return { - label: item.label, - assigned_to_slot: resolved ?? lastAgentSlot, - }; - }); - await brokerClient.createPlan({ - session_id: sessionId, - title: session_name, - items, - }); - - // Fetch the created plan to get actual item IDs - const plan = await brokerClient.getPlan(sessionId); - if (plan?.items) { - // Send each agent their personalized plan context - for (const agent of launchedAgents) { - const myItems = plan.items.filter((i: any) => i.assigned_to_slot === agent.slot_id); - if (myItems.length === 0) continue; - - const slot = await brokerClient.getSlot(agent.slot_id); - if (!slot?.peer_id) continue; - - const itemLines = myItems.map((i: any) => - ` [ ] #${i.id}: ${i.label}` - ).join("\n"); - - await brokerClient.sendMessage({ - from_id: "orchestrator", - to_id: slot.peer_id, - text: `PLAN — Your assigned items:\n${itemLines}\n\nAs you complete each item, call: update_plan({item_id: , status: "done"}).\nCall get_plan to see the full plan anytime.`, - msg_type: "system", - session_id: sessionId, - }); - } - } - - planSummary = `\nPlan: ${planItems.length} items tracked.`; - } - - const status = await getTeamStatus(sessionId, brokerClient); - const display = formatTeamStatusForDisplay(status); - - // Auto-launch dashboards (TUI in terminal + web in browser) - launchDashboard(sessionId, project_dir); - launchWebDashboard(sessionId, project_dir); - - return { - content: [{ - type: "text", - text: [ - `Session "${sessionId}" created with ${launchedAgents.length} agents.${planSummary}`, - "", - display, - "", - "IMPORTANT: Agents take 15-60 seconds to start up (loading MCP servers, connecting to broker).", - "They will show as 'starting' until they register. This is NORMAL — do NOT call cleanup_dead_slots", - "or assume agents have crashed. Wait at least 60 seconds before checking status.", - "", - `Dashboard launched — run \`multiagents dashboard ${sessionId}\` to reopen.`, - ].join("\n"), - }], - }; - } - - // ---- get_team_status ---- - case "get_team_status": { - const { session_id } = args as { session_id: string }; - const status = await getTeamStatus(session_id, brokerClient); - const display = formatTeamStatusForDisplay(status); - - // Include any pending events - const events = pendingEvents.splice(0, pendingEvents.length); - const eventText = events.length > 0 - ? "\n\nRecent events:\n" + events.map((e) => `[${e.severity}] ${e.message}`).join("\n") - : ""; - - // Check for BLOCKED messages from agents to orchestrator - let blockedText = ""; - try { - const recentMsgs = await brokerClient.getMessageLog(session_id, { limit: 50 }); - const blockedMsgs = recentMsgs.filter((m: any) => - m.to_id === "orchestrator" && m.text && /BLOCKED|NEED HELP|ESCALAT/i.test(m.text) - ); - if (blockedMsgs.length > 0) { - blockedText = "\n\nBLOCKED AGENTS (need your help):\n" + - blockedMsgs.slice(0, 5).map((m: any) => { - const fromSlot = status.agents.find((a) => a.slot_id === m.from_slot_id); - const fromName = fromSlot?.name ?? m.from_id; - return ` [!] ${fromName}: ${m.text.slice(0, 200)}`; - }).join("\n"); - } - } catch { /* best effort */ } - - // Completion check: all agents approved → suggest releasing - let completionText = ""; - const connectedAgents = status.agents.filter((a) => a.status === "connected"); - const allApproved = connectedAgents.length > 0 && connectedAgents.every((a) => a.task_state === "approved"); - const allDoneOrApproved = connectedAgents.length > 0 && connectedAgents.every((a) => - a.task_state === "approved" || a.task_state === "released" - ); - if (allApproved) { - completionText = "\n\nALL AGENTS APPROVED — Team work is complete. You can now call release_all to release all agents, or end_session to archive the session."; - } else if (!allDoneOrApproved && connectedAgents.length > 0) { - const needsWork = connectedAgents.filter((a) => a.task_state !== "approved" && a.task_state !== "released"); - completionText = `\n\nCompletion: ${connectedAgents.length - needsWork.length}/${connectedAgents.length} agents approved. Still working: ${needsWork.map((a) => `${a.name} (${a.task_state})`).join(", ")}`; - } - - return { content: [{ type: "text", text: display + eventText + blockedText + completionText }] }; - } - - // ---- broadcast_to_team ---- - case "broadcast_to_team": { - const { session_id, message, exclude_roles } = args as { - session_id: string; - message: string; - exclude_roles?: string[]; - }; - const result = await broadcastToTeam(session_id, message, brokerClient, exclude_roles); - return { content: [{ type: "text", text: `Broadcast delivered to ${result.delivered_to} agents.` }] }; - } - - // ---- direct_agent ---- - case "direct_agent": { - const { session_id, target, message } = args as { - session_id: string; - target: string; - message: string; - }; - const slot = await resolveTarget(session_id, target, brokerClient); - if (!slot || !slot.peer_id) { - return { content: [{ type: "text", text: `Could not find connected agent matching "${target}".` }] }; - } - - await brokerClient.sendMessage({ - from_id: "orchestrator", - to_id: slot.peer_id, - text: message, - msg_type: "chat", - session_id, - }); - - return { content: [{ type: "text", text: `Message sent to ${slot.display_name ?? `slot ${slot.id}`}.` }] }; - } - - // ---- add_agent ---- - case "add_agent": { - const { session_id, agent_type, name: agentName, role, role_description, initial_task, file_ownership } = args as { - session_id: string; - agent_type: "claude" | "codex" | "gemini"; - name: string; - role: string; - role_description: string; - initial_task: string; - file_ownership?: string[]; - }; - - const detection = await detectAgent(agent_type); - if (!detection.available) { - return { content: [{ type: "text", text: `Error: ${agent_type} CLI not found.` }] }; - } - - const session = await brokerClient.getSession(session_id); - const config: AgentLaunchConfig = { - agent_type, - name: agentName, - role, - role_description, - initial_task, - file_ownership, - }; - - const result = await launchAgent(session_id, session.project_dir, config, brokerClient); - if (!activeSessions.has(session_id)) { - activeSessions.set(session_id, { projectDir: session.project_dir }); - } - trackLaunch(session_id, result.slotId, result, brokerClient); - - const slot = await brokerClient.getSlot(result.slotId); - await announceNewMember(session_id, slot, config, brokerClient); - - return { - content: [{ - type: "text", - text: `Added ${agentName} (${agent_type}) as ${role} in slot ${result.slotId} (PID ${result.pid}).`, - }], - }; - } - - // ---- remove_agent ---- - case "remove_agent": { - const { session_id, target } = args as { session_id: string; target: string }; - const slot = await resolveTarget(session_id, target, brokerClient); - if (!slot) { - return { content: [{ type: "text", text: `Could not find agent matching "${target}".` }] }; - } - - // Release BEFORE stopping, so the exit handlers see "released" and skip auto-respawn - await brokerClient.updateSlot({ id: slot.id, status: "disconnected", task_state: "released" }); - - const driverState = activeDrivers.delete(session_id, slot.id); - if (driverState) { - await closeDriver(driverState); - } - const sessionProcs = activeProcesses.get(session_id); - const proc = sessionProcs?.get(slot.id); - if (proc) { - try { proc.kill(); } catch { /* already dead */ } - sessionProcs!.delete(slot.id); - } - clearSlotTracking(slot.id); - completionRestartHistory.delete(slot.id); - - // Notify remaining team - const slots = await brokerClient.listSlots(session_id); - for (const s of slots) { - if (s.id !== slot.id && s.status === "connected" && s.peer_id) { - await brokerClient.sendMessage({ - from_id: "orchestrator", - to_id: s.peer_id, - text: `[Team Update] ${slot.display_name ?? `Agent #${slot.id}`} (${slot.role ?? "unassigned"}) has been removed from the team.`, - msg_type: "team_change", - session_id, - }); - } - } - - return { content: [{ type: "text", text: `Removed ${slot.display_name ?? `slot ${slot.id}`} from the session.` }] }; - } - - // ---- control_session ---- - case "control_session": { - const { session_id, action, target, value } = args as { - session_id: string; - action: string; - target?: string; - value?: number; - }; - const result = await controlSession(session_id, action, brokerClient, target, value); - return { content: [{ type: "text", text: result.message }] }; - } - - // ---- adjust_guardrail ---- - case "adjust_guardrail": { - const { session_id, action, guardrail_id, new_value } = args as { - session_id: string; - action: "view" | "update"; - guardrail_id?: string; - new_value?: number; - }; - - if (action === "view") { - const checks = await checkGuardrails(session_id, brokerClient); - const lines = checks.map((c) => { - const icon = c.status === "triggered" ? "[!]" : c.status === "warning" ? "[?]" : "[ok]"; - return `${icon} ${c.message}`; - }); - return { content: [{ type: "text", text: lines.join("\n") }] }; - } - - if (action === "update") { - if (!guardrail_id || new_value === undefined) { - return { content: [{ type: "text", text: "guardrail_id and new_value required for update." }] }; - } - const updated = await brokerClient.updateGuardrail({ - session_id, - guardrail_id, - new_value, - changed_by: "orchestrator", - }); - return { content: [{ type: "text", text: `Updated ${guardrail_id} to ${new_value} ${updated.unit}.` }] }; - } - - return { content: [{ type: "text", text: `Unknown guardrail action: ${action}` }] }; - } - - // ---- get_session_log ---- - case "get_session_log": { - const { session_id, limit, since } = args as { - session_id: string; - limit?: number; - since?: number; - }; - const messages = await brokerClient.getMessageLog(session_id, { limit: limit ?? 50, since }); - const lines = messages.map((m) => { - const from = m.from_slot_id !== null ? `slot ${m.from_slot_id}` : m.from_id; - const to = m.to_slot_id !== null ? `slot ${m.to_slot_id}` : m.to_id; - return `[${m.sent_at}] ${from} -> ${to} (${m.msg_type}): ${m.text.slice(0, 200)}`; - }); - return { content: [{ type: "text", text: lines.length > 0 ? lines.join("\n") : "No messages found." }] }; - } - - // ---- release_agent ---- - case "release_agent": { - const { session_id, target, message } = args as { session_id: string; target: string; message?: string }; - const slot = await resolveTarget(session_id, target, brokerClient); - if (!slot) { - const slots = await brokerClient.listSlots(session_id); - return { content: [{ type: "text", text: `Agent "${target}" not found. Available: ${slots.map(s => s.display_name || s.role || s.id).join(", ")}` }] }; - } - const result = await brokerClient.releaseAgent({ - session_id, - target_slot_id: slot.id, - released_by: "__orchestrator__", - message, - }); - return { - content: [{ type: "text", text: `Released ${slot.display_name || slot.role || slot.id}. Task state: ${result.task_state}. Agent can now disconnect.` }], - }; - } - - // ---- release_all ---- - case "release_all": { - const { session_id, message } = args as { session_id: string; message?: string }; - const slots = await brokerClient.listSlots(session_id); - - // Warn if not all agents are approved - const connectedSlots = slots.filter((s) => s.status === "connected"); - const notApproved = connectedSlots.filter((s) => - s.task_state !== "approved" && s.task_state !== "released" - ); - if (notApproved.length > 0) { - const warning = `WARNING: ${notApproved.length} agent(s) not yet approved: ${notApproved.map((s) => `${s.display_name || s.role || s.id} (${s.task_state || "idle"})`).join(", ")}. Releasing anyway as requested.`; - log(LOG_PREFIX, warning); - // Still proceed — orchestrator explicitly asked to release - } - - let released = 0; - for (const slot of slots) { - if (slot.task_state !== "released" && slot.task_state !== "idle") { - await brokerClient.releaseAgent({ - session_id, - target_slot_id: slot.id, - released_by: "__orchestrator__", - message: message || "All agents released. Session complete.", - }); - released++; - } - } - return { - content: [{ type: "text", text: `Released ${released} agent(s). All agents can now disconnect.` }], - }; - } - - // ---- cleanup_dead_slots ---- - case "cleanup_dead_slots": { - const { session_id, keep_released } = args as { session_id: string; keep_released?: boolean }; - const slots = await brokerClient.listSlots(session_id); - let removed = 0; - const removedNames: string[] = []; - - const now = Date.now(); - let skippedStarting = 0; - - for (const slot of slots) { - const isDead = slot.status === "disconnected" && slot.task_state !== "released"; - const isReleased = slot.task_state === "released"; - const shouldRemove = isDead || (isReleased && !keep_released); - - // SAFETY: Never clean up slots that are still starting up. - // A slot that has never been connected (last_connected is null) is still - // booting its MCP server. Deleting it destroys the session. - if (shouldRemove && isDead && !slot.last_connected) { - skippedStarting++; - continue; // Still starting — don't delete - } - // Also skip slots that were recently connected (within 2 min) — might be restarting - if (shouldRemove && isDead && slot.last_disconnected && (now - slot.last_disconnected < 120_000)) { - skippedStarting++; - continue; - } - - if (shouldRemove) { - // Delete the slot from the broker DB - await brokerClient.deleteSlot(slot.id); - removedNames.push(slot.display_name || slot.role || `slot-${slot.id}`); - removed++; - - // Also remove from active process tracking - const sessionProcs = activeProcesses.get(session_id); - if (sessionProcs) { - const proc = sessionProcs.get(slot.id); - if (proc) { - try { proc.kill(); } catch { /* already dead */ } - sessionProcs.delete(slot.id); - } - } - } - } - - const startingNote = skippedStarting > 0 - ? `\nSkipped ${skippedStarting} slot(s) still starting up — these agents are booting and will connect shortly. Do NOT clean them up.` - : ""; - return { - content: [{ - type: "text", - text: (removed > 0 - ? `Cleaned up ${removed} dead slot(s): ${removedNames.join(", ")}` - : `No dead slots found. All ${slots.length} slot(s) are active.`) + startingNote, - }], - }; - } - - // ---- end_session ---- - case "end_session": { - const { session_id, create_pr } = args as { session_id: string; create_pr?: boolean }; - - stopSession(session_id); - - // Clean driver-mode sentinel file - try { - const session = await brokerClient.getSession(session_id); - const sentinelPath = require("node:path").join(session.project_dir, ".multiagents", ".driver-mode"); - require("node:fs").unlinkSync(sentinelPath); - } catch { /* ok */ } - - // Mark all slots as disconnected - const slots = await brokerClient.listSlots(session_id); - for (const slot of slots) { - if (slot.status === "connected") { - await brokerClient.updateSlot({ id: slot.id, status: "disconnected" }); - } - } - - // Archive session - await brokerClient.updateSession({ - id: session_id, - status: "archived", - }); - - // Clean up per-slot tracking state (memory leak prevention) - for (const slot of slots) { - clearSlotTracking(slot.id); - completionRestartHistory.delete(slot.id); - } - clearAllCrashHistory(); - - let prMessage = ""; - if (create_pr) { - prMessage = "\nNote: PR creation should be done by running `gh pr create` in the project directory."; - } - - return { - content: [{ - type: "text", - text: `Session "${session_id}" ended. ${slots.length} agents stopped. Session archived.${prMessage}`, - }], - }; - } - - // ---- delete_session ---- - case "delete_session": { - const { session_id } = args as { session_id: string }; - - stopSession(session_id); - - // Clean up per-slot tracking state (memory leak prevention) - clearAllTracking(); - clearAllCrashHistory(); - completionRestartHistory.clear(); - - const result = await brokerClient.deleteSession(session_id); - if (!result.ok) { - return { content: [{ type: "text", text: `Session "${session_id}" not found.` }] }; - } - - return { - content: [{ - type: "text", - text: `Session "${session_id}" permanently deleted. Removed: ${result.deleted.slots} slot(s), ${result.deleted.messages} message(s), ${result.deleted.plans} plan(s).`, - }], - }; - } - - // ---- list_sessions ---- - case "list_sessions": { - const { status_filter } = args as { status_filter?: string }; - const allSessions = await brokerClient.listSessions(); - - // Filter by status - const filtered = status_filter && status_filter !== "all" - ? allSessions.filter((s: any) => s.status === status_filter) - : allSessions; - - if (filtered.length === 0) { - return { content: [{ type: "text", text: `No sessions found${status_filter ? ` with status "${status_filter}"` : ""}.` }] }; - } - - // Enrich each session with slot counts and plan progress - const lines: string[] = ["=== Multiagents Sessions ===", ""]; - for (const session of filtered) { - const sid = (session as any).id; - let slotInfo = ""; - let planInfo = ""; - try { - const slots = await brokerClient.listSlots(sid); - const connected = slots.filter((s: any) => s.status === "connected").length; - const disconnected = slots.filter((s: any) => s.status === "disconnected").length; - slotInfo = ` | Agents: ${connected} online, ${disconnected} disconnected (${slots.length} total)`; - - // Plan progress - const plan = await brokerClient.getPlan(sid); - if (plan?.items && plan.items.length > 0) { - const done = plan.items.filter((i: any) => i.status === "done").length; - planInfo = ` | Plan: ${done}/${plan.items.length} done`; - } - } catch { /* best effort */ } - - const elapsed = Date.now() - ((session as any).created_at ?? 0); - const isManaged = activeProcesses.has(sid) ? " [MANAGED]" : ""; - lines.push(` ${sid}`); - lines.push(` Name: ${(session as any).name} | Status: ${(session as any).status}${isManaged} | Elapsed: ${formatDuration(elapsed)}`); - lines.push(` Dir: ${(session as any).project_dir}${slotInfo}${planInfo}`); - lines.push(""); - } - - lines.push(`${filtered.length} session(s) found. Use resume_session to restart a paused/stopped session.`); - - return { content: [{ type: "text", text: lines.join("\n") }] }; - } - - // ---- resume_session ---- - case "resume_session": { - const { session_id, agents_to_skip } = args as { session_id: string; agents_to_skip?: string[] }; - const skipSet = new Set((agents_to_skip ?? []).map(s => s.toLowerCase())); - - // Get session - const session = await brokerClient.getSession(session_id); - if (!session) { - return { content: [{ type: "text", text: `Session "${session_id}" not found.` }] }; - } - - // Update session status to active - await brokerClient.updateSession({ - id: session_id, - status: "active", - pause_reason: null, - paused_at: null, - }); - - // Get all slots - const slots = await brokerClient.listSlots(session_id); - // A slot whose driver this orchestrator still runs is not respawned on top of it, - // even if the broker lost track of its peer. - const disconnected = slots.filter((s: any) => - s.status === "disconnected" && - s.task_state !== "released" && - !activeDrivers.isLive(session_id, s.id) && - !skipSet.has((s.display_name ?? "").toLowerCase()) && - !skipSet.has((s.role ?? "").toLowerCase()) - ); - - if (disconnected.length === 0) { - // Track session even if no agents to respawn (for monitoring loops) - activeSessions.set(session_id, { projectDir: session.project_dir }); - if (!activeProcesses.has(session_id)) { - activeProcesses.set(session_id, new Map()); - } - return { content: [{ type: "text", text: `Session "${session_id}" resumed (status → active). No disconnected agents to respawn. ${slots.filter((s: any) => s.status === "connected").length} agent(s) already online.` }] }; - } - - // Respawn each disconnected agent with rich handoff context - const respawned: string[] = []; - const failed: string[] = []; - const resumeLaunchedTypes = new Set(); - - for (const slot of disconnected) { - try { - // Detect agent CLI availability - const detection = await detectAgent(slot.agent_type); - if (!detection.available) { - failed.push(`${slot.display_name || slot.role || slot.id} (${slot.agent_type} CLI not found)`); - continue; - } - - // Build rich handoff prompt - const snapshot = safeJsonParse>(slot.context_snapshot, {}); - - // Get message recap for this slot - let recapLines: string[] = []; - try { - const messages = await brokerClient.getMessageLog(session_id, { limit: 30, with_slot: slot.id }); - recapLines = messages.map((m: any) => - `[${m.msg_type}] ${m.from_id}: ${(m.text ?? "").slice(0, 200)}` - ); - } catch { /* ok */ } - - // Get plan items assigned to this slot - let planItemLines: string[] = []; - try { - const plan = await brokerClient.getPlan(session_id); - if (plan?.items) { - const myItems = plan.items.filter((i: any) => i.assigned_to_slot === slot.id); - planItemLines = myItems.map((i: any) => { - const marker = i.status === "done" ? "[x]" : i.status === "in_progress" ? "[~]" : "[ ]"; - return ` ${marker} ${i.label}`; - }); - } - } catch { /* ok */ } - - // Build team context - const teamContext = await buildTeamContext(session_id, slot.id, brokerClient); - - const handoffParts = [ - "SESSION RESUMED — You are being restarted to continue a previous session.", - "", - `Your role: ${slot.role ?? "unassigned"}`, - slot.role_description ? `Role description: ${slot.role_description}` : "", - snapshot.last_summary ? `Your last status: ${snapshot.last_summary}` : "", - snapshot.task_state ? `Your task state when you left: ${snapshot.task_state}` : "", - "", - teamContext, - ]; - - if (planItemLines.length > 0) { - handoffParts.push("", "Your assigned plan items:"); - handoffParts.push(...planItemLines); - } - - if (recapLines.length > 0) { - handoffParts.push("", "Recent message history (most recent last):"); - handoffParts.push(...recapLines); - } - - handoffParts.push( - "", - "INSTRUCTIONS:", - "1. Read the current state of the codebase — files may have changed since you disconnected.", - "2. Call check_team_status to see who else is online and what state they are in.", - "3. Call get_plan to see overall progress.", - "4. Resume your work from where you left off. If your task_state was 'addressing_feedback', check for unresolved feedback first.", - "5. Call set_summary to broadcast your current status to the team.", - ); - - const handoffTask = handoffParts.filter(Boolean).join("\n"); - - // Stagger same-type agent launches to prevent race conditions - if (resumeLaunchedTypes.has(slot.agent_type)) { - await new Promise((r) => setTimeout(r, 3000)); - } - resumeLaunchedTypes.add(slot.agent_type); - - // Relaunch into existing slot - const result = await respawnGuard.run(slot.id, async () => { - const launched = await relaunchIntoSlot(session_id, session.project_dir, slot, handoffTask, brokerClient); - trackLaunch(session_id, launched.slotId, launched, brokerClient); - return launched; - }); - if (!result) { - failed.push(`${slot.display_name || slot.role || slot.id}: respawn already in progress`); - continue; - } - - // Release held messages for this slot - try { - await brokerClient.releaseHeldMessages(session_id, slot.id); - } catch { /* ok */ } - - respawned.push(`${slot.display_name || slot.role || slot.id} (PID ${result.pid})`); - } catch (err) { - failed.push(`${slot.display_name || slot.role || slot.id}: ${err instanceof Error ? err.message : String(err)}`); - } - } - - // Track session for monitoring loops - activeSessions.set(session_id, { projectDir: session.project_dir }); - - // Launch dashboards (TUI + web) - launchDashboard(session_id, session.project_dir); - launchWebDashboard(session_id, session.project_dir); - - const lines: string[] = [`Session "${session_id}" resumed.`]; - if (respawned.length > 0) { - lines.push(`Respawned ${respawned.length} agent(s): ${respawned.join(", ")}`); - } - if (failed.length > 0) { - lines.push(`Failed to respawn ${failed.length}: ${failed.join("; ")}`); - } - const skipped = slots.length - disconnected.length - slots.filter((s: any) => s.status === "connected").length; - if (skipped > 0) { - lines.push(`Skipped ${skipped} slot(s) (released/archived).`); - } - lines.push(`\nDashboard launched. Use get_team_status to monitor progress.`); - - return { content: [{ type: "text", text: lines.join("\n") }] }; - } - - // ---- get_guide ---- - case "get_guide": { - const { topic } = args as { topic?: string }; - if (!topic) { - return { content: [{ type: "text", text: formatTopicList() }] }; - } - const content = getGuide(topic as GuideTopic); - if (!content) { - return { content: [{ type: "text", text: `Unknown topic "${topic}".\n\n${formatTopicList()}` }] }; - } - return { content: [{ type: "text", text: content }] }; - } - - default: - return { content: [{ type: "text", text: `Unknown tool: ${name}` }] }; - } - } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err); - log(LOG_PREFIX, `Tool error (${name}): ${errMsg}`); - return { content: [{ type: "text", text: `Error: ${errMsg}` }], isError: true }; - } + const { content, isError } = await callOrchestratorTool(name, args ?? {}); + return isError ? { content, isError } : { content }; }); -// --- Background loops --- - -let guardrailTimer: ReturnType | null = null; -let conflictTimer: ReturnType | null = null; - -function startBackgroundLoops(brokerClient: BrokerClient): void { - // Guardrail check loop - guardrailTimer = setInterval(async () => { - for (const sessionId of activeProcesses.keys()) { - try { - await enforceGuardrails(sessionId, brokerClient, handleEvent); - } catch (err) { - log(LOG_PREFIX, `Guardrail check error for ${sessionId}: ${err}`); - } - } - }, GUARDRAIL_CHECK_INTERVAL); - - // Dead slot auto-cleanup loop — every 60s, remove slots disconnected for >5 min - setInterval(async () => { - const now = Date.now(); - for (const sessionId of activeProcesses.keys()) { - try { - const slots = await brokerClient.listSlots(sessionId); - for (const slot of slots) { - if (slot.status !== "disconnected") continue; - - // CRITICAL: Skip slots that have NEVER connected — they're still starting up. - // Without this check, last_disconnected is null → defaults to 0 → deadFor = now - // (billions of ms) → the slot looks dead for "54 years" → gets deleted immediately. - // This was THE bug that caused all starting agents to vanish from the dashboard. - if (!slot.last_connected) continue; - - const disconnectedAt = slot.last_disconnected ?? slot.last_connected ?? now; - const deadFor = now - disconnectedAt; - if (deadFor < 5 * 60 * 1000) continue; // <5 min, might reconnect - - // Skip driver-managed slots — orchestrator controls their lifecycle - if (activeDrivers.has(sessionId, slot.id)) continue; - - // Auto-cleanup: delete the dead slot - try { - await brokerClient.deleteSlot(slot.id); - log(LOG_PREFIX, `Auto-deleted dead slot ${slot.id} (${slot.display_name || slot.role}) — disconnected for ${Math.round(deadFor / 60000)}m`); - } catch { /* best effort */ } - } - } catch { /* ok */ } - } - }, 60_000); - - // --- Driver delivery loop --- - // Every 3s: hand unacked messages to each driver (Claude: stdin user message, - // Codex: steer into the active turn or start a new one), interrupt Codex turns - // stuck in inference, and close drivers whose slot was released. - const INTERRUPT_IDLE_MS = 60_000; - - async function interruptIfStuck(slotId: number, state: Extract): Promise { - const threadId = state.threadId ?? state.driver.threadId; - const now = Date.now(); - // A running item (shell command, tool call) is legitimate work, never "stuck". - if ( - !threadId || - !state.driver.activeTurnId || - state.driver.activeItems > 0 || - now - state.driver.lastNotificationActivity <= INTERRUPT_IDLE_MS || - now - state.lastNudge <= INTERRUPT_IDLE_MS - ) return; - - state.lastNudge = now; - log(LOG_PREFIX, `Codex slot ${slotId} silent for ${Math.round((now - state.driver.lastNotificationActivity) / 1000)}s mid-turn — interrupting and requesting signal_done`); - await state.driver.interrupt(threadId); - await new Promise((r) => setTimeout(r, 2000)); - if (state.driver.activeTurnId || state.busy) return; - - state.busy = true; - state.driver.reply(threadId, "Your previous task is complete. Call signal_done NOW with a summary of what you accomplished. This is the ONLY thing you need to do.") - .then((result) => { - state.threadId = result.threadId; - log(LOG_PREFIX, `Codex slot ${slotId} signal_done turn completed`); - }) - .catch((err) => log(LOG_PREFIX, `Codex slot ${slotId} signal_done turn failed: ${err}`)) - .finally(() => { state.busy = false; }); - } - - // A released driver is closed once idle (releaseVerdict). Codex heartbeat - // replies keep lastActivity fresh, so only its notifications count as output. - async function closeIfReleased(sessionId: string, slotId: number, state: DriverSlot): Promise { - if (!(await slotIsReleased(slotId, brokerClient))) return; - const now = Date.now(); - state.releasedAt ??= now; - const peek = await brokerClient.peekUndelivered(slotId).catch(() => ({ count: 0 })); - const verdict = releaseVerdict({ - busy: state.busy, - turnActive: state.kind === "claude" ? state.driver.busy : state.driver.activeTurnId !== null, - lastOutputAt: state.kind === "claude" ? state.driver.lastActivity : state.driver.lastNotificationActivity, - releasedAt: state.releasedAt, - pendingCount: peek.count, - now, - }); - if (verdict === "keep") return; - activeDrivers.delete(sessionId, slotId); - log(LOG_PREFIX, `Slot ${slotId} released ${Math.round((now - state.releasedAt) / 1000)}s ago and idle — closing ${state.kind} driver`); - // Not awaited: a Claude close waits for the turn to end and must not stall other slots. - closeDriver(state).catch((err) => log(LOG_PREFIX, `Closing driver for slot ${slotId} failed: ${err}`)); - } - - // One tick at a time: a slow driver call must not let the next tick hand out the same messages again. - setInterval(nonOverlapping(async () => { - for (const [sessionId, drivers] of activeDrivers.sessions()) { - for (const [slotId, state] of drivers) { - if (!state.driver.alive) continue; - - if (state.kind === "codex") { - try { await interruptIfStuck(slotId, state); } catch (err) { log(LOG_PREFIX, `Interrupt check failed for slot ${slotId}: ${err}`); } - } - - try { - const outcome = await deliverPendingMessages(sessionId, slotId, state, brokerClient); - if (outcome === "failed") log(LOG_PREFIX, `Delivery to slot ${slotId} failed — messages stay queued for retry`); - } catch (err) { - log(LOG_PREFIX, `Delivery error for slot ${slotId}: ${err}`); - } - - try { await closeIfReleased(sessionId, slotId, state); } catch (err) { log(LOG_PREFIX, `Release check failed for slot ${slotId}: ${err}`); } - } - } - }), 3_000); - - // Stuck agent nudge loop — every 45s, nudge agents silent for >1 min - setInterval(async () => { - for (const sessionId of activeProcesses.keys()) { - try { - const slots = await brokerClient.listSlots(sessionId); - for (const slot of slots) { - if (slot.status !== "connected" || !slot.peer_id) continue; - if (slot.paused === true || (slot.paused as unknown as number) === 1) continue; - - // Skip agents in truly terminal task states — no point nudging them - const taskState = (slot as any).task_state; - if (taskState === "approved" || taskState === "released") continue; - // Note: done_pending_review agents are NOT skipped — they may need - // to respond to feedback or review requests from teammates. - - // Codex driver-managed agents get nudges delivered via the - // forwarding loop (steer or reply). Don't skip them — they need - // nudges too, especially to prompt signal_done after long turns. - - // Check last_connected or context_snapshot for activity - const lastActivity = (() => { - if (slot.context_snapshot) { - try { - const snap = JSON.parse(slot.context_snapshot); - if (snap.updated_at) return snap.updated_at; - } catch { /* ok */ } - } - return slot.last_connected ?? 0; - })(); - - const silentMs = Date.now() - lastActivity; - if (silentMs > 1 * 60 * 1000) { - const agentName = slot.display_name || `slot ${slot.id}`; - const silentMin = Math.round(silentMs / 60000); - - // Nudge the agent - await brokerClient.sendMessage({ - from_id: "orchestrator", - to_id: slot.peer_id, - text: `[NUDGE] You have been silent for ${silentMin} minutes. ` + - `Check your teammates with check_team_status and check_messages. ` + - `If you are done, call signal_done. If you are blocked, send a message to "orchestrator" explaining what you need.`, - msg_type: "system", - session_id: sessionId, - }); - - // Also alert the orchestrator user via pending events - pendingEvents.push({ - type: "agent_silent", - severity: silentMs > 5 * 60 * 1000 ? "warning" : "info", - slotId: slot.id, - sessionId, - message: `${agentName} (${slot.role || "unknown"}) silent for ${silentMin}m — task_state: ${(slot as any).task_state || "idle"}. May need intervention.`, - }); - log(LOG_PREFIX, `Nudged silent agent ${agentName} (${silentMin}m silent)`); - } - } - } catch { /* ok */ } - } - }, 45_000); - - // Conflict detection loop — basic git status monitoring - conflictTimer = setInterval(async () => { - for (const sessionId of activeProcesses.keys()) { - try { - const session = await brokerClient.getSession(sessionId); - if (session.status !== "active" || !session.git_root) continue; - - const proc = Bun.spawn(["git", "status", "--porcelain"], { - cwd: session.project_dir, - stdout: "pipe", - stderr: "ignore", - }); - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - if (exitCode === 0) { - // Check for merge conflicts (lines starting with UU, AA, DD, etc.) - const conflictLines = output - .split("\n") - .filter((l) => /^(UU|AA|DD|AU|UA|DU|UD)\s/.test(l)); - - if (conflictLines.length > 0) { - handleEvent({ - type: "git_conflict", - severity: "critical", - slotId: -1, - sessionId, - message: `Git conflicts detected: ${conflictLines.length} file(s)`, - data: { files: conflictLines.map((l) => l.slice(3).trim()) }, - }); - } - } - } catch (err) { - log(LOG_PREFIX, `Conflict check error for ${sessionId}: ${err}`); - } - } - }, CONFLICT_CHECK_INTERVAL); - - // Agent escalation monitor — check for messages sent TO "orchestrator" and surface as events - const seenEscalations = new Set(); - setInterval(async () => { - for (const sessionId of activeProcesses.keys()) { - try { - const messages = await brokerClient.getMessageLog(sessionId, { limit: 30 }); - for (const msg of messages) { - if (msg.to_id !== "orchestrator") continue; - // Deduplicate by message id or sent_at+from_id - const key = `${msg.from_id}:${msg.sent_at}`; - if (seenEscalations.has(key)) continue; - seenEscalations.add(key); - - const isBlocked = /BLOCKED|NEED HELP|ESCALAT|STUCK|CANNOT PROCEED/i.test(msg.text); - if (isBlocked) { - const slots = await brokerClient.listSlots(sessionId); - const fromSlot = slots.find((s) => s.peer_id === msg.from_id); - const fromName = fromSlot?.display_name || fromSlot?.role || msg.from_id; - - pendingEvents.push({ - type: "agent_blocked", - severity: "warning", - slotId: fromSlot?.id ?? -1, - sessionId, - message: `AGENT BLOCKED — ${fromName}: ${msg.text.slice(0, 300)}`, - }); - log(LOG_PREFIX, `Agent escalation from ${fromName}: ${msg.text.slice(0, 100)}`); - } - } - - // Prune old escalation keys (keep set from growing unbounded) - if (seenEscalations.size > 500) { - for (const escalationKey of [...seenEscalations].slice(0, 250)) { - seenEscalations.delete(escalationKey); - } - } - } catch { /* ok */ } - } - }, 15_000); - - // --- Session auto-end: archive session when all agents are approved/released --- - // Checks every 30s. Waits for a 30s grace period after the last approval - // to allow agents to see the final state before killing processes. - const sessionCompletionTimers: Map = new Map(); - setInterval(async () => { - for (const sessionId of activeProcesses.keys()) { - try { - const slots = await brokerClient.listSlots(sessionId); - if (slots.length === 0) continue; - - // --- Auto-approve orphaned done_pending_review agents --- - // When all reviewer/QA agents are approved/released/disconnected but some - // non-reviewer agents are still at done_pending_review, there's nobody left - // to approve them. Auto-approve to unblock session completion. - const reviewerSlots = slots.filter((s) => s.role && /qa|review|test|lead/i.test(s.role)); - const allReviewersDone = reviewerSlots.length > 0 && reviewerSlots.every( - (s) => (s as any).task_state === "approved" || (s as any).task_state === "released" || s.status === "disconnected" - ); - if (allReviewersDone) { - const orphaned = slots.filter( - (s) => (s as any).task_state === "done_pending_review" && !(s.role && /qa|review|test|lead/i.test(s.role)) - ); - for (const slot of orphaned) { - log(LOG_PREFIX, `Auto-approving orphaned ${slot.display_name ?? `slot ${slot.id}`} (all reviewers done, no one left to approve)`); - await brokerClient.updateSlot({ id: slot.id, task_state: "approved" } as any); - pendingEvents.push({ - type: "auto_approved", - severity: "info", - slotId: slot.id, - sessionId, - message: `${slot.display_name ?? `slot ${slot.id}`} auto-approved: all reviewers finished, no pending review possible.`, - }); - } - } - - const allDone = slots.every( - (s) => (s as any).task_state === "approved" || (s as any).task_state === "released" || s.status === "disconnected" - ); - const anyApproved = slots.some((s) => (s as any).task_state === "approved" || (s as any).task_state === "released"); - - if (allDone && anyApproved) { - const existingTimer = sessionCompletionTimers.get(sessionId); - if (!existingTimer) { - sessionCompletionTimers.set(sessionId, Date.now()); - log(LOG_PREFIX, `Session ${sessionId}: all agents done — starting 30s grace period before auto-end`); - pendingEvents.push({ - type: "session_completing", - severity: "info", - slotId: -1, - sessionId, - message: `All agents approved/released. Session will auto-end in 30s.`, - }); - } else if (Date.now() - existingTimer > 30_000) { - // Grace period elapsed — auto-end the session - log(LOG_PREFIX, `Session ${sessionId}: auto-ending (all agents approved for 30s)`); - - stopSession(sessionId); - - // Mark all connected slots as disconnected - for (const slot of slots) { - if (slot.status === "connected") { - await brokerClient.updateSlot({ id: slot.id, status: "disconnected" }); - } - } - - // Archive the session - await brokerClient.updateSession({ id: sessionId, status: "archived" }); - - // Clean up tracking state - for (const slot of slots) { - clearSlotTracking(slot.id); - completionRestartHistory.delete(slot.id); - } - sessionCompletionTimers.delete(sessionId); - - pendingEvents.push({ - type: "session_ended", - severity: "info", - slotId: -1, - sessionId, - message: `Session auto-ended: all agents approved. ${slots.length} agents stopped and session archived.`, - }); - } - } else { - // Reset timer if conditions no longer met (agent restarted, etc.) - sessionCompletionTimers.delete(sessionId); - } - } catch { /* ok */ } - } - }, 15_000); - - // --- Smart non-driver agent respawn --- - // - // Problem: process-launched agents (Gemini) only see teammate messages in - // their next MCP tool result. After signal_done or when idle, the LLM stops - // calling tools and messages pile up unacked. Teammates are blocked waiting. - // - // Solution: measure actual message staleness, not agent idle time. - // - A connected agent is judged by messages its adapter never claimed: a - // claimed one is merely waiting for the agent's next tool call - // - Disconnected agents with any actionable unacked messages → respawn immediately - // - Driver-managed agents (Claude, Codex) → skip, the delivery loop handles them - // - Paused agents are left alone - // - const CONNECTED_STALE_MS = STUCK_THRESHOLD_MS; - const lastRespawnAttempt: Map = new Map(); - - setInterval(async () => { - for (const sessionId of activeProcesses.keys()) { - try { - const slots = await brokerClient.listSlots(sessionId); - for (const slot of slots) { - const taskState = (slot as any).task_state; - if (taskState === "released") continue; - - // Skip driver-managed slots — the delivery loop handles them - if (activeDrivers.has(sessionId, slot.id)) continue; - - // Only act on processes this orchestrator launched; a connected slot it - // does not own may be a human's interactive session. - const owned = activeProcesses.get(sessionId)?.has(slot.id) === true; - if (slot.status === "connected" && !owned) continue; - - const inbox = watchdogInbox(slot, await brokerClient.peekUndelivered(slot.id)); - if (!inbox || inbox.count === 0) continue; - - // Only act on actionable message types - const hasActionable = inbox.msg_types.some( - (t) => t === "review_request" || t === "task_complete" || t === "feedback" || t === "system" - ); - if (!hasActionable) continue; - - const now = Date.now(); - - // A connected agent is stuck only when messages stay unclaimed past the threshold - if (slot.status === "connected") { - const messageAge = inbox.oldest_at ? now - inbox.oldest_at : 0; - if (messageAge < CONNECTED_STALE_MS) continue; - } - - // Throttle respawns (at most once per 45s per slot) - const lastAttempt = lastRespawnAttempt.get(slot.id) ?? 0; - if (now - lastAttempt < 45_000) continue; - lastRespawnAttempt.set(slot.id, now); - - const agentName = slot.display_name ?? `slot ${slot.id}`; - const sessionMeta = activeSessions.get(sessionId); - if (!sessionMeta) continue; - - // If connected but not polling — kill the stale process first - if (slot.status === "connected") { - const messageAgeSec = inbox.oldest_at ? Math.round((now - inbox.oldest_at) / 1000) : 0; - log(LOG_PREFIX, `${agentName} (${slot.agent_type}) has ${inbox.count} unclaimed message(s) for ${messageAgeSec}s — not polling, killing process`); - const sessionProcs = activeProcesses.get(sessionId); - const proc = sessionProcs?.get(slot.id); - if (proc) { - try { proc.kill(); } catch { /* ok */ } - sessionProcs?.delete(slot.id); - } - // Brief wait for broker to register disconnect - await new Promise((r) => setTimeout(r, 1000)); - } - - log(LOG_PREFIX, `Respawning ${agentName} (${slot.agent_type}) — ${inbox.count} unread messages`); - - try { - const result = await respawnGuard.run(slot.id, async () => { - const { respawnAgent } = await import("./recovery.ts"); - const launched = await respawnAgent(sessionId, slot.id, brokerClient, sessionMeta.projectDir); - trackLaunch(sessionId, slot.id, launched, brokerClient); - return launched; - }); - if (!result) continue; // another path is already respawning this slot - - pendingEvents.push({ - type: "agent_restarted", - severity: "info", - slotId: slot.id, - sessionId, - message: `${agentName} auto-respawned: ${inbox.count} unread messages waiting ${inbox.oldest_at ? Math.round((now - inbox.oldest_at) / 1000) : "?"}s`, - }); - } catch (err) { - log(LOG_PREFIX, `Failed to respawn ${agentName}: ${err}`); - } - } - } catch { /* ok */ } - } - }, 10_000); -} - // --- Main --- async function main(): Promise { - const brokerClient = new BrokerClient(BROKER_URL); + const brokerClient = new BrokerClient(brokerUrl()); // Ensure broker is running await ensureBroker(brokerClient); @@ -1975,11 +61,7 @@ main().catch((err) => { // --- Graceful shutdown: kill all managed agent processes --- function shutdownOrchestrator() { - log(LOG_PREFIX, "Shutting down — killing all managed agent processes"); - const sessions = new Set([...activeSessions.keys(), ...activeProcesses.keys(), ...activeDrivers.sessionIds()]); - for (const sessionId of sessions) { - stopSession(sessionId); - } + shutdownRuntime(); process.exit(0); } diff --git a/orchestrator/runtime.ts b/orchestrator/runtime.ts new file mode 100644 index 0000000..4f670da --- /dev/null +++ b/orchestrator/runtime.ts @@ -0,0 +1,2015 @@ +// ============================================================================ +// multiagents — Orchestrator Runtime +// ============================================================================ +// The orchestrator's agent lifecycle: launched processes and drivers, the +// background supervision loops, and the tool implementations (create_team, +// add_agent, release_agent, ...). Hosted by the MCP server +// (orchestrator-server.ts) and by the web dashboard (dashboard/server.ts), so +// both front-ends drive the same logic. Agents belong to the process that +// launched them: each host supervises only its own. +// ============================================================================ + +import type { Subprocess } from "bun"; + +import * as fs from "node:fs"; +import { BrokerClient } from "../shared/broker-client.ts"; +import type { AgentLaunchConfig } from "../shared/types.ts"; +import { log, getGitRoot, slugify, formatDuration, safeJsonParse } from "../shared/utils.ts"; +import { + GUARDRAIL_CHECK_INTERVAL, + CONFLICT_CHECK_INTERVAL, + FLAP_WINDOW_MS, + STUCK_THRESHOLD_MS, +} from "../shared/constants.ts"; +import { brokerUrl } from "../shared/broker-url.ts"; + +import { detectAgent, launchAgent, relaunchIntoSlot, announceNewMember, buildTeamContext, ENRICHED_PATH } from "./launcher.ts"; +import { getGuide, formatTopicList, type GuideTopic } from "./guide.ts"; +import { monitorProcess, monitorCodexDriver, monitorClaudeDriver, clearSlotTracking, clearAllTracking, type AgentEvent } from "./monitor.ts"; +import { deliverPendingMessages, type DriverSlot } from "./delivery.ts"; +import { RespawnGuard } from "./respawn-guard.ts"; +import type { CodexDriver } from "./codex-driver.ts"; +import type { ClaudeDriver } from "./claude-driver.ts"; +import { getTeamStatus, formatTeamStatusForDisplay } from "./progress.ts"; +import { checkGuardrails, enforceGuardrails } from "./guardrails.ts"; +import { handleAgentCrash, clearAllCrashHistory } from "./recovery.ts"; +import { controlSession, broadcastToTeam, resolveTarget } from "./session-control.ts"; +import { DriverRegistry, nonOverlapping, releaseVerdict, watchdogInbox } from "./supervision.ts"; + +const LOG_PREFIX = "orchestrator"; +const BROKER_URL = brokerUrl(); + +// Track active processes per session +const activeProcesses: Map> = new Map(); +// Track pending events to push as notifications +const pendingEvents: AgentEvent[] = []; +// Driver-managed agents (Claude over stream-json, Codex over app-server), one driver per slot +const activeDrivers = new DriverRegistry(); +// One respawn per slot at a time, whichever path (crash, code-0 exit, watchdog, resume) triggers it +const respawnGuard = new RespawnGuard(); + +interface LaunchedAgent { + pid: number; + process: Subprocess; + codexDriver?: CodexDriver; + claudeDriver?: ClaudeDriver; +} + +async function slotIsReleased(slotId: number, brokerClient: BrokerClient): Promise { + try { + return (await brokerClient.getTaskState(slotId)).task_state === "released"; + } catch { + return false; + } +} + +/** Graceful stop: Claude finishes its turn and exits on stdin EOF, Codex is killed. */ +async function closeDriver(state: DriverSlot): Promise { + if (state.kind === "claude") await state.driver.close(); + else await state.driver.kill(); +} + +/** + * Stop every agent of a session. The session is forgotten BEFORE anything is + * killed, so the exit handlers that fire while processes die find no session + * to respawn into. + */ +function stopSession(sessionId: string): { stopped: number } { + activeSessions.delete(sessionId); + const procs = activeProcesses.get(sessionId); + activeProcesses.delete(sessionId); + const drivers = activeDrivers.dropSession(sessionId); + + let stopped = 0; + for (const [slotId, state] of drivers ?? []) { + log(LOG_PREFIX, `Stopping ${state.kind} driver in slot ${slotId}`); + state.driver.kill().catch(() => { /* already dead */ }); + stopped++; + } + for (const [slotId, proc] of procs ?? []) { + if (drivers?.has(slotId)) continue; // a driver's process was killed above + log(LOG_PREFIX, `Stopping agent in slot ${slotId}`); + try { proc.kill(); } catch { /* already dead */ } + stopped++; + } + return { stopped }; +} + +/** + * Track a launched or respawned agent: process map, driver map, monitoring + * and exit handling. Every launch path goes through here so no agent is left + * unmonitored or double-tracked. + */ +function trackLaunch(sessionId: string, slotId: number, result: LaunchedAgent, brokerClient: BrokerClient): void { + let procs = activeProcesses.get(sessionId); + if (!procs) { + procs = new Map(); + activeProcesses.set(sessionId, procs); + } + procs.set(slotId, result.process); + + const state: DriverSlot | null = result.claudeDriver + ? { kind: "claude", driver: result.claudeDriver, busy: false, lastNudge: 0 } + : result.codexDriver + ? { kind: "codex", driver: result.codexDriver, threadId: result.codexDriver.threadId, busy: false, lastNudge: 0 } + : null; + if (!state) { + monitorProcess(result.process, slotId, sessionId, brokerClient, handleEvent); + return; + } + + const displaced = activeDrivers.track(sessionId, slotId, state); + if (displaced) { + log(LOG_PREFIX, `Slot ${slotId} already had a live ${displaced.kind} driver (pid ${displaced.driver.pid}) — stopping it in favour of the new one`); + displaced.driver.kill().catch(() => { /* already dead */ }); + } + if (state.kind === "claude") { + monitorClaudeDriver(state.driver, slotId, sessionId, brokerClient, handleEvent); + } else { + monitorCodexDriver(state.driver, slotId, sessionId, brokerClient, handleEvent); + } + state.driver.onExit((code) => onDriverExit(sessionId, slotId, state, code, brokerClient)); +} + +/** A driver process ended: detach it, mark the slot disconnected, and respawn only if it was not released. */ +function onDriverExit(sessionId: string, slotId: number, state: DriverSlot, code: number, brokerClient: BrokerClient): void { + const replaced = !activeDrivers.detach(sessionId, slotId, state) && activeDrivers.has(sessionId, slotId); + const procs = activeProcesses.get(sessionId); + if (procs?.get(slotId) === state.driver.process) procs.delete(slotId); + if (replaced) { + log(LOG_PREFIX, `${state.kind} driver for slot ${slotId} exited (code ${code}) after being replaced — its slot is live`); + return; + } + + void (async () => { + try { + await brokerClient.updateSlot({ id: slotId, status: "disconnected" }); + } catch { /* broker may be down */ } + + if (await slotIsReleased(slotId, brokerClient)) { + log(LOG_PREFIX, `${state.kind} driver for slot ${slotId} exited after release (code ${code})`); + pendingEvents.push({ type: "agent_released", severity: "info", slotId, sessionId, message: `Slot ${slotId} exited after being released` }); + return; + } + handleEvent({ + type: code === 0 ? "agent_completed" : "agent_crashed", + severity: code === 0 ? "info" : "critical", + slotId, + sessionId, + message: `${state.kind} driver for slot ${slotId} exited with code ${code}`, + data: { exit_code: code }, + }); + })(); +} + +// --- Dashboard auto-launch --- + +const CLI_PATH = new URL("../cli.ts", import.meta.url).pathname; +// Tests and headless hosts run the orchestrator without terminal windows or a browser +const DASHBOARDS_DISABLED = process.env.MULTIAGENTS_NO_DASHBOARD === "1"; + +/** + * Launch the TUI dashboard in a new terminal window. + * Tries macOS Terminal.app first via `open`, falls back to detached process. + */ +function launchDashboard(sessionId: string, projectDir: string): void { + if (DASHBOARDS_DISABLED || !runtimeOptions.launchDashboards) return; + try { + const platform = process.platform; + if (platform === "darwin") { + // macOS: open a new Terminal.app tab running the dashboard + const script = `tell application "Terminal" to do script "cd ${projectDir} && bun ${CLI_PATH} dashboard ${sessionId}"`; + Bun.spawn(["osascript", "-e", script], { + stdio: ["ignore", "ignore", "ignore"], + }).unref(); + } else if (platform === "linux") { + // Linux: try common terminal emulators + for (const term of ["gnome-terminal", "xterm", "konsole"]) { + const which = Bun.spawnSync(["which", term], { env: { ...process.env, PATH: ENRICHED_PATH } }); + if (which.exitCode === 0) { + Bun.spawn([term, "--", "bun", CLI_PATH, "dashboard", sessionId], { + cwd: projectDir, + stdio: ["ignore", "ignore", "ignore"], + }).unref(); + break; + } + } + } + log(LOG_PREFIX, `Dashboard launched for session ${sessionId}`); + } catch (e) { + log(LOG_PREFIX, `Dashboard auto-launch failed (non-critical): ${e}`); + } +} + +const WEB_DASHBOARD_PATH = new URL("../dashboard/server.ts", import.meta.url).pathname; + +/** + * Launch the web dashboard as a background Bun process. + * Auto-opens the browser on localhost:7900. + */ +function launchWebDashboard(sessionId: string, projectDir: string): void { + if (DASHBOARDS_DISABLED || !runtimeOptions.launchDashboards) return; + try { + const proc = Bun.spawn(["bun", WEB_DASHBOARD_PATH, sessionId], { + cwd: projectDir, + env: { ...process.env, PATH: ENRICHED_PATH }, + stdio: ["ignore", "ignore", "ignore"], + }); + proc.unref(); + log(LOG_PREFIX, `Web dashboard launched for session ${sessionId} (PID ${proc.pid})`); + } catch (e) { + log(LOG_PREFIX, `Web dashboard auto-launch failed (non-critical): ${e}`); + } +} + +// --- Broker lifecycle --- + +export async function ensureBroker(brokerClient: BrokerClient): Promise { + if (await brokerClient.isAlive()) { + log(LOG_PREFIX, "Broker already running"); + return; + } + + log(LOG_PREFIX, "Starting broker daemon..."); + const brokerScript = new URL("../broker.ts", import.meta.url).pathname; + const proc = Bun.spawn(["bun", brokerScript], { + stdio: ["ignore", "ignore", "inherit"], + }); + proc.unref(); + + for (let i = 0; i < 30; i++) { + await new Promise((r) => setTimeout(r, 200)); + if (await brokerClient.isAlive()) { + log(LOG_PREFIX, "Broker started"); + return; + } + } + throw new Error("Failed to start broker daemon after 6 seconds"); +} + +// --- Event handler --- + +function handleEvent(event: AgentEvent): void { + log(LOG_PREFIX, `[${event.severity}] ${event.message}`); + + // Auto-respawn on crash (unless flapping or the slot was intentionally released) + if (event.type === "agent_crashed" && event.data && !event.data.is_flapping) { + respawnAfterCrash(event).catch((err) => log(LOG_PREFIX, `Crash handler error: ${err}`)); + } else if (event.type === "agent_completed" && event.data) { + // Codex agents exit with code 0 when they finish their turn, but they may + // not have actually completed their task. Check task_state — if the agent + // hasn't signaled done (still "idle" or "addressing_feedback"), auto-respawn + // with a continuation prompt so it picks up where it left off. + const brokerClient = new BrokerClient(BROKER_URL); + autoRestartIfIncomplete(event.slotId, event.sessionId, brokerClient) + .then((restarted) => { + if (!restarted) { + pendingEvents.push(event); + } + }) + .catch((err) => { + log(LOG_PREFIX, `Auto-restart check error: ${err}`); + pendingEvents.push(event); + }); + } else { + pendingEvents.push(event); + } +} + +async function respawnAfterCrash(event: AgentEvent): Promise { + const sessionMeta = activeSessions.get(event.sessionId); + if (!sessionMeta) return; + const brokerClient = new BrokerClient(BROKER_URL); + + // A released slot exits on purpose (remove_agent, release_agent); respawning it + // would create an unassigned ghost agent that then receives the team's traffic. + if (await slotIsReleased(event.slotId, brokerClient)) { + log(LOG_PREFIX, `Slot ${event.slotId} exited after being released — not respawning`); + return; + } + + const crashEvent = await handleAgentCrash(event.slotId, event.data!.exit_code as number, event.sessionId, brokerClient); + pendingEvents.push(crashEvent); + if (crashEvent.data?.is_flapping) return; + + try { + const result = await respawnGuard.run(event.slotId, async () => { + const { respawnAgent } = await import("./recovery.ts"); + const launched = await respawnAgent(event.sessionId, event.slotId, brokerClient, sessionMeta.projectDir); + trackLaunch(event.sessionId, event.slotId, launched, brokerClient); + return launched; + }); + if (!result) { + log(LOG_PREFIX, `Slot ${event.slotId} is already being respawned — skipping crash respawn`); + return; + } + log(LOG_PREFIX, `Auto-respawned slot ${event.slotId} (PID ${result.pid}${result.codexDriver ? ", CodexDriver" : result.claudeDriver ? ", ClaudeDriver" : ""})`); + pendingEvents.push({ + type: "agent_restarted", + severity: "info", + slotId: event.slotId, + sessionId: event.sessionId, + message: `Slot ${event.slotId} auto-respawned after crash (PID ${result.pid})`, + }); + } catch (err) { + log(LOG_PREFIX, `Failed to auto-respawn slot ${event.slotId}: ${err}`); + } +} + +/** Flap detection for code-0 auto-restarts (separate from crash flap detection). */ +const completionRestartHistory: Map = new Map(); +const COMPLETION_RESTART_LIMIT = 3; // max code-0 restarts in FLAP_WINDOW_MS + +/** Check if a completed agent actually finished its task; if not, respawn it. */ +async function autoRestartIfIncomplete( + slotId: number, + sessionId: string, + brokerClient: BrokerClient, +): Promise { + try { + const taskInfo = await brokerClient.getTaskState(slotId); + const state = taskInfo.task_state; + const name = taskInfo.display_name ?? `Slot ${slotId}`; + + // Only restart if the agent hasn't completed its work + // "working" = actively implementing (was incorrectly "in_progress" before) + if (state === "idle" || state === "working" || state === "addressing_feedback") { + // Flap detection for code-0 exits — prevent infinite restart loops + const now = Date.now(); + const history = completionRestartHistory.get(slotId) ?? []; + const recent = history.filter((t) => now - t < FLAP_WINDOW_MS); + recent.push(now); + completionRestartHistory.set(slotId, recent); + + if (recent.length >= COMPLETION_RESTART_LIMIT) { + log(LOG_PREFIX, `${name} hit code-0 restart limit (${recent.length} in 5 min) — stopping auto-restart`); + pendingEvents.push({ + type: "agent_flapping", + severity: "critical", + slotId, + sessionId, + message: `${name} keeps exiting without completing work (${recent.length} restarts). Stopped auto-restart. Investigate why the agent exits early.`, + }); + return false; + } + + log(LOG_PREFIX, `${name} exited with code 0 but task_state="${state}" — auto-restarting (${recent.length}/${COMPLETION_RESTART_LIMIT})`); + + const sessionMeta = activeSessions.get(sessionId); + if (!sessionMeta) { + log(LOG_PREFIX, `No session metadata for ${sessionId}, cannot restart`); + return false; + } + + const result = await respawnGuard.run(slotId, async () => { + const { respawnAgent } = await import("./recovery.ts"); + const launched = await respawnAgent(sessionId, slotId, brokerClient, sessionMeta.projectDir); + trackLaunch(sessionId, slotId, launched, brokerClient); + return launched; + }); + if (!result) { + log(LOG_PREFIX, `${name} is already being respawned — skipping code-0 restart`); + return true; + } + log(LOG_PREFIX, `Auto-restarted ${name} (PID ${result.pid}${result.codexDriver ? ", CodexDriver" : result.claudeDriver ? ", ClaudeDriver" : ""})`); + + pendingEvents.push({ + type: "agent_restarted", + severity: "info", + slotId, + sessionId, + message: `${name} auto-restarted (was task_state="${state}", exited code 0)`, + }); + return true; + } + + // Agent completed normally (done_pending_review, approved, released) + log(LOG_PREFIX, `${name} completed with task_state="${state}" — no restart needed`); + return false; + } catch (err) { + log(LOG_PREFIX, `Failed to check task state for slot ${slotId}: ${err}`); + return false; + } +} + +/** Track session metadata for restart purposes. */ +const activeSessions: Map = new Map(); + +// --- Host options --- + +export interface RuntimeOptions { + /** Open the TUI and web dashboards after create_team / resume_session (the web host already is one). */ + launchDashboards: boolean; + /** Recorded as the actor of releases and other lifecycle changes. */ + actor: string; +} + +const runtimeOptions: RuntimeOptions = { launchDashboards: true, actor: "__orchestrator__" }; + +export function configureRuntime(opts: Partial): void { + Object.assign(runtimeOptions, opts); +} + +/** Whether this process launched (and so supervises) agents of the session. */ +export function isManagedSession(sessionId: string): boolean { + return activeProcesses.has(sessionId) || [...activeDrivers.sessionIds()].includes(sessionId); +} + +/** Slot ids of the session whose agent process this process owns. */ +export function managedSlotIds(sessionId: string): number[] { + const ids = new Set(activeProcesses.get(sessionId)?.keys() ?? []); + for (const [sid, drivers] of activeDrivers.sessions()) { + if (sid === sessionId) for (const slotId of drivers.keys()) ids.add(slotId); + } + return [...ids]; +} + +/** A human gate holds an all-approved session open until a person releases it. */ +async function humanGateEnabled(sessionId: string, brokerClient: BrokerClient): Promise { + try { + const session = await brokerClient.getSession(sessionId); + return safeJsonParse>(session.config, {}).human_gate === true; + } catch { + return false; + } +} + +export interface ToolResult { + content: { type: "text"; text: string }[]; + isError?: boolean; + /** Structured outcome for programmatic hosts (the MCP host ignores it). */ + data?: Record; +} + +function fail(text: string): ToolResult { + return { content: [{ type: "text", text }], isError: true }; +} + +// --- Tool definitions --- + +export const ORCHESTRATOR_TOOLS = [ + { + name: "create_team", + description: `Create a new multi-agent team session. Launches headless agents with assigned roles and file ownership. The project_dir will be created if it does not exist, and git will be initialized if needed. + +IMPORTANT — For best results, provide detailed role_descriptions including platform/framework, expertise, and constraints. The richer the description, the better the agent performs. + +Recommended team compositions: +- Feature development: Software Engineer + Code Reviewer + QA Engineer +- UI feature: UI/UX Designer + Software Engineer + Code Reviewer + QA Engineer +- Bug fix: Software Engineer + QA Engineer + +Core roles: 'Software Engineer', 'UI/UX Designer', 'QA Engineer', 'Code Reviewer' +Prefix with platform: 'Android Software Engineer', 'Web QA Engineer', 'iOS UI/UX Designer' + +Each agent receives role-specific best practices, tool discovery hints, and completion criteria automatically based on their role name and description. Agents communicate bidirectionally and loop until all parties approve.`, + inputSchema: { + type: "object" as const, + properties: { + project_dir: { type: "string", description: "Absolute path where agents will work. Will be created if it does not exist." }, + session_name: { type: "string", description: "Short name for this session. Used to generate session_id." }, + agents: { + type: "array", + items: { + type: "object", + properties: { + agent_type: { type: "string", enum: ["claude", "codex", "gemini"] }, + name: { type: "string", description: "Display name for the agent, e.g., 'Alice' or 'Backend Engineer'" }, + role: { type: "string", description: "Core role: 'Software Engineer', 'UI/UX Designer', 'QA Engineer', 'Code Reviewer'. Prefix with platform for specificity: 'Android Software Engineer', 'Web QA Engineer'." }, + role_description: { type: "string", description: "Detailed role brief including platform (web/android/ios/cli/api), framework/stack, specific expertise, constraints, and acceptance criteria. The richer this is, the better the agent performs." }, + initial_task: { type: "string", description: "Specific task with acceptance criteria. Include what 'done' looks like, files to modify, APIs to use, and verification steps." }, + file_ownership: { type: "array", items: { type: "string" }, description: "Glob patterns for files this agent owns exclusively, e.g., 'src/auth/**'" }, + }, + required: ["agent_type", "name", "role", "role_description", "initial_task"], + }, + }, + plan: { + type: "array", + description: "Plan items to track progress. Include review and QA tasks, not just implementation. Example: [{label: 'Implement auth API', agent_name: 'Engineer'}, {label: 'Review auth code', agent_name: 'Reviewer'}, {label: 'Test auth E2E', agent_name: 'QA'}]", + items: { + type: "object", + properties: { + label: { type: "string", description: "What needs to be done" }, + agent_name: { type: "string", description: "Name of the agent assigned to this item" }, + }, + required: ["label"], + }, + }, + human_gate: { type: "boolean", description: "Hold the session open once every agent is approved, until a person releases it (no auto-end). Default false." }, + }, + required: ["project_dir", "session_name", "agents"], + }, + }, + { + name: "get_team_status", + description: "Get current status of all agents in a multiagents session — their roles, connection state, task state (idle/working/done/addressing_feedback), and what they are working on. Use the session_id returned by create_team.", + inputSchema: { + type: "object" as const, + properties: { + session_id: { type: "string" }, + }, + required: ["session_id"], + }, + }, + { + name: "broadcast_to_team", + description: "Send a message to ALL connected agents in a multiagents session. Use for requirement changes, priority shifts, or announcements that every team member must see.", + inputSchema: { + type: "object" as const, + properties: { + session_id: { type: "string" }, + message: { type: "string" }, + exclude_roles: { type: "array", items: { type: "string" } }, + }, + required: ["session_id", "message"], + }, + }, + { + name: "direct_agent", + description: "Send a direct message to a specific agent by name, role, or slot ID. The agent receives this as a peer message and must respond.", + inputSchema: { + type: "object" as const, + properties: { + session_id: { type: "string" }, + target: { type: "string", description: "Agent name, role, or slot ID" }, + message: { type: "string" }, + }, + required: ["session_id", "target", "message"], + }, + }, + { + name: "add_agent", + description: "Spawn and add a new agent to an existing multiagents session. The agent auto-connects to the broker, receives team context, and gets role-specific best practices injected automatically based on role name and description.", + inputSchema: { + type: "object" as const, + properties: { + session_id: { type: "string" }, + agent_type: { type: "string", enum: ["claude", "codex", "gemini"] }, + name: { type: "string", description: "Display name for the agent" }, + role: { type: "string", description: "Core role: 'Software Engineer', 'UI/UX Designer', 'QA Engineer', 'Code Reviewer'. Prefix with platform for specificity." }, + role_description: { type: "string", description: "Detailed role brief including platform, framework, expertise, constraints. The richer this is, the better the agent performs." }, + initial_task: { type: "string", description: "Specific task with acceptance criteria. Include what 'done' looks like." }, + file_ownership: { type: "array", items: { type: "string" }, description: "Glob patterns for files this agent owns exclusively" }, + }, + required: ["session_id", "agent_type", "name", "role", "role_description", "initial_task"], + }, + }, + { + name: "remove_agent", + description: "Gracefully stop and remove an agent from the session. Kills the CLI process and marks the slot as disconnected.", + inputSchema: { + type: "object" as const, + properties: { + session_id: { type: "string" }, + target: { type: "string", description: "Agent name, role, or slot ID" }, + }, + required: ["session_id", "target"], + }, + }, + { + name: "control_session", + description: "Control a multiagents session. Actions: pause_all (pause every agent), resume_all (resume every agent), pause_agent (pause one by name/role), resume_agent (resume one), extend_budget (add minutes to time limit), set_budget (set exact time limit), status (get control state).", + inputSchema: { + type: "object" as const, + properties: { + session_id: { type: "string" }, + action: { type: "string", enum: ["pause_all", "resume_all", "pause_agent", "resume_agent", "extend_budget", "set_budget", "status"] }, + target: { type: "string", description: "Agent name/role/slot for agent-level actions, or guardrail_id for set_budget" }, + value: { type: "number", description: "New value for budget actions" }, + }, + required: ["session_id", "action"], + }, + }, + { + name: "adjust_guardrail", + description: "View or update multiagents session guardrail limits (e.g. max restarts per agent). Use action='view' to see all guardrails, action='update' to change one.", + inputSchema: { + type: "object" as const, + properties: { + session_id: { type: "string" }, + action: { type: "string", enum: ["view", "update"] }, + guardrail_id: { type: "string" }, + new_value: { type: "number" }, + }, + required: ["session_id", "action"], + }, + }, + { + name: "get_session_log", + description: "Get the inter-agent message history for a multiagents session. Returns timestamped messages between agents with sender/recipient info.", + inputSchema: { + type: "object" as const, + properties: { + session_id: { type: "string" }, + limit: { type: "number" }, + since: { type: "number", description: "Epoch ms timestamp to get messages after" }, + }, + required: ["session_id"], + }, + }, + { + name: "release_agent", + description: "Release a specific agent from the multiagents session, allowing it to disconnect. Only use after their work is approved and complete. Agents cannot self-disconnect — only the orchestrator can release them.", + inputSchema: { + type: "object" as const, + properties: { + session_id: { type: "string" }, + target: { type: "string", description: "Agent name, role, or slot ID" }, + message: { type: "string", description: "Optional release message" }, + }, + required: ["session_id", "target"], + }, + }, + { + name: "release_all", + description: "Release ALL agents in a multiagents session, allowing them to disconnect. Use when the entire team's work is complete and production-grade.", + inputSchema: { + type: "object" as const, + properties: { + session_id: { type: "string" }, + message: { type: "string", description: "Optional release message" }, + approved_only: { type: "boolean", description: "Release only agents whose task_state is approved. Default false." }, + }, + required: ["session_id"], + }, + }, + { + name: "end_session", + description: "End a multiagents session: releases and stops all agents, archives the session. Use when the project is complete.", + inputSchema: { + type: "object" as const, + properties: { + session_id: { type: "string" }, + create_pr: { type: "boolean" }, + }, + required: ["session_id"], + }, + }, + { + name: "cleanup_dead_slots", + description: "Remove disconnected/dead slots from a session that will never reconnect. Useful when crashed agents left stale slots behind.", + inputSchema: { + type: "object" as const, + properties: { + session_id: { type: "string" }, + keep_released: { type: "boolean", description: "If true, keep slots in 'released' state (default: false — removes all non-connected)" }, + }, + required: ["session_id"], + }, + }, + { + name: "list_sessions", + description: "List all multiagents sessions (active, paused, archived). Use to discover sessions from previous conversations that can be resumed with resume_session.", + inputSchema: { + type: "object" as const, + properties: { + status_filter: { type: "string", enum: ["all", "active", "paused", "archived"], description: "Filter by session status. Default: all" }, + }, + }, + }, + { + name: "resume_session", + description: "Resume a previously paused or stopped multiagents session. Respawns all disconnected agents with their previous role, context, message history, and plan items. Use list_sessions first to find the session_id.", + inputSchema: { + type: "object" as const, + properties: { + session_id: { type: "string", description: "Session ID to resume (from list_sessions)" }, + agents_to_skip: { type: "array", items: { type: "string" }, description: "Agent names or roles to NOT respawn (optional)" }, + }, + required: ["session_id"], + }, + }, + { + name: "delete_session", + description: "Permanently delete a multiagents session and all its data (slots, messages, plans, file locks). Use when a session is no longer needed. Kills any running agents first. Cannot be undone.", + inputSchema: { + type: "object" as const, + properties: { + session_id: { type: "string", description: "Session ID to delete (from list_sessions)" }, + }, + required: ["session_id"], + }, + }, + { + name: "get_guide", + description: "Get comprehensive documentation and guidance for using multiagents. Call with no topic to see all available topics. Topics: overview, quickstart, roles, workflows, tools, session_lifecycle, troubleshooting, examples, best_practices. Start with 'quickstart' for a step-by-step tutorial.", + inputSchema: { + type: "object" as const, + properties: { + topic: { type: "string", enum: ["overview", "quickstart", "roles", "workflows", "tools", "session_lifecycle", "troubleshooting", "examples", "best_practices"], description: "Guide topic to read. Omit to see the topic list." }, + }, + }, + }, +]; + +// --- Tool implementations --- + +export async function callOrchestratorTool(name: string, args: Record = {}): Promise { + const brokerClient = new BrokerClient(BROKER_URL); + + try { + switch (name) { + // ---- create_team ---- + case "create_team": { + const { project_dir, session_name, agents, plan: planItems, human_gate } = args as { + project_dir: string; + session_name: string; + agents: AgentLaunchConfig[]; + plan?: { label: string; agent_name?: string }[]; + human_gate?: boolean; + }; + + // Detect available agents — check ALL before failing, report what IS available + const unavailable: { name: string; type: string }[] = []; + const availableTypes = new Set(); + for (const agentCfg of agents) { + const detection = await detectAgent(agentCfg.agent_type); + if (!detection.available) { + unavailable.push({ name: agentCfg.name, type: agentCfg.agent_type }); + } else { + availableTypes.add(agentCfg.agent_type); + } + } + if (unavailable.length > 0) { + // Check what IS available on this machine + const allTypes: Array<"claude" | "codex" | "gemini"> = ["claude", "codex", "gemini"]; + for (const t of allTypes) { + const d = await detectAgent(t); + if (d.available) availableTypes.add(t); + } + const availableList = availableTypes.size > 0 + ? `Available agent CLIs on this machine: ${[...availableTypes].join(", ")}.` + : "No agent CLIs found. Install claude, codex, or gemini CLI first."; + const failedList = unavailable.map(u => `${u.name} (${u.type})`).join(", "); + return fail(`Cannot create team — the following agent CLIs are not installed: ${failedList}.\n\n${availableList}\n\nPlease adjust your team to only use available agent types, or install the missing CLIs.`); + } + + // Generate unique session ID — check existing sessions to avoid collisions + let sessionId = slugify(session_name); + try { + const existing = await brokerClient.listSessions(); + const existingIds = new Set(existing.map((s: any) => s.id)); + if (existingIds.has(sessionId)) { + // Append incrementing suffix until unique + let i = 2; + while (existingIds.has(`${sessionId}-${i}`)) i++; + sessionId = `${sessionId}-${i}`; + } + } catch { /* broker may not support list yet — proceed with base slug */ } + + // Auto-create project directory if it doesn't exist + if (!fs.existsSync(project_dir)) { + fs.mkdirSync(project_dir, { recursive: true }); + } + + // Init git if not already a repo (needed for file coordination, but session works without it) + let gitRoot = await getGitRoot(project_dir); + if (!gitRoot) { + try { + const initProc = Bun.spawnSync(["git", "init"], { cwd: project_dir }); + if (initProc.exitCode === 0) { + gitRoot = project_dir; + } + } catch { /* git not installed — session still works without it */ } + } + + // Create session in broker + await brokerClient.createSession({ + id: sessionId, + name: session_name, + project_dir, + git_root: gitRoot, + ...(human_gate ? { config: { human_gate: true } } : {}), + }); + + const sessionProcs = new Map(); + activeProcesses.set(sessionId, sessionProcs); + activeSessions.set(sessionId, { projectDir: project_dir }); + + // Launch each agent with staggered delays to prevent race conditions. + // Codex agents share a global skills directory (~/.codex/system-skills/) + // that gets corrupted when multiple instances initialize simultaneously. + // Gemini via npx can also race on package caching. + const launchedAgents: { name: string; slot_id: number; pid: number }[] = []; + const launchedTypes = new Set(); + for (const agentCfg of agents) { + // Stagger if we've already launched an agent of this type + if (launchedTypes.has(agentCfg.agent_type)) { + await new Promise((r) => setTimeout(r, 3000)); + } + launchedTypes.add(agentCfg.agent_type); + + const result = await launchAgent(sessionId, project_dir, agentCfg, brokerClient); + trackLaunch(sessionId, result.slotId, result, brokerClient); + + // Announce to previously launched agents + const slot = await brokerClient.getSlot(result.slotId); + await announceNewMember(sessionId, slot, agentCfg, brokerClient); + + launchedAgents.push({ + name: agentCfg.name, + slot_id: result.slotId, + pid: result.pid, + }); + } + + // Create plan if provided, then broadcast to each agent with their assigned items + let planSummary = ""; + if (planItems && planItems.length > 0) { + const slotByName = new Map(launchedAgents.map((a) => [a.name, a.slot_id])); + const lastAgentSlot = launchedAgents[launchedAgents.length - 1]?.slot_id; + + // Every plan item MUST have an assignee — unassigned items go to + // the last agent in the list (responsible for final verification) + const items = planItems.map((item) => { + const resolved = item.agent_name ? slotByName.get(item.agent_name) : undefined; + return { + label: item.label, + assigned_to_slot: resolved ?? lastAgentSlot, + }; + }); + await brokerClient.createPlan({ + session_id: sessionId, + title: session_name, + items, + }); + + // Fetch the created plan to get actual item IDs + const plan = await brokerClient.getPlan(sessionId); + if (plan?.items) { + // Send each agent their personalized plan context + for (const agent of launchedAgents) { + const myItems = plan.items.filter((i: any) => i.assigned_to_slot === agent.slot_id); + if (myItems.length === 0) continue; + + const slot = await brokerClient.getSlot(agent.slot_id); + if (!slot?.peer_id) continue; + + const itemLines = myItems.map((i: any) => + ` [ ] #${i.id}: ${i.label}` + ).join("\n"); + + await brokerClient.sendMessage({ + from_id: "orchestrator", + to_id: slot.peer_id, + text: `PLAN — Your assigned items:\n${itemLines}\n\nAs you complete each item, call: update_plan({item_id: , status: "done"}).\nCall get_plan to see the full plan anytime.`, + msg_type: "system", + session_id: sessionId, + }); + } + } + + planSummary = `\nPlan: ${planItems.length} items tracked.`; + } + + const status = await getTeamStatus(sessionId, brokerClient); + const display = formatTeamStatusForDisplay(status); + + // Auto-launch dashboards (TUI in terminal + web in browser) + launchDashboard(sessionId, project_dir); + launchWebDashboard(sessionId, project_dir); + + return { + content: [{ + type: "text", + text: [ + `Session "${sessionId}" created with ${launchedAgents.length} agents.${planSummary}`, + "", + display, + "", + "IMPORTANT: Agents take 15-60 seconds to start up (loading MCP servers, connecting to broker).", + "They will show as 'starting' until they register. This is NORMAL — do NOT call cleanup_dead_slots", + "or assume agents have crashed. Wait at least 60 seconds before checking status.", + "", + `Dashboard launched — run \`multiagents dashboard ${sessionId}\` to reopen.`, + ].join("\n"), + }], + data: { session_id: sessionId, agents: launchedAgents }, + }; + } + + // ---- get_team_status ---- + case "get_team_status": { + const { session_id } = args as { session_id: string }; + const status = await getTeamStatus(session_id, brokerClient); + const display = formatTeamStatusForDisplay(status); + + // Include any pending events + const events = pendingEvents.splice(0, pendingEvents.length); + const eventText = events.length > 0 + ? "\n\nRecent events:\n" + events.map((e) => `[${e.severity}] ${e.message}`).join("\n") + : ""; + + // Check for BLOCKED messages from agents to orchestrator + let blockedText = ""; + try { + const recentMsgs = await brokerClient.getMessageLog(session_id, { limit: 50 }); + const blockedMsgs = recentMsgs.filter((m: any) => + m.to_id === "orchestrator" && m.text && /BLOCKED|NEED HELP|ESCALAT/i.test(m.text) + ); + if (blockedMsgs.length > 0) { + blockedText = "\n\nBLOCKED AGENTS (need your help):\n" + + blockedMsgs.slice(0, 5).map((m: any) => { + const fromSlot = status.agents.find((a) => a.slot_id === m.from_slot_id); + const fromName = fromSlot?.name ?? m.from_id; + return ` [!] ${fromName}: ${m.text.slice(0, 200)}`; + }).join("\n"); + } + } catch { /* best effort */ } + + // Completion check: all agents approved → suggest releasing + let completionText = ""; + const connectedAgents = status.agents.filter((a) => a.status === "connected"); + const allApproved = connectedAgents.length > 0 && connectedAgents.every((a) => a.task_state === "approved"); + const allDoneOrApproved = connectedAgents.length > 0 && connectedAgents.every((a) => + a.task_state === "approved" || a.task_state === "released" + ); + if (allApproved) { + completionText = "\n\nALL AGENTS APPROVED — Team work is complete. You can now call release_all to release all agents, or end_session to archive the session."; + } else if (!allDoneOrApproved && connectedAgents.length > 0) { + const needsWork = connectedAgents.filter((a) => a.task_state !== "approved" && a.task_state !== "released"); + completionText = `\n\nCompletion: ${connectedAgents.length - needsWork.length}/${connectedAgents.length} agents approved. Still working: ${needsWork.map((a) => `${a.name} (${a.task_state})`).join(", ")}`; + } + + return { content: [{ type: "text", text: display + eventText + blockedText + completionText }] }; + } + + // ---- broadcast_to_team ---- + case "broadcast_to_team": { + const { session_id, message, exclude_roles } = args as { + session_id: string; + message: string; + exclude_roles?: string[]; + }; + const result = await broadcastToTeam(session_id, message, brokerClient, exclude_roles); + return { content: [{ type: "text", text: `Broadcast delivered to ${result.delivered_to} agents.` }] }; + } + + // ---- direct_agent ---- + case "direct_agent": { + const { session_id, target, message } = args as { + session_id: string; + target: string; + message: string; + }; + const slot = await resolveTarget(session_id, target, brokerClient); + if (!slot || !slot.peer_id) { + return fail(`Could not find connected agent matching "${target}".`); + } + + await brokerClient.sendMessage({ + from_id: "orchestrator", + to_id: slot.peer_id, + text: message, + msg_type: "chat", + session_id, + }); + + return { content: [{ type: "text", text: `Message sent to ${slot.display_name ?? `slot ${slot.id}`}.` }] }; + } + + // ---- add_agent ---- + case "add_agent": { + const { session_id, agent_type, name: agentName, role, role_description, initial_task, file_ownership } = args as { + session_id: string; + agent_type: "claude" | "codex" | "gemini"; + name: string; + role: string; + role_description: string; + initial_task: string; + file_ownership?: string[]; + }; + + const detection = await detectAgent(agent_type); + if (!detection.available) { + return fail(`Error: ${agent_type} CLI not found.`); + } + + const session = await brokerClient.getSession(session_id); + const config: AgentLaunchConfig = { + agent_type, + name: agentName, + role, + role_description, + initial_task, + file_ownership, + }; + + const result = await launchAgent(session_id, session.project_dir, config, brokerClient); + if (!activeSessions.has(session_id)) { + activeSessions.set(session_id, { projectDir: session.project_dir }); + } + trackLaunch(session_id, result.slotId, result, brokerClient); + + const slot = await brokerClient.getSlot(result.slotId); + await announceNewMember(session_id, slot, config, brokerClient); + + return { + content: [{ + type: "text", + text: `Added ${agentName} (${agent_type}) as ${role} in slot ${result.slotId} (PID ${result.pid}).`, + }], + data: { slot_id: result.slotId, pid: result.pid }, + }; + } + + // ---- remove_agent ---- + case "remove_agent": { + const { session_id, target } = args as { session_id: string; target: string }; + const slot = await resolveTarget(session_id, target, brokerClient); + if (!slot) { + return fail(`Could not find agent matching "${target}".`); + } + + // Release BEFORE stopping, so the exit handlers see "released" and skip auto-respawn + await brokerClient.updateSlot({ id: slot.id, status: "disconnected", task_state: "released" }); + + const driverState = activeDrivers.delete(session_id, slot.id); + if (driverState) { + await closeDriver(driverState); + } + const sessionProcs = activeProcesses.get(session_id); + const proc = sessionProcs?.get(slot.id); + if (proc) { + try { proc.kill(); } catch { /* already dead */ } + sessionProcs!.delete(slot.id); + } + clearSlotTracking(slot.id); + completionRestartHistory.delete(slot.id); + + // Notify remaining team + const slots = await brokerClient.listSlots(session_id); + for (const s of slots) { + if (s.id !== slot.id && s.status === "connected" && s.peer_id) { + await brokerClient.sendMessage({ + from_id: "orchestrator", + to_id: s.peer_id, + text: `[Team Update] ${slot.display_name ?? `Agent #${slot.id}`} (${slot.role ?? "unassigned"}) has been removed from the team.`, + msg_type: "team_change", + session_id, + }); + } + } + + return { content: [{ type: "text", text: `Removed ${slot.display_name ?? `slot ${slot.id}`} from the session.` }] }; + } + + // ---- control_session ---- + case "control_session": { + const { session_id, action, target, value } = args as { + session_id: string; + action: string; + target?: string; + value?: number; + }; + const result = await controlSession(session_id, action, brokerClient, target, value); + return { content: [{ type: "text", text: result.message }], ...(result.status === "error" ? { isError: true } : {}) }; + } + + // ---- adjust_guardrail ---- + case "adjust_guardrail": { + const { session_id, action, guardrail_id, new_value } = args as { + session_id: string; + action: "view" | "update"; + guardrail_id?: string; + new_value?: number; + }; + + if (action === "view") { + const checks = await checkGuardrails(session_id, brokerClient); + const lines = checks.map((c) => { + const icon = c.status === "triggered" ? "[!]" : c.status === "warning" ? "[?]" : "[ok]"; + return `${icon} ${c.message}`; + }); + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + + if (action === "update") { + if (!guardrail_id || new_value === undefined) { + return fail("guardrail_id and new_value required for update."); + } + const updated = await brokerClient.updateGuardrail({ + session_id, + guardrail_id, + new_value, + changed_by: "orchestrator", + }); + return { content: [{ type: "text", text: `Updated ${guardrail_id} to ${new_value} ${updated.unit}.` }] }; + } + + return fail(`Unknown guardrail action: ${action}`); + } + + // ---- get_session_log ---- + case "get_session_log": { + const { session_id, limit, since } = args as { + session_id: string; + limit?: number; + since?: number; + }; + const messages = await brokerClient.getMessageLog(session_id, { limit: limit ?? 50, since }); + const lines = messages.map((m) => { + const from = m.from_slot_id !== null ? `slot ${m.from_slot_id}` : m.from_id; + const to = m.to_slot_id !== null ? `slot ${m.to_slot_id}` : m.to_id; + return `[${m.sent_at}] ${from} -> ${to} (${m.msg_type}): ${m.text.slice(0, 200)}`; + }); + return { content: [{ type: "text", text: lines.length > 0 ? lines.join("\n") : "No messages found." }] }; + } + + // ---- release_agent ---- + case "release_agent": { + const { session_id, target, message } = args as { session_id: string; target: string; message?: string }; + const slot = await resolveTarget(session_id, target, brokerClient); + if (!slot) { + const slots = await brokerClient.listSlots(session_id); + return fail(`Agent "${target}" not found. Available: ${slots.map(s => s.display_name || s.role || s.id).join(", ")}`); + } + const result = await brokerClient.releaseAgent({ + session_id, + target_slot_id: slot.id, + released_by: runtimeOptions.actor, + message, + }); + return { + content: [{ type: "text", text: `Released ${slot.display_name || slot.role || slot.id}. Task state: ${result.task_state}. Agent can now disconnect.` }], + }; + } + + // ---- release_all ---- + case "release_all": { + const { session_id, message, approved_only } = args as { session_id: string; message?: string; approved_only?: boolean }; + const slots = await brokerClient.listSlots(session_id); + + // Warn if not all agents are approved + const connectedSlots = slots.filter((s) => s.status === "connected"); + const notApproved = connectedSlots.filter((s) => + s.task_state !== "approved" && s.task_state !== "released" + ); + if (notApproved.length > 0 && !approved_only) { + const warning = `WARNING: ${notApproved.length} agent(s) not yet approved: ${notApproved.map((s) => `${s.display_name || s.role || s.id} (${s.task_state || "idle"})`).join(", ")}. Releasing anyway as requested.`; + log(LOG_PREFIX, warning); + // Still proceed — orchestrator explicitly asked to release + } + + let released = 0; + for (const slot of slots) { + const releasable = approved_only + ? slot.task_state === "approved" + : slot.task_state !== "released" && slot.task_state !== "idle"; + if (releasable) { + await brokerClient.releaseAgent({ + session_id, + target_slot_id: slot.id, + released_by: runtimeOptions.actor, + message: message || "All agents released. Session complete.", + }); + released++; + } + } + return { + content: [{ type: "text", text: approved_only + ? `Released ${released} approved agent(s).` + : `Released ${released} agent(s). All agents can now disconnect.` }], + data: { released }, + }; + } + + // ---- cleanup_dead_slots ---- + case "cleanup_dead_slots": { + const { session_id, keep_released } = args as { session_id: string; keep_released?: boolean }; + const slots = await brokerClient.listSlots(session_id); + let removed = 0; + const removedNames: string[] = []; + + const now = Date.now(); + let skippedStarting = 0; + + for (const slot of slots) { + const isDead = slot.status === "disconnected" && slot.task_state !== "released"; + const isReleased = slot.task_state === "released"; + const shouldRemove = isDead || (isReleased && !keep_released); + + // SAFETY: Never clean up slots that are still starting up. + // A slot that has never been connected (last_connected is null) is still + // booting its MCP server. Deleting it destroys the session. + if (shouldRemove && isDead && !slot.last_connected) { + skippedStarting++; + continue; // Still starting — don't delete + } + // Also skip slots that were recently connected (within 2 min) — might be restarting + if (shouldRemove && isDead && slot.last_disconnected && (now - slot.last_disconnected < 120_000)) { + skippedStarting++; + continue; + } + + if (shouldRemove) { + // Delete the slot from the broker DB + await brokerClient.deleteSlot(slot.id); + removedNames.push(slot.display_name || slot.role || `slot-${slot.id}`); + removed++; + + // Also remove from active process tracking + const sessionProcs = activeProcesses.get(session_id); + if (sessionProcs) { + const proc = sessionProcs.get(slot.id); + if (proc) { + try { proc.kill(); } catch { /* already dead */ } + sessionProcs.delete(slot.id); + } + } + } + } + + const startingNote = skippedStarting > 0 + ? `\nSkipped ${skippedStarting} slot(s) still starting up — these agents are booting and will connect shortly. Do NOT clean them up.` + : ""; + return { + content: [{ + type: "text", + text: (removed > 0 + ? `Cleaned up ${removed} dead slot(s): ${removedNames.join(", ")}` + : `No dead slots found. All ${slots.length} slot(s) are active.`) + startingNote, + }], + }; + } + + // ---- end_session ---- + case "end_session": { + const { session_id, create_pr } = args as { session_id: string; create_pr?: boolean }; + + stopSession(session_id); + + // Clean driver-mode sentinel file + try { + const session = await brokerClient.getSession(session_id); + const sentinelPath = require("node:path").join(session.project_dir, ".multiagents", ".driver-mode"); + require("node:fs").unlinkSync(sentinelPath); + } catch { /* ok */ } + + // Mark all slots as disconnected + const slots = await brokerClient.listSlots(session_id); + for (const slot of slots) { + if (slot.status === "connected") { + await brokerClient.updateSlot({ id: slot.id, status: "disconnected" }); + } + } + + // Archive session + await brokerClient.updateSession({ + id: session_id, + status: "archived", + }); + + // Clean up per-slot tracking state (memory leak prevention) + for (const slot of slots) { + clearSlotTracking(slot.id); + completionRestartHistory.delete(slot.id); + } + clearAllCrashHistory(); + + let prMessage = ""; + if (create_pr) { + prMessage = "\nNote: PR creation should be done by running `gh pr create` in the project directory."; + } + + return { + content: [{ + type: "text", + text: `Session "${session_id}" ended. ${slots.length} agents stopped. Session archived.${prMessage}`, + }], + }; + } + + // ---- delete_session ---- + case "delete_session": { + const { session_id } = args as { session_id: string }; + + stopSession(session_id); + + // Clean up per-slot tracking state (memory leak prevention) + clearAllTracking(); + clearAllCrashHistory(); + completionRestartHistory.clear(); + + const result = await brokerClient.deleteSession(session_id); + if (!result.ok) { + return fail(`Session "${session_id}" not found.`); + } + + return { + content: [{ + type: "text", + text: `Session "${session_id}" permanently deleted. Removed: ${result.deleted.slots} slot(s), ${result.deleted.messages} message(s), ${result.deleted.plans} plan(s).`, + }], + }; + } + + // ---- list_sessions ---- + case "list_sessions": { + const { status_filter } = args as { status_filter?: string }; + const allSessions = await brokerClient.listSessions(); + + // Filter by status + const filtered = status_filter && status_filter !== "all" + ? allSessions.filter((s: any) => s.status === status_filter) + : allSessions; + + if (filtered.length === 0) { + return { content: [{ type: "text", text: `No sessions found${status_filter ? ` with status "${status_filter}"` : ""}.` }] }; + } + + // Enrich each session with slot counts and plan progress + const lines: string[] = ["=== Multiagents Sessions ===", ""]; + for (const session of filtered) { + const sid = (session as any).id; + let slotInfo = ""; + let planInfo = ""; + try { + const slots = await brokerClient.listSlots(sid); + const connected = slots.filter((s: any) => s.status === "connected").length; + const disconnected = slots.filter((s: any) => s.status === "disconnected").length; + slotInfo = ` | Agents: ${connected} online, ${disconnected} disconnected (${slots.length} total)`; + + // Plan progress + const plan = await brokerClient.getPlan(sid); + if (plan?.items && plan.items.length > 0) { + const done = plan.items.filter((i: any) => i.status === "done").length; + planInfo = ` | Plan: ${done}/${plan.items.length} done`; + } + } catch { /* best effort */ } + + const elapsed = Date.now() - ((session as any).created_at ?? 0); + const isManaged = activeProcesses.has(sid) ? " [MANAGED]" : ""; + lines.push(` ${sid}`); + lines.push(` Name: ${(session as any).name} | Status: ${(session as any).status}${isManaged} | Elapsed: ${formatDuration(elapsed)}`); + lines.push(` Dir: ${(session as any).project_dir}${slotInfo}${planInfo}`); + lines.push(""); + } + + lines.push(`${filtered.length} session(s) found. Use resume_session to restart a paused/stopped session.`); + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + + // ---- resume_session ---- + case "resume_session": { + const { session_id, agents_to_skip } = args as { session_id: string; agents_to_skip?: string[] }; + const skipSet = new Set((agents_to_skip ?? []).map(s => s.toLowerCase())); + + // Get session + const session = await brokerClient.getSession(session_id); + if (!session) { + return fail(`Session "${session_id}" not found.`); + } + + // Update session status to active + await brokerClient.updateSession({ + id: session_id, + status: "active", + pause_reason: null, + paused_at: null, + }); + + // Get all slots + const slots = await brokerClient.listSlots(session_id); + // A slot whose driver this orchestrator still runs is not respawned on top of it, + // even if the broker lost track of its peer. + const disconnected = slots.filter((s: any) => + s.status === "disconnected" && + s.task_state !== "released" && + !activeDrivers.isLive(session_id, s.id) && + !skipSet.has((s.display_name ?? "").toLowerCase()) && + !skipSet.has((s.role ?? "").toLowerCase()) + ); + + if (disconnected.length === 0) { + // Track session even if no agents to respawn (for monitoring loops) + activeSessions.set(session_id, { projectDir: session.project_dir }); + if (!activeProcesses.has(session_id)) { + activeProcesses.set(session_id, new Map()); + } + return { content: [{ type: "text", text: `Session "${session_id}" resumed (status → active). No disconnected agents to respawn. ${slots.filter((s: any) => s.status === "connected").length} agent(s) already online.` }] }; + } + + // Respawn each disconnected agent with rich handoff context + const respawned: string[] = []; + const failed: string[] = []; + const resumeLaunchedTypes = new Set(); + + for (const slot of disconnected) { + try { + // Detect agent CLI availability + const detection = await detectAgent(slot.agent_type); + if (!detection.available) { + failed.push(`${slot.display_name || slot.role || slot.id} (${slot.agent_type} CLI not found)`); + continue; + } + + // Build rich handoff prompt + const snapshot = safeJsonParse>(slot.context_snapshot, {}); + + // Get message recap for this slot + let recapLines: string[] = []; + try { + const messages = await brokerClient.getMessageLog(session_id, { limit: 30, with_slot: slot.id }); + recapLines = messages.map((m: any) => + `[${m.msg_type}] ${m.from_id}: ${(m.text ?? "").slice(0, 200)}` + ); + } catch { /* ok */ } + + // Get plan items assigned to this slot + let planItemLines: string[] = []; + try { + const plan = await brokerClient.getPlan(session_id); + if (plan?.items) { + const myItems = plan.items.filter((i: any) => i.assigned_to_slot === slot.id); + planItemLines = myItems.map((i: any) => { + const marker = i.status === "done" ? "[x]" : i.status === "in_progress" ? "[~]" : "[ ]"; + return ` ${marker} ${i.label}`; + }); + } + } catch { /* ok */ } + + // Build team context + const teamContext = await buildTeamContext(session_id, slot.id, brokerClient); + + const handoffParts = [ + "SESSION RESUMED — You are being restarted to continue a previous session.", + "", + `Your role: ${slot.role ?? "unassigned"}`, + slot.role_description ? `Role description: ${slot.role_description}` : "", + snapshot.last_summary ? `Your last status: ${snapshot.last_summary}` : "", + snapshot.task_state ? `Your task state when you left: ${snapshot.task_state}` : "", + "", + teamContext, + ]; + + if (planItemLines.length > 0) { + handoffParts.push("", "Your assigned plan items:"); + handoffParts.push(...planItemLines); + } + + if (recapLines.length > 0) { + handoffParts.push("", "Recent message history (most recent last):"); + handoffParts.push(...recapLines); + } + + handoffParts.push( + "", + "INSTRUCTIONS:", + "1. Read the current state of the codebase — files may have changed since you disconnected.", + "2. Call check_team_status to see who else is online and what state they are in.", + "3. Call get_plan to see overall progress.", + "4. Resume your work from where you left off. If your task_state was 'addressing_feedback', check for unresolved feedback first.", + "5. Call set_summary to broadcast your current status to the team.", + ); + + const handoffTask = handoffParts.filter(Boolean).join("\n"); + + // Stagger same-type agent launches to prevent race conditions + if (resumeLaunchedTypes.has(slot.agent_type)) { + await new Promise((r) => setTimeout(r, 3000)); + } + resumeLaunchedTypes.add(slot.agent_type); + + // Relaunch into existing slot + const result = await respawnGuard.run(slot.id, async () => { + const launched = await relaunchIntoSlot(session_id, session.project_dir, slot, handoffTask, brokerClient); + trackLaunch(session_id, launched.slotId, launched, brokerClient); + return launched; + }); + if (!result) { + failed.push(`${slot.display_name || slot.role || slot.id}: respawn already in progress`); + continue; + } + + // Release held messages for this slot + try { + await brokerClient.releaseHeldMessages(session_id, slot.id); + } catch { /* ok */ } + + respawned.push(`${slot.display_name || slot.role || slot.id} (PID ${result.pid})`); + } catch (err) { + failed.push(`${slot.display_name || slot.role || slot.id}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + // Track session for monitoring loops + activeSessions.set(session_id, { projectDir: session.project_dir }); + + // Launch dashboards (TUI + web) + launchDashboard(session_id, session.project_dir); + launchWebDashboard(session_id, session.project_dir); + + const lines: string[] = [`Session "${session_id}" resumed.`]; + if (respawned.length > 0) { + lines.push(`Respawned ${respawned.length} agent(s): ${respawned.join(", ")}`); + } + if (failed.length > 0) { + lines.push(`Failed to respawn ${failed.length}: ${failed.join("; ")}`); + } + const skipped = slots.length - disconnected.length - slots.filter((s: any) => s.status === "connected").length; + if (skipped > 0) { + lines.push(`Skipped ${skipped} slot(s) (released/archived).`); + } + lines.push(`\nDashboard launched. Use get_team_status to monitor progress.`); + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + + // ---- get_guide ---- + case "get_guide": { + const { topic } = args as { topic?: string }; + if (!topic) { + return { content: [{ type: "text", text: formatTopicList() }] }; + } + const content = getGuide(topic as GuideTopic); + if (!content) { + return { content: [{ type: "text", text: `Unknown topic "${topic}".\n\n${formatTopicList()}` }] }; + } + return { content: [{ type: "text", text: content }] }; + } + + default: + return fail(`Unknown tool: ${name}`); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + log(LOG_PREFIX, `Tool error (${name}): ${errMsg}`); + return { content: [{ type: "text", text: `Error: ${errMsg}` }], isError: true }; + } +} + +// --- Background loops --- + +let guardrailTimer: ReturnType | null = null; +let conflictTimer: ReturnType | null = null; + +let loopsStarted = false; + +export function startBackgroundLoops(brokerClient: BrokerClient): void { + if (loopsStarted) return; + loopsStarted = true; + + // Guardrail check loop + guardrailTimer = setInterval(async () => { + for (const sessionId of activeProcesses.keys()) { + try { + await enforceGuardrails(sessionId, brokerClient, handleEvent); + } catch (err) { + log(LOG_PREFIX, `Guardrail check error for ${sessionId}: ${err}`); + } + } + }, GUARDRAIL_CHECK_INTERVAL); + + // Dead slot auto-cleanup loop — every 60s, remove slots disconnected for >5 min + setInterval(async () => { + const now = Date.now(); + for (const sessionId of activeProcesses.keys()) { + try { + const slots = await brokerClient.listSlots(sessionId); + for (const slot of slots) { + if (slot.status !== "disconnected") continue; + + // CRITICAL: Skip slots that have NEVER connected — they're still starting up. + // Without this check, last_disconnected is null → defaults to 0 → deadFor = now + // (billions of ms) → the slot looks dead for "54 years" → gets deleted immediately. + // This was THE bug that caused all starting agents to vanish from the dashboard. + if (!slot.last_connected) continue; + + const disconnectedAt = slot.last_disconnected ?? slot.last_connected ?? now; + const deadFor = now - disconnectedAt; + if (deadFor < 5 * 60 * 1000) continue; // <5 min, might reconnect + + // Skip driver-managed slots — orchestrator controls their lifecycle + if (activeDrivers.has(sessionId, slot.id)) continue; + + // Auto-cleanup: delete the dead slot + try { + await brokerClient.deleteSlot(slot.id); + log(LOG_PREFIX, `Auto-deleted dead slot ${slot.id} (${slot.display_name || slot.role}) — disconnected for ${Math.round(deadFor / 60000)}m`); + } catch { /* best effort */ } + } + } catch { /* ok */ } + } + }, 60_000); + + // --- Driver delivery loop --- + // Every 3s: hand unacked messages to each driver (Claude: stdin user message, + // Codex: steer into the active turn or start a new one), interrupt Codex turns + // stuck in inference, and close drivers whose slot was released. + const INTERRUPT_IDLE_MS = 60_000; + + async function interruptIfStuck(slotId: number, state: Extract): Promise { + const threadId = state.threadId ?? state.driver.threadId; + const now = Date.now(); + // A running item (shell command, tool call) is legitimate work, never "stuck". + if ( + !threadId || + !state.driver.activeTurnId || + state.driver.activeItems > 0 || + now - state.driver.lastNotificationActivity <= INTERRUPT_IDLE_MS || + now - state.lastNudge <= INTERRUPT_IDLE_MS + ) return; + + state.lastNudge = now; + log(LOG_PREFIX, `Codex slot ${slotId} silent for ${Math.round((now - state.driver.lastNotificationActivity) / 1000)}s mid-turn — interrupting and requesting signal_done`); + await state.driver.interrupt(threadId); + await new Promise((r) => setTimeout(r, 2000)); + if (state.driver.activeTurnId || state.busy) return; + + state.busy = true; + state.driver.reply(threadId, "Your previous task is complete. Call signal_done NOW with a summary of what you accomplished. This is the ONLY thing you need to do.") + .then((result) => { + state.threadId = result.threadId; + log(LOG_PREFIX, `Codex slot ${slotId} signal_done turn completed`); + }) + .catch((err) => log(LOG_PREFIX, `Codex slot ${slotId} signal_done turn failed: ${err}`)) + .finally(() => { state.busy = false; }); + } + + // A released driver is closed once idle (releaseVerdict). Codex heartbeat + // replies keep lastActivity fresh, so only its notifications count as output. + async function closeIfReleased(sessionId: string, slotId: number, state: DriverSlot): Promise { + if (!(await slotIsReleased(slotId, brokerClient))) return; + const now = Date.now(); + state.releasedAt ??= now; + const peek = await brokerClient.peekUndelivered(slotId).catch(() => ({ count: 0 })); + const verdict = releaseVerdict({ + busy: state.busy, + turnActive: state.kind === "claude" ? state.driver.busy : state.driver.activeTurnId !== null, + lastOutputAt: state.kind === "claude" ? state.driver.lastActivity : state.driver.lastNotificationActivity, + releasedAt: state.releasedAt, + pendingCount: peek.count, + now, + }); + if (verdict === "keep") return; + activeDrivers.delete(sessionId, slotId); + log(LOG_PREFIX, `Slot ${slotId} released ${Math.round((now - state.releasedAt) / 1000)}s ago and idle — closing ${state.kind} driver`); + // Not awaited: a Claude close waits for the turn to end and must not stall other slots. + closeDriver(state).catch((err) => log(LOG_PREFIX, `Closing driver for slot ${slotId} failed: ${err}`)); + } + + // One tick at a time: a slow driver call must not let the next tick hand out the same messages again. + setInterval(nonOverlapping(async () => { + for (const [sessionId, drivers] of activeDrivers.sessions()) { + for (const [slotId, state] of drivers) { + if (!state.driver.alive) continue; + + if (state.kind === "codex") { + try { await interruptIfStuck(slotId, state); } catch (err) { log(LOG_PREFIX, `Interrupt check failed for slot ${slotId}: ${err}`); } + } + + try { + const outcome = await deliverPendingMessages(sessionId, slotId, state, brokerClient); + if (outcome === "failed") log(LOG_PREFIX, `Delivery to slot ${slotId} failed — messages stay queued for retry`); + } catch (err) { + log(LOG_PREFIX, `Delivery error for slot ${slotId}: ${err}`); + } + + try { await closeIfReleased(sessionId, slotId, state); } catch (err) { log(LOG_PREFIX, `Release check failed for slot ${slotId}: ${err}`); } + } + } + }), 3_000); + + // Stuck agent nudge loop — every 45s, nudge agents silent for >1 min + setInterval(async () => { + for (const sessionId of activeProcesses.keys()) { + try { + const slots = await brokerClient.listSlots(sessionId); + for (const slot of slots) { + if (slot.status !== "connected" || !slot.peer_id) continue; + if (slot.paused === true || (slot.paused as unknown as number) === 1) continue; + + // Skip agents in truly terminal task states — no point nudging them + const taskState = (slot as any).task_state; + if (taskState === "approved" || taskState === "released") continue; + // Note: done_pending_review agents are NOT skipped — they may need + // to respond to feedback or review requests from teammates. + + // Codex driver-managed agents get nudges delivered via the + // forwarding loop (steer or reply). Don't skip them — they need + // nudges too, especially to prompt signal_done after long turns. + + // Check last_connected or context_snapshot for activity + const lastActivity = (() => { + if (slot.context_snapshot) { + try { + const snap = JSON.parse(slot.context_snapshot); + if (snap.updated_at) return snap.updated_at; + } catch { /* ok */ } + } + return slot.last_connected ?? 0; + })(); + + const silentMs = Date.now() - lastActivity; + if (silentMs > 1 * 60 * 1000) { + const agentName = slot.display_name || `slot ${slot.id}`; + const silentMin = Math.round(silentMs / 60000); + + // Nudge the agent + await brokerClient.sendMessage({ + from_id: "orchestrator", + to_id: slot.peer_id, + text: `[NUDGE] You have been silent for ${silentMin} minutes. ` + + `Check your teammates with check_team_status and check_messages. ` + + `If you are done, call signal_done. If you are blocked, send a message to "orchestrator" explaining what you need.`, + msg_type: "system", + session_id: sessionId, + }); + + // Also alert the orchestrator user via pending events + pendingEvents.push({ + type: "agent_silent", + severity: silentMs > 5 * 60 * 1000 ? "warning" : "info", + slotId: slot.id, + sessionId, + message: `${agentName} (${slot.role || "unknown"}) silent for ${silentMin}m — task_state: ${(slot as any).task_state || "idle"}. May need intervention.`, + }); + log(LOG_PREFIX, `Nudged silent agent ${agentName} (${silentMin}m silent)`); + } + } + } catch { /* ok */ } + } + }, 45_000); + + // Conflict detection loop — basic git status monitoring + conflictTimer = setInterval(async () => { + for (const sessionId of activeProcesses.keys()) { + try { + const session = await brokerClient.getSession(sessionId); + if (session.status !== "active" || !session.git_root) continue; + + const proc = Bun.spawn(["git", "status", "--porcelain"], { + cwd: session.project_dir, + stdout: "pipe", + stderr: "ignore", + }); + const output = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + + if (exitCode === 0) { + // Check for merge conflicts (lines starting with UU, AA, DD, etc.) + const conflictLines = output + .split("\n") + .filter((l) => /^(UU|AA|DD|AU|UA|DU|UD)\s/.test(l)); + + if (conflictLines.length > 0) { + handleEvent({ + type: "git_conflict", + severity: "critical", + slotId: -1, + sessionId, + message: `Git conflicts detected: ${conflictLines.length} file(s)`, + data: { files: conflictLines.map((l) => l.slice(3).trim()) }, + }); + } + } + } catch (err) { + log(LOG_PREFIX, `Conflict check error for ${sessionId}: ${err}`); + } + } + }, CONFLICT_CHECK_INTERVAL); + + // Agent escalation monitor — check for messages sent TO "orchestrator" and surface as events + const seenEscalations = new Set(); + setInterval(async () => { + for (const sessionId of activeProcesses.keys()) { + try { + const messages = await brokerClient.getMessageLog(sessionId, { limit: 30 }); + for (const msg of messages) { + if (msg.to_id !== "orchestrator") continue; + // Deduplicate by message id or sent_at+from_id + const key = `${msg.from_id}:${msg.sent_at}`; + if (seenEscalations.has(key)) continue; + seenEscalations.add(key); + + const isBlocked = /BLOCKED|NEED HELP|ESCALAT|STUCK|CANNOT PROCEED/i.test(msg.text); + if (isBlocked) { + const slots = await brokerClient.listSlots(sessionId); + const fromSlot = slots.find((s) => s.peer_id === msg.from_id); + const fromName = fromSlot?.display_name || fromSlot?.role || msg.from_id; + + pendingEvents.push({ + type: "agent_blocked", + severity: "warning", + slotId: fromSlot?.id ?? -1, + sessionId, + message: `AGENT BLOCKED — ${fromName}: ${msg.text.slice(0, 300)}`, + }); + log(LOG_PREFIX, `Agent escalation from ${fromName}: ${msg.text.slice(0, 100)}`); + } + } + + // Prune old escalation keys (keep set from growing unbounded) + if (seenEscalations.size > 500) { + for (const escalationKey of [...seenEscalations].slice(0, 250)) { + seenEscalations.delete(escalationKey); + } + } + } catch { /* ok */ } + } + }, 15_000); + + // --- Session auto-end: archive session when all agents are approved/released --- + // Checks every 30s. Waits for a 30s grace period after the last approval + // to allow agents to see the final state before killing processes. + const sessionCompletionTimers: Map = new Map(); + setInterval(async () => { + for (const sessionId of activeProcesses.keys()) { + try { + const slots = await brokerClient.listSlots(sessionId); + if (slots.length === 0) continue; + + // --- Auto-approve orphaned done_pending_review agents --- + // When all reviewer/QA agents are approved/released/disconnected but some + // non-reviewer agents are still at done_pending_review, there's nobody left + // to approve them. Auto-approve to unblock session completion. + const reviewerSlots = slots.filter((s) => s.role && /qa|review|test|lead/i.test(s.role)); + const allReviewersDone = reviewerSlots.length > 0 && reviewerSlots.every( + (s) => (s as any).task_state === "approved" || (s as any).task_state === "released" || s.status === "disconnected" + ); + if (allReviewersDone) { + const orphaned = slots.filter( + (s) => (s as any).task_state === "done_pending_review" && !(s.role && /qa|review|test|lead/i.test(s.role)) + ); + for (const slot of orphaned) { + log(LOG_PREFIX, `Auto-approving orphaned ${slot.display_name ?? `slot ${slot.id}`} (all reviewers done, no one left to approve)`); + await brokerClient.updateSlot({ id: slot.id, task_state: "approved" } as any); + pendingEvents.push({ + type: "auto_approved", + severity: "info", + slotId: slot.id, + sessionId, + message: `${slot.display_name ?? `slot ${slot.id}`} auto-approved: all reviewers finished, no pending review possible.`, + }); + } + } + + const allDone = slots.every( + (s) => (s as any).task_state === "approved" || (s as any).task_state === "released" || s.status === "disconnected" + ); + const anyApproved = slots.some((s) => (s as any).task_state === "approved" || (s as any).task_state === "released"); + + // A human gate keeps approved agents waiting for a person's release + const awaitingHuman = allDone && slots.some((s) => (s as any).task_state === "approved") && + await humanGateEnabled(sessionId, brokerClient); + + if (allDone && anyApproved && !awaitingHuman) { + const existingTimer = sessionCompletionTimers.get(sessionId); + if (!existingTimer) { + sessionCompletionTimers.set(sessionId, Date.now()); + log(LOG_PREFIX, `Session ${sessionId}: all agents done — starting 30s grace period before auto-end`); + pendingEvents.push({ + type: "session_completing", + severity: "info", + slotId: -1, + sessionId, + message: `All agents approved/released. Session will auto-end in 30s.`, + }); + } else if (Date.now() - existingTimer > 30_000) { + // Grace period elapsed — auto-end the session + log(LOG_PREFIX, `Session ${sessionId}: auto-ending (all agents approved for 30s)`); + + stopSession(sessionId); + + // Mark all connected slots as disconnected + for (const slot of slots) { + if (slot.status === "connected") { + await brokerClient.updateSlot({ id: slot.id, status: "disconnected" }); + } + } + + // Archive the session + await brokerClient.updateSession({ id: sessionId, status: "archived" }); + + // Clean up tracking state + for (const slot of slots) { + clearSlotTracking(slot.id); + completionRestartHistory.delete(slot.id); + } + sessionCompletionTimers.delete(sessionId); + + pendingEvents.push({ + type: "session_ended", + severity: "info", + slotId: -1, + sessionId, + message: `Session auto-ended: all agents approved. ${slots.length} agents stopped and session archived.`, + }); + } + } else { + // Reset timer if conditions no longer met (agent restarted, etc.) + sessionCompletionTimers.delete(sessionId); + } + } catch { /* ok */ } + } + }, 15_000); + + // --- Smart non-driver agent respawn --- + // + // Problem: process-launched agents (Gemini) only see teammate messages in + // their next MCP tool result. After signal_done or when idle, the LLM stops + // calling tools and messages pile up unacked. Teammates are blocked waiting. + // + // Solution: measure actual message staleness, not agent idle time. + // - A connected agent is judged by messages its adapter never claimed: a + // claimed one is merely waiting for the agent's next tool call + // - Disconnected agents with any actionable unacked messages → respawn immediately + // - Driver-managed agents (Claude, Codex) → skip, the delivery loop handles them + // - Paused agents are left alone + // + const CONNECTED_STALE_MS = STUCK_THRESHOLD_MS; + const lastRespawnAttempt: Map = new Map(); + + setInterval(async () => { + for (const sessionId of activeProcesses.keys()) { + try { + const slots = await brokerClient.listSlots(sessionId); + for (const slot of slots) { + const taskState = (slot as any).task_state; + if (taskState === "released") continue; + + // Skip driver-managed slots — the delivery loop handles them + if (activeDrivers.has(sessionId, slot.id)) continue; + + // Only act on processes this orchestrator launched; a connected slot it + // does not own may be a human's interactive session. + const owned = activeProcesses.get(sessionId)?.has(slot.id) === true; + if (slot.status === "connected" && !owned) continue; + + const inbox = watchdogInbox(slot, await brokerClient.peekUndelivered(slot.id)); + if (!inbox || inbox.count === 0) continue; + + // Only act on actionable message types + const hasActionable = inbox.msg_types.some( + (t) => t === "review_request" || t === "task_complete" || t === "feedback" || t === "system" + ); + if (!hasActionable) continue; + + const now = Date.now(); + + // A connected agent is stuck only when messages stay unclaimed past the threshold + if (slot.status === "connected") { + const messageAge = inbox.oldest_at ? now - inbox.oldest_at : 0; + if (messageAge < CONNECTED_STALE_MS) continue; + } + + // Throttle respawns (at most once per 45s per slot) + const lastAttempt = lastRespawnAttempt.get(slot.id) ?? 0; + if (now - lastAttempt < 45_000) continue; + lastRespawnAttempt.set(slot.id, now); + + const agentName = slot.display_name ?? `slot ${slot.id}`; + const sessionMeta = activeSessions.get(sessionId); + if (!sessionMeta) continue; + + // If connected but not polling — kill the stale process first + if (slot.status === "connected") { + const messageAgeSec = inbox.oldest_at ? Math.round((now - inbox.oldest_at) / 1000) : 0; + log(LOG_PREFIX, `${agentName} (${slot.agent_type}) has ${inbox.count} unclaimed message(s) for ${messageAgeSec}s — not polling, killing process`); + const sessionProcs = activeProcesses.get(sessionId); + const proc = sessionProcs?.get(slot.id); + if (proc) { + try { proc.kill(); } catch { /* ok */ } + sessionProcs?.delete(slot.id); + } + // Brief wait for broker to register disconnect + await new Promise((r) => setTimeout(r, 1000)); + } + + log(LOG_PREFIX, `Respawning ${agentName} (${slot.agent_type}) — ${inbox.count} unread messages`); + + try { + const result = await respawnGuard.run(slot.id, async () => { + const { respawnAgent } = await import("./recovery.ts"); + const launched = await respawnAgent(sessionId, slot.id, brokerClient, sessionMeta.projectDir); + trackLaunch(sessionId, slot.id, launched, brokerClient); + return launched; + }); + if (!result) continue; // another path is already respawning this slot + + pendingEvents.push({ + type: "agent_restarted", + severity: "info", + slotId: slot.id, + sessionId, + message: `${agentName} auto-respawned: ${inbox.count} unread messages waiting ${inbox.oldest_at ? Math.round((now - inbox.oldest_at) / 1000) : "?"}s`, + }); + } catch (err) { + log(LOG_PREFIX, `Failed to respawn ${agentName}: ${err}`); + } + } + } catch { /* ok */ } + } + }, 10_000); +} + +/** Stop every agent this process launched (the host exits afterwards). */ +export function shutdownRuntime(): void { + log(LOG_PREFIX, "Shutting down — killing all managed agent processes"); + const sessions = new Set([...activeSessions.keys(), ...activeProcesses.keys(), ...activeDrivers.sessionIds()]); + for (const sessionId of sessions) { + stopSession(sessionId); + } +} diff --git a/orchestrator/session-control.ts b/orchestrator/session-control.ts index dd56f5b..7235b24 100644 --- a/orchestrator/session-control.ts +++ b/orchestrator/session-control.ts @@ -32,6 +32,7 @@ export async function controlSession( brokerClient: BrokerClient, target?: string, value?: number, + opts: { changedBy?: string; reason?: string } = {}, ): Promise { switch (action) { case "pause_all": @@ -93,7 +94,8 @@ export async function controlSession( session_id: sessionId, guardrail_id: target, new_value: value, - changed_by: "orchestrator", + changed_by: opts.changedBy ?? "orchestrator", + reason: opts.reason, }); // Check if this resolves a pause @@ -264,6 +266,7 @@ export async function broadcastToTeam( message: string, brokerClient: BrokerClient, excludeRoles?: string[], + fromId = "orchestrator", ): Promise<{ delivered_to: number }> { const slots = await brokerClient.listSlots(sessionId); const excludeSet = new Set((excludeRoles ?? []).map((r) => r.toLowerCase())); @@ -279,7 +282,7 @@ export async function broadcastToTeam( if (slot.role && excludeSet.has(slot.role.toLowerCase())) continue; await brokerClient.sendMessage({ - from_id: "orchestrator", + from_id: fromId, to_id: slot.peer_id, text: message, msg_type: "broadcast", diff --git a/shared/broker-client.ts b/shared/broker-client.ts index a93b05d..c472550 100644 --- a/shared/broker-client.ts +++ b/shared/broker-client.ts @@ -41,6 +41,8 @@ import type { KnowledgeGetRequest, KnowledgeDeleteRequest, KnowledgeCategory, + AddPlanItemRequest, + EditPlanItemRequest, } from "./types.ts"; /** HTTP client for the multiagents broker API. */ @@ -252,6 +254,20 @@ export class BrokerClient { return this.post("/plan/update-item", req); } + /** Append an item to the session's plan (creating the plan if needed). */ + addPlanItem(req: AddPlanItemRequest): Promise { + return this.post("/plan/add-item", req); + } + + /** Edit an item of the session's plan; fails if the item belongs to another session. */ + editPlanItem(req: EditPlanItemRequest): Promise { + return this.post("/plan/edit-item", req); + } + + deletePlanItem(sessionId: string, itemId: number): Promise { + return this.post("/plan/delete-item", { session_id: sessionId, item_id: itemId }); + } + // --- Slot management --- deleteSlot(slotId: number): Promise<{ ok: boolean; deleted: boolean }> { diff --git a/shared/session-ops.ts b/shared/session-ops.ts new file mode 100644 index 0000000..c0ec0ae --- /dev/null +++ b/shared/session-ops.ts @@ -0,0 +1,64 @@ +// ============================================================================ +// multiagents — Session operations shared by the CLI and the web dashboard +// ============================================================================ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { BrokerClient, type PlanState } from "./broker-client.ts"; +import { SESSION_DIR, SESSION_FILE } from "./constants.ts"; +import { getGitRoot, slugify } from "./utils.ts"; +import type { Session, SessionFile } from "./types.ts"; + +/** + * Create a session record for a project directory and write its local + * `.multiagents/session.json` (what `multiagents session create` does). + * The id is the slugified name, suffixed when a session already has it. + */ +export async function createSessionRecord( + client: BrokerClient, + name: string, + projectDir: string, + brokerPort: number, +): Promise { + const gitRoot = await getGitRoot(projectDir); + let id = slugify(name); + const existing = new Set((await client.listSessions().catch(() => [] as Session[])).map((s) => s.id)); + if (existing.has(id)) { + let i = 2; + while (existing.has(`${id}-${i}`)) i++; + id = `${id}-${i}`; + } + + const session = await client.createSession({ id, name, project_dir: projectDir, git_root: gitRoot }); + + const sessionDir = path.join(projectDir, SESSION_DIR); + if (!fs.existsSync(sessionDir)) fs.mkdirSync(sessionDir, { recursive: true }); + const sessionFile: SessionFile = { + session_id: session.id, + created_at: new Date().toISOString(), + broker_port: brokerPort, + }; + await Bun.write(path.join(projectDir, SESSION_FILE), JSON.stringify(sessionFile, null, 2)); + return session; +} + +/** Everything the broker knows about a session, as one JSON document. */ +export async function exportSessionData(client: BrokerClient, sessionId: string) { + const [session, slots, messages, plan, knowledge, guardrails] = await Promise.all([ + client.getSession(sessionId), + client.listSlots(sessionId), + client.getMessageLog(sessionId, { limit: 10000 }), + client.getPlan(sessionId).catch((): PlanState | null => null), + client.listKnowledge(sessionId).catch(() => []), + client.getGuardrails(sessionId).catch(() => []), + ]); + return { + exported_at: new Date().toISOString(), + session, + agents: slots, + messages, // oldest first, as the broker returns them + plan, + knowledge, + guardrails, + }; +} diff --git a/shared/types.ts b/shared/types.ts index 85c13b5..5533863 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -321,6 +321,25 @@ export interface UpdateSessionRequest { pause_reason?: string | null; paused_at?: number | null; config?: Record; + /** Keys merged into the existing config; other keys survive. */ + config_patch?: Record; +} + +export interface AddPlanItemRequest { + session_id: string; + label: string; + assigned_to_slot?: number | null; + /** Title for the plan when the session has none yet (default: the session name). */ + title?: string; +} + +export interface EditPlanItemRequest { + session_id: string; + item_id: number; + label?: string; + status?: "pending" | "in_progress" | "blocked" | "done"; + /** null unassigns the item. */ + assigned_to_slot?: number | null; } export interface CreateSlotRequest { diff --git a/tests/web-control.test.ts b/tests/web-control.test.ts new file mode 100644 index 0000000..8881e95 --- /dev/null +++ b/tests/web-control.test.ts @@ -0,0 +1,311 @@ +/** + * Web control API — the browser's write path. A real broker and dashboard run + * on test ports; teams are launched with the fake Claude CLI, so the dashboard + * hosts the orchestrator runtime exactly as it does for a person. + */ + +import { test, expect, beforeAll, afterAll, describe } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { awaitingHuman, workflowInstructions } from "../dashboard/control.ts"; +import type { Session, Slot } from "../shared/types.ts"; + +const BROKER_PORT = 17911; +const DASHBOARD_PORT = 17912; +const BROKER_URL = `http://127.0.0.1:${BROKER_PORT}`; +const DASHBOARD_URL = `http://127.0.0.1:${DASHBOARD_PORT}`; +const FAKE_CLAUDE = new URL("./fakes/fake-claude.ts", import.meta.url).pathname; +const SESSION = "web-control"; + +let brokerProc: import("bun").Subprocess | null = null; +let dashboardProc: import("bun").Subprocess | null = null; +let tmpDir = ""; +let slotA = 0; +let slotB = 0; + +async function broker(p: string, body: unknown): Promise { + const res = await fetch(`${BROKER_URL}${p}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + return res.json(); +} + +async function api(action: string, body?: unknown): Promise<{ status: number; ok: boolean; result?: any; error?: string }> { + const res = await fetch(`${DASHBOARD_URL}/api/${action}`, body === undefined + ? {} + : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + return { status: res.status, ...(await res.json()) as any }; +} + +async function waitFor(fn: () => Promise, timeoutMs = 10_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const v = await fn(); + if (v) return v; + await Bun.sleep(100); + } + throw new Error("waitFor timed out"); +} + +beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "web-control-")); + fs.mkdirSync(path.join(tmpDir, "proj")); + const env = { + ...process.env, + HOME: tmpDir, + MULTIAGENTS_PORT: String(BROKER_PORT), + MULTIAGENTS_DB: path.join(tmpDir, "peers.db"), + }; + brokerProc = Bun.spawn(["bun", new URL("../broker.ts", import.meta.url).pathname], { env, stdout: "ignore", stderr: "ignore" }); + await waitFor(async () => (await fetch(`${BROKER_URL}/health`).catch(() => null))?.ok); + + await broker("/sessions/create", { id: SESSION, name: "Web Control", project_dir: path.join(tmpDir, "proj") }); + slotA = (await broker("/slots/create", { session_id: SESSION, display_name: "Worker", agent_type: "claude", role: "Software Engineer" })).id; + slotB = (await broker("/slots/create", { session_id: SESSION, display_name: "Reviewer", agent_type: "claude", role: "Code Reviewer" })).id; + + dashboardProc = Bun.spawn(["bun", new URL("../dashboard/server.ts", import.meta.url).pathname, SESSION], { + env: { + ...env, + MULTIAGENTS_WEB_PORT: String(DASHBOARD_PORT), + MULTIAGENTS_NO_OPEN: "1", + MULTIAGENTS_NO_DASHBOARD: "1", + MULTIAGENTS_CLAUDE_COMMAND: `bun ${FAKE_CLAUDE}`, + }, + stdout: "ignore", + stderr: "ignore", + }); + await waitFor(async () => (await fetch(DASHBOARD_URL).catch(() => null))?.ok); +}, 30_000); + +afterAll(() => { + dashboardProc?.kill(); + brokerProc?.kill(); +}); + +describe("messages", () => { + test("a message to an agent is sent as the human, addressed by slot", async () => { + const r = await api("messages/send", { session_id: SESSION, slot_id: slotA, text: "Explain your reasoning" }); + expect(r.ok).toBe(true); + const log = await broker("/message-log", { session_id: SESSION, limit: 10 }); + const sent = log.find((m: any) => m.text === "Explain your reasoning"); + expect(sent.from_id).toBe("human"); + expect(sent.to_slot_id).toBe(slotA); + expect(sent.msg_type).toBe("chat"); + }); + + test("a slot of another session is refused", async () => { + await broker("/sessions/create", { id: "web-control-other", name: "Other", project_dir: tmpDir }); + const r = await api("messages/send", { session_id: "web-control-other", slot_id: slotA, text: "wrong session" }); + expect(r.status).toBe(404); + expect(r.ok).toBe(false); + expect(r.error).toContain("not part of session"); + }); + + test("mutations name their session", async () => { + const r = await api("messages/send", { slot_id: slotA, text: "no session" }); + expect(r.status).toBe(400); + expect(r.error).toContain("session_id"); + }); + + test("a broadcast with nobody connected fails instead of claiming success", async () => { + const r = await api("messages/send", { session_id: SESSION, slot_id: "all", text: "anyone?" }); + expect(r.ok).toBe(false); + expect(r.status).toBe(422); + }); + + test("a message to a paused agent is held and says so", async () => { + expect((await api("agents/control", { session_id: SESSION, slot_id: slotA, action: "pause" })).ok).toBe(true); + const r = await api("messages/send", { session_id: SESSION, slot_id: slotA, text: "while paused" }); + expect(r.result.warning).toContain("paused"); + expect((await api("agents/control", { session_id: SESSION, slot_id: slotA, action: "resume" })).ok).toBe(true); + const slot = await broker("/slots/get", { id: slotA }); + expect(Boolean(slot.paused)).toBe(false); + }); +}); + +describe("agents", () => { + test("rename and role changes land on the slot", async () => { + expect((await api("agents/rename", { session_id: SESSION, slot_id: slotB, display_name: "Critic" })).ok).toBe(true); + expect((await api("agents/role", { session_id: SESSION, slot_id: slotB, role: "QA Engineer", role_description: "Tests everything" })).ok).toBe(true); + const slot = await broker("/slots/get", { id: slotB }); + expect(slot.display_name).toBe("Critic"); + expect(slot.role).toBe("QA Engineer"); + expect(slot.role_description).toBe("Tests everything"); + }); + + test("send back moves the agent to addressing_feedback with the human's feedback", async () => { + await broker("/slots/update", { id: slotA, task_state: "approved" }); + const r = await api("agents/feedback", { session_id: SESSION, slot_id: slotA, feedback: "Handle the empty case" }); + expect(r.ok).toBe(true); + expect((await broker("/slots/get", { id: slotA })).task_state).toBe("addressing_feedback"); + const log = await broker("/message-log", { session_id: SESSION, limit: 5, msg_type: "feedback" }); + expect(log[0].from_id).toBe("human"); + expect(log[0].text).toContain("Handle the empty case"); + }); + + test("release all approved leaves unapproved agents alone", async () => { + await broker("/slots/update", { id: slotA, task_state: "approved" }); + await broker("/slots/update", { id: slotB, task_state: "working" }); + const r = await api("agents/release-all", { session_id: SESSION, approved_only: true }); + expect(r.ok).toBe(true); + expect((await broker("/slots/get", { id: slotA })).task_state).toBe("released"); + expect((await broker("/slots/get", { id: slotB })).task_state).toBe("working"); + }); + + test("releasing a released agent is an error", async () => { + const r = await api("agents/release", { session_id: SESSION, slot_id: slotA }); + expect(r.ok).toBe(false); + }); +}); + +describe("plan", () => { + test("add, edit, reassign and delete tasks", async () => { + const added = await api("plan/add", { session_id: SESSION, label: "Write the parser", slot_id: slotB }); + expect(added.ok).toBe(true); + const item = added.result.items.find((i: any) => i.label === "Write the parser"); + expect(item.assigned_to_slot).toBe(slotB); + expect(added.result.plan.title).toBe("Web Control"); + + const edited = await api("plan/edit", { session_id: SESSION, item_id: item.id, label: "Write the lexer", status: "in_progress", slot_id: null }); + const after = edited.result.items.find((i: any) => i.id === item.id); + expect(after.label).toBe("Write the lexer"); + expect(after.status).toBe("in_progress"); + expect(after.assigned_to_slot).toBeNull(); + + expect((await api("plan/edit", { session_id: SESSION, item_id: item.id, status: "nope" })).status).toBe(400); + expect((await api("plan/edit", { session_id: "web-control-other", item_id: item.id, status: "done" })).status).toBe(404); + + const deleted = await api("plan/delete", { session_id: SESSION, item_id: item.id }); + expect(deleted.result.items.some((i: any) => i.id === item.id)).toBe(false); + const again = await api("plan/delete", { session_id: SESSION, item_id: item.id }); + expect(again.status).toBe(404); + expect(again.error).toBe("Plan item not found in session"); + }); +}); + +describe("knowledge, files, guardrails", () => { + test("knowledge put and delete", async () => { + expect((await api("knowledge/put", { session_id: SESSION, key: "indent", value: "2 spaces", category: "convention" })).ok).toBe(true); + let entries = await broker("/knowledge/list", { session_id: SESSION }); + expect(entries.find((e: any) => e.key === "indent").value).toBe("2 spaces"); + expect((await api("knowledge/put", { session_id: SESSION, key: "x", value: "y", category: "gossip" })).status).toBe(400); + expect((await api("knowledge/delete", { session_id: SESSION, key: "indent" })).ok).toBe(true); + entries = await broker("/knowledge/list", { session_id: SESSION }); + expect(entries.some((e: any) => e.key === "indent")).toBe(false); + }); + + test("acquire a lock for an agent, then force-unlock it", async () => { + const acquired = await api("files/acquire", { session_id: SESSION, slot_id: slotB, file_path: "src/a.ts", minutes: 1 }); + expect(acquired.result.status).toBe("acquired"); + const lock = (await broker("/files/locks", { session_id: SESSION })).find((l: any) => l.file_path === "src/a.ts"); + expect(lock.held_by_slot).toBe(slotB); + expect((await api("files/release", { session_id: SESSION, lock_id: lock.id })).ok).toBe(true); + expect((await broker("/files/locks", { session_id: SESSION })).length).toBe(0); + }); + + test("guardrail changes record the reason", async () => { + const r = await api("guardrail", { session_id: SESSION, guardrail_id: "max_restarts", value: 11 }); + expect(r.ok).toBe(true); + expect(r.result.current_value).toBe(11); + expect((await api("guardrail", { session_id: SESSION, guardrail_id: "max_restarts", value: -1 })).status).toBe(400); + }); +}); + +describe("sessions", () => { + test("human gate is merged into the session config", async () => { + await broker("/sessions/update", { id: SESSION, config: { keep: 1 } }); + expect((await api("session/human-gate", { session_id: SESSION, enabled: true })).ok).toBe(true); + const session = await broker("/sessions/get", { id: SESSION }); + expect(JSON.parse(session.config)).toEqual({ keep: 1, human_gate: true }); + }); + + test("create, export, archive and delete (with confirmation)", async () => { + const created = await api("session/create", { name: "Scratch Session", project_dir: path.join(tmpDir, "proj") }); + expect(created.ok).toBe(true); + const id = created.result.id; + expect(fs.existsSync(path.join(tmpDir, "proj", ".multiagents", "session.json"))).toBe(true); + expect((await api("session/create", { name: "Nowhere", project_dir: path.join(tmpDir, "missing") })).status).toBe(400); + + const exp = await fetch(`${DASHBOARD_URL}/api/session/export?session_id=${SESSION}`); + expect(exp.headers.get("content-disposition")).toContain("attachment"); + const data = await exp.json() as any; + expect(data.session.id).toBe(SESSION); + expect(data.agents.length).toBeGreaterThanOrEqual(2); + const times = data.messages.map((m: any) => m.sent_at); + expect([...times].sort()).toEqual(times); + + expect((await api("session/archive", { session_id: id })).ok).toBe(true); + expect((await broker("/sessions/get", { id })).status).toBe("archived"); + + expect((await api("session/delete", { session_id: id })).status).toBe(400); + expect((await api("session/delete", { session_id: id, confirm: id })).ok).toBe(true); + const sessions = await broker("/sessions/list", {}); + expect(sessions.some((s: any) => s.id === id)).toBe(false); + }); +}); + +describe("teams", () => { + test("a team started from the browser is launched and supervised by the dashboard", async () => { + const r = await api("team/create", { + project_dir: path.join(tmpDir, "team"), + session_name: "Browser Team", + goal: "Ship the greeting", + agents: [{ agent_type: "claude", name: "Solo", role: "Software Engineer" }], + workflow: { require_reviewer_approval: true, human_release: true }, + }); + expect(r.ok).toBe(true); + const sessionId = r.result.data.session_id; + const session = await broker("/sessions/get", { id: sessionId }); + expect(JSON.parse(session.config).human_gate).toBe(true); + const slots = await broker("/slots/list", { session_id: sessionId }); + expect(slots.map((s: any) => s.display_name)).toEqual(["Solo"]); + + // The dashboard switched to the new session and reports the agent as its own + const ws = new WebSocket(`ws://127.0.0.1:${DASHBOARD_PORT}/ws`); + const state = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("no state")), 5000); + ws.onmessage = (e) => { clearTimeout(timer); resolve(JSON.parse(e.data as string).data); }; + }); + ws.close(); + expect(state.sessionId).toBe(sessionId); + expect(state.managedSlots).toEqual([slots[0].id]); + expect(state.humanGate).toBe(true); + + // The same name cannot be launched twice + const dup = await api("agents/add", { session_id: sessionId, agent_type: "claude", name: "solo", role: "Reviewer", initial_task: "Review" }); + expect(dup.ok).toBe(false); + + const removed = await api("agents/remove", { session_id: sessionId, slot_id: slots[0].id }); + expect(removed.ok).toBe(true); + expect((await broker("/slots/get", { id: slots[0].id })).task_state).toBe("released"); + + expect((await api("session/end", { session_id: sessionId })).ok).toBe(true); + expect((await broker("/sessions/get", { id: sessionId })).status).toBe("archived"); + }, 30_000); + + test("a team needs agents with names and roles", async () => { + expect((await api("team/create", { project_dir: tmpDir, session_name: "Empty", agents: [] })).status).toBe(400); + expect((await api("team/create", { project_dir: "relative/dir", session_name: "Rel", agents: [{ agent_type: "claude", name: "A", role: "B" }] })).status).toBe(400); + expect((await api("team/create", { project_dir: tmpDir, session_name: "Bad", agents: [{ agent_type: "llama", name: "A", role: "B" }] })).status).toBe(400); + }); +}); + +describe("workflow helpers", () => { + test("workflow rules are spelled out for the agents", () => { + const text = workflowInstructions("Build it", { require_reviewer_approval: true, human_release: true }); + expect(text).toContain("TEAM GOAL:\nBuild it"); + expect(text).toContain("reviewer has explicitly approved"); + expect(text).toContain("A human makes the final call"); + expect(text).not.toContain("actionable"); + expect(workflowInstructions("", {})).toBe(""); + }); + + test("a gated session waits on a person once every live agent is approved", () => { + const session = { status: "active", config: JSON.stringify({ human_gate: true }) } as Session; + const slot = (task_state: string) => ({ task_state }) as Slot; + expect(awaitingHuman(session, [slot("approved"), slot("released")])).toBe(true); + expect(awaitingHuman(session, [slot("approved"), slot("working")])).toBe(false); + expect(awaitingHuman(session, [slot("released")])).toBe(false); + expect(awaitingHuman({ ...session, config: "{}" }, [slot("approved")])).toBe(false); + expect(awaitingHuman({ ...session, status: "archived" }, [slot("approved")])).toBe(false); + }); +}); From 38e64e551e5b7c75dc14dac9dd4c3ad3e241195c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 01:05:33 +0000 Subject: [PATCH 02/24] Keep session names in any script addressable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit slugify kept only a-z and 0-9, so a session named "食物过敏论文" got the id "". The dashboard then could not open or end it ("session_id is required", HTTP 400 on switch). Slugs now keep letters and digits of any script and fall back to "session"; the broker refuses an empty id and, on startup, renames a session stored under "" in every table. A failed session switch shows the server's reason instead of the status code. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SP5EAuSkQZm3jj3PTTWDfN --- broker.ts | 20 +++++++++++++ dashboard/index.html | 5 +++- orchestrator/runtime.ts | 14 +++------ shared/session-ops.ts | 11 ++----- shared/utils.ts | 16 ++++++++-- tests/session-id-migration.test.ts | 48 ++++++++++++++++++++++++++++++ tests/web-control.test.ts | 20 +++++++++++++ 7 files changed, 113 insertions(+), 21 deletions(-) create mode 100644 tests/session-id-migration.test.ts diff --git a/broker.ts b/broker.ts index 7427ff5..6952cf7 100755 --- a/broker.ts +++ b/broker.ts @@ -281,6 +281,25 @@ if (addColumn("ALTER TABLE messages ADD COLUMN acked_at TEXT")) { db.run("UPDATE messages SET acked_at = COALESCE(delivered_at, sent_at) WHERE delivered = 1 AND acked_at IS NULL"); } +// Session names without ASCII letters used to slugify to "", leaving a session +// no client can address. Give such a session a real id, in every table. +{ + const orphan = db.query("SELECT created_at FROM sessions WHERE id = ''").get() as { created_at: number } | null; + if (orphan) { + let newId = `session-${orphan.created_at.toString(36)}`; + while (db.query("SELECT 1 FROM sessions WHERE id = ?").get(newId)) newId += "x"; + const tables = (db.query("SELECT name FROM sqlite_master WHERE type = 'table'").all() as { name: string }[]) + .map((t) => t.name) + .filter((t) => t !== "sessions" && (db.query(`PRAGMA table_info(${t})`).all() as { name: string }[]).some((c) => c.name === "session_id")); + db.transaction(() => { + db.run("PRAGMA defer_foreign_keys = ON"); + db.run("UPDATE sessions SET id = ? WHERE id = ''", [newId]); + for (const t of tables) db.run(`UPDATE ${t} SET session_id = ? WHERE session_id = ''`, [newId]); + })(); + console.error(`[multiagents broker] renamed the session with an empty id to "${newId}"`); + } +} + // --- Context snapshot builder --- function parseSnapshot(raw: string | null | undefined): Record { @@ -818,6 +837,7 @@ function handleKnowledgeDelete(body: KnowledgeDeleteRequest): { ok: boolean } { // --- Sessions --- function handleCreateSession(body: CreateSessionRequest): Session { + if (typeof body.id !== "string" || !body.id.trim()) throw new Error("Session id must not be empty"); const now = Date.now(); db.run( "INSERT INTO sessions (id, name, project_dir, git_root, status, config, created_at, last_active_at) VALUES (?, ?, ?, ?, 'active', ?, ?, ?)", diff --git a/dashboard/index.html b/dashboard/index.html index a58bfc9..cb6fe38 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -1963,7 +1963,10 @@ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: id }), }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `HTTP ${res.status}`); + } lastMsgSignature = ""; resetKnowledgeForm(); } catch (e) { diff --git a/orchestrator/runtime.ts b/orchestrator/runtime.ts index 4f670da..abf641b 100644 --- a/orchestrator/runtime.ts +++ b/orchestrator/runtime.ts @@ -14,7 +14,7 @@ import type { Subprocess } from "bun"; import * as fs from "node:fs"; import { BrokerClient } from "../shared/broker-client.ts"; import type { AgentLaunchConfig } from "../shared/types.ts"; -import { log, getGitRoot, slugify, formatDuration, safeJsonParse } from "../shared/utils.ts"; +import { log, getGitRoot, uniqueSessionId, formatDuration, safeJsonParse } from "../shared/utils.ts"; import { GUARDRAIL_CHECK_INTERVAL, CONFLICT_CHECK_INTERVAL, @@ -745,17 +745,11 @@ export async function callOrchestratorTool(name: string, args: Record s.id)); - if (existingIds.has(sessionId)) { - // Append incrementing suffix until unique - let i = 2; - while (existingIds.has(`${sessionId}-${i}`)) i++; - sessionId = `${sessionId}-${i}`; - } + existingIds = (await brokerClient.listSessions()).map((s) => s.id); } catch { /* broker may not support list yet — proceed with base slug */ } + const sessionId = uniqueSessionId(session_name, existingIds); // Auto-create project directory if it doesn't exist if (!fs.existsSync(project_dir)) { diff --git a/shared/session-ops.ts b/shared/session-ops.ts index c0ec0ae..b21e147 100644 --- a/shared/session-ops.ts +++ b/shared/session-ops.ts @@ -6,7 +6,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { BrokerClient, type PlanState } from "./broker-client.ts"; import { SESSION_DIR, SESSION_FILE } from "./constants.ts"; -import { getGitRoot, slugify } from "./utils.ts"; +import { getGitRoot, uniqueSessionId } from "./utils.ts"; import type { Session, SessionFile } from "./types.ts"; /** @@ -21,13 +21,8 @@ export async function createSessionRecord( brokerPort: number, ): Promise { const gitRoot = await getGitRoot(projectDir); - let id = slugify(name); - const existing = new Set((await client.listSessions().catch(() => [] as Session[])).map((s) => s.id)); - if (existing.has(id)) { - let i = 2; - while (existing.has(`${id}-${i}`)) i++; - id = `${id}-${i}`; - } + const existing = (await client.listSessions().catch(() => [] as Session[])).map((s) => s.id); + const id = uniqueSessionId(name, existing); const session = await client.createSession({ id, name, project_dir: projectDir, git_root: gitRoot }); diff --git a/shared/utils.ts b/shared/utils.ts index b9581af..11e74ee 100644 --- a/shared/utils.ts +++ b/shared/utils.ts @@ -110,11 +110,23 @@ export function getTty(): string | null { } /** Convert a string to a URL-friendly slug */ +/** Lowercase, dash-separated; letters and digits of any script survive ("食物过敏论文" stays readable). */ export function slugify(text: string): string { return text + .normalize("NFKC") .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); + .replace(/[^\p{L}\p{N}]+/gu, "-") + .replace(/^-+|-+$/g, ""); +} + +/** A session id for a name: its slug (or "session" when nothing survives), suffixed until unused. */ +export function uniqueSessionId(name: string, existingIds: Iterable): string { + const base = slugify(name) || "session"; + const taken = new Set(existingIds); + if (!taken.has(base)) return base; + let i = 2; + while (taken.has(`${base}-${i}`)) i++; + return `${base}-${i}`; } // Re-export AGENT_ID_PREFIXES for convenience diff --git a/tests/session-id-migration.test.ts b/tests/session-id-migration.test.ts new file mode 100644 index 0000000..5a492a8 --- /dev/null +++ b/tests/session-id-migration.test.ts @@ -0,0 +1,48 @@ +/** + * Older brokers stored sessions whose name had no ASCII letters under the id "". + * Starting the broker gives such a session a real id across every table. + */ + +import { test, expect } from "bun:test"; +import { Database } from "bun:sqlite"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +test("a session stored with an empty id is renamed on startup", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "session-id-migration-")); + const dbPath = path.join(dir, "peers.db"); + const port = 17913; + const env = { ...process.env, MULTIAGENTS_PORT: String(port), MULTIAGENTS_DB: dbPath }; + const brokerPath = new URL("../broker.ts", import.meta.url).pathname; + const waitHealthy = async () => { + for (let i = 0; i < 50; i++) { + try { if ((await fetch(`http://127.0.0.1:${port}/health`)).ok) return; } catch { /* not ready */ } + await Bun.sleep(100); + } + throw new Error("broker did not start"); + }; + + // First run creates the schema; then plant a legacy row with an empty id + let proc = Bun.spawn(["bun", brokerPath], { env, stdout: "ignore", stderr: "ignore" }); + await waitHealthy(); + proc.kill(); + await proc.exited; + const db = new Database(dbPath); + db.run("INSERT INTO sessions (id, name, project_dir, status, config, created_at, last_active_at) VALUES ('', '食物过敏论文', ?, 'active', '{}', 1000, 1000)", [dir]); + db.run("INSERT INTO slots (session_id, agent_type, display_name) VALUES ('', 'claude', 'Worker')"); + db.close(); + + proc = Bun.spawn(["bun", brokerPath], { env, stdout: "ignore", stderr: "ignore" }); + try { + await waitHealthy(); + const post = async (p: string, body: unknown) => + (await fetch(`http://127.0.0.1:${port}${p}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })).json() as Promise; + const sessions = await post("/sessions/list", {}); + expect(sessions.map((s: any) => s.id)).toEqual([`session-${(1000).toString(36)}`]); + const slots = await post("/slots/list", { session_id: sessions[0].id }); + expect(slots.map((s: any) => s.display_name)).toEqual(["Worker"]); + } finally { + proc.kill(); + } +}); diff --git a/tests/web-control.test.ts b/tests/web-control.test.ts index 8881e95..8f8da20 100644 --- a/tests/web-control.test.ts +++ b/tests/web-control.test.ts @@ -309,3 +309,23 @@ describe("workflow helpers", () => { expect(awaitingHuman({ ...session, status: "archived" }, [slot("approved")])).toBe(false); }); }); + +describe("session ids", () => { + test("a Chinese session name keeps a usable id", async () => { + const created = await api("session/create", { name: "食物过敏论文", project_dir: path.join(tmpDir, "proj") }); + expect(created.ok).toBe(true); + expect(created.result.id).toBe("食物过敏论文"); + const again = await api("session/create", { name: "食物过敏论文", project_dir: path.join(tmpDir, "proj") }); + expect(again.result.id).toBe("食物过敏论文-2"); + const symbols = await api("session/create", { name: "!!!", project_dir: path.join(tmpDir, "proj") }); + expect(symbols.result.id).toBe("session"); + const switched = await fetch(`${DASHBOARD_URL}/api/session`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: "食物过敏论文" }) }); + expect(switched.status).toBe(200); + expect((await api("session/end", { session_id: "食物过敏论文" })).ok).toBe(true); + }); + + test("the broker refuses an empty session id", async () => { + const res = await fetch(`${BROKER_URL}/sessions/create`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: "", name: "x", project_dir: tmpDir }) }); + expect(res.ok).toBe(false); + }); +}); From 232550650c4ce58c7cd0d2699d0d4f2e76e4495f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 03:15:14 +0000 Subject: [PATCH 03/24] Redesign the web dashboard around a team chat, explorer and preview The dashboard becomes a three-pane workspace: sessions and a file explorer of the project on the left, the team chat in the middle, and a file preview plus team roster on the right. Side panes collapse and resize, and turn into drawers on narrow screens. - Chat: your messages on the right, agents on the left with avatar, role and message kind; system notices fold into centered lines; one-to-many sends show once. Enter sends without cutting off IME input. Markdown renders safely, and file names agents mention become links once the project is found to contain them. - Preview: Markdown, code with line numbers, images, PDF, .docx, CSV, sandboxed HTML, audio and video; it follows the open file as agents edit it. - Themes: light, dark, or follow the system. dashboard/files.ts serves the project read-only under GET /api/fs/*, resolving every path with realpath so neither ".." nor a symlink leaves the project. HTML and SVG get a sandbox CSP, and .docx is rendered server-side with a small zip reader. The UI moves to index.html, app.js and styles.css. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SP5EAuSkQZm3jj3PTTWDfN --- CLAUDE.md | 1 + README.md | 20 +- dashboard/app.js | 2202 +++++++++++++++++++++++++++++++++++ dashboard/files.ts | 324 ++++++ dashboard/index.html | 2099 +++------------------------------ dashboard/server.ts | 23 +- dashboard/styles.css | 572 +++++++++ docs/web-control-center.md | 15 + tests/web-control.test.ts | 33 + tests/web-dashboard.test.ts | 50 +- tests/web-files.test.ts | 135 +++ 11 files changed, 3508 insertions(+), 1966 deletions(-) create mode 100644 dashboard/app.js create mode 100644 dashboard/files.ts create mode 100644 dashboard/styles.css create mode 100644 tests/web-files.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index a74b518..61fa58c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ Peer discovery and messaging MCP channel for Claude Code instances. - `orchestrator/delivery.ts` — Hands unacked messages to a driver (`retain` poll) and acks them on confirmed hand-off. - `orchestrator/runtime.ts` — The orchestrator itself: launched processes and drivers, background supervision loops, and the tool implementations (`ORCHESTRATOR_TOOLS`, `callOrchestratorTool`). Hosted by `orchestrator-server.ts` (MCP stdio) and by `dashboard/server.ts`; each host supervises only the agents it launched. - `dashboard/control.ts` — Web control API (`POST /api/`). Every mutation names its `session_id` and checks the slot belongs to it; lifecycle actions call `callOrchestratorTool`, record edits go to the broker. Human actions use sender id `human`. A session with `config.human_gate` is not auto-ended while agents sit in `approved`. +- `dashboard/files.ts` — Read-only `GET /api/fs/{list,raw,resolve,check,docx}` over the session's `project_dir`. Every path goes through `resolveInRoot` (realpath, so symlinks cannot escape); HTML/SVG are served with a sandbox CSP. `.docx` is rendered server-side (own zip reader, no dependency). UI: `dashboard/index.html` + `app.js` + `styles.css`, plain JS, themes via `html[data-theme]`. - `orchestrator/supervision.ts` — Driver registry (one driver per slot), non-overlapping loop ticks, release and watchdog verdicts; pure enough to unit-test. - `orchestrator/monitor.ts` — Slot summaries and token accounting. Claude's `result.usage` is per turn (added as is); Codex's `thread/tokenUsage/updated.total` is cumulative (diffed). - `shared/types.ts` — Shared TypeScript types for broker API. diff --git a/README.md b/README.md index 1ceecdf..3307385 100644 --- a/README.md +++ b/README.md @@ -219,21 +219,28 @@ Adjustable from the TUI dashboard (+/- keys), the web dashboard's Stats tab, or multiagents web [session-id] ``` -Web control center on `localhost:7900` with live WebSocket updates. Auto-opens in browser when a team is created via the orchestrator, and starts the broker itself when it is not running. Besides watching, you can run a team from the browser: +Web control center on `localhost:7900` with live WebSocket updates. Auto-opens in browser when a team is created via the orchestrator, and starts the broker itself when it is not running. Besides watching, you can run a team from the browser. The layout has three panes, and both side panes can be collapsed (`Ctrl+B` / `Ctrl+J`) and resized: + +- **Left: sessions and an explorer** of the session's project directory (read-only, refreshed while agents work; locked files show a lock). +- **Center: the team chat.** Your messages sit on the right, the agents' on the left with avatar, role and message type (feedback, task complete, approval…). System notices are folded into small centered lines. Send to one agent or the whole team (Enter sends, Shift+Enter starts a new line, IME-safe). File names that agents mention become links once the file is found in the project. +- **Right: file preview** (Markdown, code with line numbers, images, PDF, Word `.docx`, CSV, HTML in a sandbox, audio/video). It follows the open file as agents edit it. The **Team** tab lists every agent with quick actions. + +Light, dark, or follow the system: pick it from the theme button in the top bar. + +The tabs in the center hold everything else: - **Sessions** (sidebar): New Team, empty session, open, pause/resume, relaunch disconnected agents, export JSON, archive, end, delete (typed confirmation) - **Agents**: message, pause/resume, change role, rename, send back for revision, release, remove; Add Agent; Release all approved / Release all -- **Messages**: live feed (`Human → agent` for what you send) with a composer for one agent or a broadcast to the team - **Plan**: add, edit, reassign, change status (pending / in progress / blocked / done), delete - **Knowledge**: add, edit, delete entries by category -- **Files**: locks with holder and expiry, force unlock, reserve a file for an agent, assign ownership zones +- **Locks**: locks with holder and expiry, force unlock, reserve a file for an agent, assign ownership zones - **Stats**: session metrics and guardrail steppers (changes logged as "Adjusted via web dashboard") **Human gate.** A team started from the browser can wait for you once every agent is approved: instead of ending itself, the session shows *Waiting for human approval* with **Approve & Release** and **Send back for revision** (actionable feedback to a worker, which restarts the review loop). Toggle it per session with the `human gate` chip; MCP's `create_team` takes `human_gate: true`. Teams and agents launched from the browser are supervised by the dashboard process (the same orchestrator runtime the MCP server runs), so keep `multiagents web` running while they work; stopping it stops them. Agents another orchestrator launched stay under that orchestrator. See [docs/web-control-center.md](docs/web-control-center.md) for the API. -Responsive layout, keyboard shortcuts (`1-6` to switch tabs). +On narrow screens the side panes become drawers. Keyboard shortcuts: `1-6` switch tabs. ## TUI Dashboard @@ -319,7 +326,10 @@ multiagents/ ├── dashboard/ │ ├── server.ts Web dashboard (Bun.serve + WebSocket, localhost:7900) │ ├── control.ts Web control API (POST /api/*) -│ └── index.html Web dashboard UI +│ ├── files.ts Read-only project files for the explorer and preview (GET /api/fs/*) +│ ├── index.html Web dashboard layout +│ ├── app.js Web dashboard UI logic +│ └── styles.css Web dashboard themes and layout └── scripts/ ├── version.ts Version bump + release commit/tag ├── postinstall.ts Configures MCP servers after install diff --git a/dashboard/app.js b/dashboard/app.js new file mode 100644 index 0000000..4c7468b --- /dev/null +++ b/dashboard/app.js @@ -0,0 +1,2202 @@ +// ============================================================================= +// multiagents Web Control Center — frontend +// ============================================================================= +// Layout: sessions + file explorer (left) · team chat and panels (center) · +// file preview + team roster (right). State arrives over the WebSocket; every +// action goes through POST /api/*, which answers { ok, result } or +// { ok: false, error }. Actions capture the session id when they start, so +// switching sessions mid-way cannot redirect them. +// ============================================================================= + +"use strict"; + +let state = null; +let activeTab = "chat"; +let ws = null; +let wsAttempt = 0; +let wsKeepalive = null; + +const $ = (id) => document.getElementById(id); +const store = { + get(key, fallback) { try { const v = localStorage.getItem(key); return v === null ? fallback : JSON.parse(v); } catch { return fallback; } }, + set(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch {} }, +}; + +// --- Theme: system / light / dark --- + +const THEME_ICONS = { + system: '', + light: '', + dark: '', +}; +const THEME_LABEL = { system: "Follow system", light: "Light", dark: "Dark" }; +const darkQuery = window.matchMedia ? matchMedia("(prefers-color-scheme: dark)") : null; + +function themePref() { + try { return localStorage.getItem("ma-theme") || "system"; } catch { return "system"; } +} + +function applyTheme() { + const pref = themePref(); + const dark = pref === "dark" || (pref === "system" && !!darkQuery?.matches); + document.documentElement.dataset.theme = dark ? "dark" : "light"; + $("theme-btn").innerHTML = THEME_ICONS[pref] ?? THEME_ICONS.system; + $("theme-btn").title = `Theme: ${THEME_LABEL[pref] ?? pref}`; +} + +darkQuery?.addEventListener?.("change", applyTheme); +$("theme-btn").addEventListener("click", (e) => { + e.stopPropagation(); + const pref = themePref(); + openMenu($("theme-btn"), ["system", "light", "dark"].map((t) => ({ + label: THEME_LABEL[t], checked: t === pref, + onClick: () => { try { localStorage.setItem("ma-theme", t); } catch {} applyTheme(); }, + }))); +}); + +// --- Layout: collapsible, resizable side panes --- + +const narrowQuery = matchMedia("(max-width: 1100px)"); +const workspace = $("workspace"); + +function isNarrow() { return narrowQuery.matches; } +// Drawers belong to narrow screens only +narrowQuery.addEventListener("change", () => workspace.classList.remove("left-open", "right-open")); + +function applyLayout() { + const layout = store.get("ma-layout", {}); + if (layout.leftW) workspace.style.setProperty("--left-w", `${layout.leftW}px`); + if (layout.rightW) workspace.style.setProperty("--right-w", `${layout.rightW}px`); + workspace.classList.toggle("left-collapsed", !!layout.leftCollapsed); + workspace.classList.toggle("right-collapsed", !!layout.rightCollapsed); + for (const id of layout.collapsedSections ?? []) $(id)?.classList.add("collapsed"); +} + +function saveLayout(patch) { store.set("ma-layout", { ...store.get("ma-layout", {}), ...patch }); } + +function togglePane(side, open) { + if (isNarrow()) { + const cls = `${side}-open`; + const next = open ?? !workspace.classList.contains(cls); + workspace.classList.remove("left-open", "right-open"); + workspace.classList.toggle(cls, next); + return; + } + const cls = `${side}-collapsed`; + const collapsed = open === undefined ? !workspace.classList.contains(cls) : !open; + workspace.classList.toggle(cls, collapsed); + saveLayout({ [`${side}Collapsed`]: collapsed }); +} + +$("left-toggle").addEventListener("click", () => togglePane("left")); +$("right-toggle").addEventListener("click", () => togglePane("right")); +$("left-drawer-btn").addEventListener("click", () => togglePane("left")); +$("right-close").addEventListener("click", () => togglePane("right", false)); + +document.querySelectorAll("[data-resize]").forEach((handle) => { + handle.addEventListener("mousedown", (e) => { + e.preventDefault(); + const side = handle.dataset.resize; + const startX = e.clientX; + const pane = side === "left" ? $("left-pane") : $("right-pane"); + const startW = pane.getBoundingClientRect().width; + handle.classList.add("dragging"); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + const move = (ev) => { + const delta = side === "left" ? ev.clientX - startX : startX - ev.clientX; + const w = Math.round(Math.max(side === "left" ? 190 : 300, Math.min(side === "left" ? 560 : 1000, startW + delta))); + workspace.style.setProperty(side === "left" ? "--left-w" : "--right-w", `${w}px`); + saveLayout({ [side === "left" ? "leftW" : "rightW"]: w }); + }; + const up = () => { + handle.classList.remove("dragging"); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + document.removeEventListener("mousemove", move); + document.removeEventListener("mouseup", up); + }; + document.addEventListener("mousemove", move); + document.addEventListener("mouseup", up); + }); +}); + +document.querySelectorAll("[data-collapse]").forEach((el) => { + el.addEventListener("click", () => { + const section = $(el.dataset.collapse); + section.classList.toggle("collapsed"); + saveLayout({ collapsedSections: [...document.querySelectorAll(".side-section.collapsed")].map((s) => s.id) }); + }); +}); + +// --- WebSocket --- + +function connectWs() { + ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`); + ws.onopen = () => { + wsAttempt = 0; + $("ws-status").textContent = "live"; + $("ws-status").style.color = "var(--green)"; + }; + ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data); + if (msg.type === "state") { + const prevSid = state?.sessionId; + state = msg.data; + if (prevSid !== state.sessionId) onSessionChanged(); + render(); + } + } catch {} + }; + ws.onclose = () => { + $("ws-status").textContent = "reconnecting…"; + $("ws-status").style.color = "var(--yellow)"; + const delay = Math.min(500 * Math.pow(2, wsAttempt) + Math.random() * 1000, 30000); + wsAttempt++; + setTimeout(connectWs, delay); + }; + ws.onerror = () => ws.close(); + if (!wsKeepalive) wsKeepalive = setInterval(() => { if (ws?.readyState === 1) ws.send("ping"); }, 30000); +} + +// --- API --- + +async function api(action, body) { + const opts = body === undefined + ? {} + : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }; + let res, data; + try { + res = await fetch(`/api/${action}`, opts); + } catch { + throw new Error("Dashboard server unreachable"); + } + try { data = await res.json(); } catch { data = { ok: false, error: `HTTP ${res.status}` }; } + if (!res.ok || data.ok === false) throw new Error(data.error || `HTTP ${res.status}`); + return data.result; +} + +function fsUrl(op, sid, params) { + const q = new URLSearchParams({ session_id: sid }); + for (const [k, v] of Object.entries(params ?? {})) { + if (Array.isArray(v)) v.forEach((x) => q.append(k, x)); + else if (v !== undefined && v !== null) q.set(k, String(v)); + } + return `/api/fs/${op}?${q}`; +} + +/** Run an action with a busy button; report the broker's answer, never a guessed success. */ +async function run(btn, label, fn) { + if (btn) btn.classList.add("busy"); + try { + const result = await fn(); + toast(result?.message ?? `${label}: done`, result?.warning ? "warn" : "ok"); + return result ?? true; + } catch (e) { + toast(`${label} failed: ${e.message}`, "error"); + return null; + } finally { + if (btn) btn.classList.remove("busy"); + } +} + +function toast(text, kind = "info") { + const el = document.createElement("div"); + el.className = `toast ${kind}`; + el.textContent = text; + $("toasts").appendChild(el); + setTimeout(() => el.remove(), kind === "error" ? 9000 : 4500); +} + +/** The session the user is looking at; actions need one. */ +function currentSid() { + if (state?.sessionId == null || !state.session) { + toast("No session selected — pick one on the left or start a New Team", "warn"); + return null; + } + return state.sessionId; +} + +function onSessionChanged() { + lastChatSignature = ""; + fileRefCache.clear(); + expandedMessages.clear(); + resetKnowledgeForm(); + closePreview(); + explorerReset(); +} + +// --- Tabs --- + +const TABS = ["chat", "agents", "plan", "knowledge", "files", "stats"]; + +function switchTab(tab) { + activeTab = tab; + document.querySelectorAll("#tab-bar .tab").forEach((t) => t.classList.toggle("active", t.dataset.tab === tab)); + for (const t of TABS) $(`panel-${t}`).classList.toggle("hidden", t !== tab); + store.set("ma-tab", tab); + if (state) render(true); + if (tab === "chat") $("composer-text").focus(); +} + +$("tab-bar").addEventListener("click", (e) => { + const tab = e.target.closest(".tab"); + if (tab) switchTab(tab.dataset.tab); +}); + +function switchRightTab(tab) { + document.querySelectorAll("#right-tabs .tab").forEach((t) => t.classList.toggle("active", t.dataset.rtab === tab)); + $("rpanel-preview").classList.toggle("hidden", tab !== "preview"); + $("rpanel-preview").style.display = tab === "preview" ? "flex" : "none"; + $("rpanel-team").classList.toggle("hidden", tab !== "team"); + store.set("ma-rtab", tab); + if (tab === "team" && state) renderRoster(); +} + +$("right-tabs").addEventListener("click", (e) => { + const tab = e.target.closest("[data-rtab]"); + if (tab) switchRightTab(tab.dataset.rtab); +}); + +function isTyping() { + const el = document.activeElement; + return el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" || el.isContentEditable); +} + +document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { closePicker(); closeMenu(); closeModal(); return; } + if ((e.ctrlKey || e.metaKey) && (e.key === "b" || e.key === "B")) { e.preventDefault(); togglePane("left"); return; } + if ((e.ctrlKey || e.metaKey) && (e.key === "j" || e.key === "J")) { e.preventDefault(); togglePane("right"); return; } + if (isTyping() || e.metaKey || e.ctrlKey || e.altKey) return; + if ($("modal-overlay").classList.contains("open")) return; + const num = parseInt(e.key); + if (num >= 1 && num <= TABS.length) switchTab(TABS[num - 1]); +}); + +// --- Rendering --- + +/** Skip re-rendering a region the user is typing in (state pushes arrive every few seconds). */ +function busyRegion(el) { + return el && isTyping() && el.contains(document.activeElement); +} + +function render(force = false) { + if (!state) return; + renderHeader(); + renderSidebar(); + renderSessionBar(); + renderGateBanner(); + renderBadges(); + refreshAgentSelects(); + renderRoster(); + explorerSync(); + + switch (activeTab) { + case "chat": renderChat(force); break; + case "agents": renderAgents(); break; + case "plan": renderPlan(); break; + case "knowledge": renderKnowledge(); break; + case "files": renderFiles(); break; + case "stats": renderStats(); break; + } +} + +function isSessionComplete() { + if (!state || !state.slots.length) return false; + if (!state.slots.every((s) => s.status === "disconnected")) return false; + return state.slots.every((s) => s.task_state === "approved" || s.task_state === "released" || s.task_state === "done_pending_review"); +} + +function slotName(slot) { return slot?.display_name ?? (slot ? `Slot ${slot.id}` : "?"); } +function slotById(id) { return state?.slots.find((s) => s.id === id) ?? null; } +function liveSlots() { return (state?.slots ?? []).filter((s) => s.task_state !== "released"); } +function isPaused(slot) { return slot.paused === true || slot.paused === 1; } + +function renderHeader() { + const dot = $("conn-dot"); + const status = state.session?.status; + dot.className = "dot " + (!state.brokerAlive ? "red" : status === "paused" ? "yellow" : status === "archived" || isSessionComplete() ? "" : state.session ? "green" : ""); + $("session-name-text").textContent = state.session?.name ?? state.sessionId ?? "No session"; + + const b = sessionBadge(); + const badge = $("session-status-badge"); + badge.classList.toggle("hidden", !b); + if (b) { badge.textContent = b.text; badge.className = `badge ${b.cls}`; } + + const connected = state.slots.filter((s) => s.status === "connected").length; + $("agent-count").textContent = state.session ? `${connected}/${state.slots.length} agents online` : ""; + + if (state.session?.created_at) { + let endTime = Date.now(); + if (isSessionComplete() || status === "archived") { + const candidates = [state.session.last_active_at || 0]; + const last = state.messages[state.messages.length - 1]; + if (last?.sent_at) candidates.push(new Date(last.sent_at).getTime()); + for (const slot of state.slots) if (slot.last_disconnected) candidates.push(slot.last_disconnected); + endTime = Math.max(state.session.created_at, ...candidates); + } + $("uptime").textContent = fmtDuration(endTime - state.session.created_at); + } else { + $("uptime").textContent = ""; + } + + $("broker-dot").className = `dot ${state.brokerAlive ? "green" : "red"}`; + $("broker-label").textContent = state.brokerAlive + ? `Broker · ${state.sessions?.length ?? 0} sessions · ${state.peerCount ?? 0} peers` + : "Broker down"; +} + +function sessionBadge() { + if (!state.session) return null; + if (state.session.status === "archived") return { text: "archived", cls: "badge-released" }; + if (state.awaitingHuman) return { text: "waiting for you", cls: "badge-review" }; + if (state.session.status === "paused") return { text: "paused", cls: "badge-paused" }; + if (isSessionComplete()) return { text: "completed", cls: "badge-approved" }; + return { text: "active", cls: "badge-connected" }; +} + +function renderBadges() { + $("badge-chat").textContent = state.messages.filter((m) => !isSystemMessage(m)).length; + $("badge-agents").textContent = state.slots.filter((s) => s.status === "connected").length; + $("badge-plan").textContent = state.plan?.plan ? `${state.plan.completion}%` : "-"; + $("badge-knowledge").textContent = state.knowledge?.length ?? 0; + $("badge-files").textContent = state.fileLocks.length + state.fileOwnership.length; + $("badge-team").textContent = liveSlots().length; + const warnings = state.guardrails.filter((g) => g.usage?.status === "warning" || g.usage?.status === "triggered").length; + $("badge-stats").textContent = warnings > 0 ? `${warnings}!` : ""; +} + +// --- Sessions (left) --- + +function sortedSessions(list) { + const order = { active: 0, paused: 1, archived: 2 }; + return [...(list ?? [])].sort((a, b) => { + const so = (order[a.status] ?? 3) - (order[b.status] ?? 3); + if (so !== 0) return so; + return (b.last_active_at ?? b.created_at ?? 0) - (a.last_active_at ?? a.created_at ?? 0); + }); +} + +function statusDot(status) { + return ``; +} + +function renderSidebar() { + const list = $("sidebar-list"); + const sessions = sortedSessions(state.sessions); + if (!sessions.length) { + list.innerHTML = `
${state.brokerAlive ? "No sessions yet — use + to start a team." : "Broker is not running."}
`; + return; + } + let html = "", last = ""; + for (const s of sessions) { + if (s.status !== last) { + last = s.status; + html += `
${esc(s.status)}
`; + } + html += `
+ ${statusDot(s.status)} + ${esc(s.name || s.id)} + ${timeSince(s.last_active_at ?? s.created_at)} + +
`; + } + list.innerHTML = html; +} + +$("sidebar-list").addEventListener("click", (e) => { + const menuBtn = e.target.closest("[data-session-menu]"); + if (menuBtn) { + e.stopPropagation(); + const s = state.sessions.find((x) => x.id === menuBtn.dataset.sessionMenu); + if (s) openMenu(menuBtn, sessionMenuItems(s)); + return; + } + const row = e.target.closest(".session-row"); + if (row) { + selectSession(row.dataset.sid); + if (isNarrow()) togglePane("left", false); + } +}); + +// --- Explorer (left) --- + +const explorer = { sid: null, root: "", children: new Map(), expanded: new Set(), error: null }; + +function explorerReset() { + explorer.sid = null; + explorer.children.clear(); + explorer.error = null; +} + +function explorerKey(sid) { return `ma-exp:${sid}`; } + +/** Load the tree of the current session's project on first sight of the session. */ +function explorerSync() { + const sid = state.session ? state.sessionId : null; + if (sid === explorer.sid) return; + explorer.sid = sid; + explorer.children.clear(); + explorer.error = null; + explorer.expanded = new Set(store.get(sid == null ? "" : explorerKey(sid), [])); + explorer.expanded.add(""); + $("explorer-root").textContent = state.session ? baseName(state.session.project_dir) : ""; + $("explorer-root").title = state.session?.project_dir ?? ""; + if (sid == null) { renderTree(); return; } + reloadExplorer(); +} + +async function loadDir(sid, rel) { + const res = await fetch(fsUrl("list", sid, { path: rel })); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`); + return data.result.entries; +} + +async function reloadExplorer() { + const sid = explorer.sid; + if (sid == null) return; + const dirs = [...explorer.expanded]; + const results = await Promise.all(dirs.map((d) => loadDir(sid, d).then((e) => [d, e], (err) => [d, null, err]))); + if (sid !== explorer.sid) return; + let changed = false; + for (const [dir, entries, err] of results) { + if (!entries) { + if (dir === "") explorer.error = err.message; + explorer.expanded.delete(dir); + changed = true; + continue; + } + if (dir === "") explorer.error = null; + const prev = explorer.children.get(dir); + if (JSON.stringify(prev) !== JSON.stringify(entries)) { explorer.children.set(dir, entries); changed = true; } + } + if (changed || !$("explorer-tree").innerHTML) renderTree(); +} + +const FILE_ICONS = { + pdf: ["PDF", "#d1242f"], doc: ["W", "#2b5fd9"], docx: ["W", "#2b5fd9"], odt: ["W", "#2b5fd9"], rtf: ["W", "#2b5fd9"], + xls: ["X", "#1a7f37"], xlsx: ["X", "#1a7f37"], csv: ["CSV", "#1a7f37"], tsv: ["TSV", "#1a7f37"], + ppt: ["P", "#c2410c"], pptx: ["P", "#c2410c"], + md: ["M", "#6e7781"], markdown: ["M", "#6e7781"], txt: ["T", "#6e7781"], log: ["L", "#6e7781"], + ts: ["TS", "#3178c6"], tsx: ["TS", "#3178c6"], js: ["JS", "#b08800"], jsx: ["JS", "#b08800"], mjs: ["JS", "#b08800"], + json: ["{}", "#b08800"], py: ["PY", "#3572a5"], ipynb: ["NB", "#da5b0b"], r: ["R", "#276dc3"], rmd: ["R", "#276dc3"], + html: ["<>", "#e34c26"], htm: ["<>", "#e34c26"], css: ["#", "#563d7c"], sh: ["$", "#4e5a65"], go: ["GO", "#00add8"], rs: ["RS", "#a0522d"], + java: ["J", "#b07219"], c: ["C", "#555"], cpp: ["C+", "#f34b7d"], yaml: ["Y", "#cb171e"], yml: ["Y", "#cb171e"], toml: ["T", "#9c4221"], + tex: ["TX", "#3d6117"], bib: ["BIB", "#3d6117"], zip: ["ZIP", "#8250df"], gz: ["GZ", "#8250df"], +}; +const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"]); +const FOLDER_SVG = ''; +const FOLDER_OPEN_SVG = ''; +const FILE_SVG = ''; +const IMAGE_SVG = ''; + +function extOf(name) { + const m = /\.([^./]+)$/.exec(name); + return m ? m[1].toLowerCase() : ""; +} + +function fileIcon(name, isDir, open) { + if (isDir) return `${open ? FOLDER_OPEN_SVG : FOLDER_SVG}`; + const ext = extOf(name); + if (IMAGE_EXT.has(ext)) return `${IMAGE_SVG}`; + const icon = FILE_ICONS[ext]; + if (!icon) return `${FILE_SVG}`; + const [label, color] = icon; + return `${esc(label)}`; +} + +function renderTree() { + const el = $("explorer-tree"); + if (explorer.sid == null) { + el.innerHTML = '
Select a session to browse its project.
'; + return; + } + if (explorer.error) { + el.innerHTML = `
${esc(explorer.error)}
`; + return; + } + const root = explorer.children.get(""); + if (!root) { el.innerHTML = '
Loading…
'; return; } + if (!root.length) { el.innerHTML = '
The project directory is empty.
'; return; } + const locked = new Set((state?.fileLocks ?? []).map((l) => l.file_path.replace(/^\.\//, ""))); + const rows = []; + const walk = (dir, depth) => { + for (const entry of explorer.children.get(dir) ?? []) { + const open = entry.type === "dir" && explorer.expanded.has(entry.path); + const selected = entry.type === "file" && preview.path === entry.path; + const dim = entry.name.startsWith(".") || entry.name === "node_modules"; + rows.push(`
+ ${entry.type === "dir" ? "▾" : ""} + ${fileIcon(entry.name, entry.type === "dir", open)} + ${esc(entry.name)} + ${locked.has(entry.path) ? '🔒' : ""} + ${entry.type === "file" ? '' : ""} +
`); + if (open) { + if (explorer.children.has(entry.path)) walk(entry.path, depth + 1); + else rows.push(`
Loading…
`); + } + } + }; + walk("", 0); + el.innerHTML = rows.join(""); +} + +$("explorer-tree").addEventListener("click", async (e) => { + const row = e.target.closest(".tree-row"); + if (!row) return; + const p = row.dataset.path; + if (e.target.closest("[data-mention]")) { mentionFile(p); return; } + if (row.dataset.type === "file") { openPreview(p); return; } + const sid = explorer.sid; + if (explorer.expanded.has(p)) { + explorer.expanded.delete(p); + } else { + explorer.expanded.add(p); + renderTree(); + try { + explorer.children.set(p, await loadDir(sid, p)); + } catch (err) { + explorer.expanded.delete(p); + toast(`Cannot open ${p}: ${err.message}`, "error"); + } + } + if (sid !== explorer.sid) return; + store.set(explorerKey(sid), [...explorer.expanded].filter(Boolean)); + renderTree(); +}); + +$("explorer-refresh").addEventListener("click", () => { explorer.children.clear(); renderTree(); reloadExplorer(); }); +$("explorer-collapse").addEventListener("click", () => { + explorer.expanded = new Set([""]); + if (explorer.sid != null) store.set(explorerKey(explorer.sid), []); + renderTree(); +}); + +// Agents create and edit files while you watch: refresh what is open +setInterval(() => { + if (document.hidden || explorer.sid == null) return; + if (!isNarrow() && workspace.classList.contains("left-collapsed")) return; + reloadExplorer().catch(() => {}); +}, 6000); + +// --- Preview (right) --- + +const preview = { sid: null, path: null, line: null, mtime: null, size: null, mode: null, wrap: false }; + +const PREVIEW_KIND = (ext) => + IMAGE_EXT.has(ext) ? "image" + : ext === "pdf" ? "pdf" + : ext === "docx" ? "docx" + : ext === "md" || ext === "markdown" || ext === "mdx" ? "markdown" + : ext === "csv" || ext === "tsv" ? "csv" + : ext === "html" || ext === "htm" ? "html" + : ["mp3", "wav", "ogg", "m4a", "flac", "aac"].includes(ext) ? "audio" + : ["mp4", "webm", "mov", "m4v", "ogv"].includes(ext) ? "video" + : ["doc", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp", "pages", "key", "numbers", "zip", "gz", "tgz", "tar", "7z", "rar", "exe", "dmg", "bin", "rds", "rdata", "h5", "hdf5", "parquet", "bam", "sqlite", "db"].includes(ext) ? "binary" + : "text"; + +const TEXT_LIMIT = 2_000_000; + +async function openPreview(rel, line) { + const sid = currentSid(); + if (sid == null) return; + let info; + try { + info = await api(`fs/resolve?session_id=${encodeURIComponent(sid)}&path=${encodeURIComponent(rel)}`); + } catch (e) { + toast(`Cannot open ${rel}: ${e.message}`, "error"); + return; + } + Object.assign(preview, { sid, path: info.path, line: line ?? null, mtime: info.mtime, size: info.size, mode: null }); + if (isNarrow()) togglePane("right", true); + else if (workspace.classList.contains("right-collapsed")) togglePane("right", true); + switchRightTab("preview"); + renderTree(); + await renderPreview(); +} + +function closePreview() { + Object.assign(preview, { sid: null, path: null, line: null, mtime: null, size: null, mode: null }); + $("preview-head").classList.add("hidden"); + const body = $("preview-body"); + body.className = "preview-body"; + body.innerHTML = `
📄
+
No file open
+
Click a file in the explorer, or a file name an agent mentions in the chat.
`; + if (explorer.children.size) renderTree(); +} + +function rawUrl(params = {}) { + return fsUrl("raw", preview.sid, { path: preview.path, v: preview.mtime, ...params }); +} + +async function renderPreview(keepScroll = false) { + if (!preview.path) return closePreview(); + const body = $("preview-body"); + const scrollTop = body.scrollTop; + const ext = extOf(preview.path); + const kind = PREVIEW_KIND(ext); + const parts = preview.path.split("/"); + const name = parts.pop(); + $("preview-head").classList.remove("hidden"); + $("preview-path").innerHTML = `${parts.length ? esc(parts.join("/")) + "/" : ""}${esc(name)}`; + $("preview-path").title = `${preview.path} · ${fmtBytes(preview.size ?? 0)}`; + $("preview-open").href = rawUrl(); + $("preview-download").href = rawUrl({ download: 1 }); + $("preview-mode").innerHTML = ""; + body.className = "preview-body"; + + const seg = (options, current, onPick) => { + const el = document.createElement("span"); + el.className = "seg"; + el.innerHTML = options.map(([v, l]) => ``).join(""); + el.addEventListener("click", (e) => { const b = e.target.closest("button"); if (b) onPick(b.dataset.v); }); + $("preview-mode").appendChild(el); + }; + + const fetchText = async () => { + const res = await fetch(rawUrl()); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`); + const buf = await res.arrayBuffer(); + const slice = new Uint8Array(buf, 0, Math.min(buf.byteLength, TEXT_LIMIT)); + if (slice.subarray(0, 4096).includes(0)) return { binary: true }; + return { text: new TextDecoder().decode(slice), truncated: buf.byteLength > TEXT_LIMIT }; + }; + + try { + switch (kind) { + case "image": + body.innerHTML = `
${esc(name)}
`; + break; + case "pdf": + if (navigator.pdfViewerEnabled === false) { body.innerHTML = noPreview(name, "This browser cannot show PDFs inline."); break; } + body.className = "preview-body fill"; + body.innerHTML = ``; + break; + case "audio": + body.innerHTML = `
`; + break; + case "video": + body.innerHTML = `
`; + break; + case "docx": { + const r = await api(`fs/docx?session_id=${encodeURIComponent(preview.sid)}&path=${encodeURIComponent(preview.path)}`); + body.innerHTML = `
${r.html || '

The document has no text.

'}
`; + break; + } + case "html": { + const mode = preview.mode ?? "render"; + seg([["render", "Render"], ["source", "Source"]], mode, (v) => { preview.mode = v; renderPreview(); }); + if (mode === "render") { + body.className = "preview-body fill"; + body.innerHTML = ``; + } else { + const t = await fetchText(); + body.innerHTML = codeView(t.text ?? "", t.truncated); + } + break; + } + case "markdown": { + const mode = preview.mode ?? "preview"; + seg([["preview", "Preview"], ["source", "Source"]], mode, (v) => { preview.mode = v; renderPreview(); }); + const t = await fetchText(); + body.innerHTML = mode === "preview" + ? `${t.truncated ? '
Large file — showing the first 2 MB.
' : ""}
${renderMarkdown(t.text ?? "", { baseDir: parts.join("/") })}
` + : codeView(t.text ?? "", t.truncated); + hydrateFileRefs(body); + break; + } + case "csv": { + const mode = preview.mode ?? "table"; + seg([["table", "Table"], ["source", "Source"]], mode, (v) => { preview.mode = v; renderPreview(); }); + const t = await fetchText(); + body.innerHTML = mode === "table" ? csvView(t.text ?? "", ext === "tsv" ? "\t" : ",") : codeView(t.text ?? "", t.truncated); + break; + } + case "binary": + body.innerHTML = noPreview(name, "This file type has no inline preview."); + break; + default: { + seg([["nowrap", "No wrap"], ["wrap", "Wrap"]], preview.wrap ? "wrap" : "nowrap", (v) => { preview.wrap = v === "wrap"; renderPreview(true); }); + const t = await fetchText(); + body.innerHTML = t.binary ? noPreview(name, "This looks like a binary file.") : codeView(t.text, t.truncated); + } + } + } catch (e) { + body.innerHTML = noPreview(name, `Preview failed: ${e.message}`); + } + + if (keepScroll) body.scrollTop = scrollTop; + else if (preview.line) { + const lineEl = body.querySelector(`[data-line="${preview.line}"]`); + if (lineEl) { lineEl.classList.add("hl"); lineEl.scrollIntoView({ block: "center" }); } + } else body.scrollTop = 0; +} + +function noPreview(name, why) { + return `
📦
${esc(name)}
${esc(why)}
+
`; +} + +function codeView(text, truncated) { + const lines = text.replace(/\r\n?/g, "\n").split("\n"); + if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop(); + const shown = lines.slice(0, 20000); + return `${truncated || lines.length > shown.length ? '
Large file — showing the beginning only.
' : ""} +
${shown.map((l, i) => `
${i + 1}${esc(l) || " "}
`).join("")}
`; +} + +function parseCsv(text, sep) { + const rows = []; + let row = [], cell = "", quoted = false; + for (let i = 0; i < text.length && rows.length < 1001; i++) { + const c = text[i]; + if (quoted) { + if (c === '"') { if (text[i + 1] === '"') { cell += '"'; i++; } else quoted = false; } + else cell += c; + } else if (c === '"') quoted = true; + else if (c === sep) { row.push(cell); cell = ""; } + else if (c === "\n" || c === "\r") { + if (c === "\r" && text[i + 1] === "\n") i++; + row.push(cell); rows.push(row); row = []; cell = ""; + } else cell += c; + } + if (cell || row.length) { row.push(cell); rows.push(row); } + return rows; +} + +function csvView(text, sep) { + const rows = parseCsv(text, sep); + if (!rows.length) return '
Empty file
'; + const [head, ...rest] = rows; + return `
${head.map((h) => ``).join("")} + ${rest.slice(0, 1000).map((r) => `${r.map((c) => ``).join("")}`).join("")}
${esc(h)}
${esc(c)}
+ ${rows.length > 1000 ? '
Showing the first 1000 rows.
' : ""}
`; +} + +$("preview-reload").addEventListener("click", () => preview.path && openPreview(preview.path, preview.line)); +$("preview-close").addEventListener("click", closePreview); +$("preview-mention").addEventListener("click", () => preview.path && mentionFile(preview.path)); + +// Follow an open file while agents edit it +setInterval(async () => { + if (!preview.path || document.hidden) return; + if (!isNarrow() && workspace.classList.contains("right-collapsed")) return; + if ($("rpanel-preview").classList.contains("hidden")) return; + const sid = preview.sid, p = preview.path; + try { + const info = await api(`fs/resolve?session_id=${encodeURIComponent(sid)}&path=${encodeURIComponent(p)}`); + if (preview.path === p && preview.sid === sid && info.mtime !== preview.mtime) { + preview.mtime = info.mtime; + preview.size = info.size; + renderPreview(true); + } + } catch { /* deleted or session gone — keep what is shown */ } +}, 4000); + +function mentionFile(p) { + switchTab("chat"); + const ta = $("composer-text"); + const ins = `\`${p}\` `; + const pos = ta.selectionStart ?? ta.value.length; + ta.value = ta.value.slice(0, pos) + (pos && !/\s$/.test(ta.value.slice(0, pos)) ? " " : "") + ins + ta.value.slice(pos); + autosize(); + ta.focus(); +} + +// --- Rich text: markdown-lite with file references --- + +const FILE_EXTS = "md|markdown|mdx|txt|rst|pdf|docx?|odt|rtf|xlsx?|ods|pptx?|odp|csv|tsv|json|jsonl|ya?ml|toml|ini|cfg|conf|xml|html?|css|scss|less|js|mjs|cjs|jsx|ts|tsx|vue|svelte|py|ipynb|r|rmd|qmd|jl|rb|go|rs|java|kt|swift|c|h|cc|cpp|hpp|cs|php|sh|bash|zsh|ps1|sql|lua|dart|scala|tex|bib|log|png|jpe?g|gif|webp|svg|bmp|avif|tiff?|mp3|wav|ogg|m4a|flac|mp4|webm|mov|zip|gz|tgz|tar|lock|env|fa|fasta|fastq|gff|gtf|bed|vcf|sam|rds|parquet"; +const FILE_RE_SRC = String.raw`(?()]+[^\s<>().,;:!?'")。,])|` + FILE_RE_SRC, "giu"); +const FILE_ONLY_RE = new RegExp("^" + FILE_RE_SRC + "$", "iu"); + +function emph(escaped) { + return escaped + .replace(/\*\*([^*\n]+?)\*\*/g, "$1") + .replace(/(^|[^*\w])\*(?!\s)([^*\n]+?)\*(?![*\w])/g, "$1$2") + .replace(/~~([^~\n]+?)~~/g, "$1"); +} + +function fileRef(ref, line) { + return `${esc(ref)}${line ? `:${esc(line)}` : ""}`; +} + +function inlineText(s, ctx) { + let out = "", last = 0; + for (const m of s.matchAll(INLINE_RE)) { + out += emph(esc(s.slice(last, m.index))); + last = m.index + m[0].length; + if (m[1]) { + const link = /^(!?)\[([^\]]*)\]\(([^)\s]+)\)$/.exec(m[1]); + const [, bang, label, href] = link; + if (/^https?:\/\//i.test(href)) { + out += bang ? `${esc(label)}` : `${esc(label)}`; + } else { + const target = ctx?.baseDir && !href.startsWith("/") ? `${ctx.baseDir}/${href}` : href; + if (bang && preview.sid != null) out += `${esc(label)}`; + else out += `${esc(label || href)}`; + } + } else if (m[2]) { + out += `${esc(m[2])}`; + } else { + out += fileRef(m[3], m[4]); + } + } + return out + emph(esc(s.slice(last))); +} + +function inline(s, ctx) { + return s.split(/(`[^`\n]+`)/).map((part) => { + if (part.length > 2 && part.startsWith("`") && part.endsWith("`")) { + const code = part.slice(1, -1); + const fm = FILE_ONLY_RE.exec(code); + return `${fm ? fileRef(fm[1], fm[2]) : esc(code)}`; + } + return inlineText(part, ctx); + }).join(""); +} + +function normPath(p) { + const out = []; + for (const seg of p.split("/")) { + if (seg === "..") out.pop(); + else if (seg && seg !== ".") out.push(seg); + } + return out.join("/"); +} + +/** Markdown for chat bubbles and .md previews: escape first, then a safe subset. */ +function renderMarkdown(text, ctx = {}) { + const lines = String(text ?? "").replace(/\r\n?/g, "\n").split("\n"); + const html = []; + let para = []; + const flush = () => { + if (para.length) html.push(`

${para.map((l) => inline(l, ctx)).join(ctx.chat ? "
" : " ")}

`); + para = []; + }; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const fence = /^\s*(```|~~~)\s*([\w+-]*)\s*$/.exec(line); + if (fence) { + flush(); + const body = []; + while (++i < lines.length && !lines[i].trim().startsWith(fence[1])) body.push(lines[i]); + html.push(`
${esc(body.join("\n"))}
`); + continue; + } + const h = /^(#{1,6})\s+(.*)$/.exec(line); + if (h) { flush(); html.push(`${inline(h[2], ctx)}`); continue; } + if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) { flush(); html.push("
"); continue; } + if (/^\s*>/.test(line)) { + flush(); + const quote = []; + while (i < lines.length && /^\s*>/.test(lines[i])) quote.push(lines[i++].replace(/^\s*>\s?/, "")); + i--; + html.push(`
${renderMarkdown(quote.join("\n"), ctx)}
`); + continue; + } + if (/^\s*\|.*\|\s*$/.test(line) && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1] ?? "")) { + flush(); + const cells = (l) => l.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim()); + const head = cells(line); + i++; + const rows = []; + while (i + 1 < lines.length && /^\s*\|.*\|\s*$/.test(lines[i + 1])) rows.push(cells(lines[++i])); + html.push(`${head.map((c) => ``).join("")}${rows.map((r) => `${r.map((c) => ``).join("")}`).join("")}
${inline(c, ctx)}
${inline(c, ctx)}
`); + continue; + } + const li = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line); + if (li) { + flush(); + const ordered = /\d/.test(li[2]); + const items = []; + while (i < lines.length) { + const m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(lines[i]); + if (m) items.push(m[3].replace(/^\[( |x)\]\s/i, (_, x) => (x.trim() ? "☑ " : "☐ "))); + else if (lines[i].trim() && /^\s{2,}/.test(lines[i]) && items.length) items[items.length - 1] += " " + lines[i].trim(); + else break; + i++; + } + i--; + html.push(`<${ordered ? "ol" : "ul"}>${items.map((t) => `
  • ${inline(t, ctx)}
  • `).join("")}`); + continue; + } + if (!line.trim()) { flush(); continue; } + para.push(line); + } + flush(); + return html.join(""); +} + +// File references become links once the server confirms the file exists in the project +const fileRefCache = new Map(); // ref → { path, at } | { path: null, at } +let refCheckTimer = null; +const pendingRefs = new Set(); + +function hydrateFileRefs(root) { + if (state?.sessionId == null) return; + for (const el of root.querySelectorAll(".file-ref")) { + const ref = el.dataset.ref; + const hit = fileRefCache.get(ref); + if (hit && (hit.path || Date.now() - hit.at < 30_000)) { + if (hit.path) { el.classList.add("file-link"); el.dataset.path = hit.path; el.title = `Open ${hit.path}`; } + } else pendingRefs.add(ref); + } + if (pendingRefs.size && !refCheckTimer) refCheckTimer = setTimeout(checkPendingRefs, 150); +} + +async function checkPendingRefs() { + refCheckTimer = null; + const sid = state?.sessionId; + const refs = [...pendingRefs]; + pendingRefs.clear(); + if (sid == null || !state.session) return; + for (let i = 0; i < refs.length; i += 40) { + const batch = refs.slice(i, i + 40); + try { + const res = await fetch(fsUrl("check", sid, { ref: batch })); + const data = await res.json(); + if (!data.ok || sid !== state?.sessionId) return; + for (const ref of batch) fileRefCache.set(ref, { path: data.result[ref] ?? null, at: Date.now() }); + } catch { return; } + } + for (const root of [$("msg-feed"), $("preview-body"), $("panel-knowledge"), $("panel-agents"), $("rpanel-team")]) hydrateFileRefs(root); +} + +document.addEventListener("click", (e) => { + const link = e.target.closest(".file-link"); + if (!link) return; + e.preventDefault(); + const line = link.dataset.line ? parseInt(link.dataset.line, 10) : undefined; + openPreview(link.dataset.path, line); +}); + +// --- Chat --- + +let lastChatSignature = ""; +const expandedMessages = new Set(); +const SYSTEMISH = new Set(["system", "control", "team_change", "role_assignment", "rename", "release"]); +const PALETTE = ["#2f6fde", "#8250df", "#1a7f37", "#c2410c", "#0e7f86", "#bf3989", "#9a6700", "#6639ba", "#0969da", "#cf222e"]; + +function isSystemMessage(m) { return SYSTEMISH.has(m.msg_type); } + +function initials(name) { + const n = String(name ?? "?").trim(); + if (!n) return "?"; + if (/^[㐀-鿿가-힯぀-ヿ]/.test(n)) return n[0]; + const words = n.split(/[\s_\-]+/).filter(Boolean); + return (words.length > 1 ? words[0][0] + words[1][0] : n.slice(0, 2)).toUpperCase(); +} + +function slotColor(id) { return PALETTE[Math.abs(Number(id) || 0) % PALETTE.length]; } + +const PERSON_SVG = ''; + +function avatar(name, color, extra = "", me = false) { + return `
    ${me ? PERSON_SVG : esc(initials(name))}${extra}
    `; +} + +function senderOf(m) { + if (m.from_id === "human") return { key: "human", name: "You", me: true, color: "var(--accent)" }; + if (m.from_id === "orchestrator" || m.from_id === "__orchestrator__") return { key: "orchestrator", name: "Orchestrator", color: "#6e7781", role: "system" }; + if (m.from_id === "cli") return { key: "cli", name: "CLI", color: "#6e7781" }; + const slot = (m.from_slot_id != null ? slotById(m.from_slot_id) : null) ?? state.slots.find((s) => s.peer_id === m.from_id); + if (slot) return { key: `slot:${slot.id}`, name: slotName(slot), role: slot.role, color: slotColor(slot.id) }; + return { key: `peer:${m.from_id}`, name: m.from_display_name ?? m.from_id ?? "?", color: "#6e7781" }; +} + +function recipientOf(m) { + if (m.to_slot_id != null) { const s = slotById(m.to_slot_id); if (s) return slotName(s); } + if (m.to_id === "human") return "You"; + if (m.to_id === "orchestrator" || m.to_id === "__orchestrator__") return "Orchestrator"; + return m.to_display_name ?? m.to_id ?? "?"; +} + +function systemText(m) { + const t = m.text ?? ""; + try { + const j = JSON.parse(t); + if (m.msg_type === "control" && j.action) return `${j.action}${j.reason ? ` — ${j.reason}` : ""}`; + if (m.msg_type === "role_assignment" && j.role) return `role set to ${j.role}`; + if (m.msg_type === "rename" && j.display_name) return `renamed to ${j.display_name}`; + } catch { /* plain text */ } + return t.replace(/\s+/g, " "); +} + +/** Messages in display order, with one-to-many sends (broadcasts, done notices) folded into one item. */ +function chatItems() { + const showSystem = $("msg-show-system").checked; + const filter = $("msg-filter-agent").value; + let msgs = state.messages; + if (!showSystem) msgs = msgs.filter((m) => !isSystemMessage(m)); + if (filter) { + const id = parseInt(filter, 10); + const peer = slotById(id)?.peer_id; + msgs = msgs.filter((m) => m.from_slot_id === id || m.to_slot_id === id || (peer && (m.from_id === peer || m.to_id === peer))); + } + const items = []; + for (const m of msgs) { + const t = new Date(m.sent_at).getTime(); + const prev = items[items.length - 1]; + if (prev && prev.m.from_id === m.from_id && prev.m.text === m.text && prev.m.msg_type === m.msg_type && Math.abs(t - prev.t) < 5000) { + prev.to.add(recipientOf(m)); + continue; + } + items.push({ m, t, to: new Set([recipientOf(m)]) }); + } + return items; +} + +function renderChat(force) { + const feed = $("msg-feed"); + const sig = [state.sessionId, state.messages.length, state.messages.at(-1)?.id, $("msg-show-system").checked, $("msg-filter-agent").value, + state.slots.map((s) => `${s.id}:${s.display_name}:${s.role}`).join(","), document.documentElement.dataset.theme].join("|"); + if (!force && sig === lastChatSignature) return; + const firstRender = !lastChatSignature; + lastChatSignature = sig; + + if (!state.session) { + feed.innerHTML = `
    ${state.brokerAlive ? "No session selected.
    " : "Broker is not running."}
    `; + $("chat-count").textContent = ""; + return; + } + const items = chatItems(); + $("chat-count").textContent = `${items.length} message${items.length === 1 ? "" : "s"}`; + if (!items.length) { + feed.innerHTML = '
    No messages yet. Say something to the team below.
    '; + return; + } + + const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80; + const prevHeight = feed.scrollHeight; + let html = "", lastDay = "", lastSender = null, lastTime = 0; + for (const { m, t, to } of items) { + const day = new Date(t).toDateString(); + if (day !== lastDay) { + lastDay = day; + html += `
    ${esc(fmtDay(t))}
    `; + lastSender = null; + } + const sender = senderOf(m); + if (isSystemMessage(m)) { + const target = to.size > 1 ? "everyone" : [...to][0]; + html += `
    ${esc(sender.name)} → ${esc(target)}: ${esc(systemText(m))}
    `; + lastSender = null; + continue; + } + const cont = lastSender === sender.key && t - lastTime < 3 * 60_000 && to.size === 1; + lastSender = sender.key; + lastTime = t; + const toLabel = m.msg_type === "broadcast" || to.size > 2 ? "everyone" : [...to].join(", "); + const text = m.text ?? ""; + const long = !expandedMessages.has(m.id) && (text.length > 1500 || text.split("\n").length > 24); + const kind = m.msg_type !== "chat" ? m.msg_type : ""; + html += `
    + ${avatar(sender.name, sender.color, "", sender.me)} +
    +
    + ${esc(sender.name)} + ${sender.role ? `${esc(sender.role)}` : ""} + → ${esc(toLabel)} + ${kind ? `${esc(kind.replace(/_/g, " "))}` : ""} + ${esc(fmtTime(m.sent_at))} +
    +
    ${renderMarkdown(text, { chat: true })}
    +
    +
    `; + } + feed.innerHTML = html; + hydrateFileRefs(feed); + + if (atBottom || force || firstRender) { + feed.scrollTop = feed.scrollHeight; + $("jump-new").classList.add("hidden"); + } else if (feed.scrollHeight > prevHeight) { + $("jump-new").classList.remove("hidden"); + } +} + +$("msg-feed").addEventListener("click", (e) => { + const b = e.target.closest(".bubble.collapsed"); + if (b && !e.target.closest(".file-link, a")) { expandedMessages.add(Number(b.dataset.mid)); b.classList.remove("collapsed"); } + const note = e.target.closest(".sys-note > span"); + if (note) note.classList.toggle("open"); +}); +$("msg-feed").addEventListener("scroll", () => { + const feed = $("msg-feed"); + if (feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80) $("jump-new").classList.add("hidden"); +}); +$("jump-new").addEventListener("click", () => { const f = $("msg-feed"); f.scrollTop = f.scrollHeight; $("jump-new").classList.add("hidden"); }); +$("msg-show-system").addEventListener("change", () => { store.set("ma-show-system", $("msg-show-system").checked); render(true); }); +$("msg-filter-agent").addEventListener("change", () => render(true)); + +/** Keep the agent dropdowns in sync with the session's slots (preserving the selection). */ +function refreshAgentSelects() { + const live = liveSlots(); + const opts = (list) => list.map((s) => ``).join(""); + fillSelect("composer-to", `` + opts(live)); + fillSelect("msg-filter-agent", `` + opts(state.slots)); + fillSelect("plan-add-assignee", `` + opts(live)); + fillSelect("lock-agent", opts(live)); + fillSelect("own-agent", opts(live)); + const to = $("composer-to").value; + const slot = to === "all" ? null : slotById(parseInt(to, 10)); + $("composer-target-hint").textContent = slot + ? `${TASK_LABEL[slot.task_state] ?? slot.task_state}${isPaused(slot) ? " · paused (held until resume)" : ""}` + : `${live.filter((s) => s.status === "connected" && !isPaused(s)).length} online`; + $("composer-send").disabled = !state.session || state.session.status === "archived"; +} + +function fillSelect(id, html) { + const el = $(id); + if (!el || el.dataset.html === html || document.activeElement === el) return; + const prev = el.value; + el.innerHTML = html; + el.dataset.html = html; + if ([...el.options].some((o) => o.value === prev)) el.value = prev; +} + +function autosize() { + const ta = $("composer-text"); + ta.style.height = "auto"; + ta.style.height = `${Math.min(220, ta.scrollHeight)}px`; +} + +async function sendComposer() { + const sid = currentSid(); + if (sid == null) return; + const ta = $("composer-text"); + const text = ta.value.trim(); + if (!text) return; + const to = $("composer-to").value; + const r = await run($("composer-send"), "Send", () => api("messages/send", { session_id: sid, slot_id: to === "all" ? "all" : parseInt(to, 10), text })); + if (r) { + ta.value = ""; + autosize(); + const f = $("msg-feed"); + f.scrollTop = f.scrollHeight; + } +} + +$("composer-send").addEventListener("click", sendComposer); +$("composer-text").addEventListener("input", autosize); +$("composer-text").addEventListener("keydown", (e) => { + // Enter sends; Shift+Enter is a new line; an IME composition (Chinese/Japanese input) is never cut off + if (e.key === "Enter" && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); sendComposer(); } +}); +$("composer-to").addEventListener("change", refreshAgentSelects); +$("composer-presets").addEventListener("click", (e) => { + e.stopPropagation(); + openMenu($("composer-presets"), PRESETS.map((t) => ({ label: t, onClick: () => { $("composer-text").value = t; autosize(); $("composer-text").focus(); } }))); +}); + +const PRESETS = [ + "Stop editing files and explain your current reasoning.", + "Do not proceed until human approval.", + "Summarize your progress and what is left.", + "List the files you changed and why.", +]; + +// --- Session bar & gate --- + +function renderSessionBar() { + const s = state.session; + $("sb-name").textContent = s ? (s.name || s.id) : (state.sessionId ?? "No session selected"); + const b = s ? sessionBadge() : null; + $("sb-status").innerHTML = b ? `${b.text}` : ""; + $("sb-gate").innerHTML = s + ? `human gate ${state.humanGate ? "on" : "off"}` + : ""; + $("sb-meta").textContent = s + ? `${s.project_dir}${state.managed ? " · agents run under this dashboard" : ""}` + : "Create a team with + New Team, or pick a session on the left."; + $("sb-meta").title = s ? `${s.id} · ${s.project_dir}` : ""; + + const has = !!s, archived = s?.status === "archived", paused = s?.status === "paused"; + const tb = $("session-toolbar"); + tb.querySelector('[data-action="session-pause"]').disabled = !has || archived || paused; + tb.querySelector('[data-action="session-resume"]').disabled = !has || archived || !paused; + tb.querySelector('[data-action="add-agent"]').disabled = !has || archived; + tb.querySelector('[data-action="release-menu"]').disabled = !has; + tb.querySelector('[data-action="session-menu"]').disabled = !has; +} + +function renderGateBanner() { + const el = $("gate-banner"); + if (!state.awaitingHuman) { el.className = "hidden"; el.innerHTML = ""; return; } + el.className = "banner"; + el.innerHTML = ` + Every agent is approved. Review the work, then release it or send it back. + + + + `; +} + +// --- Agents --- + +const TASK_LABEL = { + idle: "idle", working: "working", done_pending_review: "in review", + addressing_feedback: "addressing feedback", approved: "approved", released: "released", +}; +const TASK_CLASS = { + idle: "badge-idle", working: "badge-working", done_pending_review: "badge-review", + addressing_feedback: "badge-feedback", approved: "badge-approved", released: "badge-released", +}; + +function taskBadge(taskState) { + return `${esc(TASK_LABEL[taskState] ?? taskState)}`; +} + +function connBadge(status) { + return status === "connected" ? 'online' : 'offline'; +} + +function slotSummary(slot) { + const peer = slot.peer_id ? state.peers.find((p) => p.id === slot.peer_id) : null; + if (peer?.summary) return peer.summary; + try { return JSON.parse(slot.context_snapshot ?? "{}")?.last_summary ?? null; } catch { return null; } +} + +function renderAgents() { + const el = $("panel-agents"); + if (busyRegion(el)) return; + if (!state.session) { + el.innerHTML = `
    ${state.brokerAlive ? "No session selected." : "Broker is not running."}
    `; + return; + } + if (!state.slots.length) { + el.innerHTML = '
    No agents in this session
    '; + return; + } + const archived = state.session.status === "archived"; + const managed = new Set(state.managedSlots ?? []); + el.innerHTML = `
    ${state.slots.map((slot) => { + const summary = slotSummary(slot); + const released = slot.task_state === "released"; + const paused = isPaused(slot); + const reviewable = slot.task_state === "approved" || slot.task_state === "done_pending_review"; + const id = slot.id; + const tokens = slot.input_tokens || slot.output_tokens ? `${fmtTokens(slot.input_tokens ?? 0)} in / ${fmtTokens(slot.output_tokens ?? 0)} out` : ""; + const actions = released || archived + ? `` + : ` + ${paused ? `` : ``} + + + ${reviewable ? `` : ""} + + `; + return `
    +
    + ${avatar(slotName(slot), slotColor(id))} +
    ${esc(slotName(slot))}
    ${esc(slot.role ?? "no role")} · ${esc(slot.agent_type)}
    + ${connBadge(slot.status)} +
    +
    + ${taskBadge(slot.task_state ?? "idle")} + ${paused ? 'paused' : ""} + ${managed.has(id) ? 'managed here' : ""} +
    +
    slot #${id} · peer ${esc(slot.peer_id ?? "—")}
    + ${tokens} + ${summary ? `
    ${inline(truncate(summary, 260))}
    ` : ""} +
    ${actions}
    +
    `; + }).join("")}
    `; + hydrateFileRefs(el); +} + +function renderRoster() { + const el = $("rpanel-team"); + if ($("rpanel-team").classList.contains("hidden") || busyRegion(el)) return; + if (!state.session) { el.innerHTML = '
    No session selected
    '; return; } + if (!state.slots.length) { el.innerHTML = '
    No agents yet
    '; return; } + const order = (s) => (s.task_state === "released" ? 2 : s.status === "connected" ? 0 : 1); + el.innerHTML = [...state.slots].sort((a, b) => order(a) - order(b) || a.id - b.id).map((slot) => { + const statusColor = isPaused(slot) ? "var(--yellow)" : slot.status === "connected" ? "var(--green)" : "var(--text-muted)"; + const summary = slotSummary(slot); + return `
    + ${avatar(slotName(slot), slotColor(slot.id), ``)} +
    +
    ${esc(slotName(slot))} ${esc(slot.role ?? "")}
    +
    ${esc(TASK_LABEL[slot.task_state] ?? slot.task_state)}${isPaused(slot) ? " · paused" : ""}${summary ? ` · ${esc(truncate(summary, 80))}` : ""}
    +
    + +
    `; + }).join(""); +} + +$("rpanel-team").addEventListener("click", (e) => { + const menuBtn = e.target.closest("[data-roster-menu]"); + const slot = slotById(parseInt(e.target.closest("[data-roster]")?.dataset.roster, 10)); + if (!slot) return; + if (menuBtn) { + e.stopPropagation(); + const released = slot.task_state === "released"; + const items = [ + { label: "Message", disabled: released, onClick: () => agentAction("message", slot) }, + isPaused(slot) ? { label: "Resume", disabled: released, onClick: () => agentAction("resume", slot) } : { label: "Pause", disabled: released, onClick: () => agentAction("pause", slot) }, + { label: "Change role", disabled: released, onClick: () => agentAction("role", slot) }, + { label: "Rename", disabled: released, onClick: () => agentAction("rename", slot) }, + { label: "Send back for revision", disabled: released, onClick: () => agentAction("sendback", slot) }, + { sep: true }, + { label: "Release", disabled: released, onClick: () => agentAction("release", slot) }, + { label: "Remove", danger: true, onClick: () => agentAction("remove", slot) }, + ]; + openMenu(menuBtn, items); + return; + } + // A click on a teammate addresses the composer to them + if (slot.task_state !== "released") { + switchTab("chat"); + $("composer-to").value = String(slot.id); + refreshAgentSelects(); + $("composer-text").focus(); + } +}); + +$("panel-agents").addEventListener("click", (e) => { + const btn = e.target.closest("[data-agent]"); + if (!btn) return; + const slot = slotById(parseInt(btn.dataset.slot, 10)); + if (slot) agentAction(btn.dataset.agent, slot, btn); +}); + +async function agentAction(kind, slot, btn) { + const sid = currentSid(); + if (sid == null) return; + const name = slotName(slot); + switch (kind) { + case "message": + switchTab("chat"); + $("composer-to").value = String(slot.id); + refreshAgentSelects(); + $("composer-text").focus(); + return; + case "pause": + return run(btn, "Pause", () => api("agents/control", { session_id: sid, slot_id: slot.id, action: "pause" })); + case "resume": + return run(btn, "Resume", () => api("agents/control", { session_id: sid, slot_id: slot.id, action: "resume" })); + case "rename": { + const v = await promptDialog({ title: `Rename ${name}`, sid, label: "Display name", value: slot.display_name ?? "" }); + if (v) return run(null, "Rename", () => api("agents/rename", { session_id: sid, slot_id: slot.id, display_name: v })); + return; + } + case "role": return roleModal(sid, slot); + case "sendback": return sendBackModal(sid, slot.id); + case "release": { + const notApproved = slot.task_state !== "approved"; + const ok = await confirmDialog({ + title: `Release ${name}?`, sid, + message: `

    Releasing tells ${esc(name)} its work is complete; it disconnects once idle.

    ` + + (notApproved ? `

    ⚠ ${esc(name)} is ${esc(TASK_LABEL[slot.task_state] ?? slot.task_state)}, not approved.

    ` : ""), + confirmLabel: "Release", danger: notApproved, + }); + if (ok) return run(btn, "Release", () => api("agents/release", { session_id: sid, slot_id: slot.id })); + return; + } + case "remove": { + const ok = await confirmDialog({ + title: `Remove ${name}?`, sid, + message: "

    Remove stops the agent now (if this dashboard runs it), marks it released and tells the team it left. Unlike Release, it does not wait for the agent to finish.

    ", + confirmLabel: "Remove", danger: true, + }); + if (ok) return run(btn, "Remove", () => api("agents/remove", { session_id: sid, slot_id: slot.id })); + return; + } + } +} + +// --- Plan --- + +const PLAN_ICON = { done: "✓", in_progress: "▶", blocked: "✗", pending: "○" }; + +function renderPlan() { + const el = $("plan-list"); + if (busyRegion(el)) return; + if (!state.plan?.plan) { + el.innerHTML = `
    ${state.session ? "No plan yet — add the first task above." : "No session selected"}
    `; + return; + } + const { plan, items, completion } = state.plan; + const itemsHtml = items.map((item) => { + const assigneeOpts = `` + state.slots + .filter((s) => s.task_state !== "released" || s.id === item.assigned_to_slot) + .map((s) => ``).join(""); + const statusOpts = ["pending", "in_progress", "blocked", "done"] + .map((st) => ``).join(""); + return `
    + ${PLAN_ICON[item.status] ?? PLAN_ICON.pending} + ${esc(item.label)} + + + +
    `; + }).join(""); + el.innerHTML = `
    +
    + ${esc(plan.title)} +
    + ${completion}% +
    +
    ${itemsHtml || '
    No tasks
    '}
    +
    `; +} + +$("plan-add-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const sid = currentSid(); + if (sid == null) return; + const label = $("plan-add-label").value.trim(); + if (!label) return; + const assignee = $("plan-add-assignee").value; + const r = await run(e.submitter, "Add task", () => api("plan/add", { session_id: sid, label, slot_id: assignee ? parseInt(assignee, 10) : null }).then(() => ({ message: "Task added" }))); + if (r) $("plan-add-label").value = ""; +}); + +$("plan-list").addEventListener("change", (e) => { + const sel = e.target.closest("select[data-plan]"); + if (!sel) return; + const sid = currentSid(); + if (sid == null) return; + const body = { session_id: sid, item_id: parseInt(sel.closest("[data-item]").dataset.item, 10) }; + if (sel.dataset.plan === "status") body.status = sel.value; + else body.slot_id = sel.value ? parseInt(sel.value, 10) : null; + sel.blur(); + run(null, "Update task", () => api("plan/edit", body).then(() => ({ message: "Task updated" }))); +}); + +$("plan-list").addEventListener("click", async (e) => { + const t = e.target.closest("[data-plan]"); + if (!t || t.tagName === "SELECT") return; + const sid = currentSid(); + if (sid == null) return; + const itemId = parseInt(t.closest("[data-item]").dataset.item, 10); + const item = state.plan?.items.find((i) => i.id === itemId); + if (!item) return; + if (t.dataset.plan === "edit") { + const v = await promptDialog({ title: "Edit task", sid, label: "Task", value: item.label, multiline: true }); + if (v) run(null, "Edit task", () => api("plan/edit", { session_id: sid, item_id: itemId, label: v }).then(() => ({ message: "Task updated" }))); + } else if (t.dataset.plan === "delete") { + const ok = await confirmDialog({ title: "Delete task?", sid, message: `

    ${esc(item.label)}

    `, confirmLabel: "Delete", danger: true }); + if (ok) run(null, "Delete task", () => api("plan/delete", { session_id: sid, item_id: itemId }).then(() => ({ message: "Task deleted" }))); + } +}); + +// --- Knowledge --- + +let editingKnowledgeKey = null; + +function renderKnowledge() { + const el = $("knowledge-list"); + if (busyRegion(el)) return; + const entries = state.knowledge ?? []; + if (!entries.length) { + el.innerHTML = `
    ${state.session ? "No knowledge entries. Agents store decisions and discoveries here." : "No session selected"}
    `; + return; + } + el.innerHTML = `
    Shared Knowledge ${entries.length}
    ${entries.map((e) => { + const by = e.created_by_name ? `by ${esc(e.created_by_name)}` : ""; + const ago = timeSince(e.updated_at); + return `
    +
    +
    ${esc(e.key)}${esc(e.category)}
    +
    ${renderMarkdown(e.value, { chat: true })}
    +
    ${by}${by && ago ? " · " : ""}${ago}
    +
    +
    + + +
    +
    `; + }).join("")}
    `; + hydrateFileRefs(el); +} + +function resetKnowledgeForm() { + editingKnowledgeKey = null; + $("knowledge-form").reset(); + $("kn-key").readOnly = false; + $("knowledge-form-title").textContent = "Add knowledge"; +} + +$("kn-cancel").addEventListener("click", resetKnowledgeForm); + +$("knowledge-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const sid = currentSid(); + if (sid == null) return; + const body = { session_id: sid, key: $("kn-key").value.trim(), value: $("kn-value").value, category: $("kn-category").value }; + const r = await run(e.submitter, "Save knowledge", () => api("knowledge/put", body).then(() => ({ message: `Saved "${body.key}"` }))); + if (r) resetKnowledgeForm(); +}); + +$("knowledge-list").addEventListener("click", async (e) => { + const btn = e.target.closest("[data-kn]"); + if (!btn) return; + const sid = currentSid(); + if (sid == null) return; + const key = btn.closest("[data-key]").dataset.key; + const entry = state.knowledge.find((k) => k.key === key); + if (!entry) return; + if (btn.dataset.kn === "edit") { + editingKnowledgeKey = key; + $("kn-key").value = entry.key; + $("kn-key").readOnly = true; + $("kn-value").value = entry.value; + $("kn-category").value = entry.category; + $("knowledge-form-title").textContent = `Edit "${key}"`; + $("kn-value").focus(); + } else { + const ok = await confirmDialog({ title: `Delete "${key}"?`, sid, message: `

    ${esc(truncate(entry.value, 300))}

    `, confirmLabel: "Delete", danger: true }); + if (ok) { + await run(btn, "Delete knowledge", () => api("knowledge/delete", { session_id: sid, key }).then(() => ({ message: `Deleted "${key}"` }))); + if (editingKnowledgeKey === key) resetKnowledgeForm(); + } + } +}); + +// --- Locks --- + +function renderFiles() { + const el = $("files-list"); + if (busyRegion(el)) return; + const lockRows = state.fileLocks.map((l) => { + const holder = slotById(l.held_by_slot); + const left = l.expires_at - Date.now(); + return ` + ${esc(l.file_path)} + ${esc(holder ? slotName(holder) : `Slot ${l.held_by_slot}`)} + ${esc(l.purpose ?? "")} + ${timeSince(l.acquired_at)} + ${left > 0 ? "in " + fmtDuration(left) : "expired"} + + `; + }).join(""); + const ownRows = state.fileOwnership.map((o) => { + const owner = slotById(o.slot_id); + return `${esc(o.path_pattern)}${esc(owner ? slotName(owner) : `Slot ${o.slot_id}`)}${esc(o.assigned_by ?? "")}${timeSince(o.assigned_at)}`; + }).join(""); + el.innerHTML = `
    Active Locks ${state.fileLocks.length}
    + ${state.fileLocks.length ? `
    ${lockRows}
    FileHeld ByPurposeAcquiredExpires
    ` : '
    No active locks
    '}
    +
    Ownership Zones ${state.fileOwnership.length}
    + ${state.fileOwnership.length ? `
    ${ownRows}
    PatternOwnerAssigned ByWhen
    ` : '
    No ownership zones
    '}
    `; + hydrateFileRefs(el); +} + +$("files-list").addEventListener("click", async (e) => { + const btn = e.target.closest("[data-lock-release]"); + if (!btn) return; + const sid = currentSid(); + if (sid == null) return; + const lock = state.fileLocks.find((l) => l.id === parseInt(btn.dataset.lockRelease, 10)); + if (!lock) return; + const holder = slotById(lock.held_by_slot); + const ok = await confirmDialog({ + title: "Force unlock?", sid, + message: `

    ${esc(lock.file_path)} is held by ${esc(holder ? slotName(holder) : `slot ${lock.held_by_slot}`)}. Unlocking lets other agents edit it while the holder may still be writing.

    `, + confirmLabel: "Force unlock", danger: true, + }); + if (ok) run(btn, "Unlock", () => api("files/release", { session_id: sid, lock_id: lock.id })); +}); + +$("lock-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const sid = currentSid(); + if (sid == null) return; + const slot = $("lock-agent").value; + if (!slot) return toast("Pick an agent", "warn"); + const r = await run(e.submitter, "Acquire lock", () => api("files/acquire", { + session_id: sid, slot_id: parseInt(slot, 10), file_path: $("lock-path").value.trim(), + minutes: Number($("lock-minutes").value), purpose: $("lock-purpose").value.trim(), + })); + if (r) { $("lock-path").value = ""; $("lock-purpose").value = ""; } +}); + +$("ownership-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const sid = currentSid(); + if (sid == null) return; + const slot = $("own-agent").value; + if (!slot) return toast("Pick an agent", "warn"); + const patterns = $("own-patterns").value.split(",").map((p) => p.trim()).filter(Boolean); + const r = await run(e.submitter, "Assign ownership", () => api("files/ownership", { session_id: sid, slot_id: parseInt(slot, 10), patterns })); + if (r) $("own-patterns").value = ""; +}); + +// --- Stats --- + +function renderStats() { + const el = $("panel-stats"); + if (busyRegion(el)) return; + if (!state.session) { el.innerHTML = '
    No session selected
    '; return; } + const totalIn = state.slots.reduce((a, s) => a + (s.input_tokens ?? 0), 0); + const totalOut = state.slots.reduce((a, s) => a + (s.output_tokens ?? 0), 0); + const cards = [ + { value: state.slots.filter((s) => s.status === "connected").length, label: "Online agents", color: "var(--green)" }, + { value: state.slots.filter((s) => s.task_state === "working").length, label: "Working", color: "var(--accent)" }, + { value: state.slots.filter((s) => s.task_state === "approved").length, label: "Approved (not released)", color: "var(--green)" }, + { value: state.slots.filter((s) => s.task_state === "released").length, label: "Released", color: "var(--text-dim)" }, + { value: state.messages.length, label: "Messages", color: "var(--cyan)" }, + { value: fmtTokens(totalIn), label: "Input tokens", color: "var(--purple)" }, + { value: fmtTokens(totalOut), label: "Output tokens", color: "var(--purple)" }, + ]; + for (const g of state.guardrails.filter((g) => g.action === "monitor")) { + cards.push({ value: g.usage?.current ?? 0, label: `${g.label} (${g.unit})`, color: "var(--text)" }); + } + const enforced = state.guardrails.filter((g) => g.action !== "monitor"); + const guardrails = enforced.length ? `
    Guardrails changes are logged as "Adjusted via web dashboard"
    +
    ${enforced.map((g) => { + const u = g.usage ?? { current: 0, limit: g.current_value, percent: 0, status: "ok" }; + const color = u.status === "triggered" ? "var(--red)" : u.status === "warning" ? "var(--yellow)" : "var(--green)"; + const pct = Math.min(100, Math.round((u.percent ?? 0) * 100)); // broker reports a 0..1 ratio + return `
    +
    +
    ${esc(g.label)} ${g.is_overridden ? 'overridden' : ""}
    +
    ${esc(g.description ?? "")} · default ${g.default_value} ${esc(g.unit)} · action: ${esc(g.action)}
    +
    +
    usage ${u.current}/${u.limit}
    +
    + ${g.adjustable ? `
    + + + + + ${(g.suggested_increases ?? []).map((v) => ``).join("")} +
    ` : 'fixed'} +
    `; + }).join("")}
    ` : ""; + el.innerHTML = `
    ${cards.map((s) => `
    ${s.value}
    ${esc(s.label)}
    `).join("")}
    ${guardrails}`; +} + +$("panel-stats").addEventListener("click", (e) => { + const row = e.target.closest("[data-guardrail]"); + if (!row) return; + const input = row.querySelector("[data-input]"); + const step = e.target.closest("[data-step]"); + if (step) { input.value = Math.max(0, Number(input.value) + Number(step.dataset.step)); input.focus(); return; } + const suggest = e.target.closest("[data-suggest]"); + if (suggest) input.value = suggest.dataset.suggest; + if (suggest || e.target.closest("[data-set]")) { + const sid = currentSid(); + if (sid == null) return; + const value = Number(input.value); + const id = row.dataset.guardrail; + input.blur(); + run(e.target, "Guardrail", () => api("guardrail", { session_id: sid, guardrail_id: id, value, reason: "Adjusted via web dashboard" }) + .then((g) => ({ message: `${g?.label ?? id} set to ${g?.current_value ?? value} ${g?.unit ?? ""}` }))); + } +}); + +// --- Toolbar / global actions --- + +document.addEventListener("click", (e) => { + const btn = e.target.closest("[data-action]"); + if (!btn || btn.disabled) return; + globalAction(btn.dataset.action, btn); +}); + +async function globalAction(action, btn) { + if (action === "new-team") return newTeamModal(); + if (action === "new-session") return newSessionModal(); + const sid = currentSid(); + if (sid == null) return; + switch (action) { + case "session-pause": { + const ok = await confirmDialog({ title: "Pause the whole session?", sid, message: "

    Every connected agent gets a pause signal, its incoming messages are held and its file locks are released. Resume picks up where they stopped.

    ", confirmLabel: "Pause all" }); + if (ok) run(btn, "Pause", () => api("session/control", { session_id: sid, action: "pause" })); + return; + } + case "session-resume": return run(btn, "Resume", () => api("session/control", { session_id: sid, action: "resume" })); + case "add-agent": return addAgentModal(sid); + case "release-menu": return openMenu(btn, releaseMenuItems(sid)); + case "session-menu": return openMenu(btn, sessionMenuItems(state.session)); + case "toggle-gate": return run(btn, "Human gate", () => api("session/human-gate", { session_id: sid, enabled: !state.humanGate })); + case "gate-release": return releaseAll(sid, true, btn); + case "gate-sendback": return sendBackModal(sid, null); + } +} + +function releaseMenuItems(sid) { + const approved = state.slots.filter((s) => s.task_state === "approved"); + const releasable = state.slots.filter((s) => s.task_state !== "released" && s.task_state !== "idle"); + return [ + { label: "Release all approved", hint: String(approved.length), disabled: !approved.length, onClick: () => releaseAll(sid, true) }, + { label: "Release all", hint: String(releasable.length), danger: true, disabled: !releasable.length, onClick: () => releaseAll(sid, false) }, + ]; +} + +async function releaseAll(sid, approvedOnly, btn) { + const targets = approvedOnly + ? state.slots.filter((s) => s.task_state === "approved") + : state.slots.filter((s) => s.task_state !== "released" && s.task_state !== "idle"); + if (!targets.length) return toast("Nothing to release", "warn"); + const notApproved = targets.filter((s) => s.task_state !== "approved"); + const ok = await confirmDialog({ + title: approvedOnly ? "Release all approved agents?" : "Release ALL agents?", sid, + message: `

    These agents are told their work is complete and disconnect once idle:

      ${targets.map((s) => `
    • ${esc(slotName(s))} — ${esc(TASK_LABEL[s.task_state] ?? s.task_state)}
    • `).join("")}
    ` + + (notApproved.length ? `

    ⚠ ${notApproved.length} of them are not approved.

    ` : ""), + confirmLabel: "Release", danger: !approvedOnly, + }); + if (ok) run(btn, "Release", () => api("agents/release-all", { session_id: sid, approved_only: approvedOnly })); +} + +function sessionMenuItems(s) { + if (!s) return []; + const sid = s.id; + const archived = s.status === "archived"; + return [ + { label: "Open", disabled: sid === state.sessionId, onClick: () => selectSession(sid) }, + { label: "Pause", disabled: s.status !== "active", onClick: async () => { + if (await confirmDialog({ title: `Pause "${s.name}"?`, sid, message: "

    Pauses every connected agent of this session.

    ", confirmLabel: "Pause all" })) + run(null, "Pause", () => api("session/control", { session_id: sid, action: "pause" })); + } }, + { label: "Resume", disabled: s.status !== "paused", onClick: () => run(null, "Resume", () => api("session/control", { session_id: sid, action: "resume" })) }, + { label: "Relaunch disconnected agents", onClick: async () => { + if (await confirmDialog({ title: `Relaunch agents of "${s.name}"?`, sid, message: "

    Respawns every disconnected, unreleased agent with a handoff of its role, plan items and recent messages. They run under this dashboard. Agents another orchestrator still runs are not touched.

    ", confirmLabel: "Relaunch" })) + run(null, "Relaunch", () => api("session/relaunch", { session_id: sid })); + } }, + { sep: true }, + { label: "Export JSON", onClick: () => { location.href = `/api/session/export?session_id=${encodeURIComponent(sid)}`; } }, + { label: "Archive", disabled: archived, onClick: async () => { + if (await confirmDialog({ title: `Archive "${s.name}"?`, sid, message: "

    Marks the session archived (like multiagents session archive). Running agents are not stopped — use End session for that.

    ", confirmLabel: "Archive" })) + run(null, "Archive", () => api("session/archive", { session_id: sid })); + } }, + { label: "End session", danger: true, disabled: archived, onClick: async () => { + if (await confirmDialog({ title: `End "${s.name}"?`, sid, message: "

    Stops the agents this dashboard runs, marks every agent disconnected and archives the session.

    ", confirmLabel: "End session", danger: true })) + run(null, "End session", () => api("session/end", { session_id: sid })); + } }, + { label: "Delete…", danger: true, onClick: async () => { + if (await confirmDialog({ title: `Delete "${s.name}" permanently?`, sid, message: "

    Stops its agents and deletes all slots, messages, plans and locks. This cannot be undone. Export first if you want a copy. Project files are not touched.

    ", confirmLabel: "Delete forever", danger: true, typeToConfirm: sid })) + run(null, "Delete", () => api("session/delete", { session_id: sid, confirm: sid })); + } }, + ]; +} + +// --- Dropdown menu --- + +function openMenu(anchor, items) { + const menu = $("menu"); + menu.innerHTML = items.map((it, i) => it.sep + ? '' + : ``).join(""); + menu.onclick = (e) => { + const el = e.target.closest(".menu-item"); + if (!el) return; + closeMenu(); + items[parseInt(el.dataset.idx, 10)]?.onClick?.(); + }; + menu.classList.add("open"); + const r = anchor.getBoundingClientRect(); + const w = menu.offsetWidth, h = menu.offsetHeight; + menu.style.left = `${Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8))}px`; + menu.style.top = `${r.bottom + h + 8 > window.innerHeight ? Math.max(8, r.top - h - 4) : r.bottom + 4}px`; +} + +function closeMenu() { $("menu").classList.remove("open"); } + +// Clicks on the menu or on a button that opens one leave it to that button +document.addEventListener("click", (e) => { + if (!e.target.closest("#menu, [data-action$='-menu'], [data-session-menu], [data-roster-menu], #theme-btn, #composer-presets")) closeMenu(); +}); + +// --- Modals --- + +let modalClose = null; + +/** Open a modal. `onSubmit(form)` may throw to keep it open with the error shown; returning closes it. */ +function openModal({ title, sub, body, submitLabel = "OK", danger = false, wide = false, onSubmit, onCancel, setup }) { + closeModal(); + const overlay = $("modal-overlay"); + overlay.innerHTML = `
    + + + +
    `; + overlay.classList.add("open"); + const form = overlay.querySelector("form"); + const errEl = form.querySelector(".modal-error"); + const submitBtn = form.querySelector('[type="submit"]'); + let done = false; + const finish = (cancelled) => { + if (done) return; + done = true; + overlay.classList.remove("open"); + overlay.innerHTML = ""; + modalClose = null; + if (cancelled) onCancel?.(); + }; + modalClose = () => finish(true); + form.querySelectorAll("[data-close]").forEach((b) => b.addEventListener("click", () => finish(true))); + form.addEventListener("submit", async (e) => { + e.preventDefault(); + errEl.textContent = ""; + submitBtn.disabled = true; + submitBtn.classList.add("busy"); + try { + await onSubmit(form); + finish(false); + } catch (err) { + errEl.textContent = err.message; + } finally { + submitBtn.disabled = false; + submitBtn.classList.remove("busy"); + } + }); + setup?.(form); + setTimeout(() => form.querySelector("[autofocus], textarea, input:not([type=checkbox]), select")?.focus(), 30); + return form; +} + +function closeModal() { modalClose?.(); } + +function sessionSub(sid) { + const s = state?.sessions?.find((x) => x.id === sid); + return `Session: ${s?.name && s.name !== sid ? `${s.name} (${sid})` : sid}`; +} + +function confirmDialog({ title, message, confirmLabel = "Confirm", danger = false, sid, typeToConfirm }) { + return new Promise((resolve) => { + openModal({ + title, sub: sid != null ? sessionSub(sid) : undefined, submitLabel: confirmLabel, danger, + body: `${message ?? ""}${typeToConfirm ? `

    Type ${esc(typeToConfirm)} to confirm:

    ` : ""}`, + onSubmit: (form) => { + if (typeToConfirm && form.confirm.value.trim() !== typeToConfirm) throw new Error("The text does not match"); + resolve(true); + }, + onCancel: () => resolve(false), + }); + }); +} + +function promptDialog({ title, label, value = "", multiline = false, sid }) { + return new Promise((resolve) => { + openModal({ + title, sub: sid != null ? sessionSub(sid) : undefined, submitLabel: "Save", + body: `
    ${multiline ? `` : ``}
    `, + onSubmit: (form) => { + const v = form.v.value.trim(); + if (!v) throw new Error(`${label} is required`); + resolve(v); + }, + onCancel: () => resolve(null), + }); + }); +} + +function agentOptions(selectedId) { + return liveSlots().map((s) => ``).join(""); +} + +function roleDatalist() { + return `${["Software Engineer", "Code Reviewer", "QA Engineer", "UI/UX Designer", "Researcher", "Writer", "Editor", "Tech Lead", "Worker", "Reviewer"] + .map((r) => ``; +} + +function roleModal(sid, slot) { + openModal({ + title: `Change role of ${slotName(slot)}`, sub: sessionSub(sid), submitLabel: "Assign role", + body: `
    + + +
    The agent receives a role_assignment message. Roles containing review, QA, test or lead count as reviewers in the approval flow.
    + ${roleDatalist()}`, + onSubmit: async (form) => { + const r = await api("agents/role", { session_id: sid, slot_id: slot.id, role: form.role.value.trim(), role_description: form.desc.value.trim() }); + toast(r?.message ?? "Role assigned", "ok"); + }, + }); +} + +function sendBackModal(sid, slotId) { + const isReviewer = (s) => /qa|review|test|lead/i.test(s.role ?? ""); + const candidates = liveSlots(); + const preferred = slotId ?? (candidates.find((s) => !isReviewer(s))?.id ?? candidates[0]?.id ?? null); + openModal({ + title: "Send back for revision", sub: sessionSub(sid), submitLabel: "Send back", danger: true, + body: `
    + + +
    Delivered as actionable feedback from you; the agent moves to addressing feedback and the review loop runs again.
    `, + onSubmit: async (form) => { + const feedback = form.feedback.value.trim(); + if (!feedback) throw new Error("Write the feedback first"); + const r = await api("agents/feedback", { session_id: sid, slot_id: parseInt(form.slot.value, 10), feedback }); + toast(r?.message ?? "Sent back", "ok"); + }, + }); +} + +let agentAvailability = null; +async function loadAvailability() { + if (agentAvailability) return agentAvailability; + try { agentAvailability = await api("agents/available"); } catch { agentAvailability = {}; } + return agentAvailability; +} + +function typeOptions(selected, avail) { + return ["claude", "codex", "gemini"].map((t) => + ``).join(""); +} + +async function addAgentModal(sid) { + const avail = await loadAvailability(); + const def = ["claude", "codex", "gemini"].find((t) => avail[t] !== false) ?? "claude"; + openModal({ + title: "Add agent", sub: sessionSub(sid), submitLabel: "Launch Agent", + body: `
    + + + + + + +
    ${roleDatalist()} +
    The agent runs under this dashboard (keep it open). Launching takes 15–60s before it connects.
    `, + onSubmit: async (form) => { + const body = { + session_id: sid, agent_type: form.type.value, name: form.name.value.trim(), role: form.role.value.trim(), + role_description: form.desc.value.trim(), initial_task: form.task.value.trim(), + file_ownership: form.own.value.split(",").map((p) => p.trim()).filter(Boolean), + }; + if (!body.name || !body.role || !body.initial_task) throw new Error("Name, role and task are required"); + if (avail[body.agent_type] === false) throw new Error(`${body.agent_type} CLI is not installed on this machine`); + const r = await api("agents/add", body); + toast(r?.message ?? "Agent launched", "ok"); + }, + }); +} + +function newSessionModal() { + openModal({ + title: "New empty session", submitLabel: "Create session", + body: `
    + + +
    Like multiagents session create: writes .multiagents/session.json into the directory. Add agents afterwards, or use New Team to launch a whole team.
    `, + onSubmit: async (form) => { + const r = await api("session/create", { name: form.name.value.trim(), project_dir: form.dir.value.trim() }); + toast(`Session "${r?.id}" created`, "ok"); + }, + }); +} + +const TEAM_PRESETS = { + "Worker + Reviewer": [ + { agent_type: "gemini", name: "Gemini Worker", role: "Software Engineer", initial_task: "" }, + { agent_type: "claude", name: "Claude Reviewer", role: "Code Reviewer", initial_task: "" }, + ], + "Engineer + Reviewer + QA": [ + { agent_type: "claude", name: "Engineer", role: "Software Engineer", initial_task: "" }, + { agent_type: "codex", name: "Reviewer", role: "Code Reviewer", initial_task: "" }, + { agent_type: "gemini", name: "QA", role: "QA Engineer", initial_task: "" }, + ], +}; + +async function newTeamModal() { + const avail = await loadAvailability(); + const fallback = ["claude", "codex", "gemini"].find((t) => avail[t] !== false) ?? "claude"; + const pick = (t) => (avail[t] === false ? fallback : t); + const agentRow = (a) => `
    +
    + + + + +
    + +
    `; + openModal({ + title: "New team", submitLabel: "Start Team", wide: true, + body: ` +
    + + + +
    + +
    ${TEAM_PRESETS["Worker + Reviewer"].map(agentRow).join("")}
    + + + + + + + + ${roleDatalist()}`, + setup: (form) => { + const list = form.querySelector("[data-agents]"); + form.querySelector("[data-add-agent]").addEventListener("click", () => list.insertAdjacentHTML("beforeend", agentRow({ agent_type: fallback, name: "", role: "", initial_task: "" }))); + list.addEventListener("click", (e) => { if (e.target.closest("[data-remove-agent]")) e.target.closest(".team-agent").remove(); }); + form.querySelectorAll("[data-preset]").forEach((b) => b.addEventListener("click", () => { list.innerHTML = TEAM_PRESETS[b.dataset.preset].map(agentRow).join(""); })); + }, + onSubmit: async (form) => { + const agents = [...form.querySelectorAll(".team-agent")].map((row) => { + const a = {}; + row.querySelectorAll("[data-f]").forEach((f) => { a[f.dataset.f] = f.value.trim(); }); + return a; + }); + if (!agents.length) throw new Error("Add at least one agent"); + if (agents.some((a) => !a.name || !a.role)) throw new Error("Every agent needs a name and a role"); + const unavailable = agents.filter((a) => avail[a.agent_type] === false).map((a) => `${a.name} (${a.agent_type})`); + if (unavailable.length) throw new Error(`Not installed on this machine: ${unavailable.join(", ")}`); + const names = agents.map((a) => a.name.toLowerCase()); + const plan = form.plan.value.split("\n").map((l) => l.trim()).filter(Boolean).map((line) => { + const m = line.match(/^([^::]{1,60})[::]\s*(.+)$/); + return m && names.includes(m[1].trim().toLowerCase()) + ? { label: m[2], agent_name: agents[names.indexOf(m[1].trim().toLowerCase())].name } + : { label: line }; + }); + const body = { + project_dir: form.dir.value.trim(), session_name: form.name.value.trim(), goal: form.goal.value.trim(), agents, plan, + workflow: { require_reviewer_approval: form.wf_approval.checked, require_actionable_feedback: form.wf_feedback.checked, human_release: form.wf_human.checked }, + }; + if (!body.project_dir || !body.session_name) throw new Error("Working directory and session name are required"); + toast("Starting team — agents take 15–60s to connect…", "info"); + const r = await api("team/create", body); + toast(`Team started: ${r?.data?.session_id ?? body.session_name}`, "ok"); + }, + }); +} + +// --- Broker info --- + +$("broker-pill").addEventListener("click", async () => { + let info; + try { info = await api("broker"); } catch (e) { return toast(`Broker status failed: ${e.message}`, "error"); } + openModal({ + title: "Broker", submitLabel: "Close", + body: `
    + ${info.alive ? '● running' : '● not running'} + ${esc(info.url)} + ${info.sessions} (${info.active_sessions} active) + ${info.peers} registered + ${esc(info.current_session ?? "—")} + ${(state?.managedSlots ?? []).length} agent(s) of the current session +
    Stop the broker from a terminal with multiagents kill-broker — it disconnects every agent on the machine.
    `, + onSubmit: () => {}, + }); +}); + +// --- Helpers --- + +function esc(s) { + if (s === null || s === undefined) return ""; + return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} +function truncate(s, n) { return s && s.length > n ? s.slice(0, n) + "…" : s; } +function baseName(p) { return String(p ?? "").replace(/\/+$/, "").split("/").pop(); } +function fmtTokens(n) { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} +function fmtBytes(n) { + if (n >= 1 << 30) return `${(n / (1 << 30)).toFixed(1)} GB`; + if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`; + if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(1)} KB`; + return `${n} B`; +} +function fmtDuration(ms) { + const t = Math.max(0, Math.round(ms / 1000)); + const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = t % 60; + return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s}s` : `${s}s`; +} +function fmtTime(iso) { + try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return ""; } +} +function fmtDay(t) { + const d = new Date(t), today = new Date(); + const yesterday = new Date(); yesterday.setDate(today.getDate() - 1); + if (d.toDateString() === today.toDateString()) return "Today"; + if (d.toDateString() === yesterday.toDateString()) return "Yesterday"; + return d.toLocaleDateString([], { year: "numeric", month: "short", day: "numeric" }); +} +function timeSince(epoch) { + if (!epoch) return ""; + const s = Math.round((Date.now() - epoch) / 1000); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 48) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + +// --- Session picker --- + +let pickerFocusIdx = 0; +let pickerFiltered = []; + +function openPicker() { + $("picker-overlay").classList.add("open"); + $("picker-search").value = ""; + $("picker-search").focus(); + renderPicker(); +} + +function closePicker() { $("picker-overlay").classList.remove("open"); } + +function renderPicker() { + const search = $("picker-search").value.toLowerCase(); + pickerFiltered = sortedSessions(state?.sessions).filter((s) => !search || s.name?.toLowerCase().includes(search) || s.id.toLowerCase().includes(search)); + pickerFocusIdx = Math.min(pickerFocusIdx, Math.max(0, pickerFiltered.length - 1)); + const list = $("picker-list"); + if (!pickerFiltered.length) { list.innerHTML = '
    No sessions found
    '; return; } + list.innerHTML = pickerFiltered.map((s, i) => `
    + ${statusDot(s.status)} + ${esc(s.name || s.id)} + ${esc(s.status)} + ${timeSince(s.last_active_at ?? s.created_at)} +
    `).join(""); + list.querySelector(".focused")?.scrollIntoView({ block: "nearest" }); +} + +async function selectSession(id) { + closePicker(); + try { + const res = await fetch("/api/session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: id }) }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `HTTP ${res.status}`); + } + } catch (e) { + toast(`Switching session failed: ${e.message}`, "error"); + } +} + +$("session-name").addEventListener("click", openPicker); +$("picker-overlay").addEventListener("click", (e) => { if (e.target.id === "picker-overlay") closePicker(); }); +$("modal-overlay").addEventListener("mousedown", (e) => { if (e.target.id === "modal-overlay") closeModal(); }); +$("picker-search").addEventListener("input", () => { pickerFocusIdx = 0; renderPicker(); }); +$("picker-list").addEventListener("click", (e) => { + const item = e.target.closest(".picker-item"); + if (item) selectSession(pickerFiltered[parseInt(item.dataset.idx, 10)].id); +}); +$("picker-search").addEventListener("keydown", (e) => { + if (e.key === "Escape") { closePicker(); e.preventDefault(); return; } + if (e.key === "ArrowDown") { pickerFocusIdx = Math.min(pickerFocusIdx + 1, pickerFiltered.length - 1); renderPicker(); e.preventDefault(); return; } + if (e.key === "ArrowUp") { pickerFocusIdx = Math.max(pickerFocusIdx - 1, 0); renderPicker(); e.preventDefault(); return; } + if (e.key === "Enter" && pickerFiltered[pickerFocusIdx] && !e.isComposing) { selectSession(pickerFiltered[pickerFocusIdx].id); e.preventDefault(); } +}); + +// --- Boot --- + +applyTheme(); +applyLayout(); +$("msg-show-system").checked = store.get("ma-show-system", false); +switchRightTab(store.get("ma-rtab", "preview")); +{ const t = store.get("ma-tab", "chat"); if (TABS.includes(t) && t !== "chat") switchTab(t); } +closePreview(); +renderTree(); +connectWs(); diff --git a/dashboard/files.ts b/dashboard/files.ts new file mode 100644 index 0000000..f4cece8 --- /dev/null +++ b/dashboard/files.ts @@ -0,0 +1,324 @@ +// ============================================================================ +// multiagents — Web Dashboard file access +// ============================================================================ +// Read-only view of a session's project directory for the explorer and the +// preview pane. Every path is resolved inside the project root (symlinks +// included), so the browser can never read outside it. +// ============================================================================ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { inflateRawSync } from "node:zlib"; +import type { BrokerClient } from "../shared/broker-client.ts"; +import { ApiError } from "./control.ts"; + +const SKIP_DIRS = new Set([".git", "node_modules", ".multiagents"]); +const SEARCH_LIMIT = 20_000; +const SCRIPTABLE = /html|svg|xml|javascript|ecmascript/i; + +export interface FsEntry { + name: string; + /** Path relative to the project root, "/"-separated */ + path: string; + type: "file" | "dir"; + size: number; + mtime: number; +} + +async function projectRoot(broker: BrokerClient, sessionId: string | null): Promise { + if (!sessionId) throw new ApiError("session_id is required"); + const session = await broker.getSession(sessionId).catch(() => null); + if (!session) throw new ApiError(`Session "${sessionId}" not found`, 404); + try { + return fs.realpathSync(session.project_dir); + } catch { + throw new ApiError(`Project directory ${session.project_dir} does not exist`, 404); + } +} + +function toRel(root: string, abs: string): string { + return path.relative(root, abs).split(path.sep).join("/"); +} + +function inside(root: string, abs: string): boolean { + return abs === root || abs.startsWith(root + path.sep); +} + +/** Absolute path of `rel` inside root; refuses anything (even via symlink) outside it. */ +export function resolveInRoot(root: string, rel: string): string { + const candidate = path.isAbsolute(rel) ? path.normalize(rel) : path.resolve(root, rel); + if (!inside(root, candidate)) throw new ApiError("Path is outside the project directory", 403); + let real: string; + try { + real = fs.realpathSync(candidate); + } catch { + throw new ApiError(`${rel || "."} does not exist`, 404); + } + if (!inside(root, real)) throw new ApiError("Path is outside the project directory", 403); + return real; +} + +export function listDir(root: string, rel: string): FsEntry[] { + const dir = resolveInRoot(root, rel); + if (!fs.statSync(dir).isDirectory()) throw new ApiError(`${rel} is not a directory`); + const entries: FsEntry[] = []; + for (const d of fs.readdirSync(dir, { withFileTypes: true })) { + if (d.name === ".git") continue; + const abs = path.join(dir, d.name); + let st: fs.Stats; + try { + st = fs.statSync(abs); + } catch { + continue; // dangling symlink + } + entries.push({ name: d.name, path: toRel(root, abs), type: st.isDirectory() ? "dir" : "file", size: st.size, mtime: st.mtimeMs }); + } + return entries.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1)); +} + +/** + * Find the file an agent meant: an exact path first (relative or absolute inside + * the root), else the shortest project path that ends with it ("parser.ts" → + * "src/lib/parser.ts"). + */ +export function findFile(root: string, ref: string): string | null { + const cleaned = ref.trim().replace(/^["'`(<]+|[)"'`>.,;:]+$/g, ""); + if (!cleaned) return null; + try { + const abs = resolveInRoot(root, cleaned); + if (fs.statSync(abs).isFile()) return toRel(root, abs); + } catch { /* not a direct hit */ } + if (path.isAbsolute(cleaned) && !inside(root, path.normalize(cleaned))) return null; + + const suffix = "/" + cleaned.replace(/^\.?\//, "").split(path.sep).join("/"); + let best: string | null = null; + let seen = 0; + const queue = [root]; + while (queue.length && seen < SEARCH_LIMIT) { + const dir = queue.shift()!; + let items: fs.Dirent[]; + try { + items = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const d of items) { + seen++; + const abs = path.join(dir, d.name); + if (d.isDirectory()) { + if (!SKIP_DIRS.has(d.name)) queue.push(abs); + } else if (("/" + toRel(root, abs)).endsWith(suffix)) { + const rel = toRel(root, abs); + if (!best || rel.length < best.length) best = rel; + } + } + } + return best && readable(root, best) ? best : null; +} + +/** A path found by name must pass the same checks as one asked for directly (no symlink escapes). */ +function readable(root: string, rel: string): boolean { + try { + return fs.statSync(resolveInRoot(root, rel)).isFile(); + } catch { + return false; + } +} + +/** Project files, walked once and reused briefly: one walk answers a whole batch of references. */ +const indexCache = new Map(); + +function projectFiles(root: string): string[] { + const cached = indexCache.get(root); + if (cached && Date.now() - cached.at < 5_000) return cached.files; + const files: string[] = []; + const queue = [root]; + while (queue.length && files.length < SEARCH_LIMIT) { + const dir = queue.shift()!; + let items: fs.Dirent[]; + try { + items = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const d of items) { + const abs = path.join(dir, d.name); + if (d.isDirectory()) { + if (!SKIP_DIRS.has(d.name)) queue.push(abs); + } else { + files.push(toRel(root, abs)); + } + } + } + indexCache.set(root, { at: Date.now(), files }); + return files; +} + +/** Resolve the file references found in chat messages: ref → project path, or null. */ +export function checkRefs(root: string, refs: string[]): Record { + const out: Record = {}; + let files: string[] | null = null; + for (const ref of refs.slice(0, 200)) { + const cleaned = ref.trim().replace(/^\.\//, ""); + let hit: string | null = null; + try { + const abs = resolveInRoot(root, cleaned); + if (fs.statSync(abs).isFile()) hit = toRel(root, abs); + } catch { /* not a direct path */ } + if (!hit && !path.isAbsolute(cleaned)) { + files ??= projectFiles(root); + const suffix = "/" + cleaned; + for (const f of files) { + if (("/" + f).endsWith(suffix) && (!hit || f.length < hit.length)) hit = f; + } + } + out[ref] = hit && readable(root, hit) ? hit : null; + } + return out; +} + +// --- .docx → HTML (paragraphs, headings, emphasis, lists, tables) --- + +/** Read one entry of a zip archive (stored or deflated). */ +export function readZipEntry(buf: Buffer, name: string): Buffer | null { + // End of central directory: scan back over a possible comment + let eocd = -1; + for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65_557); i--) { + if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; } + } + if (eocd < 0) return null; + const count = buf.readUInt16LE(eocd + 10); + let p = buf.readUInt32LE(eocd + 16); + for (let n = 0; n < count && p + 46 <= buf.length; n++) { + if (buf.readUInt32LE(p) !== 0x02014b50) return null; + const method = buf.readUInt16LE(p + 10); + const compressedSize = buf.readUInt32LE(p + 20); + const nameLen = buf.readUInt16LE(p + 28); + const extraLen = buf.readUInt16LE(p + 30); + const commentLen = buf.readUInt16LE(p + 32); + const localOffset = buf.readUInt32LE(p + 42); + const entryName = buf.toString("utf8", p + 46, p + 46 + nameLen); + if (entryName === name) { + const dataStart = localOffset + 30 + buf.readUInt16LE(localOffset + 26) + buf.readUInt16LE(localOffset + 28); + const data = buf.subarray(dataStart, dataStart + compressedSize); + if (method === 0) return Buffer.from(data); + if (method === 8) return inflateRawSync(data); + return null; + } + p += 46 + nameLen + extraLen + commentLen; + } + return null; +} + +function decodeXml(s: string): string { + return s + .replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'") + .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d))) + .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16))) + .replace(/&/g, "&"); +} + +function escHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function runsToHtml(xml: string): string { + let out = ""; + for (const run of xml.match(/][\s\S]*?<\/w:r>/g) ?? []) { + const props = run.match(/([\s\S]*?)<\/w:rPr>/)?.[1] ?? ""; + let text = ""; + for (const piece of run.match(/]*)?>[\s\S]*?<\/w:t>||||/g) ?? []) { + if (piece === "") text += "\t"; + else if (piece === "") text += "\n"; + else if (piece === "" || piece === "") text += "[image]"; + else text += decodeXml(piece.replace(/<[^>]+>/g, "")); + } + if (!text) continue; + let html = escHtml(text).replace(/\n/g, "
    "); + if (//.test(props)) html = `${html}`; + if (//.test(props)) html = `${html}`; + if (/${inner}`; + if (heading) return `${inner}`; + if (//.test(p)) return `
  • ${inner}
  • `; + return inner ? `

    ${inner}

    ` : ""; +} + +export function docxToHtml(buf: Buffer): string { + const xml = readZipEntry(buf, "word/document.xml")?.toString("utf8"); + if (!xml) throw new ApiError("Not a readable .docx file", 415); + const body = xml.match(/([\s\S]*)<\/w:body>/)?.[1] ?? xml; + const parts: string[] = []; + // Tables first (as blocks), paragraphs in between keep their order + for (const block of body.match(/[\s\S]*?<\/w:tbl>|][\s\S]*?<\/w:p>|/g) ?? []) { + if (block.startsWith("")) { + const rows = (block.match(/][\s\S]*?<\/w:tr>/g) ?? []).map((tr) => + "" + (tr.match(/[\s\S]*?<\/w:tc>/g) ?? []).map((tc) => + `${(tc.match(/][\s\S]*?<\/w:p>/g) ?? []).map(runsToHtml).join("
    ")}`).join("") + ""); + parts.push(`${rows.join("")}
    `); + } else { + parts.push(paragraphToHtml(block)); + } + } + return parts.join("\n").replace(/(?:
  • [\s\S]*?<\/li>\n?)+/g, (m) => `
      ${m}
    `); +} + +// --- HTTP --- + +/** GET /api/fs/?session_id=&path= */ +export async function handleFs(broker: BrokerClient, op: string, url: URL): Promise { + const root = await projectRoot(broker, url.searchParams.get("session_id")); + const rel = url.searchParams.get("path") ?? ""; + + switch (op) { + case "list": + return Response.json({ ok: true, result: { root, entries: listDir(root, rel) } }); + + case "resolve": { + const found = findFile(root, rel); + if (!found) throw new ApiError(`No file matching "${rel}" in the project`, 404); + const st = fs.statSync(path.join(root, found)); + return Response.json({ ok: true, result: { path: found, size: st.size, mtime: st.mtimeMs } }); + } + + case "check": { + const refs = url.searchParams.getAll("ref"); + return Response.json({ ok: true, result: checkRefs(root, refs) }); + } + + case "raw": { + const abs = resolveInRoot(root, rel); + if (!fs.statSync(abs).isFile()) throw new ApiError(`${rel} is not a file`); + const file = Bun.file(abs); + const name = path.basename(abs); + const type = file.type || "application/octet-stream"; + const headers: Record = { + "Content-Type": type, + "Content-Disposition": `${url.searchParams.get("download") ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(name)}`, + "X-Content-Type-Options": "nosniff", + "Cache-Control": "no-store", + }; + // A project file opened from the dashboard must not run scripts in its origin. + // (Not for PDFs: Chrome refuses to show a PDF in a sandboxed document.) + if (SCRIPTABLE.test(type)) headers["Content-Security-Policy"] = "sandbox; default-src 'none'; img-src data:; style-src 'unsafe-inline'"; + return new Response(file, { headers }); + } + + case "docx": { + const abs = resolveInRoot(root, rel); + return Response.json({ ok: true, result: { html: docxToHtml(fs.readFileSync(abs)) } }); + } + + default: + throw new ApiError(`Unknown file operation "${op}"`, 404); + } +} diff --git a/dashboard/index.html b/dashboard/index.html index cb6fe38..e04031a 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -4,398 +4,62 @@ multiagents control center - + +
    - -
    -
    - - - multiagents - connecting... ▾ + +
    +
    + +
    Mmultiagents
    + +
    -
    - - - - Broker - +
    + + + Broker + + + +
    -
    - -
    - -
    + +
    + + +
    -
    - + +
    No session
    @@ -404,1598 +68,159 @@
    - - +
    - -
    -
    Agents 0
    -
    Messages 0
    +
    Chat 0
    +
    Agents 0
    Plan -
    Knowledge 0
    -
    Files 0
    +
    Locks 0
    Stats
    - -
    -
    - -
    +
    + + +
    +
    + +
    +
    + +
    -
    - - - - -
    - + diff --git a/dashboard/server.ts b/dashboard/server.ts index 6f66bbd..9332a21 100644 --- a/dashboard/server.ts +++ b/dashboard/server.ts @@ -18,6 +18,7 @@ import { resolveBrokerPort, brokerUrl } from "../shared/broker-url.ts"; import type { Session, Slot, Peer, Message, GuardrailState, FileLock, FileOwnership } from "../shared/types.ts"; import type { PlanState } from "../shared/broker-client.ts"; import { configureRuntime, ensureBroker, startBackgroundLoops, shutdownRuntime, isManagedSession, managedSlotIds } from "../orchestrator/runtime.ts"; +import { handleFs } from "./files.ts"; import { handleControl, exportSession, awaitingHuman, ApiError, CONTROL_ACTIONS, HUMAN_ID, type ControlContext } from "./control.ts"; // --- Configuration --- @@ -273,6 +274,11 @@ function apiError(e: unknown): Response { const DASHBOARD_DIR = new URL(".", import.meta.url).pathname; const indexHtml = Bun.file(`${DASHBOARD_DIR}index.html`); +// The UI's own assets (a fixed list: nothing else in the directory is served) +const ASSETS: Record = { + "/app.js": "text/javascript; charset=utf-8", + "/styles.css": "text/css; charset=utf-8", +}; const server = Bun.serve({ port: WEB_DASHBOARD_PORT, @@ -327,6 +333,15 @@ const server = Bun.serve({ }); } + // API: project files for the explorer and preview (read-only, inside the project dir) + if (url.pathname.startsWith("/api/fs/") && req.method === "GET") { + try { + return await handleFs(broker, url.pathname.slice("/api/fs/".length), url); + } catch (e) { + return apiError(e); + } + } + // API: session export (download) if (url.pathname === "/api/session/export" && req.method === "GET") { const id = url.searchParams.get("session_id"); @@ -381,7 +396,13 @@ const server = Bun.serve({ // Serve index.html for root if (url.pathname === "/" || url.pathname === "/index.html") { return new Response(indexHtml, { - headers: { "Content-Type": "text/html; charset=utf-8" }, + headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" }, + }); + } + const assetType = ASSETS[url.pathname]; + if (assetType) { + return new Response(Bun.file(`${DASHBOARD_DIR}${url.pathname.slice(1)}`), { + headers: { "Content-Type": assetType, "Cache-Control": "no-cache" }, }); } diff --git a/dashboard/styles.css b/dashboard/styles.css new file mode 100644 index 0000000..98d5a6e --- /dev/null +++ b/dashboard/styles.css @@ -0,0 +1,572 @@ +/* ========================================================================== + multiagents control center — styles + Themes: html[data-theme="light" | "dark"], picked by app.js (system / light / dark) + ========================================================================== */ + +:root, +html[data-theme="light"] { + color-scheme: light; + --bg: #f5f6f8; + --surface: #ffffff; + --surface2: #f0f2f5; + --surface3: #e6e9ee; + --border: #dde1e7; + --border-strong: #c7cdd6; + --text: #1f2328; + --text-dim: #59636e; + --text-muted: #8c959f; + --accent: #2f6fde; + --accent-soft: rgba(47, 111, 222, 0.1); + --accent-text: #ffffff; + --green: #1a7f37; + --yellow: #9a6700; + --orange: #bc4c00; + --red: #cf222e; + --purple: #8250df; + --cyan: #0e7f86; + --bubble-me: #2f6fde; + --bubble-me-text: #ffffff; + --bubble-other: #ffffff; + --shadow: 0 1px 2px rgba(31, 35, 40, 0.06), 0 4px 16px rgba(31, 35, 40, 0.06); + --shadow-lg: 0 16px 48px rgba(31, 35, 40, 0.18); + --code-bg: #f3f4f6; + --overlay: rgba(31, 35, 40, 0.35); +} + +html[data-theme="dark"] { + color-scheme: dark; + --bg: #0f1218; + --surface: #161a22; + --surface2: #1c212b; + --surface3: #252b37; + --border: #2a303b; + --border-strong: #3a4250; + --text: #e6e9ef; + --text-dim: #9aa3b2; + --text-muted: #5f6878; + --accent: #5b9bff; + --accent-soft: rgba(91, 155, 255, 0.14); + --accent-text: #0f1218; + --green: #3fb950; + --yellow: #d29922; + --orange: #e07b39; + --red: #f85149; + --purple: #bc8cff; + --cyan: #39c5cf; + --bubble-me: #3d7be0; + --bubble-me-text: #ffffff; + --bubble-other: #1c212b; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.55); + --code-bg: #11151c; + --overlay: rgba(0, 0, 0, 0.55); +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html, body { height: 100%; } +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Helvetica, Arial, sans-serif; + background: var(--bg); + color: var(--text); + font-size: 14px; + line-height: 1.5; + overflow: hidden; + -webkit-font-smoothing: antialiased; +} +button, input, select, textarea { font-family: inherit; color: inherit; } +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +code, pre, .mono { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; } + +/* --- App shell --- */ +.app { display: flex; flex-direction: column; height: 100vh; } +.topbar { + height: 48px; flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; + padding: 0 12px; background: var(--surface); border-bottom: 1px solid var(--border); +} +.topbar-left, .topbar-right { display: flex; align-items: center; gap: 10px; min-width: 0; } +.brand { display: flex; align-items: center; gap: 8px; font-weight: 650; font-size: 15px; white-space: nowrap; } +.brand-mark { + width: 22px; height: 22px; border-radius: 6px; display: grid; place-items: center; + background: linear-gradient(135deg, var(--accent), var(--purple)); color: #fff; font-size: 12px; font-weight: 700; +} +.session-switch { + display: flex; align-items: center; gap: 6px; max-width: 340px; min-width: 0; + padding: 3px 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--surface2); + cursor: pointer; font-size: 13px; color: var(--text-dim); +} +.session-switch:hover { border-color: var(--accent); color: var(--text); } +.session-switch span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.topbar-stat { font-size: 12px; color: var(--text-dim); white-space: nowrap; } +.pill { + display: inline-flex; align-items: center; gap: 6px; padding: 2px 10px; border-radius: 999px; + border: 1px solid var(--border); background: var(--surface2); font-size: 12px; color: var(--text-dim); cursor: pointer; white-space: nowrap; +} +.pill:hover { border-color: var(--accent); color: var(--text); } + +.workspace { + flex: 1; min-height: 0; display: grid; + grid-template-columns: var(--left-w, 280px) 4px minmax(360px, 1fr) 4px var(--right-w, 440px); +} +.workspace.left-collapsed { grid-template-columns: 0 0 minmax(360px, 1fr) 4px var(--right-w, 440px); } +.workspace.right-collapsed { grid-template-columns: var(--left-w, 280px) 4px minmax(360px, 1fr) 0 0; } +.workspace.left-collapsed.right-collapsed { grid-template-columns: 0 0 1fr 0 0; } +.workspace.left-collapsed .pane.left, .workspace.right-collapsed .pane.right, +.workspace.left-collapsed [data-resize="left"], .workspace.right-collapsed [data-resize="right"] { display: none; } + +.pane { min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--surface); } +/* Fixed columns: hiding a pane must not shift the others into its track */ +.pane.left { grid-column: 1; } +[data-resize="left"] { grid-column: 2; } +.pane.center { grid-column: 3; } +[data-resize="right"] { grid-column: 4; } +.pane.right { grid-column: 5; } +.pane.center { background: var(--bg); } +.pane.left { border-right: 1px solid var(--border); } +.pane.right { border-left: 1px solid var(--border); } +.resizer { cursor: col-resize; background: transparent; position: relative; z-index: 5; } +.resizer:hover, .resizer.dragging { background: var(--accent-soft); } + +/* --- Side sections --- */ +.side-section { display: flex; flex-direction: column; min-height: 0; border-bottom: 1px solid var(--border); } +.side-section.grow { flex: 1; border-bottom: none; } +.side-section.collapsed .side-body { display: none; } +.side-header { + display: flex; align-items: center; gap: 6px; padding: 8px 10px 6px 12px; flex-shrink: 0; + font-size: 11px; font-weight: 650; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.06em; user-select: none; +} +.side-header .title { flex: 1; cursor: pointer; display: flex; align-items: center; gap: 4px; min-width: 0; } +.side-header .title .sub { text-transform: none; letter-spacing: 0; font-weight: 500; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.side-body { overflow-y: auto; min-height: 0; } +#sessions-section .side-body { max-height: 32vh; padding-bottom: 6px; } +#explorer-section .side-body { flex: 1; padding-bottom: 12px; } +.chev { display: inline-block; width: 12px; transition: transform 0.12s; color: var(--text-muted); } +.collapsed > .side-header .chev, .tree-row.closed .chev { transform: rotate(-90deg); } + +.session-row { + display: flex; align-items: center; gap: 8px; padding: 5px 10px 5px 14px; cursor: pointer; font-size: 13px; border-left: 2px solid transparent; +} +.session-row:hover { background: var(--surface2); } +.session-row.current { background: var(--accent-soft); border-left-color: var(--accent); } +.session-row .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.session-row .time { font-size: 11px; color: var(--text-muted); } +.session-row .icon-btn { visibility: hidden; } +.session-row:hover .icon-btn, .session-row.current .icon-btn { visibility: visible; } +.side-group { padding: 6px 14px 2px; font-size: 10px; font-weight: 650; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; } +.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; background: var(--text-muted); } +.dot.green { background: var(--green); } +.dot.yellow { background: var(--yellow); } +.dot.red { background: var(--red); } +.dot.glow { box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 0%, transparent), 0 0 6px var(--green); } + +/* --- Explorer tree --- */ +.tree { font-size: 13px; } +.tree-row { + display: flex; align-items: center; gap: 4px; height: 24px; padding-right: 8px; cursor: pointer; white-space: nowrap; position: relative; +} +.tree-row:hover { background: var(--surface2); } +.tree-row.selected { background: var(--accent-soft); } +.tree-row.selected .tree-name { color: var(--accent); font-weight: 550; } +.tree-row .tree-name { overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; } +.tree-row.dim .tree-name { color: var(--text-muted); } +.tree-row .tree-actions { display: none; gap: 2px; } +.tree-row:hover .tree-actions { display: flex; } +.tree-row .lock { font-size: 11px; color: var(--yellow); } +.ficon { width: 16px; height: 16px; flex-shrink: 0; display: inline-grid; place-items: center; font-size: 9px; font-weight: 700; border-radius: 3px; } +.ficon svg { width: 16px; height: 16px; } +.tree-empty { padding: 8px 14px; font-size: 12px; color: var(--text-muted); } + +/* --- Center: session bar, tabs --- */ +.session-bar { + display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; + padding: 10px 16px; background: var(--surface); border-bottom: 1px solid var(--border); +} +.session-info { min-width: 0; } +.session-title { font-size: 15px; font-weight: 650; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.session-meta { font-size: 12px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; } +.toolbar { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } + +.tabs { + display: flex; gap: 2px; padding: 0 12px; background: var(--surface); border-bottom: 1px solid var(--border); flex-shrink: 0; overflow-x: auto; +} +.tab { + padding: 8px 12px; cursor: pointer; color: var(--text-dim); border-bottom: 2px solid transparent; font-size: 13px; font-weight: 550; + display: flex; align-items: center; gap: 6px; user-select: none; white-space: nowrap; +} +.tab:hover { color: var(--text); } +.tab.active { color: var(--accent); border-bottom-color: var(--accent); } +.tab-badge { font-size: 11px; padding: 0 6px; border-radius: 10px; background: var(--surface2); color: var(--text-dim); min-width: 18px; text-align: center; } +.tab.active .tab-badge { background: var(--accent-soft); color: var(--accent); } + +.view { flex: 1; min-height: 0; overflow: auto; padding: 16px; } +.view.chat-view { padding: 0; display: flex; flex-direction: column; overflow: hidden; position: relative; } + +/* --- Banner --- */ +.banner { + margin: 10px 16px 0; padding: 10px 14px; border-radius: 10px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + border: 1px solid color-mix(in srgb, var(--yellow) 45%, transparent); background: color-mix(in srgb, var(--yellow) 10%, var(--surface)); +} +.banner-title { font-weight: 650; color: var(--yellow); } +.banner .toolbar { margin-left: auto; } + +/* --- Buttons --- */ +.btn { + display: inline-flex; align-items: center; gap: 5px; background: var(--surface); color: var(--text); + border: 1px solid var(--border); border-radius: 7px; padding: 4px 10px; font-size: 12.5px; font-weight: 550; cursor: pointer; + white-space: nowrap; line-height: 1.5; transition: background 0.12s, border-color 0.12s; +} +.btn:hover:not(:disabled) { border-color: var(--border-strong); background: var(--surface2); } +.btn:disabled { opacity: 0.45; cursor: not-allowed; } +.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn.primary:hover:not(:disabled) { background: color-mix(in srgb, var(--accent) 88%, #000); border-color: transparent; } +.btn.success { color: var(--green); border-color: color-mix(in srgb, var(--green) 40%, var(--border)); } +.btn.warn { color: var(--yellow); border-color: color-mix(in srgb, var(--yellow) 40%, var(--border)); } +.btn.danger { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, var(--border)); } +.btn.solid-danger { background: var(--red); border-color: var(--red); color: #fff; } +.btn.ghost { border-color: transparent; background: transparent; } +.btn.small { padding: 1px 8px; font-size: 11.5px; } +.btn.busy { opacity: 0.6; pointer-events: none; } +.icon-btn { + background: none; border: 1px solid transparent; color: var(--text-dim); cursor: pointer; border-radius: 6px; + min-width: 24px; height: 24px; padding: 0 4px; font-size: 13px; display: inline-grid; place-items: center; line-height: 1; +} +.icon-btn:hover { color: var(--text); background: var(--surface3); } +.icon-btn svg { width: 15px; height: 15px; } + +/* --- Badges --- */ +.badge { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 999px; font-size: 11px; font-weight: 550; white-space: nowrap; } +.badge-idle { background: var(--surface3); color: var(--text-dim); } +.badge-working { background: var(--accent-soft); color: var(--accent); } +.badge-review { background: color-mix(in srgb, var(--yellow) 15%, transparent); color: var(--yellow); } +.badge-feedback { background: color-mix(in srgb, var(--orange) 15%, transparent); color: var(--orange); } +.badge-approved, .badge-connected { background: color-mix(in srgb, var(--green) 14%, transparent); color: var(--green); } +.badge-released { background: var(--surface2); color: var(--text-muted); } +.badge-disconnected { background: color-mix(in srgb, var(--red) 12%, transparent); color: var(--red); } +.badge-paused { background: color-mix(in srgb, var(--yellow) 18%, transparent); color: var(--yellow); } +.badge-managed { background: color-mix(in srgb, var(--purple) 14%, transparent); color: var(--purple); } + +/* --- Chat --- */ +.chat-toolbar { + display: flex; align-items: center; gap: 10px; padding: 6px 16px; border-bottom: 1px solid var(--border); + background: var(--surface); font-size: 12px; color: var(--text-dim); flex-shrink: 0; +} +.chat-toolbar .spacer { flex: 1; } +.chat-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 20px 8px; position: relative; scroll-behavior: auto; } +.chat-empty { margin: auto; text-align: center; color: var(--text-dim); padding: 60px 20px; } +.day-sep { text-align: center; margin: 14px 0 8px; font-size: 11px; color: var(--text-muted); } +.day-sep span { background: var(--bg); padding: 0 10px; } + +.msg { display: flex; gap: 10px; margin-top: 14px; align-items: flex-start; max-width: 100%; } +.msg.cont { margin-top: 3px; } +.msg.cont .avatar { visibility: hidden; height: 0; } +.msg.cont .msg-head { display: none; } +.msg.me { flex-direction: row-reverse; } +.avatar { + width: 34px; height: 34px; border-radius: 10px; flex-shrink: 0; display: grid; place-items: center; + color: #fff; font-weight: 650; font-size: 13px; letter-spacing: -0.02em; user-select: none; +} +.msg-body { min-width: 0; max-width: min(720px, 78%); display: flex; flex-direction: column; align-items: flex-start; } +.msg.me .msg-body { align-items: flex-end; } +.msg-head { display: flex; align-items: baseline; gap: 6px; font-size: 12px; margin-bottom: 3px; color: var(--text-muted); flex-wrap: wrap; } +.msg.me .msg-head { flex-direction: row-reverse; } +.msg-name { font-weight: 650; color: var(--text); font-size: 12.5px; } +.msg-role { color: var(--text-dim); } +.msg-to { color: var(--text-muted); } +.bubble { + padding: 8px 12px; border-radius: 4px 14px 14px 14px; background: var(--bubble-other); border: 1px solid var(--border); + box-shadow: var(--shadow); word-break: break-word; overflow-wrap: anywhere; max-width: 100%; font-size: 13.5px; line-height: 1.55; +} +.msg.me .bubble { background: var(--bubble-me); color: var(--bubble-me-text); border-color: transparent; border-radius: 14px 4px 14px 14px; } +.msg.me .bubble a, .msg.me .bubble .file-link { color: #fff; text-decoration: underline; text-decoration-color: rgba(255,255,255,0.5); } +.msg.me .bubble code { background: rgba(255,255,255,0.18); color: #fff; } +.bubble.kind-task_complete, .bubble.kind-approval { border-left: 3px solid var(--green); } +.bubble.kind-review_request { border-left: 3px solid var(--yellow); } +.bubble.kind-feedback { border-left: 3px solid var(--orange); } +.bubble.kind-broadcast { border-left: 3px solid var(--cyan); } +.bubble.collapsed { max-height: 220px; overflow: hidden; position: relative; cursor: pointer; } +.bubble.collapsed::after { + content: "Show more"; position: absolute; left: 0; right: 0; bottom: 0; padding: 26px 12px 6px; font-size: 12px; font-weight: 600; + color: var(--accent); background: linear-gradient(transparent, var(--bubble-other) 65%); +} +.msg.me .bubble.collapsed::after { color: #fff; background: linear-gradient(transparent, var(--bubble-me) 65%); } +.kind-tag { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 0 6px; border-radius: 4px; } +.kind-tag.task_complete, .kind-tag.approval { color: var(--green); background: color-mix(in srgb, var(--green) 12%, transparent); } +.kind-tag.review_request { color: var(--yellow); background: color-mix(in srgb, var(--yellow) 14%, transparent); } +.kind-tag.feedback { color: var(--orange); background: color-mix(in srgb, var(--orange) 14%, transparent); } +.kind-tag.broadcast { color: var(--cyan); background: color-mix(in srgb, var(--cyan) 14%, transparent); } +.msg-time { font-size: 11px; color: var(--text-muted); } + +.sys-note { display: flex; justify-content: center; margin: 10px 0 4px; } +.sys-note > span { + max-width: 80%; font-size: 12px; color: var(--text-dim); background: var(--surface2); border: 1px solid var(--border); + padding: 2px 12px; border-radius: 999px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; +} +.sys-note > span.open { white-space: pre-wrap; border-radius: 10px; text-align: left; } + +.rich p { margin: 0 0 6px; } +.rich p:last-child { margin-bottom: 0; } +.rich ul, .rich ol { margin: 2px 0 6px 20px; } +.rich h1, .rich h2, .rich h3, .rich h4 { font-size: 1em; font-weight: 700; margin: 6px 0 4px; } +.rich code { font-size: 12px; background: var(--code-bg); border-radius: 4px; padding: 1px 5px; } +.rich pre { + background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; margin: 6px 0; + overflow-x: auto; font-size: 12px; line-height: 1.45; color: var(--text); +} +.msg.me .rich pre { background: rgba(0,0,0,0.18); border-color: transparent; color: #fff; } +.rich pre code { background: none; padding: 0; } +.rich blockquote { border-left: 3px solid var(--border-strong); padding-left: 10px; color: var(--text-dim); margin: 4px 0; } +.rich table { border-collapse: collapse; margin: 6px 0; font-size: 12.5px; } +.rich th, .rich td { border: 1px solid var(--border); padding: 3px 8px; text-align: left; } +.file-link { + color: var(--accent); cursor: pointer; text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 2px; +} +.file-link:hover { text-decoration-style: solid; } + +.jump-new { + position: absolute; left: 50%; transform: translateX(-50%); bottom: 112px; z-index: 4; + background: var(--accent); color: #fff; border: none; border-radius: 999px; padding: 4px 14px; font-size: 12px; font-weight: 600; + box-shadow: var(--shadow-lg); cursor: pointer; +} + +.composer { padding: 10px 16px 14px; background: var(--bg); flex-shrink: 0; } +.composer-box { + border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); padding: 8px 10px 8px 12px; + display: flex; flex-direction: column; gap: 6px; +} +.composer-box:focus-within { border-color: var(--accent); } +.composer-top { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); flex-wrap: wrap; } +.composer-top select { border: 1px solid var(--border); background: var(--surface2); border-radius: 999px; padding: 1px 8px; font-size: 12px; max-width: 280px; } +.composer-row { display: flex; gap: 8px; align-items: flex-end; } +.composer textarea { + flex: 1; border: none; outline: none; background: transparent; resize: none; font-size: 14px; line-height: 1.5; + min-height: 24px; max-height: 220px; padding: 2px 0; +} +.send-btn { width: 34px; height: 34px; border-radius: 10px; border: none; background: var(--accent); color: #fff; cursor: pointer; display: grid; place-items: center; flex-shrink: 0; } +.send-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.send-btn svg { width: 16px; height: 16px; } +.composer-hint { font-size: 11px; color: var(--text-muted); } + +/* --- Cards & generic panels --- */ +.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; margin-bottom: 12px; overflow: hidden; box-shadow: var(--shadow); } +.card-header { + padding: 10px 14px; font-weight: 650; font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; + border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; gap: 8px; +} +.card-body { padding: 12px 14px; } +.empty { text-align: center; padding: 40px 20px; color: var(--text-dim); } +.empty .btn { margin-top: 12px; } + +.agents-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); gap: 12px; } +.agent-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px; display: flex; flex-direction: column; gap: 6px; box-shadow: var(--shadow); } +.agent-card.approved { border-color: color-mix(in srgb, var(--green) 50%, var(--border)); } +.agent-head { display: flex; align-items: center; gap: 10px; } +.agent-head .avatar { width: 36px; height: 36px; } +.agent-title { flex: 1; min-width: 0; } +.agent-name { font-weight: 650; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.agent-role { font-size: 12px; color: var(--text-dim); } +.agent-badges { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } +.agent-ids { font-size: 11px; color: var(--text-muted); } +.agent-summary { font-size: 12px; color: var(--text-dim); border-top: 1px solid var(--border); padding-top: 8px; line-height: 1.45; } +.agent-actions { display: flex; gap: 4px; flex-wrap: wrap; margin-top: auto; padding-top: 6px; } + +.roster-item { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--border); } +.roster-item:hover { background: var(--surface2); } +.roster-item .avatar { width: 30px; height: 30px; font-size: 12px; border-radius: 9px; position: relative; } +.roster-item .avatar .status { position: absolute; right: -2px; bottom: -2px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid var(--surface); } +.roster-main { flex: 1; min-width: 0; } +.roster-name { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.roster-sub { font-size: 11.5px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* --- Plan / Knowledge / Locks / Stats --- */ +.plan-header { display: flex; align-items: center; gap: 12px; flex: 1; text-transform: none; letter-spacing: 0; } +.plan-title { font-weight: 650; font-size: 14px; color: var(--text); } +.progress-bar { flex: 1; max-width: 220px; height: 6px; background: var(--surface3); border-radius: 3px; overflow: hidden; } +.progress-fill { height: 100%; background: var(--green); border-radius: 3px; transition: width 0.3s; } +.plan-pct { font-size: 13px; color: var(--text-dim); font-weight: 650; } +.plan-item { display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: 13px; border-bottom: 1px solid var(--border); } +.plan-item:last-child { border-bottom: none; } +.plan-item .plan-label { flex: 1; cursor: text; min-width: 0; } +.plan-item.done .plan-label { opacity: 0.55; text-decoration: line-through; } +.plan-check { width: 20px; text-align: center; font-size: 14px; } +.plan-check.done { color: var(--green); } +.plan-check.in_progress { color: var(--accent); } +.plan-check.blocked { color: var(--red); } +.plan-check.pending { color: var(--text-muted); } +.plan-item select { font-size: 12px; padding: 1px 4px; } +.knowledge-item { padding: 10px 14px; border-bottom: 1px solid var(--border); display: flex; gap: 10px; } +.knowledge-item:last-child { border-bottom: none; } +.knowledge-main { flex: 1; min-width: 0; } +.knowledge-key { font-weight: 650; color: var(--accent); font-size: 13px; } +.knowledge-cat { font-size: 10px; padding: 1px 6px; border-radius: 4px; margin-left: 6px; background: color-mix(in srgb, var(--purple) 14%, transparent); color: var(--purple); text-transform: uppercase; font-weight: 650; } +.knowledge-value { font-size: 13px; margin-top: 4px; } +.knowledge-meta { font-size: 11px; color: var(--text-muted); margin-top: 4px; } +.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 12px; margin-bottom: 16px; } +.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px; box-shadow: var(--shadow); } +.stat-value { font-size: 22px; font-weight: 700; } +.stat-label { font-size: 12px; color: var(--text-dim); margin-top: 2px; } +.guardrail-row { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--border); flex-wrap: wrap; } +.guardrail-row:last-child { border-bottom: none; } +.guardrail-info { flex: 1; min-width: 200px; } +.guardrail-label { font-size: 13px; font-weight: 550; } +.guardrail-desc { font-size: 11px; color: var(--text-dim); } +.stepper { display: inline-flex; align-items: center; gap: 4px; flex-wrap: wrap; } +.stepper input { width: 64px; text-align: center; } +.table { width: 100%; border-collapse: collapse; font-size: 13px; } +.table th { text-align: left; padding: 8px 12px; color: var(--text-dim); font-weight: 650; font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 1px solid var(--border); } +.table td { padding: 6px 12px; border-bottom: 1px solid var(--border); vertical-align: middle; } +.table tr:last-child td { border-bottom: none; } +.table-wrap { overflow-x: auto; } + +/* --- Forms --- */ +input[type=text], input[type=number], select, textarea { + background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 7px; + padding: 5px 8px; font-size: 13px; outline: none; min-width: 0; +} +input:focus, select:focus, textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +textarea { resize: vertical; line-height: 1.45; } +.form-grid { display: grid; grid-template-columns: 140px 1fr; gap: 10px 12px; align-items: center; } +.form-grid > label { font-size: 12px; color: var(--text-dim); } +.form-grid textarea, .form-grid input, .form-grid select { width: 100%; } +.form-inline { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +.form-inline input[type=text] { flex: 1; min-width: 160px; } +.checkbox { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; cursor: pointer; } +.checkbox input { margin-top: 3px; accent-color: var(--accent); } +.checkbox small { display: block; color: var(--text-dim); font-size: 11.5px; } +.field-hint { font-size: 11.5px; color: var(--text-muted); } +.section-label { font-size: 11px; color: var(--text-dim); font-weight: 650; text-transform: uppercase; letter-spacing: 0.05em; margin: 14px 0 6px; display: flex; align-items: center; gap: 8px; } +.section-label:first-child { margin-top: 0; } +.team-agent { border: 1px solid var(--border); border-radius: 10px; padding: 10px; margin-bottom: 8px; background: var(--surface2); } +.team-agent-row { display: grid; grid-template-columns: 110px 1fr 1fr auto; gap: 8px; align-items: center; } +.team-agent textarea { width: 100%; margin-top: 8px; } + +/* --- Right pane: preview --- */ +.right-tabs { display: flex; align-items: center; gap: 2px; padding: 0 8px; border-bottom: 1px solid var(--border); flex-shrink: 0; height: 40px; } +.right-tabs .tab { padding: 9px 10px; } +.right-tabs .spacer { flex: 1; } +.preview-head { display: flex; align-items: center; gap: 6px; padding: 6px 8px 6px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0; min-height: 38px; } +.preview-path { flex: 1; min-width: 0; font-size: 12px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; } +.preview-path b { color: var(--text); font-weight: 600; } +.preview-body { flex: 1; min-height: 0; overflow: auto; position: relative; } +.preview-body.fill { overflow: hidden; } +.preview-body iframe { width: 100%; height: 100%; border: none; background: #fff; display: block; } +.preview-body.fill iframe.pdf { background: var(--surface2); } +.preview-empty { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 30px; color: var(--text-dim); gap: 6px; } +.preview-empty .big { font-size: 34px; opacity: 0.5; } +.preview-img { display: flex; align-items: center; justify-content: center; min-height: 100%; padding: 16px; + background-image: linear-gradient(45deg, var(--surface2) 25%, transparent 25%), linear-gradient(-45deg, var(--surface2) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, var(--surface2) 75%), linear-gradient(-45deg, transparent 75%, var(--surface2) 75%); + background-size: 16px 16px; background-position: 0 0, 0 8px, 8px -8px, -8px 0; } +.preview-img img { max-width: 100%; max-height: 100%; object-fit: contain; box-shadow: var(--shadow-lg); border-radius: 4px; } +.preview-media { padding: 20px; display: flex; justify-content: center; } +.preview-media video { max-width: 100%; border-radius: 8px; } +.code-view { font-size: 12px; line-height: 1.55; counter-reset: line; padding: 8px 0; tab-size: 4; } +.code-line { display: flex; white-space: pre; padding-right: 14px; } +.code-line:target, .code-line.hl { background: color-mix(in srgb, var(--yellow) 18%, transparent); } +.code-line .ln { width: 48px; flex-shrink: 0; text-align: right; padding-right: 12px; color: var(--text-muted); user-select: none; } +.code-wrap .code-line { white-space: pre-wrap; word-break: break-word; } +.doc-view { padding: 24px 28px; max-width: 820px; margin: 0 auto; font-size: 14px; line-height: 1.7; } +.doc-view h1 { font-size: 1.6em; margin: 0.6em 0 0.4em; } +.doc-view h2 { font-size: 1.3em; margin: 0.9em 0 0.4em; } +.doc-view h3 { font-size: 1.12em; margin: 0.8em 0 0.3em; } +.doc-view h4, .doc-view h5, .doc-view h6 { font-size: 1em; margin: 0.7em 0 0.3em; } +.doc-view p { margin: 0 0 0.75em; } +.doc-view ul, .doc-view ol { margin: 0 0 0.75em 1.5em; } +.doc-view table { border-collapse: collapse; margin: 0.8em 0; font-size: 13px; width: 100%; } +.doc-view th, .doc-view td { border: 1px solid var(--border); padding: 5px 8px; vertical-align: top; } +.doc-view th { background: var(--surface2); } +.doc-view pre { background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; overflow-x: auto; font-size: 12.5px; margin: 0 0 0.8em; } +.doc-view code { background: var(--code-bg); border-radius: 4px; padding: 1px 5px; font-size: 0.9em; } +.doc-view pre code { background: none; padding: 0; } +.doc-view blockquote { border-left: 3px solid var(--border-strong); padding-left: 12px; color: var(--text-dim); margin: 0 0 0.8em; } +.doc-view img { max-width: 100%; } +.doc-view hr { border: none; border-top: 1px solid var(--border); margin: 1.2em 0; } +.doc-page { background: var(--surface); } +html[data-theme="dark"] .doc-page { background: var(--surface); } +.csv-view { padding: 10px; } +.csv-view table { border-collapse: collapse; font-size: 12px; } +.csv-view td, .csv-view th { border: 1px solid var(--border); padding: 3px 8px; white-space: nowrap; max-width: 360px; overflow: hidden; text-overflow: ellipsis; } +.csv-view th { background: var(--surface2); position: sticky; top: 0; } +.preview-notice { padding: 6px 12px; font-size: 12px; color: var(--yellow); background: color-mix(in srgb, var(--yellow) 10%, transparent); border-bottom: 1px solid var(--border); } +.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; } +.seg button { border: none; background: transparent; padding: 1px 8px; font-size: 11.5px; cursor: pointer; color: var(--text-dim); } +.seg button.on { background: var(--accent-soft); color: var(--accent); font-weight: 600; } + +/* --- Overlays --- */ +.overlay { position: fixed; inset: 0; background: var(--overlay); z-index: 100; display: none; align-items: flex-start; justify-content: center; padding: 60px 16px 16px; } +.overlay.open { display: flex; } +.picker { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; width: 480px; max-width: 100%; max-height: 420px; display: flex; flex-direction: column; box-shadow: var(--shadow-lg); overflow: hidden; } +.picker-search { padding: 12px 14px; border: none !important; border-bottom: 1px solid var(--border) !important; border-radius: 0 !important; box-shadow: none !important; background: transparent; font-size: 14px; width: 100%; } +.picker-list { overflow-y: auto; flex: 1; padding: 4px 0; } +.picker-item { display: flex; align-items: center; gap: 10px; padding: 8px 14px; cursor: pointer; font-size: 13px; } +.picker-item:hover, .picker-item.focused { background: var(--surface2); } +.picker-item.active { box-shadow: inset 3px 0 0 var(--accent); } +.picker-item-name { flex: 1; font-weight: 550; } +.picker-item-id, .picker-item-time { font-size: 11px; color: var(--text-muted); } +.picker-empty { padding: 20px; text-align: center; color: var(--text-dim); font-size: 13px; } +.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; width: 560px; max-width: 100%; max-height: calc(100vh - 80px); display: flex; flex-direction: column; box-shadow: var(--shadow-lg); } +.modal.wide { width: 820px; } +.modal-header { padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.modal-title { font-weight: 650; font-size: 15px; } +.modal-sub { font-size: 12px; color: var(--text-dim); } +.modal-body { padding: 14px 16px; overflow-y: auto; flex: 1; } +.modal-body p { margin-bottom: 8px; } +.modal-body ul { margin: 0 0 8px 18px; font-size: 13px; } +.modal-footer { padding: 10px 16px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; align-items: center; } +.modal-error { color: var(--red); font-size: 12px; margin-right: auto; white-space: pre-wrap; } +.menu { position: fixed; z-index: 150; min-width: 200px; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow-lg); padding: 4px; display: none; } +.menu.open { display: block; } +.menu-item { padding: 6px 10px; font-size: 13px; cursor: pointer; display: flex; gap: 8px; align-items: center; border-radius: 6px; } +.menu-item:hover { background: var(--surface2); } +.menu-item.danger { color: var(--red); } +.menu-item.disabled { opacity: 0.4; pointer-events: none; } +.menu-item.checked::after { content: "✓"; margin-left: auto; color: var(--accent); font-weight: 700; } +.menu-sep { height: 1px; background: var(--border); margin: 4px 0; } +.menu-hint { font-size: 11px; color: var(--text-muted); margin-left: auto; } +.toasts { position: fixed; right: 16px; bottom: 16px; z-index: 200; display: flex; flex-direction: column; gap: 8px; max-width: min(420px, calc(100vw - 32px)); } +.toast { background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--accent); border-radius: 8px; padding: 8px 12px; font-size: 13px; box-shadow: var(--shadow-lg); white-space: pre-wrap; word-break: break-word; } +.toast.ok { border-left-color: var(--green); } +.toast.warn { border-left-color: var(--yellow); } +.toast.error { border-left-color: var(--red); } + +/* --- Utilities --- */ +.hidden { display: none !important; } +.flex { display: flex; } +.gap-2 { gap: 8px; } +.items-center { align-items: center; } +.dim { color: var(--text-dim); } +.muted { color: var(--text-muted); } +.only-narrow { display: none; } +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; border: 2px solid transparent; background-clip: padding-box; } +::-webkit-scrollbar-thumb:hover { background: var(--text-muted); background-clip: padding-box; border: 2px solid transparent; } + +/* --- Narrow screens: side panes become drawers --- */ +@media (max-width: 1100px) { + .workspace, .workspace.left-collapsed, .workspace.right-collapsed, .workspace.left-collapsed.right-collapsed { grid-template-columns: 1fr; } + .resizer { display: none !important; } + .pane.center { grid-column: 1; } + .pane.left, .pane.right { + display: flex !important; position: fixed; top: 48px; bottom: 0; z-index: 90; width: min(400px, 92vw); + transition: transform 0.2s; box-shadow: var(--shadow-lg); + } + .pane.left { left: 0; transform: translateX(-102%); } + .pane.right { right: 0; transform: translateX(102%); width: min(560px, 96vw); } + .workspace.left-open .pane.left, .workspace.right-open .pane.right { transform: none; } + .only-narrow { display: inline-grid; } +} +@media (max-width: 700px) { + .topbar { gap: 6px; padding: 0 8px; } + .topbar-left, .topbar-right { gap: 6px; } + .topbar-stat, #broker-label, .brand > span:last-child, #session-status-badge { display: none; } + .broker-pill { padding: 4px 6px; } + .session-bar { padding: 8px 12px; } + .session-bar .btn { padding: 3px 8px; } + .msg-body { max-width: 86%; } + .form-grid { grid-template-columns: 1fr; } + .team-agent-row { grid-template-columns: 1fr 1fr; } + .view { padding: 12px; } + .chat-scroll { padding: 12px 10px 8px; } + .session-switch { max-width: 150px; } +} diff --git a/docs/web-control-center.md b/docs/web-control-center.md index 60bb762..71e83c4 100644 --- a/docs/web-control-center.md +++ b/docs/web-control-center.md @@ -60,6 +60,21 @@ belongs to the named session. Human actions are sent as `human`. | Broker status | `GET /api/broker` | `BrokerClient` | `multiagents status` | | CLI availability | `GET /api/agents/available` | `detectAgent` (cached 5 min) | — | +## Project files (explorer and preview) + +Read-only, scoped to the session's `project_dir`, all `GET` with `session_id`: + +| Endpoint | Returns | +|---|---| +| `/api/fs/list?path=` | Directory entries (folders first, `.git` hidden) | +| `/api/fs/raw?path=[&download=1]` | The file, inline or as a download. HTML, SVG and XML get `Content-Security-Policy: sandbox` | +| `/api/fs/resolve?path=` | The project path for a reference (`parser.ts` → `src/lib/parser.ts`), with size and mtime | +| `/api/fs/check?ref=…&ref=…` | A batch of references from chat messages → project path or `null` | +| `/api/fs/docx?path=` | A `.docx` rendered to HTML (headings, emphasis, lists, tables) | + +Paths are resolved with `realpath`, so neither `..` nor a symlink can reach outside the +project. A file name only becomes a link in the chat once `check` has found it. + ## Lifecycle and the human gate `idle → working → done_pending_review → addressing_feedback → approved → released` diff --git a/tests/web-control.test.ts b/tests/web-control.test.ts index 8f8da20..fc04e76 100644 --- a/tests/web-control.test.ts +++ b/tests/web-control.test.ts @@ -329,3 +329,36 @@ describe("session ids", () => { expect(res.ok).toBe(false); }); }); + +describe("project files over HTTP", () => { + test("list, raw, resolve and check stay inside the project", async () => { + const proj = path.join(tmpDir, "proj"); + fs.mkdirSync(path.join(proj, "out"), { recursive: true }); + fs.writeFileSync(path.join(proj, "out", "result.csv"), "a,b\n1,2\n"); + fs.writeFileSync(path.join(proj, "page.html"), ""); + const q = (op: string, params: Record) => + fetch(`${DASHBOARD_URL}/api/fs/${op}?${new URLSearchParams({ session_id: SESSION, ...params })}`); + + const list = await (await q("list", { path: "" })).json() as any; + expect(list.result.entries.some((e: any) => e.name === "out" && e.type === "dir")).toBe(true); + + const raw = await q("raw", { path: "out/result.csv" }); + expect(await raw.text()).toBe("a,b\n1,2\n"); + + // An HTML file is sandboxed so it cannot script the dashboard + const html = await q("raw", { path: "page.html" }); + expect(html.headers.get("content-security-policy")).toContain("sandbox"); + + expect((await q("raw", { path: "../peers.db" })).status).toBe(403); + expect((await q("raw", { path: "nope.txt" })).status).toBe(404); + + const resolved = await (await q("resolve", { path: "result.csv" })).json() as any; + expect(resolved.result.path).toBe("out/result.csv"); + expect(resolved.result.size).toBe(8); + + const checked = await (await fetch(`${DASHBOARD_URL}/api/fs/check?session_id=${SESSION}&ref=result.csv&ref=ghost.md`)).json() as any; + expect(checked.result).toEqual({ "result.csv": "out/result.csv", "ghost.md": null }); + + expect((await fetch(`${DASHBOARD_URL}/api/fs/list?path=`)).status).toBe(400); + }); +}); diff --git a/tests/web-dashboard.test.ts b/tests/web-dashboard.test.ts index 8e0f6a5..61d91fc 100644 --- a/tests/web-dashboard.test.ts +++ b/tests/web-dashboard.test.ts @@ -98,7 +98,6 @@ describe("Web Dashboard — HTTP server", () => { expect(res.headers.get("content-type")).toContain("text/html"); const html = await res.text(); expect(html).toContain("multiagents"); - expect(html).toContain("connectWs"); }); test("serves index.html on /index.html", async () => { @@ -245,31 +244,36 @@ describe("Web Dashboard — WebSocket", () => { }); }); -describe("Web Dashboard — HTML content", () => { - test("contains all 6 tab panels", async () => { - const res = await fetch(DASHBOARD_URL); - const html = await res.text(); - expect(html).toContain('data-tab="agents"'); - expect(html).toContain('data-tab="messages"'); - expect(html).toContain('data-tab="plan"'); - expect(html).toContain('data-tab="knowledge"'); - expect(html).toContain('data-tab="files"'); - expect(html).toContain('data-tab="stats"'); +describe("Web Dashboard — UI assets", () => { + test("the page has the chat, the explorer and the preview", async () => { + const html = await (await fetch(DASHBOARD_URL)).text(); + for (const tab of ["chat", "agents", "plan", "knowledge", "files", "stats"]) expect(html).toContain(`data-tab="${tab}"`); + expect(html).toContain('id="explorer-tree"'); + expect(html).toContain('id="preview-body"'); + expect(html).toContain('src="/app.js"'); + expect(html).toContain('href="/styles.css"'); }); - test("contains WebSocket connection logic", async () => { - const res = await fetch(DASHBOARD_URL); - const html = await res.text(); - expect(html).toContain("new WebSocket"); - expect(html).toContain("connectWs"); - expect(html).toContain("ws.onmessage"); + test("serves the script with the WebSocket logic", async () => { + const res = await fetch(`${DASHBOARD_URL}/app.js`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("javascript"); + const js = await res.text(); + expect(js).toContain("new WebSocket"); + expect(js).toContain("connectWs"); + expect(js).toContain("ws.onmessage"); }); - test("contains CSS for dark theme", async () => { - const res = await fetch(DASHBOARD_URL); - const html = await res.text(); - expect(html).toContain("--bg:"); - expect(html).toContain("--surface:"); - expect(html).toContain("#0f1117"); + test("serves the stylesheet with light and dark themes", async () => { + const res = await fetch(`${DASHBOARD_URL}/styles.css`); + expect(res.headers.get("content-type")).toContain("text/css"); + const css = await res.text(); + expect(css).toContain('html[data-theme="light"]'); + expect(css).toContain('html[data-theme="dark"]'); + }); + + test("serves nothing else from the dashboard directory", async () => { + expect((await fetch(`${DASHBOARD_URL}/server.ts`)).status).toBe(404); + expect((await fetch(`${DASHBOARD_URL}/control.ts`)).status).toBe(404); }); }); diff --git a/tests/web-files.test.ts b/tests/web-files.test.ts new file mode 100644 index 0000000..8e76ef1 --- /dev/null +++ b/tests/web-files.test.ts @@ -0,0 +1,135 @@ +/** + * The dashboard's read-only file access: paths stay inside the project + * (symlinks included), agent references resolve to project files, and .docx + * files render as HTML. + */ + +import { test, expect, describe, beforeAll } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { deflateRawSync } from "node:zlib"; +import { checkRefs, docxToHtml, findFile, listDir, readZipEntry, resolveInRoot } from "../dashboard/files.ts"; + +let root = ""; +let outside = ""; + +beforeAll(() => { + root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "web-files-"))); + outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "web-files-outside-"))); + fs.mkdirSync(path.join(root, "src", "lib"), { recursive: true }); + fs.mkdirSync(path.join(root, "docs")); + fs.mkdirSync(path.join(root, ".git")); + fs.writeFileSync(path.join(root, "src", "lib", "parser.ts"), "export const x = 1;\n"); + fs.writeFileSync(path.join(root, "docs", "报告.md"), "# 报告\n"); + fs.writeFileSync(path.join(root, "README.md"), "hello\n"); + fs.writeFileSync(path.join(outside, "secret.txt"), "nope"); + fs.symlinkSync(path.join(outside, "secret.txt"), path.join(root, "escape.txt")); +}); + +describe("paths stay inside the project", () => { + test("relative paths resolve inside the root", () => { + expect(resolveInRoot(root, "src/lib/parser.ts")).toBe(path.join(root, "src", "lib", "parser.ts")); + }); + + test("traversal and absolute paths outside are refused", () => { + expect(() => resolveInRoot(root, "../" + path.basename(outside) + "/secret.txt")).toThrow("outside"); + expect(() => resolveInRoot(root, path.join(outside, "secret.txt"))).toThrow("outside"); + }); + + test("a symlink pointing outside is refused", () => { + expect(() => resolveInRoot(root, "escape.txt")).toThrow("outside"); + }); + + test("listing puts folders first and hides .git", () => { + const names = listDir(root, "").map((e) => `${e.type}:${e.name}`); + expect(names.slice(0, 2)).toEqual(["dir:docs", "dir:src"]); + expect(names).not.toContain("dir:.git"); + expect(names).toContain("file:README.md"); + }); +}); + +describe("file references from agents", () => { + test("a bare file name finds the file anywhere in the project", () => { + expect(findFile(root, "parser.ts")).toBe("src/lib/parser.ts"); + expect(findFile(root, "lib/parser.ts")).toBe("src/lib/parser.ts"); + expect(findFile(root, "`README.md`")).toBe("README.md"); + expect(findFile(root, path.join(root, "README.md"))).toBe("README.md"); + expect(findFile(root, "missing.ts")).toBeNull(); + expect(findFile(root, path.join(outside, "secret.txt"))).toBeNull(); + }); + + test("a batch of references is answered at once", () => { + expect(checkRefs(root, ["parser.ts", "./README.md", "报告.md", "Node.js", "escape.txt"])).toEqual({ + "parser.ts": "src/lib/parser.ts", + "./README.md": "README.md", + "报告.md": "docs/报告.md", + "Node.js": null, + "escape.txt": null, + }); + }); +}); + +/** A minimal zip archive (deflated entries) for the .docx tests. */ +function zip(entries: Record): Buffer { + const locals: Buffer[] = []; + const centrals: Buffer[] = []; + let offset = 0; + for (const [name, content] of Object.entries(entries)) { + const data = deflateRawSync(Buffer.from(content, "utf8")); + const nameBuf = Buffer.from(name, "utf8"); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(8, 8); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(Buffer.byteLength(content), 22); + local.writeUInt16LE(nameBuf.length, 26); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(8, 10); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(Buffer.byteLength(content), 24); + central.writeUInt16LE(nameBuf.length, 28); + central.writeUInt32LE(offset, 42); + locals.push(local, nameBuf, data); + centrals.push(central, nameBuf); + offset += 30 + nameBuf.length + data.length; + } + const centralBuf = Buffer.concat(centrals); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(Object.keys(entries).length, 8); + eocd.writeUInt16LE(Object.keys(entries).length, 10); + eocd.writeUInt32LE(centralBuf.length, 12); + eocd.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, centralBuf, eocd]); +} + +describe(".docx preview", () => { + const doc = ` + 食物过敏论文 + Methods + Bold and <plain> & safe + first item + A1B1 + `; + + test("zip entries are read back", () => { + const z = zip({ "a.txt": "alpha", "word/document.xml": doc }); + expect(readZipEntry(z, "a.txt")?.toString()).toBe("alpha"); + expect(readZipEntry(z, "missing")).toBeNull(); + }); + + test("headings, emphasis, lists and tables become HTML, text stays escaped", () => { + const html = docxToHtml(zip({ "word/document.xml": doc })); + expect(html).toContain("

    食物过敏论文

    "); + expect(html).toContain("

    Methods

    "); + expect(html).toContain("Bold and <plain> & safe"); + expect(html).toContain("
    • first item
    • "); + expect(html).toContain("
      A1B1
      "); + }); + + test("a file that is not a docx is refused", () => { + expect(() => docxToHtml(Buffer.from("not a zip"))).toThrow("docx"); + }); +}); From 0f9ef3d02bf9b4cc612845a267f605b3ddcfb3a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 04:31:04 +0000 Subject: [PATCH 04/24] Show each agent's steps, approve them, switch models and cap usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Activity: a live timeline of every agent's work in the dashboard — thinking, tool calls with their inputs, edits as diffs, commands with output, turn costs. orchestrator/activity.ts maps Claude stream-json, Codex app-server items and Gemini stream-json onto one shape, written to the broker (/activity/*) by the host that runs the agent. Approvals: each agent has an approval mode (bypass, auto, accept_edits, ask). Claude now runs with --permission-prompt-tool stdio, so its can_use_tool control requests reach the driver; Codex's approval requests are no longer auto-accepted when a handler is set. Both are stored in the broker (/approvals/*) and wait for a person, so any dashboard can answer: Allow, Always allow (the tool, or the shell program, for the rest of the agent's session) or Deny with a note. Team-protocol tools never ask. Models and effort: settings live in the slot snapshot, and the runtime syncs running drivers with them each delivery tick. Claude switches live (set_model, set_permission_mode); Codex picks them up at its next turn; Gemini and Claude's effort at the next start. Pausing a slot now also interrupts its running turn. Usage and limits: Claude's reported cost is metered per slot (cost_usd), Gemini's token stats are counted, and session config.limits (per agent or per session, tokens or USD) pause and interrupt an agent that reaches them. The dashboard gains the Activity tab, approval cards and banner, a Settings dialog per agent, and a Usage tab with limit locks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SP5EAuSkQZm3jj3PTTWDfN --- CLAUDE.md | 1 + README.md | 17 +- broker.ts | 136 +++++++++- dashboard/app.js | 489 +++++++++++++++++++++++++++++++--- dashboard/control.ts | 99 ++++++- dashboard/index.html | 19 +- dashboard/server.ts | 29 +- dashboard/styles.css | 82 ++++++ docs/web-control-center.md | 12 + orchestrator/activity.ts | 271 +++++++++++++++++++ orchestrator/claude-driver.ts | 177 +++++++++++- orchestrator/codex-driver.ts | 59 +++- orchestrator/delivery.ts | 27 +- orchestrator/launcher.ts | 57 +++- orchestrator/monitor.ts | 31 ++- orchestrator/recovery.ts | 10 +- orchestrator/runtime.ts | 220 ++++++++++++++- orchestrator/supervision.ts | 39 +++ shared/broker-client.ts | 30 +++ shared/types.ts | 61 +++++ tests/activity.test.ts | 80 ++++++ tests/claude-driver.test.ts | 70 +++++ tests/codex-driver.test.ts | 30 +++ tests/fakes/fake-claude.ts | 67 ++++- tests/web-control.test.ts | 76 ++++++ 25 files changed, 2108 insertions(+), 81 deletions(-) create mode 100644 orchestrator/activity.ts create mode 100644 tests/activity.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 61fa58c..809258f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ Peer discovery and messaging MCP channel for Claude Code instances. - `orchestrator/runtime.ts` — The orchestrator itself: launched processes and drivers, background supervision loops, and the tool implementations (`ORCHESTRATOR_TOOLS`, `callOrchestratorTool`). Hosted by `orchestrator-server.ts` (MCP stdio) and by `dashboard/server.ts`; each host supervises only the agents it launched. - `dashboard/control.ts` — Web control API (`POST /api/`). Every mutation names its `session_id` and checks the slot belongs to it; lifecycle actions call `callOrchestratorTool`, record edits go to the broker. Human actions use sender id `human`. A session with `config.human_gate` is not auto-ended while agents sit in `approved`. - `dashboard/files.ts` — Read-only `GET /api/fs/{list,raw,resolve,check,docx}` over the session's `project_dir`. Every path goes through `resolveInRoot` (realpath, so symlinks cannot escape); HTML/SVG are served with a sandbox CSP. `.docx` is rendered server-side (own zip reader, no dependency). UI: `dashboard/index.html` + `app.js` + `styles.css`, plain JS, themes via `html[data-theme]`. +- `orchestrator/activity.ts` — Turns Claude stream-json, Codex app-server notifications and Gemini stream-json into one activity timeline (broker `/activity/*`), and waits on tool approvals stored in the broker (`/approvals/*`, polled, so any host's dashboard can answer). Per-slot settings live in the slot snapshot (`model`, `effort`, `approval_mode`, `token_limit`); `runtime.ts` reconciles running drivers with them each delivery tick (Claude: `set_model` / `set_permission_mode` control requests; Codex: `driver.settings` for the next turn), interrupts the turn of a paused slot, and enforces session `config.limits` (`limitVerdict`). Claude runs with `--permission-prompt-tool stdio`; its `can_use_tool` control requests go to `onPermissionRequest` (queued until a handler is set, denied after 30s without one). - `orchestrator/supervision.ts` — Driver registry (one driver per slot), non-overlapping loop ticks, release and watchdog verdicts; pure enough to unit-test. - `orchestrator/monitor.ts` — Slot summaries and token accounting. Claude's `result.usage` is per turn (added as is); Codex's `thread/tokenUsage/updated.total` is cumulative (diffed). - `shared/types.ts` — Shared TypeScript types for broker API. diff --git a/README.md b/README.md index 3307385..2185680 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,19 @@ Web control center on `localhost:7900` with live WebSocket updates. Auto-opens i Light, dark, or follow the system: pick it from the theme button in the top bar. +**Watching and approving each step.** The **Activity** tab is a live timeline of what every agent does: its thinking, the files it reads, each edit as a diff, every command with its output, and the cost of each turn. Claude and Codex report every step; Gemini reports its tool calls. Each agent has an approval mode (**Settings** on its card, or when you add it): + +| Mode | Claude | Codex | +|---|---|---| +| Fully autonomous (default) | `bypassPermissions` | approval policy `never` | +| Auto review — the CLI's reviewer passes safe steps, risky ones come to you | `auto` | `on-request` | +| Auto-approve edits — commands and other tools ask | `acceptEdits` | `untrusted` | +| Ask me every step | `default` | `untrusted` | + +A step that needs you shows up as a card with the exact command or diff: **Allow**, **Always allow** (this tool, or this shell program, for the rest of the agent's session), or **Deny** with a note the agent reads. Team-protocol tools (`check_messages`, `send_message`…) never ask. Gemini runs unattended (yolo), so it is always fully autonomous. + +**Models, usage and limit locks.** The same Settings dialog switches an agent's model (an alias such as `opus`/`sonnet`, or a full name) and its effort. Claude switches live via `set_model`; Codex switches at its next turn; Gemini and Claude's effort switch at the agent's next start. The **Usage** tab shows tokens (input, output, cache reads) and Claude's reported cost per agent. There you can set limits per agent or for the whole session, in tokens or USD. An agent that reaches a limit is paused and its running turn is interrupted; raise the limit, then resume it. + The tabs in the center hold everything else: - **Sessions** (sidebar): New Team, empty session, open, pause/resume, relaunch disconnected agents, export JSON, archive, end, delete (typed confirmation) @@ -234,7 +247,8 @@ The tabs in the center hold everything else: - **Plan**: add, edit, reassign, change status (pending / in progress / blocked / done), delete - **Knowledge**: add, edit, delete entries by category - **Locks**: locks with holder and expiry, force unlock, reserve a file for an agent, assign ownership zones -- **Stats**: session metrics and guardrail steppers (changes logged as "Adjusted via web dashboard") +- **Activity**: the step-by-step timeline and pending approvals (see above) +- **Usage**: tokens and cost per agent, limit locks, and guardrail steppers (changes logged as "Adjusted via web dashboard") **Human gate.** A team started from the browser can wait for you once every agent is approved: instead of ending itself, the session shows *Waiting for human approval* with **Approve & Release** and **Send back for revision** (actionable feedback to a worker, which restarts the review loop). Toggle it per session with the `human gate` chip; MCP's `create_team` takes `human_gate: true`. @@ -304,6 +318,7 @@ multiagents/ ├── orchestrator/ │ ├── orchestrator-server.ts Orchestrator MCP server (stdio host of runtime.ts) │ ├── runtime.ts Orchestrator runtime: tools, agent processes, supervision loops (shared with the web dashboard) +│ ├── activity.ts Activity timeline from each CLI's stream; tool approvals through the broker │ ├── claude-driver.ts ClaudeDriver: long-lived `claude -p --input-format stream-json` session │ ├── codex-driver.ts CodexDriver: persistent codex app-server via JSON-RPC (steer/interrupt) │ ├── delivery.ts Hands unacked messages to drivers, acks on confirmed hand-off diff --git a/broker.ts b/broker.ts index 6952cf7..8a71aca 100755 --- a/broker.ts +++ b/broker.ts @@ -62,6 +62,9 @@ import type { KnowledgeListRequest, KnowledgeGetRequest, KnowledgeDeleteRequest, + ActivityAddRequest, + ActivityEntry, + ApprovalRequest, AddPlanItemRequest, EditPlanItemRequest, } from "./shared/types.ts"; @@ -230,6 +233,37 @@ db.run(` ) `); +db.run(` + CREATE TABLE IF NOT EXISTS activity ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + slot_id INTEGER NOT NULL, + kind TEXT NOT NULL, + title TEXT NOT NULL, + detail TEXT, + data TEXT, + ts INTEGER NOT NULL + ) +`); +db.run("CREATE INDEX IF NOT EXISTS idx_activity_session ON activity (session_id, id)"); + +db.run(` + CREATE TABLE IF NOT EXISTS approvals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + slot_id INTEGER NOT NULL, + tool TEXT NOT NULL, + title TEXT NOT NULL, + input TEXT, + status TEXT NOT NULL DEFAULT 'pending', + always INTEGER NOT NULL DEFAULT 0, + note TEXT, + decided_by TEXT, + created_at INTEGER NOT NULL, + decided_at INTEGER + ) +`); + db.run(` CREATE TABLE IF NOT EXISTS knowledge ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -271,6 +305,7 @@ for (const stmt of [ "ALTER TABLE slots ADD COLUMN input_tokens INTEGER DEFAULT 0", "ALTER TABLE slots ADD COLUMN output_tokens INTEGER DEFAULT 0", "ALTER TABLE slots ADD COLUMN cache_read_tokens INTEGER DEFAULT 0", + "ALTER TABLE slots ADD COLUMN cost_usd REAL DEFAULT 0", ]) { addColumn(stmt); } @@ -886,8 +921,10 @@ function handleDeleteSession(body: { id: string }): { ok: boolean; deleted: { sl db.run("DELETE FROM guardrail_overrides WHERE session_id = ?", [body.id]); db.run("DELETE FROM guardrail_events WHERE session_id = ?", [body.id]); - // Delete knowledge entries + // Delete knowledge entries, the activity timeline and approvals db.run("DELETE FROM knowledge WHERE session_id = ?", [body.id]); + db.run("DELETE FROM activity WHERE session_id = ?", [body.id]); + db.run("DELETE FROM approvals WHERE session_id = ?", [body.id]); db.run("DELETE FROM sessions WHERE id = ?", [body.id]); @@ -969,6 +1006,7 @@ function handleUpdateSlot(body: UpdateSlotRequest): Slot | null { if (body.input_tokens !== undefined) { fields.push("input_tokens = input_tokens + ?"); values.push(body.input_tokens); } if (body.output_tokens !== undefined) { fields.push("output_tokens = output_tokens + ?"); values.push(body.output_tokens); } if (body.cache_read_tokens !== undefined) { fields.push("cache_read_tokens = cache_read_tokens + ?"); values.push(body.cache_read_tokens); } + if (body.cost_usd !== undefined) { fields.push("cost_usd = COALESCE(cost_usd, 0) + ?"); values.push(body.cost_usd); } if (fields.length > 0) { values.push(body.id); @@ -1415,6 +1453,84 @@ function handleReleaseHeld(body: { session_id: string; slot_id: number }) { return { messages }; } +// --- Activity timeline --- + +const ACTIVITY_KEEP_PER_SLOT = 3000; +const ACTIVITY_TEXT_MAX = 20_000; + +function clip(s: unknown, max = ACTIVITY_TEXT_MAX): string | null { + if (s === undefined || s === null) return null; + const str = typeof s === "string" ? s : JSON.stringify(s); + return str.length > max ? str.slice(0, max) + `\n… (${str.length - max} more characters)` : str; +} + +function handleActivityAdd(body: ActivityAddRequest): { ok: boolean; added: number } { + if (!body.session_id || !Number.isInteger(body.slot_id)) throw new Error("session_id and slot_id required"); + const entries = Array.isArray(body.entries) ? body.entries.slice(0, 500) : []; + const insert = db.prepare("INSERT INTO activity (session_id, slot_id, kind, title, detail, data, ts) VALUES (?, ?, ?, ?, ?, ?, ?)"); + db.transaction(() => { + for (const e of entries) { + insert.run(body.session_id, body.slot_id, String(e.kind), clip(e.title, 500) ?? "", clip(e.detail), e.data === undefined ? null : clip(e.data, 60_000), e.ts ?? Date.now()); + } + // Keep the timeline bounded per agent + const cutoff = db.query("SELECT id FROM activity WHERE slot_id = ? ORDER BY id DESC LIMIT 1 OFFSET ?").get(body.slot_id, ACTIVITY_KEEP_PER_SLOT) as { id: number } | null; + if (cutoff) db.run("DELETE FROM activity WHERE slot_id = ? AND id <= ?", [body.slot_id, cutoff.id]); + })(); + return { ok: true, added: entries.length }; +} + +/** Oldest first; `after_id` pages forward, otherwise the latest `limit` entries. */ +function handleActivityList(body: { session_id: string; slot_id?: number; after_id?: number; limit?: number }): ActivityEntry[] { + const limit = Math.min(Math.max(body.limit ?? 300, 1), 2000); + const cond = ["session_id = ?"]; + const params: any[] = [body.session_id]; + if (body.slot_id !== undefined && body.slot_id !== null) { cond.push("slot_id = ?"); params.push(body.slot_id); } + if (body.after_id) { + cond.push("id > ?"); params.push(body.after_id); + return db.query(`SELECT * FROM activity WHERE ${cond.join(" AND ")} ORDER BY id ASC LIMIT ?`).all(...params, limit) as ActivityEntry[]; + } + const rows = db.query(`SELECT * FROM activity WHERE ${cond.join(" AND ")} ORDER BY id DESC LIMIT ?`).all(...params, limit) as ActivityEntry[]; + return rows.reverse(); +} + +// --- Approvals: a tool call waiting for a person --- + +function handleApprovalCreate(body: { session_id: string; slot_id: number; tool: string; title: string; input?: unknown }): ApprovalRequest { + if (!body.session_id || !Number.isInteger(body.slot_id) || !body.tool) throw new Error("session_id, slot_id and tool required"); + const now = Date.now(); + const res = db.run( + "INSERT INTO approvals (session_id, slot_id, tool, title, input, status, created_at) VALUES (?, ?, ?, ?, ?, 'pending', ?)", + [body.session_id, body.slot_id, body.tool, clip(body.title, 500) ?? body.tool, body.input === undefined ? null : clip(body.input, 60_000), now], + ); + return db.query("SELECT * FROM approvals WHERE id = ?").get(Number(res.lastInsertRowid)) as ApprovalRequest; +} + +function handleApprovalGet(body: { id: number }): ApprovalRequest | null { + return (db.query("SELECT * FROM approvals WHERE id = ?").get(body.id) as ApprovalRequest) ?? null; +} + +function handleApprovalList(body: { session_id: string; status?: string; limit?: number }): ApprovalRequest[] { + const limit = Math.min(Math.max(body.limit ?? 100, 1), 500); + if (body.status) { + return db.query("SELECT * FROM approvals WHERE session_id = ? AND status = ? ORDER BY id ASC LIMIT ?").all(body.session_id, body.status, limit) as ApprovalRequest[]; + } + return (db.query("SELECT * FROM approvals WHERE session_id = ? ORDER BY id DESC LIMIT ?").all(body.session_id, limit) as ApprovalRequest[]).reverse(); +} + +/** Decide a pending request; a request already decided or cancelled is not changed. */ +function handleApprovalDecide(body: { id: number; session_id: string; decision: "allow" | "deny" | "cancel"; note?: string; always?: boolean; decided_by?: string }): ApprovalRequest | null { + const status = body.decision === "allow" ? "allowed" : body.decision === "deny" ? "denied" : body.decision === "cancel" ? "cancelled" : null; + if (!status) throw new Error(`Invalid decision "${body.decision}"`); + const res = db.run( + "UPDATE approvals SET status = ?, always = ?, note = ?, decided_by = ?, decided_at = ? WHERE id = ? AND session_id = ? AND status = 'pending'", + [status, body.always ? 1 : 0, body.note ?? null, body.decided_by ?? null, Date.now(), body.id, body.session_id], + ); + const row = handleApprovalGet({ id: body.id }); + if (!row || row.session_id !== body.session_id) return null; + if (res.changes === 0 && body.decision !== "cancel") throw new Error(`Request ${body.id} is already ${row.status}`); + return row; +} + // --- Agent event --- function handleAgentEvent(body: { session_id: string; event_type: string; slot_id?: number; metadata?: unknown; data?: unknown }) { @@ -1548,6 +1664,24 @@ Bun.serve({ return Response.json(handleGetPlan(body as { session_id: string })); case "/plan/update-item": return Response.json(handleUpdatePlanItem(body as { item_id: number; status: string; session_id?: string })); + // --- Activity & approvals --- + case "/activity/add": + return Response.json(handleActivityAdd(body as ActivityAddRequest)); + case "/activity/list": + return Response.json(handleActivityList(body as { session_id: string; slot_id?: number; after_id?: number; limit?: number })); + case "/approvals/create": + return Response.json(handleApprovalCreate(body as { session_id: string; slot_id: number; tool: string; title: string; input?: unknown })); + case "/approvals/get": { + const row = handleApprovalGet(body as { id: number }); + return row ? Response.json(row) : Response.json({ error: "Approval not found" }, { status: 404 }); + } + case "/approvals/list": + return Response.json(handleApprovalList(body as { session_id: string; status?: string; limit?: number })); + case "/approvals/decide": { + const row = handleApprovalDecide(body as { id: number; session_id: string; decision: "allow" | "deny" | "cancel"; note?: string; always?: boolean; decided_by?: string }); + return row ? Response.json(row) : Response.json({ error: "Approval not found in session" }, { status: 404 }); + } + case "/plan/add-item": return Response.json(handleAddPlanItem(body as AddPlanItemRequest)); case "/plan/edit-item": { diff --git a/dashboard/app.js b/dashboard/app.js index 4c7468b..1f74434 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -225,11 +225,12 @@ function onSessionChanged() { resetKnowledgeForm(); closePreview(); explorerReset(); + activityReset(); } // --- Tabs --- -const TABS = ["chat", "agents", "plan", "knowledge", "files", "stats"]; +const TABS = ["chat", "activity", "agents", "plan", "knowledge", "files", "stats"]; function switchTab(tab) { activeTab = tab; @@ -287,6 +288,7 @@ function render(force = false) { renderSidebar(); renderSessionBar(); renderGateBanner(); + renderApprovals(); renderBadges(); refreshAgentSelects(); renderRoster(); @@ -299,6 +301,7 @@ function render(force = false) { case "knowledge": renderKnowledge(); break; case "files": renderFiles(); break; case "stats": renderStats(); break; + case "activity": renderActivity(); break; } } @@ -1142,6 +1145,7 @@ function refreshAgentSelects() { const opts = (list) => list.map((s) => ``).join(""); fillSelect("composer-to", `` + opts(live)); fillSelect("msg-filter-agent", `` + opts(state.slots)); + fillSelect("act-agent", `` + opts(state.slots)); fillSelect("plan-add-assignee", `` + opts(live)); fillSelect("lock-agent", opts(live)); fillSelect("own-agent", opts(live)); @@ -1283,11 +1287,13 @@ function renderAgents() { const paused = isPaused(slot); const reviewable = slot.task_state === "approved" || slot.task_state === "done_pending_review"; const id = slot.id; - const tokens = slot.input_tokens || slot.output_tokens ? `${fmtTokens(slot.input_tokens ?? 0)} in / ${fmtTokens(slot.output_tokens ?? 0)} out` : ""; + const tokens = usageLine(slot); const actions = released || archived ? `` : ` ${paused ? `` : ``} + + ${reviewable ? `` : ""} @@ -1303,6 +1309,7 @@ function renderAgents() { ${taskBadge(slot.task_state ?? "idle")} ${paused ? 'paused' : ""} ${managed.has(id) ? 'managed here' : ""} + ${settingsBadges(slot)}
    slot #${id} · peer ${esc(slot.peer_id ?? "—")}
    ${tokens} @@ -1343,6 +1350,8 @@ $("rpanel-team").addEventListener("click", (e) => { const items = [ { label: "Message", disabled: released, onClick: () => agentAction("message", slot) }, isPaused(slot) ? { label: "Resume", disabled: released, onClick: () => agentAction("resume", slot) } : { label: "Pause", disabled: released, onClick: () => agentAction("pause", slot) }, + { label: "Settings (model, approvals, limit)", disabled: released, onClick: () => agentAction("settings", slot) }, + { label: "Show activity", onClick: () => agentAction("activity", slot) }, { label: "Change role", disabled: released, onClick: () => agentAction("role", slot) }, { label: "Rename", disabled: released, onClick: () => agentAction("rename", slot) }, { label: "Send back for revision", disabled: released, onClick: () => agentAction("sendback", slot) }, @@ -1390,6 +1399,11 @@ async function agentAction(kind, slot, btn) { return; } case "role": return roleModal(sid, slot); + case "settings": return settingsModal(sid, slot); + case "activity": + $("act-agent").value = String(slot.id); + switchTab("activity"); + return; case "sendback": return sendBackModal(sid, slot.id); case "release": { const notApproved = slot.task_state !== "approved"; @@ -1637,54 +1651,93 @@ function renderStats() { const el = $("panel-stats"); if (busyRegion(el)) return; if (!state.session) { el.innerHTML = '
    No session selected
    '; return; } + const limits = state.limits ?? {}; const totalIn = state.slots.reduce((a, s) => a + (s.input_tokens ?? 0), 0); const totalOut = state.slots.reduce((a, s) => a + (s.output_tokens ?? 0), 0); + const totalCache = state.slots.reduce((a, s) => a + (s.cache_read_tokens ?? 0), 0); + const totalCost = state.slots.reduce((a, s) => a + (s.cost_usd ?? 0), 0); const cards = [ + { value: fmtTokens(totalIn + totalOut), label: "Tokens (input + output)", color: "var(--accent)", limit: limits.session_tokens, used: totalIn + totalOut, fmt: fmtTokens }, + { value: fmtUsd(totalCost), label: "Cost (reported by Claude)", color: "var(--green)", limit: limits.session_cost_usd, used: totalCost, fmt: fmtUsd }, + { value: fmtTokens(totalCache), label: "Cache reads", color: "var(--purple)" }, { value: state.slots.filter((s) => s.status === "connected").length, label: "Online agents", color: "var(--green)" }, - { value: state.slots.filter((s) => s.task_state === "working").length, label: "Working", color: "var(--accent)" }, - { value: state.slots.filter((s) => s.task_state === "approved").length, label: "Approved (not released)", color: "var(--green)" }, - { value: state.slots.filter((s) => s.task_state === "released").length, label: "Released", color: "var(--text-dim)" }, { value: state.messages.length, label: "Messages", color: "var(--cyan)" }, - { value: fmtTokens(totalIn), label: "Input tokens", color: "var(--purple)" }, - { value: fmtTokens(totalOut), label: "Output tokens", color: "var(--purple)" }, ]; - for (const g of state.guardrails.filter((g) => g.action === "monitor")) { - cards.push({ value: g.usage?.current ?? 0, label: `${g.label} (${g.unit})`, color: "var(--text)" }); - } - const enforced = state.guardrails.filter((g) => g.action !== "monitor"); - const guardrails = enforced.length ? `
    Guardrails changes are logged as "Adjusted via web dashboard"
    -
    ${enforced.map((g) => { - const u = g.usage ?? { current: 0, limit: g.current_value, percent: 0, status: "ok" }; - const color = u.status === "triggered" ? "var(--red)" : u.status === "warning" ? "var(--yellow)" : "var(--green)"; - const pct = Math.min(100, Math.round((u.percent ?? 0) * 100)); // broker reports a 0..1 ratio - return `
    -
    -
    ${esc(g.label)} ${g.is_overridden ? 'overridden' : ""}
    -
    ${esc(g.description ?? "")} · default ${g.default_value} ${esc(g.unit)} · action: ${esc(g.action)}
    -
    -
    usage ${u.current}/${u.limit}
    -
    - ${g.adjustable ? `
    - - - - - ${(g.suggested_increases ?? []).map((v) => ``).join("")} -
    ` : 'fixed'} -
    `; - }).join("")}
    ` : ""; - el.innerHTML = `
    ${cards.map((s) => `
    ${s.value}
    ${esc(s.label)}
    `).join("")}
    ${guardrails}`; -} + const cardsHtml = cards.map((c) => `
    ${c.value}
    ${esc(c.label)}
    + ${c.limit ? `
    ${limitBar(c.used, c.limit)}
    limit ${c.fmt(c.limit)}
    ` : ""}
    `).join(""); + + const rows = state.slots.map((slot) => { + const set = slotSettingsOf(slot); + const used = (slot.input_tokens ?? 0) + (slot.output_tokens ?? 0); + const limit = set.token_limit || limits.agent_tokens; + return ` +
    ${avatar(slotName(slot), slotColor(slot.id))}
    ${esc(slotName(slot))}
    ${esc(slot.agent_type)} · ${esc(set.model || "default model")}${set.effort ? ` · ${esc(set.effort)}` : ""}
    + ${fmtTokens(slot.input_tokens ?? 0)} + ${fmtTokens(slot.output_tokens ?? 0)} + ${fmtTokens(slot.cache_read_tokens ?? 0)} + ${slot.agent_type === "claude" ? fmtUsd(slot.cost_usd ?? 0) : '—'} + ${limit ? `${limitBar(used, limit)}
    ${fmtTokens(used)} / ${fmtTokens(limit)}${set.token_limit ? "" : " (session default)"}
    ` : 'no limit'} + ${set.limit_hit ? `
    paused: ${esc(set.limit_hit)}
    ` : ""} + + `; + }).join(""); + + const enforced = state.guardrails.filter((g) => g.action !== "monitor" && g.adjustable); + const guardrails = enforced.map((g) => `
    +
    ${esc(g.label)} ${g.is_overridden ? 'overridden' : ""}
    +
    ${esc(g.description ?? "")} · usage ${g.usage?.current ?? 0}/${g.usage?.limit ?? g.current_value} ${esc(g.unit)}
    +
    + + + + +
    +
    `).join(""); + + const lim = (k) => (limits[k] ? String(limits[k]) : ""); + el.innerHTML = `
    ${cardsHtml}
    +
    Usage per agent tokens as the CLIs report them; cost only where the CLI reports it
    +
    + ${rows || ''}
    AgentInputOutputCache readsCostLimit
    No agents yet
    +
    Limit locks
    +
    +
    + + + + +
    An agent that reaches a limit is paused and its running turn stops. Raise the limit, then resume it. Tokens = input + output. An agent's own limit (Settings) overrides the per-agent limit. +
    +
    +
    + ${guardrails ? `
    Guardrails
    ${guardrails}
    ` : ""}`; +} + +function limitBar(used, limit) { + const pct = limit ? Math.min(100, Math.round((used / limit) * 100)) : 0; + return `
    = 80 ? " warn" : ""}" title="${pct}%">
    `; +} + +$("panel-stats").addEventListener("submit", (e) => { + if (e.target.id !== "limits-form") return; + e.preventDefault(); + const sid = currentSid(); + if (sid == null) return; + const f = e.target; + const limits = Object.fromEntries(["agent_tokens", "agent_cost_usd", "session_tokens", "session_cost_usd"].map((k) => [k, f[k].value === "" ? null : Number(f[k].value)])); + document.activeElement?.blur(); + run(e.submitter, "Limits", () => api("session/limits", { session_id: sid, limits })); +}); $("panel-stats").addEventListener("click", (e) => { + const agentBtn = e.target.closest("[data-agent]"); + if (agentBtn) { const slot = slotById(parseInt(agentBtn.dataset.slot, 10)); if (slot) agentAction(agentBtn.dataset.agent, slot, agentBtn); return; } const row = e.target.closest("[data-guardrail]"); if (!row) return; const input = row.querySelector("[data-input]"); const step = e.target.closest("[data-step]"); if (step) { input.value = Math.max(0, Number(input.value) + Number(step.dataset.step)); input.focus(); return; } - const suggest = e.target.closest("[data-suggest]"); - if (suggest) input.value = suggest.dataset.suggest; - if (suggest || e.target.closest("[data-set]")) { + if (e.target.closest("[data-set]")) { const sid = currentSid(); if (sid == null) return; const value = Number(input.value); @@ -1695,6 +1748,347 @@ $("panel-stats").addEventListener("click", (e) => { } }); +// --- Agent settings: model, effort, approval mode, token limit --- + +const MODEL_SUGGESTIONS = { + claude: ["opus", "sonnet", "haiku", "fable", "claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"], + codex: ["gpt-5-codex", "gpt-5", "gpt-5-mini", "o3"], + gemini: ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite"], +}; +const EFFORT_OPTIONS = { claude: ["low", "medium", "high", "xhigh", "max"], codex: ["minimal", "low", "medium", "high"], gemini: [] }; +const APPROVAL_MODES = [ + ["bypass", "Fully autonomous", "Never asks. The agent reads, edits and runs commands on its own."], + ["auto", "Auto review", "The CLI's own reviewer approves safe steps; risky ones come to you. (Claude: auto mode · Codex: on-request)"], + ["accept_edits", "Auto-approve edits", "File edits go through; shell commands and other tools wait for you. (Claude; Codex asks for everything)"], + ["ask", "Ask me every step", "Every edit and command waits for your approval."], +]; +const MODE_LABEL = Object.fromEntries(APPROVAL_MODES.map(([v, l]) => [v, l])); + +function slotSettingsOf(slot) { + let snap = {}; + try { snap = JSON.parse(slot.context_snapshot ?? "{}") ?? {}; } catch {} + return { + model: typeof snap.model === "string" ? snap.model : "", + effort: typeof snap.effort === "string" ? snap.effort : "", + approval_mode: snap.approval_mode || "bypass", + token_limit: Number(snap.token_limit) > 0 ? Number(snap.token_limit) : 0, + limit_hit: typeof snap.limit_hit === "string" ? snap.limit_hit : "", + }; +} + +function settingsBadges(slot) { + const set = slotSettingsOf(slot); + return `${set.model ? `${esc(set.model)}` : ""} + ${set.approval_mode !== "bypass" ? `${esc(MODE_LABEL[set.approval_mode] ?? set.approval_mode)}` : ""} + ${set.limit_hit ? `limit reached` : ""}`; +} + +function usageLine(slot) { + const used = (slot.input_tokens ?? 0) + (slot.output_tokens ?? 0); + if (!used && !slot.cost_usd) return ""; + const set = slotSettingsOf(slot); + const limit = set.token_limit || state.limits?.agent_tokens; + return `
    ${fmtTokens(slot.input_tokens ?? 0)} in / ${fmtTokens(slot.output_tokens ?? 0)} out${slot.cost_usd ? ` · ${fmtUsd(slot.cost_usd)}` : ""}${limit ? ` · limit ${fmtTokens(limit)}` : ""}
    + ${limit ? `
    ${limitBar(used, limit)}
    ` : ""}`; +} + +function modelFields(type, values = {}) { + const efforts = EFFORT_OPTIONS[type] ?? []; + return `
    + ${(MODEL_SUGGESTIONS[type] ?? []).map((m) => ` +
    An alias (opus, sonnet…) or a full model name.${type === "gemini" ? " Applies when the agent next starts." : " Applies to a running agent within seconds."}
    + ${efforts.length + ? `` + : 'not available for this agent'}`; +} + +function approvalFields(type, current = "bypass") { + return `
    ${APPROVAL_MODES.map(([v, l, d]) => { + const disabled = type === "gemini" && v !== "bypass"; + return ``; + }).join("")}${type === "gemini" ? '
    Gemini agents run unattended (yolo); step approvals need Claude or Codex. Their steps still show in Activity.
    ' : ""}
    `; +} + +function settingsModal(sid, slot) { + const set = slotSettingsOf(slot); + openModal({ + title: `Settings · ${slotName(slot)}`, sub: `${sessionSub(sid)} · ${slot.agent_type}`, submitLabel: "Save", + body: `
    + ${modelFields(slot.agent_type, set)} +
    +
    Input + output tokens. When reached the agent is paused and its turn stops. Used so far: ${fmtTokens((slot.input_tokens ?? 0) + (slot.output_tokens ?? 0))}.
    +
    + +
    ${approvalFields(slot.agent_type, set.approval_mode)}
    `, + onSubmit: async (form) => { + const body = { + session_id: sid, slot_id: slot.id, + model: form.model.value.trim(), + approval_mode: form.querySelector("[name=approval_mode]:checked")?.value ?? "bypass", + token_limit: form.token_limit.value === "" ? null : Number(form.token_limit.value), + }; + if (form.effort) body.effort = form.effort.value; + const r = await api("agents/settings", body); + toast(r?.message ?? "Saved", "ok"); + }, + }); +} + +$("act-settings").addEventListener("click", () => { + const slot = slotById(parseInt($("act-agent").value, 10)) ?? liveSlots()[0]; + if (!slot) return toast("No agent selected", "warn"); + const sid = currentSid(); + if (sid != null) settingsModal(sid, slot); +}); + +// --- Approvals --- + +function approvalPreview(a) { + let input = {}; + try { input = JSON.parse(a.input ?? "{}") ?? {}; } catch {} + if (a.tool === "Bash" || a.tool === "shell") return `
    ${esc(input.command ?? "")}
    ${input.description ? `
    ${esc(input.description)}
    ` : ""}`; + if (a.tool === "Edit" || a.tool === "MultiEdit") return editDiff(input); + if (a.tool === "Write") return `
    ${fileRef(input.file_path ?? "")}
    ${(String(input.content ?? "")).split("\n").slice(0, 60).map((l) => `+${esc(l)}`).join("")}
    `; + if (a.tool === "apply_patch" && Array.isArray(input.changes)) return changesDiff(input.changes); + return `
    ${esc(JSON.stringify(input, null, 2))}
    `; +} + +function renderApprovals() { + const pending = state.approvals ?? []; + $("badge-activity").textContent = pending.length ? `${pending.length}!` : ""; + const banner = $("approval-banner"); + // On the Activity tab the cards themselves are the reminder + if (!pending.length || activeTab === "activity") { banner.className = "hidden"; banner.innerHTML = ""; } + else { + banner.className = "approval-banner"; + banner.innerHTML = `✋ ${pending.length} step${pending.length === 1 ? "" : "s"} waiting for your approval${esc(pending.slice(0, 3).map((a) => `${slotName(slotById(a.slot_id))}: ${a.title}`).join(" · "))}`; + } + const box = $("act-approvals"); + if (busyRegion(box)) return; + const sig = pending.map((a) => a.id).join(","); + if (box.dataset.sig === sig) return; + box.dataset.sig = sig; + box.innerHTML = pending.map((a) => { + const slot = slotById(a.slot_id); + return `
    +
    ${avatar(slotName(slot), slotColor(a.slot_id))} +
    ${esc(slotName(slot))} wants to run ${esc(a.tool)}
    ${esc(a.title)} · ${timeSince(a.created_at)}
    +
    ${approvalPreview(a)}
    +
    + + + + +
    +
    `; + }).join(""); + hydrateFileRefs(box); +} + +$("approval-banner").addEventListener("click", () => switchTab("activity")); +$("act-approvals").addEventListener("click", async (e) => { + const btn = e.target.closest("[data-decide]"); + if (!btn) return; + const sid = currentSid(); + if (sid == null) return; + const card = btn.closest("[data-approval]"); + const decision = btn.dataset.decide === "deny" ? "deny" : "allow"; + const r = await run(btn, "Approval", () => api("approvals/decide", { + session_id: sid, approval_id: parseInt(card.dataset.approval, 10), decision, + always: btn.dataset.decide === "always", note: card.querySelector("[data-note]").value.trim(), + })); + if (r) { card.remove(); delete $("act-approvals").dataset.sig; } +}); + +// --- Activity timeline --- + +const activity = { sid: null, entries: [], lastId: 0, filterSig: "", rendered: 0, polling: false }; + +function activityReset() { + Object.assign(activity, { sid: null, entries: [], lastId: 0, filterSig: "", rendered: 0 }); + $("act-feed").innerHTML = ""; +} + +async function pollActivity() { + if (activity.polling || !state?.session || document.hidden) return; + if (activeTab !== "activity") return; + const sid = state.sessionId; + if (activity.sid !== sid) { activityReset(); activity.sid = sid; } + activity.polling = true; + try { + const q = new URLSearchParams({ session_id: sid, limit: activity.lastId ? "1000" : "600" }); + if (activity.lastId) q.set("after_id", String(activity.lastId)); + const res = await fetch(`/api/activity?${q}`); + const data = await res.json(); + if (!data.ok || sid !== state?.sessionId) return; + if (data.result.length) { + activity.entries.push(...data.result); + activity.lastId = data.result[data.result.length - 1].id; + if (activity.entries.length > 3000) { activity.entries = activity.entries.slice(-2500); activity.filterSig = ""; } + renderActivity(); + } + } catch { /* retried next tick */ } finally { + activity.polling = false; + } +} +setInterval(pollActivity, 1500); + +const STEP_ICON = { thinking: "💭", tool_call: "🔧", command: "〉", file_change: "✎", approval: "✋", status: "•", error: "⚠" }; +const TOOL_ICON = { Read: "👁", Write: "📝", Edit: "✎", MultiEdit: "✎", Bash: "〉", shell: "〉", Grep: "🔍", Glob: "🔍", WebFetch: "🌐", WebSearch: "🌐", Task: "🤖", Agent: "🤖", TodoWrite: "☑" }; + +function parseData(e) { try { return JSON.parse(e.data ?? "null") ?? {}; } catch { return {}; } } + +function lineDiff(oldText, newText) { + const a = String(oldText ?? "").split("\n"), b = String(newText ?? "").split("\n"); + let pre = 0; + while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++; + let suf = 0; + while (suf < a.length - pre && suf < b.length - pre && a[a.length - 1 - suf] === b[b.length - 1 - suf]) suf++; + const ctx = (l) => ` ${esc(l)}`; + return [ + ...a.slice(Math.max(0, pre - 2), pre).map(ctx), + ...a.slice(pre, a.length - suf).map((l) => `-${esc(l)}`), + ...b.slice(pre, b.length - suf).map((l) => `+${esc(l)}`), + ...a.slice(a.length - suf, a.length - suf + 2).map(ctx), + ].join(""); +} + +function editDiff(input) { + const edits = Array.isArray(input.edits) ? input.edits : [{ old_string: input.old_string, new_string: input.new_string }]; + return `
    ${fileRef(input.file_path ?? "")}
    ${edits.map((ed) => `
    ${lineDiff(ed.old_string, ed.new_string)}
    `).join("")}`; +} + +function unifiedDiff(diff) { + return `
    ${String(diff ?? "").split("\n").map((l) =>
    +    l.startsWith("@@") ? `${esc(l)}`
    +    : l.startsWith("+") && !l.startsWith("+++") ? `${esc(l)}`
    +    : l.startsWith("-") && !l.startsWith("---") ? `${esc(l)}`
    +    : `${esc(l)}`).join("")}
    `; +} + +function changesDiff(changes) { + return changes.map((c) => `
    ${esc(c.kind ?? "update")} ${fileRef(c.path ?? "")}
    ${c.diff ? unifiedDiff(c.diff) : ""}`).join(""); +} + +function outputBlock(label, text, isErr) { + if (!text) return ""; + return `
    ${esc(label)}
    ${esc(text)}
    `; +} + +function callBody(tool, input) { + if (tool === "Edit" || tool === "MultiEdit") return editDiff(input); + if (tool === "Write") return `
    ${fileRef(input.file_path ?? "")}
    ${String(input.content ?? "").split("\n").slice(0, 200).map((l) => `+${esc(l)}`).join("")}
    `; + if (tool === "Bash") return `
    ${esc(input.command ?? "")}
    ${input.description ? `
    ${esc(input.description)}
    ` : ""}`; + if (tool === "Read") return `
    ${fileRef(input.file_path ?? "")}${input.offset ? ` from line ${esc(input.offset)}` : ""}
    `; + if (!input || !Object.keys(input).length) return ""; + return `
    ${esc(JSON.stringify(input, null, 2))}
    `; +} + +function stepTitleHtml(title) { + // File names in a step title become links like in the chat + return inline(title); +} + +/** One entry as HTML; tool results are appended to their call's element instead. */ +function stepHtml(e) { + const d = parseData(e); + const time = `${esc(fmtTime(new Date(e.ts).toISOString()))}`; + switch (e.kind) { + case "thinking": + return `
    ${STEP_ICON.thinking}${esc(truncate((e.detail ?? e.title).replace(/\s+/g, " "), 160))}${time} + ${e.detail ? `
    ${esc(e.detail)}
    ` : ""}
    `; + case "text": + return `
    ${renderMarkdown(e.detail ?? "", { chat: true })}
    `; + case "tool_call": { + const body = callBody(d.tool, d.input ?? {}); + return `
    ${TOOL_ICON[d.tool] ?? STEP_ICON.tool_call}${stepTitleHtml(e.title)}${time} +
    ${body}${e.detail ? outputBlock("Result", e.detail, d.status === "failed") : ""}
    `; + } + case "tool_result": + return `
    ${d.is_error ? "⚠" : "↳"}${esc(e.title)}${time}
    ${esc(e.detail ?? "")}
    `; + case "command": { + const failed = d.exit_code !== undefined && d.exit_code !== null && d.exit_code !== 0; + return `
    ${STEP_ICON.command}${esc(e.title)}${d.exit_code !== undefined && d.exit_code !== null ? ` exit ${esc(d.exit_code)}` : ""}${time} +
    ${outputBlock("Output", e.detail || "(no output)", failed)}
    `; + } + case "file_change": + return `
    ${STEP_ICON.file_change}${stepTitleHtml(e.title)}${time}
    ${changesDiff(d.changes ?? [])}
    `; + case "approval": + return `
    ✋ ${esc(e.title)}${e.detail ? ` — ${esc(e.detail)}` : ""}
    `; + case "turn_end": + return `
    ${esc(e.title)}
    `; + case "error": + return `
    ⚠ ${esc(e.title)}${e.detail ? ` — ${esc(e.detail)}` : ""}
    `; + default: + return `
    ${esc(e.title)}
    `; + } +} + +function activityVisible(e, filter, showTeam, showThinking) { + if (filter && e.slot_id !== filter) return false; + if (!showThinking && e.kind === "thinking") return false; + if (!showTeam && (e.kind === "tool_call" || e.kind === "tool_result") && parseData(e).team) return false; + return true; +} + +function renderActivity() { + const feed = $("act-feed"); + if (!state?.session) { feed.innerHTML = '
    No session selected.
    '; return; } + if (activity.sid !== state.sessionId) { void pollActivity(); return; } + const filter = $("act-agent").value ? parseInt($("act-agent").value, 10) : null; + const showTeam = $("act-team").checked, showThinking = $("act-thinking").checked; + const sig = `${filter}|${showTeam}|${showThinking}`; + const visible = activity.entries.filter((e) => activityVisible(e, filter, showTeam, showThinking)); + $("act-count").textContent = `${visible.length} step${visible.length === 1 ? "" : "s"}`; + if (!activity.entries.length) { + feed.innerHTML = '
    No activity yet. Steps appear here as agents think, read, edit and run commands.
    Claude and Codex agents launched by multiagents report every step; Gemini reports its tool calls.
    '; + activity.filterSig = ""; activity.rendered = 0; + return; + } + const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 80; + let fresh; + if (sig !== activity.filterSig) { + activity.filterSig = sig; + feed.innerHTML = ""; + activity.rendered = 0; + activity.lastSlot = null; + fresh = activity.entries; + } else { + fresh = activity.entries.slice(activity.rendered); + } + activity.rendered = activity.entries.length; + const frag = document.createElement("div"); + for (const e of fresh) { + if (!activityVisible(e, filter, showTeam, showThinking)) continue; + if (e.kind === "tool_result") { + const d = parseData(e); + const call = d.tool_use_id ? feed.querySelector(`[data-tuid="${CSS.escape(d.tool_use_id)}"] .step-body`) ?? frag.querySelector(`[data-tuid="${CSS.escape(d.tool_use_id)}"] .step-body`) : null; + if (call) { + call.insertAdjacentHTML("beforeend", outputBlock(d.is_error ? "Error" : "Result", e.detail || "(empty)", d.is_error)); + if (d.is_error) call.closest(".step").classList.add("err"); + continue; + } + } + const slot = slotById(e.slot_id); + const head = activity.lastSlot !== e.slot_id + ? `
    ${avatar(slotName(slot), slotColor(e.slot_id)).replace('class="avatar"', 'class="avatar" style="display:inline-grid;width:18px;height:18px;font-size:8px;border-radius:5px;vertical-align:-4px;background:' + slotColor(e.slot_id) + '"')} ${esc(slotName(slot))} ${slot?.role ? `· ${esc(slot.role)}` : ""}
    ` + : ""; + activity.lastSlot = e.slot_id; + frag.insertAdjacentHTML("beforeend", head + stepHtml(e)); + } + while (frag.firstChild) feed.appendChild(frag.firstChild); + hydrateFileRefs(feed); + if (atBottom || fresh === activity.entries) { feed.scrollTop = feed.scrollHeight; $("act-jump").classList.add("hidden"); } + else if (fresh.length) $("act-jump").classList.remove("hidden"); +} + +for (const id of ["act-agent", "act-team", "act-thinking"]) $(id).addEventListener("change", () => { activity.filterSig = ""; renderActivity(); }); +$("act-jump").addEventListener("click", () => { const f = $("act-feed"); f.scrollTop = f.scrollHeight; $("act-jump").classList.add("hidden"); }); +$("act-feed").addEventListener("scroll", () => { + const f = $("act-feed"); + if (f.scrollHeight - f.scrollTop - f.clientHeight < 80) $("act-jump").classList.add("hidden"); +}); + // --- Toolbar / global actions --- document.addEventListener("click", (e) => { @@ -1961,14 +2355,23 @@ async function addAgentModal(sid) { -
    ${roleDatalist()} +
  • + +
    ${modelFields(def)}${approvalFields(def)}
    + ${roleDatalist()}
    The agent runs under this dashboard (keep it open). Launching takes 15–60s before it connects.
    `, + setup: (form) => form.type.addEventListener("change", () => { + form.querySelector("[data-settings]").innerHTML = modelFields(form.type.value) + approvalFields(form.type.value); + }), onSubmit: async (form) => { const body = { session_id: sid, agent_type: form.type.value, name: form.name.value.trim(), role: form.role.value.trim(), role_description: form.desc.value.trim(), initial_task: form.task.value.trim(), file_ownership: form.own.value.split(",").map((p) => p.trim()).filter(Boolean), + model: form.model.value.trim(), + approval_mode: form.querySelector("[name=approval_mode]:checked")?.value ?? "bypass", }; + if (form.effort?.value) body.effort = form.effort.value; if (!body.name || !body.role || !body.initial_task) throw new Error("Name, role and task are required"); if (avail[body.agent_type] === false) throw new Error(`${body.agent_type} CLI is not installed on this machine`); const r = await api("agents/add", body); @@ -2014,6 +2417,10 @@ async function newTeamModal() { +
    + + +
    `; openModal({ @@ -2033,7 +2440,8 @@ async function newTeamModal() { - ${roleDatalist()}`, + ${roleDatalist()} + ${Object.values(MODEL_SUGGESTIONS).flat().map((m) => ``, setup: (form) => { const list = form.querySelector("[data-agents]"); form.querySelector("[data-add-agent]").addEventListener("click", () => list.insertAdjacentHTML("beforeend", agentRow({ agent_type: fallback, name: "", role: "", initial_task: "" }))); @@ -2044,6 +2452,8 @@ async function newTeamModal() { const agents = [...form.querySelectorAll(".team-agent")].map((row) => { const a = {}; row.querySelectorAll("[data-f]").forEach((f) => { a[f.dataset.f] = f.value.trim(); }); + if (!a.model) delete a.model; + if (a.agent_type === "gemini") a.approval_mode = "bypass"; return a; }); if (!agents.length) throw new Error("Add at least one agent"); @@ -2101,6 +2511,7 @@ function fmtTokens(n) { if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; return String(n); } +function fmtUsd(n) { return `$${(n ?? 0) < 1 ? (n ?? 0).toFixed(3) : (n ?? 0).toFixed(2)}`; } function fmtBytes(n) { if (n >= 1 << 30) return `${(n / (1 << 30)).toFixed(1)} GB`; if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`; diff --git a/dashboard/control.ts b/dashboard/control.ts index e05931d..5cc7a92 100644 --- a/dashboard/control.ts +++ b/dashboard/control.ts @@ -9,7 +9,7 @@ // ============================================================================ import type { BrokerClient } from "../shared/broker-client.ts"; -import type { AgentLaunchConfig, AgentType, KnowledgeCategory, Session, Slot } from "../shared/types.ts"; +import type { AgentLaunchConfig, AgentType, ApprovalMode, KnowledgeCategory, Session, Slot } from "../shared/types.ts"; import { safeJsonParse } from "../shared/utils.ts"; import { createSessionRecord, exportSessionData } from "../shared/session-ops.ts"; import { callOrchestratorTool, type ToolResult } from "../orchestrator/runtime.ts"; @@ -135,6 +135,7 @@ function parseAgentConfig(raw: unknown, index: number): AgentLaunchConfig { const ownership = Array.isArray(a.file_ownership) ? a.file_ownership.filter((p): p is string => typeof p === "string" && p.trim() !== "").map((p) => p.trim()) : []; + const settings = parseSettings(a, agentType, `Agent ${index + 1}: `); return { agent_type: agentType, name, @@ -142,9 +143,53 @@ function parseAgentConfig(raw: unknown, index: number): AgentLaunchConfig { role_description: str(a, "role_description", { optional: true }).trim() || role, initial_task: str(a, "initial_task", { optional: true }).trim(), ...(ownership.length ? { file_ownership: ownership } : {}), + ...(settings.model ? { model: settings.model } : {}), + ...(settings.effort ? { effort: settings.effort } : {}), + ...(settings.approval_mode ? { approval_mode: settings.approval_mode } : {}), }; } +const APPROVAL_MODES: ApprovalMode[] = ["bypass", "auto", "accept_edits", "ask"]; +const EFFORTS: Record = { + claude: ["low", "medium", "high", "xhigh", "max"], + codex: ["minimal", "low", "medium", "high"], + gemini: [], + custom: [], +}; + +/** Model, effort and approval mode from a request; "" clears a value (null). */ +function parseSettings(body: Body, agentType: AgentType, prefix = ""): { model?: string | null; effort?: string | null; approval_mode?: ApprovalMode } { + const out: { model?: string | null; effort?: string | null; approval_mode?: ApprovalMode } = {}; + if (body.model !== undefined) { + const model = body.model === null ? "" : str(body, "model", { allowEmpty: true, optional: true }).trim(); + if (model && !/^[\w.:\/\[\]@-]{1,100}$/.test(model)) throw new ApiError(`${prefix}"${model}" is not a model name`); + out.model = model || null; + } + if (body.effort !== undefined) { + const effort = body.effort === null ? "" : str(body, "effort", { allowEmpty: true, optional: true }).trim(); + if (effort && !EFFORTS[agentType].includes(effort)) { + throw new ApiError(`${prefix}${agentType} ${EFFORTS[agentType].length ? `effort must be one of ${EFFORTS[agentType].join(", ")}` : "has no effort setting"}`); + } + out.effort = effort || null; + } + if (body.approval_mode !== undefined && body.approval_mode !== null && body.approval_mode !== "") { + const mode = str(body, "approval_mode") as ApprovalMode; + if (!APPROVAL_MODES.includes(mode)) throw new ApiError(`${prefix}approval_mode must be one of ${APPROVAL_MODES.join(", ")}`); + if (agentType === "gemini" && mode !== "bypass") throw new ApiError(`${prefix}Gemini agents run unattended (yolo); step approvals need Claude or Codex`); + out.approval_mode = mode; + } + return out; +} + +function positiveOrNull(body: Body, key: string): number | null | undefined { + const v = body[key]; + if (v === undefined) return undefined; + if (v === null || v === "" || v === 0) return null; + const n = Number(v); + if (!Number.isFinite(n) || n < 0) throw new ApiError(`${key} must be a positive number`); + return n; +} + // --- Handlers --- const AVAILABILITY_TTL_MS = 5 * 60_000; @@ -325,6 +370,50 @@ const handlers: Record = { return { message: `Sent back to ${slot.display_name ?? `slot ${slot.id}`} (${result.task_state})` }; }, + /** Model, effort, approval mode and token limit; a running agent picks them up within seconds. */ + async "agents/settings"(ctx, body) { + const session = await requireSession(ctx, body); + const slot = await requireSlot(ctx, session, body); + const patch: Record = parseSettings(body, slot.agent_type); + const limit = positiveOrNull(body, "token_limit"); + if (limit !== undefined) patch.token_limit = limit === null ? null : Math.round(limit); + if (!Object.keys(patch).length) throw new ApiError("Nothing to change"); + await ctx.broker.updateSlot({ id: slot.id, context_patch: patch }); + const live = slot.agent_type === "gemini" && (patch.model !== undefined) + ? " The model applies when the agent next starts." + : slot.agent_type === "claude" && patch.effort !== undefined ? " Effort applies when the agent next starts." : ""; + return { message: `Updated ${slot.display_name ?? `slot ${slot.id}`}.${live}` }; + }, + + /** Usage limits: an agent over its limit, or every agent of a session over the session limit, is paused. */ + async "session/limits"(ctx, body) { + const session = await requireSession(ctx, body); + const raw = (body.limits ?? {}) as Body; + const limits: Record = {}; + for (const key of ["agent_tokens", "agent_cost_usd", "session_tokens", "session_cost_usd"]) { + const v = positiveOrNull(raw, key); + if (v) limits[key] = key.endsWith("tokens") ? Math.round(v) : v; + } + await ctx.broker.updateSession({ id: session.id, config_patch: { limits } }); + return { message: Object.keys(limits).length ? "Usage limits saved" : "Usage limits removed", limits }; + }, + + /** Answer an agent's tool call that waits for a person. */ + async "approvals/decide"(ctx, body) { + const session = await requireSession(ctx, body); + const decision = str(body, "decision"); + if (decision !== "allow" && decision !== "deny") throw new ApiError(`decision must be allow or deny`); + const row = await ctx.broker.decideApproval({ + id: int(body, "approval_id"), + session_id: session.id, + decision, + always: body.always === true, + note: str(body, "note", { optional: true }).trim() || undefined, + decided_by: HUMAN_ID, + }); + return { message: `${decision === "allow" ? (row.always ? "Always allowed" : "Approved") : "Denied"}: ${row.title}`, approval: row }; + }, + // ---- Messages ---- async "messages/send"(ctx, body) { @@ -456,6 +545,14 @@ export async function handleControl(ctx: ControlContext, action: string, body: B export const CONTROL_ACTIONS = Object.keys(handlers); +/** GET /api/activity?session_id=&slot_id=&after_id=&limit= — the agents' steps, oldest first. */ +export async function listActivity(ctx: ControlContext, url: URL): Promise { + const sessionId = url.searchParams.get("session_id"); + if (!sessionId) throw new ApiError("session_id is required"); + const num = (k: string) => (url.searchParams.get(k) ? Number(url.searchParams.get(k)) : undefined); + return await ctx.broker.listActivity({ session_id: sessionId, slot_id: num("slot_id"), after_id: num("after_id"), limit: num("limit") }); +} + /** GET /api/session/export — the full session as JSON. */ export async function exportSession(ctx: ControlContext, sessionId: string): Promise { const session = await ctx.broker.getSession(sessionId).catch(() => null); diff --git a/dashboard/index.html b/dashboard/index.html index e04031a..7f4828d 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -74,14 +74,16 @@ +
    Chat 0
    +
    Activity
    Agents 0
    Plan -
    Knowledge 0
    Locks 0
    -
    Stats
    +
    Usage
    @@ -111,6 +113,21 @@ + + + `); if (open) { if (explorer.children.has(entry.path)) walk(entry.path, depth + 1); - else rows.push(`
    Loading…
    `); + else rows.push(`
    ${tr("Loading…")}
    `); } } }; @@ -565,7 +602,7 @@ $("explorer-tree").addEventListener("click", async (e) => { explorer.children.set(p, await loadDir(sid, p)); } catch (err) { explorer.expanded.delete(p); - toast(`Cannot open ${p}: ${err.message}`, "error"); + toast(tr("Cannot open {path}: {error}", { path: p, error: err.message }), "error"); } } if (sid !== explorer.sid) return; @@ -612,7 +649,7 @@ async function openPreview(rel, line) { try { info = await api(`fs/resolve?session_id=${encodeURIComponent(sid)}&path=${encodeURIComponent(rel)}`); } catch (e) { - toast(`Cannot open ${rel}: ${e.message}`, "error"); + toast(tr("Cannot open {path}: {error}", { path: rel, error: e.message }), "error"); return; } Object.assign(preview, { sid, path: info.path, line: line ?? null, mtime: info.mtime, size: info.size, mode: null }); @@ -629,8 +666,8 @@ function closePreview() { const body = $("preview-body"); body.className = "preview-body"; body.innerHTML = `
    📄
    -
    No file open
    -
    Click a file in the explorer, or a file name an agent mentions in the chat.
    `; +
    ${tr("No file open")}
    +
    ${tr("Click a file in the explorer, or a file name an agent mentions in the chat.")}
    `; if (explorer.children.size) renderTree(); } @@ -677,7 +714,7 @@ async function renderPreview(keepScroll = false) { body.innerHTML = `
    ${esc(name)}
    `; break; case "pdf": - if (navigator.pdfViewerEnabled === false) { body.innerHTML = noPreview(name, "This browser cannot show PDFs inline."); break; } + if (navigator.pdfViewerEnabled === false) { body.innerHTML = noPreview(name, tr("This browser cannot show PDFs inline.")); break; } body.className = "preview-body fill"; body.innerHTML = ``; break; @@ -689,12 +726,12 @@ async function renderPreview(keepScroll = false) { break; case "docx": { const r = await api(`fs/docx?session_id=${encodeURIComponent(preview.sid)}&path=${encodeURIComponent(preview.path)}`); - body.innerHTML = `
    ${r.html || '

    The document has no text.

    '}
    `; + body.innerHTML = `
    ${r.html || `

    ${tr("The document has no text.")}

    `}
    `; break; } case "html": { const mode = preview.mode ?? "render"; - seg([["render", "Render"], ["source", "Source"]], mode, (v) => { preview.mode = v; renderPreview(); }); + seg([["render", tr("Render")], ["source", tr("Source")]], mode, (v) => { preview.mode = v; renderPreview(); }); if (mode === "render") { body.className = "preview-body fill"; body.innerHTML = ``; @@ -706,32 +743,32 @@ async function renderPreview(keepScroll = false) { } case "markdown": { const mode = preview.mode ?? "preview"; - seg([["preview", "Preview"], ["source", "Source"]], mode, (v) => { preview.mode = v; renderPreview(); }); + seg([["preview", tr("Preview")], ["source", tr("Source")]], mode, (v) => { preview.mode = v; renderPreview(); }); const t = await fetchText(); body.innerHTML = mode === "preview" - ? `${t.truncated ? '
    Large file — showing the first 2 MB.
    ' : ""}
    ${renderMarkdown(t.text ?? "", { baseDir: parts.join("/") })}
    ` + ? `${t.truncated ? `
    ${tr("Large file — showing the first 2 MB.")}
    ` : ""}
    ${renderMarkdown(t.text ?? "", { baseDir: parts.join("/") })}
    ` : codeView(t.text ?? "", t.truncated); hydrateFileRefs(body); break; } case "csv": { const mode = preview.mode ?? "table"; - seg([["table", "Table"], ["source", "Source"]], mode, (v) => { preview.mode = v; renderPreview(); }); + seg([["table", tr("Table")], ["source", tr("Source")]], mode, (v) => { preview.mode = v; renderPreview(); }); const t = await fetchText(); body.innerHTML = mode === "table" ? csvView(t.text ?? "", ext === "tsv" ? "\t" : ",") : codeView(t.text ?? "", t.truncated); break; } case "binary": - body.innerHTML = noPreview(name, "This file type has no inline preview."); + body.innerHTML = noPreview(name, tr("This file type has no inline preview.")); break; default: { - seg([["nowrap", "No wrap"], ["wrap", "Wrap"]], preview.wrap ? "wrap" : "nowrap", (v) => { preview.wrap = v === "wrap"; renderPreview(true); }); + seg([["nowrap", tr("No wrap")], ["wrap", tr("Wrap")]], preview.wrap ? "wrap" : "nowrap", (v) => { preview.wrap = v === "wrap"; renderPreview(true); }); const t = await fetchText(); - body.innerHTML = t.binary ? noPreview(name, "This looks like a binary file.") : codeView(t.text, t.truncated); + body.innerHTML = t.binary ? noPreview(name, tr("This looks like a binary file.")) : codeView(t.text, t.truncated); } } } catch (e) { - body.innerHTML = noPreview(name, `Preview failed: ${e.message}`); + body.innerHTML = noPreview(name, tr("Preview failed: {error}", { error: e.message })); } if (keepScroll) body.scrollTop = scrollTop; @@ -743,14 +780,14 @@ async function renderPreview(keepScroll = false) { function noPreview(name, why) { return `
    📦
    ${esc(name)}
    ${esc(why)}
    -
    `; + `; } function codeView(text, truncated) { const lines = text.replace(/\r\n?/g, "\n").split("\n"); if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop(); const shown = lines.slice(0, 20000); - return `${truncated || lines.length > shown.length ? '
    Large file — showing the beginning only.
    ' : ""} + return `${truncated || lines.length > shown.length ? `
    ${tr("Large file — showing the beginning only.")}
    ` : ""}
    ${shown.map((l, i) => `
    ${i + 1}${esc(l) || " "}
    `).join("")}
    `; } @@ -775,11 +812,11 @@ function parseCsv(text, sep) { function csvView(text, sep) { const rows = parseCsv(text, sep); - if (!rows.length) return '
    Empty file
    '; + if (!rows.length) return `
    ${tr("Empty file")}
    `; const [head, ...rest] = rows; return `
    ${head.map((h) => ``).join("")}${rest.slice(0, 1000).map((r) => `${r.map((c) => ``).join("")}`).join("")}
    ${esc(h)}
    ${esc(c)}
    - ${rows.length > 1000 ? '
    Showing the first 1000 rows.
    ' : ""}
    `; + ${rows.length > 1000 ? `
    ${tr("Showing the first 1000 rows.")}
    ` : ""}`; } $("preview-reload").addEventListener("click", () => preview.path && openPreview(preview.path, preview.line)); @@ -948,7 +985,7 @@ function hydrateFileRefs(root) { const ref = el.dataset.ref; const hit = fileRefCache.get(ref); if (hit && (hit.path || Date.now() - hit.at < 30_000)) { - if (hit.path) { el.classList.add("file-link"); el.dataset.path = hit.path; el.title = `Open ${hit.path}`; } + if (hit.path) { el.classList.add("file-link"); el.dataset.path = hit.path; el.title = tr("Open {path}", { path: hit.path }); } } else pendingRefs.add(ref); } if (pendingRefs.size && !refCheckTimer) refCheckTimer = setTimeout(checkPendingRefs, 150); @@ -1005,19 +1042,236 @@ function avatar(name, color, extra = "", me = false) { return `
    ${me ? PERSON_SVG : esc(initials(name))}${extra}
    `; } +// --- Avatars: the agent's official icon by default, or the user's own pick --- +// Icons: Simple Icons (CC0). OpenAI asked Simple Icons to remove its logo, so +// Codex gets a neutral terminal glyph; users can upload any image instead. + +const AGENT_ICONS = { + claude: { bg: "#D97757", svg: '' }, + gemini: { bg: "linear-gradient(135deg, #4285F4 0%, #8E75B2 60%, #D96570 100%)", svg: '' }, + codex: { bg: "#1f2328", svg: '' }, +}; +const AVATAR_COLORS = ["#2f6fde", "#8250df", "#1a7f37", "#bc4c00", "#cf222e", "#0e7f86", "#d4a72c", "#6e7781", "#D97757", "#1f2328"]; + +/** Per-browser avatar choices, keyed ":" (and "human" for you). */ +const avatarPrefs = { + all: store.get("ma-avatars", {}), + get(key) { return this.all[key] ?? null; }, + set(key, pref) { + const next = { ...this.all }; + if (pref) next[key] = pref; else delete next[key]; + try { localStorage.setItem("ma-avatars", JSON.stringify(next)); } catch { throw new Error(tr("This image is too large to store in the browser.")); } + this.all = next; + }, +}; + +function slotAvatarKey(slot) { return `${slot.session_id ?? state?.sessionId ?? ""}:${slot.id}`; } + +/** Avatar markup from a pref ({ kind: "icon" | "text" | "image" }) with a fallback. */ +function avatarHtml(pref, { type, name, color }, extra = "", attrs = "", cls = "") { + const p = pref ?? (AGENT_ICONS[type] ? { kind: "icon" } : { kind: "text" }); + if (p.kind === "image" && p.src) { + return `
    ${extra}
    `; + } + if (p.kind === "icon" && AGENT_ICONS[type]) { + return `
    ${AGENT_ICONS[type].svg}${extra}
    `; + } + if (p.kind === "person") return `
    ${PERSON_SVG}${extra}
    `; + const text = (p.text ?? "").trim() || initials(name); + return `
    ${esc([...text].slice(0, 2).join(""))}${extra}
    `; +} + +function slotAvatar(slot, extra = "", clickable = false, size = "") { + const attrs = clickable ? `data-avatar-slot="${slot.id}" title="${esc(tr("Change avatar"))}" role="button"` : ""; + return avatarHtml(avatarPrefs.get(slotAvatarKey(slot)), { type: slot.agent_type, name: slotName(slot), color: slotColor(slot.id) }, extra, attrs, `${clickable ? "clickable" : ""} ${size}`); +} + +function humanAvatar(clickable = false) { + const attrs = clickable ? `data-avatar-human title="${esc(tr("Change avatar"))}" role="button"` : ""; + return avatarHtml(avatarPrefs.get("human") ?? { kind: "person" }, { type: null, name: tr("You"), color: "var(--accent)" }, "", attrs, clickable ? "clickable" : ""); +} + +/** Read an image file and shrink it to fit max×max, as a data URL. */ +function readImage(file, max, type = "image/png", quality = 0.86) { + return new Promise((resolve, reject) => { + if (!file || !/^image\//.test(file.type)) return reject(new Error(tr("Could not read the image"))); + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + const scale = Math.min(1, max / Math.max(img.width, img.height)); + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(img.width * scale)); + canvas.height = Math.max(1, Math.round(img.height * scale)); + canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height); + URL.revokeObjectURL(url); + resolve(canvas.toDataURL(type, quality)); + }; + img.onerror = () => { URL.revokeObjectURL(url); reject(new Error(tr("Could not read the image"))); }; + img.src = url; + }); +} + +/** Avatar picker for an agent (slot) or for you (slot = null). */ +function avatarModal(slot) { + const key = slot ? slotAvatarKey(slot) : "human"; + const type = slot?.agent_type ?? null; + const base = slot ? { type, name: slotName(slot), color: slotColor(slot.id) } : { type: null, name: tr("You"), color: "var(--accent)" }; + const current = avatarPrefs.get(key); + let draft = current ?? (slot ? (AGENT_ICONS[type] ? { kind: "icon" } : { kind: "text" }) : { kind: "person" }); + const kinds = [ + ...(AGENT_ICONS[type] ? [["icon", "Official icon"]] : []), + ...(slot ? [] : [["person", "Icon"]]), + ["text", "Initials"], ["image", "Image"], + ]; + openModal({ + title: slot ? tr("Avatar · {name}", { name: slotName(slot) }) : "Your avatar", + sub: slot ? sessionSub(slot.session_id ?? state.sessionId) : undefined, submitLabel: "Save", + body: `
    +
    +
    +
    ${kinds.map(([k, l]) => ``).join("")}
    + +
    ${AVATAR_COLORS.map((c) => ``).join("")}
    +
    +
    +
    ${tr(slot ? "Applies to this agent in this session, in this browser." : "Customizations are saved in this browser only.")}${type === "codex" ? ` ${tr("OpenAI's icon is not bundled; upload it as an image if you like.")}` : ""}
    + ${current ? `` : ""} +
    `, + setup: (form) => { + const paint = () => { + form.querySelector("[data-preview]").innerHTML = avatarHtml(draft, base); + form.querySelectorAll("[data-kind]").forEach((b) => b.classList.toggle("primary", b.dataset.kind === draft.kind)); + form.querySelectorAll("[data-for]").forEach((el) => { + const f = el.dataset.for; + el.hidden = !(f === draft.kind || (f === "color" && (draft.kind === "text" || draft.kind === "person"))); + }); + form.querySelectorAll("[data-color]").forEach((b) => b.classList.toggle("on", b.dataset.color === draft.color)); + }; + form.avatar_text.value = draft.kind === "text" ? draft.text ?? "" : ""; + form.querySelector("[data-kinds]").addEventListener("click", (e) => { + const b = e.target.closest("[data-kind]"); + if (!b) return; + const prev = draft; + draft = { kind: b.dataset.kind, color: prev.color }; + if (draft.kind === "text") draft.text = form.avatar_text.value; + if (draft.kind === "image") draft.src = prev.kind === "image" ? prev.src : current?.kind === "image" ? current.src : null; + paint(); + }); + form.avatar_text.addEventListener("input", () => { draft = { ...draft, kind: "text", text: form.avatar_text.value }; paint(); }); + form.querySelector(".swatches").addEventListener("click", (e) => { + const b = e.target.closest("[data-color]"); + if (b) { draft = { ...draft, color: b.dataset.color }; paint(); } + }); + form.file.addEventListener("change", async () => { + try { draft = { kind: "image", src: await readImage(form.file.files[0], 160) }; paint(); } + catch (err) { toast(err.message, "error"); } + }); + form.querySelector("[data-reset]")?.addEventListener("click", () => { + avatarPrefs.set(key, null); + closeModal(); + rerenderAll(); + }); + paint(); + }, + onSubmit: async () => { + if (draft.kind === "image" && !draft.src) throw new Error(tr("Could not read the image")); + avatarPrefs.set(key, draft); + toast(tr("Avatar saved"), "ok"); + rerenderAll(); + }, + }); +} + +document.addEventListener("click", (e) => { + const el = e.target.closest("[data-avatar-slot], [data-avatar-human]"); + if (!el || !state) return; + e.stopPropagation(); + if (el.hasAttribute("data-avatar-human")) return avatarModal(null); + const slot = slotById(parseInt(el.dataset.avatarSlot, 10)); + if (slot) avatarModal(slot); +}, true); + +// --- Appearance: a background image under the whole page --- + +const APPEARANCE_DEFAULT = { image: null, imageOpacity: 0.7, panelOpacity: 0.75, blur: true }; + +function appearance() { return { ...APPEARANCE_DEFAULT, ...store.get("ma-appearance", {}) }; } + +function saveAppearance(next) { + try { localStorage.setItem("ma-appearance", JSON.stringify(next)); } + catch { throw new Error(tr("This image is too large to store in the browser.")); } +} + +function applyAppearance(a = appearance()) { + const root = document.documentElement; + const layer = $("app-bg"); + root.classList.toggle("has-bg", !!a.image); + root.classList.toggle("bg-blur", !!a.image && !!a.blur); + layer.style.backgroundImage = a.image ? `url("${a.image}")` : ""; + layer.style.opacity = String(a.imageOpacity); + root.style.setProperty("--panel-alpha", `${Math.round(a.panelOpacity * 100)}%`); +} + +function appearanceModal() { + const saved = appearance(); + let draft = { ...saved }; + const pct = (v) => `${Math.round(v * 100)}%`; + openModal({ + title: "Appearance", submitLabel: "Done", + body: `
    +
    + +
    +
    ${pct(draft.imageOpacity)}
    +
    ${pct(draft.panelOpacity)}
    + +
    ${humanAvatar().replace('class="avatar', 'class="avatar clickable')}${tr("Click the avatar to change it.")}
    +
    +
    ${tr("The image sits under the whole page. Lower the panel opacity to let more of it show through.")} ${tr("Customizations are saved in this browser only.")}
    + `, + setup: (form) => { + const sync = () => { + form.querySelector("[data-remove-bg]").disabled = !draft.image; + for (const k of ["imageOpacity", "panelOpacity"]) form.querySelector(`[data-out="${k}"]`).textContent = pct(draft[k]); + applyAppearance(draft); + }; + for (const k of ["imageOpacity", "panelOpacity"]) form[k].addEventListener("input", () => { draft[k] = Number(form[k].value); sync(); }); + form.bg_blur.addEventListener("change", () => { draft.blur = form.bg_blur.checked; sync(); }); + form.file.addEventListener("change", async () => { + try { draft.image = await readImage(form.file.files[0], 1920, "image/jpeg", 0.82); sync(); } + catch (err) { toast(err.message, "error"); } + }); + form.querySelector("[data-remove-bg]").addEventListener("click", () => { draft.image = null; sync(); }); + // Keep what was set here, then switch to the avatar picker + form.querySelector("[data-own-avatar]").addEventListener("click", () => { + try { saveAppearance(draft); } catch (err) { return toast(err.message, "error"); } + avatarModal(null); + }); + form.querySelector("[data-reset]").addEventListener("click", () => { + draft = { ...APPEARANCE_DEFAULT }; + form.imageOpacity.value = draft.imageOpacity; form.panelOpacity.value = draft.panelOpacity; form.bg_blur.checked = draft.blur; + sync(); + }); + sync(); + }, + onSubmit: async () => { saveAppearance(draft); }, + onCancel: () => applyAppearance(), + }); +} + function senderOf(m) { - if (m.from_id === "human") return { key: "human", name: "You", me: true, color: "var(--accent)" }; - if (m.from_id === "orchestrator" || m.from_id === "__orchestrator__") return { key: "orchestrator", name: "Orchestrator", color: "#6e7781", role: "system" }; + if (m.from_id === "human") return { key: "human", name: tr("You"), me: true, color: "var(--accent)" }; + if (m.from_id === "orchestrator" || m.from_id === "__orchestrator__") return { key: "orchestrator", name: tr("Orchestrator"), color: "#6e7781", role: tr("system") }; if (m.from_id === "cli") return { key: "cli", name: "CLI", color: "#6e7781" }; const slot = (m.from_slot_id != null ? slotById(m.from_slot_id) : null) ?? state.slots.find((s) => s.peer_id === m.from_id); - if (slot) return { key: `slot:${slot.id}`, name: slotName(slot), role: slot.role, color: slotColor(slot.id) }; + if (slot) return { key: `slot:${slot.id}`, name: slotName(slot), role: slot.role, color: slotColor(slot.id), slot }; return { key: `peer:${m.from_id}`, name: m.from_display_name ?? m.from_id ?? "?", color: "#6e7781" }; } function recipientOf(m) { if (m.to_slot_id != null) { const s = slotById(m.to_slot_id); if (s) return slotName(s); } - if (m.to_id === "human") return "You"; - if (m.to_id === "orchestrator" || m.to_id === "__orchestrator__") return "Orchestrator"; + if (m.to_id === "human") return tr("You"); + if (m.to_id === "orchestrator" || m.to_id === "__orchestrator__") return tr("Orchestrator"); return m.to_display_name ?? m.to_id ?? "?"; } @@ -1025,9 +1279,9 @@ function systemText(m) { const t = m.text ?? ""; try { const j = JSON.parse(t); - if (m.msg_type === "control" && j.action) return `${j.action}${j.reason ? ` — ${j.reason}` : ""}`; - if (m.msg_type === "role_assignment" && j.role) return `role set to ${j.role}`; - if (m.msg_type === "rename" && j.display_name) return `renamed to ${j.display_name}`; + if (m.msg_type === "control" && j.action) return `${tr(j.action)}${j.reason ? ` — ${tr(j.reason)}` : ""}`; + if (m.msg_type === "role_assignment" && j.role) return tr("role set to {role}", { role: j.role }); + if (m.msg_type === "rename" && j.display_name) return tr("renamed to {name}", { name: j.display_name }); } catch { /* plain text */ } return t.replace(/\s+/g, " "); } @@ -1065,14 +1319,14 @@ function renderChat(force) { lastChatSignature = sig; if (!state.session) { - feed.innerHTML = `
    ${state.brokerAlive ? "No session selected.
    " : "Broker is not running."}
    `; + feed.innerHTML = `
    ${state.brokerAlive ? `${tr("No session selected.")}
    ` : tr("Broker is not running.")}
    `; $("chat-count").textContent = ""; return; } const items = chatItems(); - $("chat-count").textContent = `${items.length} message${items.length === 1 ? "" : "s"}`; + $("chat-count").textContent = tr(items.length === 1 ? "{n} message" : "{n} messages", { n: items.length }); if (!items.length) { - feed.innerHTML = '
    No messages yet. Say something to the team below.
    '; + feed.innerHTML = `
    ${tr("No messages yet. Say something to the team below.")}
    `; return; } @@ -1088,7 +1342,7 @@ function renderChat(force) { } const sender = senderOf(m); if (isSystemMessage(m)) { - const target = to.size > 1 ? "everyone" : [...to][0]; + const target = to.size > 1 ? tr("everyone") : [...to][0]; html += `
    ${esc(sender.name)} → ${esc(target)}: ${esc(systemText(m))}
    `; lastSender = null; continue; @@ -1096,18 +1350,18 @@ function renderChat(force) { const cont = lastSender === sender.key && t - lastTime < 3 * 60_000 && to.size === 1; lastSender = sender.key; lastTime = t; - const toLabel = m.msg_type === "broadcast" || to.size > 2 ? "everyone" : [...to].join(", "); + const toLabel = m.msg_type === "broadcast" || to.size > 2 ? tr("everyone") : [...to].join(", "); const text = m.text ?? ""; const long = !expandedMessages.has(m.id) && (text.length > 1500 || text.split("\n").length > 24); const kind = m.msg_type !== "chat" ? m.msg_type : ""; html += `
    - ${avatar(sender.name, sender.color, "", sender.me)} + ${sender.slot ? slotAvatar(sender.slot) : sender.me ? humanAvatar() : avatar(sender.name, sender.color)}
    ${esc(sender.name)} ${sender.role ? `${esc(sender.role)}` : ""} → ${esc(toLabel)} - ${kind ? `${esc(kind.replace(/_/g, " "))}` : ""} + ${kind ? `${esc(tr(kind.replace(/_/g, " ")))}` : ""} ${esc(fmtTime(m.sent_at))}
    ${renderMarkdown(text, { chat: true })}
    @@ -1143,17 +1397,17 @@ $("msg-filter-agent").addEventListener("change", () => render(true)); function refreshAgentSelects() { const live = liveSlots(); const opts = (list) => list.map((s) => ``).join(""); - fillSelect("composer-to", `` + opts(live)); - fillSelect("msg-filter-agent", `` + opts(state.slots)); - fillSelect("act-agent", `` + opts(state.slots)); - fillSelect("plan-add-assignee", `` + opts(live)); + fillSelect("composer-to", `` + opts(live)); + fillSelect("msg-filter-agent", `` + opts(state.slots)); + fillSelect("act-agent", `` + opts(state.slots)); + fillSelect("plan-add-assignee", `` + opts(live)); fillSelect("lock-agent", opts(live)); fillSelect("own-agent", opts(live)); const to = $("composer-to").value; const slot = to === "all" ? null : slotById(parseInt(to, 10)); $("composer-target-hint").textContent = slot - ? `${TASK_LABEL[slot.task_state] ?? slot.task_state}${isPaused(slot) ? " · paused (held until resume)" : ""}` - : `${live.filter((s) => s.status === "connected" && !isPaused(s)).length} online`; + ? `${taskLabel(slot.task_state)}${isPaused(slot) ? tr(" · paused (held until resume)") : ""}` + : tr("{n} online", { n: live.filter((s) => s.status === "connected" && !isPaused(s)).length }); $("composer-send").disabled = !state.session || state.session.status === "archived"; } @@ -1197,7 +1451,7 @@ $("composer-text").addEventListener("keydown", (e) => { $("composer-to").addEventListener("change", refreshAgentSelects); $("composer-presets").addEventListener("click", (e) => { e.stopPropagation(); - openMenu($("composer-presets"), PRESETS.map((t) => ({ label: t, onClick: () => { $("composer-text").value = t; autosize(); $("composer-text").focus(); } }))); + openMenu($("composer-presets"), PRESETS.map((t) => ({ label: tr(t), onClick: () => { $("composer-text").value = tr(t); autosize(); $("composer-text").focus(); } }))); }); const PRESETS = [ @@ -1211,15 +1465,15 @@ const PRESETS = [ function renderSessionBar() { const s = state.session; - $("sb-name").textContent = s ? (s.name || s.id) : (state.sessionId ?? "No session selected"); + $("sb-name").textContent = s ? (s.name || s.id) : (state.sessionId ?? tr("No session selected")); const b = s ? sessionBadge() : null; $("sb-status").innerHTML = b ? `${b.text}` : ""; $("sb-gate").innerHTML = s - ? `human gate ${state.humanGate ? "on" : "off"}` + ? `${tr(state.humanGate ? "human gate on" : "human gate off")}` : ""; $("sb-meta").textContent = s - ? `${s.project_dir}${state.managed ? " · agents run under this dashboard" : ""}` - : "Create a team with + New Team, or pick a session on the left."; + ? `${s.project_dir}${state.managed ? tr(" · agents run under this dashboard") : ""}` + : tr("Create a team with + New Team, or pick a session on the left."); $("sb-meta").title = s ? `${s.id} · ${s.project_dir}` : ""; const has = !!s, archived = s?.status === "archived", paused = s?.status === "paused"; @@ -1235,11 +1489,11 @@ function renderGateBanner() { const el = $("gate-banner"); if (!state.awaitingHuman) { el.className = "hidden"; el.innerHTML = ""; return; } el.className = "banner"; - el.innerHTML = ` - Every agent is approved. Review the work, then release it or send it back. + el.innerHTML = ` + ${tr("Every agent is approved. Review the work, then release it or send it back.")} - - + + `; } @@ -1249,17 +1503,19 @@ const TASK_LABEL = { idle: "idle", working: "working", done_pending_review: "in review", addressing_feedback: "addressing feedback", approved: "approved", released: "released", }; +function taskLabel(state) { return tr(TASK_LABEL[state] ?? state); } + const TASK_CLASS = { idle: "badge-idle", working: "badge-working", done_pending_review: "badge-review", addressing_feedback: "badge-feedback", approved: "badge-approved", released: "badge-released", }; function taskBadge(taskState) { - return `${esc(TASK_LABEL[taskState] ?? taskState)}`; + return `${esc(taskLabel(taskState))}`; } function connBadge(status) { - return status === "connected" ? 'online' : 'offline'; + return status === "connected" ? `${tr("online")}` : `${tr("offline")}`; } function slotSummary(slot) { @@ -1272,11 +1528,11 @@ function renderAgents() { const el = $("panel-agents"); if (busyRegion(el)) return; if (!state.session) { - el.innerHTML = `
    ${state.brokerAlive ? "No session selected." : "Broker is not running."}
    `; + el.innerHTML = `
    ${state.brokerAlive ? tr("No session selected.") : tr("Broker is not running.")}
    `; return; } if (!state.slots.length) { - el.innerHTML = '
    No agents in this session
    '; + el.innerHTML = `
    ${tr("No agents in this session")}
    `; return; } const archived = state.session.status === "archived"; @@ -1289,29 +1545,29 @@ function renderAgents() { const id = slot.id; const tokens = usageLine(slot); const actions = released || archived - ? `` - : ` - ${paused ? `` : ``} - - - - - ${reviewable ? `` : ""} - - `; + ? `` + : ` + ${paused ? `` : ``} + + + + + ${reviewable ? `` : ""} + + `; return `
    - ${avatar(slotName(slot), slotColor(id))} -
    ${esc(slotName(slot))}
    ${esc(slot.role ?? "no role")} · ${esc(slot.agent_type)}
    + ${slotAvatar(slot, "", true)} +
    ${esc(slotName(slot))}
    ${esc(slot.role ?? tr("no role"))} · ${esc(slot.agent_type)}
    ${connBadge(slot.status)}
    ${taskBadge(slot.task_state ?? "idle")} - ${paused ? 'paused' : ""} - ${managed.has(id) ? 'managed here' : ""} + ${paused ? `${tr("paused")}` : ""} + ${managed.has(id) ? `${tr("managed here")}` : ""} ${settingsBadges(slot)}
    -
    slot #${id} · peer ${esc(slot.peer_id ?? "—")}
    +
    ${tr("slot #{id} · peer {peer}", { id, peer: esc(slot.peer_id ?? "—") })}
    ${tokens} ${summary ? `
    ${inline(truncate(summary, 260))}
    ` : ""}
    ${actions}
    @@ -1323,19 +1579,19 @@ function renderAgents() { function renderRoster() { const el = $("rpanel-team"); if ($("rpanel-team").classList.contains("hidden") || busyRegion(el)) return; - if (!state.session) { el.innerHTML = '
    No session selected
    '; return; } - if (!state.slots.length) { el.innerHTML = '
    No agents yet
    '; return; } + if (!state.session) { el.innerHTML = `
    ${tr("No session selected")}
    `; return; } + if (!state.slots.length) { el.innerHTML = `
    ${tr("No agents yet")}
    `; return; } const order = (s) => (s.task_state === "released" ? 2 : s.status === "connected" ? 0 : 1); el.innerHTML = [...state.slots].sort((a, b) => order(a) - order(b) || a.id - b.id).map((slot) => { const statusColor = isPaused(slot) ? "var(--yellow)" : slot.status === "connected" ? "var(--green)" : "var(--text-muted)"; const summary = slotSummary(slot); return `
    - ${avatar(slotName(slot), slotColor(slot.id), ``)} + ${slotAvatar(slot, ``)}
    ${esc(slotName(slot))} ${esc(slot.role ?? "")}
    -
    ${esc(TASK_LABEL[slot.task_state] ?? slot.task_state)}${isPaused(slot) ? " · paused" : ""}${summary ? ` · ${esc(truncate(summary, 80))}` : ""}
    +
    ${esc(taskLabel(slot.task_state))}${isPaused(slot) ? ` · ${tr("paused")}` : ""}${summary ? ` · ${esc(truncate(summary, 80))}` : ""}
    - +
    `; }).join(""); } @@ -1394,7 +1650,7 @@ async function agentAction(kind, slot, btn) { case "resume": return run(btn, "Resume", () => api("agents/control", { session_id: sid, slot_id: slot.id, action: "resume" })); case "rename": { - const v = await promptDialog({ title: `Rename ${name}`, sid, label: "Display name", value: slot.display_name ?? "" }); + const v = await promptDialog({ title: tr("Rename {name}", { name }), sid, label: "Display name", value: slot.display_name ?? "" }); if (v) return run(null, "Rename", () => api("agents/rename", { session_id: sid, slot_id: slot.id, display_name: v })); return; } @@ -1408,9 +1664,9 @@ async function agentAction(kind, slot, btn) { case "release": { const notApproved = slot.task_state !== "approved"; const ok = await confirmDialog({ - title: `Release ${name}?`, sid, - message: `

    Releasing tells ${esc(name)} its work is complete; it disconnects once idle.

    ` + - (notApproved ? `

    ⚠ ${esc(name)} is ${esc(TASK_LABEL[slot.task_state] ?? slot.task_state)}, not approved.

    ` : ""), + title: tr("Release {name}?", { name }), sid, + message: `

    ${tr("Releasing tells {name} its work is complete; it disconnects once idle.", { name: esc(name) })}

    ` + + (notApproved ? `

    ⚠ ${tr("{name} is {state}, not approved.", { name: esc(name), state: esc(taskLabel(slot.task_state)) })}

    ` : ""), confirmLabel: "Release", danger: notApproved, }); if (ok) return run(btn, "Release", () => api("agents/release", { session_id: sid, slot_id: slot.id })); @@ -1418,8 +1674,8 @@ async function agentAction(kind, slot, btn) { } case "remove": { const ok = await confirmDialog({ - title: `Remove ${name}?`, sid, - message: "

    Remove stops the agent now (if this dashboard runs it), marks it released and tells the team it left. Unlike Release, it does not wait for the agent to finish.

    ", + title: tr("Remove {name}?", { name }), sid, + message: `

    ${tr("Remove stops the agent now (if this dashboard runs it), marks it released and tells the team it left. Unlike Release, it does not wait for the agent to finish.")}

    `, confirmLabel: "Remove", danger: true, }); if (ok) return run(btn, "Remove", () => api("agents/remove", { session_id: sid, slot_id: slot.id })); @@ -1436,22 +1692,22 @@ function renderPlan() { const el = $("plan-list"); if (busyRegion(el)) return; if (!state.plan?.plan) { - el.innerHTML = `
    ${state.session ? "No plan yet — add the first task above." : "No session selected"}
    `; + el.innerHTML = `
    ${state.session ? tr("No plan yet — add the first task above.") : tr("No session selected")}
    `; return; } const { plan, items, completion } = state.plan; const itemsHtml = items.map((item) => { - const assigneeOpts = `` + state.slots + const assigneeOpts = `` + state.slots .filter((s) => s.task_state !== "released" || s.id === item.assigned_to_slot) .map((s) => ``).join(""); const statusOpts = ["pending", "in_progress", "blocked", "done"] - .map((st) => ``).join(""); + .map((st) => ``).join(""); return `
    ${PLAN_ICON[item.status] ?? PLAN_ICON.pending} - ${esc(item.label)} + ${esc(item.label)} - +
    `; }).join(""); el.innerHTML = `
    @@ -1460,7 +1716,7 @@ function renderPlan() {
    ${completion}%
    -
    ${itemsHtml || '
    No tasks
    '}
    +
    ${itemsHtml || `
    ${tr("No tasks")}
    `}
    `; } @@ -1496,10 +1752,10 @@ $("plan-list").addEventListener("click", async (e) => { const item = state.plan?.items.find((i) => i.id === itemId); if (!item) return; if (t.dataset.plan === "edit") { - const v = await promptDialog({ title: "Edit task", sid, label: "Task", value: item.label, multiline: true }); + const v = await promptDialog({ title: tr("Edit task"), sid, label: "Task", value: item.label, multiline: true }); if (v) run(null, "Edit task", () => api("plan/edit", { session_id: sid, item_id: itemId, label: v }).then(() => ({ message: "Task updated" }))); } else if (t.dataset.plan === "delete") { - const ok = await confirmDialog({ title: "Delete task?", sid, message: `

    ${esc(item.label)}

    `, confirmLabel: "Delete", danger: true }); + const ok = await confirmDialog({ title: tr("Delete task?"), sid, message: `

    ${esc(item.label)}

    `, confirmLabel: "Delete", danger: true }); if (ok) run(null, "Delete task", () => api("plan/delete", { session_id: sid, item_id: itemId }).then(() => ({ message: "Task deleted" }))); } }); @@ -1513,21 +1769,21 @@ function renderKnowledge() { if (busyRegion(el)) return; const entries = state.knowledge ?? []; if (!entries.length) { - el.innerHTML = `
    ${state.session ? "No knowledge entries. Agents store decisions and discoveries here." : "No session selected"}
    `; + el.innerHTML = `
    ${state.session ? tr("No knowledge entries. Agents store decisions and discoveries here.") : tr("No session selected")}
    `; return; } - el.innerHTML = `
    Shared Knowledge ${entries.length}
    ${entries.map((e) => { - const by = e.created_by_name ? `by ${esc(e.created_by_name)}` : ""; + el.innerHTML = `
    ${tr("Shared Knowledge")} ${entries.length}
    ${entries.map((e) => { + const by = e.created_by_name ? tr("by {name}", { name: esc(e.created_by_name) }) : ""; const ago = timeSince(e.updated_at); return `
    -
    ${esc(e.key)}${esc(e.category)}
    +
    ${esc(e.key)}${esc(tr(e.category))}
    ${renderMarkdown(e.value, { chat: true })}
    ${by}${by && ago ? " · " : ""}${ago}
    - - + +
    `; }).join("")}
    `; @@ -1538,7 +1794,7 @@ function resetKnowledgeForm() { editingKnowledgeKey = null; $("knowledge-form").reset(); $("kn-key").readOnly = false; - $("knowledge-form-title").textContent = "Add knowledge"; + $("knowledge-form-title").textContent = tr("Add knowledge"); } $("kn-cancel").addEventListener("click", resetKnowledgeForm); @@ -1548,7 +1804,7 @@ $("knowledge-form").addEventListener("submit", async (e) => { const sid = currentSid(); if (sid == null) return; const body = { session_id: sid, key: $("kn-key").value.trim(), value: $("kn-value").value, category: $("kn-category").value }; - const r = await run(e.submitter, "Save knowledge", () => api("knowledge/put", body).then(() => ({ message: `Saved "${body.key}"` }))); + const r = await run(e.submitter, "Save knowledge", () => api("knowledge/put", body).then(() => ({ message: tr("Saved \"{key}\"", { key: body.key }) }))); if (r) resetKnowledgeForm(); }); @@ -1566,12 +1822,12 @@ $("knowledge-list").addEventListener("click", async (e) => { $("kn-key").readOnly = true; $("kn-value").value = entry.value; $("kn-category").value = entry.category; - $("knowledge-form-title").textContent = `Edit "${key}"`; + $("knowledge-form-title").textContent = tr("Edit \"{key}\"", { key }); $("kn-value").focus(); } else { - const ok = await confirmDialog({ title: `Delete "${key}"?`, sid, message: `

    ${esc(truncate(entry.value, 300))}

    `, confirmLabel: "Delete", danger: true }); + const ok = await confirmDialog({ title: tr("Delete \"{key}\"?", { key }), sid, message: `

    ${esc(truncate(entry.value, 300))}

    `, confirmLabel: "Delete", danger: true }); if (ok) { - await run(btn, "Delete knowledge", () => api("knowledge/delete", { session_id: sid, key }).then(() => ({ message: `Deleted "${key}"` }))); + await run(btn, "Delete knowledge", () => api("knowledge/delete", { session_id: sid, key }).then(() => ({ message: tr("Deleted \"{key}\"", { key }) }))); if (editingKnowledgeKey === key) resetKnowledgeForm(); } } @@ -1587,21 +1843,22 @@ function renderFiles() { const left = l.expires_at - Date.now(); return ` ${esc(l.file_path)} - ${esc(holder ? slotName(holder) : `Slot ${l.held_by_slot}`)} + ${esc(holder ? slotName(holder) : tr("Slot {id}", { id: l.held_by_slot }))} ${esc(l.purpose ?? "")} ${timeSince(l.acquired_at)} - ${left > 0 ? "in " + fmtDuration(left) : "expired"} - + ${left > 0 ? tr("in {time}", { time: fmtDuration(left) }) : tr("expired")} + `; }).join(""); const ownRows = state.fileOwnership.map((o) => { const owner = slotById(o.slot_id); - return `${esc(o.path_pattern)}${esc(owner ? slotName(owner) : `Slot ${o.slot_id}`)}${esc(o.assigned_by ?? "")}${timeSince(o.assigned_at)}`; + return `${esc(o.path_pattern)}${esc(owner ? slotName(owner) : tr("Slot {id}", { id: o.slot_id }))}${esc(o.assigned_by ?? "")}${timeSince(o.assigned_at)}`; }).join(""); - el.innerHTML = `
    Active Locks ${state.fileLocks.length}
    - ${state.fileLocks.length ? `
    ${lockRows}
    FileHeld ByPurposeAcquiredExpires
    ` : '
    No active locks
    '}
    -
    Ownership Zones ${state.fileOwnership.length}
    - ${state.fileOwnership.length ? `
    ${ownRows}
    PatternOwnerAssigned ByWhen
    ` : '
    No ownership zones
    '}
    `; + const th = (...cols) => cols.map((c) => `${c ? tr(c) : ""}`).join(""); + el.innerHTML = `
    ${tr("Active Locks")} ${state.fileLocks.length}
    + ${state.fileLocks.length ? `
    ${th("File", "Held By", "Purpose", "Acquired", "Expires", "")}${lockRows}
    ` : `
    ${tr("No active locks")}
    `}
    +
    ${tr("Ownership Zones")} ${state.fileOwnership.length}
    + ${state.fileOwnership.length ? `
    ${th("Pattern", "Owner", "Assigned By", "When")}${ownRows}
    ` : `
    ${tr("No ownership zones")}
    `}
    `; hydrateFileRefs(el); } @@ -1614,8 +1871,8 @@ $("files-list").addEventListener("click", async (e) => { if (!lock) return; const holder = slotById(lock.held_by_slot); const ok = await confirmDialog({ - title: "Force unlock?", sid, - message: `

    ${esc(lock.file_path)} is held by ${esc(holder ? slotName(holder) : `slot ${lock.held_by_slot}`)}. Unlocking lets other agents edit it while the holder may still be writing.

    `, + title: tr("Force unlock?"), sid, + message: `

    ${tr("{file} is held by {holder}. Unlocking lets other agents edit it while the holder may still be writing.", { file: esc(lock.file_path), holder: esc(holder ? slotName(holder) : tr("Slot {id}", { id: lock.held_by_slot })) })}

    `, confirmLabel: "Force unlock", danger: true, }); if (ok) run(btn, "Unlock", () => api("files/release", { session_id: sid, lock_id: lock.id })); @@ -1626,7 +1883,7 @@ $("lock-form").addEventListener("submit", async (e) => { const sid = currentSid(); if (sid == null) return; const slot = $("lock-agent").value; - if (!slot) return toast("Pick an agent", "warn"); + if (!slot) return toast(tr("Pick an agent"), "warn"); const r = await run(e.submitter, "Acquire lock", () => api("files/acquire", { session_id: sid, slot_id: parseInt(slot, 10), file_path: $("lock-path").value.trim(), minutes: Number($("lock-minutes").value), purpose: $("lock-purpose").value.trim(), @@ -1639,7 +1896,7 @@ $("ownership-form").addEventListener("submit", async (e) => { const sid = currentSid(); if (sid == null) return; const slot = $("own-agent").value; - if (!slot) return toast("Pick an agent", "warn"); + if (!slot) return toast(tr("Pick an agent"), "warn"); const patterns = $("own-patterns").value.split(",").map((p) => p.trim()).filter(Boolean); const r = await run(e.submitter, "Assign ownership", () => api("files/ownership", { session_id: sid, slot_id: parseInt(slot, 10), patterns })); if (r) $("own-patterns").value = ""; @@ -1650,7 +1907,7 @@ $("ownership-form").addEventListener("submit", async (e) => { function renderStats() { const el = $("panel-stats"); if (busyRegion(el)) return; - if (!state.session) { el.innerHTML = '
    No session selected
    '; return; } + if (!state.session) { el.innerHTML = `
    ${tr("No session selected")}
    `; return; } const limits = state.limits ?? {}; const totalIn = state.slots.reduce((a, s) => a + (s.input_tokens ?? 0), 0); const totalOut = state.slots.reduce((a, s) => a + (s.output_tokens ?? 0), 0); @@ -1663,54 +1920,54 @@ function renderStats() { { value: state.slots.filter((s) => s.status === "connected").length, label: "Online agents", color: "var(--green)" }, { value: state.messages.length, label: "Messages", color: "var(--cyan)" }, ]; - const cardsHtml = cards.map((c) => `
    ${c.value}
    ${esc(c.label)}
    - ${c.limit ? `
    ${limitBar(c.used, c.limit)}
    limit ${c.fmt(c.limit)}
    ` : ""}
    `).join(""); + const cardsHtml = cards.map((c) => `
    ${c.value}
    ${esc(tr(c.label))}
    + ${c.limit ? `
    ${limitBar(c.used, c.limit)}
    ${tr("limit {value}", { value: c.fmt(c.limit) })}
    ` : ""}
    `).join(""); const rows = state.slots.map((slot) => { const set = slotSettingsOf(slot); const used = (slot.input_tokens ?? 0) + (slot.output_tokens ?? 0); const limit = set.token_limit || limits.agent_tokens; return ` -
    ${avatar(slotName(slot), slotColor(slot.id))}
    ${esc(slotName(slot))}
    ${esc(slot.agent_type)} · ${esc(set.model || "default model")}${set.effort ? ` · ${esc(set.effort)}` : ""}
    +
    ${slotAvatar(slot)}
    ${esc(slotName(slot))}
    ${esc(slot.agent_type)} · ${esc(set.model || tr("default model"))}${set.effort ? ` · ${esc(set.effort)}` : ""}
    ${fmtTokens(slot.input_tokens ?? 0)} ${fmtTokens(slot.output_tokens ?? 0)} ${fmtTokens(slot.cache_read_tokens ?? 0)} ${slot.agent_type === "claude" ? fmtUsd(slot.cost_usd ?? 0) : '—'} - ${limit ? `${limitBar(used, limit)}
    ${fmtTokens(used)} / ${fmtTokens(limit)}${set.token_limit ? "" : " (session default)"}
    ` : 'no limit'} - ${set.limit_hit ? `
    paused: ${esc(set.limit_hit)}
    ` : ""} - + ${limit ? `${limitBar(used, limit)}
    ${fmtTokens(used)} / ${fmtTokens(limit)}${set.token_limit ? "" : tr(" (session default)")}
    ` : `${tr("no limit")}`} + ${set.limit_hit ? `
    ${tr("paused: {reason}", { reason: esc(tr(set.limit_hit)) })}
    ` : ""} + `; }).join(""); const enforced = state.guardrails.filter((g) => g.action !== "monitor" && g.adjustable); const guardrails = enforced.map((g) => `
    -
    ${esc(g.label)} ${g.is_overridden ? 'overridden' : ""}
    -
    ${esc(g.description ?? "")} · usage ${g.usage?.current ?? 0}/${g.usage?.limit ?? g.current_value} ${esc(g.unit)}
    +
    ${esc(tr(g.label))} ${g.is_overridden ? `${tr("overridden")}` : ""}
    +
    ${esc(tr(g.description ?? ""))} · ${tr("usage")} ${g.usage?.current ?? 0}/${g.usage?.limit ?? g.current_value} ${esc(tr(g.unit))}
    - +
    `).join(""); const lim = (k) => (limits[k] ? String(limits[k]) : ""); el.innerHTML = `
    ${cardsHtml}
    -
    Usage per agent tokens as the CLIs report them; cost only where the CLI reports it
    -
    - ${rows || ''}
    AgentInputOutputCache readsCostLimit
    No agents yet
    -
    Limit locks
    +
    ${tr("Usage per agent")} ${tr("tokens as the CLIs report them; cost only where the CLI reports it")}
    +
    + ${rows || ``}
    ${tr("Agent")}${tr("Input")}${tr("Output")}${tr("Cache reads")}${tr("Cost")}${tr("Limit")}
    ${tr("No agents yet")}
    +
    ${tr("Limit locks")}
    - - - - -
    An agent that reaches a limit is paused and its running turn stops. Raise the limit, then resume it. Tokens = input + output. An agent's own limit (Settings) overrides the per-agent limit. -
    + + + + +
    ${tr("An agent that reaches a limit is paused and its running turn stops. Raise the limit, then resume it. Tokens = input + output. An agent's own limit (Settings) overrides the per-agent limit.")} +
    - ${guardrails ? `
    Guardrails
    ${guardrails}
    ` : ""}`; + ${guardrails ? `
    ${tr("Guardrails")}
    ${guardrails}
    ` : ""}`; } function limitBar(used, limit) { @@ -1744,7 +2001,7 @@ $("panel-stats").addEventListener("click", (e) => { const id = row.dataset.guardrail; input.blur(); run(e.target, "Guardrail", () => api("guardrail", { session_id: sid, guardrail_id: id, value, reason: "Adjusted via web dashboard" }) - .then((g) => ({ message: `${g?.label ?? id} set to ${g?.current_value ?? value} ${g?.unit ?? ""}` }))); + .then((g) => ({ message: tr("{name} set to {value} {unit}", { name: tr(g?.label ?? id), value: g?.current_value ?? value, unit: tr(g?.unit ?? "") }) }))); } }); @@ -1763,6 +2020,7 @@ const APPROVAL_MODES = [ ["ask", "Ask me every step", "Every edit and command waits for your approval."], ]; const MODE_LABEL = Object.fromEntries(APPROVAL_MODES.map(([v, l]) => [v, l])); +const modeLabel = (mode) => tr(MODE_LABEL[mode] ?? mode); function slotSettingsOf(slot) { let snap = {}; @@ -1778,9 +2036,9 @@ function slotSettingsOf(slot) { function settingsBadges(slot) { const set = slotSettingsOf(slot); - return `${set.model ? `${esc(set.model)}` : ""} - ${set.approval_mode !== "bypass" ? `${esc(MODE_LABEL[set.approval_mode] ?? set.approval_mode)}` : ""} - ${set.limit_hit ? `limit reached` : ""}`; + return `${set.model ? `${esc(set.model)}` : ""} + ${set.approval_mode !== "bypass" ? `${esc(modeLabel(set.approval_mode))}` : ""} + ${set.limit_hit ? `${tr("limit reached")}` : ""}`; } function usageLine(slot) { @@ -1788,37 +2046,38 @@ function usageLine(slot) { if (!used && !slot.cost_usd) return ""; const set = slotSettingsOf(slot); const limit = set.token_limit || state.limits?.agent_tokens; - return `
    ${fmtTokens(slot.input_tokens ?? 0)} in / ${fmtTokens(slot.output_tokens ?? 0)} out${slot.cost_usd ? ` · ${fmtUsd(slot.cost_usd)}` : ""}${limit ? ` · limit ${fmtTokens(limit)}` : ""}
    + return `
    ${tr("{in} in / {out} out", { in: fmtTokens(slot.input_tokens ?? 0), out: fmtTokens(slot.output_tokens ?? 0) })}${slot.cost_usd ? ` · ${fmtUsd(slot.cost_usd)}` : ""}${limit ? ` · ${tr("limit {value}", { value: fmtTokens(limit) })}` : ""}
    ${limit ? `
    ${limitBar(used, limit)}
    ` : ""}`; } function modelFields(type, values = {}) { const efforts = EFFORT_OPTIONS[type] ?? []; - return `
    + return `
    ${(MODEL_SUGGESTIONS[type] ?? []).map((m) => ` -
    An alias (opus, sonnet…) or a full model name.${type === "gemini" ? " Applies when the agent next starts." : " Applies to a running agent within seconds."}
    - ${efforts.length - ? `` - : 'not available for this agent'}`; +
    ${tr("An alias (opus, sonnet…) or a full model name.")}${type === "gemini" ? tr(" Applies when the agent next starts.") : tr(" Applies to a running agent within seconds.")}
    + ${efforts.length + ? `` + : `${tr("not available for this agent")}`}`; } function approvalFields(type, current = "bypass") { return `
    ${APPROVAL_MODES.map(([v, l, d]) => { const disabled = type === "gemini" && v !== "bypass"; - return ``; - }).join("")}${type === "gemini" ? '
    Gemini agents run unattended (yolo); step approvals need Claude or Codex. Their steps still show in Activity.
    ' : ""}
    `; + return ``; + }).join("")}${type === "gemini" ? `
    ${tr("Gemini agents run unattended (yolo); step approvals need Claude or Codex. Their steps still show in Activity.")}
    ` : ""}
    `; } function settingsModal(sid, slot) { const set = slotSettingsOf(slot); openModal({ - title: `Settings · ${slotName(slot)}`, sub: `${sessionSub(sid)} · ${slot.agent_type}`, submitLabel: "Save", + title: tr("Settings · {name}", { name: slotName(slot) }), sub: `${sessionSub(sid)} · ${slot.agent_type}`, submitLabel: "Save", body: `
    +
    ${slotAvatar(slot, "", true)}${tr("Click the avatar to change it.")}
    ${modelFields(slot.agent_type, set)} -
    -
    Input + output tokens. When reached the agent is paused and its turn stops. Used so far: ${fmtTokens((slot.input_tokens ?? 0) + (slot.output_tokens ?? 0))}.
    +
    +
    ${tr("Input + output tokens. When reached the agent is paused and its turn stops. Used so far: {used}.", { used: fmtTokens((slot.input_tokens ?? 0) + (slot.output_tokens ?? 0)) })}
    - +
    ${approvalFields(slot.agent_type, set.approval_mode)}
    `, onSubmit: async (form) => { const body = { @@ -1829,14 +2088,14 @@ function settingsModal(sid, slot) { }; if (form.effort) body.effort = form.effort.value; const r = await api("agents/settings", body); - toast(r?.message ?? "Saved", "ok"); + toast(tr(r?.message ?? "Saved"), "ok"); }, }); } $("act-settings").addEventListener("click", () => { const slot = slotById(parseInt($("act-agent").value, 10)) ?? liveSlots()[0]; - if (!slot) return toast("No agent selected", "warn"); + if (!slot) return toast(tr("No agent selected"), "warn"); const sid = currentSid(); if (sid != null) settingsModal(sid, slot); }); @@ -1861,7 +2120,7 @@ function renderApprovals() { if (!pending.length || activeTab === "activity") { banner.className = "hidden"; banner.innerHTML = ""; } else { banner.className = "approval-banner"; - banner.innerHTML = `✋ ${pending.length} step${pending.length === 1 ? "" : "s"} waiting for your approval${esc(pending.slice(0, 3).map((a) => `${slotName(slotById(a.slot_id))}: ${a.title}`).join(" · "))}`; + banner.innerHTML = `✋ ${tr(pending.length === 1 ? "{n} step waiting for your approval" : "{n} steps waiting for your approval", { n: pending.length })}${esc(pending.slice(0, 3).map((a) => `${slotName(slotById(a.slot_id))}: ${a.title}`).join(" · "))}`; } const box = $("act-approvals"); if (busyRegion(box)) return; @@ -1871,14 +2130,14 @@ function renderApprovals() { box.innerHTML = pending.map((a) => { const slot = slotById(a.slot_id); return `
    -
    ${avatar(slotName(slot), slotColor(a.slot_id))} -
    ${esc(slotName(slot))} wants to run ${esc(a.tool)}
    ${esc(a.title)} · ${timeSince(a.created_at)}
    +
    ${slot ? slotAvatar(slot) : avatar("?", slotColor(a.slot_id))} +
    ${tr("{name} wants to run {tool}", { name: esc(slotName(slot)), tool: esc(a.tool) })}
    ${esc(a.title)} · ${timeSince(a.created_at)}
    ${approvalPreview(a)}
    - - - - + + + +
    `; }).join(""); @@ -1967,26 +2226,26 @@ function unifiedDiff(diff) { } function changesDiff(changes) { - return changes.map((c) => `
    ${esc(c.kind ?? "update")} ${fileRef(c.path ?? "")}
    ${c.diff ? unifiedDiff(c.diff) : ""}`).join(""); + return changes.map((c) => `
    ${esc(tr(c.kind ?? "update"))} ${fileRef(c.path ?? "")}
    ${c.diff ? unifiedDiff(c.diff) : ""}`).join(""); } function outputBlock(label, text, isErr) { if (!text) return ""; - return `
    ${esc(label)}
    ${esc(text)}
    `; + return `
    ${esc(tr(label))}
    ${esc(text)}
    `; } function callBody(tool, input) { if (tool === "Edit" || tool === "MultiEdit") return editDiff(input); if (tool === "Write") return `
    ${fileRef(input.file_path ?? "")}
    ${String(input.content ?? "").split("\n").slice(0, 200).map((l) => `+${esc(l)}`).join("")}
    `; if (tool === "Bash") return `
    ${esc(input.command ?? "")}
    ${input.description ? `
    ${esc(input.description)}
    ` : ""}`; - if (tool === "Read") return `
    ${fileRef(input.file_path ?? "")}${input.offset ? ` from line ${esc(input.offset)}` : ""}
    `; + if (tool === "Read") return `
    ${fileRef(input.file_path ?? "")}${input.offset ? ` ${tr("from line {n}", { n: esc(input.offset) })}` : ""}
    `; if (!input || !Object.keys(input).length) return ""; return `
    ${esc(JSON.stringify(input, null, 2))}
    `; } function stepTitleHtml(title) { // File names in a step title become links like in the chat - return inline(title); + return inline(tr(title)); } /** One entry as HTML; tool results are appended to their call's element instead. */ @@ -2005,22 +2264,22 @@ function stepHtml(e) {
    ${body}${e.detail ? outputBlock("Result", e.detail, d.status === "failed") : ""}
    `; } case "tool_result": - return `
    ${d.is_error ? "⚠" : "↳"}${esc(e.title)}${time}
    ${esc(e.detail ?? "")}
    `; + return `
    ${d.is_error ? "⚠" : "↳"}${esc(tr(e.title))}${time}
    ${esc(e.detail ?? "")}
    `; case "command": { const failed = d.exit_code !== undefined && d.exit_code !== null && d.exit_code !== 0; - return `
    ${STEP_ICON.command}${esc(e.title)}${d.exit_code !== undefined && d.exit_code !== null ? ` exit ${esc(d.exit_code)}` : ""}${time} -
    ${outputBlock("Output", e.detail || "(no output)", failed)}
    `; + return `
    ${STEP_ICON.command}${esc(e.title)}${d.exit_code !== undefined && d.exit_code !== null ? ` ${tr("exit {code}", { code: esc(d.exit_code) })}` : ""}${time} +
    ${outputBlock("Output", e.detail || tr("(no output)"), failed)}
    `; } case "file_change": return `
    ${STEP_ICON.file_change}${stepTitleHtml(e.title)}${time}
    ${changesDiff(d.changes ?? [])}
    `; case "approval": - return `
    ✋ ${esc(e.title)}${e.detail ? ` — ${esc(e.detail)}` : ""}
    `; + return `
    ✋ ${esc(tr(e.title))}${e.detail ? ` — ${esc(e.detail)}` : ""}
    `; case "turn_end": - return `
    ${esc(e.title)}
    `; + return `
    ${esc(tr(e.title))}
    `; case "error": - return `
    ⚠ ${esc(e.title)}${e.detail ? ` — ${esc(e.detail)}` : ""}
    `; + return `
    ⚠ ${esc(tr(e.title))}${e.detail ? ` — ${esc(tr(e.detail))}` : ""}
    `; default: - return `
    ${esc(e.title)}
    `; + return `
    ${esc(tr(e.title))}
    `; } } @@ -2033,15 +2292,15 @@ function activityVisible(e, filter, showTeam, showThinking) { function renderActivity() { const feed = $("act-feed"); - if (!state?.session) { feed.innerHTML = '
    No session selected.
    '; return; } + if (!state?.session) { feed.innerHTML = `
    ${tr("No session selected.")}
    `; return; } if (activity.sid !== state.sessionId) { void pollActivity(); return; } const filter = $("act-agent").value ? parseInt($("act-agent").value, 10) : null; const showTeam = $("act-team").checked, showThinking = $("act-thinking").checked; const sig = `${filter}|${showTeam}|${showThinking}`; const visible = activity.entries.filter((e) => activityVisible(e, filter, showTeam, showThinking)); - $("act-count").textContent = `${visible.length} step${visible.length === 1 ? "" : "s"}`; + $("act-count").textContent = tr(visible.length === 1 ? "{n} step" : "{n} steps", { n: visible.length }); if (!activity.entries.length) { - feed.innerHTML = '
    No activity yet. Steps appear here as agents think, read, edit and run commands.
    Claude and Codex agents launched by multiagents report every step; Gemini reports its tool calls.
    '; + feed.innerHTML = `
    ${tr("No activity yet. Steps appear here as agents think, read, edit and run commands.")}
    ${tr("Claude and Codex agents launched by multiagents report every step; Gemini reports its tool calls.")}
    `; activity.filterSig = ""; activity.rendered = 0; return; } @@ -2064,14 +2323,14 @@ function renderActivity() { const d = parseData(e); const call = d.tool_use_id ? feed.querySelector(`[data-tuid="${CSS.escape(d.tool_use_id)}"] .step-body`) ?? frag.querySelector(`[data-tuid="${CSS.escape(d.tool_use_id)}"] .step-body`) : null; if (call) { - call.insertAdjacentHTML("beforeend", outputBlock(d.is_error ? "Error" : "Result", e.detail || "(empty)", d.is_error)); + call.insertAdjacentHTML("beforeend", outputBlock(d.is_error ? "Error" : "Result", e.detail || tr("(empty)"), d.is_error)); if (d.is_error) call.closest(".step").classList.add("err"); continue; } } const slot = slotById(e.slot_id); const head = activity.lastSlot !== e.slot_id - ? `
    ${avatar(slotName(slot), slotColor(e.slot_id)).replace('class="avatar"', 'class="avatar" style="display:inline-grid;width:18px;height:18px;font-size:8px;border-radius:5px;vertical-align:-4px;background:' + slotColor(e.slot_id) + '"')} ${esc(slotName(slot))} ${slot?.role ? `· ${esc(slot.role)}` : ""}
    ` + ? `
    ${slot ? slotAvatar(slot, "", false, "mini") : ""} ${esc(slotName(slot))} ${slot?.role ? `· ${esc(slot.role)}` : ""}
    ` : ""; activity.lastSlot = e.slot_id; frag.insertAdjacentHTML("beforeend", head + stepHtml(e)); @@ -2104,7 +2363,7 @@ async function globalAction(action, btn) { if (sid == null) return; switch (action) { case "session-pause": { - const ok = await confirmDialog({ title: "Pause the whole session?", sid, message: "

    Every connected agent gets a pause signal, its incoming messages are held and its file locks are released. Resume picks up where they stopped.

    ", confirmLabel: "Pause all" }); + const ok = await confirmDialog({ title: tr("Pause the whole session?"), sid, message: `

    ${tr("Every connected agent gets a pause signal, its incoming messages are held and its file locks are released. Resume picks up where they stopped.")}

    `, confirmLabel: "Pause all" }); if (ok) run(btn, "Pause", () => api("session/control", { session_id: sid, action: "pause" })); return; } @@ -2131,12 +2390,12 @@ async function releaseAll(sid, approvedOnly, btn) { const targets = approvedOnly ? state.slots.filter((s) => s.task_state === "approved") : state.slots.filter((s) => s.task_state !== "released" && s.task_state !== "idle"); - if (!targets.length) return toast("Nothing to release", "warn"); + if (!targets.length) return toast(tr("Nothing to release"), "warn"); const notApproved = targets.filter((s) => s.task_state !== "approved"); const ok = await confirmDialog({ - title: approvedOnly ? "Release all approved agents?" : "Release ALL agents?", sid, - message: `

    These agents are told their work is complete and disconnect once idle:

      ${targets.map((s) => `
    • ${esc(slotName(s))} — ${esc(TASK_LABEL[s.task_state] ?? s.task_state)}
    • `).join("")}
    ` + - (notApproved.length ? `

    ⚠ ${notApproved.length} of them are not approved.

    ` : ""), + title: tr(approvedOnly ? "Release all approved agents?" : "Release ALL agents?"), sid, + message: `

    ${tr("These agents are told their work is complete and disconnect once idle:")}

      ${targets.map((s) => `
    • ${esc(slotName(s))} — ${esc(taskLabel(s.task_state))}
    • `).join("")}
    ` + + (notApproved.length ? `

    ⚠ ${tr("{n} of them are not approved.", { n: notApproved.length })}

    ` : ""), confirmLabel: "Release", danger: !approvedOnly, }); if (ok) run(btn, "Release", () => api("agents/release-all", { session_id: sid, approved_only: approvedOnly })); @@ -2149,26 +2408,26 @@ function sessionMenuItems(s) { return [ { label: "Open", disabled: sid === state.sessionId, onClick: () => selectSession(sid) }, { label: "Pause", disabled: s.status !== "active", onClick: async () => { - if (await confirmDialog({ title: `Pause "${s.name}"?`, sid, message: "

    Pauses every connected agent of this session.

    ", confirmLabel: "Pause all" })) + if (await confirmDialog({ title: tr("Pause \"{name}\"?", { name: s.name }), sid, message: `

    ${tr("Pauses every connected agent of this session.")}

    `, confirmLabel: "Pause all" })) run(null, "Pause", () => api("session/control", { session_id: sid, action: "pause" })); } }, { label: "Resume", disabled: s.status !== "paused", onClick: () => run(null, "Resume", () => api("session/control", { session_id: sid, action: "resume" })) }, { label: "Relaunch disconnected agents", onClick: async () => { - if (await confirmDialog({ title: `Relaunch agents of "${s.name}"?`, sid, message: "

    Respawns every disconnected, unreleased agent with a handoff of its role, plan items and recent messages. They run under this dashboard. Agents another orchestrator still runs are not touched.

    ", confirmLabel: "Relaunch" })) + if (await confirmDialog({ title: tr("Relaunch agents of \"{name}\"?", { name: s.name }), sid, message: `

    ${tr("Respawns every disconnected, unreleased agent with a handoff of its role, plan items and recent messages. They run under this dashboard. Agents another orchestrator still runs are not touched.")}

    `, confirmLabel: "Relaunch" })) run(null, "Relaunch", () => api("session/relaunch", { session_id: sid })); } }, { sep: true }, { label: "Export JSON", onClick: () => { location.href = `/api/session/export?session_id=${encodeURIComponent(sid)}`; } }, { label: "Archive", disabled: archived, onClick: async () => { - if (await confirmDialog({ title: `Archive "${s.name}"?`, sid, message: "

    Marks the session archived (like multiagents session archive). Running agents are not stopped — use End session for that.

    ", confirmLabel: "Archive" })) + if (await confirmDialog({ title: tr("Archive \"{name}\"?", { name: s.name }), sid, message: `

    ${tr("Marks the session archived (like multiagents session archive). Running agents are not stopped — use End session for that.")}

    `, confirmLabel: "Archive" })) run(null, "Archive", () => api("session/archive", { session_id: sid })); } }, { label: "End session", danger: true, disabled: archived, onClick: async () => { - if (await confirmDialog({ title: `End "${s.name}"?`, sid, message: "

    Stops the agents this dashboard runs, marks every agent disconnected and archives the session.

    ", confirmLabel: "End session", danger: true })) + if (await confirmDialog({ title: tr("End \"{name}\"?", { name: s.name }), sid, message: `

    ${tr("Stops the agents this dashboard runs, marks every agent disconnected and archives the session.")}

    `, confirmLabel: "End session", danger: true })) run(null, "End session", () => api("session/end", { session_id: sid })); } }, { label: "Delete…", danger: true, onClick: async () => { - if (await confirmDialog({ title: `Delete "${s.name}" permanently?`, sid, message: "

    Stops its agents and deletes all slots, messages, plans and locks. This cannot be undone. Export first if you want a copy. Project files are not touched.

    ", confirmLabel: "Delete forever", danger: true, typeToConfirm: sid })) + if (await confirmDialog({ title: tr("Delete \"{name}\" permanently?", { name: s.name }), sid, message: `

    ${tr("Stops its agents and deletes all slots, messages, plans and locks. This cannot be undone. Export first if you want a copy. Project files are not touched.")}

    `, confirmLabel: "Delete forever", danger: true, typeToConfirm: sid })) run(null, "Delete", () => api("session/delete", { session_id: sid, confirm: sid })); } }, ]; @@ -2180,7 +2439,7 @@ function openMenu(anchor, items) { const menu = $("menu"); menu.innerHTML = items.map((it, i) => it.sep ? '' - : ``).join(""); + : ``).join(""); menu.onclick = (e) => { const el = e.target.closest(".menu-item"); if (!el) return; @@ -2198,7 +2457,7 @@ function closeMenu() { $("menu").classList.remove("open"); } // Clicks on the menu or on a button that opens one leave it to that button document.addEventListener("click", (e) => { - if (!e.target.closest("#menu, [data-action$='-menu'], [data-session-menu], [data-roster-menu], #theme-btn, #composer-presets")) closeMenu(); + if (!e.target.closest("#menu, [data-action$='-menu'], [data-session-menu], [data-roster-menu], #theme-btn, #lang-btn, #composer-presets")) closeMenu(); }); // --- Modals --- @@ -2210,13 +2469,15 @@ function openModal({ title, sub, body, submitLabel = "OK", danger = false, wide closeModal(); const overlay = $("modal-overlay"); overlay.innerHTML = `
    -
    `; + // Static labels, hints and placeholders of the body are translated in place + translateTree(overlay.querySelector(".modal-body")); overlay.classList.add("open"); const form = overlay.querySelector("form"); const errEl = form.querySelector(".modal-error"); @@ -2241,7 +2502,7 @@ function openModal({ title, sub, body, submitLabel = "OK", danger = false, wide await onSubmit(form); finish(false); } catch (err) { - errEl.textContent = err.message; + errEl.textContent = tr(err.message); } finally { submitBtn.disabled = false; submitBtn.classList.remove("busy"); @@ -2256,16 +2517,16 @@ function closeModal() { modalClose?.(); } function sessionSub(sid) { const s = state?.sessions?.find((x) => x.id === sid); - return `Session: ${s?.name && s.name !== sid ? `${s.name} (${sid})` : sid}`; + return tr("Session: {name}", { name: s?.name && s.name !== sid ? `${s.name} (${sid})` : sid }); } function confirmDialog({ title, message, confirmLabel = "Confirm", danger = false, sid, typeToConfirm }) { return new Promise((resolve) => { openModal({ title, sub: sid != null ? sessionSub(sid) : undefined, submitLabel: confirmLabel, danger, - body: `${message ?? ""}${typeToConfirm ? `

    Type ${esc(typeToConfirm)} to confirm:

    ` : ""}`, + body: `${message ?? ""}${typeToConfirm ? `

    ${tr("Type {text} to confirm:", { text: esc(typeToConfirm) })}

    ` : ""}`, onSubmit: (form) => { - if (typeToConfirm && form.confirm.value.trim() !== typeToConfirm) throw new Error("The text does not match"); + if (typeToConfirm && form.confirm.value.trim() !== typeToConfirm) throw new Error(tr("The text does not match")); resolve(true); }, onCancel: () => resolve(false), @@ -2277,10 +2538,10 @@ function promptDialog({ title, label, value = "", multiline = false, sid }) { return new Promise((resolve) => { openModal({ title, sub: sid != null ? sessionSub(sid) : undefined, submitLabel: "Save", - body: `
    ${multiline ? `` : ``}
    `, + body: `
    ${multiline ? `` : ``}
    `, onSubmit: (form) => { const v = form.v.value.trim(); - if (!v) throw new Error(`${label} is required`); + if (!v) throw new Error(tr("{label} is required", { label: tr(label) })); resolve(v); }, onCancel: () => resolve(null), @@ -2289,7 +2550,7 @@ function promptDialog({ title, label, value = "", multiline = false, sid }) { } function agentOptions(selectedId) { - return liveSlots().map((s) => ``).join(""); + return liveSlots().map((s) => ``).join(""); } function roleDatalist() { @@ -2299,7 +2560,7 @@ function roleDatalist() { function roleModal(sid, slot) { openModal({ - title: `Change role of ${slotName(slot)}`, sub: sessionSub(sid), submitLabel: "Assign role", + title: tr("Change role of {name}", { name: slotName(slot) }), sub: sessionSub(sid), submitLabel: "Assign role", body: `
    @@ -2307,7 +2568,7 @@ function roleModal(sid, slot) { ${roleDatalist()}`, onSubmit: async (form) => { const r = await api("agents/role", { session_id: sid, slot_id: slot.id, role: form.role.value.trim(), role_description: form.desc.value.trim() }); - toast(r?.message ?? "Role assigned", "ok"); + toast(tr(r?.message ?? "Role assigned"), "ok"); }, }); } @@ -2321,12 +2582,12 @@ function sendBackModal(sid, slotId) { body: `
    -
    Delivered as actionable feedback from you; the agent moves to addressing feedback and the review loop runs again.
    `, +
    ${tr("Delivered as actionable feedback from you; the agent moves to addressing feedback and the review loop runs again.")}
    `, onSubmit: async (form) => { const feedback = form.feedback.value.trim(); if (!feedback) throw new Error("Write the feedback first"); const r = await api("agents/feedback", { session_id: sid, slot_id: parseInt(form.slot.value, 10), feedback }); - toast(r?.message ?? "Sent back", "ok"); + toast(tr(r?.message ?? "Sent back"), "ok"); }, }); } @@ -2340,7 +2601,7 @@ async function loadAvailability() { function typeOptions(selected, avail) { return ["claude", "codex", "gemini"].map((t) => - ``).join(""); + ``).join(""); } async function addAgentModal(sid) { @@ -2373,9 +2634,9 @@ async function addAgentModal(sid) { }; if (form.effort?.value) body.effort = form.effort.value; if (!body.name || !body.role || !body.initial_task) throw new Error("Name, role and task are required"); - if (avail[body.agent_type] === false) throw new Error(`${body.agent_type} CLI is not installed on this machine`); + if (avail[body.agent_type] === false) throw new Error(tr("{type} CLI is not installed on this machine", { type: body.agent_type })); const r = await api("agents/add", body); - toast(r?.message ?? "Agent launched", "ok"); + toast(tr(r?.message ?? "Agent launched"), "ok"); }, }); } @@ -2386,10 +2647,10 @@ function newSessionModal() { body: `
    -
    Like multiagents session create: writes .multiagents/session.json into the directory. Add agents afterwards, or use New Team to launch a whole team.
    `, +
    ${tr("Like multiagents session create: writes .multiagents/session.json into the directory. Add agents afterwards, or use New Team to launch a whole team.")}
    `, onSubmit: async (form) => { const r = await api("session/create", { name: form.name.value.trim(), project_dir: form.dir.value.trim() }); - toast(`Session "${r?.id}" created`, "ok"); + toast(tr("Session \"{id}\" created", { id: r?.id }), "ok"); }, }); } @@ -2419,7 +2680,7 @@ async function newTeamModal() {
    - +
    `; @@ -2431,15 +2692,15 @@ async function newTeamModal() { - +
    ${TEAM_PRESETS["Worker + Reviewer"].map(agentRow).join("")}
    - + - - - + + + ${roleDatalist()} ${Object.values(MODEL_SUGGESTIONS).flat().map((m) => ``, setup: (form) => { @@ -2459,7 +2720,7 @@ async function newTeamModal() { if (!agents.length) throw new Error("Add at least one agent"); if (agents.some((a) => !a.name || !a.role)) throw new Error("Every agent needs a name and a role"); const unavailable = agents.filter((a) => avail[a.agent_type] === false).map((a) => `${a.name} (${a.agent_type})`); - if (unavailable.length) throw new Error(`Not installed on this machine: ${unavailable.join(", ")}`); + if (unavailable.length) throw new Error(tr("Not installed on this machine: {list}", { list: unavailable.join(", ") })); const names = agents.map((a) => a.name.toLowerCase()); const plan = form.plan.value.split("\n").map((l) => l.trim()).filter(Boolean).map((line) => { const m = line.match(/^([^::]{1,60})[::]\s*(.+)$/); @@ -2472,9 +2733,9 @@ async function newTeamModal() { workflow: { require_reviewer_approval: form.wf_approval.checked, require_actionable_feedback: form.wf_feedback.checked, human_release: form.wf_human.checked }, }; if (!body.project_dir || !body.session_name) throw new Error("Working directory and session name are required"); - toast("Starting team — agents take 15–60s to connect…", "info"); + toast(tr("Starting team — agents take 15–60s to connect…"), "info"); const r = await api("team/create", body); - toast(`Team started: ${r?.data?.session_id ?? body.session_name}`, "ok"); + toast(tr("Team started: {id}", { id: r?.data?.session_id ?? body.session_name }), "ok"); }, }); } @@ -2483,17 +2744,17 @@ async function newTeamModal() { $("broker-pill").addEventListener("click", async () => { let info; - try { info = await api("broker"); } catch (e) { return toast(`Broker status failed: ${e.message}`, "error"); } + try { info = await api("broker"); } catch (e) { return toast(tr("Broker status failed: {error}", { error: e.message }), "error"); } openModal({ title: "Broker", submitLabel: "Close", body: `
    - ${info.alive ? '● running' : '● not running'} + ${info.alive ? `● ${tr("running")}` : `● ${tr("not running")}`} ${esc(info.url)} - ${info.sessions} (${info.active_sessions} active) - ${info.peers} registered + ${tr("{n} ({active} active)", { n: info.sessions, active: info.active_sessions })} + ${tr("{n} registered", { n: info.peers })} ${esc(info.current_session ?? "—")} - ${(state?.managedSlots ?? []).length} agent(s) of the current session -
    Stop the broker from a terminal with multiagents kill-broker — it disconnects every agent on the machine.
    `, + ${tr("{n} agent(s) of the current session", { n: (state?.managedSlots ?? []).length })} +
    ${tr("Stop the broker from a terminal with multiagents kill-broker — it disconnects every agent on the machine.")}
    `, onSubmit: () => {}, }); }); @@ -2524,24 +2785,24 @@ function fmtDuration(ms) { return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s}s` : `${s}s`; } function fmtTime(iso) { - try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return ""; } + try { return new Date(iso).toLocaleTimeString(locale(), { hour: "2-digit", minute: "2-digit" }); } catch { return ""; } } function fmtDay(t) { const d = new Date(t), today = new Date(); const yesterday = new Date(); yesterday.setDate(today.getDate() - 1); - if (d.toDateString() === today.toDateString()) return "Today"; - if (d.toDateString() === yesterday.toDateString()) return "Yesterday"; - return d.toLocaleDateString([], { year: "numeric", month: "short", day: "numeric" }); + if (d.toDateString() === today.toDateString()) return tr("Today"); + if (d.toDateString() === yesterday.toDateString()) return tr("Yesterday"); + return d.toLocaleDateString(locale(), { year: "numeric", month: "short", day: "numeric" }); } function timeSince(epoch) { if (!epoch) return ""; const s = Math.round((Date.now() - epoch) / 1000); - if (s < 60) return `${s}s ago`; + if (s < 60) return tr("{n}s ago", { n: s }); const m = Math.floor(s / 60); - if (m < 60) return `${m}m ago`; + if (m < 60) return tr("{n}m ago", { n: m }); const h = Math.floor(m / 60); - if (h < 48) return `${h}h ago`; - return `${Math.floor(h / 24)}d ago`; + if (h < 48) return tr("{n}h ago", { n: h }); + return tr("{n}d ago", { n: Math.floor(h / 24) }); } // --- Session picker --- @@ -2563,11 +2824,11 @@ function renderPicker() { pickerFiltered = sortedSessions(state?.sessions).filter((s) => !search || s.name?.toLowerCase().includes(search) || s.id.toLowerCase().includes(search)); pickerFocusIdx = Math.min(pickerFocusIdx, Math.max(0, pickerFiltered.length - 1)); const list = $("picker-list"); - if (!pickerFiltered.length) { list.innerHTML = '
    No sessions found
    '; return; } + if (!pickerFiltered.length) { list.innerHTML = `
    ${tr("No sessions found")}
    `; return; } list.innerHTML = pickerFiltered.map((s, i) => `
    ${statusDot(s.status)} ${esc(s.name || s.id)} - ${esc(s.status)} + ${esc(tr(s.status))} ${timeSince(s.last_active_at ?? s.created_at)}
    `).join(""); list.querySelector(".focused")?.scrollIntoView({ block: "nearest" }); @@ -2582,7 +2843,7 @@ async function selectSession(id) { throw new Error(data.error || `HTTP ${res.status}`); } } catch (e) { - toast(`Switching session failed: ${e.message}`, "error"); + toast(tr("Switching session failed: {error}", { error: e.message }), "error"); } } @@ -2603,7 +2864,8 @@ $("picker-search").addEventListener("keydown", (e) => { // --- Boot --- -applyTheme(); +applyLanguage(); +applyAppearance(); applyLayout(); $("msg-show-system").checked = store.get("ma-show-system", false); switchRightTab(store.get("ma-rtab", "preview")); diff --git a/dashboard/i18n.js b/dashboard/i18n.js new file mode 100644 index 0000000..fc638ce --- /dev/null +++ b/dashboard/i18n.js @@ -0,0 +1,733 @@ +// ============================================================================= +// multiagents Web Control Center — translations +// ============================================================================= +// English is the source language and doubles as the lookup key: +// tr("Release {name}?", { name }) → "释放 {name}?" in Chinese. +// Strings the server or the agents produce (activity titles, toasts) are +// matched by RULES. The static HTML is translated in place by translateTree, +// which remembers the English original so switching back works. +// ============================================================================= + +"use strict"; + +const LANGS = { en: "English", zh: "中文" }; + +function detectLang() { + try { + const saved = localStorage.getItem("ma-lang"); + if (saved && LANGS[saved]) return saved; + } catch {} + return /^zh\b/i.test(navigator.language || "") ? "zh" : "en"; +} + +let LANG = detectLang(); + +function getLang() { return LANG; } +function setLang(lang) { + if (!LANGS[lang]) return; + LANG = lang; + try { localStorage.setItem("ma-lang", lang); } catch {} +} +function locale() { return LANG === "zh" ? "zh-CN" : "en-US"; } + +const ZH = { + // --- Top bar, panes, tabs --- + "multiagents control center": "multiagents 控制中心", + "Switch session": "切换会话", + "No session": "无会话", + "Search sessions...": "搜索会话…", + "Toggle sidebar (Ctrl+B)": "切换侧栏(Ctrl+B)", + "Toggle preview (Ctrl+J)": "切换预览(Ctrl+J)", + "Theme": "主题", + "Theme: {name}": "主题:{name}", + "Follow system": "跟随系统", + "Light": "日间", + "Dark": "夜间", + "Language": "语言", + "Appearance": "外观", + "Broker": "Broker", + "Broker status": "Broker 状态", + "Broker down": "Broker 未运行", + "Broker is not running.": "Broker 未运行。", + "Broker · {sessions} sessions · {peers} peers": "Broker · {sessions} 个会话 · {peers} 个节点", + "Broker status failed: {error}": "获取 Broker 状态失败:{error}", + "connecting…": "连接中…", + "live": "实时", + "reconnecting…": "重新连接中…", + "Dashboard server unreachable": "无法连接控制台服务器", + "{online}/{total} agents online": "{online}/{total} 个 agent 在线", + "{n} online": "{n} 在线", + "Sessions": "会话", + "Sessions and files": "会话与文件", + "Explorer": "资源管理器", + "Collapse folders": "折叠文件夹", + "Refresh": "刷新", + "Reload": "重新加载", + "Preview": "预览", + "Team": "团队", + "Chat": "群聊", + "Agents": "Agent", + "Plan": "计划", + "Knowledge": "知识库", + "Locks": "文件锁", + "Activity": "活动", + "Usage": "用量", + "Close": "关闭", + "Close preview": "关闭预览", + "Open in new tab": "在新标签页打开", + "Open in browser": "在浏览器中打开", + "Download": "下载", + "Mention in chat": "在群聊中提及", + "No sessions yet — use + to start a team.": "还没有会话 — 点 + 创建团队。", + "No sessions found": "没有找到会话", + "No session selected": "未选择会话", + "No session selected.": "未选择会话。", + "No session selected — pick one on the left or start a New Team": "未选择会话 — 从左侧选择一个,或新建团队", + "Create a team with + New Team, or pick a session on the left.": "点 + 新建团队,或从左侧选择会话。", + "Select a session to browse its project.": "选择会话以浏览其项目文件。", + "The project directory is empty.": "项目目录为空。", + "Session actions": "会话操作", + "Session: {name}": "会话:{name}", + "+ New Team": "+ 新建团队", + "+ Add Agent": "+ 添加 Agent", + "+ Add agent": "+ 添加 Agent", + "New team": "新建团队", + "New Team": "新建团队", + "New empty session": "新建空会话", + "More ▾": "更多 ▾", + "Release ▾": "释放 ▾", + "❚❚ Pause": "❚❚ 暂停", + "▶ Resume": "▶ 继续", + "⚡ Quick": "⚡ 快捷", + "Quick messages": "快捷消息", + "active": "活跃", + "paused": "已暂停", + "archived": "已归档", + "completed": "已完成", + "ended": "已结束", + "Today": "今天", + "Yesterday": "昨天", + "{n}s ago": "{n} 秒前", + "{n}m ago": "{n} 分钟前", + "{n}h ago": "{n} 小时前", + "{n}d ago": "{n} 天前", + " · agents run under this dashboard": " · agent 在此控制台下运行", + " · paused (held until resume)": " · 已暂停(消息保留至继续)", + "Human gate: when on, approved work waits for your release instead of auto-ending": "人工把关:开启后,已批准的工作会等待你释放,而不是自动结束", + "human gate on": "人工把关:开", + "human gate off": "人工把关:关", + "Human gate": "人工把关", + + // --- Preview --- + "No file open": "未打开文件", + "Click a file in the explorer, or a file name an agent mentions in the chat.": "点击资源管理器中的文件,或群聊中 agent 提到的文件名。", + "Loading…": "加载中…", + "Empty file": "空文件", + "Wrap": "自动换行", + "No wrap": "不换行", + "Render": "渲染", + "Source": "源码", + "Table": "表格", + "Large file — showing the beginning only.": "文件较大 — 仅显示开头部分。", + "Large file — showing the first 2 MB.": "文件较大 — 仅显示前 2 MB。", + "Showing the first 1000 rows.": "仅显示前 1000 行。", + "This looks like a binary file.": "这似乎是二进制文件。", + "This file type has no inline preview.": "此文件类型不支持内联预览。", + "This browser cannot show PDFs inline.": "此浏览器无法内联显示 PDF。", + "The document has no text.": "文档中没有文本。", + "Preview failed: {error}": "预览失败:{error}", + "Cannot open {path}: {error}": "无法打开 {path}:{error}", + "Open {path}": "打开 {path}", + "from line {n}": "从第 {n} 行", + + // --- Chat --- + "Message the team… (Enter to send, Shift+Enter for a new line)": "给团队发消息…(Enter 发送,Shift+Enter 换行)", + "To": "发送给", + "everyone": "所有人", + "Everyone": "所有人", + "all agents": "全部 agent", + "Send": "发送", + "New messages ↓": "新消息 ↓", + "system messages": "系统消息", + "No messages yet. Say something to the team below.": "还没有消息。在下方和团队说点什么吧。", + "You": "你", + "Orchestrator": "调度器", + "system": "系统", + "{n} message": "{n} 条消息", + "{n} messages": "{n} 条消息", + "Summarize your progress and what is left.": "总结一下你的进度和剩余工作。", + "Stop editing files and explain your current reasoning.": "先停止编辑文件,解释一下你当前的思路。", + "List the files you changed and why.": "列出你修改过的文件及原因。", + "Do not proceed until human approval.": "在人工批准之前不要继续。", + "Status": "状态", + "Review": "评审", + "Explain": "解释", + "Changes": "改动", + "Hold": "等待", + + // --- Agents --- + "No agents yet": "还没有 agent", + "No agents in this session": "此会话中没有 agent", + "no role": "无角色", + "online": "在线", + "offline": "离线", + "managed here": "由此控制台管理", + "This dashboard launched and supervises the agent process": "此控制台启动并监管该 agent 进程", + "slot #{id} · peer {peer}": "席位 #{id} · 节点 {peer}", + "slot id = stable seat in the session; peer id = the running process's registration": "席位 id = 会话中固定的位置;节点 id = 运行中进程的注册号", + "Slot {id}": "席位 {id}", + "idle": "空闲", + "working": "工作中", + "in review": "评审中", + "addressing feedback": "处理反馈中", + "approved": "已批准", + "released": "已释放", + "waiting for you": "等你处理", + "limit reached": "已达上限", + "paused: {reason}": "已暂停:{reason}", + "exit {code}": "退出码 {code}", + "Message": "发消息", + "Pause": "暂停", + "Resume": "继续", + "Release": "释放", + "Remove": "移除", + "Rename": "重命名", + "Change role": "更改角色", + "Settings": "设置", + "Settings (model, approvals, limit)": "设置(模型、审批、限额)", + "Show activity": "查看活动", + "Send back": "退回", + "Send Back": "退回", + "Send back for revision": "退回修改", + "Approve & Release": "批准并释放", + "Every agent is approved. Review the work, then release it or send it back.": "所有 agent 都已批准。请检查工作,然后释放或退回。", + "Messages": "消息", + "Open": "打开", + "Avatar": "头像", + "Click the avatar to change it.": "点击头像即可更换。", + "Change avatar": "更换头像", + "Pause \"{name}\"?": "暂停“{name}”?", + "Release {name}?": "释放 {name}?", + "Remove {name}?": "移除 {name}?", + "Rename {name}": "重命名 {name}", + "Releasing tells {name} its work is complete; it disconnects once idle.": "释放会告诉 {name} 它的工作已完成;空闲后它会断开连接。", + "{name} is {state}, not approved.": "{name} 当前是{state},尚未批准。", + "Remove stops the agent now (if this dashboard runs it), marks it released and tells the team it left. Unlike Release, it does not wait for the agent to finish.": "移除会立即停止该 agent(如果由此控制台运行),将其标记为已释放并通知团队。与释放不同,它不会等 agent 完成工作。", + "Pause the whole session?": "暂停整个会话?", + "Pauses every connected agent of this session.": "暂停此会话中所有已连接的 agent。", + "Every connected agent gets a pause signal, its incoming messages are held and its file locks are released. Resume picks up where they stopped.": "所有已连接的 agent 都会收到暂停信号,新消息会被保留,文件锁会被释放。继续后从中断处接着做。", + "Release all": "全部释放", + "Release all approved": "释放所有已批准的", + "Release all approved agents?": "释放所有已批准的 agent?", + "Release ALL agents?": "释放全部 agent?", + "These agents are told their work is complete and disconnect once idle:": "这些 agent 会被告知工作已完成,空闲后断开连接:", + "{n} of them are not approved.": "其中 {n} 个尚未批准。", + "Nothing to release": "没有可释放的 agent", + "Pause all": "全部暂停", + "Relaunch": "重新启动", + "Relaunch disconnected agents": "重新启动已断开的 agent", + "Relaunch agents of \"{name}\"?": "重新启动“{name}”的 agent?", + "Respawns every disconnected, unreleased agent with a handoff of its role, plan items and recent messages. They run under this dashboard. Agents another orchestrator still runs are not touched.": "重新拉起所有已断开且未释放的 agent,并交接其角色、计划项和最近消息。它们将在此控制台下运行。其他调度器仍在运行的 agent 不受影响。", + "Export JSON": "导出 JSON", + "Archive": "归档", + "Archive \"{name}\"?": "归档“{name}”?", + "Marks the session archived (like multiagents session archive). Running agents are not stopped — use End session for that.": "将会话标记为已归档(同 multiagents session archive)。运行中的 agent 不会停止 — 如需停止请用结束会话。", + "End session": "结束会话", + "End \"{name}\"?": "结束“{name}”?", + "Stops the agents this dashboard runs, marks every agent disconnected and archives the session.": "停止此控制台运行的 agent,将所有 agent 标记为断开,并归档会话。", + "Delete…": "删除…", + "Delete forever": "永久删除", + "Delete \"{name}\" permanently?": "永久删除“{name}”?", + "Stops its agents and deletes all slots, messages, plans and locks. This cannot be undone. Export first if you want a copy. Project files are not touched.": "停止其 agent 并删除所有席位、消息、计划和文件锁。此操作无法撤销。如需备份请先导出。项目文件不受影响。", + "This cannot be undone.": "此操作无法撤销。", + + // --- Team roster --- + "Online agents": "在线 agent", + "Agent settings": "Agent 设置", + "Model, approvals and limits of the selected agent": "所选 agent 的模型、审批和限额", + "No agent selected": "未选择 agent", + "Pick an agent": "选择一个 agent", + + // --- Plan --- + "Task description": "任务描述", + "Add": "添加", + "Add task": "添加任务", + "Edit task": "编辑任务", + "Update task": "更新任务", + "Delete task": "删除任务", + "Delete task?": "删除任务?", + "No plan yet — add the first task above.": "还没有计划 — 在上方添加第一个任务。", + "No tasks": "没有任务", + "unassigned": "未分配", + "Task added": "已添加任务", + "Task updated": "已更新任务", + "Task deleted": "已删除任务", + "Click to edit": "点击编辑", + "Task": "任务", + "Owner": "负责人", + "pending": "待办", + "in_progress": "进行中", + "in progress": "进行中", + "done": "完成", + "blocked": "受阻", + + // --- Knowledge --- + "Shared Knowledge": "共享知识", + "Add knowledge": "添加知识", + "Save knowledge": "保存知识", + "Delete knowledge": "删除知识", + "Key": "键", + "Value": "内容", + "Category": "类别", + "e.g. api-auth-scheme": "例如 api-auth-scheme", + "decision": "决策", + "discovery": "发现", + "blocker": "阻碍", + "convention": "约定", + "context": "背景", + "No knowledge entries. Agents store decisions and discoveries here.": "暂无知识条目。agent 会把决策和发现存到这里。", + "Edit \"{key}\"": "编辑“{key}”", + "Delete \"{key}\"?": "删除“{key}”?", + "Saved \"{key}\"": "已保存“{key}”", + "Deleted \"{key}\"": "已删除“{key}”", + "by {name}": "来自 {name}", + "Edit": "编辑", + "Delete": "删除", + + // --- Locks --- + "Active Locks": "当前文件锁", + "Acquire lock": "加锁", + "Reserve a file for an agent": "为 agent 预留一个文件", + "path/relative/to/project.ts": "相对项目的路径.ts", + "purpose (optional)": "用途(可选)", + "min": "分钟", + "minutes": "分钟", + "No active locks": "没有文件锁", + "File": "文件", + "Held By": "持有者", + "Purpose": "用途", + "Expires": "到期", + "expired": "已过期", + "in {time}": "{time}后", + "Actions": "操作", + "Unlock": "解锁", + "Force unlock": "强制解锁", + "Force unlock?": "强制解锁?", + "Locked by an agent": "已被 agent 锁定", + "{file} is held by {holder}. Unlocking lets other agents edit it while the holder may still be writing.": "{file} 正被 {holder} 持有。解锁后其他 agent 可以编辑它,但持有者可能仍在写入。", + "Ownership Zones": "所有权区域", + "Assign ownership zone": "分配所有权区域", + "Assign ownership": "分配所有权", + "Assign": "分配", + "src/api/**, docs/*.md": "src/api/**, docs/*.md", + "Comma-separated glob patterns. Other agents are denied locks inside the zone.": "逗号分隔的 glob 模式。其他 agent 无法在该区域内加锁。", + "No ownership zones": "没有所有权区域", + "Pattern": "模式", + "Assigned By": "分配者", + "Acquired": "加锁时间", + "Agent": "Agent", + + // --- Guardrails & limits --- + "Guardrails": "护栏", + "Guardrail": "护栏", + "Limits": "限额", + "Limit": "上限", + "Limit locks": "限额锁", + "Save limits": "保存限额", + "Set": "设置", + "overridden": "已覆盖", + "no limit": "不限", + "no limit (Claude only)": "不限(仅 Claude)", + "limit {value}": "上限 {value}", + "session default {value}": "会话默认 {value}", + "{name} set to {value} {unit}": "{name} 已设为 {value} {unit}", + "An agent that reaches a limit is paused and its running turn stops. Raise the limit, then resume it. Tokens = input + output. An agent's own limit (Settings) overrides the per-agent limit.": "达到限额的 agent 会被暂停,正在运行的回合会停止。提高限额后再继续即可。Token = 输入 + 输出。agent 自己的限额(设置中)优先于“每个 agent”限额。", + "Per agent · tokens": "每个 agent · token", + "Per agent · cost (USD)": "每个 agent · 费用(美元)", + "Whole session · tokens": "整个会话 · token", + "Whole session · cost (USD)": "整个会话 · 费用(美元)", + "Session Duration": "会话时长", + "How long the session has been running": "会话已运行的时间", + "Total Messages": "消息总数", + "Total messages exchanged across all agents": "所有 agent 之间交换的消息总数", + "Messages Per Agent (max)": "单个 agent 消息数(最多)", + "Highest message count from any single agent": "单个 agent 发送的最多消息数", + "Active Agents": "活跃 agent", + "Currently connected agents": "当前已连接的 agent", + "Longest Idle": "最长空闲", + "Longest time any agent has gone without activity": "agent 无活动的最长时间", + "Restart Limit": "重启上限", + "Stops flapping agents after too many crash-restart cycles": "崩溃重启次数过多时停止该 agent", + "messages": "条消息", + "agents": "个 agent", + "restarts": "次重启", + "Adjusted via web dashboard": "通过网页控制台调整", + + // --- Usage --- + "Usage per agent": "各 agent 用量", + "tokens as the CLIs report them; cost only where the CLI reports it": "token 以各 CLI 上报为准;费用仅在 CLI 上报时显示", + "Input": "输入", + "Output": "输出", + "Cache reads": "缓存读取", + "Cost": "费用", + "Tokens (input + output)": "Token(输入 + 输出)", + "Cost (reported by Claude)": "费用(Claude 上报)", + "{in} in / {out} out": "输入 {in} / 输出 {out}", + "usage": "用量", + "default model": "默认模型", + "default": "默认", + + // --- Agent settings --- + "Settings · {name}": "设置 · {name}", + "Model": "模型", + "Effort": "推理强度", + "Model & approvals": "模型与审批", + "model (default)": "模型(默认)", + "An alias (opus, sonnet…) or a full model name.": "别名(opus、sonnet…)或完整的模型名。", + " (session default)": "(会话默认)", + " Applies to a running agent within seconds.": " 几秒内对运行中的 agent 生效。", + " Applies when the agent next starts.": " 下次启动 agent 时生效。", + "not available for this agent": "该 agent 不支持", + "Approval mode": "审批模式", + "Approval": "审批", + "Who approves this agent's steps": "由谁审批该 agent 的每一步", + "Fully autonomous": "完全自主", + "Never asks. The agent reads, edits and runs commands on its own.": "从不询问。agent 自行读取、编辑和运行命令。", + "Auto review": "自动审查", + "The CLI's own reviewer approves safe steps; risky ones come to you. (Claude: auto mode · Codex: on-request)": "由 CLI 自带的审查器批准安全的步骤,有风险的交给你。(Claude:auto 模式 · Codex:on-request)", + "Auto-approve edits": "自动批准编辑", + "File edits go through; shell commands and other tools wait for you. (Claude; Codex asks for everything)": "文件编辑直接通过;shell 命令和其他工具等你批准。(Claude;Codex 全部询问)", + "Ask me every step": "每一步都问我", + "Every edit and command waits for your approval.": "每次编辑和命令都等待你的批准。", + "Gemini agents run unattended (yolo); step approvals need Claude or Codex. Their steps still show in Activity.": "Gemini agent 无人值守运行(yolo);逐步审批需要 Claude 或 Codex。它们的步骤仍会显示在“活动”中。", + "Token limit": "Token 上限", + "Input + output tokens. When reached the agent is paused and its turn stops. Used so far: {used}.": "输入 + 输出 token。达到后 agent 会被暂停,当前回合停止。已用:{used}。", + "Save": "保存", + "Saved": "已保存", + + // --- Approvals & activity --- + "Approvals": "审批", + "Waiting for your approval": "等待你的批准", + "{n} step waiting for your approval": "{n} 个步骤等待你的批准", + "{n} steps waiting for your approval": "{n} 个步骤等待你的批准", + "{name} wants to run {tool}": "{name} 想要运行 {tool}", + "Allow": "允许", + "Always allow": "始终允许", + "Allow this tool (for shell: this program) for the rest of the agent's session": "在该 agent 本次会话的剩余时间内允许此工具(shell 则为该程序)", + "Deny": "拒绝", + "Note to the agent (optional, sent with a denial)": "给 agent 的备注(可选,随拒绝一起发送)", + "No activity yet. Steps appear here as agents think, read, edit and run commands.": "还没有活动。agent 思考、读取、编辑和运行命令时,步骤会显示在这里。", + "Claude and Codex agents launched by multiagents report every step; Gemini reports its tool calls.": "由 multiagents 启动的 Claude 和 Codex agent 会上报每一步;Gemini 上报其工具调用。", + "team messages": "团队消息", + "thinking": "思考", + "Newest steps ↓": "最新步骤 ↓", + "Clear": "清空", + "(no output)": "(无输出)", + "(empty)": "(空)", + "{n} step": "{n} 个步骤", + "{n} steps": "{n} 个步骤", + "tool call": "工具调用", + "tool result": "工具结果", + "text": "回复", + "status": "状态", + "error": "错误", + "approval": "审批", + "turn end": "回合结束", + "update": "更新", + + // --- Dialogs & forms --- + "Cancel": "取消", + "Confirm": "确认", + "OK": "确定", + "Error": "错误", + "Type {text} to confirm:": "输入 {text} 以确认:", + "The text does not match": "输入的文本不匹配", + "{label} is required": "请填写{label}", + "{label}: done": "{label}:完成", + "{label} failed: {error}": "{label}失败:{error}", + "Name": "名称", + "Display name": "显示名称", + "Role": "角色", + "Role description": "角色说明", + "Expertise, platform, constraints (optional)": "专长、平台、约束(可选)", + "e.g. Code Reviewer": "例如 代码评审", + "e.g. Codex Reviewer": "例如 Codex 评审", + "Assign role": "分配角色", + "Change role of {name}": "更改 {name} 的角色", + "The agent receives a role_assignment message. Roles containing review, QA, test or lead count as reviewers in the approval flow.": "agent 会收到一条 role_assignment 消息。包含 review、QA、test 或 lead 的角色在审批流程中算作评审者。", + "Role assigned": "已分配角色", + "Feedback": "反馈", + "What must change before you approve?": "批准前需要改什么?", + "Write the feedback first": "请先填写反馈", + "Delivered as actionable feedback from you; the agent moves to addressing feedback and the review loop runs again.": "将作为你的可执行反馈发送;agent 进入处理反馈中,评审循环重新开始。", + "Sent back": "已退回", + "Agent type": "Agent 类型", + " (not installed)": "(未安装)", + "Task / instruction": "任务 / 指令", + "What should this agent do first?": "这个 agent 首先要做什么?", + "Launch Agent": "启动 Agent", + "Add agent": "添加 Agent", + "Agent launched": "Agent 已启动", + "Name, role and task are required": "名称、角色和任务均为必填", + "{type} CLI is not installed on this machine": "此机器未安装 {type} CLI", + "The agent runs under this dashboard (keep it open). Launching takes 15–60s before it connects.": "该 agent 在此控制台下运行(请保持页面服务开启)。启动后需 15–60 秒才能连上。", + "Working directory": "工作目录", + "Project directory": "项目目录", + "Project": "项目", + "Session name": "会话名称", + "/absolute/path/to/project": "/项目的/绝对/路径", + "/absolute/path/to/project (created if missing)": "/项目的/绝对/路径(不存在则创建)", + "e.g. 食物过敏论文 / Manuscript Revision": "例如 食物过敏论文 / Manuscript Revision", + "Description": "描述", + "Create session": "创建会话", + "Working directory and session name are required": "工作目录和会话名称均为必填", + "Like multiagents session create: writes .multiagents/session.json into the directory. Add agents afterwards, or use New Team to launch a whole team.": "同 multiagents session create:在目录中写入 .multiagents/session.json。之后再添加 agent,或用新建团队一次启动整个团队。", + "Session \"{id}\" created": "会话“{id}”已创建", + "Team goal / prompt": "团队目标 / 提示词", + "What should the team achieve?": "团队要完成什么?", + "Plan (optional)": "计划(可选)", + "One task per line. Prefix with an agent name and a colon to assign it, e.g. \"Claude Reviewer: Review the API\"": "每行一个任务。以 agent 名称加冒号开头即可分配,例如“Claude Reviewer: Review the API”", + "Workflow": "工作流", + "Require reviewer approval": "需要评审者批准", + "Work counts as finished only after an explicit approve.": "只有明确批准后工作才算完成。", + "Require actionable feedback before revision": "修改前需要可执行的反馈", + "Reviewers must say concretely what to change.": "评审者必须具体说明要改什么。", + "Pause for human approval before release": "释放前暂停等待人工批准", + "Human gate: once everything is approved the session waits for you (Approve & Release / Send back) instead of ending itself.": "人工把关:全部批准后会话会等你处理(批准并释放 / 退回),而不是自行结束。", + "Agent-specific task (optional — the team goal is shared with everyone)": "该 agent 的专属任务(可选 — 团队目标会发给所有人)", + "Start Team": "启动团队", + "Add at least one agent": "至少添加一个 agent", + "Every agent needs a name and a role": "每个 agent 都需要名称和角色", + "Not installed on this machine: {list}": "此机器未安装:{list}", + "Starting team — agents take 15–60s to connect…": "正在启动团队 — agent 需要 15–60 秒才能连上…", + "Team started: {id}": "团队已启动:{id}", + "Worker + Reviewer": "工作者 + 评审者", + "Engineer + Reviewer + QA": "工程师 + 评审者 + QA", + "Worker": "工作者", + "Reviewer": "评审者", + "Engineer": "工程师", + "Researcher": "研究员", + "Writer": "写作者", + "Editor": "编辑", + "Tech Lead": "技术负责人", + "Software Engineer": "软件工程师", + "Code Reviewer": "代码评审", + "QA Engineer": "QA 工程师", + "UI/UX Designer": "UI/UX 设计师", + "optional: src/api/**, tests/**": "可选:src/api/**, tests/**", + "File ownership": "文件所有权", + "URL": "URL", + "Sessions ": "会话", + "Peers": "节点", + "Current session": "当前会话", + "This dashboard runs": "此控制台运行", + "running": "运行中", + "not running": "未运行", + "{n} ({active} active)": "{n}({active} 个活跃)", + "{n} registered": "已注册 {n} 个", + "{n} agent(s) of the current session": "当前会话的 {n} 个 agent", + "Stop the broker from a terminal with multiagents kill-broker — it disconnects every agent on the machine.": "在终端运行 multiagents kill-broker 可停止 broker — 这会断开本机所有 agent。", + "Switching session failed: {error}": "切换会话失败:{error}", + "renamed to {name}": "重命名为 {name}", + "role set to {role}": "角色设为 {role}", + + // --- Appearance & avatars --- + "Background": "背景", + "Background image": "背景图片", + "Choose image…": "选择图片…", + "Remove image": "移除图片", + "Image opacity": "图片不透明度", + "Panel opacity": "面板不透明度", + "Blur behind panels": "面板背景模糊", + "The image sits under the whole page. Lower the panel opacity to let more of it show through.": "图片垫在整个页面下方。降低面板不透明度可以让它透出来更多。", + "Your avatar": "你的头像", + "Customizations are saved in this browser only.": "自定义设置仅保存在当前浏览器。", + "Reset appearance": "恢复默认外观", + "Done": "完成", + "Official icon": "官方图标", + "Initials": "首字母", + "Image": "图片", + "Upload image…": "上传图片…", + "Text": "文字", + "Color": "颜色", + "Reset to default": "恢复默认", + "Avatar · {name}": "头像 · {name}", + "Avatar saved": "头像已保存", + "This image is too large to store in the browser.": "图片太大,浏览器无法保存。", + "Could not read the image": "无法读取该图片", + "Applies to this agent in this session, in this browser.": "仅对此会话中的这个 agent 生效,仅保存在当前浏览器。", + "OpenAI's icon is not bundled; upload it as an image if you like.": "未内置 OpenAI 图标;如需要可以作为图片上传。", +}; + +// Text the server and the agents produce. Each rule maps a pattern to a +// replacement function; captured parts are translated again where they are +// themselves known phrases (tool titles, names stay as they are). +const RULES_ZH = [ + // Activity timeline + [/^Session started · (.+)$/, (m) => `会话已启动 · ${tz(m[1])}`], + [/^Thinking \(redacted\)$/, () => "思考(已隐藏)"], + [/^Thinking$/, () => "思考"], + [/^Reply$/, () => "回复"], + [/^Result$/, () => "结果"], + [/^Failed$/, () => "失败"], + [/^Thread started$/, () => "线程已启动"], + [/^Updated the plan$/, () => "更新了计划"], + [/^Update the todo list$/, () => "更新待办清单"], + [/^(Result|Failed) · (.+)$/s, (m) => `${m[1] === "Result" ? "结果" : "失败"} · ${tz(m[2])}`], + [/^Turn finished(?: · \$([\d.]+) so far)?$/, (m) => `本轮结束${m[1] ? ` · 累计 $${m[1]}` : ""}`], + [/^Turn ended \((.+?)\)(?: · \$([\d.]+) so far)?$/, (m) => `本轮异常结束(${m[1]})${m[2] ? ` · 累计 $${m[2]}` : ""}`], + [/^Turn (completed|interrupted|failed|inProgress|finished)$/, (m) => `本轮${{ completed: "完成", interrupted: "已中断", failed: "失败", inProgress: "进行中", finished: "结束" }[m[1]]}`], + [/^Waiting for approval · (.+)$/s, (m) => `等待批准 · ${tz(m[1])}`], + [/^(Approved|Always allowed|Denied|Cancelled) · (.+)$/s, (m) => `${{ Approved: "已批准", "Always allowed": "始终允许", Denied: "已拒绝", Cancelled: "已取消" }[m[1]]} · ${tz(m[2])}`], + [/^(Approved|Always allowed|Denied): (.+)$/s, (m) => `${{ Approved: "已批准", "Always allowed": "已始终允许", Denied: "已拒绝" }[m[1]]}:${tz(m[2])}`], + [/^Agent process exited \(code (.+)\)$/, (m) => `agent 进程已退出(退出码 ${m[1]})`], + [/^Paused — the running turn was interrupted$/, () => "已暂停 — 正在运行的回合已中断"], + [/^Model → (.+)$/, (m) => `模型 → ${tz(m[1])}`], + [/^Approval mode → (.+)$/, (m) => `审批模式 → ${m[1]}`], + [/^Effort → (.+) \(applies when the agent restarts\)$/, (m) => `推理强度 → ${tz(m[1])}(agent 重启后生效)`], + [/^Could not switch to model (.+)$/, (m) => `无法切换到模型 ${tz(m[1])}`], + [/^Could not switch to approval mode (.+)$/, (m) => `无法切换到审批模式 ${m[1]}`], + [/^Settings for the next turn: model (.+) · effort (.+) · approvals (.+)$/, (m) => `下一回合设置:模型 ${tz(m[1])} · 推理强度 ${tz(m[2])} · 审批 ${m[3]}`], + [/^Stopped: (.+)$/s, (m) => `已停止:${tz(m[1])}`], + [/^Raise the limit in the dashboard, then resume the agent\.$/, () => "请在控制台提高限额,然后继续该 agent。"], + [/^Read (.+)$/s, (m) => `读取 ${m[1]}`], + [/^Write (.+)$/s, (m) => `写入 ${m[1]}`], + [/^Edit notebook (.+)$/s, (m) => `编辑笔记本 ${m[1]}`], + [/^Edit (.+)$/s, (m) => `编辑 ${m[1]}`], + [/^Created (.+)$/s, (m) => `新建 ${m[1]}`], + [/^Edited (.+)$/s, (m) => `编辑 ${m[1]}`], + [/^Deleted (.+)$/s, (m) => `删除 ${m[1]}`], + [/^Changed (\d+) files$/, (m) => `修改了 ${m[1]} 个文件`], + [/^Grep "(.*)" in (.+)$/s, (m) => `搜索内容 "${m[1]}"(在 ${m[2]} 中)`], + [/^Grep "(.*)"$/s, (m) => `搜索内容 "${m[1]}"`], + [/^Glob (.+)$/s, (m) => `匹配文件 ${m[1]}`], + [/^List (.+)$/s, (m) => `列出 ${m[1]}`], + [/^Fetch (.+)$/s, (m) => `获取 ${m[1]}`], + [/^Search "(.*)"$/s, (m) => `搜索 "${m[1]}"`], + [/^Subagent: (.+)$/s, (m) => `子代理:${m[1]}`], + [/^Team: (.+)$/, (m) => `团队:${m[1]}`], + // Limits + [/^(session )?token limit reached \((.+) \/ (.+)\)$/, (m) => `${m[1] ? "会话" : ""} token 达到上限(${m[2]} / ${m[3]})`.trim()], + [/^(session )?cost limit reached \((.+) \/ (.+)\)$/, (m) => `${m[1] ? "会话" : ""}费用达到上限(${m[2]} / ${m[3]})`], + [/^(.+) paused: (.+)$/s, (m) => `${m[1]} 已暂停:${tz(m[2])}`], + // Control API and tool results + [/^Broadcast to (\d+) agent\(s\)$/, (m) => `已广播给 ${m[1]} 个 agent`], + [/^Broadcast delivered to (\d+) agents\.$/, (m) => `已广播给 ${m[1]} 个 agent。`], + [/^Sent to (.+)$/, (m) => `已发送给 ${m[1]}`], + [/^Message sent to (.+)\.$/, (m) => `消息已发送给 ${m[1]}。`], + [/^Sent back to (.+) \((.+)\)$/, (m) => `已退回给 ${m[1]}(${tz(m[2].replace(/_/g, " "))})`], + [/^Paused (\d+) agents$/, (m) => `已暂停 ${m[1]} 个 agent`], + [/^Resumed (\d+) agents$/, (m) => `已继续 ${m[1]} 个 agent`], + [/^Paused (.+)$/, (m) => `已暂停 ${m[1]}`], + [/^Resumed (.+)$/, (m) => `已继续 ${m[1]}`], + [/^Renamed to (.+)$/, (m) => `已重命名为 ${m[1]}`], + [/^(.+) is now (.+)$/, (m) => `${m[1]} 现在是 ${m[2]}`], + [/^Updated (.+?)\.( The model applies when the agent next starts\.| Effort applies when the agent next starts\.)?$/, (m) => `已更新 ${m[1]}。${m[2] ? (m[2].includes("model") ? "模型将在 agent 下次启动时生效。" : "推理强度将在 agent 下次启动时生效。") : ""}`], + [/^Updated (\S+) to (.+)\.$/, (m) => `已将 ${tz(m[1])} 更新为 ${m[2]}。`], + [/^Usage limits saved$/, () => "用量限额已保存"], + [/^Usage limits removed$/, () => "用量限额已移除"], + [/^Unlocked (.+)$/, (m) => `已解锁 ${m[1]}`], + [/^Assigned (.+)$/, (m) => `已分配 ${m[1]}`], + [/^Session "(.+)" archived\.$/, (m) => `会话“${m[1]}”已归档。`], + [/^Human gate on: approved work waits for your release\.$/, () => "人工把关已开启:已批准的工作会等待你释放。"], + [/^Human gate off: the session ends itself once everything is approved\.$/, () => "人工把关已关闭:全部批准后会话自动结束。"], + [/^Could not find agent matching "(.+)"$/, (m) => `找不到匹配“${m[1]}”的 agent`], + [/^Released (.+?)\. Task state: (.+?)\. Agent can now disconnect\.$/, (m) => `已释放 ${m[1]}。任务状态:${tz(m[2].replace(/_/g, " "))}。agent 现在可以断开连接。`], + [/^Removed (.+) from the session\.$/, (m) => `已将 ${m[1]} 移出会话。`], + [/^Added (.+) \((\w+)\) as (.+) in slot (\d+) \(PID (\d+)\)\.$/, (m) => `已添加 ${m[1]}(${m[2]}),角色 ${m[3]},席位 ${m[4]}(PID ${m[5]})。`], + [/^All agents released\. Session complete\.$/, () => "所有 agent 已释放,会话完成。"], + [/^Session "(.+)" ended\. (\d+) agents stopped\. Session archived\.(.*)$/s, (m) => `会话“${m[1]}”已结束。已停止 ${m[2]} 个 agent,会话已归档。${m[3]}`], + [/^Session "(.+)" permanently deleted\. Removed: (\d+) slot\(s\), (\d+) message\(s\), (\d+) plan\(s\)\.$/, (m) => `会话“${m[1]}”已永久删除。共删除 ${m[2]} 个席位、${m[3]} 条消息、${m[4]} 个计划。`], + [/^Extended session duration to (.+)$/, (m) => `会话时长已延长至 ${m[1]}`], + [/^Nothing to change$/, () => "没有需要修改的内容"], + [/^A person denied this tool call\.?(?:: (.+))?$/s, (m) => `有人拒绝了此工具调用${m[1] ? `:${m[1]}` : "。"}`], + [/^Paused by orchestrator$/, () => "已被调度器暂停"], + [/^HTTP (\d+)$/, (m) => `HTTP ${m[1]}`], +]; + +/** Translate a captured fragment when it is itself a known phrase. */ +function tz(s) { + if (LANG !== "zh") return s; + return ZH[s] ?? matchRule(s) ?? s; +} + +function matchRule(s) { + for (const [re, fn] of RULES_ZH) { + const m = re.exec(s); + if (m) return fn(m); + } + return null; +} + +/** + * Translate `en` into the current language and fill in `{name}` placeholders. + * Unknown strings come back unchanged (in English). + */ +function tr(en, vars) { + let s = en == null ? "" : String(en); + if (LANG === "zh" && s) s = ZH[s] ?? matchRule(s) ?? s; + if (vars) s = s.replace(/\{(\w+)\}/g, (all, k) => (k in vars ? String(vars[k] ?? "") : all)); + return s; +} + +// --- Static markup: text nodes and title / placeholder / aria-label --- + +const I18N_ATTRS = ["title", "placeholder", "aria-label"]; +const textOrigin = new WeakMap(); // text node → { en, shown } +let staticNodes = null; + +/** + * Translate the page's own markup. The first call (at boot, before app code + * renders anything) records which nodes are static; later calls re-translate + * only those, so names and file paths rendered since are never touched. + */ +function translateStatic() { + if (!staticNodes) { + staticNodes = { text: collectText(document.body), els: [...document.body.querySelectorAll(I18N_ATTRS.map((a) => `[${a}]`).join(","))] }; + } + for (const n of staticNodes.text) if (n.isConnected) translateText(n); + for (const el of staticNodes.els) if (el.isConnected) translateAttrs(el); +} + +/** Translate a freshly built subtree (modal bodies). */ +function translateTree(root) { + if (!root) return; + for (const n of collectText(root)) translateText(n); + for (const el of [root, ...root.querySelectorAll(I18N_ATTRS.map((a) => `[${a}]`).join(","))]) translateAttrs(el); +} + +function collectText(root) { + const nodes = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode: (n) => { + const p = n.parentElement; + if (!p || p.closest("script, style, code, pre, textarea, .mono, [data-no-i18n]")) return NodeFilter.FILTER_REJECT; + return /[A-Za-z]/.test(n.nodeValue) || textOrigin.has(n) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; + }, + }); + for (let n = walker.nextNode(); n; n = walker.nextNode()) nodes.push(n); + return nodes; +} + +function translateText(n) { + let rec = textOrigin.get(n); + // Text that changed since we last translated it was set by app code: start over + if (!rec || rec.shown !== n.nodeValue) { rec = { en: n.nodeValue }; textOrigin.set(n, rec); } + const key = rec.en.trim(); + const out = key ? rec.en.replace(key, () => tr(key)) : rec.en; + if (out !== n.nodeValue) n.nodeValue = out; + rec.shown = n.nodeValue; +} + +function translateAttrs(el) { + for (const a of I18N_ATTRS) { + const v = el.getAttribute(a); + if (v == null) continue; + const k = `i18n${a.replace(/-./g, (x) => x[1].toUpperCase()).replace(/^./, (x) => x.toUpperCase())}`; + // Remember the English original; a value set since then replaces it + if (el.dataset[k] === undefined || el.dataset[`${k}Shown`] !== v) el.dataset[k] = v; + const out = tr(el.dataset[k]); + if (out !== v) el.setAttribute(a, out); + el.dataset[`${k}Shown`] = out; + } +} diff --git a/dashboard/index.html b/dashboard/index.html index 7f4828d..86d7648 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -16,6 +16,7 @@ +
    @@ -30,6 +31,8 @@ Broker + + @@ -238,6 +241,7 @@
    + diff --git a/dashboard/server.ts b/dashboard/server.ts index ff97a44..9bdd468 100644 --- a/dashboard/server.ts +++ b/dashboard/server.ts @@ -289,6 +289,7 @@ const indexHtml = Bun.file(`${DASHBOARD_DIR}index.html`); // The UI's own assets (a fixed list: nothing else in the directory is served) const ASSETS: Record = { "/app.js": "text/javascript; charset=utf-8", + "/i18n.js": "text/javascript; charset=utf-8", "/styles.css": "text/css; charset=utf-8", }; diff --git a/dashboard/styles.css b/dashboard/styles.css index 3f07ace..37c87b1 100644 --- a/dashboard/styles.css +++ b/dashboard/styles.css @@ -652,3 +652,38 @@ html[data-theme="dark"] .doc-page { background: var(--surface); } .chat-scroll { padding: 12px 10px 8px; } .session-switch { max-width: 150px; } } + +/* --- Avatars: official icons, initials or an uploaded image --- */ +.avatar.has-icon svg { width: 58%; height: 58%; display: block; } +.avatar.has-img { background: var(--surface3); overflow: visible; } +.avatar.has-img img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block; } +.avatar.clickable { cursor: pointer; transition: box-shadow 0.12s; } +.avatar.clickable:hover { box-shadow: 0 0 0 2px var(--accent); } +.avatar.mini { width: 18px; height: 18px; font-size: 8px; border-radius: 5px; display: inline-grid; vertical-align: -4px; } +.avatar-editor .avatar-preview { display: flex; justify-content: center; margin-bottom: 14px; } +.avatar-editor .avatar-preview .avatar { width: 72px; height: 72px; font-size: 26px; border-radius: 20px; } +.seg { display: flex; flex-wrap: wrap; gap: 6px; } +.swatches { display: flex; flex-wrap: wrap; gap: 6px; } +.swatch { width: 22px; height: 22px; border-radius: 6px; border: 2px solid transparent; cursor: pointer; padding: 0; } +.swatch.on { border-color: var(--text); } +.file-btn { cursor: pointer; } +.form-grid [hidden] { display: none !important; } +.range-row { display: flex; align-items: center; gap: 10px; } +.form-grid .range-row input[type=range] { flex: 1; width: auto; accent-color: var(--accent); } +.range-row span { width: 42px; text-align: right; font-variant-numeric: tabular-nums; color: var(--text-dim); font-size: 12px; } +.lang-btn { font-size: 11.5px; font-weight: 650; width: auto; min-width: 30px; padding: 0 6px; } + +/* --- Background image under the whole page --- */ +#app-bg { + position: fixed; inset: 0; z-index: 0; pointer-events: none; + background-size: cover; background-position: center; background-repeat: no-repeat; +} +html:not(.has-bg) #app-bg { display: none; } +html.has-bg .app { position: relative; z-index: 1; } +html.has-bg .topbar, html.has-bg .pane { + background: color-mix(in srgb, var(--surface) var(--panel-alpha, 82%), transparent); +} +html.has-bg .pane.center { background: color-mix(in srgb, var(--bg) var(--panel-alpha, 82%), transparent); } +html.has-bg .session-bar, html.has-bg .tabs, html.has-bg .chat-toolbar, +html.has-bg .composer, html.has-bg #act-approvals, html.has-bg .day-sep span { background: transparent; } +html.has-bg.bg-blur .topbar, html.has-bg.bg-blur .pane { backdrop-filter: blur(14px) saturate(1.1); -webkit-backdrop-filter: blur(14px) saturate(1.1); } diff --git a/tests/web-dashboard.test.ts b/tests/web-dashboard.test.ts index 61d91fc..8df7d3b 100644 --- a/tests/web-dashboard.test.ts +++ b/tests/web-dashboard.test.ts @@ -252,6 +252,16 @@ describe("Web Dashboard — UI assets", () => { expect(html).toContain('id="preview-body"'); expect(html).toContain('src="/app.js"'); expect(html).toContain('href="/styles.css"'); + // Translations load first: app.js calls tr() while it boots + expect(html.indexOf('src="/i18n.js"')).toBeGreaterThan(-1); + expect(html.indexOf('src="/i18n.js"')).toBeLessThan(html.indexOf('src="/app.js"')); + }); + + test("serves the translations", async () => { + const res = await fetch(`${DASHBOARD_URL}/i18n.js`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("javascript"); + expect(await res.text()).toContain("function tr("); }); test("serves the script with the WebSocket logic", async () => { diff --git a/tests/web-i18n.test.ts b/tests/web-i18n.test.ts new file mode 100644 index 0000000..9d2fa62 --- /dev/null +++ b/tests/web-i18n.test.ts @@ -0,0 +1,69 @@ +/** + * Dashboard translations: every literal tr() key in app.js has a Chinese + * entry, and server-generated text (activity titles, toasts) is matched by + * the pattern rules. + */ +import { describe, test, expect } from "bun:test"; +import { join } from "node:path"; + +const DIR = join(import.meta.dir, "..", "dashboard"); + +async function loadI18n(lang: string) { + const src = await Bun.file(join(DIR, "i18n.js")).text(); + const localStorage = { getItem: () => lang, setItem: () => {} }; + const navigator = { language: "en-US" }; + return new Function("localStorage", "navigator", `${src}\nreturn { tr, ZH, getLang, setLang, locale };`)(localStorage, navigator) as { + tr: (en: string, vars?: Record) => string; + ZH: Record; + getLang: () => string; + setLang: (l: string) => void; + locale: () => string; + }; +} + +/** String literals passed straight to tr(), plus the label strings app.js translates indirectly. */ +async function appKeys(): Promise { + const js = await Bun.file(join(DIR, "app.js")).text(); + const keys = new Set(); + for (const m of js.matchAll(/\btr\(\s*"((?:[^"\\]|\\.)*)"/g)) keys.add(JSON.parse(`"${m[1]}"`)); + for (const m of js.matchAll(/\b(?:label|submitLabel|confirmLabel|title): "((?:[^"\\]|\\.)*)"/g)) keys.add(JSON.parse(`"${m[1]}"`)); + for (const m of js.matchAll(/\brun\([^,]+, "((?:[^"\\]|\\.)*)"/g)) keys.add(JSON.parse(`"${m[1]}"`)); + return [...keys]; +} + +describe("dashboard i18n", () => { + test("every UI string app.js translates has a Chinese entry", async () => { + const { ZH } = await loadI18n("zh"); + const missing = (await appKeys()).filter((k) => !(k in ZH)); + expect(missing).toEqual([]); + }); + + test("placeholders survive translation and English passes through", async () => { + const zh = await loadI18n("zh"); + expect(zh.tr("Release {name}?", { name: "Claude" })).toBe("释放 Claude?"); + expect(zh.tr("some unknown text")).toBe("some unknown text"); + expect(zh.locale()).toBe("zh-CN"); + const en = await loadI18n("en"); + expect(en.tr("Release {name}?", { name: "Claude" })).toBe("Release Claude?"); + expect(en.locale()).toBe("en-US"); + }); + + test("server-generated activity titles and messages are matched", async () => { + const { tr } = await loadI18n("zh"); + expect(tr("Waiting for approval · Edit src/app.ts")).toBe("等待批准 · 编辑 src/app.ts"); + expect(tr("Result · Read README.md")).toBe("结果 · 读取 README.md"); + expect(tr("Turn finished · $0.0123 so far")).toBe("本轮结束 · 累计 $0.0123"); + expect(tr("Session started · default model")).toBe("会话已启动 · 默认模型"); + expect(tr("token limit reached (1,200 / 1,000)")).toBe("token 达到上限(1,200 / 1,000)"); + expect(tr("Broadcast to 3 agent(s)")).toBe("已广播给 3 个 agent"); + expect(tr("Paused 2 agents")).toBe("已暂停 2 个 agent"); + }); + + test("the language choice is remembered", async () => { + const i18n = await loadI18n("en"); + i18n.setLang("zh"); + expect(i18n.getLang()).toBe("zh"); + i18n.setLang("xx"); + expect(i18n.getLang()).toBe("zh"); + }); +}); From 21ac08b426a902dd25df3e293600675e1791f89a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 05:09:18 +0000 Subject: [PATCH 06/24] Keep the dashboard working when an older server serves it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dashboard server started before an update serves the new index.html and app.js from disk but 404s i18n.js, which it doesn't know. app.js then threw at boot and the page sat on "connecting…". It now falls back to English and says the server needs a restart. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SP5EAuSkQZm3jj3PTTWDfN --- dashboard/app.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/dashboard/app.js b/dashboard/app.js index afc6c26..e9503bb 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -10,6 +10,17 @@ "use strict"; +// A dashboard server started before an update serves the new page but not +// files it doesn't know yet (i18n.js): run in English instead of stopping here. +const STALE_SERVER = typeof tr !== "function"; +if (STALE_SERVER) { + const en = (s, vars) => String(s ?? "").replace(/\{(\w+)\}/g, (all, k) => (vars && k in vars ? String(vars[k] ?? "") : all)); + Object.assign(window, { + tr: en, LANGS: { en: "English" }, getLang: () => "en", setLang: () => {}, locale: () => "en-US", + translateStatic: () => {}, translateTree: () => {}, + }); +} + let state = null; let activeTab = "chat"; let ws = null; @@ -2873,3 +2884,4 @@ switchRightTab(store.get("ma-rtab", "preview")); closePreview(); renderTree(); connectWs(); +if (STALE_SERVER) toast("This dashboard server is older than the page: stop the old dashboard process and start it again. / 网页服务是旧版本:请结束旧的 dashboard 进程后重新启动。", "error"); From 55c4dd4347b3641948c673b056dae2817114b2df Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 05:11:11 +0000 Subject: [PATCH 07/24] Let CI be run by hand from the Actions tab Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SP5EAuSkQZm3jj3PTTWDfN --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9cbf5d..f80cc01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,8 @@ on: pull_request: push: branches: [main] + # Run it by hand from the Actions tab (Run workflow) on any branch + workflow_dispatch: permissions: contents: read From 8503852a4a3bf04ffac8d6b3accc652bdd65a072 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 06:04:35 +0000 Subject: [PATCH 08/24] Show plan limits, let the person interrupt and question agents, stream thinking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan limits: the Usage tab (and a top-bar meter) shows each CLI's subscription windows, read without a model turn — Claude's 5-hour and weekly windows (get_usage control request on a throwaway claude -p), Codex's 5-hour and weekly windows (account/rateLimits/read), Gemini's daily per-model quota (Code Assist retrieveUserQuota with the stored Google login, never refreshed here). Cached two minutes. Talking to agents: agents can now reply to "human" (the broker refused it) and are told the id to use. "Interrupt" on the composer stops one agent's turn so it answers first. Q&A mode holds the team: running turns stop, agents hear only the person, edits and commands are refused (reads stay allowed; Codex turns run read-only), teammates' messages wait, and nudges, respawns and auto-end pause until it ends. Live view: Claude runs with --include-partial-messages; a LiveTracker per agent turns Claude, Codex and Gemini deltas into the block being written and the current step, kept in memory on the broker. The chat shows typing dots, the step and the thinking as it forms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SP5EAuSkQZm3jj3PTTWDfN --- CLAUDE.md | 3 + README.md | 10 +- adapters/base-adapter.ts | 4 +- broker.ts | 63 +++++- dashboard/app.js | 196 ++++++++++++++++- dashboard/control.ts | 34 ++- dashboard/i18n.js | 72 +++++++ dashboard/index.html | 4 + dashboard/server.ts | 30 ++- dashboard/styles.css | 39 ++++ orchestrator/claude-driver.ts | 5 +- orchestrator/codex-driver.ts | 8 + orchestrator/delivery.ts | 2 + orchestrator/live.ts | 163 ++++++++++++++ orchestrator/plan-usage.ts | 310 +++++++++++++++++++++++++++ orchestrator/runtime.ts | 88 +++++++- shared/broker-client.ts | 10 + shared/constants.ts | 7 + shared/types.ts | 14 ++ tests/codex-driver.test.ts | 9 + tests/fakes/fake-claude.ts | 26 +++ tests/fakes/fake-codex-app-server.ts | 9 + tests/live.test.ts | 76 +++++++ tests/plan-usage.test.ts | 97 +++++++++ tests/web-control.test.ts | 102 +++++++++ tests/web-i18n.test.ts | 13 ++ 26 files changed, 1372 insertions(+), 22 deletions(-) create mode 100644 orchestrator/live.ts create mode 100644 orchestrator/plan-usage.ts create mode 100644 tests/live.test.ts create mode 100644 tests/plan-usage.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 06ee0bc..a6164fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,9 @@ Peer discovery and messaging MCP channel for Claude Code instances. - `dashboard/control.ts` — Web control API (`POST /api/`). Every mutation names its `session_id` and checks the slot belongs to it; lifecycle actions call `callOrchestratorTool`, record edits go to the broker. Human actions use sender id `human`. A session with `config.human_gate` is not auto-ended while agents sit in `approved`. - `dashboard/files.ts` — Read-only `GET /api/fs/{list,raw,resolve,check,docx}` over the session's `project_dir`. Every path goes through `resolveInRoot` (realpath, so symlinks cannot escape); HTML/SVG are served with a sandbox CSP. `.docx` is rendered server-side (own zip reader, no dependency). UI: `dashboard/index.html` + `i18n.js` + `app.js` + `styles.css`, plain JS, themes via `html[data-theme]`. `i18n.js` loads first: `tr(english, vars)` looks the English text up in `ZH` (then `RULES_ZH` for server-generated text); static HTML is translated in place at boot. A new UI string needs a `ZH` entry (`tests/web-i18n.test.ts` checks). Avatars and the background are per-browser localStorage (`ma-avatars`, `ma-appearance`). - `orchestrator/activity.ts` — Turns Claude stream-json, Codex app-server notifications and Gemini stream-json into one activity timeline (broker `/activity/*`), and waits on tool approvals stored in the broker (`/approvals/*`, polled, so any host's dashboard can answer). Per-slot settings live in the slot snapshot (`model`, `effort`, `approval_mode`, `token_limit`); `runtime.ts` reconciles running drivers with them each delivery tick (Claude: `set_model` / `set_permission_mode` control requests; Codex: `driver.settings` for the next turn), interrupts the turn of a paused slot, and enforces session `config.limits` (`limitVerdict`). Claude runs with `--permission-prompt-tool stdio`; its `can_use_tool` control requests go to `onPermissionRequest` (queued until a handler is set, denied after 30s without one). +- `orchestrator/live.ts` — `LiveTracker`: the block an agent is writing right now (Claude `stream_event` deltas from `--include-partial-messages`, Codex `item/*/delta`, Gemini message deltas) plus its phase, published to the broker's in-memory `/activity/live/*` (busy entries not refreshed for 60s expire). The chat polls `/api/live` for the typing indicator. +- `orchestrator/plan-usage.ts` — Subscription windows without a model turn: Claude `get_usage` control request on a throwaway `claude -p` (no MCP, hooks off, cwd tmp), Codex `account/rateLimits/read` on a throwaway app-server, Gemini Code Assist `retrieveUserQuota` with `~/.gemini/oauth_creds.json` (never refreshed here). Cached 2 min; `GET /api/plan-usage`. +- Talking to agents: `human` is a valid message target (agents see `HUMAN_SENDER_LABEL` with the id to reply to). `messages/send` with `interrupt` sets the slot's `interrupt_at`; the host running the driver stops its turn. Session `config.qa_mode` (Q&A mode, `session/qa`): the broker hands agents only `human` / orchestrator messages (`qaFilter`), `runtime.ts` interrupts running turns, runs drivers in ask mode and only lets read-only tools through, and skips nudges, respawns and auto-end. - `orchestrator/supervision.ts` — Driver registry (one driver per slot), non-overlapping loop ticks, release and watchdog verdicts; pure enough to unit-test. - `orchestrator/monitor.ts` — Slot summaries and token accounting. Claude's `result.usage` is per turn (added as is); Codex's `thread/tokenUsage/updated.total` is cumulative (diffed). - `shared/types.ts` — Shared TypeScript types for broker API. diff --git a/README.md b/README.md index a61a638..c6eeb04 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,12 @@ A step that needs you shows up as a card with the exact command or diff: **Allow **Models, usage and limit locks.** The same Settings dialog switches an agent's model (an alias such as `opus`/`sonnet`, or a full name) and its effort. Claude switches live via `set_model`; Codex switches at its next turn; Gemini and Claude's effort switch at the agent's next start. The **Usage** tab shows tokens (input, output, cache reads) and Claude's reported cost per agent. There you can set limits per agent or for the whole session, in tokens or USD. An agent that reaches a limit is paused and its running turn is interrupted; raise the limit, then resume it. +**Plan limits (5-hour / weekly).** The **Usage** tab starts with each CLI's subscription quota, read the way the CLIs' own `/usage` screens read it and without starting a model turn; the fullest window of each also shows in the top bar. Claude: the claude.ai plan's 5-hour window and weekly windows (all models, Opus, Sonnet, per-model ones), via Claude Code's `get_usage` control request. Codex: the ChatGPT plan's 5-hour (primary) and weekly (secondary) windows, via `account/rateLimits/read`. Gemini has no 5-hour or weekly windows; with a Google login it shows the daily per-model quota (Code Assist `retrieveUserQuota`, using the token Gemini CLI stored, only while it is valid). API-key logins have no plan windows and say so. Answers are cached for two minutes; **Refresh** asks again. + +**Talking to agents while they work.** Pick one agent under **To** and ask it anything: agents reply to you with `send_message(to_id="human")` and the answer lands in the chat. Tick **Interrupt** to stop what that agent is doing first, so it answers you right away. **Q&A mode** (session toolbar) puts the whole team on hold: every running turn stops, agents are told to wait and only answer you, edits and commands are refused (reading files stays allowed, so they can check before answering), and teammates' messages wait. Ask one agent, then another; **End Q&A** resumes everyone where they stopped, with the messages that waited. + +**Seeing an agent think.** While an agent works, the chat shows it under the last message: typing dots, what it is doing (thinking, writing a reply, running `$ bun test`…) and how long, and its thinking as it forms, token by token (Claude with `--include-partial-messages`, Codex reasoning summaries, Gemini replies). **Collapse** keeps it to a few lines. + The tabs in the center hold everything else: - **Sessions** (sidebar): New Team, empty session, open, pause/resume, relaunch disconnected agents, export JSON, archive, end, delete (typed confirmation) @@ -250,7 +256,7 @@ The tabs in the center hold everything else: - **Knowledge**: add, edit, delete entries by category - **Locks**: locks with holder and expiry, force unlock, reserve a file for an agent, assign ownership zones - **Activity**: the step-by-step timeline and pending approvals (see above) -- **Usage**: tokens and cost per agent, limit locks, and guardrail steppers (changes logged as "Adjusted via web dashboard") +- **Usage**: plan limits, tokens and cost per agent, limit locks, and guardrail steppers (changes logged as "Adjusted via web dashboard") **Human gate.** A team started from the browser can wait for you once every agent is approved: instead of ending itself, the session shows *Waiting for human approval* with **Approve & Release** and **Send back for revision** (actionable feedback to a worker, which restarts the review loop). Toggle it per session with the `human gate` chip; MCP's `create_team` takes `human_gate: true`. @@ -321,6 +327,8 @@ multiagents/ │ ├── orchestrator-server.ts Orchestrator MCP server (stdio host of runtime.ts) │ ├── runtime.ts Orchestrator runtime: tools, agent processes, supervision loops (shared with the web dashboard) │ ├── activity.ts Activity timeline from each CLI's stream; tool approvals through the broker +│ ├── live.ts What each agent is doing right now (streamed thinking, typing indicator) +│ ├── plan-usage.ts Subscription plan limits of the CLIs (5-hour / weekly / daily) │ ├── claude-driver.ts ClaudeDriver: long-lived `claude -p --input-format stream-json` session │ ├── codex-driver.ts CodexDriver: persistent codex app-server via JSON-RPC (steer/interrupt) │ ├── delivery.ts Hands unacked messages to drivers, acks on confirmed hand-off diff --git a/adapters/base-adapter.ts b/adapters/base-adapter.ts index 9c9714b..af8d313 100644 --- a/adapters/base-adapter.ts +++ b/adapters/base-adapter.ts @@ -41,6 +41,8 @@ import { BROKER_STARTUP_POLL_MS, BROKER_STARTUP_MAX_ATTEMPTS, SESSION_FILE, + HUMAN_SENDER, + HUMAN_SENDER_LABEL, } from "../shared/constants.ts"; import { resolveBrokerPort, brokerUrl } from "../shared/broker-url.ts"; import { @@ -1752,7 +1754,7 @@ export abstract class BaseAdapter { const role = msg.from_role ? ` (${msg.from_role})` : ""; const isOrchestrator = msg.from_id === "orchestrator" || msg.from_id === "__orchestrator__"; const idTag = msg.from_display_name && !isOrchestrator ? ` [id ${msg.from_id}]` : ""; - const sender = `${name}${role}${idTag}`; + const sender = msg.from_id === HUMAN_SENDER ? HUMAN_SENDER_LABEL : `${name}${role}${idTag}`; switch (msg.msg_type) { case "chat": diff --git a/broker.ts b/broker.ts index 8a71aca..968777e 100755 --- a/broker.ts +++ b/broker.ts @@ -20,7 +20,7 @@ import { DEFAULT_GUARDRAILS, CLEANUP_INTERVAL, } from "./shared/constants.ts"; -import { generatePeerId } from "./shared/utils.ts"; +import { generatePeerId, safeJsonParse } from "./shared/utils.ts"; import { resolveBrokerPort } from "./shared/broker-url.ts"; import type { RegisterRequest, @@ -64,6 +64,7 @@ import type { KnowledgeDeleteRequest, ActivityAddRequest, ActivityEntry, + LiveActivity, ApprovalRequest, AddPlanItemRequest, EditPlanItemRequest, @@ -674,7 +675,8 @@ function handleSendMessage(body: SendMessageRequest): SendMessageResult { if (!toId) return { ok: false, error: "No target specified" }; // Allow messages to "orchestrator" and driver-managed slots (no real peer_id) - const isSpecialTarget = toId === "orchestrator" || toId === "__orchestrator__" || toId.startsWith("__slot_"); + // "human" is the person at the dashboard: its messages are read there, never polled + const isSpecialTarget = toId === "orchestrator" || toId === "__orchestrator__" || toId === "human" || toId.startsWith("__slot_"); // Verify target exists (unless targeting orchestrator or driver-managed slot) if (!isSpecialTarget) { @@ -719,7 +721,23 @@ function handlePollMessages(body: PollMessagesRequest): PollMessagesResponse { } const rows = (body.unclaimed_only ? selectUnclaimed : selectUnacked).all(body.id, peer?.slot_id ?? null) as Message[]; - return { messages: handOff(rows, body.retain !== true) }; + return { messages: handOff(qaFilter(peer?.slot_id ?? null, rows), body.retain !== true) }; +} + +const selectSlotSessionConfig = db.prepare( + "SELECT s.config AS config FROM slots sl JOIN sessions s ON s.id = sl.session_id WHERE sl.id = ?" +); + +/** + * Q&A mode (session config `qa_mode`): the team's work is on hold and agents + * only hear the person and the orchestrator; teammates' messages wait (stay + * unclaimed) until the mode ends. + */ +function qaFilter(slotId: number | null, rows: Message[]): Message[] { + if (slotId === null || rows.length === 0) return rows; + const row = selectSlotSessionConfig.get(slotId) as { config: string | null } | null; + if (!safeJsonParse>(row?.config ?? "{}", {}).qa_mode) return rows; + return rows.filter((m) => m.from_id === "human" || m.from_id === "orchestrator" || m.from_id === "__orchestrator__"); } /** Poll a slot's messages (driver-managed agents have no peer id). */ @@ -727,7 +745,7 @@ function handlePollBySlot(body: { slot_id: number; session_id?: string; retain?: const slot = db.query("SELECT paused FROM slots WHERE id = ?").get(body.slot_id) as { paused: number } | null; if (slot?.paused) return { messages: [], paused: true }; - return { messages: handOff(selectUnackedBySlot.all(body.slot_id) as Message[], body.retain !== true) }; + return { messages: handOff(qaFilter(body.slot_id, selectUnackedBySlot.all(body.slot_id) as Message[]), body.retain !== true) }; } /** @@ -1480,6 +1498,39 @@ function handleActivityAdd(body: ActivityAddRequest): { ok: boolean; added: numb } /** Oldest first; `after_id` pages forward, otherwise the latest `limit` entries. */ +// --- Live activity: what each agent is doing right now (memory only) --- + +/** A busy entry not refreshed this long ago belongs to a host that went away */ +const LIVE_STALE_MS = 60_000; +const liveActivity = new Map>(); + +function handleLiveSet(body: { session_id: string; slot_id: number; live: LiveActivity }): { ok: boolean } { + if (!body.session_id || !Number.isInteger(body.slot_id) || !body.live || typeof body.live !== "object") throw new Error("session_id, slot_id and live required"); + const live = body.live; + const entry: LiveActivity = { + busy: live.busy === true, + phase: ["idle", "waiting", "thinking", "writing", "tool"].includes(live.phase) ? live.phase : "waiting", + text: typeof live.text === "string" ? live.text.slice(-8_000) : "", + tool: typeof live.tool === "string" ? live.tool.slice(0, 500) : null, + since: typeof live.since === "number" ? live.since : null, + updatedAt: Date.now(), + }; + let session = liveActivity.get(body.session_id); + if (!session) liveActivity.set(body.session_id, (session = new Map())); + if (entry.busy) session.set(body.slot_id, entry); + else session.delete(body.slot_id); + return { ok: true }; +} + +function handleLiveList(body: { session_id: string }): Record { + const out: Record = {}; + const now = Date.now(); + for (const [slotId, live] of liveActivity.get(body.session_id) ?? []) { + if (now - live.updatedAt < LIVE_STALE_MS) out[slotId] = live; + } + return out; +} + function handleActivityList(body: { session_id: string; slot_id?: number; after_id?: number; limit?: number }): ActivityEntry[] { const limit = Math.min(Math.max(body.limit ?? 300, 1), 2000); const cond = ["session_id = ?"]; @@ -1669,6 +1720,10 @@ Bun.serve({ return Response.json(handleActivityAdd(body as ActivityAddRequest)); case "/activity/list": return Response.json(handleActivityList(body as { session_id: string; slot_id?: number; after_id?: number; limit?: number })); + case "/activity/live/set": + return Response.json(handleLiveSet(body as { session_id: string; slot_id: number; live: LiveActivity })); + case "/activity/live/list": + return Response.json(handleLiveList(body as { session_id: string })); case "/approvals/create": return Response.json(handleApprovalCreate(body as { session_id: string; slot_id: number; tool: string; title: string; input?: unknown })); case "/approvals/get": { diff --git a/dashboard/app.js b/dashboard/app.js index e9503bb..77b2ef9 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -285,6 +285,7 @@ function switchTab(tab) { document.querySelectorAll("#tab-bar .tab").forEach((t) => t.classList.toggle("active", t.dataset.tab === tab)); for (const t of TABS) $(`panel-${t}`).classList.toggle("hidden", t !== tab); store.set("ma-tab", tab); + if (tab === "stats" && (!planUsage.data || Date.now() - planUsage.loadedAt > 60_000)) { planUsage.loadedAt = Date.now(); loadPlanUsage(); } if (state) render(true); if (tab === "chat") $("composer-text").focus(); } @@ -336,14 +337,18 @@ function render(force = false) { renderSidebar(); renderSessionBar(); renderGateBanner(); + renderQaBanner(); renderApprovals(); renderBadges(); refreshAgentSelects(); renderRoster(); explorerSync(); + if (state.live && (state.sessionId !== liveView.sid || !liveView.at || Date.now() - liveView.at > 2_000)) { + liveView.data = state.live; liveView.sid = state.sessionId; liveView.at = Date.now(); + } switch (activeTab) { - case "chat": renderChat(force); break; + case "chat": renderChat(force); renderLive(); break; case "agents": renderAgents(); break; case "plan": renderPlan(); break; case "knowledge": renderKnowledge(); break; @@ -1294,6 +1299,9 @@ function systemText(m) { if (m.msg_type === "role_assignment" && j.role) return tr("role set to {role}", { role: j.role }); if (m.msg_type === "rename" && j.display_name) return tr("renamed to {name}", { name: j.display_name }); } catch { /* plain text */ } + // Q&A mode announcements are long instructions for the agents; people get the gist + if (t.startsWith("[HOLD — Q&A MODE]")) return tr("Q&A mode started — work is on hold"); + if (t.startsWith("[RESUME]")) return tr("Q&A mode ended — work resumes"); return t.replace(/\s+/g, " "); } @@ -1381,6 +1389,7 @@ function renderChat(force) { } feed.innerHTML = html; hydrateFileRefs(feed); + renderLive(); if (atBottom || force || firstRender) { feed.scrollTop = feed.scrollHeight; @@ -1390,6 +1399,81 @@ function renderChat(force) { } } +// --- Live: agents working right now (typing dots, thinking as it streams) --- + +const liveView = { data: {}, sid: null, el: document.createElement("div"), expanded: store.get("ma-live-expanded", true), polling: false }; +liveView.el.id = "chat-live"; + +const LIVE_PHASE = { waiting: "Working", thinking: "Thinking", writing: "Writing a reply", tool: "Running a tool" }; + +function renderLive() { + const feed = $("msg-feed"); + const el = liveView.el; + if (!state?.session) { el.replaceChildren(); return; } + if (el.parentNode !== feed || feed.lastElementChild !== el) feed.appendChild(el); + const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 120; + const busy = Object.entries(liveView.sid === state.sessionId ? liveView.data : {}) + .map(([id, l]) => ({ slot: slotById(Number(id)), l })) + .filter(({ slot, l }) => slot && l.busy); + const keep = new Set(busy.map(({ slot }) => String(slot.id))); + for (const row of [...el.children]) if (!keep.has(row.dataset.slot)) row.remove(); + for (const { slot, l } of busy) { + let row = el.querySelector(`[data-slot="${slot.id}"]`); + if (!row) { + row = document.createElement("div"); + row.className = "msg live-row"; + row.dataset.slot = String(slot.id); + row.innerHTML = `${slotAvatar(slot)}
    +
    +
    +
    +
    `; + el.appendChild(row); + } + row.querySelector(".msg-name").textContent = slotName(slot); + row.querySelector(".msg-role").textContent = slot.role ?? ""; + const label = l.phase === "tool" && l.tool ? tr("Running {tool}", { tool: tr(l.tool) }) : tr(LIVE_PHASE[l.phase] ?? "Working"); + row.querySelector(".live-label").textContent = label; + row.querySelector(".live-time").textContent = l.since ? fmtDuration(Date.now() - l.since) : ""; + const wrap = row.querySelector(".live-text-wrap"); + const hasText = !!l.text && (l.phase === "thinking" || l.phase === "writing"); + wrap.hidden = !hasText; + if (hasText) { + wrap.classList.toggle("expanded", liveView.expanded); + row.querySelector(".live-text-label").textContent = tr(l.phase === "thinking" ? "Thinking" : "Reply so far"); + row.querySelector("[data-live-toggle]").textContent = tr(liveView.expanded ? "Collapse" : "Expand"); + const box = row.querySelector(".live-text"); + const stick = box.scrollHeight - box.scrollTop - box.clientHeight < 30; + if (box.textContent !== l.text) box.textContent = l.text; + if (stick) box.scrollTop = box.scrollHeight; + } + } + if (atBottom) feed.scrollTop = feed.scrollHeight; +} + +liveView.el.addEventListener("click", (e) => { + if (!e.target.closest("[data-live-toggle]")) return; + liveView.expanded = !liveView.expanded; + store.set("ma-live-expanded", liveView.expanded); + renderLive(); +}); + +/** Fast poll while the chat is open and someone is working (state pushes cover the rest). */ +async function pollLive() { + if (liveView.polling || !state?.sessionId || document.hidden || activeTab !== "chat") return; + const anyBusy = Object.values(liveView.data).some((l) => l.busy); + if (!anyBusy && Date.now() - (liveView.at ?? 0) < 3_000) return; + liveView.polling = true; + const sid = state.sessionId; + try { + const data = await api(`live?session_id=${encodeURIComponent(sid)}`); + if (sid === state?.sessionId) { liveView.data = data ?? {}; liveView.sid = sid; liveView.at = Date.now(); renderLive(); } + } catch { /* next tick */ } finally { + liveView.polling = false; + } +} +setInterval(pollLive, 800); + $("msg-feed").addEventListener("click", (e) => { const b = e.target.closest(".bubble.collapsed"); if (b && !e.target.closest(".file-link, a")) { expandedMessages.add(Number(b.dataset.mid)); b.classList.remove("collapsed"); } @@ -1420,6 +1504,8 @@ function refreshAgentSelects() { ? `${taskLabel(slot.task_state)}${isPaused(slot) ? tr(" · paused (held until resume)") : ""}` : tr("{n} online", { n: live.filter((s) => s.status === "connected" && !isPaused(s)).length }); $("composer-send").disabled = !state.session || state.session.status === "archived"; + // Interrupting is for one agent; in Q&A mode nobody is working anyway + $("composer-interrupt-wrap").hidden = !slot || state.qaMode; } function fillSelect(id, html) { @@ -1444,7 +1530,8 @@ async function sendComposer() { const text = ta.value.trim(); if (!text) return; const to = $("composer-to").value; - const r = await run($("composer-send"), "Send", () => api("messages/send", { session_id: sid, slot_id: to === "all" ? "all" : parseInt(to, 10), text })); + const interrupt = to !== "all" && $("composer-interrupt").checked; + const r = await run($("composer-send"), "Send", () => api("messages/send", { session_id: sid, slot_id: to === "all" ? "all" : parseInt(to, 10), text, interrupt })); if (r) { ta.value = ""; autosize(); @@ -1496,6 +1583,17 @@ function renderSessionBar() { tb.querySelector('[data-action="session-menu"]').disabled = !has; } +function renderQaBanner() { + const el = $("qa-banner"); + $("qa-btn").innerHTML = state.qaMode ? `▶ ${esc(tr("End Q&A"))}` : `✋ ${esc(tr("Q&A mode"))}`; + $("qa-btn").classList.toggle("primary", !!state.qaMode); + if (!state.qaMode || !state.session) { el.className = "hidden"; el.innerHTML = ""; return; } + el.className = "banner qa"; + el.innerHTML = ` + ${tr("Agents stopped their tasks and only answer you. Pick an agent under To and ask; teammates' messages wait until you end Q&A.")} + `; +} + function renderGateBanner() { const el = $("gate-banner"); if (!state.awaitingHuman) { el.className = "hidden"; el.innerHTML = ""; return; } @@ -1963,7 +2061,7 @@ function renderStats() {
    `).join(""); const lim = (k) => (limits[k] ? String(limits[k]) : ""); - el.innerHTML = `
    ${cardsHtml}
    + el.innerHTML = `${planCardHtml()}
    ${cardsHtml}
    ${tr("Usage per agent")} ${tr("tokens as the CLIs report them; cost only where the CLI reports it")}
    ${rows || ``}
    ${tr("Agent")}${tr("Input")}${tr("Output")}${tr("Cache reads")}${tr("Cost")}${tr("Limit")}
    ${tr("No agents yet")}
    @@ -1981,6 +2079,81 @@ function renderStats() { ${guardrails ? `
    ${tr("Guardrails")}
    ${guardrails}
    ` : ""}`; } +// --- Subscription plan limits (5-hour / weekly / daily windows of each CLI) --- + +const planUsage = { data: null, loading: false, error: null }; +const PLAN_NAMES = { claude: "Claude", codex: "Codex (ChatGPT)", gemini: "Gemini" }; + +async function loadPlanUsage(refresh = false) { + if (planUsage.loading) return; + planUsage.loading = true; + if (activeTab === "stats" && state) renderStats(); + try { + planUsage.data = await api(`plan-usage${refresh ? "?refresh=1" : ""}`); + planUsage.error = null; + } catch (e) { + planUsage.error = e.message; + } finally { + planUsage.loading = false; + } + renderPlanMeter(); + if (activeTab === "stats" && state) renderStats(); +} + +function pctBar(p) { + const v = Math.max(0, Math.min(100, p ?? 0)); + return `
    = 80 ? " warn" : ""}" title="${Math.round(v)}%">
    `; +} + +/** "resets in 2h 13m · Fri 14:00" */ +function resetText(ms) { + if (!ms) return ""; + const left = ms - Date.now(); + if (left <= 0) return tr("resets now"); + const when = new Date(ms).toLocaleString(locale(), left > 20 * 3600_000 ? { weekday: "short", hour: "2-digit", minute: "2-digit" } : { hour: "2-digit", minute: "2-digit" }); + return tr("resets in {time} · {when}", { time: fmtDuration(left), when }); +} + +function planCardHtml() { + const data = planUsage.data; + const head = `
    ${tr("Plan limits")} ${tr("subscription quota as each CLI reports it")} +
    `; + if (!data) { + return `
    ${head}
    ${planUsage.error ? esc(tr(planUsage.error)) : tr("Loading…")}
    `; + } + const blocks = data.map((p) => { + const icon = avatarHtml(null, { type: p.provider, name: PLAN_NAMES[p.provider], color: "#6e7781" }, "", "", "plan-icon"); + const windows = p.windows.map((w) => `
    +
    ${esc(tr(w.label))}${w.usedPercent == null ? "—" : `${Math.round(w.usedPercent)}%`}
    + ${pctBar(w.usedPercent)} +
    ${[w.detail ? esc(tr(w.detail)) : "", resetText(w.resetsAt)].filter(Boolean).join(" · ")}
    +
    `).join(""); + const note = p.note || p.error ? `
    ${esc(tr(p.note ?? p.error))}
    ` : ""; + return `
    +
    ${icon}${esc(PLAN_NAMES[p.provider])}${p.plan ? `${esc(p.plan)}` : ""}
    + ${windows ? `
    ${windows}
    ` : ""}${note} +
    `; + }).join(""); + return `
    ${head}
    ${blocks}
    `; +} + +/** Top bar: the fullest window of each provider that has any. */ +function renderPlanMeter() { + const el = $("plan-meter"); + const shown = (planUsage.data ?? []).filter((p) => p.ok && p.windows.length); + if (!shown.length) { el.innerHTML = ""; el.hidden = true; return; } + el.hidden = false; + el.innerHTML = shown.map((p) => { + const top = p.windows.filter((w) => w.usedPercent != null).sort((a, b) => b.usedPercent - a.usedPercent)[0] ?? p.windows[0]; + const v = Math.round(top.usedPercent ?? 0); + const tip = p.windows.map((w) => `${tr(w.label)}: ${w.usedPercent == null ? "—" : `${Math.round(w.usedPercent)}%`}`).join("\n"); + return `= 80 ? " warn" : ""}" title="${esc(`${PLAN_NAMES[p.provider]}\n${tip}`)}">${avatarHtml(null, { type: p.provider, name: p.provider, color: "#6e7781" }, "", "", "mini")}${v}%`; + }).join(""); +} + +$("plan-meter").addEventListener("click", () => switchTab("stats")); +$("panel-stats").addEventListener("click", (e) => { if (e.target.closest("[data-plan-refresh]")) loadPlanUsage(true); }); + function limitBar(used, limit) { const pct = limit ? Math.min(100, Math.round((used / limit) * 100)) : 0; return `
    = 80 ? " warn" : ""}" title="${pct}%">
    `; @@ -2383,6 +2556,15 @@ async function globalAction(action, btn) { case "release-menu": return openMenu(btn, releaseMenuItems(sid)); case "session-menu": return openMenu(btn, sessionMenuItems(state.session)); case "toggle-gate": return run(btn, "Human gate", () => api("session/human-gate", { session_id: sid, enabled: !state.humanGate })); + case "toggle-qa": { + if (state.qaMode) return run(btn, "Q&A mode", () => api("session/qa", { session_id: sid, enabled: false })); + const ok = await confirmDialog({ + title: "Put the team's work on hold?", sid, confirmLabel: "Start Q&A", + message: `

    ${tr("Every agent stops its current turn and waits. Then ask one agent at a time (pick it under To); it answers you in the chat and may read files, but not edit or run commands. Teammates' messages wait until you end Q&A, and then everyone resumes.")}

    `, + }); + if (ok) return run(btn, "Q&A mode", () => api("session/qa", { session_id: sid, enabled: true })); + return; + } case "gate-release": return releaseAll(sid, true, btn); case "gate-sendback": return sendBackModal(sid, null); } @@ -2792,8 +2974,10 @@ function fmtBytes(n) { } function fmtDuration(ms) { const t = Math.max(0, Math.round(ms / 1000)); - const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = t % 60; - return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s}s` : `${s}s`; + const d = Math.floor(t / 86400), h = Math.floor((t % 86400) / 3600), m = Math.floor((t % 3600) / 60), s = t % 60; + if (d >= 2) return tr("{d}d {h}h", { d, h }); + if (d > 0 || h > 0) return tr("{h}h {m}m", { h: d * 24 + h, m }); + return m > 0 ? tr("{m}m {s}s", { m, s }) : tr("{s}s", { s }); } function fmtTime(iso) { try { return new Date(iso).toLocaleTimeString(locale(), { hour: "2-digit", minute: "2-digit" }); } catch { return ""; } @@ -2884,4 +3068,6 @@ switchRightTab(store.get("ma-rtab", "preview")); closePreview(); renderTree(); connectWs(); +loadPlanUsage(); +setInterval(() => { if (!document.hidden) loadPlanUsage(); }, 5 * 60_000); if (STALE_SERVER) toast("This dashboard server is older than the page: stop the old dashboard process and start it again. / 网页服务是旧版本:请结束旧的 dashboard 进程后重新启动。", "error"); diff --git a/dashboard/control.ts b/dashboard/control.ts index 5cc7a92..a265032 100644 --- a/dashboard/control.ts +++ b/dashboard/control.ts @@ -15,9 +15,18 @@ import { createSessionRecord, exportSessionData } from "../shared/session-ops.ts import { callOrchestratorTool, type ToolResult } from "../orchestrator/runtime.ts"; import { controlSession, broadcastToTeam } from "../orchestrator/session-control.ts"; import { detectAgent } from "../orchestrator/launcher.ts"; +import { HUMAN_SENDER } from "../shared/constants.ts"; /** Sender id of everything a person does from the browser. */ -export const HUMAN_ID = "human"; +export const HUMAN_ID = HUMAN_SENDER; + +const QA_HOLD_TEXT = + "[HOLD — Q&A MODE] The person has put the team's work on hold to ask questions. Stop your current task now: " + + "do not edit files, run commands or builds, or message teammates. Wait for the person's questions and answer each one " + + "with send_message(to_id=\"human\"), briefly and directly; you may read files to answer. " + + "Do not resume work until a [RESUME] message arrives."; +const QA_RESUME_TEXT = + "[RESUME] The person ended Q&A mode. Continue your task where you left off; messages your teammates sent meanwhile arrive now."; const AGENT_TYPES: AgentType[] = ["claude", "codex", "gemini"]; const KNOWLEDGE_CATEGORIES: KnowledgeCategory[] = ["decision", "convention", "discovery", "blocker", "context"]; @@ -254,6 +263,24 @@ const handlers: Record = { return await tool("delete_session", { session_id: session.id }); }, + /** + * Q&A mode: hold the team's work so the person can ask agents questions one + * at a time. Running turns stop, agents only hear the person (and the + * orchestrator) and may only read; teammates' messages wait until it ends. + */ + async "session/qa"(ctx, body) { + const session = await requireSession(ctx, body); + const enabled = body.enabled === true; + const wasOn = !!safeJsonParse>(session.config, {}).qa_mode; + if (enabled === wasOn) return { message: enabled ? "Q&A mode is already on" : "Q&A mode is already off" }; + await ctx.broker.updateSession({ id: session.id, config_patch: { qa_mode: enabled ? { since: Date.now() } : null } }); + const slots = (await ctx.broker.listSlots(session.id)).filter((s) => s.task_state !== "released"); + for (const slot of slots) { + await ctx.broker.sendMessage({ from_id: "orchestrator", to_slot_id: slot.id, text: enabled ? QA_HOLD_TEXT : QA_RESUME_TEXT, msg_type: "system", session_id: session.id }); + } + return { message: enabled ? "Q&A mode on: the team's work is on hold" : "Q&A mode off: the team resumes its work" }; + }, + async "session/human-gate"(ctx, body) { const session = await requireSession(ctx, body); const enabled = body.enabled === true; @@ -428,6 +455,11 @@ const handlers: Record = { const slot = await requireSlot(ctx, session, body); const result = await ctx.broker.sendMessage({ from_id: HUMAN_ID, to_slot_id: slot.id, text, msg_type: "chat", session_id: session.id }); if (!result.ok) throw new ApiError(result.error ?? "Broker refused the message", 422); + // "Interrupt and ask": the host running the agent stops its current turn, so the question is answered next + if (body.interrupt === true) { + await ctx.broker.updateSlot({ id: slot.id, context_patch: { interrupt_at: Date.now() } }); + return { message: `Interrupted ${slot.display_name ?? `slot ${slot.id}`} and asked`, warning: result.warning }; + } return { message: result.warning ?? `Sent to ${slot.display_name ?? `slot ${slot.id}`}`, warning: result.warning }; }, diff --git a/dashboard/i18n.js b/dashboard/i18n.js index fc638ce..b711706 100644 --- a/dashboard/i18n.js +++ b/dashboard/i18n.js @@ -241,6 +241,37 @@ const ZH = { "Stops its agents and deletes all slots, messages, plans and locks. This cannot be undone. Export first if you want a copy. Project files are not touched.": "停止其 agent 并删除所有席位、消息、计划和文件锁。此操作无法撤销。如需备份请先导出。项目文件不受影响。", "This cannot be undone.": "此操作无法撤销。", + // --- Q&A mode and interrupting --- + "Working": "处理中", + "Writing a reply": "正在写回复", + "Running a tool": "正在执行工具", + "Running {tool}": "正在执行 {tool}", + "Reply so far": "已写出的回复", + "Expand": "展开", + "Collapse": "收起", + "✋ Q&A mode": "✋ 问答模式", + "Q&A mode": "问答模式", + "End Q&A": "结束问答", + "Hold the team's work and ask agents questions one at a time": "暂停团队工作,逐个向 agent 提问", + "Q&A mode — work is on hold": "问答模式 — 工作已暂停", + "Agents stopped their tasks and only answer you. Pick an agent under To and ask; teammates' messages wait until you end Q&A.": "agent 已停下手头任务,只回答你的问题。在“发送给”里选一个 agent 提问;agent 之间的消息会等到问答结束再送达。", + "End Q&A, resume work": "结束问答,继续工作", + "Put the team's work on hold?": "暂停团队工作,进入问答模式?", + "Start Q&A": "开始问答", + "Every agent stops its current turn and waits. Then ask one agent at a time (pick it under To); it answers you in the chat and may read files, but not edit or run commands. Teammates' messages wait until you end Q&A, and then everyone resumes.": "所有 agent 会停下当前的工作并等待。然后你可以逐个提问(在发送给里选择 agent);它会在群聊里回答你,可以读文件,但不能编辑文件或运行命令。agent 之间的消息会先保留,结束问答后大家继续工作。", + "Interrupt": "打断", + "Q&A mode started — work is on hold": "问答模式开始 — 工作已暂停", + "Q&A mode ended — work resumes": "问答模式结束 — 继续工作", + "shell command": "shell 命令", + "file change": "文件修改", + "Stop what this agent is doing so it answers you first": "让这个 agent 停下手头的事,先回答你", + "Q&A mode is already on": "问答模式已经开启", + "Q&A mode is already off": "问答模式已经关闭", + "Q&A mode on: the team's work is on hold": "已开启问答模式:团队工作已暂停", + "Q&A mode off: the team resumes its work": "已关闭问答模式:团队继续工作", + "Q&A mode — the running turn was interrupted": "问答模式 — 正在运行的回合已中断", + "Interrupted to answer the person": "已打断,先回答你的问题", + // --- Team roster --- "Online agents": "在线 agent", "Agent settings": "Agent 设置", @@ -377,6 +408,38 @@ const ZH = { "default model": "默认模型", "default": "默认", + // --- Subscription plan limits --- + "Plan limits": "套餐限额", + "subscription quota as each CLI reports it": "各 CLI 上报的订阅额度", + "resets now": "即将重置", + "resets in {time} · {when}": "{time}后重置 · {when}", + "{d}d {h}h": "{d}天{h}小时", + "{h}h {m}m": "{h}小时{m}分", + "{m}m {s}s": "{m}分{s}秒", + "{s}s": "{s}秒", + "5-hour window": "5 小时额度", + "Weekly": "每周额度", + "Daily": "每日额度", + "Weekly · all models": "每周额度 · 全部模型", + "Weekly · Opus": "每周额度 · Opus", + "Weekly · Sonnet": "每周额度 · Sonnet", + "Weekly · other apps": "每周额度 · 其他应用", + "Extra usage · monthly": "额外用量 · 每月", + "Short window": "短周期额度", + "Long window": "长周期额度", + "Claude Code is not installed.": "未安装 Claude Code。", + "Codex CLI is not installed.": "未安装 Codex CLI。", + "Claude Code did not answer the usage request.": "Claude Code 没有响应用量查询。", + "This Claude Code version does not report plan usage — update it.": "此版本的 Claude Code 不支持查询套餐用量,请更新。", + "No claude.ai plan limits for this login (an API key has none).": "当前登录没有 claude.ai 套餐限额(API key 没有这类限额)。", + "Sign in to Codex with ChatGPT to see its limits (an API key has none).": "用 ChatGPT 账号登录 Codex 后才能看到限额(API key 没有这类限额)。", + "Codex reported no usage windows for this account.": "Codex 没有返回此账号的用量窗口。", + "Gemini runs on an API key; Google does not report its quota.": "Gemini 使用 API key 运行,Google 不提供其额度信息。", + "Sign in to Gemini CLI with Google to see its quota.": "用 Google 账号登录 Gemini CLI 后才能看到额度。", + "The Gemini login has expired; it refreshes the next time Gemini CLI runs.": "Gemini 登录凭据已过期,下次运行 Gemini CLI 时会自动刷新。", + "Gemini did not report a Code Assist project for this login.": "Gemini 没有返回此登录对应的 Code Assist 项目。", + "Gemini reported no quota buckets for this login.": "Gemini 没有返回此登录的额度信息。", + // --- Agent settings --- "Settings · {name}": "设置 · {name}", "Model": "模型", @@ -606,6 +669,13 @@ const RULES_ZH = [ [/^Search "(.*)"$/s, (m) => `搜索 "${m[1]}"`], [/^Subagent: (.+)$/s, (m) => `子代理:${m[1]}`], [/^Team: (.+)$/, (m) => `团队:${m[1]}`], + // Plan limits + [/^Weekly · (.+)$/, (m) => `每周额度 · ${m[1]}`], + [/^Daily · (.+)$/, (m) => `每日额度 · ${m[1]}`], + [/^(\d+)-(hour|day|minute) window$/, (m) => `${m[1]} ${{ hour: "小时", day: "天", minute: "分钟" }[m[2]]}额度`], + [/^(.+) · (5-hour window|Weekly|Daily|Short window|Long window)$/, (m) => `${m[1]} · ${tz(m[2])}`], + [/^(\d+) \/ (\d+) left$/, (m) => `剩余 ${m[1]} / ${m[2]}`], + [/^Gemini (\w+): HTTP (\d+)$/, (m) => `Gemini ${m[1]} 请求失败:HTTP ${m[2]}`], // Limits [/^(session )?token limit reached \((.+) \/ (.+)\)$/, (m) => `${m[1] ? "会话" : ""} token 达到上限(${m[2]} / ${m[3]})`.trim()], [/^(session )?cost limit reached \((.+) \/ (.+)\)$/, (m) => `${m[1] ? "会话" : ""}费用达到上限(${m[2]} / ${m[3]})`], @@ -616,6 +686,8 @@ const RULES_ZH = [ [/^Sent to (.+)$/, (m) => `已发送给 ${m[1]}`], [/^Message sent to (.+)\.$/, (m) => `消息已发送给 ${m[1]}。`], [/^Sent back to (.+) \((.+)\)$/, (m) => `已退回给 ${m[1]}(${tz(m[2].replace(/_/g, " "))})`], + [/^Interrupted (.+) and asked$/, (m) => `已打断 ${m[1]} 并提问`], + [/^Refused in Q&A mode · (.+)$/s, (m) => `问答模式下已拒绝 · ${tz(m[1])}`], [/^Paused (\d+) agents$/, (m) => `已暂停 ${m[1]} 个 agent`], [/^Resumed (\d+) agents$/, (m) => `已继续 ${m[1]} 个 agent`], [/^Paused (.+)$/, (m) => `已暂停 ${m[1]}`], diff --git a/dashboard/index.html b/dashboard/index.html index 86d7648..b318f2a 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -29,6 +29,7 @@
    + Broker @@ -71,12 +72,14 @@
    +
    +
    @@ -105,6 +108,7 @@ To +
    diff --git a/dashboard/server.ts b/dashboard/server.ts index 9bdd468..918ef6d 100644 --- a/dashboard/server.ts +++ b/dashboard/server.ts @@ -15,10 +15,11 @@ import { BrokerClient } from "../shared/broker-client.ts"; import { DASHBOARD_REFRESH } from "../shared/constants.ts"; import { resolveBrokerPort, brokerUrl } from "../shared/broker-url.ts"; -import type { Session, Slot, Peer, Message, GuardrailState, FileLock, FileOwnership, ApprovalRequest } from "../shared/types.ts"; +import type { Session, Slot, Peer, Message, GuardrailState, FileLock, FileOwnership, ApprovalRequest, LiveActivity } from "../shared/types.ts"; import type { PlanState } from "../shared/broker-client.ts"; import { configureRuntime, ensureBroker, startBackgroundLoops, shutdownRuntime, isManagedSession, managedSlotIds } from "../orchestrator/runtime.ts"; import { handleFs } from "./files.ts"; +import { getPlanUsage } from "../orchestrator/plan-usage.ts"; import { handleControl, exportSession, listActivity, awaitingHuman, ApiError, CONTROL_ACTIONS, HUMAN_ID, type ControlContext } from "./control.ts"; // --- Configuration --- @@ -81,6 +82,10 @@ interface DashboardState { managedSlots: number[]; managed: boolean; humanGate: boolean; + /** Q&A mode: the team's work is on hold while the person asks questions */ + qaMode: boolean; + /** What each busy agent is doing right now, by slot id */ + live: Record; awaitingHuman: boolean; /** Tool calls waiting for a person */ approvals: ApprovalRequest[]; @@ -108,6 +113,8 @@ async function fetchState(sessionId: string | null): Promise { managedSlots: [], managed: false, humanGate: false, + qaMode: false, + live: {}, awaitingHuman: false, approvals: [], limits: {}, @@ -126,7 +133,7 @@ async function fetchState(sessionId: string | null): Promise { if (!sessionId) return state; try { - const [session, slots, messages, guardrails, fileLocks, fileOwnership, plan, knowledge, approvals] = await Promise.all([ + const [session, slots, messages, guardrails, fileLocks, fileOwnership, plan, knowledge, approvals, live] = await Promise.all([ broker.getSession(sessionId).catch(() => null), broker.listSlots(sessionId).catch(() => []), broker.getMessageLog(sessionId, { limit: 200 }).catch(() => []), @@ -136,6 +143,7 @@ async function fetchState(sessionId: string | null): Promise { broker.getPlan(sessionId).catch(() => null), broker.listKnowledge(sessionId).catch(() => []), broker.listApprovals(sessionId, "pending").catch(() => []), + broker.listLiveActivity(sessionId).catch(() => ({})), ]); // Get peers for slot enrichment @@ -158,9 +166,11 @@ async function fetchState(sessionId: string | null): Promise { state.managedSlots = managedSlotIds(sessionId); state.managed = isManagedSession(sessionId); state.approvals = approvals; + state.live = live; try { const config = JSON.parse(session?.config ?? "{}"); state.humanGate = config.human_gate === true; + state.qaMode = !!config.qa_mode; state.limits = config.limits && typeof config.limits === "object" ? config.limits : {}; } catch { /* malformed config */ } state.awaitingHuman = awaitingHuman(session, slots); @@ -364,6 +374,22 @@ const server = Bun.serve({ } } + // API: what each agent is doing right now (polled fast while the chat is open) + if (url.pathname === "/api/live" && req.method === "GET") { + const id = url.searchParams.get("session_id"); + if (!id) return Response.json({ ok: false, error: "session_id required" }, { status: 400 }); + return Response.json({ ok: true, result: await broker.listLiveActivity(id).catch(() => ({})) }); + } + + // API: subscription plan limits of the CLIs (5-hour / weekly / daily windows) + if (url.pathname === "/api/plan-usage" && req.method === "GET") { + try { + return Response.json({ ok: true, result: await getPlanUsage({ refresh: url.searchParams.get("refresh") === "1" }) }); + } catch (e) { + return apiError(e); + } + } + // API: session export (download) if (url.pathname === "/api/session/export" && req.method === "GET") { const id = url.searchParams.get("session_id"); diff --git a/dashboard/styles.css b/dashboard/styles.css index 37c87b1..b98fb53 100644 --- a/dashboard/styles.css +++ b/dashboard/styles.css @@ -687,3 +687,42 @@ html.has-bg .pane.center { background: color-mix(in srgb, var(--bg) var(--panel- html.has-bg .session-bar, html.has-bg .tabs, html.has-bg .chat-toolbar, html.has-bg .composer, html.has-bg #act-approvals, html.has-bg .day-sep span { background: transparent; } html.has-bg.bg-blur .topbar, html.has-bg.bg-blur .pane { backdrop-filter: blur(14px) saturate(1.1); -webkit-backdrop-filter: blur(14px) saturate(1.1); } + +/* --- Subscription plan limits --- */ +.plan-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; } +.plan-block { border: 1px solid var(--border); border-radius: 10px; padding: 12px; display: flex; flex-direction: column; gap: 10px; background: var(--surface); } +.plan-title { display: flex; align-items: center; gap: 8px; } +.plan-title .avatar { width: 24px; height: 24px; border-radius: 7px; font-size: 10px; } +.plan-windows { display: flex; flex-direction: column; gap: 10px; } +.plan-window-head { display: flex; justify-content: space-between; font-size: 12.5px; margin-bottom: 3px; } +.plan-meter { display: inline-flex; gap: 6px; cursor: pointer; } +.plan-meter[hidden] { display: none; } +.plan-chip { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; padding: 1px 7px 1px 3px; border-radius: 999px; border: 1px solid var(--border); background: var(--surface2); } +.plan-chip.warn { color: var(--yellow); border-color: color-mix(in srgb, var(--yellow) 45%, var(--border)); } +.plan-chip.over { color: var(--red); border-color: color-mix(in srgb, var(--red) 45%, var(--border)); } +.plan-chip .avatar.mini { width: 16px; height: 16px; border-radius: 50%; } + +/* --- Q&A mode and "interrupt and ask" --- */ +.banner.qa { background: color-mix(in srgb, var(--accent) 10%, var(--surface)); border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); } +.banner.qa .banner-title { color: var(--accent); } +.composer-interrupt { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--text-dim); cursor: pointer; user-select: none; } +.composer-interrupt[hidden] { display: none; } +.composer-interrupt input { margin: 0; accent-color: var(--orange); } +.composer-interrupt:has(input:checked) { color: var(--orange); font-weight: 600; } + +/* --- Live: an agent working right now --- */ +#chat-live:empty { display: none; } +.live-row .bubble.live-bubble { background: var(--bubble-other); border-style: dashed; min-width: 180px; max-width: 100%; } +.live-status { display: flex; align-items: center; gap: 8px; color: var(--text-dim); font-size: 13px; } +.live-time { color: var(--text-muted); font-size: 11.5px; font-variant-numeric: tabular-nums; } +.typing-dots { display: inline-flex; gap: 3px; align-items: center; } +.typing-dots i { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); opacity: 0.25; animation: typing-dot 1.2s infinite ease-in-out; } +.typing-dots i:nth-child(2) { animation-delay: 0.15s; } +.typing-dots i:nth-child(3) { animation-delay: 0.3s; } +@keyframes typing-dot { 0%, 80%, 100% { opacity: 0.25; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } } +@media (prefers-reduced-motion: reduce) { .typing-dots i { animation: none; opacity: 0.7; } } +.live-text-wrap { margin-top: 6px; border-top: 1px solid var(--border); padding-top: 6px; } +.live-text-wrap[hidden] { display: none; } +.live-text-head { display: flex; align-items: center; justify-content: space-between; font-size: 11.5px; color: var(--text-muted); } +.live-text { white-space: pre-wrap; word-break: break-word; font-size: 12.5px; line-height: 1.5; color: var(--text-dim); font-style: italic; max-height: 4.6em; overflow: hidden; } +.live-text-wrap.expanded .live-text { max-height: 40vh; overflow-y: auto; } diff --git a/orchestrator/claude-driver.ts b/orchestrator/claude-driver.ts index 040557d..1cff2ef 100644 --- a/orchestrator/claude-driver.ts +++ b/orchestrator/claude-driver.ts @@ -96,6 +96,8 @@ export function buildClaudeArgs(opts: ClaudeLaunchOptions): string[] { "--output-format", "stream-json", "--verbose", "--replay-user-messages", + // Thinking and replies stream token by token (stream_event), for the live view + "--include-partial-messages", "--permission-prompt-tool", "stdio", ...permissionArgs(opts.permissionMode ?? "bypassPermissions"), "--strict-mcp-config", @@ -441,7 +443,8 @@ export class ClaudeDriver { private handleEvent(event: ClaudeEvent): void { this._lastActivity = Date.now(); - if (this._history.length < 500) this._history.push(event); + // Token deltas are only of interest live; replaying them would crowd out the first turn's steps + if (this._history.length < 500 && event.type !== "stream_event") this._history.push(event); for (const listener of this._listeners) { try { listener(event); } catch { /* listener errors must not break the reader */ } } diff --git a/orchestrator/codex-driver.ts b/orchestrator/codex-driver.ts index 11134ae..cd28907 100644 --- a/orchestrator/codex-driver.ts +++ b/orchestrator/codex-driver.ts @@ -64,6 +64,8 @@ export interface CodexTurnSettings { model?: string; /** Reasoning effort: minimal | low | medium | high */ effort?: string; + /** Overrides the thread's sandbox for the next turns (Q&A mode: read-only) */ + sandbox?: "read-only"; } /** A command or file change waiting for approval (item/*\/requestApproval). */ @@ -378,6 +380,11 @@ export class CodexDriver { } } + /** Send any app-server request (account reads and the like) and wait for its result. */ + request(method: string, params: unknown, timeoutMs = 30_000): Promise { + return this.sendRequest(method, params, timeoutMs); + } + async kill(): Promise { if (!this._alive) return; this.stopHeartbeat(); @@ -397,6 +404,7 @@ export class CodexDriver { params = { ...params, approvalPolicy: this.settings.approvalPolicy }; if (this.settings.model) params.model = this.settings.model; if (this.settings.effort) params.effort = this.settings.effort; + if (this.settings.sandbox) params.sandboxPolicy = sandboxPolicyObject(this.settings.sandbox); const turnMeta = await this.sendRequest("turn/start", params, 30_000) as { turn?: { id: string; status: string }; }; diff --git a/orchestrator/delivery.ts b/orchestrator/delivery.ts index a14d7ce..6a76156 100644 --- a/orchestrator/delivery.ts +++ b/orchestrator/delivery.ts @@ -11,6 +11,7 @@ import type { Message } from "../shared/types.ts"; import type { CodexDriver } from "./codex-driver.ts"; import type { ClaudeDriver } from "./claude-driver.ts"; import { log } from "../shared/utils.ts"; +import { HUMAN_SENDER, HUMAN_SENDER_LABEL } from "../shared/constants.ts"; const LOG_PREFIX = "delivery"; @@ -48,6 +49,7 @@ type SenderInfo = { display_name: string | null; role: string | null }; /** One line per message: type, sender name/role and the peer id to reply to. */ export function formatForwardedMessages(messages: Message[], senders: Map): string { return messages.map((m) => { + if (m.from_id === HUMAN_SENDER) return `[${m.msg_type}] ${HUMAN_SENDER_LABEL}: ${m.text}`; const sender = m.from_slot_id !== null ? senders.get(m.from_slot_id) : undefined; const name = sender?.display_name ?? (m.from_slot_id !== null ? `slot ${m.from_slot_id}` : m.from_id); const role = sender?.role ? ` (${sender.role})` : ""; diff --git a/orchestrator/live.ts b/orchestrator/live.ts new file mode 100644 index 0000000..cd6cb55 --- /dev/null +++ b/orchestrator/live.ts @@ -0,0 +1,163 @@ +// ============================================================================ +// multiagents — Live "what is this agent doing right now" +// ============================================================================ +// While an agent's turn runs, its thinking and reply stream in token by token +// (Claude with --include-partial-messages, Codex delta notifications, Gemini +// message deltas). The timeline only gets whole blocks; this keeps the block +// being written, plus the phase (thinking / writing / running a tool), and +// publishes it to the broker a few times a second so every dashboard can show +// a typing indicator with the thinking as it forms. +// ============================================================================ + +import type { BrokerClient } from "../shared/broker-client.ts"; +import type { LiveActivity } from "../shared/types.ts"; +import { describeTool } from "./activity.ts"; + +const PUBLISH_EVERY_MS = 700; +/** Re-sent while busy so a host that died stops showing as busy */ +const HEARTBEAT_MS = 15_000; +const TEXT_KEEP = 6_000; + +export class LiveTracker { + private live: LiveActivity = { busy: false, phase: "idle", text: "", tool: null, since: null, updatedAt: Date.now() }; + private dirty = false; + private lastSent = 0; + private timer: ReturnType; + + /** `isBusy` is polled: drivers know best whether a turn is running */ + constructor(private sessionId: string, private slotId: number, private broker: BrokerClient, private isBusy?: () => boolean) { + this.timer = setInterval(() => this.tick(), PUBLISH_EVERY_MS); + } + + get state(): LiveActivity { return this.live; } + + // --- Claude stream-json --- + + claude(event: Record): void { + if (event.type === "stream_event") { + const e = event.event ?? {}; + if (e.type === "content_block_start") { + const block = e.content_block ?? {}; + if (block.type === "thinking") this.phase("thinking", ""); + else if (block.type === "text") this.phase("writing", ""); + else if (block.type === "tool_use") this.phase("tool", "", block.name ?? null); + } else if (e.type === "content_block_delta") { + const d = e.delta ?? {}; + if (d.type === "thinking_delta" && typeof d.thinking === "string") this.append(d.thinking, "thinking"); + else if (d.type === "text_delta" && typeof d.text === "string") this.append(d.text, "writing"); + } + return; + } + if (event.type === "assistant") { + // Whole blocks (also the only source without partial messages) + for (const block of event.message?.content ?? []) { + if (block.type === "tool_use") this.phase("tool", "", describeTool(block.name, block.input ?? {})); + else if (block.type === "thinking" && block.thinking && this.live.phase !== "thinking") this.phase("thinking", String(block.thinking)); + } + return; + } + if (event.type === "user" && event.isReplay !== true) { + // A tool result came back: the model is deciding what to do next + if (this.live.phase === "tool") this.phase("waiting", ""); + return; + } + if (event.type === "result") this.setBusy(false); + } + + // --- Codex app-server notifications --- + + codex(method: string, params: Record): void { + switch (method) { + case "turn/started": this.setBusy(true); this.phase("waiting", ""); return; + case "turn/completed": this.setBusy(false); return; + case "item/started": { + const item = params.item ?? {}; + if (item.type === "reasoning") this.phase("thinking", ""); + else if (item.type === "agentMessage") this.phase("writing", ""); + else if (item.type === "commandExecution") this.phase("tool", "", `$ ${Array.isArray(item.command) ? item.command.join(" ") : item.command ?? ""}`.trim()); + else if (item.type === "fileChange") this.phase("tool", "", "Editing files"); + else if (item.type === "mcpToolCall") this.phase("tool", "", describeTool(`mcp__${item.server ?? "mcp"}__${item.tool ?? "tool"}`, item.arguments ?? {})); + else if (item.type === "webSearch") this.phase("tool", "", describeTool("WebSearch", { query: item.query })); + return; + } + case "item/completed": + if (this.live.phase === "tool") this.phase("waiting", ""); + return; + case "item/reasoning/summaryTextDelta": + case "item/reasoning/textDelta": + if (typeof params.delta === "string") this.append(params.delta, "thinking"); + return; + case "item/reasoning/summaryPartAdded": + if (this.live.text) this.append("\n\n", "thinking"); + return; + case "item/agentMessage/delta": + if (typeof params.delta === "string") this.append(params.delta, "writing"); + return; + } + } + + // --- Gemini stream-json --- + + gemini(event: Record): void { + switch (event.type) { + case "init": this.setBusy(true); this.phase("waiting", ""); return; + case "message": + if (event.role !== "assistant") return; + if (event.delta && typeof event.content === "string") this.append(event.content, "writing"); + else if (this.live.phase !== "writing") this.phase("writing", ""); + return; + case "tool_use": this.phase("tool", "", describeTool(String(event.tool_name ?? "tool"), event.parameters ?? {})); return; + case "tool_result": this.phase("waiting", ""); return; + case "result": this.setBusy(false); return; + } + } + + setBusy(busy: boolean): void { + if (busy === this.live.busy) return; + this.live = busy + ? { busy: true, phase: "waiting", text: "", tool: null, since: Date.now(), updatedAt: Date.now() } + : { busy: false, phase: "idle", text: "", tool: null, since: null, updatedAt: Date.now() }; + this.dirty = true; + this.tick(); + } + + /** Stop publishing and clear the indicator (the agent exited). */ + dispose(): void { + clearInterval(this.timer); + this.live = { busy: false, phase: "idle", text: "", tool: null, since: null, updatedAt: Date.now() }; + void this.publish(); + } + + private phase(phase: LiveActivity["phase"], text: string, tool: string | null = null): void { + if (!this.live.busy) { this.live.busy = true; this.live.since = Date.now(); } + this.live.phase = phase; + this.live.text = text.slice(-TEXT_KEEP); + this.live.tool = tool; + this.dirty = true; + } + + /** Add streamed text; a delta of another kind (or the block's start was missed) starts a new block. */ + private append(s: string, kind: "thinking" | "writing"): void { + if (this.live.phase !== kind) this.phase(kind, ""); + this.live.text = (this.live.text + s).slice(-TEXT_KEEP); + this.dirty = true; + } + + private tick(): void { + if (this.isBusy) { + const busy = this.isBusy(); + if (busy !== this.live.busy) { this.setBusy(busy); return; } + } + const now = Date.now(); + if (this.dirty || (this.live.busy && now - this.lastSent > HEARTBEAT_MS)) void this.publish(); + } + + private async publish(): Promise { + this.dirty = false; + this.lastSent = Date.now(); + this.live.updatedAt = this.lastSent; + try { + await this.broker.setLiveActivity({ session_id: this.sessionId, slot_id: this.slotId, live: { ...this.live } }); + } catch { /* the next tick retries */ this.dirty = true; } + } +} diff --git a/orchestrator/plan-usage.ts b/orchestrator/plan-usage.ts new file mode 100644 index 0000000..ecea4b0 --- /dev/null +++ b/orchestrator/plan-usage.ts @@ -0,0 +1,310 @@ +// ============================================================================ +// multiagents — Subscription plan limits (5-hour / weekly / daily windows) +// ============================================================================ +// Reads how much of each CLI's subscription quota is used, the way the CLIs' +// own /usage screens do, without starting a model turn: +// Claude `get_usage` control request (claude.ai plan windows: 5-hour, weekly, +// weekly per model), asked of a short-lived `claude -p` process. +// Codex `account/rateLimits/read` on a short-lived `codex app-server` +// (primary = 5-hour window, secondary = weekly). +// Gemini Code Assist `retrieveUserQuota` with the Google login Gemini CLI +// stored (per-model daily buckets). The token is only used while it +// is valid; Gemini CLI refreshes it whenever it runs. +// Answers are cached; the dashboard asks while its Usage page is open. +// ============================================================================ + +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { CodexDriver } from "./codex-driver.ts"; +import { log } from "../shared/utils.ts"; + +const LOG_PREFIX = "plan-usage"; + +export type PlanProvider = "claude" | "codex" | "gemini"; + +export interface PlanWindow { + /** Stable key: five_hour, seven_day, seven_day_opus, daily:… */ + id: string; + /** English label; the dashboard translates it */ + label: string; + /** 0–100, null when the CLI did not say */ + usedPercent: number | null; + /** Epoch ms, null when unknown */ + resetsAt: number | null; + /** Window length, when known */ + windowMins?: number | null; + /** Extra text such as "$12.40 of $50" */ + detail?: string; +} + +export interface PlanUsage { + provider: PlanProvider; + /** True when windows were read */ + ok: boolean; + plan: string | null; + windows: PlanWindow[]; + /** Why there is nothing to show (no subscription login, CLI not installed…) */ + note?: string; + error?: string; + fetchedAt: number; +} + +const CACHE_TTL_MS = 120_000; +const MIN_REFRESH_MS = 15_000; +const cache = new Map(); +const inflight = new Map>(); + +/** Every provider's plan usage, from cache unless stale (or `refresh`, at most every 15s). */ +export async function getPlanUsage(opts: { refresh?: boolean; providers?: PlanProvider[] } = {}): Promise { + const providers = opts.providers ?? ["claude", "codex", "gemini"]; + return Promise.all(providers.map((p) => cached(p, opts.refresh === true))); +} + +function cached(provider: PlanProvider, refresh: boolean): Promise { + const hit = cache.get(provider); + const age = hit ? Date.now() - hit.fetchedAt : Infinity; + if (hit && age < (refresh ? MIN_REFRESH_MS : CACHE_TTL_MS)) return Promise.resolve(hit); + const running = inflight.get(provider); + if (running) return running; + const probe = PROBES[provider]() + .catch((err): PlanUsage => result(provider, { error: err instanceof Error ? err.message : String(err) })) + .then((r) => { cache.set(provider, r); return r; }) + .finally(() => inflight.delete(provider)); + inflight.set(provider, probe); + return probe; +} + +/** Forget cached answers (tests). */ +export function clearPlanUsageCache(): void { cache.clear(); } + +function result(provider: PlanProvider, fields: Partial): PlanUsage { + const windows = fields.windows ?? []; + return { provider, ok: windows.length > 0, plan: null, windows, fetchedAt: Date.now(), ...fields }; +} + +function commandOf(envVar: string, fallback: string[]): string[] { + const override = process.env[envVar]?.trim(); + return override ? override.split(/\s+/) : fallback; +} + +/** ISO string or epoch seconds/ms → epoch ms. */ +function toMs(v: unknown): number | null { + if (typeof v === "number" && Number.isFinite(v)) return v < 1e12 ? v * 1000 : v; + if (typeof v === "string" && v) { + const t = Date.parse(v); + return Number.isFinite(t) ? t : null; + } + return null; +} + +function pct(v: unknown): number | null { + return typeof v === "number" && Number.isFinite(v) ? Math.max(0, Math.round(v * 10) / 10) : null; +} + +const PROBES: Record Promise> = { claude: probeClaude, codex: probeCodex, gemini: probeGemini }; + +// --- Claude --- + +const CLAUDE_WINDOWS: Array<[key: string, label: string]> = [ + ["five_hour", "5-hour window"], + ["seven_day", "Weekly · all models"], + ["seven_day_opus", "Weekly · Opus"], + ["seven_day_sonnet", "Weekly · Sonnet"], + ["seven_day_oauth_apps", "Weekly · other apps"], +]; + +/** The `get_usage` answer → windows. */ +export function claudeWindows(answer: Record): PlanWindow[] { + const limits = answer?.rate_limits; + if (!limits || typeof limits !== "object") return []; + const windows: PlanWindow[] = []; + for (const [key, label] of CLAUDE_WINDOWS) { + const w = limits[key]; + if (w && typeof w === "object") windows.push({ id: key, label, usedPercent: pct(w.utilization), resetsAt: toMs(w.resets_at) }); + } + for (const m of Array.isArray(limits.model_scoped) ? limits.model_scoped : []) { + if (m?.display_name) windows.push({ id: `model:${m.display_name}`, label: `Weekly · ${m.display_name}`, usedPercent: pct(m.utilization), resetsAt: toMs(m.resets_at) }); + } + const extra = limits.extra_usage; + if (extra?.is_enabled) { + const money = (n: unknown) => (typeof n === "number" ? `${extra.currency && extra.currency !== "USD" ? `${extra.currency} ` : "$"}${n.toFixed(2)}` : "?"); + windows.push({ + id: "extra_usage", label: "Extra usage · monthly", usedPercent: pct(extra.utilization), resetsAt: null, + detail: extra.monthly_limit != null ? `${money(extra.used_credits)} / ${money(extra.monthly_limit)}` : undefined, + }); + } + return windows; +} + +async function probeClaude(): Promise { + const command = commandOf("MULTIAGENTS_CLAUDE_COMMAND", ["claude"]); + if (!Bun.which(command[0]!)) return result("claude", { note: "Claude Code is not installed." }); + // No MCP servers, no hooks, outside any project: the process only answers the control request + const proc = Bun.spawn([ + ...command, "-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", + "--strict-mcp-config", "--mcp-config", JSON.stringify({ mcpServers: {} }), + "--settings", JSON.stringify({ disableAllHooks: true }), + ], { cwd: tmpdir(), stdin: "pipe", stdout: "pipe", stderr: "pipe", env: process.env }); + try { + proc.stdin.write(JSON.stringify({ type: "control_request", request_id: "plan_usage", request: { subtype: "get_usage", skip_behaviors: true } }) + "\n"); + proc.stdin.flush(); + const response = await readJsonLines(proc.stdout, (m) => m.type === "control_response" && m.response?.request_id === "plan_usage", 25_000); + if (!response) return result("claude", { error: "Claude Code did not answer the usage request." }); + if (response.response.subtype === "error") { + return result("claude", { note: "This Claude Code version does not report plan usage — update it.", error: String(response.response.error ?? "") }); + } + const answer = response.response.response ?? {}; + const windows = claudeWindows(answer); + return result("claude", { + plan: typeof answer.subscription_type === "string" ? answer.subscription_type : null, + windows, + note: windows.length ? undefined : "No claude.ai plan limits for this login (an API key has none).", + }); + } finally { + try { proc.stdin.end(); } catch { /* closed */ } + setTimeout(() => { try { proc.kill(); } catch { /* gone */ } }, 2_000); + } +} + +/** Read newline-delimited JSON until `match` or the timeout; null on timeout/EOF. */ +async function readJsonLines(stream: ReadableStream, match: (m: any) => boolean, timeoutMs: number): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + const deadline = Date.now() + timeoutMs; + try { + while (Date.now() < deadline) { + const chunk = await Promise.race([reader.read(), Bun.sleep(deadline - Date.now()).then(() => null)]); + if (!chunk || chunk.done) return null; + buffer += decoder.decode(chunk.value, { stream: true }); + let nl; + while ((nl = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + try { + const msg = JSON.parse(line); + if (match(msg)) return msg; + } catch { /* not JSON */ } + } + } + return null; + } finally { + reader.releaseLock(); + } +} + +// --- Codex --- + +function windowLabel(mins: number | null | undefined, fallback: string): string { + if (mins === 300) return "5-hour window"; + if (mins === 10080) return "Weekly"; + if (mins === 1440) return "Daily"; + if (typeof mins === "number" && mins > 0) return mins % 1440 === 0 ? `${mins / 1440}-day window` : mins % 60 === 0 ? `${mins / 60}-hour window` : `${mins}-minute window`; + return fallback; +} + +/** `account/rateLimits/read` → windows (every metered bucket when the server lists several). */ +export function codexWindows(answer: Record): PlanWindow[] { + const buckets: Array<[string, Record]> = answer?.rateLimitsByLimitId && typeof answer.rateLimitsByLimitId === "object" + ? Object.entries(answer.rateLimitsByLimitId) + : answer?.rateLimits ? [[answer.rateLimits.limitId ?? "codex", answer.rateLimits]] : []; + const windows: PlanWindow[] = []; + for (const [limitId, snap] of buckets) { + const name = snap?.limitName && buckets.length > 1 ? `${snap.limitName} · ` : buckets.length > 1 && limitId !== "codex" ? `${limitId} · ` : ""; + for (const [slot, fallback] of [["primary", "Short window"], ["secondary", "Long window"]] as const) { + const w = snap?.[slot]; + if (!w || typeof w !== "object") continue; + windows.push({ + id: `${limitId}:${slot}`, label: `${name}${windowLabel(w.windowDurationMins, fallback)}`, + usedPercent: pct(w.usedPercent), resetsAt: toMs(w.resetsAt), windowMins: w.windowDurationMins ?? null, + }); + } + } + return windows; +} + +async function probeCodex(): Promise { + const command = commandOf("MULTIAGENTS_CODEX_COMMAND", ["codex", "app-server"]); + if (!Bun.which(command[0]!)) return result("codex", { note: "Codex CLI is not installed." }); + let driver: CodexDriver | null = null; + try { + driver = await CodexDriver.spawn(tmpdir(), process.env, { command, timeoutMs: 15_000 }); + const account = await driver.request("account/read", { refreshToken: false }, 15_000).catch(() => null) as { account?: { planType?: string; type?: string } | null } | null; + let answer: Record; + try { + answer = await driver.request("account/rateLimits/read", { excludeResetCreditDetails: true }, 20_000) as Record; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/auth/i.test(msg)) return result("codex", { note: "Sign in to Codex with ChatGPT to see its limits (an API key has none)." }); + throw err; + } + const windows = codexWindows(answer); + return result("codex", { + plan: account?.account?.planType ?? answer?.rateLimits?.planType ?? null, + windows, + note: windows.length ? undefined : "Codex reported no usage windows for this account.", + }); + } finally { + await driver?.kill().catch(() => {}); + } +} + +// --- Gemini --- + +const CODE_ASSIST = "https://cloudcode-pa.googleapis.com/v1internal"; + +/** `retrieveUserQuota` buckets → windows (Gemini counts requests per model per day). */ +export function geminiWindows(answer: Record): PlanWindow[] { + const windows: PlanWindow[] = []; + for (const b of Array.isArray(answer?.buckets) ? answer.buckets : []) { + if (!b?.modelId || typeof b.remainingFraction !== "number") continue; + const remaining = b.remainingAmount !== undefined ? Number.parseInt(String(b.remainingAmount), 10) : NaN; + const limit = Number.isFinite(remaining) && b.remainingFraction > 0 ? Math.round(remaining / b.remainingFraction) : null; + windows.push({ + id: `daily:${b.modelId}${b.tokenType ? `:${b.tokenType}` : ""}`, + label: `Daily · ${b.modelId}`, + usedPercent: pct((1 - b.remainingFraction) * 100), + resetsAt: toMs(b.resetTime), + detail: Number.isFinite(remaining) && limit ? `${remaining} / ${limit} left` : undefined, + }); + } + return windows; +} + +async function probeGemini(): Promise { + const file = Bun.file(join(process.env.GEMINI_CLI_HOME ?? homedir(), ".gemini", "oauth_creds.json")); + if (!(await file.exists())) { + return result("gemini", { + note: process.env.GEMINI_API_KEY ? "Gemini runs on an API key; Google does not report its quota." : "Sign in to Gemini CLI with Google to see its quota.", + }); + } + const creds = await file.json().catch(() => null) as { access_token?: string; expiry_date?: number } | null; + if (!creds?.access_token) return result("gemini", { note: "Sign in to Gemini CLI with Google to see its quota." }); + if (typeof creds.expiry_date === "number" && creds.expiry_date < Date.now() + 30_000) { + return result("gemini", { note: "The Gemini login has expired; it refreshes the next time Gemini CLI runs." }); + } + const post = async (method: string, body: unknown) => { + const res = await fetch(`${CODE_ASSIST}:${method}`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.access_token}` }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) throw new Error(`Gemini ${method}: HTTP ${res.status}`); + return await res.json() as Record; + }; + const load = await post("loadCodeAssist", { + cloudaicompanionProject: process.env.GOOGLE_CLOUD_PROJECT || undefined, + metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" }, + }); + const project = load.cloudaicompanionProject ?? process.env.GOOGLE_CLOUD_PROJECT; + if (!project) return result("gemini", { plan: load.currentTier?.name ?? null, note: "Gemini did not report a Code Assist project for this login." }); + const quota = await post("retrieveUserQuota", { project }); + const windows = geminiWindows(quota); + log(LOG_PREFIX, `Gemini quota: ${windows.length} bucket(s)`); + return result("gemini", { + plan: load.currentTier?.name ?? load.currentTier?.id ?? null, + windows, + note: windows.length ? undefined : "Gemini reported no quota buckets for this login.", + }); +} diff --git a/orchestrator/runtime.ts b/orchestrator/runtime.ts index fad7a4c..c645c26 100644 --- a/orchestrator/runtime.ts +++ b/orchestrator/runtime.ts @@ -35,6 +35,7 @@ import { checkGuardrails, enforceGuardrails } from "./guardrails.ts"; import { handleAgentCrash, clearAllCrashHistory } from "./recovery.ts"; import { controlSession, broadcastToTeam, resolveTarget, pauseAgent } from "./session-control.ts"; import { DriverRegistry, nonOverlapping, releaseVerdict, watchdogInbox, limitVerdict, type UsageLimits } from "./supervision.ts"; +import { LiveTracker } from "./live.ts"; import { ActivityRecorder, awaitApproval, claudeActivity, codexActivity, geminiActivity, describeTool, isTeamTool, CLAUDE_PERMISSION_MODE, CODEX_APPROVAL_POLICY } from "./activity.ts"; const LOG_PREFIX = "orchestrator"; @@ -49,6 +50,25 @@ const activeDrivers = new DriverRegistry(); // One respawn per slot at a time, whichever path (crash, code-0 exit, watchdog, resume) triggers it const respawnGuard = new RespawnGuard(); +// --- Q&A mode: the person put the team's work on hold to ask questions --- +// Session config `qa_mode` is set from a dashboard. The broker then hands agents +// only the person's and the orchestrator's messages; here running turns are +// interrupted, edits and commands are refused, and nudges / respawns wait. +const qaCache = new Map(); + +async function qaModeOn(sessionId: string, brokerClient: BrokerClient): Promise { + const hit = qaCache.get(sessionId); + if (hit && Date.now() - hit.at < 2_500) return hit.on; + const session = await brokerClient.getSession(sessionId).catch(() => null); + const on = !!safeJsonParse>(session?.config ?? "{}", {}).qa_mode; + qaCache.set(sessionId, { on, at: Date.now() }); + return on; +} + +/** Tools an agent may still use in Q&A mode: reading, to answer questions about its work. */ +const QA_READ_ONLY_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "NotebookRead", "TodoWrite", "WebSearch", "WebFetch"]); +const QA_DENIAL = "Work is on hold (Q&A mode): the person is asking questions. Do not edit files or run commands — answer with send_message(to_id=\"human\") and wait for [RESUME]."; + interface LaunchedAgent { pid: number; process: Subprocess; @@ -117,8 +137,12 @@ function trackLaunch(sessionId: string, slotId: number, result: LaunchedAgent, b : null; if (!state) { const recorder = new ActivityRecorder(sessionId, slotId, brokerClient); - monitorProcess(result.process, slotId, sessionId, brokerClient, handleEvent, (event) => recorder.push(...geminiActivity(event))); - result.process.exited.then(() => recorder.flush()); + const live = new LiveTracker(sessionId, slotId, brokerClient); + monitorProcess(result.process, slotId, sessionId, brokerClient, handleEvent, (event) => { + recorder.push(...geminiActivity(event)); + live.gemini(event as Record); + }); + result.process.exited.then(() => { live.dispose(); return recorder.flush(); }); return; } @@ -151,7 +175,10 @@ function superviseDriver(sessionId: string, slotId: number, state: DriverSlot, b const recorder = new ActivityRecorder(sessionId, slotId, brokerClient); const stillWanted = () => state.driver.alive && activeDrivers.get(sessionId, slotId) === state; const alwaysAllowed = new Set(); + const live = new LiveTracker(sessionId, slotId, brokerClient, () => + state.driver.alive && (state.kind === "claude" ? state.driver.busy : state.driver.activeTurnId !== null)); state.driver.onExit((code) => { + live.dispose(); recorder.push({ kind: "status", title: `Agent process exited (code ${code})` }); void recorder.flush(); }); @@ -171,9 +198,17 @@ function superviseDriver(sessionId: string, slotId: number, state: DriverSlot, b if (state.kind === "claude") { const tools = new Map(); - state.driver.onEvent((event) => recorder.push(...claudeActivity(event as Record, tools)), { replay: true }); + state.driver.onEvent((event) => { + live.claude(event as Record); + if ((event as { type?: string }).type !== "stream_event") recorder.push(...claudeActivity(event as Record, tools)); + }, { replay: true }); state.driver.onPermissionRequest(async (req) => { if (isTeamTool(req.toolName)) return { behavior: "allow" }; + if (await qaModeOn(sessionId, brokerClient)) { + if (QA_READ_ONLY_TOOLS.has(req.toolName)) return { behavior: "allow" }; + recorder.push({ kind: "approval", title: `Refused in Q&A mode · ${describeTool(req.toolName, req.input)}` }); + return { behavior: "deny", message: QA_DENIAL }; + } const key = approvalKey(req.toolName, req.input); if (alwaysAllowed.has(key)) return { behavior: "allow" }; const outcome = await ask(req.toolName, describeTool(req.toolName, req.input), req.input); @@ -190,9 +225,15 @@ function superviseDriver(sessionId: string, slotId: number, state: DriverSlot, b items.set((params.item as { id: string }).id, params.item as Record); } if (method === "item/completed" && params.item) items.delete((params.item as { id?: string }).id ?? ""); + live.codex(method, params as Record); recorder.push(...codexActivity(method, params as Record)); }); state.driver.onApprovalRequest(async ({ kind, params }) => { + // Q&A mode runs Codex with "untrusted": reads go through, anything else is refused + if (await qaModeOn(sessionId, brokerClient)) { + recorder.push({ kind: "approval", title: `Refused in Q&A mode · ${kind === "command" ? "shell command" : "file change"}` }); + return "decline"; + } const item = items.get(String(params.itemId ?? "")); const command = Array.isArray(params.command) ? params.command.join(" ") : (params.command as string | undefined) ?? (item?.command as string | undefined); const changes = (item?.changes as Array<{ path?: string }> | undefined) ?? []; @@ -221,8 +262,10 @@ function recorderOf(state: DriverSlot): ActivityRecorder | undefined { * mode edited in a dashboard apply live (Claude: control requests; Codex: the * next turn), and a paused slot's running turn is interrupted. */ -async function reconcileDriver(slot: Slot, state: DriverSlot): Promise { +async function reconcileDriver(slot: Slot, state: DriverSlot, qa = false): Promise { const want = slotSettings(slot); + // Q&A mode: every tool goes through the handler, which only lets reads pass + if (qa) want.approvalMode = "ask"; const recorder = recorderOf(state); // A driver launched without a record of its settings is taken to run the slot's current ones state.applied ??= launchedSettings(state.kind, want); @@ -252,13 +295,37 @@ async function reconcileDriver(slot: Slot, state: DriverSlot): Promise { recorder?.push({ kind: "status", title: `Effort → ${want.effort ?? "default"} (applies when the agent restarts)` }); } } else { - if (applied.model !== want.model || applied.effort !== want.effort || applied.approvalMode !== want.approvalMode) { - state.driver.settings = { approvalPolicy: CODEX_APPROVAL_POLICY[want.approvalMode], model: want.model, effort: want.effort }; + const qaApplied = (state as DriverSlot & { qaSandbox?: boolean }).qaSandbox === true; + if (applied.model !== want.model || applied.effort !== want.effort || applied.approvalMode !== want.approvalMode || qaApplied !== qa) { + (state as DriverSlot & { qaSandbox?: boolean }).qaSandbox = qa; + state.driver.settings = { approvalPolicy: CODEX_APPROVAL_POLICY[want.approvalMode], model: want.model, effort: want.effort, ...(qa ? { sandbox: "read-only" as const } : {}) }; recorder?.push({ kind: "status", title: `Settings for the next turn: model ${want.model ?? "default"} · effort ${want.effort ?? "default"} · approvals ${want.approvalMode}` }); Object.assign(applied, { model: want.model, effort: want.effort, approvalMode: want.approvalMode }); } } + // Q&A mode starting, or the person asked to interrupt ("interrupt and ask"), stops the running turn + const snap = safeJsonParse>(slot.context_snapshot, {}); + const interruptAt = typeof snap.interrupt_at === "number" ? snap.interrupt_at : 0; + const ext = state as DriverSlot & { qaInterrupted?: boolean; interruptSeen?: number }; + ext.interruptSeen ??= interruptAt; // requests from before this driver started are old + const wantInterrupt = (qa && !ext.qaInterrupted) || interruptAt > ext.interruptSeen; + ext.interruptSeen = Math.max(ext.interruptSeen, interruptAt); + if (!qa) ext.qaInterrupted = false; + if (wantInterrupt) { + if (qa) ext.qaInterrupted = true; + const running = state.kind === "claude" ? state.driver.busy : state.driver.activeTurnId !== null; + if (running) { + try { + if (state.kind === "claude") await state.driver.interrupt(); + else await state.driver.interrupt(state.threadId ?? state.driver.threadId ?? ""); + recorder?.push({ kind: "status", title: qa ? "Q&A mode — the running turn was interrupted" : "Interrupted to answer the person" }); + } catch (err) { + log(LOG_PREFIX, `Interrupt failed for slot ${slot.id}: ${err}`); + } + } + } + // Pausing stops the running turn too, not only the next delivery const paused = slot.paused === true || (slot.paused as unknown as number) === 1; if (!paused) { @@ -1841,12 +1908,13 @@ export function startBackgroundLoops(brokerClient: BrokerClient): void { setInterval(nonOverlapping(async () => { for (const [sessionId, drivers] of activeDrivers.sessions()) { const slots = new Map((await brokerClient.listSlots(sessionId).catch(() => [] as Slot[])).map((s) => [s.id, s])); + const qa = await qaModeOn(sessionId, brokerClient); for (const [slotId, state] of drivers) { if (!state.driver.alive) continue; const slot = slots.get(slotId); if (slot) { - try { await reconcileDriver(slot, state); } catch (err) { log(LOG_PREFIX, `Settings sync failed for slot ${slotId}: ${err}`); } + try { await reconcileDriver(slot, state, qa); } catch (err) { log(LOG_PREFIX, `Settings sync failed for slot ${slotId}: ${err}`); } } if (state.kind === "codex") { @@ -1876,6 +1944,8 @@ export function startBackgroundLoops(brokerClient: BrokerClient): void { setInterval(async () => { for (const sessionId of activeProcesses.keys()) { try { + // Agents waiting for the person's questions are silent on purpose + if (await qaModeOn(sessionId, brokerClient)) continue; const slots = await brokerClient.listSlots(sessionId); for (const slot of slots) { if (slot.status !== "connected" || !slot.peer_id) continue; @@ -2018,6 +2088,8 @@ export function startBackgroundLoops(brokerClient: BrokerClient): void { setInterval(async () => { for (const sessionId of activeProcesses.keys()) { try { + // The person is talking to the team: never end the session under them + if (await qaModeOn(sessionId, brokerClient)) { sessionCompletionTimers.delete(sessionId); continue; } const slots = await brokerClient.listSlots(sessionId); if (slots.length === 0) continue; @@ -2125,6 +2197,8 @@ export function startBackgroundLoops(brokerClient: BrokerClient): void { setInterval(async () => { for (const sessionId of activeProcesses.keys()) { try { + // Teammates' messages held during Q&A mode are no reason to respawn anyone + if (await qaModeOn(sessionId, brokerClient)) continue; const slots = await brokerClient.listSlots(sessionId); for (const slot of slots) { const taskState = (slot as any).task_state; diff --git a/shared/broker-client.ts b/shared/broker-client.ts index ebc0a5b..0301fff 100644 --- a/shared/broker-client.ts +++ b/shared/broker-client.ts @@ -45,6 +45,7 @@ import type { EditPlanItemRequest, ActivityAddRequest, ActivityEntry, + LiveActivity, ApprovalRequest, } from "./types.ts"; @@ -288,6 +289,15 @@ export class BrokerClient { return this.post("/activity/list", req); } + setLiveActivity(req: { session_id: string; slot_id: number; live: LiveActivity }): Promise<{ ok: boolean }> { + return this.post("/activity/live/set", req); + } + + /** Live state per slot id of a session (stale entries from dead hosts read as idle). */ + listLiveActivity(sessionId: string): Promise> { + return this.post("/activity/live/list", { session_id: sessionId }); + } + createApproval(req: { session_id: string; slot_id: number; tool: string; title: string; input?: unknown }): Promise { return this.post("/approvals/create", req); } diff --git a/shared/constants.ts b/shared/constants.ts index 51f7f6f..267be48 100644 --- a/shared/constants.ts +++ b/shared/constants.ts @@ -148,3 +148,10 @@ export const FLAP_WINDOW_MS = 5 * 60 * 1000; // 5 min window for flap detection export const FLAP_THRESHOLD = 3; // crashes in window before declaring flapping export const GUARDRAIL_CHECK_INTERVAL = 30_000; // 30s export const CONFLICT_CHECK_INTERVAL = 10_000; // 10s + +// --- The person at the dashboard --- + +/** Sender / recipient id of the person using the web dashboard. */ +export const HUMAN_SENDER = "human"; +/** How agents see that sender, with the id to reply to. */ +export const HUMAN_SENDER_LABEL = 'The human user (reply with send_message to_id="human")'; diff --git a/shared/types.ts b/shared/types.ts index ca79960..bff0c0d 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -492,6 +492,20 @@ export interface ActivityAddRequest { entries: Array<{ kind: ActivityKind; title: string; detail?: string | null; data?: unknown; ts?: number }>; } +/** What an agent is doing right now (in memory on the broker, not stored). */ +export interface LiveActivity { + busy: boolean; + /** waiting = between steps (the model is deciding what to do next) */ + phase: "idle" | "waiting" | "thinking" | "writing" | "tool"; + /** The thinking or reply being written, tail only */ + text: string; + /** The tool step running (phase "tool") */ + tool: string | null; + /** When the turn started (epoch ms) */ + since: number | null; + updatedAt: number; +} + export type ApprovalStatus = "pending" | "allowed" | "denied" | "cancelled"; export interface ApprovalRequest { diff --git a/tests/codex-driver.test.ts b/tests/codex-driver.test.ts index d9d638a..791f84c 100644 --- a/tests/codex-driver.test.ts +++ b/tests/codex-driver.test.ts @@ -231,4 +231,13 @@ describe("CodexDriver approvals and turn settings", () => { expect(turns[0].approvalPolicy).toBe("never"); expect(turns[1]).toMatchObject({ approvalPolicy: "untrusted", model: "gpt-5", effort: "high" }); }); + + test("a read-only sandbox override (Q&A mode) reaches the next turn", async () => { + const { driver, dir, entries } = await spawnFake(); + const first = await driver.startSession({ prompt: "warm up", cwd: dir }); + driver.settings = { approvalPolicy: "untrusted", sandbox: "read-only" }; + await driver.reply(first.threadId, "what is left?"); + const turns = entries().filter((e) => e.request === "turn/start").map((e) => e.params); + expect(turns[1].sandboxPolicy).toMatchObject({ type: "readOnly" }); + }); }); diff --git a/tests/fakes/fake-claude.ts b/tests/fakes/fake-claude.ts index 859df30..5bb568a 100644 --- a/tests/fakes/fake-claude.ts +++ b/tests/fakes/fake-claude.ts @@ -28,6 +28,7 @@ log({ argv: args }); const resumeIdx = args.indexOf("--resume"); const sessionId = resumeIdx !== -1 ? args[resumeIdx + 1]! : `fake-session-${Math.random().toString(36).slice(2, 8)}`; const replay = args.includes("--replay-user-messages"); +const partial = args.includes("--include-partial-messages"); const modelIdx = args.indexOf("--model"); let model = modelIdx !== -1 ? args[modelIdx + 1]! : "fake-haiku"; const modeIdx = args.indexOf("--permission-mode"); @@ -88,6 +89,16 @@ async function runTurn(msg: any): Promise { } const think = text.match(/THINK:([^|]+)/); + if (think && partial) { + // Like the real host with --include-partial-messages: the block streams in word by word first + const stream = (event: unknown) => out({ type: "stream_event", event, session_id: sessionId, parent_tool_use_id: null }); + stream({ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }); + for (const word of think[1]!.trim().split(/(?<= )/)) { + stream({ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: word } }); + await new Promise((r) => setTimeout(r, Number(process.env.FAKE_CLAUDE_DELTA_MS ?? 0))); + } + stream({ type: "content_block_stop", index: 0 }); + } if (think) out({ type: "assistant", message: { role: "assistant", content: [{ type: "thinking", thinking: think[1]!.trim() }] } }); for (const m of text.matchAll(/TOOL:(\w+):([^\s|]+)/g)) { @@ -171,6 +182,21 @@ process.stdin.on("data", (chunk) => { interrupted = inTurn; for (const [id, w] of controlWaiters) { controlWaiters.delete(id); w({ behavior: "deny", message: "interrupted" }); } ok(); + } else if (req.subtype === "get_usage") { + // FAKE_CLAUDE_USAGE=none: a login without claude.ai plan limits (API key) + const none = process.env.FAKE_CLAUDE_USAGE === "none"; + ok({ + session: { total_cost_usd: 0 }, + subscription_type: none ? null : "max", + rate_limits_available: !none, + rate_limits: none ? null : { + five_hour: { utilization: 42, resets_at: "2030-01-01T05:00:00Z" }, + seven_day: { utilization: 18.5, resets_at: "2030-01-06T00:00:00Z" }, + seven_day_opus: { utilization: 7, resets_at: "2030-01-06T00:00:00Z" }, + seven_day_sonnet: null, + }, + behaviors: null, + }); } else fail(`unsupported ${req.subtype}`); continue; } diff --git a/tests/fakes/fake-codex-app-server.ts b/tests/fakes/fake-codex-app-server.ts index 03f7db6..8e58297 100644 --- a/tests/fakes/fake-codex-app-server.ts +++ b/tests/fakes/fake-codex-app-server.ts @@ -136,6 +136,15 @@ function handle(msg: any): void { } case "thread/list": return reply(id, { data: [], nextCursor: null }); + case "account/read": + return reply(id, { account: { type: "chatgpt", email: "fake@example.com", planType: "plus" }, requiresOpenaiAuth: true }); + case "account/rateLimits/read": + if (process.env.FAKE_CODEX_LOGGED_OUT === "1") return replyError(id, -32600, "codex account authentication required to read rate limits"); + return reply(id, { rateLimits: { + limitId: "codex", planType: "plus", + primary: { usedPercent: 63, windowDurationMins: 300, resetsAt: 1893474000 }, + secondary: { usedPercent: 21, windowDurationMins: 10080, resetsAt: 1893906000 }, + } }); case "turn/start": { if (activeTurn) return replyError(id, -32600, "a turn is already active"); const turnId = `turn_${++turnCounter}`; diff --git a/tests/live.test.ts b/tests/live.test.ts new file mode 100644 index 0000000..44d6e5f --- /dev/null +++ b/tests/live.test.ts @@ -0,0 +1,76 @@ +/** + * Live activity: the block an agent is writing right now, from each CLI's stream. + */ +import { describe, test, expect } from "bun:test"; +import { LiveTracker } from "../orchestrator/live.ts"; +import type { LiveActivity } from "../shared/types.ts"; + +function fakeBroker() { + const sent: LiveActivity[] = []; + return { sent, client: { setLiveActivity: async (req: { live: LiveActivity }) => { sent.push(req.live); return { ok: true }; } } as any }; +} + +describe("LiveTracker", () => { + test("Claude: thinking streams in, then a tool step, then the turn ends", async () => { + const { sent, client } = fakeBroker(); + let busy = true; + const t = new LiveTracker("s", 1, client, () => busy); + t.claude({ type: "stream_event", event: { type: "content_block_start", content_block: { type: "thinking" } } }); + t.claude({ type: "stream_event", event: { type: "content_block_delta", delta: { type: "thinking_delta", thinking: "Check the " } } }); + t.claude({ type: "stream_event", event: { type: "content_block_delta", delta: { type: "thinking_delta", thinking: "tests first" } } }); + expect(t.state).toMatchObject({ busy: true, phase: "thinking", text: "Check the tests first" }); + + t.claude({ type: "assistant", message: { content: [{ type: "tool_use", name: "Bash", input: { command: "bun test" } }] } }); + expect(t.state).toMatchObject({ phase: "tool", tool: "$ bun test", text: "" }); + t.claude({ type: "user", message: { content: [{ type: "tool_result", content: "ok" }] } }); + expect(t.state.phase).toBe("waiting"); + + await Bun.sleep(800); + expect(sent.at(-1)).toMatchObject({ busy: true, phase: "waiting" }); + busy = false; + t.claude({ type: "result" }); + expect(t.state.busy).toBe(false); + expect(sent.at(-1)!.busy).toBe(false); + t.dispose(); + }); + + test("Codex: reasoning summary and message deltas", () => { + const { client } = fakeBroker(); + const t = new LiveTracker("s", 2, client); + t.codex("turn/started", {}); + t.codex("item/started", { item: { type: "reasoning" } }); + t.codex("item/reasoning/summaryTextDelta", { delta: "Plan: " }); + t.codex("item/reasoning/summaryTextDelta", { delta: "read the spec" }); + expect(t.state).toMatchObject({ busy: true, phase: "thinking", text: "Plan: read the spec" }); + t.codex("item/started", { item: { type: "commandExecution", command: "ls -la" } }); + expect(t.state).toMatchObject({ phase: "tool", tool: "$ ls -la" }); + t.codex("item/agentMessage/delta", { delta: "Done" }); + expect(t.state).toMatchObject({ phase: "writing", text: "Done" }); + t.codex("turn/completed", {}); + expect(t.state.busy).toBe(false); + t.dispose(); + }); + + test("Gemini: a run is busy from init to result", () => { + const { client } = fakeBroker(); + const t = new LiveTracker("s", 3, client); + t.gemini({ type: "init" }); + t.gemini({ type: "tool_use", tool_name: "read_file", parameters: { file_path: "a.ts" } }); + expect(t.state).toMatchObject({ busy: true, phase: "tool" }); + t.gemini({ type: "message", role: "assistant", delta: true, content: "Hi" }); + expect(t.state).toMatchObject({ phase: "writing", text: "Hi" }); + t.gemini({ type: "result" }); + expect(t.state.busy).toBe(false); + t.dispose(); + }); + + test("long text keeps its tail", () => { + const { client } = fakeBroker(); + const t = new LiveTracker("s", 4, client); + t.claude({ type: "stream_event", event: { type: "content_block_start", content_block: { type: "text" } } }); + t.claude({ type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "x".repeat(7000) + "END" } } }); + expect(t.state.text.length).toBe(6000); + expect(t.state.text.endsWith("END")).toBe(true); + t.dispose(); + }); +}); diff --git a/tests/plan-usage.test.ts b/tests/plan-usage.test.ts new file mode 100644 index 0000000..a5dd6bc --- /dev/null +++ b/tests/plan-usage.test.ts @@ -0,0 +1,97 @@ +/** + * Subscription plan limits: parsing each CLI's answer, and the probes against + * the fake Claude / Codex CLIs. + */ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { claudeWindows, codexWindows, geminiWindows, getPlanUsage, clearPlanUsageCache } from "../orchestrator/plan-usage.ts"; + +const FAKE_CLAUDE = new URL("./fakes/fake-claude.ts", import.meta.url).pathname; +const FAKE_CODEX = new URL("./fakes/fake-codex-app-server.ts", import.meta.url).pathname; + +describe("plan usage parsing", () => { + test("Claude: 5-hour, weekly and per-model windows; null windows are skipped", () => { + const w = claudeWindows({ + rate_limits: { + five_hour: { utilization: 42.25, resets_at: "2030-01-01T05:00:00Z" }, + seven_day: { utilization: 18, resets_at: "2030-01-06T00:00:00Z" }, + seven_day_sonnet: null, + model_scoped: [{ display_name: "Fable", utilization: 3, resets_at: null }], + extra_usage: { is_enabled: true, monthly_limit: 50, used_credits: 12.4, utilization: 24.8, currency: "USD" }, + }, + }); + expect(w.map((x) => x.id)).toEqual(["five_hour", "seven_day", "model:Fable", "extra_usage"]); + expect(w[0]).toMatchObject({ label: "5-hour window", usedPercent: 42.3, resetsAt: Date.parse("2030-01-01T05:00:00Z") }); + expect(w[3]!.detail).toBe("$12.40 / $50.00"); + expect(claudeWindows({ rate_limits: null })).toEqual([]); + }); + + test("Codex: primary is the 5-hour window, secondary the weekly one", () => { + const w = codexWindows({ rateLimits: { + primary: { usedPercent: 63, windowDurationMins: 300, resetsAt: 1893474000 }, + secondary: { usedPercent: 21, windowDurationMins: 10080, resetsAt: null }, + } }); + expect(w.map((x) => [x.label, x.usedPercent])).toEqual([["5-hour window", 63], ["Weekly", 21]]); + expect(w[0]!.resetsAt).toBe(1893474000 * 1000); + }); + + test("Gemini: daily buckets per model, used = 1 − remaining fraction", () => { + const w = geminiWindows({ buckets: [ + { modelId: "gemini-2.5-pro", remainingFraction: 0.75, remainingAmount: "75", resetTime: "2030-01-02T00:00:00Z" }, + { modelId: "gemini-2.5-flash", remainingFraction: 1 }, + { remainingFraction: 0.5 }, + ] }); + expect(w).toHaveLength(2); + expect(w[0]).toMatchObject({ label: "Daily · gemini-2.5-pro", usedPercent: 25, detail: "75 / 100 left" }); + expect(w[1]!.usedPercent).toBe(0); + }); +}); + +describe("plan usage probes", () => { + const saved = { ...process.env }; + let home: string; + beforeEach(() => { + clearPlanUsageCache(); + home = fs.mkdtempSync(path.join(os.tmpdir(), "ma-plan-")); + process.env.MULTIAGENTS_CLAUDE_COMMAND = `bun ${FAKE_CLAUDE}`; + process.env.MULTIAGENTS_CODEX_COMMAND = `bun ${FAKE_CODEX}`; + process.env.GEMINI_CLI_HOME = home; + delete process.env.GEMINI_API_KEY; + }); + afterEach(() => { + process.env = { ...saved }; + fs.rmSync(home, { recursive: true, force: true }); + }); + + test("reads Claude's and Codex's windows from the CLIs", async () => { + const [claude, codex] = await getPlanUsage({ providers: ["claude", "codex"] }); + expect(claude).toMatchObject({ provider: "claude", ok: true, plan: "max" }); + expect(claude!.windows.map((w) => w.id)).toEqual(["five_hour", "seven_day", "seven_day_opus"]); + expect(codex).toMatchObject({ provider: "codex", ok: true, plan: "plus" }); + expect(codex!.windows.map((w) => w.usedPercent)).toEqual([63, 21]); + }, 30_000); + + test("explains a login without plan limits", async () => { + process.env.FAKE_CLAUDE_USAGE = "none"; + process.env.FAKE_CODEX_LOGGED_OUT = "1"; + const [claude, codex, gemini] = await getPlanUsage(); + expect(claude).toMatchObject({ ok: false, windows: [] }); + expect(claude!.note).toContain("API key"); + expect(codex!.note).toContain("Sign in to Codex"); + expect(gemini!.note).toContain("Sign in to Gemini"); + }, 30_000); + + test("answers from cache until refreshed", async () => { + const [first] = await getPlanUsage({ providers: ["claude"] }); + const [second] = await getPlanUsage({ providers: ["claude"] }); + expect(second!.fetchedAt).toBe(first!.fetchedAt); + }, 30_000); + + test("a missing CLI is reported, not thrown", async () => { + process.env.MULTIAGENTS_CLAUDE_COMMAND = "definitely-not-a-claude-binary"; + const [claude] = await getPlanUsage({ providers: ["claude"] }); + expect(claude).toMatchObject({ ok: false, note: "Claude Code is not installed." }); + }); +}); diff --git a/tests/web-control.test.ts b/tests/web-control.test.ts index fb77430..bfcb67f 100644 --- a/tests/web-control.test.ts +++ b/tests/web-control.test.ts @@ -9,6 +9,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { awaitingHuman, workflowInstructions } from "../dashboard/control.ts"; +import { formatForwardedMessages } from "../orchestrator/delivery.ts"; import type { Session, Slot } from "../shared/types.ts"; const BROKER_PORT = 17911; @@ -70,6 +71,7 @@ beforeAll(async () => { MULTIAGENTS_NO_DASHBOARD: "1", MULTIAGENTS_CLAUDE_COMMAND: `bun ${FAKE_CLAUDE}`, MULTIAGENTS_LIMIT_CHECK_MS: "300", + FAKE_CLAUDE_DELTA_MS: "150", FAKE_CLAUDE_LOG: path.join(tmpDir, "fake-claude.log"), }, stdout: "ignore", @@ -438,3 +440,103 @@ describe("approvals, activity, settings and limits", () => { await api("session/limits", { session_id: SESSION, limits: {} }); }); }); + +describe("talking to agents: replies, interrupting, Q&A mode", () => { + test("an agent can reply to the person, and sees how to", async () => { + const r = await broker("/send-message", { from_id: "orchestrator", to_id: "human", text: "Progress: 3 of 5 done", session_id: SESSION }); + expect(r.ok).toBe(true); + const text = formatForwardedMessages([{ id: 1, from_id: "human", from_slot_id: null, msg_type: "chat", text: "How far are you?" } as any], new Map()); + expect(text).toContain('to_id="human"'); + }); + + test("interrupt-and-ask marks the agent's turn to be stopped", async () => { + const before = Date.now(); + const r = await api("messages/send", { session_id: SESSION, slot_id: slotB, text: "Stop and tell me what you're doing", interrupt: true }); + expect(r.ok).toBe(true); + expect(JSON.parse((await broker("/slots/get", { id: slotB })).context_snapshot).interrupt_at).toBeGreaterThanOrEqual(before); + }); + + test("in Q&A mode agents only hear the person; teammates' messages wait", async () => { + await broker("/send-message", { from_id: "peer-teammate", to_slot_id: slotB, text: "teammate: please review", session_id: SESSION }); + const on = await api("session/qa", { session_id: SESSION, enabled: true }); + expect(on.ok).toBe(true); + expect(JSON.parse((await broker("/sessions/get", { id: SESSION })).config).qa_mode).toBeTruthy(); + await api("messages/send", { session_id: SESSION, slot_id: slotB, text: "person: what is left?" }); + + const during = (await broker("/poll-by-slot", { slot_id: slotB, retain: true })).messages.map((m: any) => m.text); + expect(during.some((t: string) => t.startsWith("[HOLD — Q&A MODE]"))).toBe(true); + expect(during).toContain("person: what is left?"); + expect(during).not.toContain("teammate: please review"); + + expect((await api("session/qa", { session_id: SESSION, enabled: false })).ok).toBe(true); + const after = (await broker("/poll-by-slot", { slot_id: slotB, retain: true })).messages.map((m: any) => m.text); + expect(after).toContain("teammate: please review"); + expect(after.some((t: string) => t.startsWith("[RESUME]"))).toBe(true); + // Leave the inbox empty for later tests + await broker("/poll-by-slot", { slot_id: slotB }); + }); + + test("Q&A mode stops a running turn and refuses edits and commands, not reads", async () => { + const r = await api("team/create", { + project_dir: path.join(tmpDir, "qa-team"), + session_name: "QA Team", + goal: "Work slowly", + agents: [{ agent_type: "claude", name: "Busy", role: "Software Engineer", initial_task: "SLEEP:6000" }], + workflow: {}, + }); + expect(r.ok).toBe(true); + const sid = r.result.data.session_id; + const [slot] = await broker("/slots/list", { session_id: sid }); + const activity = async () => ((await (await fetch(`${DASHBOARD_URL}/api/activity?session_id=${encodeURIComponent(sid)}`)).json()) as any).result as any[]; + await waitFor(async () => (await activity()).some((e) => e.title === "$ sleep 6000"), 15_000); + + expect((await api("session/qa", { session_id: sid, enabled: true })).ok).toBe(true); + await waitFor(async () => (await activity()).some((e) => e.title === "Q&A mode — the running turn was interrupted"), 10_000); + // The interrupted turn ends (the fake finishes its sleep first), then the question starts a new one + await waitFor(async () => (await activity()).some((e) => e.kind === "turn_end"), 15_000); + + await api("messages/send", { session_id: sid, slot_id: slot.id, text: "TOOL:Bash:rm-rf TOOL:Read:notes.md" }); + const steps = await waitFor(async () => { + const list = await activity(); + return list.some((e) => e.kind === "tool_result" && /notes\.md/.test(e.detail ?? "")) ? list : null; + }, 15_000); + expect(steps.some((e) => e.title === "Refused in Q&A mode · $ rm-rf")).toBe(true); + expect(steps.find((e) => e.kind === "tool_result" && /^denied:/.test(e.detail ?? ""))?.detail).toContain("Q&A mode"); + expect(steps.find((e) => e.kind === "tool_result" && /notes\.md/.test(e.detail ?? ""))?.detail).toBe("ran Read notes.md"); + + expect((await api("session/qa", { session_id: sid, enabled: false })).ok).toBe(true); + expect((await api("session/end", { session_id: sid })).ok).toBe(true); + }, 60_000); +}); + +describe("live activity", () => { + test("an agent's thinking shows while it forms, and clears when the turn ends", async () => { + const r = await api("team/create", { + project_dir: path.join(tmpDir, "live-team"), + session_name: "Live Team", + goal: "Think out loud", + agents: [{ agent_type: "claude", name: "Thinker", role: "Researcher", initial_task: "THINK:first check the cohort size then compare the two filters carefully before editing |" }], + workflow: {}, + }); + expect(r.ok).toBe(true); + const sid = r.result.data.session_id; + const [slot] = await broker("/slots/list", { session_id: sid }); + const live = async () => ((await (await fetch(`${DASHBOARD_URL}/api/live?session_id=${encodeURIComponent(sid)}`)).json()) as any).result; + const partial = await waitFor(async () => { + const l = (await live())[slot.id]; + // Words streamed before the dashboard attached to the agent (its first turn starts at launch) may be missing + return l?.phase === "thinking" && l.text.includes("the cohort size") && !l.text.includes("before editing") ? l : null; + }, 20_000); + expect(partial.busy).toBe(true); + await waitFor(async () => !(await live())[slot.id], 20_000); + expect((await api("session/end", { session_id: sid })).ok).toBe(true); + }, 60_000); + + test("a busy entry from a host that stopped refreshing it expires", async () => { + await broker("/activity/live/set", { session_id: SESSION, slot_id: slotA, live: { busy: true, phase: "thinking", text: "hm", tool: null, since: Date.now(), updatedAt: 0 } }); + const list = await broker("/activity/live/list", { session_id: SESSION }); + expect(list[slotA]).toMatchObject({ busy: true, text: "hm" }); + await broker("/activity/live/set", { session_id: SESSION, slot_id: slotA, live: { busy: false, phase: "idle", text: "", tool: null, since: null, updatedAt: 0 } }); + expect((await broker("/activity/live/list", { session_id: SESSION }))[slotA]).toBeUndefined(); + }); +}); diff --git a/tests/web-i18n.test.ts b/tests/web-i18n.test.ts index 9d2fa62..ebb43da 100644 --- a/tests/web-i18n.test.ts +++ b/tests/web-i18n.test.ts @@ -38,6 +38,19 @@ describe("dashboard i18n", () => { expect(missing).toEqual([]); }); + test("plan-limit notes and window labels from the server are translated", async () => { + const { tr } = await loadI18n("zh"); + const src = await Bun.file(join(DIR, "..", "orchestrator", "plan-usage.ts")).text(); + const texts = [ + ...[...src.matchAll(/note: (?:[^"\n]*\? )?"([^"]+)"/g)].map((m) => m[1]!), + ...[...src.matchAll(/\["\w+", "([A-Z0-9][^"]+)"\]/g)].map((m) => m[1]!), + "Weekly · Fable", "Daily · gemini-2.5-pro", "8-hour window", "42 / 100 left", + ]; + expect(texts.length).toBeGreaterThan(15); + const untranslated = texts.filter((t) => tr(t) === t); + expect(untranslated).toEqual([]); + }); + test("placeholders survive translation and English passes through", async () => { const zh = await loadI18n("zh"); expect(zh.tr("Release {name}?", { name: "Claude" })).toBe("释放 Claude?"); From 8466d3d4ba6ea40c1e47c7bee80e3682098cd8e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 06:27:11 +0000 Subject: [PATCH 09/24] Detect a CLI through its command override detectAgent looked for the real claude / codex binaries even when MULTIAGENTS_CLAUDE_COMMAND / MULTIAGENTS_CODEX_COMMAND replace them, so on a machine without the CLI (CI) every team launch was refused although the override would have run. The override's program is what has to exist. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SP5EAuSkQZm3jj3PTTWDfN --- orchestrator/launcher.ts | 12 +++++++++++- tests/launcher.test.ts | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/orchestrator/launcher.ts b/orchestrator/launcher.ts index f895703..f0b138e 100644 --- a/orchestrator/launcher.ts +++ b/orchestrator/launcher.ts @@ -89,6 +89,12 @@ const AGENT_COMMANDS: Record = { custom: "", }; +/** Env vars that replace an agent's CLI binary (the drivers launch these instead). */ +const COMMAND_OVERRIDES: Partial> = { + claude: "MULTIAGENTS_CLAUDE_COMMAND", + codex: "MULTIAGENTS_CODEX_COMMAND", +}; + /** * Detect whether an agent CLI is installed and available on PATH. * Handles both direct binaries (claude, codex) and npx-based tools (gemini). @@ -116,12 +122,16 @@ export async function detectAgent(type: AgentType): Promise { return { available: false }; } - const which = Bun.spawnSync(["which", cmd], { env: spawnEnv }); + // An override is what gets launched, so it is what has to exist + const overrideVar = COMMAND_OVERRIDES[type]; + const override = overrideVar ? process.env[overrideVar]?.trim() : undefined; + const which = Bun.spawnSync(["which", override ? override.split(/\s+/)[0]! : cmd], { env: spawnEnv }); const binPath = new TextDecoder().decode(which.stdout).trim(); if (which.exitCode !== 0 || !binPath) { return { available: false }; } + if (override) return { available: true, version: `${overrideVar}=${override}`, path: binPath }; // Try to get version let version: string | undefined; diff --git a/tests/launcher.test.ts b/tests/launcher.test.ts index afe9693..98cc5e3 100644 --- a/tests/launcher.test.ts +++ b/tests/launcher.test.ts @@ -490,6 +490,21 @@ describe("detectAgent", () => { } }); + test("a command override is what has to exist, not the real CLI", async () => { + const saved = process.env.MULTIAGENTS_CLAUDE_COMMAND; + try { + process.env.MULTIAGENTS_CLAUDE_COMMAND = "bun tests/fakes/fake-claude.ts"; + const found = await detectAgent("claude"); + expect(found.available).toBe(true); + expect(found.path).toMatch(/bun$/); + process.env.MULTIAGENTS_CLAUDE_COMMAND = "definitely-not-a-binary --flag"; + expect((await detectAgent("claude")).available).toBe(false); + } finally { + if (saved === undefined) delete process.env.MULTIAGENTS_CLAUDE_COMMAND; + else process.env.MULTIAGENTS_CLAUDE_COMMAND = saved; + } + }); + test("returns available: false for custom type", async () => { const result = await detectAgent("custom"); expect(result.available).toBe(false); From 58bb2bdc942ed5d03b24bec9b889222bb34154f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 06:51:02 +0000 Subject: [PATCH 10/24] Restart an outdated broker without killing agents; show who has not answered The broker is a daemon that keeps its old code across updates, so replies to the person, Q&A mode and the live thinking view silently did nothing until it was restarted. /health now lists the broker's features; the dashboard restarts an idle outdated broker at startup and otherwise shows a banner with a Restart button. A restart leaves agents running: current brokers exit on SIGUSR2 without the SIGTERM handler that kills every registered peer, older ones are ended with SIGKILL (WAL keeps the data). The chat now also shows an agent that was asked something and has not answered yet, from the messages alone, so every agent gets a "working" row even when no live report reaches the dashboard. Codex plan limits: older app-servers take no params on account/rateLimits/read ("invalid type: map, expected unit"); send null, which every version accepts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SP5EAuSkQZm3jj3PTTWDfN --- CLAUDE.md | 1 + broker.ts | 13 ++++- cli/update.ts | 3 ++ dashboard/app.js | 49 +++++++++++++++++- dashboard/control.ts | 10 +++- dashboard/i18n.js | 9 ++++ dashboard/index.html | 1 + dashboard/server.ts | 20 +++++++- orchestrator/plan-usage.ts | 7 ++- orchestrator/runtime.ts | 37 +++++++++++++- shared/constants.ts | 8 +++ tests/broker-restart.test.ts | 74 ++++++++++++++++++++++++++++ tests/fakes/fake-codex-app-server.ts | 2 + 13 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 tests/broker-restart.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index a6164fc..840fd39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ Peer discovery and messaging MCP channel for Claude Code instances. - `orchestrator/live.ts` — `LiveTracker`: the block an agent is writing right now (Claude `stream_event` deltas from `--include-partial-messages`, Codex `item/*/delta`, Gemini message deltas) plus its phase, published to the broker's in-memory `/activity/live/*` (busy entries not refreshed for 60s expire). The chat polls `/api/live` for the typing indicator. - `orchestrator/plan-usage.ts` — Subscription windows without a model turn: Claude `get_usage` control request on a throwaway `claude -p` (no MCP, hooks off, cwd tmp), Codex `account/rateLimits/read` on a throwaway app-server, Gemini Code Assist `retrieveUserQuota` with `~/.gemini/oauth_creds.json` (never refreshed here). Cached 2 min; `GET /api/plan-usage`. - Talking to agents: `human` is a valid message target (agents see `HUMAN_SENDER_LABEL` with the id to reply to). `messages/send` with `interrupt` sets the slot's `interrupt_at`; the host running the driver stops its turn. Session `config.qa_mode` (Q&A mode, `session/qa`): the broker hands agents only `human` / orchestrator messages (`qaFilter`), `runtime.ts` interrupts running turns, runs drivers in ask mode and only lets read-only tools through, and skips nudges, respawns and auto-end. +- Broker updates: the broker is a daemon that keeps old code across updates. `/health` lists `BROKER_FEATURES` (shared/constants.ts — add one with each endpoint or behavior the dashboard needs); the dashboard shows a restart banner when some are missing and restarts an idle one at startup. `restartBroker` leaves agents running: SIGUSR2 (current builds exit without the SIGTERM handler that kills every registered peer) or SIGKILL for older builds (WAL keeps committed data). - `orchestrator/supervision.ts` — Driver registry (one driver per slot), non-overlapping loop ticks, release and watchdog verdicts; pure enough to unit-test. - `orchestrator/monitor.ts` — Slot summaries and token accounting. Claude's `result.usage` is per turn (added as is); Codex's `thread/tokenUsage/updated.total` is cumulative (diffed). - `shared/types.ts` — Shared TypeScript types for broker API. diff --git a/broker.ts b/broker.ts index 968777e..f562f86 100755 --- a/broker.ts +++ b/broker.ts @@ -19,6 +19,7 @@ import { RECONNECT_RECAP_LIMIT, DEFAULT_GUARDRAILS, CLEANUP_INTERVAL, + BROKER_FEATURES, } from "./shared/constants.ts"; import { generatePeerId, safeJsonParse } from "./shared/utils.ts"; import { resolveBrokerPort } from "./shared/broker-url.ts"; @@ -1604,7 +1605,7 @@ Bun.serve({ if (req.method !== "POST") { if (path === "/health") { - return Response.json({ status: "ok", peers: (selectAllPeers.all() as Peer[]).length, pid: process.pid, version: BROKER_VERSION }); + return Response.json({ status: "ok", peers: (selectAllPeers.all() as Peer[]).length, pid: process.pid, version: BROKER_VERSION, features: BROKER_FEATURES }); } return new Response("multiagents broker", { status: 200 }); } @@ -1951,3 +1952,13 @@ function shutdownBroker() { process.on("SIGINT", shutdownBroker); process.on("SIGTERM", shutdownBroker); + +// Restart (a newer build takes over): leave the agents running, they keep their +// registrations in the database and talk to the next broker on the same port +try { + process.on("SIGUSR2", () => { + console.error("[multiagents broker] exiting for a restart; agents stay up"); + try { db.close(); } catch {} + process.exit(0); + }); +} catch { /* no SIGUSR2 on this platform */ } diff --git a/cli/update.ts b/cli/update.ts index 2b983d6..63f31c3 100644 --- a/cli/update.ts +++ b/cli/update.ts @@ -19,6 +19,8 @@ export interface BrokerHealth { peers: number; pid?: number; version?: string; + /** Capabilities of the running build (absent on brokers older than the list) */ + features?: string[]; } export interface UpdateDeps { @@ -198,6 +200,7 @@ export function parseBrokerHealth(body: unknown): BrokerHealth | null { const health: BrokerHealth = { peers: b.peers }; if (typeof b.pid === "number") health.pid = b.pid; if (typeof b.version === "string") health.version = b.version; + if (Array.isArray(b.features)) health.features = b.features.filter((f): f is string => typeof f === "string"); return health; } diff --git a/dashboard/app.js b/dashboard/app.js index 77b2ef9..bab1708 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -338,6 +338,7 @@ function render(force = false) { renderSessionBar(); renderGateBanner(); renderQaBanner(); + renderBrokerBanner(); renderApprovals(); renderBadges(); refreshAgentSelects(); @@ -1415,6 +1416,10 @@ function renderLive() { const busy = Object.entries(liveView.sid === state.sessionId ? liveView.data : {}) .map(([id, l]) => ({ slot: slotById(Number(id)), l })) .filter(({ slot, l }) => slot && l.busy); + // Agents with no live report (another host, an older broker, an interactive CLI) still show + // they were asked something and have not answered yet + const reported = new Set(busy.map(({ slot }) => slot.id)); + for (const w of awaitingReply()) if (!reported.has(w.slot.id)) busy.push(w); const keep = new Set(busy.map(({ slot }) => String(slot.id))); for (const row of [...el.children]) if (!keep.has(row.dataset.slot)) row.remove(); for (const { slot, l } of busy) { @@ -1432,7 +1437,7 @@ function renderLive() { } row.querySelector(".msg-name").textContent = slotName(slot); row.querySelector(".msg-role").textContent = slot.role ?? ""; - const label = l.phase === "tool" && l.tool ? tr("Running {tool}", { tool: tr(l.tool) }) : tr(LIVE_PHASE[l.phase] ?? "Working"); + const label = l.awaiting ? tr("Has not answered yet") : l.phase === "tool" && l.tool ? tr("Running {tool}", { tool: tr(l.tool) }) : tr(LIVE_PHASE[l.phase] ?? "Working"); row.querySelector(".live-label").textContent = label; row.querySelector(".live-time").textContent = l.since ? fmtDuration(Date.now() - l.since) : ""; const wrap = row.querySelector(".live-text-wrap"); @@ -1458,6 +1463,31 @@ liveView.el.addEventListener("click", (e) => { renderLive(); }); +const AWAIT_REPLY_MS = 20 * 60_000; +const NOT_A_QUESTION = new Set(["system", "control", "rename", "role_assignment", "team_change", "approval", "release"]); + +/** Agents that got a message from you or a teammate after their own last message. */ +function awaitingReply() { + const lastIn = new Map(), lastOut = new Map(); + for (const m of state.messages) { + const t = Date.parse(m.sent_at); + if (m.from_slot_id != null) lastOut.set(m.from_slot_id, Math.max(lastOut.get(m.from_slot_id) ?? 0, t)); + const fromPerson = m.from_id === "human"; + const fromTeammate = m.from_slot_id != null && m.from_slot_id !== m.to_slot_id; + if (m.to_slot_id != null && (fromPerson || fromTeammate) && !NOT_A_QUESTION.has(m.msg_type)) { + lastIn.set(m.to_slot_id, Math.max(lastIn.get(m.to_slot_id) ?? 0, t)); + } + } + const out = []; + for (const [slotId, t] of lastIn) { + const slot = slotById(slotId); + if (!slot || slot.task_state === "released" || isPaused(slot) || slot.status !== "connected" && !(state.managedSlots ?? []).includes(slot.id)) continue; + if ((lastOut.get(slotId) ?? 0) >= t || Date.now() - t > AWAIT_REPLY_MS) continue; + out.push({ slot, l: { busy: true, phase: "waiting", text: "", tool: null, since: t, awaiting: true } }); + } + return out; +} + /** Fast poll while the chat is open and someone is working (state pushes cover the rest). */ async function pollLive() { if (liveView.polling || !state?.sessionId || document.hidden || activeTab !== "chat") return; @@ -1583,6 +1613,15 @@ function renderSessionBar() { tb.querySelector('[data-action="session-menu"]').disabled = !has; } +function renderBrokerBanner() { + const el = $("broker-banner"); + if (!state.brokerMissing?.length) { el.className = "hidden"; el.innerHTML = ""; return; } + el.className = "banner"; + el.innerHTML = ` + ${tr("It was started before an update, so replies to you, Q&A mode and the live thinking view do not work yet. Restarting it keeps sessions, messages and running agents.")} + `; +} + function renderQaBanner() { const el = $("qa-banner"); $("qa-btn").innerHTML = state.qaMode ? `▶ ${esc(tr("End Q&A"))}` : `✋ ${esc(tr("Q&A mode"))}`; @@ -2543,6 +2582,14 @@ document.addEventListener("click", (e) => { async function globalAction(action, btn) { if (action === "new-team") return newTeamModal(); if (action === "new-session") return newSessionModal(); + if (action === "broker-restart") { + const ok = await confirmDialog({ + title: "Restart the broker?", confirmLabel: "Restart broker", + message: `

    ${tr("Sessions, messages and plans are stored on disk and stay. Running agents keep running; for a few seconds their requests to the broker fail and are retried.")}

    `, + }); + if (ok) await run(btn, "Restart broker", () => api("broker/restart", {})); + return; + } const sid = currentSid(); if (sid == null) return; switch (action) { diff --git a/dashboard/control.ts b/dashboard/control.ts index a265032..b591739 100644 --- a/dashboard/control.ts +++ b/dashboard/control.ts @@ -12,7 +12,7 @@ import type { BrokerClient } from "../shared/broker-client.ts"; import type { AgentLaunchConfig, AgentType, ApprovalMode, KnowledgeCategory, Session, Slot } from "../shared/types.ts"; import { safeJsonParse } from "../shared/utils.ts"; import { createSessionRecord, exportSessionData } from "../shared/session-ops.ts"; -import { callOrchestratorTool, type ToolResult } from "../orchestrator/runtime.ts"; +import { callOrchestratorTool, restartBroker, missingBrokerFeatures, type ToolResult } from "../orchestrator/runtime.ts"; import { controlSession, broadcastToTeam } from "../orchestrator/session-control.ts"; import { detectAgent } from "../orchestrator/launcher.ts"; import { HUMAN_SENDER } from "../shared/constants.ts"; @@ -216,6 +216,14 @@ const handlers: Record = { return availability.result; }, + /** Restart a broker that runs code from before an update (data is in SQLite and survives). */ + async "broker/restart"(ctx) { + await restartBroker(ctx.broker); + const missing = await missingBrokerFeatures(); + if (missing.length) throw new ApiError(`The broker still lacks: ${missing.join(", ")}`, 500); + return { message: "Broker restarted" }; + }, + // ---- Sessions ---- async "session/create"(ctx, body) { diff --git a/dashboard/i18n.js b/dashboard/i18n.js index b711706..101906e 100644 --- a/dashboard/i18n.js +++ b/dashboard/i18n.js @@ -243,6 +243,13 @@ const ZH = { // --- Q&A mode and interrupting --- "Working": "处理中", + "Has not answered yet": "还没回复,正在处理", + "The broker runs older code": "broker 还在运行旧版本", + "It was started before an update, so replies to you, Q&A mode and the live thinking view do not work yet. Restarting it keeps sessions, messages and running agents.": "它是在更新之前启动的,所以 agent 回复你、问答模式和实时思考显示暂时都用不了。重启不会丢失会话和消息,正在运行的 agent 也不会被关掉。", + "Restart broker": "重启 broker", + "Restart the broker?": "重启 broker?", + "Sessions, messages and plans are stored on disk and stay. Running agents keep running; for a few seconds their requests to the broker fail and are retried.": "会话、消息和计划都保存在磁盘上,不会丢失。正在运行的 agent 会继续运行,只是有几秒钟连不上 broker,之后会自动重试。", + "Broker restarted": "broker 已重启", "Writing a reply": "正在写回复", "Running a tool": "正在执行工具", "Running {tool}": "正在执行 {tool}", @@ -712,6 +719,8 @@ const RULES_ZH = [ [/^Session "(.+)" permanently deleted\. Removed: (\d+) slot\(s\), (\d+) message\(s\), (\d+) plan\(s\)\.$/, (m) => `会话“${m[1]}”已永久删除。共删除 ${m[2]} 个席位、${m[3]} 条消息、${m[4]} 个计划。`], [/^Extended session duration to (.+)$/, (m) => `会话时长已延长至 ${m[1]}`], [/^Nothing to change$/, () => "没有需要修改的内容"], + [/^The broker still lacks: (.+)$/, (m) => `broker 仍缺少:${m[1]}`], + [/^JSON-RPC error: (.+)$/s, (m) => `JSON-RPC 错误:${m[1]}`], [/^A person denied this tool call\.?(?:: (.+))?$/s, (m) => `有人拒绝了此工具调用${m[1] ? `:${m[1]}` : "。"}`], [/^Paused by orchestrator$/, () => "已被调度器暂停"], [/^HTTP (\d+)$/, (m) => `HTTP ${m[1]}`], diff --git a/dashboard/index.html b/dashboard/index.html index b318f2a..17f3585 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -80,6 +80,7 @@ +
    diff --git a/dashboard/server.ts b/dashboard/server.ts index 918ef6d..c6580f1 100644 --- a/dashboard/server.ts +++ b/dashboard/server.ts @@ -17,7 +17,7 @@ import { DASHBOARD_REFRESH } from "../shared/constants.ts"; import { resolveBrokerPort, brokerUrl } from "../shared/broker-url.ts"; import type { Session, Slot, Peer, Message, GuardrailState, FileLock, FileOwnership, ApprovalRequest, LiveActivity } from "../shared/types.ts"; import type { PlanState } from "../shared/broker-client.ts"; -import { configureRuntime, ensureBroker, startBackgroundLoops, shutdownRuntime, isManagedSession, managedSlotIds } from "../orchestrator/runtime.ts"; +import { configureRuntime, ensureBroker, startBackgroundLoops, shutdownRuntime, isManagedSession, managedSlotIds, missingBrokerFeatures, restartBroker } from "../orchestrator/runtime.ts"; import { handleFs } from "./files.ts"; import { getPlanUsage } from "../orchestrator/plan-usage.ts"; import { handleControl, exportSession, listActivity, awaitingHuman, ApiError, CONTROL_ACTIONS, HUMAN_ID, type ControlContext } from "./control.ts"; @@ -73,6 +73,8 @@ interface DashboardState { plan: PlanState | null; knowledge: Array<{ key: string; value: string; category: string; created_by_name: string | null; updated_at: number }>; brokerAlive: boolean; + /** Features the running broker lacks: it runs code from before an update */ + brokerMissing: string[]; brokerUrl: string; /** Every session on the broker, for the sidebar */ sessions: Session[]; @@ -94,6 +96,13 @@ interface DashboardState { timestamp: number; } +/** The broker's missing features: checked every 30s, and on every build while some are missing (a restart clears them). */ +let brokerCheck: { at: number; missing: string[] } = { at: 0, missing: [] }; +async function brokerMissingCached(): Promise { + if (brokerCheck.missing.length || Date.now() - brokerCheck.at > 30_000) brokerCheck = { at: Date.now(), missing: await missingBrokerFeatures().catch(() => []) }; + return brokerCheck.missing; +} + async function fetchState(sessionId: string | null): Promise { const state: DashboardState = { sessionId, @@ -107,6 +116,7 @@ async function fetchState(sessionId: string | null): Promise { plan: null, knowledge: [], brokerAlive: false, + brokerMissing: [], brokerUrl: BROKER_URL, sessions: [], peerCount: 0, @@ -126,6 +136,7 @@ async function fetchState(sessionId: string | null): Promise { } catch { return state; } + state.brokerMissing = state.brokerAlive ? await brokerMissingCached() : []; if (!state.brokerAlive) return state; @@ -481,6 +492,13 @@ const server = Bun.serve({ // loops for the agents this dashboard launches try { await ensureBroker(broker); + // A broker left running from before an update lacks what this dashboard needs; + // with nobody connected it is restarted right away, otherwise the page offers it + const missing = await missingBrokerFeatures(); + if (missing.length && (await broker.listPeers({ scope: "machine", cwd: process.cwd(), git_root: null }).catch(() => [1])).length === 0) { + console.error(`[web-dashboard] The broker runs older code (missing: ${missing.join(", ")}) and has no agents — restarting it`); + await restartBroker(broker); + } } catch (e) { console.error(`[web-dashboard] Broker unavailable: ${e instanceof Error ? e.message : e}`); } diff --git a/orchestrator/plan-usage.ts b/orchestrator/plan-usage.ts index ecea4b0..7df472a 100644 --- a/orchestrator/plan-usage.ts +++ b/orchestrator/plan-usage.ts @@ -229,10 +229,13 @@ async function probeCodex(): Promise { let driver: CodexDriver | null = null; try { driver = await CodexDriver.spawn(tmpdir(), process.env, { command, timeoutMs: 15_000 }); - const account = await driver.request("account/read", { refreshToken: false }, 15_000).catch(() => null) as { account?: { planType?: string; type?: string } | null } | null; + const account = await driver.request("account/read", { refreshToken: false }, 15_000) + .catch(() => driver!.request("account/read", null, 15_000)) + .catch(() => null) as { account?: { planType?: string; type?: string } | null } | null; let answer: Record; try { - answer = await driver.request("account/rateLimits/read", { excludeResetCreditDetails: true }, 20_000) as Record; + // Older app-servers take no params here ("invalid type: map, expected unit"); null suits every version + answer = await driver.request("account/rateLimits/read", null, 20_000) as Record; } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (/auth/i.test(msg)) return result("codex", { note: "Sign in to Codex with ChatGPT to see its limits (an API key has none)." }); diff --git a/orchestrator/runtime.ts b/orchestrator/runtime.ts index c645c26..2ebd739 100644 --- a/orchestrator/runtime.ts +++ b/orchestrator/runtime.ts @@ -20,8 +20,10 @@ import { CONFLICT_CHECK_INTERVAL, FLAP_WINDOW_MS, STUCK_THRESHOLD_MS, + BROKER_FEATURES, } from "../shared/constants.ts"; -import { brokerUrl } from "../shared/broker-url.ts"; +import { brokerUrl, resolveBrokerPort } from "../shared/broker-url.ts"; +import { probeBrokerHealth, stopBrokerProcess } from "../cli/update.ts"; import { detectAgent, launchAgent, relaunchIntoSlot, announceNewMember, buildTeamContext, ENRICHED_PATH, slotSettings } from "./launcher.ts"; import { getGuide, formatTopicList, type GuideTopic } from "./guide.ts"; @@ -471,6 +473,39 @@ function launchWebDashboard(sessionId: string, projectDir: string): void { // --- Broker lifecycle --- +/** Features this code relies on that the running broker lacks (it runs older code); [] when none or unknown. */ +export async function missingBrokerFeatures(): Promise { + const health = await probeBrokerHealth(BROKER_URL); + if (!health) return []; + const have = new Set(health.features ?? []); + return BROKER_FEATURES.filter((f) => !have.has(f)); +} + +/** + * Restart the broker on the code next to this file, leaving agents running. + * A normal stop (SIGTERM) makes the broker kill every registered agent; a + * restart instead asks it to just exit (SIGUSR2), or — for builds without that + * — ends it outright (SIGKILL; SQLite in WAL mode loses nothing committed). + * Agents' registrations, sessions and messages are in the database, so agents + * carry on with the new broker on the same port after a moment. + */ +export async function restartBroker(brokerClient: BrokerClient): Promise { + const health = await probeBrokerHealth(BROKER_URL); + if (health) { + log(LOG_PREFIX, `Restarting the broker (pid ${health.pid ?? "?"}, ${health.peers} peer(s))`); + if (health.pid !== undefined) { + const graceful = process.platform !== "win32" && (health.features ?? []).includes("graceful-restart"); + process.kill(health.pid, graceful ? "SIGUSR2" : "SIGKILL"); + } else if (health.peers === 0) { + stopBrokerProcess(health, resolveBrokerPort()); + } else { + throw new Error("The broker does not report its pid; stop it with `multiagents broker stop` once its agents are done"); + } + for (let i = 0; i < 50 && await brokerClient.isAlive(); i++) await Bun.sleep(100); + } + await ensureBroker(brokerClient); +} + export async function ensureBroker(brokerClient: BrokerClient): Promise { if (await brokerClient.isAlive()) { log(LOG_PREFIX, "Broker already running"); diff --git a/shared/constants.ts b/shared/constants.ts index 267be48..e9259c6 100644 --- a/shared/constants.ts +++ b/shared/constants.ts @@ -155,3 +155,11 @@ export const CONFLICT_CHECK_INTERVAL = 10_000; // 10s export const HUMAN_SENDER = "human"; /** How agents see that sender, with the id to reply to. */ export const HUMAN_SENDER_LABEL = 'The human user (reply with send_message to_id="human")'; + +/** + * What this broker build can do, reported on /health. The broker is a daemon + * that keeps running old code across updates; a dashboard that misses a + * feature here offers to restart it. Add an entry with each new endpoint or + * behavior the dashboard depends on. + */ +export const BROKER_FEATURES = ["human-target", "qa-mode", "live-activity", "graceful-restart"] as const; diff --git a/tests/broker-restart.test.ts b/tests/broker-restart.test.ts new file mode 100644 index 0000000..2ec6c52 --- /dev/null +++ b/tests/broker-restart.test.ts @@ -0,0 +1,74 @@ +/** + * A broker left running from before an update is recognised by its missing + * features and restarted on the current code. + */ +import { test, expect, afterAll } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { BROKER_FEATURES } from "../shared/constants.ts"; + +const PORT = 17931; +const URL_ = `http://127.0.0.1:${PORT}`; +const dir = fs.mkdtempSync(path.join(os.tmpdir(), "broker-restart-")); +const env = { ...process.env, HOME: dir, MULTIAGENTS_PORT: String(PORT), MULTIAGENTS_DB: path.join(dir, "peers.db"), MULTIAGENTS_NO_DASHBOARD: "1" }; +let newPid: number | undefined; + +afterAll(() => { + if (newPid) try { process.kill(newPid, "SIGTERM"); } catch { /* gone */ } + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test("an old broker is reported as missing features and restarted on this code", async () => { + // Stands in for a broker build from before the feature list existed + const oldScript = path.join(dir, "old-broker.ts"); + fs.writeFileSync(oldScript, `Bun.serve({ port: ${PORT}, hostname: "127.0.0.1", fetch: () => Response.json({ status: "ok", peers: 0, pid: process.pid, version: "0.0.1" }) });`); + const old = Bun.spawn(["bun", oldScript], { env, stdout: "ignore", stderr: "ignore" }); + for (let i = 0; i < 50 && !(await fetch(`${URL_}/health`).catch(() => null)); i++) await Bun.sleep(100); + + const runtime = new URL("../orchestrator/runtime.ts", import.meta.url).pathname; + const client = new URL("../shared/broker-client.ts", import.meta.url).pathname; + const script = ` + import { missingBrokerFeatures, restartBroker } from ${JSON.stringify(runtime)}; + import { BrokerClient } from ${JSON.stringify(client)}; + const before = await missingBrokerFeatures(); + await restartBroker(new BrokerClient(${JSON.stringify(URL_)})); + console.log(JSON.stringify({ before, after: await missingBrokerFeatures() })); + process.exit(0);`; + const child = Bun.spawn(["bun", "-e", script], { env, stdout: "pipe", stderr: "ignore" }); + const out = (await new Response(child.stdout).text()).trim().split("\n").at(-1)!; + const { before, after } = JSON.parse(out); + expect(before).toEqual([...BROKER_FEATURES]); + expect(after).toEqual([]); + expect(await old.exited).not.toBeNull(); + + const health = await (await fetch(`${URL_}/health`)).json() as { pid: number; features: string[] }; + newPid = health.pid; + expect(health.features).toEqual([...BROKER_FEATURES]); +}, 30_000); + +test("restarting a current broker leaves its agents running and registered", async () => { + // The broker from the first test is current; register an agent process with it + const agent = Bun.spawn(["sleep", "60"], { stdout: "ignore" }); + const reg = await (await fetch(`${URL_}/register`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pid: agent.pid, cwd: dir, git_root: null, tty: null, summary: "working", agent_type: "claude" }), + })).json() as { id: string }; + expect(reg.id).toBeTruthy(); + + const runtime = new URL("../orchestrator/runtime.ts", import.meta.url).pathname; + const client = new URL("../shared/broker-client.ts", import.meta.url).pathname; + const child = Bun.spawn(["bun", "-e", ` + import { restartBroker } from ${JSON.stringify(runtime)}; + import { BrokerClient } from ${JSON.stringify(client)}; + await restartBroker(new BrokerClient(${JSON.stringify(URL_)})); + process.exit(0);`], { env, stdout: "ignore", stderr: "ignore" }); + expect(await child.exited).toBe(0); + + const health = await (await fetch(`${URL_}/health`)).json() as { pid: number; peers: number }; + expect(health.pid).not.toBe(newPid); + newPid = health.pid; + expect(health.peers).toBe(1); + expect(agent.killed || agent.exitCode !== null).toBe(false); + agent.kill(); +}, 30_000); diff --git a/tests/fakes/fake-codex-app-server.ts b/tests/fakes/fake-codex-app-server.ts index 8e58297..c4fd979 100644 --- a/tests/fakes/fake-codex-app-server.ts +++ b/tests/fakes/fake-codex-app-server.ts @@ -139,6 +139,8 @@ function handle(msg: any): void { case "account/read": return reply(id, { account: { type: "chatgpt", email: "fake@example.com", planType: "plus" }, requiresOpenaiAuth: true }); case "account/rateLimits/read": + // Like older app-servers, whose request takes no params at all + if (params !== null && params !== undefined && Object.keys(params).length) return replyError(id, -32600, "Invalid request: invalid type: map, expected unit"); if (process.env.FAKE_CODEX_LOGGED_OUT === "1") return replyError(id, -32600, "codex account authentication required to read rate limits"); return reply(id, { rateLimits: { limitId: "codex", planType: "plus", From 866016b287309cd851d14d09766d8c00b808ef5f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 07:00:19 +0000 Subject: [PATCH 11/24] Count connected slots before treating the broker as idle Driver-run Codex agents have a connected slot but no peer row, so the zero-peer check could restart an outdated broker under them at dashboard startup. brokerIsIdle also requires no connected slot in any open session. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SP5EAuSkQZm3jj3PTTWDfN --- dashboard/server.ts | 4 ++-- orchestrator/runtime.ts | 18 ++++++++++++++++++ tests/broker-restart.test.ts | 21 +++++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/dashboard/server.ts b/dashboard/server.ts index c6580f1..01b5ea9 100644 --- a/dashboard/server.ts +++ b/dashboard/server.ts @@ -17,7 +17,7 @@ import { DASHBOARD_REFRESH } from "../shared/constants.ts"; import { resolveBrokerPort, brokerUrl } from "../shared/broker-url.ts"; import type { Session, Slot, Peer, Message, GuardrailState, FileLock, FileOwnership, ApprovalRequest, LiveActivity } from "../shared/types.ts"; import type { PlanState } from "../shared/broker-client.ts"; -import { configureRuntime, ensureBroker, startBackgroundLoops, shutdownRuntime, isManagedSession, managedSlotIds, missingBrokerFeatures, restartBroker } from "../orchestrator/runtime.ts"; +import { configureRuntime, ensureBroker, startBackgroundLoops, shutdownRuntime, isManagedSession, managedSlotIds, missingBrokerFeatures, restartBroker, brokerIsIdle } from "../orchestrator/runtime.ts"; import { handleFs } from "./files.ts"; import { getPlanUsage } from "../orchestrator/plan-usage.ts"; import { handleControl, exportSession, listActivity, awaitingHuman, ApiError, CONTROL_ACTIONS, HUMAN_ID, type ControlContext } from "./control.ts"; @@ -495,7 +495,7 @@ try { // A broker left running from before an update lacks what this dashboard needs; // with nobody connected it is restarted right away, otherwise the page offers it const missing = await missingBrokerFeatures(); - if (missing.length && (await broker.listPeers({ scope: "machine", cwd: process.cwd(), git_root: null }).catch(() => [1])).length === 0) { + if (missing.length && await brokerIsIdle(broker)) { console.error(`[web-dashboard] The broker runs older code (missing: ${missing.join(", ")}) and has no agents — restarting it`); await restartBroker(broker); } diff --git a/orchestrator/runtime.ts b/orchestrator/runtime.ts index 2ebd739..f95d617 100644 --- a/orchestrator/runtime.ts +++ b/orchestrator/runtime.ts @@ -481,6 +481,24 @@ export async function missingBrokerFeatures(): Promise { return BROKER_FEATURES.filter((f) => !have.has(f)); } +/** + * No agent uses the broker: no registered peer and no connected slot (driver-run + * Codex agents have a connected slot but no peer row). Unknown counts as busy. + */ +export async function brokerIsIdle(brokerClient: BrokerClient): Promise { + try { + const peers = await brokerClient.listPeers({ scope: "machine", cwd: process.cwd(), git_root: null }); + if (peers.length) return false; + for (const s of await brokerClient.listSessions()) { + if (s.status === "archived") continue; + if ((await brokerClient.listSlots(s.id)).some((slot) => slot.status === "connected")) return false; + } + return true; + } catch { + return false; + } +} + /** * Restart the broker on the code next to this file, leaving agents running. * A normal stop (SIGTERM) makes the broker kill every registered agent; a diff --git a/tests/broker-restart.test.ts b/tests/broker-restart.test.ts index 2ec6c52..831ec36 100644 --- a/tests/broker-restart.test.ts +++ b/tests/broker-restart.test.ts @@ -72,3 +72,24 @@ test("restarting a current broker leaves its agents running and registered", asy expect(agent.killed || agent.exitCode !== null).toBe(false); agent.kill(); }, 30_000); + +test("a broker counts as busy while a driver-run agent's slot is connected, even with no peer", async () => { + const runtime = new URL("../orchestrator/runtime.ts", import.meta.url).pathname; + const client = new URL("../shared/broker-client.ts", import.meta.url).pathname; + const post = (p: string, body: unknown) => fetch(`${URL_}${p}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }).then((r) => r.json()) as Promise; + // Drop the peer of the previous test so only the slot can make the broker busy + for (const p of await post("/list-peers", { scope: "machine", cwd: dir, git_root: null })) await post("/unregister", { id: p.id }); + const idle = async () => { + const child = Bun.spawn(["bun", "-e", ` + import { brokerIsIdle } from ${JSON.stringify(runtime)}; + import { BrokerClient } from ${JSON.stringify(client)}; + console.log(await brokerIsIdle(new BrokerClient(${JSON.stringify(URL_)}))); + process.exit(0);`], { env, stdout: "pipe", stderr: "ignore" }); + return (await new Response(child.stdout).text()).trim().split("\n").at(-1) === "true"; + }; + await post("/sessions/create", { id: "idle-check", name: "Idle check", project_dir: dir }); + const slot = await post("/slots/create", { session_id: "idle-check", agent_type: "codex", display_name: "Codex" }); + expect(await idle()).toBe(true); + await post("/slots/update", { id: slot.id, status: "connected" }); + expect(await idle()).toBe(false); +}, 30_000); From 25ca2180639c218dea03e8ed23af88851b00509b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 08:19:12 +0000 Subject: [PATCH 12/24] Keep finished agents: only delete orphan slots in the background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dead-slot loop deleted every slot disconnected for 5 minutes. Agents that exit when done (Gemini after approval, exit 0) vanished from the team list — also while the session was paused or waiting on a person. deadSlotVerdict now decides: a slot is deleted only if its agent connected, never worked (task_state idle), never sent a message or held a plan item, and the session is not paused, in Q&A mode or at the human gate. Everything else stays listed as offline until a person removes it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SP5EAuSkQZm3jj3PTTWDfN --- CLAUDE.md | 2 +- orchestrator/runtime.ts | 42 ++++++++++++++++++++----------------- orchestrator/supervision.ts | 34 ++++++++++++++++++++++++++++++ tests/supervision.test.ts | 35 +++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 840fd39..b89da67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ Peer discovery and messaging MCP channel for Claude Code instances. - `orchestrator/plan-usage.ts` — Subscription windows without a model turn: Claude `get_usage` control request on a throwaway `claude -p` (no MCP, hooks off, cwd tmp), Codex `account/rateLimits/read` on a throwaway app-server, Gemini Code Assist `retrieveUserQuota` with `~/.gemini/oauth_creds.json` (never refreshed here). Cached 2 min; `GET /api/plan-usage`. - Talking to agents: `human` is a valid message target (agents see `HUMAN_SENDER_LABEL` with the id to reply to). `messages/send` with `interrupt` sets the slot's `interrupt_at`; the host running the driver stops its turn. Session `config.qa_mode` (Q&A mode, `session/qa`): the broker hands agents only `human` / orchestrator messages (`qaFilter`), `runtime.ts` interrupts running turns, runs drivers in ask mode and only lets read-only tools through, and skips nudges, respawns and auto-end. - Broker updates: the broker is a daemon that keeps old code across updates. `/health` lists `BROKER_FEATURES` (shared/constants.ts — add one with each endpoint or behavior the dashboard needs); the dashboard shows a restart banner when some are missing and restarts an idle one at startup. `restartBroker` leaves agents running: SIGUSR2 (current builds exit without the SIGTERM handler that kills every registered peer) or SIGKILL for older builds (WAL keeps committed data). -- `orchestrator/supervision.ts` — Driver registry (one driver per slot), non-overlapping loop ticks, release and watchdog verdicts; pure enough to unit-test. +- `orchestrator/supervision.ts` — Driver registry (one driver per slot), non-overlapping loop ticks, release, watchdog and dead-slot verdicts; pure enough to unit-test. The background cleanup deletes only orphan slots (`deadSlotVerdict`: connected once, never worked or messaged, session not on hold); finished agents stay listed as offline until a person removes them. - `orchestrator/monitor.ts` — Slot summaries and token accounting. Claude's `result.usage` is per turn (added as is); Codex's `thread/tokenUsage/updated.total` is cumulative (diffed). - `shared/types.ts` — Shared TypeScript types for broker API. - `shared/summarize.ts` — Git-based summary of what an agent is working on (no API calls). diff --git a/orchestrator/runtime.ts b/orchestrator/runtime.ts index f95d617..e864452 100644 --- a/orchestrator/runtime.ts +++ b/orchestrator/runtime.ts @@ -36,7 +36,7 @@ import { getTeamStatus, formatTeamStatusForDisplay } from "./progress.ts"; import { checkGuardrails, enforceGuardrails } from "./guardrails.ts"; import { handleAgentCrash, clearAllCrashHistory } from "./recovery.ts"; import { controlSession, broadcastToTeam, resolveTarget, pauseAgent } from "./session-control.ts"; -import { DriverRegistry, nonOverlapping, releaseVerdict, watchdogInbox, limitVerdict, type UsageLimits } from "./supervision.ts"; +import { DriverRegistry, nonOverlapping, releaseVerdict, watchdogInbox, limitVerdict, deadSlotVerdict, type UsageLimits } from "./supervision.ts"; import { LiveTracker } from "./live.ts"; import { ActivityRecorder, awaitApproval, claudeActivity, codexActivity, geminiActivity, describeTool, isTeamTool, CLAUDE_PERMISSION_MODE, CODEX_APPROVAL_POLICY } from "./activity.ts"; @@ -1869,32 +1869,36 @@ export function startBackgroundLoops(brokerClient: BrokerClient): void { } }, GUARDRAIL_CHECK_INTERVAL); - // Dead slot auto-cleanup loop — every 60s, remove slots disconnected for >5 min + // Orphan slot cleanup — every 60s. Only slots whose agent connected but never + // worked are deleted (deadSlotVerdict): a finished agent (e.g. Gemini exits 0 + // after approval) stays listed as offline until a person removes it. setInterval(async () => { const now = Date.now(); for (const sessionId of activeProcesses.keys()) { try { const slots = await brokerClient.listSlots(sessionId); + if (!slots.some((s) => s.status === "disconnected")) continue; + const session = await brokerClient.getSession(sessionId); + const config = safeJsonParse>(session.config, {}); + const sessionOnHold = session.status !== "active" || !!config.qa_mode + || (config.human_gate === true && slots.some((s) => s.task_state === "approved")); + const [messages, plan] = await Promise.all([ + brokerClient.getMessageLog(sessionId, { limit: 1000 }).catch(() => null), + brokerClient.getPlan(sessionId).catch(() => null), + ]); + // Without the history, every slot counts as having taken part + if (!messages) continue; + const tookPart = new Set([ + ...messages.map((m) => m.from_slot_id).filter((id): id is number => id != null), + ...(plan?.items ?? []).map((i) => i.assigned_to_slot).filter((id): id is number => id != null), + ]); for (const slot of slots) { - if (slot.status !== "disconnected") continue; - - // CRITICAL: Skip slots that have NEVER connected — they're still starting up. - // Without this check, last_disconnected is null → defaults to 0 → deadFor = now - // (billions of ms) → the slot looks dead for "54 years" → gets deleted immediately. - // This was THE bug that caused all starting agents to vanish from the dashboard. - if (!slot.last_connected) continue; - - const disconnectedAt = slot.last_disconnected ?? slot.last_connected ?? now; - const deadFor = now - disconnectedAt; - if (deadFor < 5 * 60 * 1000) continue; // <5 min, might reconnect - - // Skip driver-managed slots — orchestrator controls their lifecycle - if (activeDrivers.has(sessionId, slot.id)) continue; - - // Auto-cleanup: delete the dead slot + const verdict = deadSlotVerdict({ slot, driven: activeDrivers.has(sessionId, slot.id), tookPart: tookPart.has(slot.id), sessionOnHold, now }); + if (verdict !== "delete") continue; try { await brokerClient.deleteSlot(slot.id); - log(LOG_PREFIX, `Auto-deleted dead slot ${slot.id} (${slot.display_name || slot.role}) — disconnected for ${Math.round(deadFor / 60000)}m`); + const deadFor = now - (slot.last_disconnected ?? slot.last_connected ?? now); + log(LOG_PREFIX, `Auto-deleted orphan slot ${slot.id} (${slot.display_name || slot.role}) — never worked, disconnected for ${Math.round(deadFor / 60000)}m`); } catch { /* best effort */ } } } catch { /* ok */ } diff --git a/orchestrator/supervision.ts b/orchestrator/supervision.ts index bc1ac20..fb1ce9b 100644 --- a/orchestrator/supervision.ts +++ b/orchestrator/supervision.ts @@ -163,3 +163,37 @@ export function watchdogInbox(slot: { status: string; paused: boolean }, peek: P } return { count: peek.count, msg_types: peek.msg_types, oldest_at: peek.oldest_at }; } + +// --- Dead slot cleanup --- + +/** A slot that has been disconnected this long may be an orphan */ +export const DEAD_SLOT_AFTER_MS = 5 * 60 * 1000; + +export interface DeadSlotInput { + slot: { status: string; task_state: string; last_connected: number | null; last_disconnected: number | null; paused?: boolean | number }; + /** The slot's process is driven by this host (it controls the lifecycle) */ + driven: boolean; + /** The slot ever sent a message or is assigned a plan item: it took part in the work */ + tookPart: boolean; + /** The session is paused, waits on a person (human gate, Q&A) or is archived */ + sessionOnHold: boolean; + now: number; +} + +/** + * Whether the background loop may delete a disconnected slot. Only orphans go: + * an agent that connected, never started working and never took part. A slot + * whose agent worked — approved, done, released, or just in the middle of it — + * stays (shown as offline) until a person removes it, and nothing is deleted + * while the session is paused or waits on a person. + */ +export function deadSlotVerdict(input: DeadSlotInput): "keep" | "delete" { + const { slot } = input; + if (slot.status !== "disconnected") return "keep"; + // Never connected: still starting up + if (!slot.last_connected) return "keep"; + if (input.driven || input.sessionOnHold || slot.paused === true || slot.paused === 1) return "keep"; + if (slot.task_state !== "idle" || input.tookPart) return "keep"; + const disconnectedAt = slot.last_disconnected ?? slot.last_connected; + return input.now - disconnectedAt >= DEAD_SLOT_AFTER_MS ? "delete" : "keep"; +} diff --git a/tests/supervision.test.ts b/tests/supervision.test.ts index b12ccc3..fd05bb3 100644 --- a/tests/supervision.test.ts +++ b/tests/supervision.test.ts @@ -12,6 +12,8 @@ import { watchdogInbox, RELEASE_IDLE_MS, RELEASE_MAX_WAIT_MS, + deadSlotVerdict, + type DeadSlotInput, } from "../orchestrator/supervision.ts"; import type { DriverSlot } from "../orchestrator/delivery.ts"; @@ -140,3 +142,36 @@ describe("watchdogInbox", () => { expect(watchdogInbox({ status: "connected", paused: 1 as unknown as boolean }, peek)).toBeNull(); }); }); + +describe("deadSlotVerdict", () => { + const now = 10 * 60 * 60_000; + const gone = (over: Partial = {}): DeadSlotInput["slot"] => ({ + status: "disconnected", task_state: "idle", last_connected: now - 3_600_000, last_disconnected: now - 10 * 60_000, paused: false, ...over, + }); + const verdict = (slot: DeadSlotInput["slot"], over: Partial = {}) => + deadSlotVerdict({ slot, driven: false, tookPart: false, sessionOnHold: false, now, ...over }); + + test("an agent that finished its work and exited stays (the reported Gemini case)", () => { + expect(verdict(gone({ task_state: "approved" }))).toBe("keep"); + expect(verdict(gone({ task_state: "done_pending_review" }))).toBe("keep"); + expect(verdict(gone({ task_state: "released" }))).toBe("keep"); + expect(verdict(gone({ task_state: "working" }))).toBe("keep"); + }); + + test("an idle agent that took part in the conversation stays", () => { + expect(verdict(gone(), { tookPart: true })).toBe("keep"); + }); + + test("nothing is deleted while the session is paused or waits on a person", () => { + expect(verdict(gone(), { sessionOnHold: true })).toBe("keep"); + expect(verdict(gone({ paused: 1 }))).toBe("keep"); + }); + + test("only an orphan goes: connected once, never worked, gone for 5 minutes", () => { + expect(verdict(gone())).toBe("delete"); + expect(verdict(gone({ last_disconnected: now - 60_000 }))).toBe("keep"); + expect(verdict(gone({ last_connected: null, last_disconnected: null }))).toBe("keep"); + expect(verdict(gone({ status: "connected" }))).toBe("keep"); + expect(verdict(gone(), { driven: true })).toBe("keep"); + }); +}); From edefb7138092075279e014ad91dcbb7dd8a81e92 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 10:56:19 +0000 Subject: [PATCH 13/24] =?UTF-8?q?Add=20"Other"=20agents=20on=20any=20OpenA?= =?UTF-8?q?I-compatible=20API=20(DeepSeek,=20Qwen,=20Kimi=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new agent type, api, runs an agent on any chat-completions API configured under API providers (name, base URL, key, default model, optional prices, context window and extra request fields). Keys live in ~/.multiagents/providers.json (0600) and never reach the page; slots keep the provider id. ApiDriver runs the agent in-process: it spawns the team-tools MCP server in driver mode and speaks MCP to it, adds local tools with Claude's names (Read, Write, Edit, LS, Glob, Grep, Bash, WebFetch) confined to the project, streams completions (reasoning_content becomes thinking) and emits Claude's stream-json events. It implements ClaudeSession, so delivery, approvals per permission mode, the activity timeline, the live view, token and cost accounting, Q&A mode, interrupts and live model switches all reuse the Claude paths. The dashboard gets an API providers dialog with presets and model fetching, the "Other (OpenAI-compatible API)" type in Add Agent and New Team, provider names on cards and a plug avatar. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SP5EAuSkQZm3jj3PTTWDfN --- CLAUDE.md | 1 + README.md | 3 + adapters/base-adapter.ts | 2 +- dashboard/app.js | 212 ++++++++++- dashboard/control.ts | 72 +++- dashboard/i18n.js | 42 +++ dashboard/styles.css | 9 + orchestrator/api-driver.ts | 651 ++++++++++++++++++++++++++++++++++ orchestrator/claude-driver.ts | 29 +- orchestrator/delivery.ts | 5 +- orchestrator/launcher.ts | 72 +++- orchestrator/monitor.ts | 4 +- orchestrator/recovery.ts | 4 +- orchestrator/runtime.ts | 20 +- shared/constants.ts | 2 + shared/providers.ts | 143 ++++++++ shared/types.ts | 5 +- tests/api-agent.test.ts | 200 +++++++++++ tests/live-claude.test.ts | 2 +- 19 files changed, 1438 insertions(+), 40 deletions(-) create mode 100644 orchestrator/api-driver.ts create mode 100644 shared/providers.ts create mode 100644 tests/api-agent.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index b89da67..1f82e33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ Peer discovery and messaging MCP channel for Claude Code instances. - `adapters/protocol-text.ts` — Team protocol: short form for MCP `instructions` (Claude Code caps them at 4096 chars, Codex reads 512), full form for orchestrator prompts. - `orchestrator/claude-driver.ts` — Long-lived `claude -p --input-format stream-json` session; teammate messages are written to stdin and read mid-turn. `system/init` only arrives after the first message; closing stdin ends the session. - `orchestrator/codex-driver.ts` — `codex app-server` JSON-RPC client (shapes verified against the 0.156.1 schema). +- `orchestrator/api-driver.ts` — Agent type `api` ("Other"): any OpenAI-compatible chat API, configured in `shared/providers.ts` (`~/.multiagents/providers.json`, 0600, keys never sent to the page; slots keep `provider_id`). In-process loop: spawns the team-tools MCP server in driver mode and talks MCP to it, adds local tools with Claude's names (Read/Write/Edit/LS/Glob/Grep/Bash/WebFetch, confined to the project), streams completions (`reasoning_content` → thinking) and emits Claude stream-json events, so it is a `ClaudeSession` and runs on every Claude path (`kind: "claude"` in the driver registry: delivery, approvals by permission mode, activity, live view, token and cost accounting, Q&A, interrupts). Tests use a scripted fake server (`tests/api-agent.test.ts`). - `orchestrator/delivery.ts` — Hands unacked messages to a driver (`retain` poll) and acks them on confirmed hand-off. - `orchestrator/runtime.ts` — The orchestrator itself: launched processes and drivers, background supervision loops, and the tool implementations (`ORCHESTRATOR_TOOLS`, `callOrchestratorTool`). Hosted by `orchestrator-server.ts` (MCP stdio) and by `dashboard/server.ts`; each host supervises only the agents it launched. - `dashboard/control.ts` — Web control API (`POST /api/`). Every mutation names its `session_id` and checks the slot belongs to it; lifecycle actions call `callOrchestratorTool`, record edits go to the broker. Human actions use sender id `human`. A session with `config.human_gate` is not auto-ended while agents sit in `approved`. diff --git a/README.md b/README.md index c6eeb04..bc6668d 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,8 @@ A step that needs you shows up as a card with the exact command or diff: **Allow **Plan limits (5-hour / weekly).** The **Usage** tab starts with each CLI's subscription quota, read the way the CLIs' own `/usage` screens read it and without starting a model turn; the fullest window of each also shows in the top bar. Claude: the claude.ai plan's 5-hour window and weekly windows (all models, Opus, Sonnet, per-model ones), via Claude Code's `get_usage` control request. Codex: the ChatGPT plan's 5-hour (primary) and weekly (secondary) windows, via `account/rateLimits/read`. Gemini has no 5-hour or weekly windows; with a Google login it shows the daily per-model quota (Code Assist `retrieveUserQuota`, using the token Gemini CLI stored, only while it is valid). API-key logins have no plan windows and say so. Answers are cached for two minutes; **Refresh** asks again. +**Other models (any OpenAI-compatible API).** Besides Claude, Codex and Gemini, an agent can run on DeepSeek, Qwen, Kimi, GLM, OpenRouter, a local Ollama, the OpenAI API, or anything else with an OpenAI-compatible chat API. Add the service under **More ▾ → API providers…** (or **Manage providers…** in Add Agent / New Team): a name, the base URL (the part before `/chat/completions`), the API key and a default model — presets fill in the URL of the well-known ones, **Fetch models** reads the model list, an optional price per million tokens turns on cost and cost limits, and extra request fields (JSON) pass provider options such as Qwen's built-in web search. Keys stay in `~/.multiagents/providers.json` (mode 0600) and never reach the page; `$NAME` reads a key from an environment variable. Then pick **Other (OpenAI-compatible API)** as the agent type. Such an agent works like the others: it talks to the team and to you with the same team tools, reads, edits and searches the project's files and runs commands (inside the project directory), and fetches web pages; its thinking streams into the chat, its steps fill the Activity timeline, approval modes, Q&A mode, interrupting, live model switches, token and cost accounting and limit locks all apply. It is driven in-process by the dashboard, so no CLI is installed; plan limits (5-hour / weekly) do not exist for API keys. + **Talking to agents while they work.** Pick one agent under **To** and ask it anything: agents reply to you with `send_message(to_id="human")` and the answer lands in the chat. Tick **Interrupt** to stop what that agent is doing first, so it answers you right away. **Q&A mode** (session toolbar) puts the whole team on hold: every running turn stops, agents are told to wait and only answer you, edits and commands are refused (reading files stays allowed, so they can check before answering), and teammates' messages wait. Ask one agent, then another; **End Q&A** resumes everyone where they stopped, with the messages that waited. **Seeing an agent think.** While an agent works, the chat shows it under the last message: typing dots, what it is doing (thinking, writing a reply, running `$ bun test`…) and how long, and its thinking as it forms, token by token (Claude with `--include-partial-messages`, Codex reasoning summaries, Gemini replies). **Collapse** keeps it to a few lines. @@ -331,6 +333,7 @@ multiagents/ │ ├── plan-usage.ts Subscription plan limits of the CLIs (5-hour / weekly / daily) │ ├── claude-driver.ts ClaudeDriver: long-lived `claude -p --input-format stream-json` session │ ├── codex-driver.ts CodexDriver: persistent codex app-server via JSON-RPC (steer/interrupt) +│ ├── api-driver.ts ApiDriver: agents on any OpenAI-compatible API (team + local tools, Claude-style events) │ ├── delivery.ts Hands unacked messages to drivers, acks on confirmed hand-off │ ├── launcher.ts Agent spawning (drivers, MCP configs, prompts) │ ├── monitor.ts Process/driver monitoring (event parsing, token tracking) diff --git a/adapters/base-adapter.ts b/adapters/base-adapter.ts index af8d313..0dec3fd 100644 --- a/adapters/base-adapter.ts +++ b/adapters/base-adapter.ts @@ -79,7 +79,7 @@ const TOOLS = [ }, agent_type: { type: "string" as const, - enum: ["claude", "codex", "gemini", "custom", "all"], + enum: ["claude", "codex", "gemini", "api", "custom", "all"], description: "Optional filter by agent type. Defaults to all.", }, }, diff --git a/dashboard/app.js b/dashboard/app.js index bab1708..bc1d547 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -1066,6 +1066,8 @@ function avatar(name, color, extra = "", me = false) { const AGENT_ICONS = { claude: { bg: "#D97757", svg: '' }, gemini: { bg: "linear-gradient(135deg, #4285F4 0%, #8E75B2 60%, #D96570 100%)", svg: '' }, + // Other (OpenAI-compatible API): a neutral plug; upload the provider's logo as an image if you like + api: { bg: "linear-gradient(135deg, #475569, #0e7f86)", svg: '' }, codex: { bg: "#1f2328", svg: '' }, }; const AVATAR_COLORS = ["#2f6fde", "#8250df", "#1a7f37", "#bc4c00", "#cf222e", "#0e7f86", "#d4a72c", "#6e7781", "#D97757", "#1f2328"]; @@ -1706,7 +1708,7 @@ function renderAgents() { return `
    ${slotAvatar(slot, "", true)} -
    ${esc(slotName(slot))}
    ${esc(slot.role ?? tr("no role"))} · ${esc(slot.agent_type)}
    +
    ${esc(slotName(slot))}
    ${esc(slot.role ?? tr("no role"))} · ${esc(agentTypeLabel(slot))}
    ${connBadge(slot.status)}
    @@ -2076,7 +2078,7 @@ function renderStats() { const used = (slot.input_tokens ?? 0) + (slot.output_tokens ?? 0); const limit = set.token_limit || limits.agent_tokens; return ` -
    ${slotAvatar(slot)}
    ${esc(slotName(slot))}
    ${esc(slot.agent_type)} · ${esc(set.model || tr("default model"))}${set.effort ? ` · ${esc(set.effort)}` : ""}
    +
    ${slotAvatar(slot)}
    ${esc(slotName(slot))}
    ${esc(agentTypeLabel(slot))} · ${esc(set.model || tr("default model"))}${set.effort ? ` · ${esc(set.effort)}` : ""}
    ${fmtTokens(slot.input_tokens ?? 0)} ${fmtTokens(slot.output_tokens ?? 0)} ${fmtTokens(slot.cache_read_tokens ?? 0)} @@ -2235,7 +2237,155 @@ const MODEL_SUGGESTIONS = { codex: ["gpt-5-codex", "gpt-5", "gpt-5-mini", "o3"], gemini: ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite"], }; -const EFFORT_OPTIONS = { claude: ["low", "medium", "high", "xhigh", "max"], codex: ["minimal", "low", "medium", "high"], gemini: [] }; +const EFFORT_OPTIONS = { claude: ["low", "medium", "high", "xhigh", "max"], codex: ["minimal", "low", "medium", "high"], gemini: [], api: ["minimal", "low", "medium", "high"] }; + +// --- API providers ("Other": any OpenAI-compatible API) --- + +let apiProviders = []; +async function loadApiProviders() { + try { apiProviders = (await api("providers/list")) ?? []; } catch { apiProviders = []; } + return apiProviders; +} +function providerById(id) { return apiProviders.find((p) => p.id === id) ?? null; } + +/** Base URLs of well-known OpenAI-compatible services; anything else goes under Custom. */ +const PROVIDER_PRESETS = [ + ["OpenAI", "https://api.openai.com/v1"], + ["DeepSeek", "https://api.deepseek.com"], + ["Qwen (DashScope)", "https://dashscope.aliyuncs.com/compatible-mode/v1"], + ["Kimi (Moonshot)", "https://api.moonshot.cn/v1"], + ["GLM (Zhipu)", "https://open.bigmodel.cn/api/paas/v4"], + ["OpenRouter", "https://openrouter.ai/api/v1"], + ["SiliconFlow", "https://api.siliconflow.cn/v1"], + ["Ollama (local)", "http://localhost:11434/v1"], +]; + +function providerModelsDatalist(p) { + return `${(p?.models ?? []).map((m) => ``; +} + +/** Provider, model and effort fields of an API agent. */ +function apiModelFields(values = {}) { + const current = values.provider_id || apiProviders[0]?.id || ""; + const p = providerById(current); + const options = apiProviders.map((x) => ``).join(""); + return `
    + ${apiProviders.length ? `` : `${tr("No API provider yet")}`} + ${values.lockProvider ? "" : ``}
    +
    + ${providerModelsDatalist(p)} +
    ${tr("Applies to a running agent from its next step.")}
    +
    +
    ${tr("Sent as reasoning_effort; keep the default if the provider does not support it.")}
    `; +} + +// Switching the provider in a form refreshes its model suggestions +document.addEventListener("change", (e) => { + const sel = e.target.closest?.("select[name=provider], select[data-f=provider]"); + if (!sel) return; + const p = providerById(sel.value); + const scope = sel.closest(".team-agent, form"); + const model = scope?.querySelector("[name=model], [data-f=model]"); + if (model) model.placeholder = p?.default_model || tr("model name"); + const dl = scope?.querySelector("#models-api"); + if (dl) dl.outerHTML = providerModelsDatalist(p); +}); + +/** Where "Manage providers…" returns to once the providers dialog closes */ +let providersReturn = null; +document.addEventListener("click", (e) => { + if (!e.target.closest?.("[data-manage-providers]")) return; + e.preventDefault(); + providersModal(providersReturn); +}); + +async function providersModal(onBack) { + await loadApiProviders(); + const list = apiProviders.length + ? apiProviders.map((p) => `
    +
    ${esc(p.name)} ${esc(p.base_url)} +
    ${tr("Key")}: ${esc(p.api_key_hint || tr("none"))}${p.default_model ? ` · ${tr("default model")}: ${esc(p.default_model)}` : ""}${p.models?.length ? ` · ${tr("{n} models", { n: p.models.length })}` : ""}
    + + +
    `).join("") + : `
    ${tr("No API provider yet. Pick a preset or fill in any OpenAI-compatible endpoint.")}
    `; + let back = true; + openModal({ + title: "API providers", submitLabel: "Save provider", wide: true, + body: `
    ${tr("Any service with an OpenAI-compatible chat API can run an agent: DeepSeek, Qwen, Kimi, GLM, OpenRouter, a local Ollama… Keys stay on this computer (~/.multiagents/providers.json) and are never sent to the page.")}
    +
    ${list}
    + + +
    + +
    ${tr("The address before /chat/completions.")}
    +
    ${tr("Or $NAME to read it from an environment variable. Leave empty to keep the saved key; local servers need none.")}
    +
    +
    + +
    ${tr("JSON merged into every request, for provider options such as Qwen's built-in web search.")}
    +
    + `, + setup: (form) => { + const fill = (p) => { + form.pid.value = p.id ?? ""; + form.pname.value = p.name ?? ""; + form.base_url.value = p.base_url ?? ""; + form.api_key.value = ""; + form.api_key.placeholder = p.api_key_hint ? tr("saved: {hint}", { hint: p.api_key_hint }) : "sk-…"; + form.default_model.value = p.default_model ?? ""; + form.input_price.value = p.input_price ?? ""; + form.output_price.value = p.output_price ?? ""; + form.context_tokens.value = p.context_tokens ?? ""; + form.extra_body.value = p.extra_body ? JSON.stringify(p.extra_body) : ""; + form.querySelector("#models-api").outerHTML = providerModelsDatalist(p); + form.pname.focus(); + }; + form.addEventListener("click", async (e) => { + const preset = e.target.closest("[data-preset-provider]"); + if (preset) return fill({ name: preset.dataset.name, base_url: preset.dataset.presetProvider }); + const row = e.target.closest("[data-provider]"); + if (row && e.target.closest("[data-edit-provider]")) return fill(providerById(row.dataset.provider) ?? {}); + if (row && e.target.closest("[data-delete-provider]")) { + const p = providerById(row.dataset.provider); + back = false; + if (await confirmDialog({ title: tr("Delete {name}?", { name: p?.name ?? "" }), message: `

    ${tr("Agents already running on it keep running until they restart.")}

    `, confirmLabel: "Delete", danger: true })) { + await run(null, "Delete", () => api("providers/delete", { id: row.dataset.provider })); + agentAvailability = null; + } + return providersModal(onBack); + } + const fetchBtn = e.target.closest("[data-fetch-models]"); + if (fetchBtn) { + fetchBtn.classList.add("busy"); + try { + const r = await api("providers/models", { id: form.pid.value, name: form.pname.value, base_url: form.base_url.value, api_key: form.api_key.value }); + form.querySelector("#models-api").outerHTML = providerModelsDatalist({ models: r.models }); + toast(tr(r.message), "ok"); + if (!form.default_model.value && r.models?.length) form.default_model.focus(); + } catch (err) { + toast(tr(err.message), "error"); + } finally { + fetchBtn.classList.remove("busy"); + } + } + }); + }, + onSubmit: async (form) => { + if (!form.pname.value.trim() || !form.base_url.value.trim()) throw new Error("Name and base URL are required"); + const r = await api("providers/save", { + id: form.pid.value, name: form.pname.value.trim(), base_url: form.base_url.value.trim(), api_key: form.api_key.value, + default_model: form.default_model.value.trim(), extra_body: form.extra_body.value, + input_price: form.input_price.value, output_price: form.output_price.value, context_tokens: form.context_tokens.value, + }); + agentAvailability = null; + toast(tr(r.message), "ok"); + back = false; + setTimeout(() => providersModal(onBack), 0); + }, + onCancel: () => { if (back) onBack?.(); }, + }); +} const APPROVAL_MODES = [ ["bypass", "Fully autonomous", "Never asks. The agent reads, edits and runs commands on its own."], ["auto", "Auto review", "The CLI's own reviewer approves safe steps; risky ones come to you. (Claude: auto mode · Codex: on-request)"], @@ -2254,9 +2404,18 @@ function slotSettingsOf(slot) { approval_mode: snap.approval_mode || "bypass", token_limit: Number(snap.token_limit) > 0 ? Number(snap.token_limit) : 0, limit_hit: typeof snap.limit_hit === "string" ? snap.limit_hit : "", + provider_id: typeof snap.provider_id === "string" ? snap.provider_id : "", + provider_name: typeof snap.provider_name === "string" ? snap.provider_name : "", }; } +/** "claude", "codex", "gemini", or the API provider's name */ +function agentTypeLabel(slot) { + if (slot.agent_type !== "api") return slot.agent_type; + const set = slotSettingsOf(slot); + return set.provider_name || providerById(set.provider_id)?.name || "API"; +} + function settingsBadges(slot) { const set = slotSettingsOf(slot); return `${set.model ? `${esc(set.model)}` : ""} @@ -2274,6 +2433,7 @@ function usageLine(slot) { } function modelFields(type, values = {}) { + if (type === "api") return apiModelFields(values); const efforts = EFFORT_OPTIONS[type] ?? []; return `
    ${(MODEL_SUGGESTIONS[type] ?? []).map((m) => ` @@ -2293,10 +2453,10 @@ function approvalFields(type, current = "bypass") { function settingsModal(sid, slot) { const set = slotSettingsOf(slot); openModal({ - title: tr("Settings · {name}", { name: slotName(slot) }), sub: `${sessionSub(sid)} · ${slot.agent_type}`, submitLabel: "Save", + title: tr("Settings · {name}", { name: slotName(slot) }), sub: `${sessionSub(sid)} · ${agentTypeLabel(slot)}`, submitLabel: "Save", body: `
    ${slotAvatar(slot, "", true)}${tr("Click the avatar to change it.")}
    - ${modelFields(slot.agent_type, set)} + ${modelFields(slot.agent_type, { ...set, lockProvider: true })}
    ${tr("Input + output tokens. When reached the agent is paused and its turn stops. Used so far: {used}.", { used: fmtTokens((slot.input_tokens ?? 0) + (slot.output_tokens ?? 0)) })}
    @@ -2658,6 +2818,7 @@ function sessionMenuItems(s) { } }, { sep: true }, { label: "Export JSON", onClick: () => { location.href = `/api/session/export?session_id=${encodeURIComponent(sid)}`; } }, + { label: "API providers…", onClick: () => { providersReturn = null; providersModal(null); } }, { label: "Archive", disabled: archived, onClick: async () => { if (await confirmDialog({ title: tr("Archive \"{name}\"?", { name: s.name }), sid, message: `

    ${tr("Marks the session archived (like multiagents session archive). Running agents are not stopped — use End session for that.")}

    `, confirmLabel: "Archive" })) run(null, "Archive", () => api("session/archive", { session_id: sid })); @@ -2839,14 +3000,20 @@ async function loadAvailability() { return agentAvailability; } +const AGENT_TYPE_CHOICES = ["claude", "codex", "gemini", "api"]; + function typeOptions(selected, avail) { - return ["claude", "codex", "gemini"].map((t) => - ``).join(""); + return AGENT_TYPE_CHOICES.map((t) => { + const label = t === "api" ? tr("Other (OpenAI-compatible API)") : t; + const missing = avail && avail[t] === false ? tr(t === "api" ? " (add a provider)" : " (not installed)") : ""; + return ``; + }).join(""); } -async function addAgentModal(sid) { - const avail = await loadAvailability(); - const def = ["claude", "codex", "gemini"].find((t) => avail[t] !== false) ?? "claude"; +async function addAgentModal(sid, preset = {}) { + const [avail] = await Promise.all([loadAvailability(), loadApiProviders()]); + const def = preset.type ?? ["claude", "codex", "gemini", "api"].find((t) => avail[t] !== false) ?? "claude"; + providersReturn = () => addAgentModal(sid, { type: "api" }); openModal({ title: "Add agent", sub: sessionSub(sid), submitLabel: "Launch Agent", body: `
    @@ -2874,7 +3041,10 @@ async function addAgentModal(sid) { }; if (form.effort?.value) body.effort = form.effort.value; if (!body.name || !body.role || !body.initial_task) throw new Error("Name, role and task are required"); - if (avail[body.agent_type] === false) throw new Error(tr("{type} CLI is not installed on this machine", { type: body.agent_type })); + if (body.agent_type === "api") { + body.provider = form.provider?.value ?? ""; + if (!body.provider) throw new Error("Add an API provider first (Manage providers…)"); + } else if (avail[body.agent_type] === false) throw new Error(tr("{type} CLI is not installed on this machine", { type: body.agent_type })); const r = await api("agents/add", body); toast(tr(r?.message ?? "Agent launched"), "ok"); }, @@ -2908,12 +3078,15 @@ const TEAM_PRESETS = { }; async function newTeamModal() { - const avail = await loadAvailability(); - const fallback = ["claude", "codex", "gemini"].find((t) => avail[t] !== false) ?? "claude"; + const [avail] = await Promise.all([loadAvailability(), loadApiProviders()]); + const fallback = ["claude", "codex", "gemini", "api"].find((t) => avail[t] !== false) ?? "claude"; const pick = (t) => (avail[t] === false ? fallback : t); + providersReturn = () => newTeamModal(); + const providerSelect = (a) => ``; const agentRow = (a) => `
    + ${providerSelect(a)} @@ -2935,6 +3108,7 @@ async function newTeamModal() {
    ${TEAM_PRESETS["Worker + Reviewer"].map(agentRow).join("")}
    + @@ -2947,6 +3121,14 @@ async function newTeamModal() { const list = form.querySelector("[data-agents]"); form.querySelector("[data-add-agent]").addEventListener("click", () => list.insertAdjacentHTML("beforeend", agentRow({ agent_type: fallback, name: "", role: "", initial_task: "" }))); list.addEventListener("click", (e) => { if (e.target.closest("[data-remove-agent]")) e.target.closest(".team-agent").remove(); }); + list.addEventListener("change", (e) => { + const type = e.target.closest("[data-f=agent_type]"); + if (!type) return; + const row = type.closest(".team-agent"); + row.querySelector("[data-f=provider]").hidden = type.value !== "api"; + const model = row.querySelector("[data-f=model]"); + model.placeholder = type.value === "api" ? (providerById(row.querySelector("[data-f=provider]").value)?.default_model || tr("model name")) : tr("model (default)"); + }); form.querySelectorAll("[data-preset]").forEach((b) => b.addEventListener("click", () => { list.innerHTML = TEAM_PRESETS[b.dataset.preset].map(agentRow).join(""); })); }, onSubmit: async (form) => { @@ -2955,11 +3137,13 @@ async function newTeamModal() { row.querySelectorAll("[data-f]").forEach((f) => { a[f.dataset.f] = f.value.trim(); }); if (!a.model) delete a.model; if (a.agent_type === "gemini") a.approval_mode = "bypass"; + if (a.agent_type !== "api") delete a.provider; return a; }); + if (agents.some((a) => a.agent_type === "api" && !a.provider)) throw new Error("Add an API provider first (Manage providers…)"); if (!agents.length) throw new Error("Add at least one agent"); if (agents.some((a) => !a.name || !a.role)) throw new Error("Every agent needs a name and a role"); - const unavailable = agents.filter((a) => avail[a.agent_type] === false).map((a) => `${a.name} (${a.agent_type})`); + const unavailable = agents.filter((a) => a.agent_type !== "api" && avail[a.agent_type] === false).map((a) => `${a.name} (${a.agent_type})`); if (unavailable.length) throw new Error(tr("Not installed on this machine: {list}", { list: unavailable.join(", ") })); const names = agents.map((a) => a.name.toLowerCase()); const plan = form.plan.value.split("\n").map((l) => l.trim()).filter(Boolean).map((line) => { diff --git a/dashboard/control.ts b/dashboard/control.ts index b591739..2467f97 100644 --- a/dashboard/control.ts +++ b/dashboard/control.ts @@ -16,6 +16,7 @@ import { callOrchestratorTool, restartBroker, missingBrokerFeatures, type ToolRe import { controlSession, broadcastToTeam } from "../orchestrator/session-control.ts"; import { detectAgent } from "../orchestrator/launcher.ts"; import { HUMAN_SENDER } from "../shared/constants.ts"; +import { loadProviders, getProvider, saveProvider, deleteProvider, maskProvider, fetchProviderModels, normalizeBaseUrl, type ApiProvider } from "../shared/providers.ts"; /** Sender id of everything a person does from the browser. */ export const HUMAN_ID = HUMAN_SENDER; @@ -28,7 +29,7 @@ const QA_HOLD_TEXT = const QA_RESUME_TEXT = "[RESUME] The person ended Q&A mode. Continue your task where you left off; messages your teammates sent meanwhile arrive now."; -const AGENT_TYPES: AgentType[] = ["claude", "codex", "gemini"]; +const AGENT_TYPES: AgentType[] = ["claude", "codex", "gemini", "api"]; const KNOWLEDGE_CATEGORIES: KnowledgeCategory[] = ["decision", "convention", "discovery", "blocker", "context"]; const PLAN_STATUSES = ["pending", "in_progress", "blocked", "done"] as const; @@ -145,6 +146,13 @@ function parseAgentConfig(raw: unknown, index: number): AgentLaunchConfig { ? a.file_ownership.filter((p): p is string => typeof p === "string" && p.trim() !== "").map((p) => p.trim()) : []; const settings = parseSettings(a, agentType, `Agent ${index + 1}: `); + let provider: string | undefined; + if (agentType === "api") { + provider = str(a, "provider", { optional: true }).trim(); + const p = getProvider(provider); + if (!p) throw new ApiError(`Agent ${index + 1}: pick an API provider (add one under API providers first)`); + if (!settings.model && !p.default_model) throw new ApiError(`Agent ${index + 1}: pick a model for ${p.name}`); + } return { agent_type: agentType, name, @@ -155,6 +163,7 @@ function parseAgentConfig(raw: unknown, index: number): AgentLaunchConfig { ...(settings.model ? { model: settings.model } : {}), ...(settings.effort ? { effort: settings.effort } : {}), ...(settings.approval_mode ? { approval_mode: settings.approval_mode } : {}), + ...(provider ? { provider } : {}), }; } @@ -163,6 +172,8 @@ const EFFORTS: Record = { claude: ["low", "medium", "high", "xhigh", "max"], codex: ["minimal", "low", "medium", "high"], gemini: [], + // Sent as reasoning_effort; providers without it ignore or reject the field + api: ["minimal", "low", "medium", "high"], custom: [], }; @@ -224,6 +235,65 @@ const handlers: Record = { return { message: "Broker restarted" }; }, + // ---- API providers ("Other" agents: any OpenAI-compatible API) ---- + + async "providers/list"() { + return loadProviders().map(maskProvider); + }, + + async "providers/save"(_ctx, body) { + const name = str(body, "name").trim(); + const base = str(body, "base_url").trim(); + if (!name || !base) throw new ApiError("Name and base URL are required"); + let extra: Record | undefined; + const rawExtra = typeof body.extra_body === "string" ? body.extra_body.trim() : ""; + if (rawExtra) { + try { extra = JSON.parse(rawExtra); } catch { throw new ApiError("Extra request fields must be a JSON object"); } + if (!extra || typeof extra !== "object" || Array.isArray(extra)) throw new ApiError("Extra request fields must be a JSON object"); + } + try { + const saved = saveProvider({ + id: str(body, "id", { optional: true }).trim() || undefined, + name, base_url: base, + api_key: str(body, "api_key", { optional: true, allowEmpty: true }), + default_model: str(body, "default_model", { optional: true, allowEmpty: true }), + extra_body: extra ?? {}, + input_price: body.input_price as number | undefined, + output_price: body.output_price as number | undefined, + context_tokens: body.context_tokens as number | undefined, + }); + availability = null; // "api" becomes available + return { message: `Saved ${saved.name}`, provider: maskProvider(saved) }; + } catch (e) { + throw new ApiError(e instanceof Error ? e.message : String(e)); + } + }, + + async "providers/delete"(_ctx, body) { + const id = str(body, "id"); + if (!deleteProvider(id)) throw new ApiError("No such provider", 404); + availability = null; + return { message: "Provider removed" }; + }, + + /** Check a provider (saved, or the form's unsaved values) by listing its models. */ + async "providers/models"(_ctx, body) { + const id = str(body, "id", { optional: true }).trim(); + const stored = id ? getProvider(id) : null; + const base = str(body, "base_url", { optional: true }).trim(); + const key = str(body, "api_key", { optional: true, allowEmpty: true }).trim(); + const p: ApiProvider | null = base + ? { id: stored?.id ?? "unsaved", name: str(body, "name", { optional: true }).trim() || stored?.name || "Provider", base_url: normalizeBaseUrl(base), api_key: key || stored?.api_key || "" } + : stored; + if (!p) throw new ApiError("Give a base URL or a saved provider"); + try { + const models = await fetchProviderModels(p); + return { message: `${p.name}: ${models.length} model(s)`, models }; + } catch (e) { + throw new ApiError(e instanceof Error ? e.message : String(e), 502); + } + }, + // ---- Sessions ---- async "session/create"(ctx, body) { diff --git a/dashboard/i18n.js b/dashboard/i18n.js index 101906e..94683a6 100644 --- a/dashboard/i18n.js +++ b/dashboard/i18n.js @@ -279,6 +279,42 @@ const ZH = { "Q&A mode — the running turn was interrupted": "问答模式 — 正在运行的回合已中断", "Interrupted to answer the person": "已打断,先回答你的问题", + // --- API providers ("Other" agents) --- + "Other (OpenAI-compatible API)": "其他(OpenAI 兼容 API)", + " (add a provider)": "(先添加接入)", + "API providers": "API 接入", + "API providers…": "API 接入…", + "Provider": "接入", + "No API provider yet": "还没有 API 接入", + "Manage providers…": "管理 API 接入…", + "model name": "模型名", + "Applies to a running agent from its next step.": "对运行中的 agent 从下一步开始生效。", + "Sent as reasoning_effort; keep the default if the provider does not support it.": "作为 reasoning_effort 发送;接口不支持时请保持默认。", + "none": "无", + "{n} models": "{n} 个模型", + "No API provider yet. Pick a preset or fill in any OpenAI-compatible endpoint.": "还没有 API 接入。选一个预设,或填写任意 OpenAI 兼容的接口地址。", + "Any service with an OpenAI-compatible chat API can run an agent: DeepSeek, Qwen, Kimi, GLM, OpenRouter, a local Ollama… Keys stay on this computer (~/.multiagents/providers.json) and are never sent to the page.": "任何提供 OpenAI 兼容聊天接口的服务都能作为 agent:DeepSeek、通义千问、Kimi、智谱 GLM、OpenRouter、本地 Ollama……密钥只保存在这台电脑上(~/.multiagents/providers.json),不会发给网页。", + "Add or edit": "添加或编辑", + "Base URL": "接口地址", + "API key": "API 密钥", + "Default model": "默认模型", + "Price (USD / 1M tokens)": "价格(美元 / 百万 token)", + "Context window": "上下文长度", + "Extra request fields": "额外请求参数", + "The address before /chat/completions.": "/chat/completions 之前的那部分地址。", + "Or $NAME to read it from an environment variable. Leave empty to keep the saved key; local servers need none.": "也可以写 $变量名,从环境变量读取。留空则保留已保存的密钥;本地服务不需要密钥。", + "Fetch models": "获取模型列表", + "input": "输入", + "output": "输出", + "JSON merged into every request, for provider options such as Qwen's built-in web search.": "合并进每次请求的 JSON,用于接口自带的选项,比如通义千问的联网搜索。", + "saved: {hint}": "已保存:{hint}", + "Delete {name}?": "删除 {name}?", + "Agents already running on it keep running until they restart.": "已经在用它的 agent 会继续运行,直到重启。", + "Save provider": "保存接入", + "Saved {name}": "已保存 {name}", + "Provider removed": "已删除接入", + "Add an API provider first (Manage providers…)": "请先添加 API 接入(管理 API 接入…)", + // --- Team roster --- "Online agents": "在线 agent", "Agent settings": "Agent 设置", @@ -719,6 +755,12 @@ const RULES_ZH = [ [/^Session "(.+)" permanently deleted\. Removed: (\d+) slot\(s\), (\d+) message\(s\), (\d+) plan\(s\)\.$/, (m) => `会话“${m[1]}”已永久删除。共删除 ${m[2]} 个席位、${m[3]} 条消息、${m[4]} 个计划。`], [/^Extended session duration to (.+)$/, (m) => `会话时长已延长至 ${m[1]}`], [/^Nothing to change$/, () => "没有需要修改的内容"], + [/^Saved (.+)$/, (m) => `已保存 ${m[1]}`], + [/^(.+): (\d+) model\(s\)$/, (m) => `${m[1]}:${m[2]} 个模型`], + [/^(.+) \/models: HTTP (\d+)( \(check the API key\))?$/, (m) => `${m[1]} 获取模型失败:HTTP ${m[2]}${m[3] ? "(请检查 API 密钥)" : ""}`], + [/^Agent (\d+): pick an API provider \(add one under API providers first\)$/, (m) => `第 ${m[1]} 个 agent:请选择 API 接入(先在“API 接入”里添加)`], + [/^Agent (\d+): pick a model for (.+)$/, (m) => `第 ${m[1]} 个 agent:请为 ${m[2]} 选择模型`], + [/^API provider "(.*)" is not configured — add it under API providers first$/, (m) => `没有配置 API 接入“${m[1]}”,请先在“API 接入”里添加`], [/^The broker still lacks: (.+)$/, (m) => `broker 仍缺少:${m[1]}`], [/^JSON-RPC error: (.+)$/s, (m) => `JSON-RPC 错误:${m[1]}`], [/^A person denied this tool call\.?(?:: (.+))?$/s, (m) => `有人拒绝了此工具调用${m[1] ? `:${m[1]}` : "。"}`], diff --git a/dashboard/styles.css b/dashboard/styles.css index b98fb53..8ac530a 100644 --- a/dashboard/styles.css +++ b/dashboard/styles.css @@ -726,3 +726,12 @@ html.has-bg.bg-blur .topbar, html.has-bg.bg-blur .pane { backdrop-filter: blur(1 .live-text-head { display: flex; align-items: center; justify-content: space-between; font-size: 11.5px; color: var(--text-muted); } .live-text { white-space: pre-wrap; word-break: break-word; font-size: 12.5px; line-height: 1.5; color: var(--text-dim); font-style: italic; max-height: 4.6em; overflow: hidden; } .live-text-wrap.expanded .live-text { max-height: 40vh; overflow-y: auto; } + +/* --- API providers ("Other" agents) --- */ +.team-agent > .team-agent-row:first-child { grid-template-columns: 170px auto 1fr 1fr auto; } +.team-agent-row select[hidden] { display: none; } +.provider-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 6px; } +.provider-row { display: flex; align-items: center; gap: 8px; border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; } +.provider-info { flex: 1; min-width: 0; } +.provider-info .mono { font-size: 11.5px; margin-left: 6px; overflow-wrap: anywhere; } +@media (max-width: 760px) { .team-agent > .team-agent-row:first-child { grid-template-columns: 1fr 1fr; } } diff --git a/orchestrator/api-driver.ts b/orchestrator/api-driver.ts new file mode 100644 index 0000000..a7ecaa1 --- /dev/null +++ b/orchestrator/api-driver.ts @@ -0,0 +1,651 @@ +// ============================================================================ +// multiagents — Agent driver for OpenAI-compatible APIs ("Other" agents) +// ============================================================================ +// Runs an agent on any chat-completions API (DeepSeek, Qwen, Kimi, GLM, +// OpenRouter, Ollama…) with the same reach as the CLI agents: +// - team tools: the multiagents-peer MCP server is spawned in driver mode and +// its tools are offered to the model (send_message, signal_done, …); +// - local tools: Read, Write, Edit, LS, Glob, Grep, Bash, WebFetch, confined +// to the project directory; +// - one long conversation; messages that arrive mid-turn are read before the +// next model call, like Claude reading queued input at a tool boundary. +// It emits Claude's stream-json events (system/init, stream_event deltas, +// assistant blocks, tool results, result with usage and cost), so activity, +// live view, token accounting and approvals reuse the Claude paths, and it +// asks for tool approval the way the Claude CLI does for its permission modes. +// ============================================================================ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { randomUUID } from "node:crypto"; +import { log } from "../shared/utils.ts"; +import { authHeaders, type ApiProvider } from "../shared/providers.ts"; +import type { + ClaudeEvent, ClaudeEventListener, ClaudePermissionDecision, ClaudePermissionHandler, ClaudeSession, PipedSubprocess, +} from "./claude-driver.ts"; + +const LOG_PREFIX = "api-driver"; +const TEAM_SERVER = "multiagents-peer"; +const MAX_STEPS_PER_TURN = 60; +const DEFAULT_CONTEXT_TOKENS = 64_000; +const TOOL_OUTPUT_MAX = 30_000; +const COMMAND_TIMEOUT_MS = 120_000; + +export interface ApiDriverOptions { + cwd: string; + env: Record; + provider: ApiProvider; + model: string; + effort?: string; + /** The team-tools MCP server to run (as for Claude: --driver-mode) */ + mcpServer: { command: string; args: string[]; env?: Record }; + systemPrompt: string; + firstPrompt: string; + /** Claude permission mode names: bypassPermissions | acceptEdits | default | auto | plan */ + permissionMode?: string; + /** Overrides fetch (tests) */ + fetch?: typeof fetch; +} + +type ChatMessage = + | { role: "system" | "user"; content: string } + | { role: "assistant"; content: string | null; tool_calls?: ToolCall[]; reasoning_content?: string } + | { role: "tool"; tool_call_id: string; content: string }; + +interface ToolCall { id: string; type: "function"; function: { name: string; arguments: string } } +interface McpTool { name: string; description?: string; inputSchema?: Record } + +// --- Local tools (Claude's names, so describeTool and the Q&A rules apply) --- + +const LOCAL_TOOLS = [ + { name: "Read", description: "Read a text file of the project. Returns numbered lines.", parameters: { type: "object", properties: { file_path: { type: "string", description: "Path relative to the project (or absolute inside it)" }, offset: { type: "integer", description: "First line (1-based)" }, limit: { type: "integer", description: "Number of lines" } }, required: ["file_path"] } }, + { name: "Write", description: "Create or overwrite a file in the project with the given content.", parameters: { type: "object", properties: { file_path: { type: "string" }, content: { type: "string" } }, required: ["file_path", "content"] } }, + { name: "Edit", description: "Replace exact text in a file. old_string must occur once unless replace_all is true.", parameters: { type: "object", properties: { file_path: { type: "string" }, old_string: { type: "string" }, new_string: { type: "string" }, replace_all: { type: "boolean" } }, required: ["file_path", "old_string", "new_string"] } }, + { name: "LS", description: "List a directory of the project.", parameters: { type: "object", properties: { path: { type: "string", description: "Directory, default the project root" } } } }, + { name: "Glob", description: "Find files by glob pattern, e.g. src/**/*.ts", parameters: { type: "object", properties: { pattern: { type: "string" }, path: { type: "string" } }, required: ["pattern"] } }, + { name: "Grep", description: "Search file contents with a regular expression. Returns matching lines with file and line number.", parameters: { type: "object", properties: { pattern: { type: "string" }, path: { type: "string" }, glob: { type: "string", description: "Only files matching this glob" } }, required: ["pattern"] } }, + { name: "Bash", description: "Run a shell command in the project directory (2 minute timeout). Returns exit code and output.", parameters: { type: "object", properties: { command: { type: "string" }, timeout_ms: { type: "integer" } }, required: ["command"] } }, + { name: "WebFetch", description: "Fetch a web page or URL and return its text.", parameters: { type: "object", properties: { url: { type: "string" } }, required: ["url"] } }, +] as const; + +const READ_ONLY = new Set(["Read", "LS", "Glob", "Grep", "WebFetch"]); +const EDITS = new Set(["Write", "Edit"]); + +/** Whether a permission mode asks before this tool runs (the Claude CLI's rules). */ +export function needsApproval(mode: string, tool: string): boolean { + if (READ_ONLY.has(tool) || tool.startsWith(`mcp__${TEAM_SERVER}__`)) return false; + if (mode === "bypassPermissions") return false; + if ((mode === "acceptEdits" || mode === "auto") && EDITS.has(tool)) return false; + return true; +} + +export class ApiDriver implements ClaudeSession { + private proc: PipedSubprocess; + private opts: ApiDriverOptions; + private fetchFn: typeof fetch; + private root: string; + private messages: ChatMessage[] = []; + private queue: Array<{ text: string; settle: (read: boolean) => void }> = []; + private listeners: ClaudeEventListener[] = []; + private history: ClaudeEvent[] = []; + private exitCbs: Array<(code: number) => void> = []; + private permissionHandler: ClaudePermissionHandler | null = null; + private mcpTools: McpTool[] = []; + private rpcSeq = 0; + private rpcWaiters = new Map void; reject: (e: Error) => void }>(); + private stdoutBuf = ""; + private abort: AbortController | null = null; + private interrupted = false; + private _alive = true; + private _busy = false; + private _exitCode: number | null = null; + private _lastActivity = Date.now(); + private _model: string; + private _mode: string; + private _sessionId = `api-${randomUUID()}`; + private totalCost = 0; + private callSeq = 0; + + get alive(): boolean { return this._alive; } + get busy(): boolean { return this._busy; } + get pid(): number { return this.proc.pid; } + get process(): PipedSubprocess { return this.proc; } + get sessionId(): string | null { return this._sessionId; } + get lastActivity(): number { return this._lastActivity; } + get model(): string { return this._model; } + get permissionMode(): string { return this._mode; } + + private constructor(proc: PipedSubprocess, opts: ApiDriverOptions) { + this.proc = proc; + this.opts = opts; + this.fetchFn = opts.fetch ?? fetch; + this.root = fs.realpathSync(opts.cwd); + this._model = opts.model; + this._mode = opts.permissionMode ?? "bypassPermissions"; + this.messages.push({ role: "system", content: opts.systemPrompt }); + void this.readStdout(); + void (async () => { for await (const _ of proc.stderr) { /* the MCP server logs here */ } })().catch(() => {}); + proc.exited.then((code) => { + this._alive = false; + this._exitCode = code; + this.abort?.abort(); + for (const w of this.rpcWaiters.values()) w.reject(new Error(`team tools server exited (code ${code})`)); + this.rpcWaiters.clear(); + for (const q of this.queue.splice(0)) q.settle(false); + log(LOG_PREFIX, `team tools server exited with code ${code}`); + for (const cb of this.exitCbs) { try { cb(code); } catch { /* keep going */ } } + }); + } + + /** Start the team-tools server, connect to it and begin the first turn (in the background). */ + static async spawn(opts: ApiDriverOptions): Promise { + const proc = Bun.spawn([opts.mcpServer.command, ...opts.mcpServer.args], { + cwd: opts.cwd, stdin: "pipe", stdout: "pipe", stderr: "pipe", + env: { ...opts.env, ...(opts.mcpServer.env ?? {}) }, + }); + const driver = new ApiDriver(proc, opts); + try { + await driver.rpc("initialize", { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "multiagents-api-driver", version: "1.0" } }, 30_000); + driver.notify("notifications/initialized", {}); + const listed = await driver.rpc("tools/list", {}, 30_000) as { tools?: McpTool[] }; + driver.mcpTools = listed.tools ?? []; + } catch (err) { + await driver.kill(); + throw new Error(`Could not start the team tools for the API agent: ${err instanceof Error ? err.message : err}`); + } + driver.emit({ + type: "system", subtype: "init", session_id: driver._sessionId, model: opts.model, permissionMode: driver._mode, + mcp_servers: [{ name: TEAM_SERVER, status: "connected" }], tools: driver.toolSpecs().map((t) => t.function.name), + }); + log(LOG_PREFIX, `${opts.provider.name} agent ready (model ${opts.model}, ${driver.mcpTools.length} team tools)`); + void driver.send(opts.firstPrompt, { echoTimeoutMs: Infinity }); + return driver; + } + + // --- ClaudeSession --- + + onExit(cb: (code: number) => void): void { + if (this._exitCode !== null) { const code = this._exitCode; queueMicrotask(() => cb(code)); return; } + this.exitCbs.push(cb); + } + + onEvent(cb: ClaudeEventListener, opts: { replay?: boolean } = {}): void { + if (opts.replay) for (const e of this.history) { try { cb(e); } catch { /* keep going */ } } + this.listeners.push(cb); + } + + onPermissionRequest(handler: ClaudePermissionHandler): void { this.permissionHandler = handler; } + + send(text: string, _opts: { echoTimeoutMs?: number } = {}): Promise { + if (!this._alive) return Promise.resolve(false); + this._lastActivity = Date.now(); + const read = new Promise((settle) => this.queue.push({ text, settle })); + if (!this._busy) void this.runTurn(); + return read; + } + + async interrupt(): Promise { + if (!this._busy) return; + this.interrupted = true; + this.abort?.abort(); + } + + async setModel(model: string | undefined): Promise { + this._model = model || this.opts.provider.default_model || this._model; + } + + async setPermissionMode(mode: string): Promise { + this._mode = mode === "manual" ? "default" : mode; + } + + async close(timeoutMs = 5_000): Promise { + if (!this._alive) return this._exitCode ?? -1; + await this.interrupt(); + try { this.proc.stdin.end(); } catch { /* closed */ } + const code = await Promise.race([this.proc.exited, Bun.sleep(timeoutMs).then(() => null)]); + if (code !== null) return code; + await this.kill(); + return this.proc.exited; + } + + async kill(): Promise { + this.abort?.abort(); + if (!this._alive) return; + try { this.proc.kill("SIGTERM"); } catch { /* gone */ } + const exited = await Promise.race([this.proc.exited, Bun.sleep(3_000).then(() => null)]); + if (exited === null) { try { this.proc.kill("SIGKILL"); } catch { /* gone */ } } + } + + // --- The agent loop --- + + private async runTurn(): Promise { + if (this._busy || !this._alive) return; + this._busy = true; + this.interrupted = false; + const usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 }; + let finalText = ""; + let error: string | null = null; + let steps = 0; + try { + while (this._alive && !this.interrupted && steps++ < MAX_STEPS_PER_TURN) { + this.takeQueued(); + this.trimContext(); + const reply = await this.complete(usage); + if (this.interrupted) break; + this.messages.push({ role: "assistant", content: reply.text || null, ...(reply.toolCalls.length ? { tool_calls: reply.toolCalls } : {}) }); + if (reply.text) finalText = reply.text; + if (!reply.toolCalls.length) { + // Messages that came in while it answered start the next step of the same turn + if (this.queue.length) continue; + break; + } + for (const call of reply.toolCalls) { + if (this.interrupted) break; + const output = await this.runToolCall(call); + this.messages.push({ role: "tool", tool_call_id: call.id, content: output.text }); + this.emit({ type: "user", message: { role: "user", content: [{ type: "tool_result", tool_use_id: call.id, content: output.text, is_error: output.isError }] } }); + } + } + if (steps > MAX_STEPS_PER_TURN) error = `stopped after ${MAX_STEPS_PER_TURN} steps in one turn`; + } catch (err) { + if (!this.interrupted) error = err instanceof Error ? err.message : String(err); + } finally { + // A tool call without its result would make the next request invalid + this.closeOpenToolCalls(); + this._busy = false; + this._lastActivity = Date.now(); + const subtype = this.interrupted ? "error_during_execution" : error ? "error_api" : "success"; + if (error) log(LOG_PREFIX, `turn ended with an error: ${error}`); + this.emit({ + type: "result", subtype, is_error: subtype !== "success", session_id: this._sessionId, + result: error ?? finalText, usage, total_cost_usd: this.totalCost, + ...(error ? { errors: [error] } : {}), + }); + this.interrupted = false; + if (this.queue.length && this._alive) void this.runTurn(); + } + } + + /** Queued messages join the conversation now (the model reads them next). */ + private takeQueued(): void { + if (!this.queue.length) return; + const items = this.queue.splice(0); + const text = items.map((q) => q.text).join("\n\n---\n\n"); + this.messages.push({ role: "user", content: text }); + this.emit({ type: "user", isReplay: true, message: { role: "user", content: text } }); + for (const q of items) q.settle(true); + } + + private closeOpenToolCalls(): void { + const answered = new Set(this.messages.filter((m) => m.role === "tool").map((m) => (m as { tool_call_id: string }).tool_call_id)); + for (let i = this.messages.length - 1; i >= 0; i--) { + const m = this.messages[i]!; + if (m.role !== "assistant" || !m.tool_calls) continue; + const missing = m.tool_calls.filter((c) => !answered.has(c.id)); + if (!missing.length) break; + this.messages.push(...missing.map((c) => ({ role: "tool" as const, tool_call_id: c.id, content: "Interrupted before it ran." }))); + break; + } + } + + /** Keep the conversation inside the context window: drop the oldest exchanges first. */ + private trimContext(): void { + const limit = (this.opts.provider.context_tokens ?? DEFAULT_CONTEXT_TOKENS) * 0.75; + const size = () => this.messages.reduce((n, m) => n + JSON.stringify(m).length / 3, 0); + let trimmed = false; + while (size() > limit && this.messages.length > 4) { + // messages[0] is the system prompt; drop the next exchange (an assistant call and its results go together) + const first = this.messages[1]!; + this.messages.splice(1, 1); + if (first.role === "assistant" && first.tool_calls) { + while (this.messages[1]?.role === "tool") this.messages.splice(1, 1); + } + while (this.messages[1]?.role === "tool") this.messages.splice(1, 1); + trimmed = true; + } + if (trimmed && !(this.messages[1]?.role === "user" && String(this.messages[1].content).startsWith("[Earlier conversation"))) { + this.messages.splice(1, 0, { role: "user", content: "[Earlier conversation was trimmed to fit the context window. Use get_history / get_plan / check_team_status if you need it again.]" }); + } + } + + /** One streamed chat completion; text and reasoning arrive as deltas. */ + private async complete(usage: { input_tokens: number; output_tokens: number }): Promise<{ text: string; toolCalls: ToolCall[] }> { + const p = this.opts.provider; + const body: Record = { + ...(p.extra_body ?? {}), + model: this._model, + messages: this.messages, + tools: this.toolSpecs(), + stream: true, + stream_options: { include_usage: true }, + }; + if (this.opts.effort) body.reasoning_effort = this.opts.effort; + + let res: Response | null = null; + for (let attempt = 0; ; attempt++) { + this.abort = new AbortController(); + res = await this.fetchFn(`${p.base_url}/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders(p) }, + body: JSON.stringify(body), + signal: this.abort.signal, + }); + if (res.ok) break; + const detail = (await res.text().catch(() => "")).slice(0, 500); + // Some servers reject stream_options; retry once without it + if (res.status === 400 && attempt === 0 && /stream_options/i.test(detail)) { delete body.stream_options; continue; } + if ((res.status === 429 || res.status >= 500) && attempt < 3) { await Bun.sleep(2_000 * 2 ** attempt); continue; } + throw new Error(`${p.name}: HTTP ${res.status}${detail ? ` — ${detail}` : ""}`); + } + + const msgId = `msg_${++this.callSeq}`; + let text = "", thinking = ""; + let block: "thinking" | "text" | null = null; + let index = -1; + const calls = new Map(); + let callUsage: { prompt_tokens?: number; completion_tokens?: number; prompt_tokens_details?: { cached_tokens?: number } } | null = null; + const startBlock = (kind: "thinking" | "text") => { + if (block === kind) return; + if (block) this.emit({ type: "stream_event", event: { type: "content_block_stop", index } }); + block = kind; + index++; + this.emit({ type: "stream_event", event: { type: "content_block_start", index, content_block: { type: kind, [kind]: "" } } }); + }; + + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let nl; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (data === "[DONE]") continue; + let chunk: any; + try { chunk = JSON.parse(data); } catch { continue; } + if (chunk.error) throw new Error(`${p.name}: ${chunk.error.message ?? JSON.stringify(chunk.error)}`); + if (chunk.usage) callUsage = chunk.usage; + const delta = chunk.choices?.[0]?.delta ?? {}; + this._lastActivity = Date.now(); + const reasoning = delta.reasoning_content ?? delta.reasoning; + if (typeof reasoning === "string" && reasoning) { + startBlock("thinking"); + thinking += reasoning; + this.emit({ type: "stream_event", event: { type: "content_block_delta", index, delta: { type: "thinking_delta", thinking: reasoning } } }); + } + if (typeof delta.content === "string" && delta.content) { + startBlock("text"); + text += delta.content; + this.emit({ type: "stream_event", event: { type: "content_block_delta", index, delta: { type: "text_delta", text: delta.content } } }); + } + for (const tc of delta.tool_calls ?? []) { + const i = typeof tc.index === "number" ? tc.index : calls.size; + const call = calls.get(i) ?? { id: "", type: "function" as const, function: { name: "", arguments: "" } }; + if (tc.id) call.id = tc.id; + if (tc.function?.name) call.function.name += tc.function.name; + if (tc.function?.arguments) call.function.arguments += tc.function.arguments; + calls.set(i, call); + } + } + } + if (block) this.emit({ type: "stream_event", event: { type: "content_block_stop", index } }); + + const toolCalls = [...calls.values()].filter((c) => c.function.name).map((c, i) => ({ ...c, id: c.id || `call_${this.callSeq}_${i}` })); + const input = callUsage?.prompt_tokens ?? 0; + const output = callUsage?.completion_tokens ?? 0; + usage.input_tokens += input; + usage.output_tokens += output; + if (p.input_price || p.output_price) this.totalCost += (input * (p.input_price ?? 0) + output * (p.output_price ?? 0)) / 1e6; + + // The whole call as one assistant event (timeline blocks; input tokens are metered per call) + const content: Array> = []; + if (thinking) content.push({ type: "thinking", thinking }); + if (text) content.push({ type: "text", text }); + for (const c of toolCalls) content.push({ type: "tool_use", id: c.id, name: this.eventToolName(c.function.name), input: parseArgs(c.function.arguments) }); + this.emit({ + type: "assistant", + message: { id: msgId, role: "assistant", model: this._model, content, usage: { input_tokens: input, output_tokens: output, cache_read_input_tokens: callUsage?.prompt_tokens_details?.cached_tokens ?? 0 } }, + }); + return { text, toolCalls }; + } + + /** Team tools carry their MCP name in events, so isTeamTool / describeTool know them. */ + private eventToolName(name: string): string { + return this.mcpTools.some((t) => t.name === name) ? `mcp__${TEAM_SERVER}__${name}` : name; + } + + private toolSpecs(): Array<{ type: "function"; function: { name: string; description: string; parameters: unknown } }> { + const team = this.mcpTools.map((t) => ({ + type: "function" as const, + function: { name: t.name, description: (t.description ?? "").slice(0, 1000), parameters: t.inputSchema ?? { type: "object", properties: {} } }, + })); + const local = LOCAL_TOOLS.map((t) => ({ type: "function" as const, function: { name: t.name, description: t.description, parameters: t.parameters } })); + return [...local, ...team]; + } + + private async runToolCall(call: ToolCall): Promise<{ text: string; isError: boolean }> { + const name = call.function.name; + const input = parseArgs(call.function.arguments); + if (input === null) return { text: "The arguments were not valid JSON. Call the tool again with a JSON object.", isError: true }; + const eventName = this.eventToolName(name); + if (needsApproval(this._mode, eventName)) { + const decision = await this.askPermission(eventName, input, call.id); + if (decision.behavior === "deny") return { text: `denied: ${decision.message}`, isError: true }; + if (decision.updatedInput) Object.assign(input, decision.updatedInput); + } else if (this._mode === "plan" && !READ_ONLY.has(name) && !eventName.startsWith("mcp__")) { + return { text: "Plan mode: only reading is allowed.", isError: true }; + } + try { + if (this.mcpTools.some((t) => t.name === name)) return await this.callTeamTool(name, input); + return await this.runLocalTool(name, input); + } catch (err) { + return { text: `Error: ${err instanceof Error ? err.message : String(err)}`, isError: true }; + } + } + + private async askPermission(toolName: string, input: Record, toolUseId: string): Promise { + for (let waited = 0; !this.permissionHandler && this._alive && waited < 30_000; waited += 100) await Bun.sleep(100); + if (!this.permissionHandler) return { behavior: "deny", message: "No approver is connected to this agent." }; + try { + return await this.permissionHandler({ toolName, input, toolUseId }); + } catch (err) { + return { behavior: "deny", message: `Approval failed: ${err instanceof Error ? err.message : err}` }; + } + } + + private async callTeamTool(name: string, input: Record): Promise<{ text: string; isError: boolean }> { + const result = await this.rpc("tools/call", { name, arguments: input }, 120_000) as { content?: Array<{ type: string; text?: string }>; isError?: boolean }; + const text = (result.content ?? []).map((c) => c.text ?? "").join("\n") || "(no output)"; + return { text: clip(text), isError: result.isError === true }; + } + + // --- Local tools, inside the project --- + + private resolve(p: unknown, mustExist = true): string { + if (typeof p !== "string" || !p.trim()) throw new Error("A path is required"); + const abs = path.resolve(this.root, p); + let real = abs; + try { real = fs.realpathSync(abs); } catch { + if (mustExist) throw new Error(`${p} does not exist`); + // A new file: its directory decides + try { real = path.join(fs.realpathSync(path.dirname(abs)), path.basename(abs)); } catch { real = abs; } + } + if (real !== this.root && !real.startsWith(this.root + path.sep)) throw new Error(`${p} is outside the project directory`); + return real; + } + + private async runLocalTool(name: string, input: Record): Promise<{ text: string; isError: boolean }> { + switch (name) { + case "Read": { + const file = this.resolve(input.file_path); + const lines = (await Bun.file(file).text()).split("\n"); + const start = Math.max(1, Number(input.offset) || 1); + const count = Math.min(2_000, Math.max(1, Number(input.limit) || 2_000)); + const slice = lines.slice(start - 1, start - 1 + count).map((l, i) => `${String(start + i).padStart(6)}\t${l}`); + const more = lines.length > start - 1 + count ? `\n… ${lines.length - (start - 1 + count)} more lines (use offset)` : ""; + return { text: clip(slice.join("\n") + more), isError: false }; + } + case "Write": { + const file = this.resolve(input.file_path, false); + fs.mkdirSync(path.dirname(file), { recursive: true }); + await Bun.write(file, String(input.content ?? "")); + return { text: `Wrote ${path.relative(this.root, file)}`, isError: false }; + } + case "Edit": { + const file = this.resolve(input.file_path); + const before = await Bun.file(file).text(); + const oldS = String(input.old_string ?? ""); + if (!oldS) throw new Error("old_string is empty"); + const count = before.split(oldS).length - 1; + if (count === 0) throw new Error("old_string was not found in the file"); + if (count > 1 && !input.replace_all) throw new Error(`old_string occurs ${count} times; add context or set replace_all`); + const after = input.replace_all ? before.split(oldS).join(String(input.new_string ?? "")) : before.replace(oldS, () => String(input.new_string ?? "")); + await Bun.write(file, after); + return { text: `Edited ${path.relative(this.root, file)} (${input.replace_all ? count : 1} replacement${count > 1 && input.replace_all ? "s" : ""})`, isError: false }; + } + case "LS": { + const dir = this.resolve(input.path ?? "."); + const entries = fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.name !== ".git").sort((a, b) => a.name.localeCompare(b.name)); + return { text: entries.map((e) => (e.isDirectory() ? `${e.name}/` : e.name)).join("\n") || "(empty)", isError: false }; + } + case "Glob": { + const base = this.resolve(input.path ?? "."); + const out: string[] = []; + for await (const f of new Bun.Glob(String(input.pattern)).scan({ cwd: base, onlyFiles: true })) { + if (f.startsWith(".git/") || f.includes("/node_modules/") || f.startsWith("node_modules/")) continue; + out.push(path.relative(this.root, path.join(base, f))); + if (out.length >= 500) break; + } + return { text: out.join("\n") || "No files matched", isError: false }; + } + case "Grep": { + const base = this.resolve(input.path ?? "."); + let re: RegExp; + try { re = new RegExp(String(input.pattern)); } catch { throw new Error("The pattern is not a valid regular expression"); } + const glob = new Bun.Glob(String(input.glob ?? "**/*")); + const stat = fs.statSync(base); + const files = stat.isFile() ? [base] : [] as string[]; + if (!stat.isFile()) { + for await (const f of glob.scan({ cwd: base, onlyFiles: true })) { + if (f.startsWith(".git/") || f.includes("node_modules/")) continue; + files.push(path.join(base, f)); + if (files.length >= 5_000) break; + } + } + const hits: string[] = []; + for (const f of files) { + let text: string; + try { if (fs.statSync(f).size > 2_000_000) continue; text = await Bun.file(f).text(); } catch { continue; } + if (text.includes("\u0000")) continue; + text.split("\n").forEach((line, i) => { if (hits.length < 300 && re.test(line)) hits.push(`${path.relative(this.root, f)}:${i + 1}: ${line.slice(0, 300)}`); }); + if (hits.length >= 300) break; + } + return { text: hits.join("\n") || "No matches", isError: false }; + } + case "Bash": { + const timeout = Math.min(600_000, Math.max(1_000, Number(input.timeout_ms) || COMMAND_TIMEOUT_MS)); + const shell = process.platform === "win32" ? ["cmd", "/c", String(input.command)] : ["bash", "-c", String(input.command)]; + const child = Bun.spawn(shell, { cwd: this.root, stdout: "pipe", stderr: "pipe", env: this.opts.env }); + const onAbort = () => { try { child.kill(); } catch { /* gone */ } }; + this.abort?.signal.addEventListener("abort", onAbort); + const timer = setTimeout(onAbort, timeout); + const [stdout, stderr, code] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]); + clearTimeout(timer); + this.abort?.signal.removeEventListener("abort", onAbort); + const out = [stdout, stderr && `[stderr]\n${stderr}`].filter(Boolean).join("\n"); + return { text: clip(`exit ${code}\n${out}`), isError: code !== 0 }; + } + case "WebFetch": { + const url = String(input.url ?? ""); + if (!/^https?:\/\//i.test(url)) throw new Error("Only http(s) URLs can be fetched"); + const res = await fetch(url, { signal: AbortSignal.timeout(30_000), headers: { "User-Agent": "multiagents-api-agent" } }); + const type = res.headers.get("content-type") ?? ""; + let text = await res.text(); + if (/html/i.test(type)) text = htmlToText(text); + return { text: clip(`HTTP ${res.status} ${url}\n\n${text}`), isError: !res.ok }; + } + default: + return { text: `Unknown tool ${name}`, isError: true }; + } + } + + // --- MCP over stdio (newline-delimited JSON-RPC) --- + + private rpc(method: string, params: unknown, timeoutMs: number): Promise { + if (!this._alive) return Promise.reject(new Error("team tools server is not running")); + const id = ++this.rpcSeq; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { this.rpcWaiters.delete(id); reject(new Error(`${method} timed out`)); }, timeoutMs); + this.rpcWaiters.set(id, { resolve: (v) => { clearTimeout(timer); resolve(v); }, reject: (e) => { clearTimeout(timer); reject(e); } }); + this.write({ jsonrpc: "2.0", id, method, params }); + }); + } + + private notify(method: string, params: unknown): void { this.write({ jsonrpc: "2.0", method, params }); } + + private write(msg: unknown): void { + try { this.proc.stdin.write(JSON.stringify(msg) + "\n"); this.proc.stdin.flush(); } catch { /* exited */ } + } + + private async readStdout(): Promise { + const decoder = new TextDecoder(); + for await (const chunk of this.proc.stdout) { + this.stdoutBuf += decoder.decode(chunk, { stream: true }); + let nl; + while ((nl = this.stdoutBuf.indexOf("\n")) >= 0) { + const line = this.stdoutBuf.slice(0, nl).trim(); + this.stdoutBuf = this.stdoutBuf.slice(nl + 1); + if (!line) continue; + let msg: any; + try { msg = JSON.parse(line); } catch { continue; } + if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) { + const w = this.rpcWaiters.get(msg.id); + if (!w) continue; + this.rpcWaiters.delete(msg.id); + if (msg.error) w.reject(new Error(msg.error.message ?? "MCP error")); + else w.resolve(msg.result); + } else if (msg.id !== undefined && msg.method) { + // Server-initiated requests (roots, sampling…) are not supported + this.write({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "not supported" } }); + } + } + } + } + + private emit(event: ClaudeEvent): void { + this._lastActivity = Date.now(); + if (this.history.length < 500 && event.type !== "stream_event") this.history.push(event); + for (const l of this.listeners) { try { l(event); } catch { /* listener errors must not break the loop */ } } + } +} + +function parseArgs(raw: string): Record | null { + if (!raw.trim()) return {}; + try { + const v = JSON.parse(raw); + return v && typeof v === "object" && !Array.isArray(v) ? v : null; + } catch { + return null; + } +} + +function clip(s: string): string { + return s.length > TOOL_OUTPUT_MAX ? `${s.slice(0, TOOL_OUTPUT_MAX)}\n… (${s.length - TOOL_OUTPUT_MAX} more characters)` : s; +} + +/** Readable text from an HTML page (scripts, styles and tags dropped). */ +export function htmlToText(html: string): string { + return html + .replace(/<(script|style|noscript|svg)[\s\S]*?<\/\1>/gi, " ") + .replace(/|<\/(p|div|li|h[1-6]|tr|section|article)>/gi, "\n") + .replace(/<[^>]+>/g, " ") + .replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'") + .replace(/[ \t]+/g, " ") + .replace(/\n\s*\n+/g, "\n\n") + .trim(); +} diff --git a/orchestrator/claude-driver.ts b/orchestrator/claude-driver.ts index 1cff2ef..688e459 100644 --- a/orchestrator/claude-driver.ts +++ b/orchestrator/claude-driver.ts @@ -29,7 +29,7 @@ import type { Subprocess } from "bun"; import { log } from "../shared/utils.ts"; -type PipedSubprocess = Subprocess<"pipe", "pipe", "pipe">; +export type PipedSubprocess = Subprocess<"pipe", "pipe", "pipe">; const LOG_PREFIX = "claude-driver"; @@ -74,6 +74,31 @@ export interface ClaudeTurnResult { export type ClaudeEvent = { type: string; [key: string]: unknown }; export type ClaudeEventListener = (event: ClaudeEvent) => void; +/** + * A session that speaks Claude's stream-json dialect: the Claude CLI itself, + * and the API driver for OpenAI-compatible providers, which emits the same + * events so the timeline, live view, token accounting and approvals treat + * both alike. + */ +export interface ClaudeSession { + readonly alive: boolean; + readonly busy: boolean; + readonly pid: number; + readonly process: PipedSubprocess; + readonly sessionId: string | null; + readonly lastActivity: number; + onExit(cb: (code: number) => void): void; + onEvent(cb: ClaudeEventListener, opts?: { replay?: boolean }): void; + onPermissionRequest(handler: ClaudePermissionHandler): void; + /** Resolves true once the model has read the message, false if the session ended first. */ + send(text: string, opts?: { echoTimeoutMs?: number }): Promise; + interrupt(): Promise; + setModel(model: string | undefined): Promise; + setPermissionMode(mode: string): Promise; + close(timeoutMs?: number): Promise; + kill(): Promise; +} + /** A tool call the CLI wants approved (control_request subtype can_use_tool). */ export interface ClaudePermissionRequest { toolName: string; @@ -139,7 +164,7 @@ function hasToolResult(message: unknown): boolean { return Array.isArray(content) && content.some((block) => (block as { type?: string }).type === "tool_result"); } -export class ClaudeDriver { +export class ClaudeDriver implements ClaudeSession { private proc: PipedSubprocess; private readonly initTimeoutMs: number; private buffer = ""; diff --git a/orchestrator/delivery.ts b/orchestrator/delivery.ts index 6a76156..b7f2302 100644 --- a/orchestrator/delivery.ts +++ b/orchestrator/delivery.ts @@ -9,7 +9,7 @@ import type { BrokerClient } from "../shared/broker-client.ts"; import type { Message } from "../shared/types.ts"; import type { CodexDriver } from "./codex-driver.ts"; -import type { ClaudeDriver } from "./claude-driver.ts"; +import type { ClaudeSession } from "./claude-driver.ts"; import { log } from "../shared/utils.ts"; import { HUMAN_SENDER, HUMAN_SENDER_LABEL } from "../shared/constants.ts"; @@ -40,7 +40,8 @@ interface DriverSupervision { export type DriverSlot = | ({ kind: "codex"; driver: CodexDriver; threadId: string | null } & DriverSupervision) - | ({ kind: "claude"; driver: ClaudeDriver; inflight?: Set } & DriverSupervision); + // Claude CLI sessions and API agents (both speak Claude's stream-json events) + | ({ kind: "claude"; driver: ClaudeSession; inflight?: Set } & DriverSupervision); export type DeliveryOutcome = "dead" | "not_ready" | "busy" | "none" | "in_flight" | "steered" | "replied" | "sent" | "failed"; diff --git a/orchestrator/launcher.ts b/orchestrator/launcher.ts index f0b138e..f48bcca 100644 --- a/orchestrator/launcher.ts +++ b/orchestrator/launcher.ts @@ -17,9 +17,11 @@ import { DEFAULT_BROKER_PORT } from "../shared/constants.ts"; import { resolveBrokerPort } from "../shared/broker-url.ts"; import { FULL_PROTOCOL, SHORT_PROTOCOL } from "../adapters/protocol-text.ts"; import { getStructuredRolePractices } from "../adapters/role-practices.ts"; -import { ClaudeDriver } from "./claude-driver.ts"; +import { ClaudeDriver, type ClaudeSession } from "./claude-driver.ts"; +import { ApiDriver } from "./api-driver.ts"; +import { getProvider, loadProviders } from "../shared/providers.ts"; import { CodexDriver } from "./codex-driver.ts"; -import { CODEX_APPROVAL_POLICY, parseApprovalMode } from "./activity.ts"; +import { CODEX_APPROVAL_POLICY, CLAUDE_PERMISSION_MODE, parseApprovalMode } from "./activity.ts"; import * as path from "node:path"; import * as fs from "node:fs"; @@ -75,8 +77,8 @@ export interface LaunchResult { process: Subprocess; /** Present for Codex agents (driven over `codex app-server`). */ codexDriver?: CodexDriver; - /** Present for Claude agents (driven over stream-json stdin). */ - claudeDriver?: ClaudeDriver; + /** Present for Claude agents (driven over stream-json stdin) and API agents (same events). */ + claudeDriver?: ClaudeSession; /** Model, effort and approval mode the agent was launched with */ settings?: { model?: string; effort?: string; approvalMode?: ApprovalMode }; } @@ -86,6 +88,7 @@ const AGENT_COMMANDS: Record = { claude: "claude", codex: "codex", gemini: "npx", + api: "", custom: "", }; @@ -100,6 +103,11 @@ const COMMAND_OVERRIDES: Partial> = { * Handles both direct binaries (claude, codex) and npx-based tools (gemini). */ export async function detectAgent(type: AgentType): Promise { + // API agents run in-process: available once a provider is configured + if (type === "api") { + const providers = loadProviders(); + return providers.length ? { available: true, version: providers.map((p) => p.name).join(", "), path: "api" } : { available: false }; + } const cmd = AGENT_COMMANDS[type]; if (!cmd) { return { available: false }; @@ -312,10 +320,12 @@ interface AgentIdentity { model?: string; effort?: string; approvalMode?: ApprovalMode; + /** API agents: the provider to call */ + providerId?: string; } /** Model, effort and approval mode stored in a slot's snapshot. */ -export function slotSettings(slot: Pick): { model?: string; effort?: string; approvalMode: ApprovalMode; tokenLimit?: number } { +export function slotSettings(slot: Pick): { model?: string; effort?: string; approvalMode: ApprovalMode; tokenLimit?: number; providerId?: string } { let snap: Record = {}; try { snap = JSON.parse(slot.context_snapshot ?? "{}") ?? {}; } catch { /* no snapshot */ } const text = (v: unknown) => (typeof v === "string" && v.trim() ? v.trim() : undefined); @@ -325,6 +335,7 @@ export function slotSettings(slot: Pick): { model?: st effort: text(snap.effort), approvalMode: parseApprovalMode(snap.approval_mode), tokenLimit: Number.isFinite(limit) && limit > 0 ? limit : undefined, + providerId: text(snap.provider_id), }; } @@ -551,6 +562,47 @@ async function spawnClaudeAgent( return { slotId: identity.slotId, pid: driver.pid, process: driver.process, claudeDriver: driver, settings: launchSettings(identity) }; } +/** System prompt of an API agent: who it is, how its tools work, the team protocol. */ +export function apiSystemPrompt(identity: AgentIdentity, providerName: string): string { + return [ + `You are "${identity.name}", role: ${identity.role}, a member of a multi-agent team. You run on ${providerName}.`, + identity.roleDescription, + "", + `Your working directory is ${identity.projectDir}; file tools (Read, Write, Edit, LS, Glob, Grep) and Bash work inside it. WebFetch reads web pages.`, + "Team tools (send_message, check_messages, set_summary, signal_done, check_team_status, get_plan, update_plan, submit_feedback, approve, store_knowledge, query_knowledge…) are how you talk to teammates and the person — they only see what you send with them.", + "Teammate messages arrive as user messages starting with [Teammate messages]. To answer the person, use send_message with to_id \"human\".", + "Work in small steps: call a tool, read its result, decide the next step. Keep replies short; put details in files or messages.", + "", + claudeSystemPromptAppend(identity.role, identity.roleDescription), + ].join("\n"); +} + +/** Start an API agent (OpenAI-compatible provider) in-process; its first turn runs in the background. */ +async function spawnApiAgent(identity: AgentIdentity, firstPrompt: string, brokerClient: BrokerClient): Promise { + const provider = getProvider(identity.providerId); + if (!provider) throw new Error(`API provider "${identity.providerId ?? ""}" is not configured — add it under API providers first`); + const model = identity.model || provider.default_model; + if (!model) throw new Error(`Pick a model for ${provider.name} (no default model is set)`); + const server = claudeMcpConfig(identity, { driverMode: true }).mcpServers as Record }>; + const driver = await ApiDriver.spawn({ + cwd: identity.projectDir, + env: agentEnv(identity), + provider, + model, + effort: identity.effort, + mcpServer: server["multiagents-peer"]!, + systemPrompt: apiSystemPrompt(identity, provider.name), + firstPrompt, + permissionMode: CLAUDE_PERMISSION_MODE[identity.approvalMode ?? "bypass"], + }); + await brokerClient.updateSlot({ + id: identity.slotId, + context_patch: { provider_name: provider.name, last_summary: `Starting: ${firstPrompt.slice(0, 100)}`, last_status: "working", updated_at: Date.now() }, + }); + log(LOG_PREFIX, `API agent ${identity.name} on ${provider.name} (${model}) started`); + return { slotId: identity.slotId, pid: driver.pid, process: driver.process, claudeDriver: driver, settings: launchSettings(identity) }; +} + /** * Spawn a Codex agent driver. Two-phase startup: a short bootstrap turn gets * the thread id quickly (so messages can be forwarded), then the real task @@ -683,7 +735,10 @@ export async function launchAgent( const approvalMode = parseApprovalMode(config.approval_mode); await brokerClient.updateSlot({ id: slot.id, - context_patch: { model: config.model ?? null, effort: config.effort ?? null, approval_mode: approvalMode }, + context_patch: { + model: config.model ?? null, effort: config.effort ?? null, approval_mode: approvalMode, + ...(config.agent_type === "api" ? { provider_id: config.provider ?? null, provider_name: getProvider(config.provider)?.name ?? null } : {}), + }, }); if (config.file_ownership && config.file_ownership.length > 0) { @@ -716,9 +771,12 @@ export async function launchAgent( model: config.model || undefined, effort: config.effort || undefined, approvalMode, + providerId: config.provider || undefined, }; switch (config.agent_type) { + case "api": + return spawnApiAgent(identity, buildTaskPrompt(identity, config.initial_task, "driver"), brokerClient); case "claude": { const result = await spawnClaudeAgent(identity, buildTaskPrompt(identity, config.initial_task, "driver"), brokerClient); log(LOG_PREFIX, `Launched ${config.name} (PID ${result.pid}) in slot ${slot.id}`); @@ -788,6 +846,8 @@ export async function relaunchIntoSlot( } case "codex": return spawnCodexAgent(identity, buildHandoffPrompt(identity, handoffTask, "driver"), brokerClient); + case "api": + return spawnApiAgent(identity, buildHandoffPrompt(identity, handoffTask, "driver"), brokerClient); default: { const result = spawnProcessAgent(slot.agent_type, identity, buildHandoffPrompt(identity, handoffTask, "inbox")); log(LOG_PREFIX, `Relaunched ${identity.name} (PID ${result.pid}) into existing slot ${slot.id}`); diff --git a/orchestrator/monitor.ts b/orchestrator/monitor.ts index 7994684..3621a00 100644 --- a/orchestrator/monitor.ts +++ b/orchestrator/monitor.ts @@ -8,7 +8,7 @@ import type { Subprocess } from "bun"; import type { BrokerClient } from "../shared/broker-client.ts"; import type { CodexDriver, CodexNotification } from "./codex-driver.ts"; -import type { ClaudeDriver, ClaudeEvent } from "./claude-driver.ts"; +import type { ClaudeSession, ClaudeEvent } from "./claude-driver.ts"; import { log } from "../shared/utils.ts"; const LOG_PREFIX = "monitor"; @@ -357,7 +357,7 @@ export async function processEvent( * as a plain Claude process (summaries, token usage, working transition). */ export function monitorClaudeDriver( - driver: ClaudeDriver, + driver: ClaudeSession, slotId: number, sessionId: string, brokerClient: BrokerClient, diff --git a/orchestrator/recovery.ts b/orchestrator/recovery.ts index 2e9d2b6..598ad70 100644 --- a/orchestrator/recovery.ts +++ b/orchestrator/recovery.ts @@ -8,7 +8,7 @@ import type { BrokerClient } from "../shared/broker-client.ts"; import type { AgentEvent } from "./monitor.ts"; import type { CodexDriver } from "./codex-driver.ts"; -import type { ClaudeDriver } from "./claude-driver.ts"; +import type { ClaudeSession } from "./claude-driver.ts"; import { FLAP_THRESHOLD, FLAP_WINDOW_MS } from "../shared/constants.ts"; import { log, safeJsonParse, formatDuration } from "../shared/utils.ts"; import { relaunchIntoSlot, buildTeamContext, ENRICHED_PATH, codexDeveloperInstructions, slotSettings } from "./launcher.ts"; @@ -127,7 +127,7 @@ export interface RespawnResult { pid: number; process: import("bun").Subprocess; codexDriver?: CodexDriver; - claudeDriver?: ClaudeDriver; + claudeDriver?: ClaudeSession; settings?: { model?: string; effort?: string; approvalMode?: string }; } diff --git a/orchestrator/runtime.ts b/orchestrator/runtime.ts index e864452..2ced0e0 100644 --- a/orchestrator/runtime.ts +++ b/orchestrator/runtime.ts @@ -31,7 +31,7 @@ import { monitorProcess, monitorCodexDriver, monitorClaudeDriver, clearSlotTrack import { deliverPendingMessages, launchedSettings, type DriverSlot } from "./delivery.ts"; import { RespawnGuard } from "./respawn-guard.ts"; import type { CodexDriver } from "./codex-driver.ts"; -import type { ClaudeDriver } from "./claude-driver.ts"; +import type { ClaudeSession } from "./claude-driver.ts"; import { getTeamStatus, formatTeamStatusForDisplay } from "./progress.ts"; import { checkGuardrails, enforceGuardrails } from "./guardrails.ts"; import { handleAgentCrash, clearAllCrashHistory } from "./recovery.ts"; @@ -75,7 +75,7 @@ interface LaunchedAgent { pid: number; process: Subprocess; codexDriver?: CodexDriver; - claudeDriver?: ClaudeDriver; + claudeDriver?: ClaudeSession; settings?: { model?: string; effort?: string; approvalMode?: string }; } @@ -773,7 +773,8 @@ Each agent receives role-specific best practices, tool discovery hints, and comp items: { type: "object", properties: { - agent_type: { type: "string", enum: ["claude", "codex", "gemini"] }, + agent_type: { type: "string", enum: ["claude", "codex", "gemini", "api"], description: "api = an OpenAI-compatible provider configured in the dashboard (DeepSeek, Qwen, …); set provider." }, + provider: { type: "string", description: "For agent_type api: the provider id (see the dashboard's API providers)." }, name: { type: "string", description: "Display name for the agent, e.g., 'Alice' or 'Backend Engineer'" }, role: { type: "string", description: "Core role: 'Software Engineer', 'UI/UX Designer', 'QA Engineer', 'Code Reviewer'. Prefix with platform for specificity: 'Android Software Engineer', 'Web QA Engineer'." }, role_description: { type: "string", description: "Detailed role brief including platform (web/android/ios/cli/api), framework/stack, specific expertise, constraints, and acceptance criteria. The richer this is, the better the agent performs." }, @@ -847,7 +848,8 @@ Each agent receives role-specific best practices, tool discovery hints, and comp type: "object" as const, properties: { session_id: { type: "string" }, - agent_type: { type: "string", enum: ["claude", "codex", "gemini"] }, + agent_type: { type: "string", enum: ["claude", "codex", "gemini", "api"], description: "api = an OpenAI-compatible provider configured in the dashboard (DeepSeek, Qwen, …); set provider." }, + provider: { type: "string", description: "For agent_type api: the provider id (see the dashboard's API providers)." }, name: { type: "string", description: "Display name for the agent" }, role: { type: "string", description: "Core role: 'Software Engineer', 'UI/UX Designer', 'QA Engineer', 'Code Reviewer'. Prefix with platform for specificity." }, role_description: { type: "string", description: "Detailed role brief including platform, framework, expertise, constraints. The richer this is, the better the agent performs." }, @@ -1038,7 +1040,7 @@ export async function callOrchestratorTool(name: string, args: Record 0) { // Check what IS available on this machine - const allTypes: Array<"claude" | "codex" | "gemini"> = ["claude", "codex", "gemini"]; + const allTypes: Array<"claude" | "codex" | "gemini" | "api"> = ["claude", "codex", "gemini", "api"]; for (const t of allTypes) { const d = await detectAgent(t); if (d.available) availableTypes.add(t); @@ -1270,9 +1272,10 @@ export async function callOrchestratorTool(name: string, args: Record = { claude: 1000, // Also pushes a channel notification; headless sessions drop those codex: 300, // Aggressive: piggyback delivery depends on fast buffering gemini: 300, // Same as Codex — no push capability + api: 1000, // Driver-managed (the adapter runs in driver mode) custom: 500, // Reasonable default for unknown agents }; @@ -48,6 +49,7 @@ export const AGENT_ID_PREFIXES: Record = { claude: "cl", codex: "cx", gemini: "gm", + api: "ap", custom: "cu", }; diff --git a/shared/providers.ts b/shared/providers.ts new file mode 100644 index 0000000..1242eba --- /dev/null +++ b/shared/providers.ts @@ -0,0 +1,143 @@ +// ============================================================================ +// multiagents — OpenAI-compatible API providers ("Other" agents) +// ============================================================================ +// DeepSeek, Qwen, Kimi, GLM, OpenRouter, a local Ollama… anything that speaks +// the OpenAI chat-completions API can back an agent. Providers live in +// ~/.multiagents/providers.json (mode 0600) on this machine only; slots +// remember the provider's id, never its key. A key written as $NAME is read +// from that environment variable at call time. +// ============================================================================ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { homedir } from "node:os"; + +export interface ApiProvider { + id: string; + /** Display name: "DeepSeek", "Qwen"… */ + name: string; + /** Base URL up to (not including) /chat/completions, e.g. https://api.deepseek.com/v1 */ + base_url: string; + /** The key, or $ENV_NAME to read it from the environment; empty for keyless local servers */ + api_key: string; + default_model?: string; + /** Models seen on the provider's /models list (filled by "fetch models") */ + models?: string[]; + /** Extra fields merged into every request body, e.g. {"enable_search": true} for Qwen */ + extra_body?: Record; + /** USD per million input / output tokens, for cost and cost limits */ + input_price?: number; + output_price?: number; + /** Context window in tokens; older turns are trimmed to stay within it (default 64000) */ + context_tokens?: number; +} + +/** What the browser gets: the key masked. */ +export type ApiProviderView = Omit & { api_key_hint: string; key_from_env: string | null }; + +function providersFile(): string { + return process.env.MULTIAGENTS_PROVIDERS_FILE ?? path.join(process.env.HOME ?? homedir(), ".multiagents", "providers.json"); +} + +export function loadProviders(): ApiProvider[] { + try { + const raw = JSON.parse(fs.readFileSync(providersFile(), "utf-8")); + return Array.isArray(raw) ? raw.filter((p) => p && typeof p.id === "string" && typeof p.base_url === "string") : []; + } catch { + return []; + } +} + +function writeProviders(list: ApiProvider[]): void { + const file = providersFile(); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(list, null, 2), { mode: 0o600 }); + try { fs.chmodSync(file, 0o600); } catch { /* best effort on platforms without modes */ } +} + +export function getProvider(id: string | null | undefined): ApiProvider | null { + if (!id) return null; + return loadProviders().find((p) => p.id === id) ?? null; +} + +/** The key to send: a $NAME key is read from the environment. */ +export function resolveApiKey(p: ApiProvider): string { + const m = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(p.api_key.trim()); + return m ? process.env[m[1]!] ?? "" : p.api_key.trim(); +} + +export function maskProvider(p: ApiProvider): ApiProviderView { + const { api_key, ...rest } = p; + const env = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(api_key.trim())?.[1] ?? null; + const key = api_key.trim(); + // Long keys show their ends to tell them apart; short ones show nothing of themselves + const hint = env ? `$${env}` : !key ? "" : key.length >= 16 ? `${key.slice(0, 3)}…${key.slice(-4)}` : "••••"; + return { ...rest, key_from_env: env, api_key_hint: hint }; +} + +function slugId(name: string, taken: Set): string { + const base = name.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "provider"; + let id = base; + for (let i = 2; taken.has(id); i++) id = `${base}-${i}`; + return id; +} + +/** Normalise a base URL: no trailing slash, no /chat/completions suffix. */ +export function normalizeBaseUrl(url: string): string { + return url.trim().replace(/\/+$/, "").replace(/\/chat\/completions$/, ""); +} + +/** + * Add or update a provider. An update that leaves `api_key` empty keeps the + * stored key (the browser never sees it to send it back). + */ +export function saveProvider(input: Partial & { name: string; base_url: string }): ApiProvider { + const list = loadProviders(); + const existing = input.id ? list.find((p) => p.id === input.id) : undefined; + let base: URL; + try { base = new URL(normalizeBaseUrl(input.base_url)); } catch { throw new Error("The base URL is not a valid URL"); } + if (base.protocol !== "https:" && base.protocol !== "http:") throw new Error("The base URL must start with http:// or https://"); + const provider: ApiProvider = { + id: existing?.id ?? slugId(input.name, new Set(list.map((p) => p.id))), + name: input.name.trim() || "API", + base_url: normalizeBaseUrl(input.base_url), + api_key: input.api_key?.trim() ? input.api_key.trim() : existing?.api_key ?? "", + default_model: input.default_model?.trim() || undefined, + models: input.models ?? existing?.models, + extra_body: input.extra_body && Object.keys(input.extra_body).length ? input.extra_body : undefined, + input_price: positive(input.input_price), + output_price: positive(input.output_price), + context_tokens: positive(input.context_tokens), + }; + writeProviders(existing ? list.map((p) => (p.id === provider.id ? provider : p)) : [...list, provider]); + return provider; +} + +function positive(v: unknown): number | undefined { + const n = typeof v === "string" ? Number(v) : v; + return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : undefined; +} + +export function deleteProvider(id: string): boolean { + const list = loadProviders(); + const next = list.filter((p) => p.id !== id); + if (next.length === list.length) return false; + writeProviders(next); + return true; +} + +export function authHeaders(p: ApiProvider): Record { + const key = resolveApiKey(p); + return key ? { Authorization: `Bearer ${key}` } : {}; +} + +/** The provider's model list (GET /models), also remembered on the provider. */ +export async function fetchProviderModels(p: ApiProvider): Promise { + const res = await fetch(`${p.base_url}/models`, { headers: authHeaders(p), signal: AbortSignal.timeout(15_000) }); + if (!res.ok) throw new Error(`${p.name} /models: HTTP ${res.status}${res.status === 401 ? " (check the API key)" : ""}`); + const body = await res.json() as { data?: Array<{ id?: string }> }; + const models = (body.data ?? []).map((m) => m.id).filter((id): id is string => typeof id === "string").sort(); + const list = loadProviders(); + writeProviders(list.map((x) => (x.id === p.id ? { ...x, models } : x))); + return models; +} diff --git a/shared/types.ts b/shared/types.ts index bff0c0d..fe4fc82 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -10,7 +10,8 @@ export type PeerId = string; /** Supported agent CLI types */ -export type AgentType = "claude" | "codex" | "gemini" | "custom"; +/** "api": an OpenAI-compatible API provider (DeepSeek, Qwen, …) driven in-process, see orchestrator/api-driver.ts */ +export type AgentType = "claude" | "codex" | "gemini" | "api" | "custom"; /** Message types for routing and formatting */ export type MessageType = @@ -458,6 +459,8 @@ export interface AgentLaunchConfig { effort?: string; /** Who approves the agent's tool calls; default "bypass" (fully autonomous). */ approval_mode?: ApprovalMode; + /** For agent_type "api": the configured provider's id (shared/providers.ts) */ + provider?: string; } /** diff --git a/tests/api-agent.test.ts b/tests/api-agent.test.ts new file mode 100644 index 0000000..b5a8ce6 --- /dev/null +++ b/tests/api-agent.test.ts @@ -0,0 +1,200 @@ +/** + * "Other" agents: an OpenAI-compatible provider (a fake one here) runs an + * agent with the team tools and local tools, through a real broker and the + * web dashboard — streamed thinking, tool steps, messages to the person, + * token accounting, step approvals and a live model switch. + */ +import { test, expect, beforeAll, afterAll, describe } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +const BROKER_PORT = 17941; +const DASHBOARD_PORT = 17942; +const BROKER_URL = `http://127.0.0.1:${BROKER_PORT}`; +const DASHBOARD_URL = `http://127.0.0.1:${DASHBOARD_PORT}`; + +let brokerProc: import("bun").Subprocess | null = null; +let dashboardProc: import("bun").Subprocess | null = null; +let fakeApi: ReturnType | null = null; +let tmpDir = ""; +const requests: Array<{ model: string; messages: any[]; tools: any[]; reasoning_effort?: string }> = []; + +// --- A scripted OpenAI-compatible server --- +// The latest user message holds the script: THINK: | then actions +// TOOL:Read:, TOOL:Bash:, SAY: (send_message +// to the person). Each tool result advances to the next action; then a final reply. +function scriptFor(messages: any[]) { + let lastUser = -1; + for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === "user") { lastUser = i; break; } + const text = String(messages[lastUser]?.content ?? ""); + const done = messages.slice(lastUser + 1).filter((m) => m.role === "tool").length; + const actions = [...text.matchAll(/(TOOL:(\w+):([^\s|]+)|SAY:([^\s|]+))/g)].map((m) => m[4] + ? { name: "send_message", args: { to_id: "human", message: m[4].replace(/_/g, " ") } } + : { name: m[2]!, args: m[2] === "Bash" ? { command: m[3]!.replace(/_/g, " ") } : { file_path: m[3]! } }); + const think = done === 0 ? text.match(/THINK:([^|]+)/)?.[1]?.trim() : undefined; + return { action: actions[done], think, text }; +} + +function sse(chunks: unknown[]): Response { + const body = chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`).join("") + "data: [DONE]\n\n"; + return new Response(body, { headers: { "Content-Type": "text/event-stream" } }); +} + +async function post(p: string, body: unknown): Promise { + return (await fetch(`${BROKER_URL}${p}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })).json(); +} +async function api(action: string, body?: unknown): Promise<{ status: number; ok: boolean; result?: any; error?: string }> { + const res = await fetch(`${DASHBOARD_URL}/api/${action}`, body === undefined ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + return { status: res.status, ...(await res.json()) as any }; +} +async function waitFor(fn: () => Promise, timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const v = await fn(); + if (v) return v; + await Bun.sleep(150); + } + throw new Error("waitFor timed out"); +} +const activity = async (sid: string) => ((await (await fetch(`${DASHBOARD_URL}/api/activity?session_id=${encodeURIComponent(sid)}`)).json()) as any).result as any[]; + +beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "api-agent-")); + fakeApi = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + const url = new URL(req.url); + if (req.headers.get("authorization") !== "Bearer test-key") return Response.json({ error: { message: "bad key" } }, { status: 401 }); + if (url.pathname === "/v1/models") return Response.json({ data: [{ id: "fake-chat" }, { id: "fake-reasoner" }] }); + if (url.pathname !== "/v1/chat/completions") return new Response("not found", { status: 404 }); + const body = await req.json() as any; + requests.push(body); + const { action, think, text } = scriptFor(body.messages); + const chunks: unknown[] = []; + if (think) for (const w of think.split(/(?<= )/)) chunks.push({ choices: [{ delta: { reasoning_content: w } }] }); + if (action) { + const args = JSON.stringify(action.args); + chunks.push({ choices: [{ delta: { tool_calls: [{ index: 0, id: `call_${requests.length}`, function: { name: action.name, arguments: args.slice(0, 10) } }] } }] }); + chunks.push({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: args.slice(10) } }] } }] }); + } else { + chunks.push({ choices: [{ delta: { content: `done: ${text.slice(0, 40)}` } }] }); + } + chunks.push({ choices: [], usage: { prompt_tokens: 100, completion_tokens: 20 } }); + return sse(chunks); + }, + }); + + const env = { ...process.env, HOME: tmpDir, MULTIAGENTS_PORT: String(BROKER_PORT), MULTIAGENTS_DB: path.join(tmpDir, "peers.db") }; + brokerProc = Bun.spawn(["bun", new URL("../broker.ts", import.meta.url).pathname], { env, stdout: "ignore", stderr: "ignore" }); + await waitFor(async () => (await fetch(`${BROKER_URL}/health`).catch(() => null))?.ok); + dashboardProc = Bun.spawn(["bun", new URL("../dashboard/server.ts", import.meta.url).pathname], { + env: { ...env, MULTIAGENTS_WEB_PORT: String(DASHBOARD_PORT), MULTIAGENTS_NO_OPEN: "1", MULTIAGENTS_NO_DASHBOARD: "1" }, + stdout: "ignore", stderr: Bun.file(path.join(tmpDir, "dashboard.log")), + }); + await waitFor(async () => (await fetch(DASHBOARD_URL).catch(() => null))?.ok); +}, 30_000); + +afterAll(async () => { + if (process.env.DEBUG_API_AGENT) console.log(fs.readFileSync(path.join(tmpDir, "dashboard.log"), "utf-8").slice(-4000)); + dashboardProc?.kill(); + brokerProc?.kill(); + fakeApi?.stop(true); + await Bun.sleep(200); + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("API providers", () => { + test("a provider is saved without exposing its key, and lists its models", async () => { + const saved = await api("providers/save", { name: "Fake AI", base_url: `http://127.0.0.1:${fakeApi!.port}/v1/chat/completions`, api_key: "test-key", default_model: "fake-chat", input_price: 1, output_price: 2 }); + expect(saved.ok).toBe(true); + // A short key shows nothing of itself + expect(saved.result.provider).toMatchObject({ id: "fake-ai", base_url: `http://127.0.0.1:${fakeApi!.port}/v1`, api_key_hint: "••••" }); + expect(JSON.stringify((await api("providers/list")).result)).not.toContain("test-key"); + const stored = JSON.parse(fs.readFileSync(path.join(tmpDir, ".multiagents", "providers.json"), "utf-8")); + expect(stored[0].api_key).toBe("test-key"); + expect(fs.statSync(path.join(tmpDir, ".multiagents", "providers.json")).mode & 0o077).toBe(0); + + const models = await api("providers/models", { id: "fake-ai" }); + expect(models.result.models).toEqual(["fake-chat", "fake-reasoner"]); + // An update without a key keeps the saved one + expect((await api("providers/save", { id: "fake-ai", name: "Fake AI", base_url: `http://127.0.0.1:${fakeApi!.port}/v1`, api_key: "", default_model: "fake-chat", input_price: 1, output_price: 2 })).ok).toBe(true); + expect((await api("providers/models", { id: "fake-ai" })).ok).toBe(true); + expect((await api("agents/available")).result.api).toBe(true); + }, 30_000); + + test("an API agent needs a configured provider", async () => { + const r = await api("team/create", { project_dir: path.join(tmpDir, "nope"), session_name: "Nope", agents: [{ agent_type: "api", provider: "missing", name: "X", role: "Writer" }] }); + expect(r.status).toBe(400); + expect(r.error).toContain("pick an API provider"); + }, 15_000); +}); + +describe("an API agent at work", () => { + let sid = ""; + let slotId = 0; + + test("it thinks, reads a project file, messages the person, and its tokens and cost are counted", async () => { + const dir = path.join(tmpDir, "proj"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "notes.md"), "cohort: 1024 participants\n"); + const r = await api("team/create", { + project_dir: dir, session_name: "API Team", goal: "Review the notes", + agents: [{ agent_type: "api", provider: "fake-ai", name: "Editor", role: "Editor", initial_task: "THINK:read the notes before answering | TOOL:Read:notes.md SAY:notes_look_fine" }], + workflow: {}, + }); + expect(r.ok).toBe(true); + sid = r.result.data.session_id; + const [slot] = await post("/slots/list", { session_id: sid }); + slotId = slot.id; + expect(slot.agent_type).toBe("api"); + expect(JSON.parse(slot.context_snapshot)).toMatchObject({ provider_id: "fake-ai", provider_name: "Fake AI" }); + + const reply = await waitFor(async () => (await post("/message-log", { session_id: sid, limit: 50 })).find((m: any) => m.to_id === "human" && m.from_slot_id === slotId), 20_000); + expect(reply.text).toBe("notes look fine"); + + const steps = await waitFor(async () => { + const list = await activity(sid); + return list.some((e) => e.kind === "turn_end") ? list : null; + }); + expect(steps.some((e) => e.kind === "thinking" && /read the notes/.test(e.detail ?? ""))).toBe(true); + expect(steps.some((e) => e.title === "Read notes.md")).toBe(true); + expect(steps.find((e) => e.kind === "tool_result" && /cohort/.test(e.detail ?? ""))).toBeTruthy(); + + // Team tools reach the model under their own names, next to the local ones + const names = requests[0]!.tools.map((t: any) => t.function.name); + expect(names).toEqual(expect.arrayContaining(["Read", "Bash", "send_message", "signal_done"])); + expect(requests[0]!.model).toBe("fake-chat"); + + const counted = await waitFor(async () => { + const s = await post("/slots/get", { id: slotId }); + return s.output_tokens >= 60 ? s : null; + }); + expect(counted.input_tokens).toBeGreaterThanOrEqual(300); + expect(counted.cost_usd).toBeGreaterThan(0); + }, 40_000); + + test("in ask mode a command waits for the person; a denial reaches the model", async () => { + expect((await api("agents/settings", { session_id: sid, slot_id: slotId, approval_mode: "ask" })).ok).toBe(true); + await Bun.sleep(3_500); // the next delivery tick applies the mode + await api("messages/send", { session_id: sid, slot_id: slotId, text: "TOOL:Bash:rm_-rf_build" }); + const pending = await waitFor(async () => { + const list = await post("/approvals/list", { session_id: sid, status: "pending" }); + return list.length ? list : null; + }); + expect(pending[0]).toMatchObject({ tool: "Bash", title: "$ rm -rf build", slot_id: slotId }); + expect((await api("approvals/decide", { session_id: sid, approval_id: pending[0].id, decision: "deny", note: "not now" })).ok).toBe(true); + await waitFor(async () => (await activity(sid)).some((e) => e.kind === "tool_result" && /denied: A person denied this tool call: not now/.test(e.detail ?? ""))); + }, 40_000); + + test("a model switch applies to the next request", async () => { + expect((await api("agents/settings", { session_id: sid, slot_id: slotId, model: "fake-reasoner", approval_mode: "bypass" })).ok).toBe(true); + await Bun.sleep(3_500); + const before = requests.length; + await api("messages/send", { session_id: sid, slot_id: slotId, text: "what model are you?" }); + await waitFor(async () => requests.length > before); + expect(requests.at(-1)!.model).toBe("fake-reasoner"); + expect((await api("session/end", { session_id: sid })).ok).toBe(true); + }, 30_000); +}); diff --git a/tests/live-claude.test.ts b/tests/live-claude.test.ts index 5f30cbd..8a6877d 100644 --- a/tests/live-claude.test.ts +++ b/tests/live-claude.test.ts @@ -108,7 +108,7 @@ describe.skipIf(!LIVE)("live: Claude driver end-to-end", () => { const unacked = await post("/peek-undelivered", { slot_id: launched.slotId }); expect(unacked.count).toBe(0); } finally { - console.log(`[live] session=${driver.sessionId} turns=${driver.turnsCompleted}\n[live] observed events:\n${observed.join("\n")}`); + console.log(`[live] session=${driver.sessionId} turns=${(driver as { turnsCompleted?: number }).turnsCompleted}\n[live] observed events:\n${observed.join("\n")}`); const code = await driver.close(30_000); expect(code).toBe(0); } From 9c92c74107710564aab10eb657c5d546d80d3588 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 11:05:16 +0000 Subject: [PATCH 14/24] Say that an Antigravity login does not count for Gemini's quota, and how to sign in Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SP5EAuSkQZm3jj3PTTWDfN --- dashboard/i18n.js | 2 +- orchestrator/plan-usage.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dashboard/i18n.js b/dashboard/i18n.js index 94683a6..ead9681 100644 --- a/dashboard/i18n.js +++ b/dashboard/i18n.js @@ -478,7 +478,7 @@ const ZH = { "Sign in to Codex with ChatGPT to see its limits (an API key has none).": "用 ChatGPT 账号登录 Codex 后才能看到限额(API key 没有这类限额)。", "Codex reported no usage windows for this account.": "Codex 没有返回此账号的用量窗口。", "Gemini runs on an API key; Google does not report its quota.": "Gemini 使用 API key 运行,Google 不提供其额度信息。", - "Sign in to Gemini CLI with Google to see its quota.": "用 Google 账号登录 Gemini CLI 后才能看到额度。", + "Sign in to Gemini CLI with Google to see its quota (an Antigravity login is separate): run `npx @google/gemini-cli` once and choose Login with Google.": "需要用 Google 账号登录 Gemini CLI 才能看到额度(Antigravity 的登录和它是分开的):在终端运行一次 `npx @google/gemini-cli`,选择 Login with Google。", "The Gemini login has expired; it refreshes the next time Gemini CLI runs.": "Gemini 登录凭据已过期,下次运行 Gemini CLI 时会自动刷新。", "Gemini did not report a Code Assist project for this login.": "Gemini 没有返回此登录对应的 Code Assist 项目。", "Gemini reported no quota buckets for this login.": "Gemini 没有返回此登录的额度信息。", diff --git a/orchestrator/plan-usage.ts b/orchestrator/plan-usage.ts index 7df472a..6e86265 100644 --- a/orchestrator/plan-usage.ts +++ b/orchestrator/plan-usage.ts @@ -278,11 +278,11 @@ async function probeGemini(): Promise { const file = Bun.file(join(process.env.GEMINI_CLI_HOME ?? homedir(), ".gemini", "oauth_creds.json")); if (!(await file.exists())) { return result("gemini", { - note: process.env.GEMINI_API_KEY ? "Gemini runs on an API key; Google does not report its quota." : "Sign in to Gemini CLI with Google to see its quota.", + note: process.env.GEMINI_API_KEY ? "Gemini runs on an API key; Google does not report its quota." : "Sign in to Gemini CLI with Google to see its quota (an Antigravity login is separate): run `npx @google/gemini-cli` once and choose Login with Google.", }); } const creds = await file.json().catch(() => null) as { access_token?: string; expiry_date?: number } | null; - if (!creds?.access_token) return result("gemini", { note: "Sign in to Gemini CLI with Google to see its quota." }); + if (!creds?.access_token) return result("gemini", { note: "Sign in to Gemini CLI with Google to see its quota (an Antigravity login is separate): run `npx @google/gemini-cli` once and choose Login with Google." }); if (typeof creds.expiry_date === "number" && creds.expiry_date < Date.now() + 30_000) { return result("gemini", { note: "The Gemini login has expired; it refreshes the next time Gemini CLI runs." }); } From b5faf01dbd064566d0978403cf10d80231c94352 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 11:08:37 +0000 Subject: [PATCH 15/24] API agents: keep writes in the project, register as "api", keep a model - A new file whose parent directories do not exist yet is resolved through its nearest existing ancestor, so a symlinked directory in the project cannot carry a Write outside it. - The team-tools adapter of an API agent registers as "api" (not "claude"), so peer lists and type filters report it correctly. - Clearing an API agent's model is refused when the provider has no default model, since a restarted agent could not launch without one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SP5EAuSkQZm3jj3PTTWDfN --- adapters/claude-adapter.ts | 7 ++++--- dashboard/control.ts | 6 ++++++ orchestrator/api-driver.ts | 25 ++++++++++++++++++++++--- orchestrator/launcher.ts | 6 +++--- server.ts | 6 ++++++ tests/api-agent.test.ts | 28 ++++++++++++++++++++++++++++ 6 files changed, 69 insertions(+), 9 deletions(-) diff --git a/adapters/claude-adapter.ts b/adapters/claude-adapter.ts index f7f8a8b..d66ca74 100644 --- a/adapters/claude-adapter.ts +++ b/adapters/claude-adapter.ts @@ -10,11 +10,12 @@ import { BaseAdapter } from "./base-adapter.ts"; import { ALL_TOOLS } from "./protocol-text.ts"; -import type { BufferedMessage } from "../shared/types.ts"; +import type { AgentType, BufferedMessage } from "../shared/types.ts"; export class ClaudeAdapter extends BaseAdapter { - constructor() { - super("claude"); + /** API agents use this adapter too (driver mode), registering as "api" */ + constructor(agentType: AgentType = "claude") { + super(agentType); } getCapabilities(): Record { diff --git a/dashboard/control.ts b/dashboard/control.ts index 2467f97..b65deb8 100644 --- a/dashboard/control.ts +++ b/dashboard/control.ts @@ -480,6 +480,12 @@ const handlers: Record = { const session = await requireSession(ctx, body); const slot = await requireSlot(ctx, session, body); const patch: Record = parseSettings(body, slot.agent_type); + if (slot.agent_type === "api" && patch.model === null) { + // Without a model a restarted agent could not launch: fall back to the provider's default, or refuse + const providerId = safeJsonParse>(slot.context_snapshot, {}).provider_id; + const p = getProvider(typeof providerId === "string" ? providerId : null); + if (!p?.default_model) throw new ApiError(`${p?.name ?? "This provider"} has no default model; keep a model set`); + } const limit = positiveOrNull(body, "token_limit"); if (limit !== undefined) patch.token_limit = limit === null ? null : Math.round(limit); if (!Object.keys(patch).length) throw new ApiError("Nothing to change"); diff --git a/orchestrator/api-driver.ts b/orchestrator/api-driver.ts index a7ecaa1..f604ba2 100644 --- a/orchestrator/api-driver.ts +++ b/orchestrator/api-driver.ts @@ -469,11 +469,10 @@ export class ApiDriver implements ClaudeSession { private resolve(p: unknown, mustExist = true): string { if (typeof p !== "string" || !p.trim()) throw new Error("A path is required"); const abs = path.resolve(this.root, p); - let real = abs; + let real: string; try { real = fs.realpathSync(abs); } catch { if (mustExist) throw new Error(`${p} does not exist`); - // A new file: its directory decides - try { real = path.join(fs.realpathSync(path.dirname(abs)), path.basename(abs)); } catch { real = abs; } + real = realThroughExistingAncestor(abs); } if (real !== this.root && !real.startsWith(this.root + path.sep)) throw new Error(`${p} is outside the project directory`); return real; @@ -624,6 +623,26 @@ export class ApiDriver implements ClaudeSession { } } +/** + * Where a path that does not exist yet would land: the nearest existing + * ancestor resolved through its symlinks, plus the missing rest. A symlinked + * directory higher up cannot smuggle a new file out of the project. + */ +export function realThroughExistingAncestor(abs: string): string { + const missing: string[] = []; + let dir = abs; + for (;;) { + try { + return path.join(fs.realpathSync(dir), ...missing.reverse()); + } catch { + const parent = path.dirname(dir); + if (parent === dir) return abs; + missing.push(path.basename(dir)); + dir = parent; + } + } +} + function parseArgs(raw: string): Record | null { if (!raw.trim()) return {}; try { diff --git a/orchestrator/launcher.ts b/orchestrator/launcher.ts index f48bcca..d4db048 100644 --- a/orchestrator/launcher.ts +++ b/orchestrator/launcher.ts @@ -472,8 +472,8 @@ function buildHandoffPrompt(identity: AgentIdentity, handoffTask: string, delive * `alwaysLoad` keeps the team tools out of tool-search deferral, and * `--driver-mode` tells the adapter the orchestrator delivers messages. */ -export function claudeMcpConfig(identity: AgentIdentity, opts: { driverMode: boolean }): Record { - const claudeMcp = mcpServerCommand("claude"); +export function claudeMcpConfig(identity: AgentIdentity, opts: { driverMode: boolean; agentType?: AgentType }): Record { + const claudeMcp = mcpServerCommand(opts.agentType ?? "claude"); const args = [ ...claudeMcp.args, "--session", identity.sessionId, @@ -583,7 +583,7 @@ async function spawnApiAgent(identity: AgentIdentity, firstPrompt: string, broke if (!provider) throw new Error(`API provider "${identity.providerId ?? ""}" is not configured — add it under API providers first`); const model = identity.model || provider.default_model; if (!model) throw new Error(`Pick a model for ${provider.name} (no default model is set)`); - const server = claudeMcpConfig(identity, { driverMode: true }).mcpServers as Record }>; + const server = claudeMcpConfig(identity, { driverMode: true, agentType: "api" }).mcpServers as Record }>; const driver = await ApiDriver.spawn({ cwd: identity.projectDir, env: agentEnv(identity), diff --git a/server.ts b/server.ts index 9dbffd3..df14b52 100755 --- a/server.ts +++ b/server.ts @@ -35,6 +35,12 @@ async function main() { await new GeminiAdapter().start(); break; } + case "api": { + // API agents get Claude's team tools in driver mode, registered under their own type + const { ClaudeAdapter } = await import("./adapters/claude-adapter.ts"); + await new ClaudeAdapter("api").start(); + break; + } default: { // For 'custom' or unknown types, fall back to Claude adapter behavior const { ClaudeAdapter } = await import("./adapters/claude-adapter.ts"); diff --git a/tests/api-agent.test.ts b/tests/api-agent.test.ts index b5a8ce6..4290c12 100644 --- a/tests/api-agent.test.ts +++ b/tests/api-agent.test.ts @@ -8,6 +8,7 @@ import { test, expect, beforeAll, afterAll, describe } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { realThroughExistingAncestor } from "../orchestrator/api-driver.ts"; const BROKER_PORT = 17941; const DASHBOARD_PORT = 17942; @@ -162,6 +163,10 @@ describe("an API agent at work", () => { expect(steps.some((e) => e.title === "Read notes.md")).toBe(true); expect(steps.find((e) => e.kind === "tool_result" && /cohort/.test(e.detail ?? ""))).toBeTruthy(); + // Its team-tools process registers under the agent's own type + const peers = await post("/list-peers", { scope: "machine", cwd: dir, git_root: null, agent_type: "api" }); + expect(peers.some((p: any) => p.slot_id === slotId)).toBe(true); + // Team tools reach the model under their own names, next to the local ones const names = requests[0]!.tools.map((t: any) => t.function.name); expect(names).toEqual(expect.arrayContaining(["Read", "Bash", "send_message", "signal_done"])); @@ -188,6 +193,16 @@ describe("an API agent at work", () => { await waitFor(async () => (await activity(sid)).some((e) => e.kind === "tool_result" && /denied: A person denied this tool call: not now/.test(e.detail ?? ""))); }, 40_000); + test("the model can be cleared only when the provider has a default", async () => { + const base = `http://127.0.0.1:${fakeApi!.port}/v1`; + await api("providers/save", { id: "fake-ai", name: "Fake AI", base_url: base, default_model: "", input_price: 1, output_price: 2 }); + const refused = await api("agents/settings", { session_id: sid, slot_id: slotId, model: "" }); + expect(refused.status).toBe(400); + expect(refused.error).toContain("no default model"); + await api("providers/save", { id: "fake-ai", name: "Fake AI", base_url: base, default_model: "fake-chat", input_price: 1, output_price: 2 }); + expect((await api("agents/settings", { session_id: sid, slot_id: slotId, model: "" })).ok).toBe(true); + }); + test("a model switch applies to the next request", async () => { expect((await api("agents/settings", { session_id: sid, slot_id: slotId, model: "fake-reasoner", approval_mode: "bypass" })).ok).toBe(true); await Bun.sleep(3_500); @@ -198,3 +213,16 @@ describe("an API agent at work", () => { expect((await api("session/end", { session_id: sid })).ok).toBe(true); }, 30_000); }); + +describe("local tools stay in the project", () => { + test("a new file under a symlinked directory resolves to where it would really land", () => { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "api-root-"))); + const outside = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "api-outside-"))); + fs.symlinkSync(outside, path.join(root, "link")); + // Neither link/new nor link/new/file exists: the symlink still decides + expect(realThroughExistingAncestor(path.join(root, "link", "new", "file.md"))).toBe(path.join(outside, "new", "file.md")); + expect(realThroughExistingAncestor(path.join(root, "plain", "new.md"))).toBe(path.join(root, "plain", "new.md")); + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + }); +}); From 590c7d8da065245037277ff9c9de84e4614a05c0 Mon Sep 17 00:00:00 2001 From: Zeyu LIu Date: Thu, 22 Oct 2026 19:30:10 +0800 Subject: [PATCH 16/24] feat: add daemon mode (-d), antigravity cli support, and proxy integration --- cli/commands.ts | 45 ++++++++++++- dashboard/app.js | 10 ++- orchestrator/launcher.ts | 133 ++++++++++++++++++++++++++++----------- orchestrator/runtime.ts | 118 +++++++++++++++++----------------- package.json | 4 +- tests/launcher.test.ts | 27 +++----- 6 files changed, 220 insertions(+), 117 deletions(-) diff --git a/cli/commands.ts b/cli/commands.ts index 06b3b7b..10c9504 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -181,10 +181,53 @@ export async function runCli(args: string[]): Promise { break; } + case "restart": case "web": { + const isRestart = command === "restart" || args.includes("--restart") || args.includes("-r"); + if (isRestart) { + const { listeningPids } = await import("./update.ts"); + const webPort = parseInt(process.env.MULTIAGENTS_WEB_PORT ?? "7900", 10); + const brokerPort = BROKER_PORT; + const pids = new Set([ + ...(listeningPids(webPort) ?? []), + ...(listeningPids(brokerPort) ?? []), + ]); + if (pids.size > 0) { + console.log(`Stopping running instances (${Array.from(pids).join(", ")})...`); + for (const pid of pids) { + try { process.kill(parseInt(pid, 10), "SIGTERM"); } catch { /* ok */ } + } + await Bun.sleep(1000); + } + } // Launch web dashboard server (auto-opens browser) + const isDaemon = args.includes("--daemon") || args.includes("-d"); const serverPath = new URL("../dashboard/server.ts", import.meta.url).pathname; - const proc = Bun.spawn(["bun", serverPath, ...(args[1] ? [args[1]] : [])], { + const targetArgs = args.filter((a) => a !== "--restart" && a !== "-r" && a !== "restart" && a !== "web" && a !== "--daemon" && a !== "-d"); + const webPort = parseInt(process.env.MULTIAGENTS_WEB_PORT ?? "7900", 10); + + if (isDaemon) { + const path = await import("node:path"); + const fs = await import("node:fs"); + const { spawn } = await import("node:child_process"); + const logFile = path.join(process.env.HOME ?? "", ".local/share/multiagents/server.log"); + const logFd = fs.openSync(logFile, "a"); + const bunBin = process.execPath || "/Users/liuzeyu/.local/share/multiagents/bin/bun"; + const env = { ...process.env, http_proxy: "http://127.0.0.1:3067", https_proxy: "http://127.0.0.1:3067", all_proxy: "http://127.0.0.1:3067" }; + const child = spawn(bunBin, [serverPath, ...targetArgs], { + detached: true, + stdio: ["ignore", logFd, logFd], + env, + cwd: process.cwd(), + }); + child.unref(); + console.log(`multiagents web dashboard started in background (PID ${child.pid})`); + console.log(`URL: http://127.0.0.1:${webPort}`); + console.log(`Logs: ${logFile}`); + break; + } + + const proc = Bun.spawn(["bun", serverPath, ...targetArgs], { cwd: process.cwd(), stdio: ["inherit", "inherit", "inherit"], }); diff --git a/dashboard/app.js b/dashboard/app.js index bc1d547..bf4cf05 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -3003,8 +3003,14 @@ async function loadAvailability() { const AGENT_TYPE_CHOICES = ["claude", "codex", "gemini", "api"]; function typeOptions(selected, avail) { + const labels = { + claude: "claude", + codex: "codex", + gemini: "antigravity (gemini)", + api: tr("Other (OpenAI-compatible API)"), + }; return AGENT_TYPE_CHOICES.map((t) => { - const label = t === "api" ? tr("Other (OpenAI-compatible API)") : t; + const label = labels[t] || t; const missing = avail && avail[t] === false ? tr(t === "api" ? " (add a provider)" : " (not installed)") : ""; return ``; }).join(""); @@ -3067,7 +3073,7 @@ function newSessionModal() { const TEAM_PRESETS = { "Worker + Reviewer": [ - { agent_type: "gemini", name: "Gemini Worker", role: "Software Engineer", initial_task: "" }, + { agent_type: "gemini", name: "Antigravity Worker", role: "Software Engineer", initial_task: "" }, { agent_type: "claude", name: "Claude Reviewer", role: "Code Reviewer", initial_task: "" }, ], "Engineer + Reviewer + QA": [ diff --git a/orchestrator/launcher.ts b/orchestrator/launcher.ts index d4db048..5ad053b 100644 --- a/orchestrator/launcher.ts +++ b/orchestrator/launcher.ts @@ -87,7 +87,7 @@ export interface LaunchResult { const AGENT_COMMANDS: Record = { claude: "claude", codex: "codex", - gemini: "npx", + gemini: process.env.MULTIAGENTS_ANTIGRAVITY_PATH || process.env.MULTIAGENTS_GEMINI_PATH || "agy", api: "", custom: "", }; @@ -96,6 +96,7 @@ const AGENT_COMMANDS: Record = { const COMMAND_OVERRIDES: Partial> = { claude: "MULTIAGENTS_CLAUDE_COMMAND", codex: "MULTIAGENTS_CODEX_COMMAND", + gemini: "MULTIAGENTS_ANTIGRAVITY_PATH", }; /** @@ -115,8 +116,20 @@ export async function detectAgent(type: AgentType): Promise { const spawnEnv = { ...process.env, PATH: ENRICHED_PATH }; - // Gemini is invoked via npx — check if the package is available + // Gemini worker is upgraded to Antigravity CLI (agy) if (type === "gemini") { + const agyCmd = (process.env.MULTIAGENTS_ANTIGRAVITY_PATH || AGENT_COMMANDS.gemini || "agy").trim(); + const which = Bun.spawnSync(["which", agyCmd.split(/\s+/)[0]!], { env: spawnEnv }); + const binPath = new TextDecoder().decode(which.stdout).trim(); + if (which.exitCode === 0 && binPath) { + let version: string | undefined; + try { + const proc = Bun.spawnSync([binPath, "--version"], { env: spawnEnv }); + const out = new TextDecoder().decode(proc.stdout).trim(); + if (proc.exitCode === 0 && out) version = out.split("\n")[0]; + } catch { /* ok */ } + return { available: true, version: version || "antigravity", path: binPath }; + } try { const proc = Bun.spawnSync(["npx", "-y", "@google/gemini-cli", "--version"], { timeout: 15_000, @@ -236,32 +249,70 @@ export async function ensureMcpConfigs(projectDir: string, sessionId: string, ag await Bun.write(codexTomlPath, codexToml.trimEnd() + "\n" + codexEntry); } - // --- Gemini: ~/.gemini/settings.json --- - const geminiSettingsPath = path.join(process.env.HOME ?? "", ".gemini", "settings.json"); - if (writeGemini) try { - let geminiConfig: Record = {}; + // --- Antigravity & Gemini MCP configs --- + if (writeGemini) { + const geminiMcp = mcpServerCommand("gemini"); + const mcpEntry = { + command: geminiMcp.command, + args: geminiMcp.args, + ...(portOverride ? { env: { MULTIAGENTS_PORT: portOverride } } : {}), + }; + + // 1. Global Antigravity config: ~/.gemini/config/mcp_config.json try { - const existing = await Bun.file(geminiSettingsPath).text(); - geminiConfig = JSON.parse(existing); + const homeDir = process.env.HOME ?? ""; + const mcpConfigDir = path.join(homeDir, ".gemini", "config"); + if (!fs.existsSync(mcpConfigDir)) fs.mkdirSync(mcpConfigDir, { recursive: true }); + const mcpConfigPath = path.join(mcpConfigDir, "mcp_config.json"); + let mcpConfig: Record = {}; + try { + const existing = await Bun.file(mcpConfigPath).text(); + mcpConfig = JSON.parse(existing); + } catch { /* ok */ } + + const mcpServers = (mcpConfig.mcpServers as Record) ?? {}; + delete mcpServers["multiagents"]; + mcpServers["multiagents-peer"] = mcpEntry; + mcpConfig.mcpServers = mcpServers; + await Bun.write(mcpConfigPath, JSON.stringify(mcpConfig, null, 2)); } catch { /* ok */ } - const geminiMcpServers = (geminiConfig.mcpServers as Record) ?? {}; - // Remove stale "multiagents" entries from older versions - if (geminiMcpServers["multiagents"] && !geminiMcpServers["multiagents-peer"]) { - delete geminiMcpServers["multiagents"]; - } - if (!geminiMcpServers["multiagents-peer"]) { - const geminiMcp = mcpServerCommand("gemini"); - geminiMcpServers["multiagents-peer"] = { - command: geminiMcp.command, - args: geminiMcp.args, - ...(portOverride ? { env: { MULTIAGENTS_PORT: portOverride } } : {}), - }; - geminiConfig.mcpServers = geminiMcpServers; - await Bun.write(geminiSettingsPath, JSON.stringify(geminiConfig, null, 2)); - } - } catch { - // Gemini config is optional — skip if ~/.gemini doesn't exist + // 2. Project-level Antigravity config: /.agents/mcp_config.json + try { + const agentsDir = path.join(projectDir, ".agents"); + if (!fs.existsSync(agentsDir)) fs.mkdirSync(agentsDir, { recursive: true }); + const projMcpConfigPath = path.join(agentsDir, "mcp_config.json"); + let projMcpConfig: Record = {}; + try { + const existing = await Bun.file(projMcpConfigPath).text(); + projMcpConfig = JSON.parse(existing); + } catch { /* ok */ } + + const projMcpServers = (projMcpConfig.mcpServers as Record) ?? {}; + projMcpServers["multiagents-peer"] = mcpEntry; + projMcpConfig.mcpServers = projMcpServers; + await Bun.write(projMcpConfigPath, JSON.stringify(projMcpConfig, null, 2)); + } catch { /* ok */ } + + // 3. Backward-compatible ~/.gemini/settings.json + try { + const geminiSettingsPath = path.join(process.env.HOME ?? "", ".gemini", "settings.json"); + let geminiConfig: Record = {}; + try { + const existing = await Bun.file(geminiSettingsPath).text(); + geminiConfig = JSON.parse(existing); + } catch { /* ok */ } + + const geminiMcpServers = (geminiConfig.mcpServers as Record) ?? {}; + if (geminiMcpServers["multiagents"] && !geminiMcpServers["multiagents-peer"]) { + delete geminiMcpServers["multiagents"]; + } + if (!geminiMcpServers["multiagents-peer"]) { + geminiMcpServers["multiagents-peer"] = mcpEntry; + geminiConfig.mcpServers = geminiMcpServers; + await Bun.write(geminiSettingsPath, JSON.stringify(geminiConfig, null, 2)); + } + } catch { /* ok */ } } // --- Session file: .multiagents/session.json --- @@ -358,6 +409,16 @@ function agentEnv(identity: AgentIdentity): Record { MULTIAGENTS_NAME: identity.name, MULTIAGENTS_SLOT: String(identity.slotId), }; + // Automatically inject proxy if available locally to ensure Google Antigravity endpoints are reachable + if (!env.http_proxy && !env.HTTP_PROXY) { + const proxyUrl = "http://127.0.0.1:3067"; + env.http_proxy = proxyUrl; + env.https_proxy = proxyUrl; + env.HTTP_PROXY = proxyUrl; + env.HTTPS_PROXY = proxyUrl; + env.all_proxy = "socks5://127.0.0.1:3067"; + env.ALL_PROXY = "socks5://127.0.0.1:3067"; + } // A Claude Code parent marks its environment; a child session must not look nested. delete env.CLAUDECODE; delete env.CLAUDE_CODE_ENTRYPOINT; @@ -863,19 +924,19 @@ export async function relaunchIntoSlot( export function buildCliArgs(agentType: AgentType, task: string, opts: { model?: string } = {}): string[] { switch (agentType) { case "gemini": { - // Gemini CLI is invoked via npx. MCP is configured at user level - // (~/.gemini/settings.json) by ensureMcpConfigs. Use --approval-mode - // yolo for fully autonomous operation, --sandbox for safety, and - // --output-format stream-json for structured output parsing. - return [ - "-y", "@google/gemini-cli", - "--sandbox", - "--approval-mode", "yolo", + // Upgraded to Antigravity CLI (`agy`). + // Uses --dangerously-skip-permissions for fully autonomous operation + // and --output-format stream-json for structured output parsing. + const args = [ + "--dangerously-skip-permissions", "--output-format", "stream-json", - "--allowed-mcp-server-names", "multiagents-peer", - ...(opts.model ? ["--model", opts.model] : []), - "-p", task, ]; + const model = opts.model ?? process.env.MULTIAGENTS_ANTIGRAVITY_MODEL ?? process.env.MULTIAGENTS_GEMINI_MODEL; + if (model) { + args.push("--model", model); + } + args.push("-p", task); + return args; } case "custom": return [task]; diff --git a/orchestrator/runtime.ts b/orchestrator/runtime.ts index 2ced0e0..15d4db1 100644 --- a/orchestrator/runtime.ts +++ b/orchestrator/runtime.ts @@ -2001,68 +2001,70 @@ export function startBackgroundLoops(brokerClient: BrokerClient): void { } }), USAGE_LIMIT_CHECK_MS); - // Stuck agent nudge loop — every 45s, nudge agents silent for >1 min - setInterval(async () => { - for (const sessionId of activeProcesses.keys()) { - try { - // Agents waiting for the person's questions are silent on purpose - if (await qaModeOn(sessionId, brokerClient)) continue; - const slots = await brokerClient.listSlots(sessionId); - for (const slot of slots) { - if (slot.status !== "connected" || !slot.peer_id) continue; - if (slot.paused === true || (slot.paused as unknown as number) === 1) continue; - - // Skip agents in truly terminal task states — no point nudging them - const taskState = (slot as any).task_state; - if (taskState === "approved" || taskState === "released") continue; - // Note: done_pending_review agents are NOT skipped — they may need - // to respond to feedback or review requests from teammates. - - // Codex driver-managed agents get nudges delivered via the - // forwarding loop (steer or reply). Don't skip them — they need - // nudges too, especially to prompt signal_done after long turns. - - // Check last_connected or context_snapshot for activity - const lastActivity = (() => { - if (slot.context_snapshot) { - try { - const snap = JSON.parse(slot.context_snapshot); - if (snap.updated_at) return snap.updated_at; - } catch { /* ok */ } - } - return slot.last_connected ?? 0; - })(); + // Stuck agent nudge loop (disabled by default to prevent noisy spam; set MULTIAGENTS_ENABLE_NUDGE=1 to enable) + if (process.env.MULTIAGENTS_ENABLE_NUDGE === "1") { + setInterval(async () => { + for (const sessionId of activeProcesses.keys()) { + try { + // Agents waiting for the person's questions are silent on purpose + if (await qaModeOn(sessionId, brokerClient)) continue; + const slots = await brokerClient.listSlots(sessionId); + for (const slot of slots) { + if (slot.status !== "connected" || !slot.peer_id) continue; + if (slot.paused === true || (slot.paused as unknown as number) === 1) continue; + + // Skip agents in truly terminal task states — no point nudging them + const taskState = (slot as any).task_state; + if (taskState === "approved" || taskState === "released") continue; + // Note: done_pending_review agents are NOT skipped — they may need + // to respond to feedback or review requests from teammates. + + // Codex driver-managed agents get nudges delivered via the + // forwarding loop (steer or reply). Don't skip them — they need + // nudges too, especially to prompt signal_done after long turns. + + // Check last_connected or context_snapshot for activity + const lastActivity = (() => { + if (slot.context_snapshot) { + try { + const snap = JSON.parse(slot.context_snapshot); + if (snap.updated_at) return snap.updated_at; + } catch { /* ok */ } + } + return slot.last_connected ?? 0; + })(); - const silentMs = Date.now() - lastActivity; - if (silentMs > 1 * 60 * 1000) { - const agentName = slot.display_name || `slot ${slot.id}`; - const silentMin = Math.round(silentMs / 60000); + const silentMs = Date.now() - lastActivity; + if (silentMs > 1 * 60 * 1000) { + const agentName = slot.display_name || `slot ${slot.id}`; + const silentMin = Math.round(silentMs / 60000); - // Nudge the agent - await brokerClient.sendMessage({ - from_id: "orchestrator", - to_id: slot.peer_id, - text: `[NUDGE] You have been silent for ${silentMin} minutes. ` + - `Check your teammates with check_team_status and check_messages. ` + - `If you are done, call signal_done. If you are blocked, send a message to "orchestrator" explaining what you need.`, - msg_type: "system", - session_id: sessionId, - }); + // Nudge the agent + await brokerClient.sendMessage({ + from_id: "orchestrator", + to_id: slot.peer_id, + text: `[NUDGE] You have been silent for ${silentMin} minutes. ` + + `Check your teammates with check_team_status and check_messages. ` + + `If you are done, call signal_done. If you are blocked, send a message to "orchestrator" explaining what you need.`, + msg_type: "system", + session_id: sessionId, + }); - // Also alert the orchestrator user via pending events - pendingEvents.push({ - type: "agent_silent", - severity: silentMs > 5 * 60 * 1000 ? "warning" : "info", - slotId: slot.id, - sessionId, - message: `${agentName} (${slot.role || "unknown"}) silent for ${silentMin}m — task_state: ${(slot as any).task_state || "idle"}. May need intervention.`, - }); - log(LOG_PREFIX, `Nudged silent agent ${agentName} (${silentMin}m silent)`); + // Also alert the orchestrator user via pending events + pendingEvents.push({ + type: "agent_silent", + severity: silentMs > 5 * 60 * 1000 ? "warning" : "info", + slotId: slot.id, + sessionId, + message: `${agentName} (${slot.role || "unknown"}) silent for ${silentMin}m — task_state: ${(slot as any).task_state || "idle"}. May need intervention.`, + }); + log(LOG_PREFIX, `Nudged silent agent ${agentName} (${silentMin}m silent)`); + } } - } - } catch { /* ok */ } - } - }, 45_000); + } catch { /* ok */ } + } + }, 45_000); + } // Conflict detection loop — basic git status monitoring conflictTimer = setInterval(async () => { diff --git a/package.json b/package.json index 858b872..1fb45f7 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,9 @@ "version:major": "bun run scripts/version.ts major", "prepack": "bun test", "postinstall": "bun run scripts/postinstall.ts 2>/dev/null || true", - "preuninstall": "bun run scripts/preuninstall.ts 2>/dev/null || true" + "preuninstall": "bun run scripts/preuninstall.ts 2>/dev/null || true", + "web": "bun cli.ts web", + "restart": "bun cli.ts restart" }, "engines": { "bun": ">=1.1.0" diff --git a/tests/launcher.test.ts b/tests/launcher.test.ts index 98cc5e3..7ec991d 100644 --- a/tests/launcher.test.ts +++ b/tests/launcher.test.ts @@ -180,25 +180,14 @@ describe("buildCliArgs — claude and codex", () => { }); // --------------------------------------------------------------------------- -// buildCliArgs — Gemini +// buildCliArgs — Gemini (upgraded to Antigravity CLI) // --------------------------------------------------------------------------- describe("buildCliArgs — gemini", () => { const task = "Do something"; - test("starts with -y @google/gemini-cli for npx invocation", () => { + test("includes --dangerously-skip-permissions for autonomous operation", () => { const args = buildCliArgs("gemini", task); - expect(args[0]).toBe("-y"); - expect(args[1]).toBe("@google/gemini-cli"); - }); - - test("includes --sandbox", () => { - const args = buildCliArgs("gemini", task); - expect(args).toContain("--sandbox"); - }); - - test("includes --approval-mode yolo for autonomous operation", () => { - const args = buildCliArgs("gemini", task); - expect(getFlagValue(args, "--approval-mode")).toBe("yolo"); + expect(args).toContain("--dangerously-skip-permissions"); }); test("includes --output-format stream-json", () => { @@ -206,17 +195,17 @@ describe("buildCliArgs — gemini", () => { expect(getFlagValue(args, "--output-format")).toBe("stream-json"); }); - test("restricts MCP to multiagents only via --allowed-mcp-server-names", () => { - const args = buildCliArgs("gemini", task); - expect(getFlagValue(args, "--allowed-mcp-server-names")).toBe("multiagents-peer"); - }); - test("ends with -p and the task", () => { const args = buildCliArgs("gemini", task); const pIdx = args.lastIndexOf("-p"); expect(pIdx).toBeGreaterThan(-1); expect(args[pIdx + 1]).toBe(task); }); + + test("passes optional model flag when specified", () => { + const args = buildCliArgs("gemini", task, { model: "gemini-2.5-pro" }); + expect(getFlagValue(args, "--model")).toBe("gemini-2.5-pro"); + }); }); // --------------------------------------------------------------------------- From 99a875e83389d2e2755f32e34e9327c72048e12d Mon Sep 17 00:00:00 2001 From: Zeyu LIu Date: Thu, 22 Oct 2026 19:34:39 +0800 Subject: [PATCH 17/24] test: increase waitFor timeout in api-agent test to prevent CI flakes --- tests/api-agent.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/api-agent.test.ts b/tests/api-agent.test.ts index 4290c12..5e91b5d 100644 --- a/tests/api-agent.test.ts +++ b/tests/api-agent.test.ts @@ -152,7 +152,7 @@ describe("an API agent at work", () => { expect(slot.agent_type).toBe("api"); expect(JSON.parse(slot.context_snapshot)).toMatchObject({ provider_id: "fake-ai", provider_name: "Fake AI" }); - const reply = await waitFor(async () => (await post("/message-log", { session_id: sid, limit: 50 })).find((m: any) => m.to_id === "human" && m.from_slot_id === slotId), 20_000); + const reply = await waitFor(async () => (await post("/message-log", { session_id: sid, limit: 50 })).find((m: any) => m.to_id === "human" && m.from_slot_id === slotId), 35_000); expect(reply.text).toBe("notes look fine"); const steps = await waitFor(async () => { From 2244a6692f5cf46d82f42cb9d7736900d2547d87 Mon Sep 17 00:00:00 2001 From: Zeyu LIu Date: Thu, 22 Oct 2026 19:38:09 +0800 Subject: [PATCH 18/24] fix(ci): skip local proxy injection in CI and set loopback no_proxy --- orchestrator/launcher.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/orchestrator/launcher.ts b/orchestrator/launcher.ts index 5ad053b..697d751 100644 --- a/orchestrator/launcher.ts +++ b/orchestrator/launcher.ts @@ -409,15 +409,17 @@ function agentEnv(identity: AgentIdentity): Record { MULTIAGENTS_NAME: identity.name, MULTIAGENTS_SLOT: String(identity.slotId), }; - // Automatically inject proxy if available locally to ensure Google Antigravity endpoints are reachable - if (!env.http_proxy && !env.HTTP_PROXY) { - const proxyUrl = "http://127.0.0.1:3067"; + // In non-CI environments, inject proxy if needed and preserve localhost loopback + if (!process.env.CI && !env.http_proxy && !env.HTTP_PROXY) { + const proxyUrl = process.env.MULTIAGENTS_PROXY || "http://127.0.0.1:3067"; env.http_proxy = proxyUrl; env.https_proxy = proxyUrl; env.HTTP_PROXY = proxyUrl; env.HTTPS_PROXY = proxyUrl; env.all_proxy = "socks5://127.0.0.1:3067"; env.ALL_PROXY = "socks5://127.0.0.1:3067"; + env.no_proxy = "localhost,127.0.0.1,::1"; + env.NO_PROXY = "localhost,127.0.0.1,::1"; } // A Claude Code parent marks its environment; a child session must not look nested. delete env.CLAUDECODE; From 5972c0e29f7dbc3c065693400e45fea6452280d7 Mon Sep 17 00:00:00 2001 From: Zeyu LIu Date: Thu, 22 Oct 2026 19:42:48 +0800 Subject: [PATCH 19/24] feat: detect and display Antigravity tier for Gemini subscription --- orchestrator/plan-usage.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/orchestrator/plan-usage.ts b/orchestrator/plan-usage.ts index 6e86265..7072095 100644 --- a/orchestrator/plan-usage.ts +++ b/orchestrator/plan-usage.ts @@ -301,7 +301,15 @@ async function probeGemini(): Promise { metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" }, }); const project = load.cloudaicompanionProject ?? process.env.GOOGLE_CLOUD_PROJECT; - if (!project) return result("gemini", { plan: load.currentTier?.name ?? null, note: "Gemini did not report a Code Assist project for this login." }); + if (!project) { + const isAntigravity = Array.isArray(load.ineligibleTiers) && load.ineligibleTiers.some((t: any) => /antigravity/i.test(t.reasonMessage ?? "")); + const tierName = isAntigravity ? "Antigravity" : (load.currentTier?.name ?? load.allowedTiers?.[0]?.name ?? null); + return result("gemini", { + plan: tierName, + windows: [], + note: isAntigravity ? "Antigravity 订阅:由 Google Workspace/内部认证驱动,不设 5 小时/每周窗口限制。" : "Gemini did not report a Code Assist project for this login.", + }); + } const quota = await post("retrieveUserQuota", { project }); const windows = geminiWindows(quota); log(LOG_PREFIX, `Gemini quota: ${windows.length} bucket(s)`); From 91ccab869a59e436f90d335df8f8477715f5d28c Mon Sep 17 00:00:00 2001 From: Zeyu LIu Date: Thu, 22 Oct 2026 19:55:02 +0800 Subject: [PATCH 20/24] feat(ui): update Codex/ChatGPT avatar icon with official logo --- dashboard/app.js | 2 +- dashboard/chatgpt-icon.png | Bin 0 -> 3044 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 dashboard/chatgpt-icon.png diff --git a/dashboard/app.js b/dashboard/app.js index bf4cf05..88ddd26 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -1068,7 +1068,7 @@ const AGENT_ICONS = { gemini: { bg: "linear-gradient(135deg, #4285F4 0%, #8E75B2 60%, #D96570 100%)", svg: '' }, // Other (OpenAI-compatible API): a neutral plug; upload the provider's logo as an image if you like api: { bg: "linear-gradient(135deg, #475569, #0e7f86)", svg: '' }, - codex: { bg: "#1f2328", svg: '' }, + codex: { bg: "#10a37f", svg: '' }, }; const AVATAR_COLORS = ["#2f6fde", "#8250df", "#1a7f37", "#bc4c00", "#cf222e", "#0e7f86", "#d4a72c", "#6e7781", "#D97757", "#1f2328"]; diff --git a/dashboard/chatgpt-icon.png b/dashboard/chatgpt-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..07a375bffcda0882237410987119e4b25a65669f GIT binary patch literal 3044 zcmVL)zefBwLud~;mv6fY$*01Hc0SrU7_2fQJ9MxJ{$;@Js+f$VMt{aIIgip0FQ)+u0_Rm9`teQUG5DFciQM0D1!` z0cZhmDu7P`xDPj6CgEHF%B8-TCyq`TOj%K^NojMo9+l8En8_aNQA z%&;PE0MIMP*d&?z7Kejv|Z=08R^dkB_ms zO5Wq&03KwQInDl%3S0r;3IK05L$*e%#8vjSc3;i{K@J@;;>Q>Iz>KFF<-a zZ%{ee5}nhe-3s6U#8JIlm3xjy9pMk+;VDM&#Ym1>OzLR~2X--WA{+MPeRr_k>2e_T zFa*HOY&Tw0BIW}mfMD3fQySc@C`EVHq` zEBPX^k|`fQ0Qs>#hR%jp$EFrYs;8aku5` z-=GkTlZW#4ALpziye-sVN`MF#r}? zR@Dm`=$_leHB>1R4UI}Q$4wcpbrr}`(VsjR6NaY5D+?Ue&IbWJX&LWPOWt3)_m?3e zq+8eSkal$y$YRldl+uSL`Dd1iqbPn1{Au>f6!NgrC`S5`lEz&r`VB`!+#_!GLx*c! z1+qx=Pfl!4RzKS#f!v$&vK>sj+vG{c#1zOJ zi!dH{eA$zfd_}Uo$c(ga5yZO;)hKNo+eE=P9O?7C0-0^t8Ff((LrKy8I*VYQ;F!h- z%}gI@dsAW*S-d5HYOavG6a$%u8n2*{`6-(^e8AIPr4QxX%6O!HgX8n!CO?TRkXfQX z*|1Q!F@v+Phj}TNy(y-TsT^ao&~R~s$07^l`=bAq2#0BkD(nmxST-@?DtRyp9)sa1 zrT(ZXqCmbY`qQMua!{r-T`wmwPhi=qu*V@+`CHhOBS#?xLX%>{?V|rAgu}IB>=_9g z@bQ$ua*4yP-kX(G5_Av!tQ@h36v$qV5lnk5ZWaBBmI#GwQYv850!zw5X068Ak%Ml^ zL`%!Sif)<(ml|HKonKz=C%nZ`#koLoPUIQ61eWx1Wrd{U>7x#Y2HxP3ya6rCENJHC zZc+u$v$`m8{~6-{q-H{4k{J6tjyA_dAZLqfN$m&46`mQx3Pz~u$){tRJ{NA%ZAT!1( zljuq|f`hI*44YrdIhg?$qI6bdf!tkdlO0_4mbp4$xTGcTH zB4d%yAtFYnyzJ|o?rh4zq0O;uP0#|%1~#0gnqJL5RnyC&eJRmktMp5@^*L*lPFME0 z%fRwNE_7IJ>9>T9s!K6K9)VDp@E>vg+YkvQ5LP47oUA1ca=C6L)U6MIfL zU-5pD=*I!0N&jzQT~B43J{V)ED5>SCC8%pCWJtG8Eo4xP$e9xs2iW7T^o3FWR_z&Ix^Js+HknS z_C6LsASW6&nLZV(a?ym10o}U3{Ia_P)$~e-;A%d6pd8Ss_vf^O_RP=4tnD>nW`yNA zK;%bW&RNqt+H(8{#Yi7@ETg@Pk2YUq`FPgl*zS-ELll)l2eE{}$?RSA$#K2h?=ICz zmW}=2%AiV3b+nlvT1)@woEr<@0gDO=s(t`B*@o6Hr?eF0PGP z>!|_H+X{|WrT%COyMr+?Fl967hMdRkjaLHEx`BN7N&rVks)oh^Ox5hMWD+L_*aGm7 z7?*aE)fHq>b(tq^Y9OYwf%WxfiyJ#T&^c9u6{a9ha;;|eL~gdJw6z>K`oBBP4}nm|YGRSPpMV($dSh zWJvy8L|e+WJGcm~cZA;@dDStiSU2d$dF0SPs4_oN^~S-`^}hV@teMw?%)>#jmhz87m97d5O|@SKy@2JDHsgQPp3!C~@wY_@B=LxQg4w zT!@f#F^4N9p`FY&`;w1f`*V|Fw4IIp1TH;l;Xtwv=O%`+5SCkP*_+G&6h8s(fI%sTUi!A@avVOh&!ZOypIOdb z%;Gpq%V~=WB)qok4LhUK+TYpsM6Z|H7tz4Gwr=2`;#<$rb_d&>&0M@TmxWMwBw>L9 m1qu`>P@q780tE^nBK`}#X=e-P(6QD40000 Date: Thu, 22 Oct 2026 20:10:16 +0800 Subject: [PATCH 21/24] fix(dashboard): enable permanent deletion for released agent cards and fix i18n test --- dashboard/app.js | 13 ++++++++----- dashboard/control.ts | 4 ++++ dashboard/i18n.js | 2 ++ orchestrator/plan-usage.ts | 2 +- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/dashboard/app.js b/dashboard/app.js index 88ddd26..2a0cad8 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -1695,7 +1695,7 @@ function renderAgents() { const id = slot.id; const tokens = usageLine(slot); const actions = released || archived - ? `` + ? `` : ` ${paused ? `` : ``} @@ -1823,12 +1823,15 @@ async function agentAction(kind, slot, btn) { return; } case "remove": { + const isReleased = slot.task_state === "released"; const ok = await confirmDialog({ - title: tr("Remove {name}?", { name }), sid, - message: `

    ${tr("Remove stops the agent now (if this dashboard runs it), marks it released and tells the team it left. Unlike Release, it does not wait for the agent to finish.")}

    `, - confirmLabel: "Remove", danger: true, + title: isReleased ? tr("Delete \"{name}\" permanently?", { name }) : tr("Remove {name}?", { name }), sid, + message: isReleased + ? `

    ${tr("This permanently removes the seat and clears this card from the board.")}

    ` + : `

    ${tr("Remove stops the agent now (if this dashboard runs it), marks it released and tells the team it left. Unlike Release, it does not wait for the agent to finish.")}

    `, + confirmLabel: isReleased ? "Delete forever" : "Remove", danger: true, }); - if (ok) return run(btn, "Remove", () => api("agents/remove", { session_id: sid, slot_id: slot.id })); + if (ok) return run(btn, isReleased ? "Delete" : "Remove", () => api("agents/remove", { session_id: sid, slot_id: slot.id })); return; } } diff --git a/dashboard/control.ts b/dashboard/control.ts index b65deb8..66a4d59 100644 --- a/dashboard/control.ts +++ b/dashboard/control.ts @@ -437,6 +437,10 @@ const handlers: Record = { async "agents/remove"(ctx, body) { const session = await requireSession(ctx, body); const slot = await requireSlot(ctx, session, body); + if (slot.task_state === "released") { + await ctx.broker.deleteSlot(slot.id); + return { message: `Slot ${slot.id} (${slot.display_name ?? slot.role ?? "agent"}) permanently removed.` }; + } return await tool("remove_agent", { session_id: session.id, target: String(slot.id) }); }, diff --git a/dashboard/i18n.js b/dashboard/i18n.js index ead9681..f773aac 100644 --- a/dashboard/i18n.js +++ b/dashboard/i18n.js @@ -481,6 +481,8 @@ const ZH = { "Sign in to Gemini CLI with Google to see its quota (an Antigravity login is separate): run `npx @google/gemini-cli` once and choose Login with Google.": "需要用 Google 账号登录 Gemini CLI 才能看到额度(Antigravity 的登录和它是分开的):在终端运行一次 `npx @google/gemini-cli`,选择 Login with Google。", "The Gemini login has expired; it refreshes the next time Gemini CLI runs.": "Gemini 登录凭据已过期,下次运行 Gemini CLI 时会自动刷新。", "Gemini did not report a Code Assist project for this login.": "Gemini 没有返回此登录对应的 Code Assist 项目。", + "Antigravity subscription: workspace internal auth, no window limits.": "Antigravity 订阅:由 Google Workspace/内部认证驱动,不设窗口限制。", + "This permanently removes the seat and clears this card from the board.": "此操作将永久删除此席位并从版面上清除该卡片。", "Gemini reported no quota buckets for this login.": "Gemini 没有返回此登录的额度信息。", // --- Agent settings --- diff --git a/orchestrator/plan-usage.ts b/orchestrator/plan-usage.ts index 7072095..d7a44c4 100644 --- a/orchestrator/plan-usage.ts +++ b/orchestrator/plan-usage.ts @@ -307,7 +307,7 @@ async function probeGemini(): Promise { return result("gemini", { plan: tierName, windows: [], - note: isAntigravity ? "Antigravity 订阅:由 Google Workspace/内部认证驱动,不设 5 小时/每周窗口限制。" : "Gemini did not report a Code Assist project for this login.", + note: isAntigravity ? "Antigravity subscription: workspace internal auth, no window limits." : "Gemini did not report a Code Assist project for this login.", }); } const quota = await post("retrieveUserQuota", { project }); From e4e9673de213ed5033f1a057bc0b5fdb5831f453 Mon Sep 17 00:00:00 2001 From: Zeyu LIu Date: Thu, 22 Oct 2026 20:30:20 +0800 Subject: [PATCH 22/24] feat(dashboard): auto-save draft to localStorage for new team modal and composer, prevent accidental dismiss --- dashboard/app.js | 158 +++++++++++++++++++++++++++++++++++++++++----- dashboard/i18n.js | 3 + 2 files changed, 146 insertions(+), 15 deletions(-) diff --git a/dashboard/app.js b/dashboard/app.js index 2a0cad8..d53ba7f 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -1340,6 +1340,13 @@ function renderChat(force) { const firstRender = !lastChatSignature; lastChatSignature = sig; + const curSid = currentSid(); + if (curSid && !$("composer-text").value) { + try { + const saved = localStorage.getItem(`multiagents:draft:composer:${curSid}`); + if (saved) { $("composer-text").value = saved; autosize(); } + } catch {} + } if (!state.session) { feed.innerHTML = `
    ${state.brokerAlive ? `${tr("No session selected.")}
    ` : tr("Broker is not running.")}
    `; $("chat-count").textContent = ""; @@ -1567,13 +1574,24 @@ async function sendComposer() { if (r) { ta.value = ""; autosize(); + try { localStorage.removeItem(`multiagents:draft:composer:${sid}`); } catch {} const f = $("msg-feed"); f.scrollTop = f.scrollHeight; } } $("composer-send").addEventListener("click", sendComposer); -$("composer-text").addEventListener("input", autosize); +$("composer-text").addEventListener("input", () => { + autosize(); + const sid = currentSid(); + if (sid) { + try { + const val = $("composer-text").value; + if (val) localStorage.setItem(`multiagents:draft:composer:${sid}`, val); + else localStorage.removeItem(`multiagents:draft:composer:${sid}`); + } catch {} + } +}); $("composer-text").addEventListener("keydown", (e) => { // Enter sends; Shift+Enter is a new line; an IME composition (Chinese/Japanese input) is never cut off if (e.key === "Enter" && !e.shiftKey && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); sendComposer(); } @@ -1677,6 +1695,13 @@ function slotSummary(slot) { function renderAgents() { const el = $("panel-agents"); if (busyRegion(el)) return; + const curSid = currentSid(); + if (curSid && !$("composer-text").value) { + try { + const saved = localStorage.getItem(`multiagents:draft:composer:${curSid}`); + if (saved) { $("composer-text").value = saved; autosize(); } + } catch {} + } if (!state.session) { el.innerHTML = `
    ${state.brokerAlive ? tr("No session selected.") : tr("Broker is not running.")}
    `; return; @@ -1729,6 +1754,13 @@ function renderAgents() { function renderRoster() { const el = $("rpanel-team"); if ($("rpanel-team").classList.contains("hidden") || busyRegion(el)) return; + const curSid = currentSid(); + if (curSid && !$("composer-text").value) { + try { + const saved = localStorage.getItem(`multiagents:draft:composer:${curSid}`); + if (saved) { $("composer-text").value = saved; autosize(); } + } catch {} + } if (!state.session) { el.innerHTML = `
    ${tr("No session selected")}
    `; return; } if (!state.slots.length) { el.innerHTML = `
    ${tr("No agents yet")}
    `; return; } const order = (s) => (s.task_state === "released" ? 2 : s.status === "connected" ? 0 : 1); @@ -2060,6 +2092,13 @@ $("ownership-form").addEventListener("submit", async (e) => { function renderStats() { const el = $("panel-stats"); if (busyRegion(el)) return; + const curSid = currentSid(); + if (curSid && !$("composer-text").value) { + try { + const saved = localStorage.getItem(`multiagents:draft:composer:${curSid}`); + if (saved) { $("composer-text").value = saved; autosize(); } + } catch {} + } if (!state.session) { el.innerHTML = `
    ${tr("No session selected")}
    `; return; } const limits = state.limits ?? {}; const totalIn = state.slots.reduce((a, s) => a + (s.input_tokens ?? 0), 0); @@ -3091,6 +3130,17 @@ async function newTeamModal() { const fallback = ["claude", "codex", "gemini", "api"].find((t) => avail[t] !== false) ?? "claude"; const pick = (t) => (avail[t] === false ? fallback : t); providersReturn = () => newTeamModal(); + + const DRAFT_KEY = "multiagents:draft:new-team"; + let draft = null; + try { + const raw = localStorage.getItem(DRAFT_KEY); + if (raw) draft = JSON.parse(raw); + } catch {} + + const hasDraft = !!(draft && (draft.dir || draft.name || draft.goal || draft.plan || (draft.agents && draft.agents.length))); + const initialAgents = (hasDraft && draft.agents?.length) ? draft.agents : TEAM_PRESETS["Worker + Reviewer"]; + const providerSelect = (a) => ``; const agentRow = (a) => `
    @@ -3104,32 +3154,84 @@ async function newTeamModal() {
    - +
    `; openModal({ title: "New team", submitLabel: "Start Team", wide: true, - body: ` + body: `
    + 💾 ${tr("Draft restored from cache")} + +
    +
    - - - + + +
    -
    ${TEAM_PRESETS["Worker + Reviewer"].map(agentRow).join("")}
    +
    ${initialAgents.map(agentRow).join("")}
    - + - - - + + + ${roleDatalist()} ${Object.values(MODEL_SUGGESTIONS).flat().map((m) => ``, setup: (form) => { const list = form.querySelector("[data-agents]"); - form.querySelector("[data-add-agent]").addEventListener("click", () => list.insertAdjacentHTML("beforeend", agentRow({ agent_type: fallback, name: "", role: "", initial_task: "" }))); - list.addEventListener("click", (e) => { if (e.target.closest("[data-remove-agent]")) e.target.closest(".team-agent").remove(); }); + + const collectDraft = () => { + const agents = [...form.querySelectorAll(".team-agent")].map((row) => ({ + agent_type: row.querySelector("[data-f=agent_type]")?.value || "", + provider: row.querySelector("[data-f=provider]")?.value || "", + name: row.querySelector("[data-f=name]")?.value || "", + role: row.querySelector("[data-f=role]")?.value || "", + model: row.querySelector("[data-f=model]")?.value || "", + approval_mode: row.querySelector("[data-f=approval_mode]")?.value || "bypass", + initial_task: row.querySelector("[data-f=initial_task]")?.value || "", + })); + return { + dir: form.dir?.value || "", + name: form.name?.value || "", + goal: form.goal?.value || "", + plan: form.plan?.value || "", + wf_approval: form.wf_approval?.checked ?? true, + wf_feedback: form.wf_feedback?.checked ?? true, + wf_human: form.wf_human?.checked ?? true, + agents, + }; + }; + + const saveDraft = () => { + try { + const d = collectDraft(); + const hasContent = d.dir || d.name || d.goal || d.plan || d.agents.some((a) => a.name || a.role || a.initial_task); + if (hasContent) { + localStorage.setItem(DRAFT_KEY, JSON.stringify({ ...d, updated_at: Date.now() })); + const banner = form.querySelector("#new-team-draft-banner"); + if (banner) banner.style.display = "flex"; + } + } catch {} + }; + + form.addEventListener("input", saveDraft); + form.addEventListener("change", saveDraft); + + form.querySelector("[data-add-agent]").addEventListener("click", () => { + list.insertAdjacentHTML("beforeend", agentRow({ agent_type: fallback, name: "", role: "", initial_task: "" })); + saveDraft(); + }); + + list.addEventListener("click", (e) => { + if (e.target.closest("[data-remove-agent]")) { + e.target.closest(".team-agent").remove(); + saveDraft(); + } + }); + list.addEventListener("change", (e) => { const type = e.target.closest("[data-f=agent_type]"); if (!type) return; @@ -3138,7 +3240,26 @@ async function newTeamModal() { const model = row.querySelector("[data-f=model]"); model.placeholder = type.value === "api" ? (providerById(row.querySelector("[data-f=provider]").value)?.default_model || tr("model name")) : tr("model (default)"); }); - form.querySelectorAll("[data-preset]").forEach((b) => b.addEventListener("click", () => { list.innerHTML = TEAM_PRESETS[b.dataset.preset].map(agentRow).join(""); })); + + form.querySelectorAll("[data-preset]").forEach((b) => b.addEventListener("click", () => { + list.innerHTML = TEAM_PRESETS[b.dataset.preset].map(agentRow).join(""); + saveDraft(); + })); + + form.querySelector("[data-reset-draft]")?.addEventListener("click", () => { + try { localStorage.removeItem(DRAFT_KEY); } catch {} + form.dir.value = ""; + form.name.value = ""; + form.goal.value = ""; + form.plan.value = ""; + form.wf_approval.checked = true; + form.wf_feedback.checked = true; + form.wf_human.checked = true; + list.innerHTML = TEAM_PRESETS["Worker + Reviewer"].map(agentRow).join(""); + const banner = form.querySelector("#new-team-draft-banner"); + if (banner) banner.style.display = "none"; + toast(tr("Draft cleared"), "info"); + }); }, onSubmit: async (form) => { const agents = [...form.querySelectorAll(".team-agent")].map((row) => { @@ -3168,6 +3289,7 @@ async function newTeamModal() { if (!body.project_dir || !body.session_name) throw new Error("Working directory and session name are required"); toast(tr("Starting team — agents take 15–60s to connect…"), "info"); const r = await api("team/create", body); + try { localStorage.removeItem(DRAFT_KEY); } catch {} toast(tr("Team started: {id}", { id: r?.data?.session_id ?? body.session_name }), "ok"); }, }); @@ -3284,7 +3406,13 @@ async function selectSession(id) { $("session-name").addEventListener("click", openPicker); $("picker-overlay").addEventListener("click", (e) => { if (e.target.id === "picker-overlay") closePicker(); }); -$("modal-overlay").addEventListener("mousedown", (e) => { if (e.target.id === "modal-overlay") closeModal(); }); +$("modal-overlay").addEventListener("mousedown", (e) => { + if (e.target.id === "modal-overlay") { + const modal = $("modal-overlay").querySelector(".modal"); + if (modal && modal.classList.contains("wide")) return; + closeModal(); + } +}); $("picker-search").addEventListener("input", () => { pickerFocusIdx = 0; renderPicker(); }); $("picker-list").addEventListener("click", (e) => { const item = e.target.closest(".picker-item"); diff --git a/dashboard/i18n.js b/dashboard/i18n.js index f773aac..a19ae3d 100644 --- a/dashboard/i18n.js +++ b/dashboard/i18n.js @@ -92,6 +92,9 @@ const ZH = { "+ Add Agent": "+ 添加 Agent", "+ Add agent": "+ 添加 Agent", "New team": "新建团队", + "Draft restored from cache": "已自动恢复上次编辑的草稿", + "Clear draft": "清空草稿", + "Draft cleared": "草稿已清空", "New Team": "新建团队", "New empty session": "新建空会话", "More ▾": "更多 ▾", From 1417b6bbba195de796f860b737af0d9b4d3e9a9b Mon Sep 17 00:00:00 2001 From: Zeyu LIu Date: Thu, 22 Oct 2026 20:39:40 +0800 Subject: [PATCH 23/24] refactor(dashboard): prompt to save draft on close and only show restored banner on reopen --- dashboard/app.js | 36 ++++++++++++++++++++++++++++++------ dashboard/i18n.js | 5 +++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/dashboard/app.js b/dashboard/app.js index d53ba7f..486b669 100644 --- a/dashboard/app.js +++ b/dashboard/app.js @@ -2908,7 +2908,7 @@ document.addEventListener("click", (e) => { let modalClose = null; /** Open a modal. `onSubmit(form)` may throw to keep it open with the error shown; returning closes it. */ -function openModal({ title, sub, body, submitLabel = "OK", danger = false, wide = false, onSubmit, onCancel, setup }) { +function openModal({ title, sub, body, submitLabel = "OK", cancelLabel = "Cancel", danger = false, wide = false, onSubmit, onCancel, setup }) { closeModal(); const overlay = $("modal-overlay"); overlay.innerHTML = `
    @@ -2916,7 +2916,7 @@ function openModal({ title, sub, body, submitLabel = "OK", danger = false, wide
    `; // Static labels, hints and placeholders of the body are translated in place @@ -2963,10 +2963,10 @@ function sessionSub(sid) { return tr("Session: {name}", { name: s?.name && s.name !== sid ? `${s.name} (${sid})` : sid }); } -function confirmDialog({ title, message, confirmLabel = "Confirm", danger = false, sid, typeToConfirm }) { +function confirmDialog({ title, message, confirmLabel = "Confirm", cancelLabel = "Cancel", danger = false, sid, typeToConfirm }) { return new Promise((resolve) => { openModal({ - title, sub: sid != null ? sessionSub(sid) : undefined, submitLabel: confirmLabel, danger, + title, sub: sid != null ? sessionSub(sid) : undefined, submitLabel: confirmLabel, cancelLabel, danger, body: `${message ?? ""}${typeToConfirm ? `

    ${tr("Type {text} to confirm:", { text: esc(typeToConfirm) })}

    ` : ""}`, onSubmit: (form) => { if (typeToConfirm && form.confirm.value.trim() !== typeToConfirm) throw new Error(tr("The text does not match")); @@ -3211,12 +3211,36 @@ async function newTeamModal() { const hasContent = d.dir || d.name || d.goal || d.plan || d.agents.some((a) => a.name || a.role || a.initial_task); if (hasContent) { localStorage.setItem(DRAFT_KEY, JSON.stringify({ ...d, updated_at: Date.now() })); - const banner = form.querySelector("#new-team-draft-banner"); - if (banner) banner.style.display = "flex"; } } catch {} }; + // 点叉叉或取消时询问是否保存草稿 + form.querySelectorAll("[data-close]").forEach((b) => { + b.addEventListener("click", async (e) => { + e.stopPropagation(); + e.preventDefault(); + const d = collectDraft(); + const hasContent = !!(d.dir || d.name || d.goal || d.plan || d.agents.some((a) => a.name || a.role || a.initial_task)); + if (hasContent) { + const keep = await confirmDialog({ + title: "Save draft?", + message: `

    ${tr("Would you like to keep your changes as a draft for next time?")}

    `, + confirmLabel: "Save draft", + cancelLabel: "Discard", + }); + if (keep) { + try { localStorage.setItem(DRAFT_KEY, JSON.stringify({ ...d, updated_at: Date.now() })); } catch {} + toast(tr("Draft saved"), "info"); + } else { + try { localStorage.removeItem(DRAFT_KEY); } catch {} + } + } else { + closeModal(); + } + }, true); + }); + form.addEventListener("input", saveDraft); form.addEventListener("change", saveDraft); diff --git a/dashboard/i18n.js b/dashboard/i18n.js index a19ae3d..b0ca4a3 100644 --- a/dashboard/i18n.js +++ b/dashboard/i18n.js @@ -95,6 +95,11 @@ const ZH = { "Draft restored from cache": "已自动恢复上次编辑的草稿", "Clear draft": "清空草稿", "Draft cleared": "草稿已清空", + "Save draft?": "保存草稿?", + "Would you like to keep your changes as a draft for next time?": "是否保留未完成的配置为草稿?下次打开时将自动恢复。", + "Save draft": "保存草稿", + "Discard": "放弃草稿", + "Draft saved": "草稿已保存", "New Team": "新建团队", "New empty session": "新建空会话", "More ▾": "更多 ▾", From 32d93582474c480aebd6444dee3e37d37b95206c Mon Sep 17 00:00:00 2001 From: Zeyu LIu Date: Thu, 22 Oct 2026 22:17:38 +0800 Subject: [PATCH 24/24] fix(orchestrator): keep approved agents online in standby and auto-engage human gate --- broker.ts | 18 ++++++++++++++++++ dashboard/control.ts | 11 ++++++++++- orchestrator/monitor.ts | 28 +++++++++++++++++++++------- orchestrator/runtime.ts | 25 +++++++++++++++++++++++-- tests/web-control.test.ts | 6 ++++++ 5 files changed, 78 insertions(+), 10 deletions(-) diff --git a/broker.ts b/broker.ts index f562f86..8a2ef2d 100755 --- a/broker.ts +++ b/broker.ts @@ -1870,6 +1870,24 @@ Bun.serve({ if (allReviewRolesApproved || singleReviewerApproved) { db.run("UPDATE slots SET task_state = 'approved' WHERE id = ?", [target_slot_id]); newState = "approved"; + + // If all non-reviewer workers are now approved, also transition active reviewers to approved + const liveSlots = db.query( + "SELECT id, role, task_state FROM slots WHERE session_id = ? AND task_state != 'released'" + ).all(session_id) as Array<{ id: number; role: string | null; task_state: string }>; + + const unapprovedWorkers = liveSlots.filter( + (s) => !/qa|review|test|lead/i.test(s.role ?? "") && s.task_state !== "approved" + ); + + if (unapprovedWorkers.length === 0) { + const activeReviewers = liveSlots.filter( + (s) => /qa|review|test|lead/i.test(s.role ?? "") && s.task_state !== "approved" + ); + for (const rev of activeReviewers) { + db.run("UPDATE slots SET task_state = 'approved' WHERE id = ?", [rev.id]); + } + } } else { // Keep in done_pending_review — still waiting for more approvals newState = targetSlot.task_state; diff --git a/dashboard/control.ts b/dashboard/control.ts index 66a4d59..836dbaf 100644 --- a/dashboard/control.ts +++ b/dashboard/control.ts @@ -685,5 +685,14 @@ export function awaitingHuman(session: Session | null, slots: Slot[]): boolean { if (!session || session.status === "archived") return false; if (safeJsonParse>(session.config, {}).human_gate !== true) return false; const live = slots.filter((s) => s.task_state !== "released"); - return live.length > 0 && live.every((s) => s.task_state === "approved"); + if (live.length === 0) return false; + if (live.every((s) => s.task_state === "approved")) return true; + + // When all workers are approved, and reviewers have finished reviewing, gate opens + const workers = live.filter((s) => !/qa|review|test|lead/i.test(s.role ?? "")); + if (workers.length > 0 && workers.every((s) => s.task_state === "approved")) { + const reviewers = live.filter((s) => /qa|review|test|lead/i.test(s.role ?? "")); + return reviewers.every((s) => s.task_state === "approved" || s.task_state === "working" || s.task_state === "idle"); + } + return false; } diff --git a/orchestrator/monitor.ts b/orchestrator/monitor.ts index 3621a00..50e1dcc 100644 --- a/orchestrator/monitor.ts +++ b/orchestrator/monitor.ts @@ -417,14 +417,28 @@ async function handleExit( brokerClient: BrokerClient, onEvent: (event: AgentEvent) => void, ): Promise { - // Update slot status to disconnected + let taskState: string | undefined; try { - await brokerClient.updateSlot({ - id: slotId, - status: "disconnected", - }); - } catch (err) { - log(LOG_PREFIX, `Failed to update slot ${slotId} on exit: ${err}`); + const slot = await brokerClient.getSlot(slotId); + taskState = slot?.task_state; + } catch { /* ok */ } + + // An agent whose work completed normally (code 0) in "approved" state remains connected + // in standby awaiting human review or release — it must not drop offline while the session is active. + const isStandbyApproved = exitCode === 0 && taskState === "approved"; + + if (!isStandbyApproved) { + // Update slot status to disconnected + try { + await brokerClient.updateSlot({ + id: slotId, + status: "disconnected", + }); + } catch (err) { + log(LOG_PREFIX, `Failed to update slot ${slotId} on exit: ${err}`); + } + } else { + log(LOG_PREFIX, `Slot ${slotId} completed (exit 0) with task_state="approved" — keeping slot online (standby for review/release)`); } if (exitCode === 0) { diff --git a/orchestrator/runtime.ts b/orchestrator/runtime.ts index 15d4db1..d6a7b66 100644 --- a/orchestrator/runtime.ts +++ b/orchestrator/runtime.ts @@ -684,6 +684,13 @@ async function autoRestartIfIncomplete( } // Agent completed normally (done_pending_review, approved, released) + if (state === "approved") { + log(LOG_PREFIX, `${name} completed with task_state="approved" — keeping online in standby awaiting human review/release`); + try { + await brokerClient.updateSlot({ id: slotId, status: "connected" }); + } catch { /* ok */ } + return false; + } log(LOG_PREFIX, `${name} completed with task_state="${state}" — no restart needed`); return false; } catch (err) { @@ -2181,6 +2188,16 @@ export function startBackgroundLoops(brokerClient: BrokerClient): void { } } + // --- Auto-approve reviewers when all workers are approved --- + const workerSlots = slots.filter((s) => !(s.role && /qa|review|test|lead/i.test(s.role)) && (s as any).task_state !== "released"); + if (workerSlots.length > 0 && workerSlots.every((s) => (s as any).task_state === "approved")) { + const activeReviewers = reviewerSlots.filter((s) => (s as any).task_state !== "approved" && (s as any).task_state !== "released"); + for (const rev of activeReviewers) { + log(LOG_PREFIX, `Auto-approving reviewer ${rev.display_name ?? `slot ${rev.id}`} (all worker tasks approved)`); + await brokerClient.updateSlot({ id: rev.id, task_state: "approved" } as any); + } + } + const allDone = slots.every( (s) => (s as any).task_state === "approved" || (s as any).task_state === "released" || s.status === "disconnected" ); @@ -2286,8 +2303,12 @@ export function startBackgroundLoops(brokerClient: BrokerClient): void { const now = Date.now(); - // A connected agent is stuck only when messages stay unclaimed past the threshold - if (slot.status === "connected") { + const sessionProcs = activeProcesses.get(sessionId); + const hasRunningProc = sessionProcs?.has(slot.id) === true; + + // A connected agent is stuck only when messages stay unclaimed past the threshold, + // UNLESS its process already completed and is in standby, in which case wake it up immediately! + if (slot.status === "connected" && hasRunningProc) { const messageAge = inbox.oldest_at ? now - inbox.oldest_at : 0; if (messageAge < CONNECTED_STALE_MS) continue; } diff --git a/tests/web-control.test.ts b/tests/web-control.test.ts index bfcb67f..db52cdb 100644 --- a/tests/web-control.test.ts +++ b/tests/web-control.test.ts @@ -308,6 +308,12 @@ describe("workflow helpers", () => { const slot = (task_state: string) => ({ task_state }) as Slot; expect(awaitingHuman(session, [slot("approved"), slot("released")])).toBe(true); expect(awaitingHuman(session, [slot("approved"), slot("working")])).toBe(false); + // When worker is approved and reviewer role has finished review, human gate engages + const workerSlot = (task_state: string) => ({ task_state, role: "Worker" }) as Slot; + const reviewerSlot = (task_state: string) => ({ task_state, role: "Code Reviewer" }) as Slot; + expect(awaitingHuman(session, [workerSlot("approved"), reviewerSlot("working")])).toBe(true); + expect(awaitingHuman(session, [workerSlot("approved"), reviewerSlot("approved")])).toBe(true); + expect(awaitingHuman(session, [workerSlot("working"), reviewerSlot("working")])).toBe(false); expect(awaitingHuman(session, [slot("released")])).toBe(false); expect(awaitingHuman({ ...session, config: "{}" }, [slot("approved")])).toBe(false); expect(awaitingHuman({ ...session, status: "archived" }, [slot("approved")])).toBe(false);