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 diff --git a/CLAUDE.md b/CLAUDE.md index f27488f..1f82e33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,8 +15,17 @@ 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/supervision.ts` — Driver registry (one driver per slot), non-overlapping loop ticks, release and watchdog verdicts; pure enough to unit-test. +- `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` + `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. +- 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, 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/README.md b/README.md index 97e1949..bc6668d 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,52 @@ 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. The layout has three panes, and both side panes can be collapsed (`Ctrl+B` / `Ctrl+J`) and resized: -- **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 +- **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. -Dark theme, responsive layout, keyboard shortcuts (`1-6` to switch tabs). +Light, dark, or follow the system: pick it from the theme button in the top bar. The language button switches between English and 中文 (Chinese is the default for a Chinese-language browser). Text the agents write stays as they wrote it. + +**Making it yours.** Agents show their CLI's icon (Claude, Gemini; Codex gets a neutral terminal glyph because OpenAI's logo isn't redistributable). Click an avatar on an agent card or in its settings to use initials, a color or your own image instead. The **Appearance** button in the top bar sets a background image under the whole page, how strongly it shows, how see-through the panels are, and your own avatar. These choices are kept in the browser (localStorage), not on the server. + +**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. + +**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. + +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 +- **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 +- **Activity**: the step-by-step timeline and pending approvals (see above) +- **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`. + +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. + +On narrow screens the side panes become drawers. Keyboard shortcuts: `1-6` switch tabs. ## TUI Dashboard @@ -278,6 +314,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,9 +326,14 @@ 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) +│ ├── 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) +│ ├── 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) @@ -311,7 +353,12 @@ multiagents/ │ └── update.ts `multiagents update` ├── dashboard/ │ ├── server.ts Web dashboard (Bun.serve + WebSocket, localhost:7900) -│ └── index.html Web dashboard UI +│ ├── control.ts Web control API (POST /api/*) +│ ├── 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 +│ ├── i18n.js Web dashboard translations (English / 中文) +│ └── 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/adapters/base-adapter.ts b/adapters/base-adapter.ts index 9c9714b..0dec3fd 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 { @@ -77,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.", }, }, @@ -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/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/broker.ts b/broker.ts index 7413f68..8a2ef2d 100755 --- a/broker.ts +++ b/broker.ts @@ -19,8 +19,9 @@ import { RECONNECT_RECAP_LIMIT, DEFAULT_GUARDRAILS, CLEANUP_INTERVAL, + BROKER_FEATURES, } 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, @@ -62,6 +63,12 @@ import type { KnowledgeListRequest, KnowledgeGetRequest, KnowledgeDeleteRequest, + ActivityAddRequest, + ActivityEntry, + LiveActivity, + ApprovalRequest, + AddPlanItemRequest, + EditPlanItemRequest, } from "./shared/types.ts"; // --- Configuration --- @@ -228,6 +235,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, @@ -269,6 +307,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); } @@ -279,6 +318,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 { @@ -618,7 +676,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) { @@ -663,7 +722,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). */ @@ -671,7 +746,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) }; } /** @@ -816,6 +891,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', ?, ?, ?)", @@ -864,8 +940,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]); @@ -879,7 +957,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()); @@ -942,6 +1025,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); @@ -1191,6 +1275,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 +1346,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) { @@ -1328,6 +1472,117 @@ 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. */ +// --- 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 = ?"]; + 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 }) { @@ -1350,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 }); } @@ -1461,6 +1716,38 @@ 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 "/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": { + 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": { + 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": @@ -1583,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; @@ -1665,3 +1970,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/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/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/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 new file mode 100644 index 0000000..486b669 --- /dev/null +++ b/dashboard/app.js @@ -0,0 +1,3465 @@ +// ============================================================================= +// 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"; + +// 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; +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 = tr("Theme: {name}", { name: tr(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: tr(THEME_LABEL[t]), checked: t === pref, + onClick: () => { try { localStorage.setItem("ma-theme", t); } catch {} applyTheme(); }, + }))); +}); + +// --- Language: English / 中文 --- + +function applyLanguage() { + document.documentElement.lang = getLang() === "zh" ? "zh-CN" : "en"; + translateStatic(); + $("lang-btn").textContent = getLang() === "zh" ? "中" : "EN"; + $("lang-btn").title = tr("Language"); + applyTheme(); +} + +/** Drop every render cache so the next render rebuilds all regions (language, avatars). */ +function rerenderAll() { + lastChatSignature = ""; + activity.filterSig = ""; + activity.rendered = 0; + document.querySelectorAll("[data-html]").forEach((el) => delete el.dataset.html); + delete $("act-approvals").dataset.sig; + if (state) render(true); + renderTree(); +} + +$("lang-btn").addEventListener("click", (e) => { + e.stopPropagation(); + openMenu($("lang-btn"), Object.entries(LANGS).map(([code, name]) => ({ + label: name, checked: code === getLang(), + onClick: () => { + if (code === getLang()) return; + setLang(code); + applyLanguage(); + rerenderAll(); + if (preview.path) openPreview(preview.path, preview.line); + }, + }))); +}); + +$("appearance-btn").addEventListener("click", () => appearanceModal()); + +// --- 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 = tr("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 = tr("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(tr("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 ? tr(result.message) : tr("{label}: done", { label: tr(label) }), result?.warning ? "warn" : "ok"); + return result ?? true; + } catch (e) { + toast(tr("{label} failed: {error}", { label: tr(label), error: tr(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(tr("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(); + activityReset(); +} + +// --- Tabs --- + +const TABS = ["chat", "activity", "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 (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(); +} + +$("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(); + renderQaBanner(); + renderBrokerBanner(); + 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); renderLive(); break; + case "agents": renderAgents(); break; + case "plan": renderPlan(); break; + case "knowledge": renderKnowledge(); break; + case "files": renderFiles(); break; + case "stats": renderStats(); break; + case "activity": renderActivity(); 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 ? tr("Slot {id}", { id: 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 ?? tr("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 ? tr("{online}/{total} agents online", { online: connected, total: state.slots.length }) : ""; + + 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 + ? tr("Broker · {sessions} sessions · {peers} peers", { sessions: state.sessions?.length ?? 0, peers: state.peerCount ?? 0 }) + : tr("Broker down"); +} + +function sessionBadge() { + if (!state.session) return null; + if (state.session.status === "archived") return { text: tr("archived"), cls: "badge-released" }; + if (state.awaitingHuman) return { text: tr("waiting for you"), cls: "badge-review" }; + if (state.session.status === "paused") return { text: tr("paused"), cls: "badge-paused" }; + if (isSessionComplete()) return { text: tr("completed"), cls: "badge-approved" }; + return { text: tr("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 ? tr("No sessions yet — use + to start a team.") : tr("Broker is not running.")}
`; + return; + } + let html = "", last = ""; + for (const s of sessions) { + if (s.status !== last) { + last = s.status; + html += `
${esc(tr(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 = `
${tr("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 = `
${tr("Loading…")}
`; return; } + if (!root.length) { el.innerHTML = `
${tr("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(`
${tr("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(tr("Cannot open {path}: {error}", { path: p, error: 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(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 }); + 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 = `
📄
+
${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(); +} + +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, tr("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 || `

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

`}
`; + break; + } + case "html": { + const mode = preview.mode ?? "render"; + seg([["render", tr("Render")], ["source", tr("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", tr("Preview")], ["source", tr("Source")]], mode, (v) => { preview.mode = v; renderPreview(); }); + const t = await fetchText(); + body.innerHTML = mode === "preview" + ? `${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", 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, tr("This file type has no inline preview.")); + break; + default: { + 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, tr("This looks like a binary file.")) : codeView(t.text, t.truncated); + } + } + } catch (e) { + body.innerHTML = noPreview(name, tr("Preview failed: {error}", { error: 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 ? `
${tr("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 `
${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 ? `
${tr("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 = tr("Open {path}", { path: 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}
    `; +} + +// --- 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: '' }, + // 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: "#10a37f", 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: 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), 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 tr("You"); + if (m.to_id === "orchestrator" || m.to_id === "__orchestrator__") return tr("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 `${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 */ } + // 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, " "); +} + +/** 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; + + 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 = ""; + return; + } + const items = chatItems(); + $("chat-count").textContent = tr(items.length === 1 ? "{n} message" : "{n} messages", { n: items.length }); + if (!items.length) { + feed.innerHTML = `
    ${tr("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 ? tr("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 ? 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 += `
    + ${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(tr(kind.replace(/_/g, " ")))}` : ""} + ${esc(fmtTime(m.sent_at))} +
    +
    ${renderMarkdown(text, { chat: true })}
    +
    +
    `; + } + feed.innerHTML = html; + hydrateFileRefs(feed); + renderLive(); + + if (atBottom || force || firstRender) { + feed.scrollTop = feed.scrollHeight; + $("jump-new").classList.add("hidden"); + } else if (feed.scrollHeight > prevHeight) { + $("jump-new").classList.remove("hidden"); + } +} + +// --- 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); + // 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) { + 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.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"); + 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(); +}); + +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; + 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"); } + 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("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 + ? `${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) { + 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 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(); + 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(); + 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(); } +}); +$("composer-to").addEventListener("change", refreshAgentSelects); +$("composer-presets").addEventListener("click", (e) => { + e.stopPropagation(); + openMenu($("composer-presets"), PRESETS.map((t) => ({ label: tr(t), onClick: () => { $("composer-text").value = tr(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 ?? tr("No session selected")); + const b = s ? sessionBadge() : null; + $("sb-status").innerHTML = b ? `${b.text}` : ""; + $("sb-gate").innerHTML = s + ? `${tr(state.humanGate ? "human gate on" : "human gate off")}` + : ""; + $("sb-meta").textContent = s + ? `${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"; + 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 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"))}`; + $("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; } + el.className = "banner"; + el.innerHTML = ` + ${tr("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", +}; +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(taskLabel(taskState))}`; +} + +function connBadge(status) { + return status === "connected" ? `${tr("online")}` : `${tr("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; + 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; + } + if (!state.slots.length) { + el.innerHTML = `
    ${tr("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 = usageLine(slot); + const actions = released || archived + ? `` + : ` + ${paused ? `` : ``} + + + + + ${reviewable ? `` : ""} + + `; + return `
    +
    + ${slotAvatar(slot, "", true)} +
    ${esc(slotName(slot))}
    ${esc(slot.role ?? tr("no role"))} · ${esc(agentTypeLabel(slot))}
    + ${connBadge(slot.status)} +
    +
    + ${taskBadge(slot.task_state ?? "idle")} + ${paused ? `${tr("paused")}` : ""} + ${managed.has(id) ? `${tr("managed here")}` : ""} + ${settingsBadges(slot)} +
    +
    ${tr("slot #{id} · peer {peer}", { 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; + 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); + 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 `
    + ${slotAvatar(slot, ``)} +
    +
    ${esc(slotName(slot))} ${esc(slot.role ?? "")}
    +
    ${esc(taskLabel(slot.task_state))}${isPaused(slot) ? ` · ${tr("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: "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) }, + { 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: 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; + } + 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"; + const ok = await confirmDialog({ + 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 })); + return; + } + case "remove": { + const isReleased = slot.task_state === "released"; + const ok = await confirmDialog({ + 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, isReleased ? "Delete" : "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 ? 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 + .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 || `
    ${tr("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: 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: 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" }))); + } +}); + +// --- 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 ? tr("No knowledge entries. Agents store decisions and discoveries here.") : tr("No session selected")}
    `; + return; + } + 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(tr(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 = tr("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: tr("Saved \"{key}\"", { key: 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 = tr("Edit \"{key}\"", { key }); + $("kn-value").focus(); + } else { + 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: tr("Deleted \"{key}\"", { 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) : tr("Slot {id}", { id: l.held_by_slot }))} + ${esc(l.purpose ?? "")} + ${timeSince(l.acquired_at)} + ${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) : tr("Slot {id}", { id: o.slot_id }))}${esc(o.assigned_by ?? "")}${timeSince(o.assigned_at)}`; + }).join(""); + 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); +} + +$("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: 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 })); +}); + +$("lock-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const sid = currentSid(); + if (sid == null) return; + const slot = $("lock-agent").value; + 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(), + })); + 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(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 = ""; +}); + +// --- Stats --- + +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); + 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.messages.length, label: "Messages", color: "var(--cyan)" }, + ]; + 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 ` +
    ${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)} + ${slot.agent_type === "claude" ? fmtUsd(slot.cost_usd ?? 0) : '—'} + ${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(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 = `${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")}
    +
    ${tr("Limit locks")}
    +
    +
    + + + + +
    ${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 ? `
    ${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}%">
    `; +} + +$("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; } + if (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: tr("{name} set to {value} {unit}", { name: tr(g?.label ?? id), value: g?.current_value ?? value, unit: tr(g?.unit ?? "") }) }))); + } +}); + +// --- 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: [], 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)"], + ["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])); +const modeLabel = (mode) => tr(MODE_LABEL[mode] ?? mode); + +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 : "", + 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)}` : ""} + ${set.approval_mode !== "bypass" ? `${esc(modeLabel(set.approval_mode))}` : ""} + ${set.limit_hit ? `${tr("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 `
    ${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 = {}) { + if (type === "api") return apiModelFields(values); + const efforts = EFFORT_OPTIONS[type] ?? []; + return `
    + ${(MODEL_SUGGESTIONS[type] ?? []).map((m) => ` +
    ${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" ? `
    ${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: 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, 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)) })}
    +
    + +
    ${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(tr(r?.message ?? "Saved"), "ok"); + }, + }); +} + +$("act-settings").addEventListener("click", () => { + const slot = slotById(parseInt($("act-agent").value, 10)) ?? liveSlots()[0]; + if (!slot) return toast(tr("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 = `✋ ${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; + 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 `
    +
    ${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(""); + 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(tr(c.kind ?? "update"))} ${fileRef(c.path ?? "")}
    ${c.diff ? unifiedDiff(c.diff) : ""}`).join(""); +} + +function outputBlock(label, text, isErr) { + if (!text) return ""; + 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 ? ` ${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(tr(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(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 ? ` ${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(tr(e.title))}${e.detail ? ` — ${esc(e.detail)}` : ""}
    `; + case "turn_end": + return `
    ${esc(tr(e.title))}
    `; + case "error": + return `
    ⚠ ${esc(tr(e.title))}${e.detail ? ` — ${esc(tr(e.detail))}` : ""}
    `; + default: + return `
    ${esc(tr(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 = `
    ${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 = tr(visible.length === 1 ? "{n} step" : "{n} steps", { n: visible.length }); + if (!activity.entries.length) { + 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; + } + 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 || 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 + ? `
    ${slot ? slotAvatar(slot, "", false, "mini") : ""} ${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) => { + 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(); + 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) { + case "session-pause": { + 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; + } + 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 "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); + } +} + +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(tr("Nothing to release"), "warn"); + const notApproved = targets.filter((s) => s.task_state !== "approved"); + const ok = await confirmDialog({ + 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 })); +} + +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: 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: 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: "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 })); + } }, + { label: "End session", danger: true, disabled: archived, onClick: async () => { + 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: 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 })); + } }, + ]; +} + +// --- 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, #lang-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", cancelLabel = "Cancel", danger = false, wide = false, onSubmit, onCancel, setup }) { + 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"); + 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 = tr(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 tr("Session: {name}", { name: s?.name && s.name !== sid ? `${s.name} (${sid})` : sid }); +} + +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, 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")); + 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(tr("{label} is required", { label: tr(label) })); + 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: tr("Change role of {name}", { name: 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(tr(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: `
    + + +
    ${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(tr(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; +} + +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 = labels[t] || t; + const missing = avail && avail[t] === false ? tr(t === "api" ? " (add a provider)" : " (not installed)") : ""; + return ``; + }).join(""); +} + +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: `
    + + + + + + +
    + +
    ${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 (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"); + }, + }); +} + +function newSessionModal() { + openModal({ + title: "New empty session", submitLabel: "Create session", + body: `
    + + +
    ${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(tr("Session \"{id}\" created", { id: r?.id }), "ok"); + }, + }); +} + +const TEAM_PRESETS = { + "Worker + Reviewer": [ + { 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": [ + { 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 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 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) => `
    +
    + + ${providerSelect(a)} + + + +
    +
    + + +
    + +
    `; + openModal({ + title: "New team", submitLabel: "Start Team", wide: true, + body: `
    + 💾 ${tr("Draft restored from cache")} + +
    + +
    + + + +
    + +
    ${initialAgents.map(agentRow).join("")}
    + + + + + + + + + ${roleDatalist()} + ${Object.values(MODEL_SUGGESTIONS).flat().map((m) => ``, + setup: (form) => { + const list = form.querySelector("[data-agents]"); + + 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() })); + } + } 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); + + 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; + 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(""); + 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) => { + 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"; + 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) => 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) => { + 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(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"); + }, + }); +} + +// --- Broker info --- + +$("broker-pill").addEventListener("click", async () => { + let info; + 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 ? `● ${tr("running")}` : `● ${tr("not running")}`} + ${esc(info.url)} + ${tr("{n} ({active} active)", { n: info.sessions, active: info.active_sessions })} + ${tr("{n} registered", { n: info.peers })} + ${esc(info.current_session ?? "—")} + ${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: () => {}, + }); +}); + +// --- 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 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`; + 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 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 ""; } +} +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 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 tr("{n}s ago", { n: s }); + const m = Math.floor(s / 60); + if (m < 60) return tr("{n}m ago", { n: m }); + const h = Math.floor(m / 60); + if (h < 48) return tr("{n}h ago", { n: h }); + return tr("{n}d ago", { n: Math.floor(h / 24) }); +} + +// --- 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 = `
    ${tr("No sessions found")}
    `; return; } + list.innerHTML = pickerFiltered.map((s, i) => `
    + ${statusDot(s.status)} + ${esc(s.name || s.id)} + ${esc(tr(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(tr("Switching session failed: {error}", { error: 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") { + 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"); + 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 --- + +applyLanguage(); +applyAppearance(); +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(); +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/chatgpt-icon.png b/dashboard/chatgpt-icon.png new file mode 100644 index 0000000..07a375b Binary files /dev/null and b/dashboard/chatgpt-icon.png differ diff --git a/dashboard/control.ts b/dashboard/control.ts new file mode 100644 index 0000000..836dbaf --- /dev/null +++ b/dashboard/control.ts @@ -0,0 +1,698 @@ +// ============================================================================ +// 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, ApprovalMode, KnowledgeCategory, Session, Slot } from "../shared/types.ts"; +import { safeJsonParse } from "../shared/utils.ts"; +import { createSessionRecord, exportSessionData } from "../shared/session-ops.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"; +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; + +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", "api"]; +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()) + : []; + 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, + 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 } : {}), + ...(settings.model ? { model: settings.model } : {}), + ...(settings.effort ? { effort: settings.effort } : {}), + ...(settings.approval_mode ? { approval_mode: settings.approval_mode } : {}), + ...(provider ? { provider } : {}), + }; +} + +const APPROVAL_MODES: ApprovalMode[] = ["bypass", "auto", "accept_edits", "ask"]; +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: [], +}; + +/** 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; +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; + }, + + /** 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" }; + }, + + // ---- 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) { + 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 }); + }, + + /** + * 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; + 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); + 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) }); + }, + + 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})` }; + }, + + /** 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); + 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"); + 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) { + 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); + // "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 }; + }, + + // ---- 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/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); + 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"); + 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/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/i18n.js b/dashboard/i18n.js new file mode 100644 index 0000000..b0ca4a3 --- /dev/null +++ b/dashboard/i18n.js @@ -0,0 +1,866 @@ +// ============================================================================= +// 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": "新建团队", + "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 ▾": "更多 ▾", + "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.": "此操作无法撤销。", + + // --- 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}", + "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": "已打断,先回答你的问题", + + // --- 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 设置", + "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": "默认", + + // --- 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 (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 --- + "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]}`], + // 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]})`], + [/^(.+) 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, " "))})`], + [/^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]}`], + [/^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$/, () => "没有需要修改的内容"], + [/^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]}` : "。"}`], + [/^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 fad8052..17f3585 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -3,923 +3,250 @@ - multiagents dashboard - + multiagents control center + + +
    - -
    -
    - - multiagents - connecting... ▾ + +
    +
    + +
    Mmultiagents
    + +
    -
    - - - - +
    + + + + Broker + + + + + +
    -
    - - -
    -
    Agents 0
    -
    Messages 0
    -
    Plan -
    -
    Knowledge 0
    -
    Files 0
    -
    Stats
    -
    - - -
    -
    - - - - - -
    -
    - - -
    -
    - -
    -
    -
    - - + + diff --git a/dashboard/server.ts b/dashboard/server.ts index ecaec13..01b5ea9 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] @@ -13,8 +15,12 @@ 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 } 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, 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"; // --- Configuration --- @@ -22,6 +28,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,9 +73,36 @@ 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[]; + /** Registered peers machine-wide */ + peerCount: number; + /** Slots whose agent process this dashboard launched and supervises */ + 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[]; + /** Usage limits from the session config */ + limits: Record; 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, @@ -79,6 +116,18 @@ async function fetchState(sessionId: string | null): Promise { plan: null, knowledge: [], brokerAlive: false, + brokerMissing: [], + brokerUrl: BROKER_URL, + sessions: [], + peerCount: 0, + managedSlots: [], + managed: false, + humanGate: false, + qaMode: false, + live: {}, + awaitingHuman: false, + approvals: [], + limits: {}, timestamp: Date.now(), }; @@ -87,11 +136,15 @@ async function fetchState(sessionId: string | null): Promise { } catch { return state; } + state.brokerMissing = state.brokerAlive ? await brokerMissingCached() : []; - 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([ + 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(() => []), @@ -100,6 +153,8 @@ async function fetchState(sessionId: string | null): Promise { broker.listFileOwnership(sessionId).catch(() => []), broker.getPlan(sessionId).catch(() => null), broker.listKnowledge(sessionId).catch(() => []), + broker.listApprovals(sessionId, "pending").catch(() => []), + broker.listLiveActivity(sessionId).catch(() => ({})), ]); // Get peers for slot enrichment @@ -118,6 +173,18 @@ 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); + 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); } catch (e) { console.error(`[web-dashboard] Fetch error: ${e}`); } @@ -133,6 +200,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 +243,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 +268,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,10 +290,29 @@ 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; 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", +}; const server = Bun.serve({ port: WEB_DASHBOARD_PORT, @@ -253,43 +352,116 @@ 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: project files for the explorer and preview (read-only, inside the project dir) + if (url.pathname.startsWith("/api/fs/") && req.method === "GET") { 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", - }); - return Response.json(result); + return await handleFs(broker, url.pathname.slice("/api/fs/".length), url); + } catch (e) { + return apiError(e); + } + } + + // API: activity timeline (incremental with after_id) + if (url.pathname === "/api/activity" && req.method === "GET") { + try { + return Response.json({ ok: true, result: await listActivity(controlCtx, url) }); + } catch (e) { + return apiError(e); + } + } + + // 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 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" }; + // 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.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, + 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: 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); + } } } // 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" }, }); } @@ -316,9 +488,33 @@ 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); + // 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 brokerIsIdle(broker)) { + 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}`); +} +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/dashboard/styles.css b/dashboard/styles.css new file mode 100644 index 0000000..8ac530a --- /dev/null +++ b/dashboard/styles.css @@ -0,0 +1,737 @@ +/* ========================================================================== + 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; } + +/* --- Activity timeline --- */ +.act-scroll { padding: 12px 20px 24px; } +.act-row { display: flex; gap: 10px; margin-top: 6px; align-items: flex-start; } +.act-row .avatar { width: 24px; height: 24px; font-size: 10px; border-radius: 7px; margin-top: 2px; } +.act-row.cont .avatar { visibility: hidden; height: 0; } +.act-main { flex: 1; min-width: 0; } +.act-who { font-size: 11.5px; color: var(--text-muted); margin: 10px 0 2px; } +.act-who b { color: var(--text); font-weight: 650; } +.step { + border: 1px solid var(--border); background: var(--surface); border-radius: 8px; font-size: 12.5px; overflow: hidden; +} +.step > summary, .step > .step-head { + display: flex; align-items: center; gap: 8px; padding: 5px 10px; cursor: pointer; list-style: none; min-width: 0; +} +.step > summary::-webkit-details-marker { display: none; } +.step > summary::before { content: "›"; color: var(--text-muted); width: 8px; transition: transform 0.12s; flex-shrink: 0; } +.step[open] > summary::before { transform: rotate(90deg); } +.step-icon { width: 18px; text-align: center; flex-shrink: 0; font-size: 12px; } +.step-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.step-title code { font-size: 11.5px; } +.step-time { font-size: 11px; color: var(--text-muted); flex-shrink: 0; } +.step-body { border-top: 1px solid var(--border); padding: 8px 10px; background: var(--bg); } +.step-body pre, .approval-card pre { + margin: 0; font-size: 11.5px; line-height: 1.45; white-space: pre-wrap; word-break: break-word; max-height: 360px; overflow: auto; +} +.step.thinking { border-style: dashed; background: transparent; } +.step.thinking .step-title { color: var(--text-dim); font-style: italic; } +.step.thinking .think-text { color: var(--text-dim); font-style: italic; white-space: pre-wrap; font-size: 12.5px; line-height: 1.5; } +.step.err { border-color: color-mix(in srgb, var(--red) 45%, var(--border)); } +.step.err .step-icon, .step.err .step-title { color: var(--red); } +.step.kind-text { background: transparent; border-color: transparent; padding: 0; } +.step.kind-text .rich { font-size: 13px; padding: 2px 0; } +.step .result { margin-top: 6px; } +.step .result-label { font-size: 10.5px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin-bottom: 3px; } +.step .result.err .result-label, .step .result.err pre { color: var(--red); } +.act-note { display: flex; justify-content: center; margin: 8px 0; } +.act-note span { font-size: 11.5px; color: var(--text-muted); background: var(--surface2); border-radius: 999px; padding: 1px 10px; } +.act-note.err span { color: var(--red); background: color-mix(in srgb, var(--red) 10%, transparent); } +.act-note.ok span { color: var(--green); } +.act-turn { display: flex; align-items: center; gap: 10px; margin: 12px 0 4px; font-size: 11px; color: var(--text-muted); } +.act-turn::before, .act-turn::after { content: ""; flex: 1; height: 1px; background: var(--border); } +.diff { font-size: 11.5px; line-height: 1.45; margin: 0; white-space: pre-wrap; word-break: break-word; max-height: 420px; overflow: auto; } +.diff .add { background: color-mix(in srgb, var(--green) 14%, transparent); color: var(--text); display: block; } +.diff .del { background: color-mix(in srgb, var(--red) 13%, transparent); color: var(--text); display: block; } +.diff .hunk { color: var(--cyan); display: block; } +.diff .ctx { color: var(--text-dim); display: block; } +.diff-file { font-size: 11.5px; font-weight: 600; margin: 6px 0 3px; } + +/* --- Approvals --- */ +.approval-banner { + margin: 10px 16px 0; padding: 8px 14px; border-radius: 10px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; cursor: pointer; + border: 1px solid color-mix(in srgb, var(--orange) 55%, transparent); background: color-mix(in srgb, var(--orange) 10%, var(--surface)); +} +.approval-banner b { color: var(--orange); } +#act-approvals { padding: 0 20px 10px; flex-shrink: 0; max-height: 55%; overflow-y: auto; border-bottom: 1px solid var(--border); background: var(--bg); } +#act-approvals:empty { display: none; } +.approval-card { + border: 1px solid color-mix(in srgb, var(--orange) 55%, var(--border)); background: var(--surface); border-radius: 10px; + margin-top: 10px; box-shadow: var(--shadow); overflow: hidden; +} +.approval-head { display: flex; align-items: center; gap: 10px; padding: 8px 12px; } +.approval-head .avatar { width: 26px; height: 26px; font-size: 11px; border-radius: 8px; } +.approval-title { flex: 1; min-width: 0; font-size: 13px; } +.approval-title .sub { font-size: 11.5px; color: var(--text-muted); } +.approval-body { padding: 8px 12px; border-top: 1px solid var(--border); background: var(--bg); max-height: 180px; overflow: auto; } +.approval-actions { display: flex; gap: 6px; align-items: center; padding: 8px 12px; border-top: 1px solid var(--border); flex-wrap: wrap; } +.approval-actions input { flex: 1; min-width: 140px; } + +/* --- Usage --- */ +.usage-bar { height: 6px; background: var(--surface3); border-radius: 3px; overflow: hidden; min-width: 80px; } +.usage-bar > div { height: 100%; background: var(--accent); border-radius: 3px; } +.usage-bar.warn > div { background: var(--yellow); } +.usage-bar.over > div { background: var(--red); } +.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } +.mode-option { display: flex; gap: 8px; align-items: flex-start; padding: 6px 8px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer; margin-bottom: 6px; } +.mode-option:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); } +.mode-option input, .form-grid .mode-option input { margin-top: 3px; accent-color: var(--accent); width: auto; flex-shrink: 0; } +.mode-option small { display: block; color: var(--text-dim); font-size: 11.5px; } +.mode-option.disabled { opacity: 0.45; pointer-events: none; } +.badge-limit { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); } +.badge-mode { background: var(--surface3); color: var(--text-dim); } + +/* --- 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; } +} + +/* --- 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); } + +/* --- 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; } + +/* --- 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/docs/web-control-center.md b/docs/web-control-center.md new file mode 100644 index 0000000..460a800 --- /dev/null +++ b/docs/web-control-center.md @@ -0,0 +1,126 @@ +# 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) | — | + +## Activity, approvals, models and limits + +| Feature | API | Implementation | +|---|---|---| +| Activity timeline | `GET /api/activity?session_id=&slot_id=&after_id=&limit=` | broker `/activity/list`, written by `ActivityRecorder` in the host that runs the agent | +| Decide a pending step | `approvals/decide` `{approval_id, decision: allow\|deny, always, note}` | broker `/approvals/decide`; the waiting host polls `/approvals/get` | +| Model / effort / approval mode / token limit | `agents/settings` `{slot_id, model, effort, approval_mode, token_limit}` | slot snapshot via `context_patch`; `reconcileDriver` applies it to the running driver | +| Limit locks | `session/limits` `{limits: {agent_tokens, agent_cost_usd, session_tokens, session_cost_usd}}` | session `config.limits`; `enforceUsageLimits` pauses and interrupts | + +Pending approvals are pushed with the dashboard state (`state.approvals`). A decision can be +made only once; a request whose agent exits is cancelled. + +## 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` + +`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/activity.ts b/orchestrator/activity.ts new file mode 100644 index 0000000..d870639 --- /dev/null +++ b/orchestrator/activity.ts @@ -0,0 +1,271 @@ +// ============================================================================ +// multiagents — Agent activity timeline and tool approvals +// ============================================================================ +// Turns what the CLIs stream (Claude stream-json, Codex app-server +// notifications, Gemini stream-json) into one timeline of steps — thinking, +// tool calls with their inputs, command output, file diffs — stored in the +// broker so any dashboard can show it. Tool calls that need a person are +// recorded as approval requests in the broker and waited on until decided. +// ============================================================================ + +import type { BrokerClient } from "../shared/broker-client.ts"; +import type { ActivityKind, ApprovalMode } from "../shared/types.ts"; +import { log } from "../shared/utils.ts"; + +const LOG_PREFIX = "activity"; + +export interface ActivityItem { + kind: ActivityKind; + title: string; + detail?: string | null; + data?: unknown; + ts?: number; +} + +/** The team-protocol MCP tools: coordination, not work, so never gated or headlined. */ +export function isTeamTool(name: string): boolean { + return /^mcp__multiagents(-peer)?__/.test(name); +} + +function str(v: unknown, max = 400): string { + const s = typeof v === "string" ? v : v === undefined || v === null ? "" : JSON.stringify(v); + return s.length > max ? s.slice(0, max) + "…" : s; +} + +/** A one-line description of a tool call: "Edit src/a.ts", "$ npm test", "Grep "foo" in src". */ +export function describeTool(name: string, input: Record = {}): string { + const file = (input.file_path ?? input.path ?? input.notebook_path) as string | undefined; + switch (name) { + case "Read": return `Read ${file ?? ""}`.trim(); + case "Write": return `Write ${file ?? ""}`.trim(); + case "Edit": + case "MultiEdit": return `Edit ${file ?? ""}`.trim(); + case "NotebookEdit": return `Edit notebook ${file ?? ""}`.trim(); + case "Bash": return `$ ${str(input.command, 300)}`; + case "Grep": return `Grep "${str(input.pattern, 80)}"${input.path ? ` in ${str(input.path, 120)}` : ""}`; + case "Glob": return `Glob ${str(input.pattern, 120)}`; + case "LS": return `List ${file ?? ""}`.trim(); + case "WebFetch": return `Fetch ${str(input.url, 200)}`; + case "WebSearch": return `Search "${str(input.query, 120)}"`; + case "Task": + case "Agent": return `Subagent: ${str(input.description ?? input.prompt, 120)}`; + case "TodoWrite": return "Update the todo list"; + } + if (isTeamTool(name)) return `Team: ${name.replace(/^mcp__multiagents(-peer)?__/, "")}`; + const mcp = /^mcp__([^_]+(?:_[^_]+)*)__(.+)$/.exec(name); + if (mcp) return `${mcp[1]} · ${mcp[2]}`; + return name; +} + +function contentText(content: unknown): string { + if (typeof content === "string") return content; + if (Array.isArray(content)) return content.map((c) => (typeof c === "string" ? c : (c as { text?: string }).text ?? "")).filter(Boolean).join("\n"); + return content === undefined || content === null ? "" : JSON.stringify(content); +} + +/** Claude stream-json → timeline entries. `tools` maps tool_use ids to their call, across events. */ +export function claudeActivity(event: Record, tools: Map): ActivityItem[] { + const items: ActivityItem[] = []; + if (event.type === "system" && event.subtype === "init") { + items.push({ kind: "status", title: `Session started · ${event.model ?? "default model"}`, data: { model: event.model, permissionMode: event.permissionMode } }); + } else if (event.type === "assistant" && Array.isArray(event.message?.content)) { + for (const block of event.message.content) { + if (block.type === "thinking" && block.thinking) items.push({ kind: "thinking", title: "Thinking", detail: block.thinking }); + else if (block.type === "redacted_thinking") items.push({ kind: "thinking", title: "Thinking (redacted)" }); + else if (block.type === "text" && block.text?.trim()) items.push({ kind: "text", title: "Reply", detail: block.text }); + else if (block.type === "tool_use") { + const title = describeTool(block.name, block.input ?? {}); + tools.set(block.id, { name: block.name, title }); + items.push({ kind: "tool_call", title, data: { tool: block.name, input: block.input ?? {}, tool_use_id: block.id, team: isTeamTool(block.name) } }); + } + } + } else if (event.type === "user" && Array.isArray(event.message?.content) && !event.isReplay) { + for (const block of event.message.content) { + if (block.type !== "tool_result") continue; + const call = tools.get(block.tool_use_id); + tools.delete(block.tool_use_id); + items.push({ + kind: "tool_result", + title: `${block.is_error ? "Failed" : "Result"}${call ? ` · ${call.title}` : ""}`, + detail: contentText(block.content), + data: { tool_use_id: block.tool_use_id, tool: call?.name, is_error: block.is_error === true, team: call ? isTeamTool(call.name) : false }, + }); + } + } else if (event.type === "result") { + const u = event.usage ?? {}; + items.push({ + kind: "turn_end", + title: `Turn ${event.is_error ? `ended (${event.subtype})` : "finished"}${typeof event.total_cost_usd === "number" ? ` · $${event.total_cost_usd.toFixed(4)} so far` : ""}`, + data: { usage: u, cost_usd: event.total_cost_usd, num_turns: event.num_turns }, + }); + } + return items; +} + +/** Codex app-server notification → timeline entries (completed items only, so each step appears once). */ +export function codexActivity(method: string, params: Record): ActivityItem[] { + if (method === "turn/completed") { + const status = params.turn?.status ?? "completed"; + return [{ kind: "turn_end", title: `Turn ${status}`, data: { status, error: params.turn?.error } }]; + } + if (method === "thread/started") return [{ kind: "status", title: "Thread started", data: { thread: params.thread?.id } }]; + if (method !== "item/completed" || !params.item) return []; + const item = params.item as Record; + switch (item.type) { + case "reasoning": { + const text = [contentText(item.summary), contentText(item.content)].filter(Boolean).join("\n\n"); + return text ? [{ kind: "thinking", title: "Thinking", detail: text }] : []; + } + case "agentMessage": + return item.text ? [{ kind: "text", title: "Reply", detail: item.text }] : []; + case "commandExecution": { + const command = Array.isArray(item.command) ? item.command.join(" ") : str(item.command, 2000); + return [{ + kind: "command", + title: `$ ${command.slice(0, 300)}`, + detail: item.aggregatedOutput ?? item.output ?? "", + data: { command, cwd: item.cwd, exit_code: item.exitCode, status: item.status, duration_ms: item.durationMs }, + }]; + } + case "fileChange": { + const changes = (Array.isArray(item.changes) ? item.changes : []).map((c: any) => ({ + path: c.path ?? c.filePath, kind: typeof c.kind === "string" ? c.kind : c.kind?.type, diff: c.diff ?? c.unified_diff ?? "", + })); + if (!changes.length && item.filePath) changes.push({ path: item.filePath, kind: "update", diff: "" }); + return [{ + kind: "file_change", + title: changes.length === 1 ? `${verb(changes[0]!.kind)} ${changes[0]!.path}` : `Changed ${changes.length} files`, + data: { changes, status: item.status }, + }]; + } + case "mcpToolCall": { + const tool = `mcp__${item.server ?? item.serverLabel ?? "mcp"}__${item.tool ?? item.toolName ?? "tool"}`; + return [{ + kind: "tool_call", + title: describeTool(tool, item.arguments ?? {}), + detail: item.result ? contentText(item.result.content ?? item.result) : item.error ? str(item.error, 2000) : null, + data: { tool, input: item.arguments ?? {}, status: item.status, team: isTeamTool(tool) || item.server === "multiagents-peer" }, + }]; + } + case "webSearch": + return [{ kind: "tool_call", title: `Search "${str(item.query, 120)}"`, data: { tool: "web_search", input: { query: item.query } } }]; + case "plan": + case "todoList": + return [{ kind: "status", title: "Updated the plan", detail: contentText(item.text ?? item.items) }]; + default: + return []; + } +} + +function verb(kind: string | undefined): string { + return kind === "add" ? "Created" : kind === "delete" ? "Deleted" : "Edited"; +} + +/** Gemini CLI stream-json (`--output-format stream-json`) → timeline entries. */ +export function geminiActivity(event: Record): ActivityItem[] { + switch (event.type) { + case "init": + return [{ kind: "status", title: `Session started · ${event.model ?? "default model"}`, data: { model: event.model } }]; + case "message": + return event.role === "assistant" && !event.delta && event.content ? [{ kind: "text", title: "Reply", detail: contentText(event.content) }] : []; + case "tool_use": + return [{ kind: "tool_call", title: describeTool(geminiToolName(event.tool_name), event.parameters ?? {}), data: { tool: event.tool_name, input: event.parameters ?? {}, tool_use_id: event.tool_id, team: /multiagents/.test(String(event.tool_name)) } }]; + case "tool_result": + return [{ kind: "tool_result", title: event.status === "error" ? "Failed" : "Result", detail: contentText(event.output ?? event.error?.message), data: { tool_use_id: event.tool_id, is_error: event.status === "error" } }]; + case "error": + return [{ kind: "error", title: "Error", detail: contentText(event.message ?? event.error) }]; + case "result": + return [{ kind: "turn_end", title: `Turn ${event.status ?? "finished"}`, data: { stats: event.stats } }]; + default: + return []; + } +} + +/** Gemini's built-in tool names, mapped to the Claude names describeTool knows. */ +function geminiToolName(name: string): string { + const map: Record = { read_file: "Read", write_file: "Write", replace: "Edit", run_shell_command: "Bash", search_file_content: "Grep", glob: "Glob", list_directory: "LS", web_fetch: "WebFetch", google_web_search: "WebSearch" }; + return map[name] ?? name; +} + +/** + * Buffers one agent's entries and writes them to the broker in batches, so a + * burst of stream events costs one request. + */ +export class ActivityRecorder { + private queue: ActivityItem[] = []; + private timer: ReturnType | null = null; + + constructor(private sessionId: string, private slotId: number, private broker: BrokerClient) {} + + push(...items: ActivityItem[]): void { + for (const item of items) this.queue.push({ ...item, ts: item.ts ?? Date.now() }); + if (!this.timer && this.queue.length) this.timer = setTimeout(() => void this.flush(), 300); + } + + async flush(): Promise { + if (this.timer) { clearTimeout(this.timer); this.timer = null; } + const entries = this.queue.splice(0); + if (!entries.length) return; + try { + await this.broker.addActivity({ session_id: this.sessionId, slot_id: this.slotId, entries }); + } catch (err) { + log(LOG_PREFIX, `Could not record ${entries.length} activity entr${entries.length === 1 ? "y" : "ies"} for slot ${this.slotId}: ${err}`); + } + } +} + +// --- Approvals --- + +export interface ApprovalOutcome { + allowed: boolean; + always: boolean; + note: string | null; +} + +/** + * Record a tool call as waiting for a person and wait for the decision. The + * request is cancelled (and denied) once `stillWanted` turns false — the agent + * exited or the request was superseded. + */ +export async function awaitApproval( + broker: BrokerClient, + req: { sessionId: string; slotId: number; tool: string; title: string; input: unknown }, + stillWanted: () => boolean, + pollMs = 600, +): Promise { + const created = await broker.createApproval({ session_id: req.sessionId, slot_id: req.slotId, tool: req.tool, title: req.title, input: req.input }); + while (true) { + await Bun.sleep(pollMs); + if (!stillWanted()) { + await broker.decideApproval({ id: created.id, session_id: req.sessionId, decision: "cancel", decided_by: "orchestrator", note: "The agent stopped" }).catch(() => {}); + return { allowed: false, always: false, note: "cancelled" }; + } + let row; + try { + row = await broker.getApproval(created.id); + } catch { + continue; // broker hiccup: keep waiting + } + if (row.status === "pending") continue; + return { allowed: row.status === "allowed", always: row.always === 1, note: row.note }; + } +} + +/** What each approval mode means for each CLI. */ +export const CLAUDE_PERMISSION_MODE: Record = { + bypass: "bypassPermissions", + auto: "auto", + accept_edits: "acceptEdits", + ask: "default", +}; + +export const CODEX_APPROVAL_POLICY: Record = { + bypass: "never", + auto: "on-request", + accept_edits: "untrusted", + ask: "untrusted", +}; + +export function parseApprovalMode(v: unknown): ApprovalMode { + return v === "auto" || v === "accept_edits" || v === "ask" ? v : "bypass"; +} diff --git a/orchestrator/api-driver.ts b/orchestrator/api-driver.ts new file mode 100644 index 0000000..f604ba2 --- /dev/null +++ b/orchestrator/api-driver.ts @@ -0,0 +1,670 @@ +// ============================================================================ +// 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: string; + try { real = fs.realpathSync(abs); } catch { + if (mustExist) throw new Error(`${p} does not exist`); + real = realThroughExistingAncestor(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 */ } } + } +} + +/** + * 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 { + 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 c8945dc..688e459 100644 --- a/orchestrator/claude-driver.ts +++ b/orchestrator/claude-driver.ts @@ -17,13 +17,19 @@ // user, isReplay → echo of a message we sent (--replay-user-messages) // assistant → activity // result → turn finished (usage, cost, subtype) +// control_request → the CLI asks the host (subtype can_use_tool: may this +// tool call run?); answered with a control_response +// control_response → answer to a request the host sent (set_model, +// set_permission_mode, interrupt) +// Permission prompts reach the host because the session runs with +// `--permission-prompt-tool stdio`, the Agent SDK's wire protocol. // Closing stdin ends the session cleanly (exit code 0). // ============================================================================ 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"; @@ -32,6 +38,14 @@ export interface ClaudeLaunchOptions { mcpConfig: Record; appendSystemPrompt?: string; model?: string; + /** `--effort` level (low, medium, high, xhigh, max). */ + effort?: string; + /** + * Launch permission mode. "bypassPermissions" (the default) never asks; + * "acceptEdits" asks for everything but file edits; "default" asks for every + * tool. Other modes ("auto", "plan") are set after launch with setPermissionMode. + */ + permissionMode?: "bypassPermissions" | "acceptEdits" | "default"; maxTurns?: number; /** Resume a previous session instead of starting a fresh one. */ resumeSessionId?: string; @@ -60,6 +74,46 @@ 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; + input: Record; + toolUseId?: string; + /** Suggested rules the CLI would add to "always allow" this call */ + suggestions?: unknown[]; +} + +export type ClaudePermissionDecision = + | { behavior: "allow"; updatedInput?: Record; always?: boolean } + | { behavior: "deny"; message: string; interrupt?: boolean }; + +export type ClaudePermissionHandler = (req: ClaudePermissionRequest) => Promise; + export function buildClaudeArgs(opts: ClaudeLaunchOptions): string[] { const args = [ "-p", @@ -67,17 +121,29 @@ export function buildClaudeArgs(opts: ClaudeLaunchOptions): string[] { "--output-format", "stream-json", "--verbose", "--replay-user-messages", - "--dangerously-skip-permissions", + // 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", "--mcp-config", JSON.stringify(opts.mcpConfig), ]; if (opts.appendSystemPrompt) args.push("--append-system-prompt", opts.appendSystemPrompt); if (opts.model) args.push("--model", opts.model); + if (opts.effort) args.push("--effort", opts.effort); if (opts.maxTurns) args.push("--max-turns", String(opts.maxTurns)); if (opts.resumeSessionId) args.push("--resume", opts.resumeSessionId); return args; } +/** Bypass skips every check; any other mode keeps bypass available for a later switch to it. */ +function permissionArgs(mode: NonNullable): string[] { + if (mode === "bypassPermissions") return ["--dangerously-skip-permissions"]; + const args = ["--allow-dangerously-skip-permissions"]; + if (mode === "acceptEdits") args.push("--permission-mode", "acceptEdits"); + return args; +} + function defaultCommand(): string[] { const override = process.env.MULTIAGENTS_CLAUDE_COMMAND?.trim(); if (override) return override.split(/\s+/); @@ -98,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 = ""; @@ -115,6 +181,13 @@ export class ClaudeDriver { private _turnWaiters: Array<{ resolve: (r: ClaudeTurnResult) => void; reject: (e: Error) => void }> = []; /** Texts we wrote to stdin that the host has not echoed back yet (FIFO). */ private _awaitingEcho: Array<{ text: string; settle: (echoed: boolean) => void }> = []; + private _permissionHandler: ClaudePermissionHandler | null = null; + private _controlSeq = 0; + private _controlWaiters = new Map void; reject: (e: Error) => void }>(); + private _model: string | null = null; + /** Events kept for listeners added after spawn (bounded) */ + private _history: ClaudeEvent[] = []; + private _permissionMode: string; get alive(): boolean { return this._alive; } get pid(): number { return this.proc.pid; } @@ -126,10 +199,15 @@ export class ClaudeDriver { get turnsCompleted(): number { return this._turnsCompleted; } get lastActivity(): number { return this._lastActivity; } get exitCode(): number | null { return this._exitCode; } + /** Model reported by init or last set with setModel (null until known). */ + get model(): string | null { return this._model; } + /** Permission mode the session runs under (launch mode or last setPermissionMode). */ + get permissionMode(): string { return this._permissionMode; } - private constructor(proc: PipedSubprocess, initTimeoutMs: number) { + private constructor(proc: PipedSubprocess, initTimeoutMs: number, permissionMode: string) { this.proc = proc; this.initTimeoutMs = initTimeoutMs; + this._permissionMode = permissionMode; this.readStdout(); this.readStderr(); this.proc.exited.then((code) => { @@ -142,6 +220,8 @@ export class ClaudeDriver { this._initWaiter = null; for (const w of this._turnWaiters.splice(0)) w.reject(err); for (const e of this._awaitingEcho.splice(0)) e.settle(false); + for (const w of this._controlWaiters.values()) w.reject(err); + this._controlWaiters.clear(); for (const cb of this._onExit) { try { cb(code); } catch { /* keep going */ } } @@ -157,7 +237,105 @@ export class ClaudeDriver { } this._onExit.push(cb); } - onEvent(cb: ClaudeEventListener): void { this._listeners.push(cb); } + /** With `replay`, the listener first receives the events seen before it was added (the first turn starts at spawn). */ + 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); + } + + /** + * Who answers the CLI's permission prompts. A prompt that arrives before a + * handler is set (the first turn runs during spawn) waits for one; without a + * handler within 30s it is denied. + */ + onPermissionRequest(handler: ClaudePermissionHandler): void { this._permissionHandler = handler; } + + /** Send a control request to the CLI and wait for its control_response. */ + async control(subtype: string, fields: Record = {}, timeoutMs = 15_000): Promise { + if (!this._alive) throw new Error("claude is not alive"); + const requestId = `ma_${++this._controlSeq}`; + const reply = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this._controlWaiters.delete(requestId); + reject(new Error(`No control_response to ${subtype} within ${timeoutMs}ms`)); + }, timeoutMs); + this._controlWaiters.set(requestId, { + resolve: (r) => { clearTimeout(timer); resolve(r); }, + reject: (e) => { clearTimeout(timer); reject(e); }, + }); + }); + this.write({ type: "control_request", request_id: requestId, request: { subtype, ...fields } }); + return reply; + } + + /** Switch the model for the next API call (an alias such as "sonnet", or a full name; undefined = default). */ + async setModel(model: string | undefined): Promise { + await this.control("set_model", model ? { model } : {}); + this._model = model ?? null; + } + + /** + * Switch the permission mode. Newer CLIs call "default" "manual" (and the + * reverse for older ones), so a rejected name is retried as its alias. + */ + async setPermissionMode(mode: string): Promise { + const aliases: Record = { default: "manual", manual: "default" }; + try { + await this.control("set_permission_mode", { mode }); + this._permissionMode = mode; + } catch (err) { + const alias = aliases[mode]; + if (!alias) throw err; + await this.control("set_permission_mode", { mode: alias }); + this._permissionMode = mode; + } + } + + /** Stop the running turn (a no-op between turns). */ + async interrupt(): Promise { + if (!this._busy) return; + await this.control("interrupt"); + } + + private write(msg: unknown): void { + this.proc.stdin.write(JSON.stringify(msg) + "\n"); + this.proc.stdin.flush(); + } + + /** Answer a request the CLI sent: tool permissions go to the handler; anything else is refused. */ + private async answerControlRequest(event: ClaudeEvent): Promise { + const requestId = event.request_id as string | undefined; + const request = (event.request ?? {}) as Record; + if (!requestId) return; + const respond = (response: Record) => + this._alive && this.write({ type: "control_response", response: { subtype: "success", request_id: requestId, response } }); + if (request.subtype !== "can_use_tool") { + if (this._alive) this.write({ type: "control_response", response: { subtype: "error", request_id: requestId, error: `${String(request.subtype)} is not supported by multiagents` } }); + return; + } + const input = (request.input ?? {}) as Record; + for (let waited = 0; !this._permissionHandler && this._alive && waited < 30_000; waited += 100) await Bun.sleep(100); + let decision: ClaudePermissionDecision = { behavior: "deny", message: "No approver is connected to this agent." }; + if (this._permissionHandler) { + try { + decision = await this._permissionHandler({ + toolName: String(request.tool_name ?? "unknown"), + input, + toolUseId: request.tool_use_id as string | undefined, + suggestions: request.permission_suggestions as unknown[] | undefined, + }); + } catch (err) { + decision = { behavior: "deny", message: `Approval failed: ${err instanceof Error ? err.message : err}` }; + } + } + if (decision.behavior === "allow") { + const response: Record = { behavior: "allow", updatedInput: decision.updatedInput ?? input }; + if (decision.always && Array.isArray(request.permission_suggestions)) response.updatedPermissions = request.permission_suggestions; + respond(response); + } else { + respond({ behavior: "deny", message: decision.message, ...(decision.interrupt ? { interrupt: true } : {}) }); + } + } /** * Spawn the session. With `firstPrompt`, the prompt is written immediately @@ -174,7 +352,8 @@ export class ClaudeDriver { stderr: "pipe", env: opts.env, }); - const driver = new ClaudeDriver(proc, opts.initTimeoutMs ?? 90_000); + const driver = new ClaudeDriver(proc, opts.initTimeoutMs ?? 90_000, opts.permissionMode ?? "bypassPermissions"); + if (opts.model) driver._model = opts.model; if (opts.firstPrompt !== undefined) { await driver.send(opts.firstPrompt); log(LOG_PREFIX, `Session ${driver.sessionId} ready (pid ${proc.pid}, mcp: ${driver.mcpServers.map((s) => `${s.name}=${s.status}`).join(",") || "none"})`); @@ -217,8 +396,7 @@ export class ClaudeDriver { const entry = { text, settle }; this._awaitingEcho.push(entry); - this.proc.stdin.write(JSON.stringify(message) + "\n"); - this.proc.stdin.flush(); + this.write(message); this._busy = true; this._lastActivity = Date.now(); @@ -290,14 +468,29 @@ export class ClaudeDriver { private handleEvent(event: ClaudeEvent): void { this._lastActivity = Date.now(); + // 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 */ } } switch (event.type) { + case "control_request": + void this.answerControlRequest(event); + return; + case "control_response": { + const response = (event.response ?? {}) as Record; + const waiter = this._controlWaiters.get(response.request_id as string); + if (!waiter) return; + this._controlWaiters.delete(response.request_id as string); + if (response.subtype === "error") waiter.reject(new Error(String(response.error ?? "control request failed"))); + else waiter.resolve(response.response); + return; + } case "system": { if (event.subtype === "init") { this._sessionId = (event.session_id as string) ?? null; + if (typeof event.model === "string") this._model = event.model; this._mcpServers = (event.mcp_servers as Array<{ name: string; status: string }>) ?? []; this._initWaiter?.resolve(); this._initWaiter = null; diff --git a/orchestrator/codex-driver.ts b/orchestrator/codex-driver.ts index cf11d9d..cd28907 100644 --- a/orchestrator/codex-driver.ts +++ b/orchestrator/codex-driver.ts @@ -57,6 +57,27 @@ export interface CodexSessionOptions { model?: string; } +/** Turn settings that can change between turns. */ +export interface CodexTurnSettings { + /** never | on-request | on-failure | untrusted */ + approvalPolicy: string; + 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). */ +export interface CodexApprovalRequest { + kind: "command" | "fileChange" | "permissions"; + method: string; + params: Record; +} + +export type CodexApprovalDecision = "accept" | "acceptForSession" | "decline" | "cancel"; +export type CodexApprovalHandler = (req: CodexApprovalRequest) => Promise; + export interface CodexSpawnOptions { /** Command to run instead of `codex app-server` (tests use a fake). */ command?: string[]; @@ -129,6 +150,9 @@ export class CodexDriver { * Unlike _lastActivity, this is NOT updated by heartbeat responses. */ private _lastNotificationActivity = Date.now(); private _approvalsReviewer: string | null = null; + private _approvalHandler: CodexApprovalHandler | null = null; + /** Applied to every thread/start, thread/resume and turn/start from now on. */ + settings: CodexTurnSettings = { approvalPolicy: "never" }; private _threadStatus: string = "notLoaded"; private _activeFlags: string[] = []; private _activeItems = 0; @@ -201,6 +225,9 @@ export class CodexDriver { this._onExitCallbacks.push(cb); } + /** Who answers approval requests; without one they are accepted for the session. */ + onApprovalRequest(handler: CodexApprovalHandler): void { this._approvalHandler = handler; } + /** Register a listener for notifications from the app-server. */ onNotification(cb: NotificationListener): void { this._notificationListeners.push(cb); } @@ -247,7 +274,7 @@ export class CodexDriver { async startSession(opts: CodexSessionOptions): Promise { log(LOG_PREFIX, `Starting session: ${opts.prompt.slice(0, 100)}...`); - const threadParams: Record = { approvalPolicy: "never" }; + const threadParams: Record = { approvalPolicy: this.settings.approvalPolicy }; if (opts.cwd) threadParams.cwd = opts.cwd; if (opts.sandbox) threadParams.sandbox = opts.sandbox; if (opts.developerInstructions) threadParams.developerInstructions = opts.developerInstructions; @@ -269,7 +296,7 @@ export class CodexDriver { const turnInput: Record = { threadId, input: [{ type: "text", text: opts.prompt }], - approvalPolicy: "never", + approvalPolicy: this.settings.approvalPolicy, }; if (opts.cwd) turnInput.cwd = opts.cwd; if (opts.sandbox) turnInput.sandboxPolicy = sandboxPolicyObject(opts.sandbox, opts.cwd); @@ -285,7 +312,7 @@ export class CodexDriver { * continue it. Throws if the app-server does not know the thread. */ async resumeSession(opts: { threadId: string } & Omit): Promise { - const params: Record = { threadId: opts.threadId, approvalPolicy: "never" }; + const params: Record = { threadId: opts.threadId, approvalPolicy: this.settings.approvalPolicy }; if (opts.cwd) params.cwd = opts.cwd; if (opts.sandbox) params.sandbox = opts.sandbox; if (opts.developerInstructions) params.developerInstructions = opts.developerInstructions; @@ -310,7 +337,7 @@ export class CodexDriver { const result = await this.startTurnAndWait(threadId, { threadId, input: [{ type: "text", text: prompt }], - approvalPolicy: "never", + approvalPolicy: this.settings.approvalPolicy, }); log(LOG_PREFIX, `Reply complete: content=${result.content.slice(0, 100)}...`); @@ -353,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(); @@ -369,6 +401,10 @@ export class CodexDriver { params: Record, timeoutMs = 10 * 60_000, ): Promise { + 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 }; }; @@ -651,10 +687,18 @@ export class CodexDriver { switch (method) { case "item/commandExecution/requestApproval": case "item/fileChange/requestApproval": - return this.writeResponse(id, { result: { decision: "acceptForSession" } }); + if (!this._approvalHandler) return this.writeResponse(id, { result: { decision: "acceptForSession" } }); + void this.askApproval(method === "item/fileChange/requestApproval" ? "fileChange" : "command", method, params) + .then((decision) => this.writeResponse(id, { result: { decision } })); + return; case "execCommandApproval": - case "applyPatchApproval": - return this.writeResponse(id, { result: { decision: "approved_for_session" } }); + case "applyPatchApproval": { + if (!this._approvalHandler) return this.writeResponse(id, { result: { decision: "approved_for_session" } }); + const legacy: Record = { accept: "approved", acceptForSession: "approved_for_session", decline: "denied", cancel: "abort" }; + void this.askApproval(method === "applyPatchApproval" ? "fileChange" : "command", method, params) + .then((decision) => this.writeResponse(id, { result: { decision: legacy[decision] } })); + return; + } case "item/permissions/requestApproval": return this.writeResponse(id, { result: { permissions: params.permissions ?? {}, scope: "session" } }); case "item/tool/requestUserInput": @@ -673,6 +717,15 @@ export class CodexDriver { } } + private async askApproval(kind: CodexApprovalRequest["kind"], method: string, params: Record): Promise { + try { + return await this._approvalHandler!({ kind, method, params }); + } catch (err) { + log(LOG_PREFIX, `Approval handler failed (${err instanceof Error ? err.message : err}) — declining`); + return "decline"; + } + } + private async readStderr(): Promise { const decoder = new TextDecoder(); const reader = this.proc.stderr.getReader(); diff --git a/orchestrator/delivery.ts b/orchestrator/delivery.ts index 641d990..b7f2302 100644 --- a/orchestrator/delivery.ts +++ b/orchestrator/delivery.ts @@ -9,14 +9,39 @@ 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"; const LOG_PREFIX = "delivery"; +/** Settings last applied to a running driver (compared with the slot's to apply changes live). */ +export interface AppliedSettings { + model?: string; + effort?: string; + approvalMode: string; +} + +/** The settings an agent was launched with ("launch" for Claude's auto mode, which is switched to once running). */ +export function launchedSettings(kind: "claude" | "codex", s: { model?: string; effort?: string; approvalMode?: string }): AppliedSettings { + const mode = s.approvalMode ?? "bypass"; + return { model: s.model, effort: s.effort, approvalMode: kind === "claude" && mode === "auto" ? "launch" : mode }; +} + +/** Supervision state kept next to every driver. */ +interface DriverSupervision { + busy: boolean; + lastNudge: number; + releasedAt?: number; + applied?: AppliedSettings; + /** The running turn was interrupted because the slot got paused */ + pauseInterrupted?: boolean; +} + export type DriverSlot = - | { kind: "codex"; driver: CodexDriver; threadId: string | null; busy: boolean; lastNudge: number; releasedAt?: number } - | { kind: "claude"; driver: ClaudeDriver; busy: boolean; lastNudge: number; releasedAt?: number; inflight?: Set }; + | ({ kind: "codex"; driver: CodexDriver; threadId: string | null } & 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"; @@ -25,6 +50,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/launcher.ts b/orchestrator/launcher.ts index 61dcfc9..697d751 100644 --- a/orchestrator/launcher.ts +++ b/orchestrator/launcher.ts @@ -10,15 +10,18 @@ // ============================================================================ import type { Subprocess } from "bun"; -import type { AgentType, AgentLaunchConfig, Slot } from "../shared/types.ts"; +import type { AgentType, AgentLaunchConfig, ApprovalMode, Slot } from "../shared/types.ts"; import type { BrokerClient } from "../shared/broker-client.ts"; import { log } from "../shared/utils.ts"; 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, CLAUDE_PERMISSION_MODE, parseApprovalMode } from "./activity.ts"; import * as path from "node:path"; import * as fs from "node:fs"; @@ -74,23 +77,38 @@ 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 }; } /** CLI binary name for each agent type. */ const AGENT_COMMANDS: Record = { claude: "claude", codex: "codex", - gemini: "npx", + gemini: process.env.MULTIAGENTS_ANTIGRAVITY_PATH || process.env.MULTIAGENTS_GEMINI_PATH || "agy", + api: "", 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", + gemini: "MULTIAGENTS_ANTIGRAVITY_PATH", +}; + /** * Detect whether an agent CLI is installed and available on PATH. * 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 }; @@ -98,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, @@ -113,12 +143,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; @@ -215,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 --- @@ -295,6 +367,36 @@ interface AgentIdentity { name: string; role: string; roleDescription: string; + /** Per-agent settings, kept in the slot snapshot so respawns reuse them */ + 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; 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); + const limit = Number(snap.token_limit); + return { + model: text(snap.model), + effort: text(snap.effort), + approvalMode: parseApprovalMode(snap.approval_mode), + tokenLimit: Number.isFinite(limit) && limit > 0 ? limit : undefined, + providerId: text(snap.provider_id), + }; +} + +function launchSettings(identity: AgentIdentity): NonNullable { + return { model: identity.model, effort: identity.effort, approvalMode: identity.approvalMode ?? "bypass" }; +} + +/** The mode a Claude session is launched in; "auto" is switched to once it runs. */ +function claudeLaunchMode(mode: ApprovalMode): "bypassPermissions" | "acceptEdits" | "default" { + return mode === "bypass" ? "bypassPermissions" : mode === "accept_edits" ? "acceptEdits" : "default"; } /** Env for a spawned agent: enriched PATH, session/slot identity, no nested-Claude marker. */ @@ -307,6 +409,18 @@ function agentEnv(identity: AgentIdentity): Record { MULTIAGENTS_NAME: identity.name, MULTIAGENTS_SLOT: String(identity.slotId), }; + // 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; delete env.CLAUDE_CODE_ENTRYPOINT; @@ -421,8 +535,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, @@ -477,7 +591,9 @@ async function spawnClaudeAgent( mcpConfig: claudeMcpConfig(identity, { driverMode: true }), appendSystemPrompt: claudeSystemPromptAppend(identity.role, identity.roleDescription), maxTurns: CLAUDE_MAX_TURNS, - model: process.env.MULTIAGENTS_CLAUDE_MODEL || undefined, + model: identity.model || process.env.MULTIAGENTS_CLAUDE_MODEL || undefined, + effort: identity.effort, + permissionMode: claudeLaunchMode(identity.approvalMode ?? "bypass"), }; // The session reports init only after its first message, so the prompt goes @@ -506,7 +622,48 @@ async function spawnClaudeAgent( log(LOG_PREFIX, `Claude ${identity.name} (pid ${driver.pid}, session ${driver.sessionId}) started`); - return { slotId: identity.slotId, pid: driver.pid, process: driver.process, claudeDriver: driver }; + 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, agentType: "api" }).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) }; } /** @@ -535,6 +692,7 @@ async function spawnCodexAgent( env.MULTIAGENTS_DRIVER_MODE = "1"; const driver = await CodexDriver.spawn(identity.projectDir, env); + driver.settings = { approvalPolicy: CODEX_APPROVAL_POLICY[identity.approvalMode ?? "bypass"], model: identity.model, effort: identity.effort }; log(LOG_PREFIX, `CodexDriver spawned for ${identity.name} in slot ${identity.slotId}`); await brokerClient.updateSlot({ id: identity.slotId, status: "connected" }); @@ -548,6 +706,7 @@ async function spawnCodexAgent( cwd: identity.projectDir, sandbox: "workspace-write", developerInstructions: codexDeveloperInstructions(identity), + model: identity.model, }); log(LOG_PREFIX, `${identity.name} bootstrap complete: thread=${bootstrap.threadId} (${bootstrap.content.slice(0, 50)})`); @@ -578,7 +737,7 @@ async function spawnCodexAgent( } })(); - return { slotId: identity.slotId, pid: driver.pid, process: driver.process, codexDriver: driver }; + return { slotId: identity.slotId, pid: driver.pid, process: driver.process, codexDriver: driver, settings: launchSettings(identity) }; } /** Spawn a one-shot Gemini (or custom) process; messages reach it through tool results. */ @@ -587,7 +746,7 @@ function spawnProcessAgent(agentType: AgentType, identity: AgentIdentity, taskPr if (!cmd) { throw new Error(`No CLI command configured for agent type: ${agentType}`); } - const proc = Bun.spawn([cmd, ...buildCliArgs(agentType, taskPrompt)], { + const proc = Bun.spawn([cmd, ...buildCliArgs(agentType, taskPrompt, { model: identity.model })], { cwd: identity.projectDir, stdin: "pipe", stdout: "pipe", @@ -636,6 +795,15 @@ export async function launchAgent( log(LOG_PREFIX, `Created slot ${slot.id} for ${config.name} (${config.agent_type})`); + 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, + ...(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) { await brokerClient.assignOwnership({ session_id: sessionId, @@ -663,9 +831,15 @@ export async function launchAgent( name: config.name, role: config.role, roleDescription: enrichedDescription, + 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}`); @@ -720,6 +894,7 @@ export async function relaunchIntoSlot( role_description: slot.role_description ?? "", initial_task: handoffTask, }, projectDir), + ...slotSettings(slot), }; switch (slot.agent_type) { @@ -734,6 +909,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}`); @@ -746,21 +923,22 @@ export async function relaunchIntoSlot( * Build CLI arguments for the process-based agent types. Claude and Codex are * driven over their session protocols instead (see ClaudeDriver/CodexDriver). */ -export function buildCliArgs(agentType: AgentType, task: string): string[] { +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", - "-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/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/monitor.ts b/orchestrator/monitor.ts index d9e088a..50e1dcc 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"; @@ -16,6 +16,8 @@ type ProcessReadableStream = Exclude(); +/** Last running cost a Claude session reported, per slot (its result events carry the session total). */ +const lastCost = new Map(); // Input-side usage already metered from a Claude turn's API calls (keyed by message id), settled by its result event const claudeTurnInput = new Map }>(); @@ -98,10 +100,12 @@ export function monitorProcess( sessionId: string, brokerClient: BrokerClient, onEvent: (event: AgentEvent) => void, + /** Every JSON line the process prints (the activity timeline reads Gemini's stream-json here) */ + onJson?: (parsed: any) => void, ): void { // Read stdout for JSON progress lines if (isReadableStream(proc.stdout)) { - readStream(proc.stdout, slotId, sessionId, brokerClient, onEvent); + readStream(proc.stdout, slotId, sessionId, brokerClient, onEvent, onJson); } // Read stderr for error output @@ -122,6 +126,7 @@ async function readStream( sessionId: string, brokerClient: BrokerClient, onEvent: (event: AgentEvent) => void, + onJson?: (parsed: any) => void, ): Promise { const decoder = new TextDecoder(); let buffer = ""; @@ -139,7 +144,7 @@ async function readStream( for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; - await processLine(trimmed, slotId, sessionId, brokerClient, onEvent); + await processLine(trimmed, slotId, sessionId, brokerClient, onEvent, onJson); } } } catch (err) { @@ -154,6 +159,7 @@ const transitionedToWorking = new Set(); /** Clear all tracking state for a slot (call on release/session end). */ export function clearSlotTracking(slotId: number): void { lastTokenTotals.delete(slotId); + lastCost.delete(slotId); claudeTurnInput.delete(slotId); transitionedToWorking.delete(slotId); } @@ -161,6 +167,7 @@ export function clearSlotTracking(slotId: number): void { /** Clear all tracking state (call on full session cleanup). */ export function clearAllTracking(): void { lastTokenTotals.clear(); + lastCost.clear(); claudeTurnInput.clear(); transitionedToWorking.clear(); } @@ -184,6 +191,7 @@ async function processLine( sessionId: string, brokerClient: BrokerClient, onEvent: (event: AgentEvent) => void, + onJson?: (parsed: any) => void, ): Promise { let parsed: any; try { @@ -191,6 +199,7 @@ async function processLine( } catch { return; // Not JSON — plain text output, ignore } + try { onJson?.(parsed); } catch { /* the timeline must not break monitoring */ } await processEvent(parsed, slotId, sessionId, brokerClient, onEvent); } @@ -214,6 +223,24 @@ export async function processEvent( data: { result: parsed.result, subtype: parsed.subtype }, }); + // Claude reports the session's running cost; meter the increase (a new process starts over) + if (typeof parsed.total_cost_usd === "number") { + const last = lastCost.get(slotId) ?? 0; + const delta = parsed.total_cost_usd >= last ? parsed.total_cost_usd - last : parsed.total_cost_usd; + lastCost.set(slotId, parsed.total_cost_usd); + if (delta > 0) await brokerClient.updateSlot({ id: slotId, cost_usd: delta }).catch(() => {}); + } + + // Gemini stream-json: one run's totals in `stats` + if (parsed.stats && !parsed.usage) { + const st = parsed.stats; + await updateTokenUsage(slotId, { + input: st.input_tokens ?? st.prompt_tokens ?? 0, + output: st.output_tokens ?? st.candidates_tokens ?? 0, + cacheRead: st.cached ?? st.cached_tokens ?? 0, + }, brokerClient, "per_turn"); + } + // The turn's usage minus what its assistant events already metered; output is only known here. const usage = parsed.usage ?? parsed.result?.usage; if (usage) { @@ -330,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, @@ -390,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/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/plan-usage.ts b/orchestrator/plan-usage.ts new file mode 100644 index 0000000..d7a44c4 --- /dev/null +++ b/orchestrator/plan-usage.ts @@ -0,0 +1,321 @@ +// ============================================================================ +// 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(() => driver!.request("account/read", null, 15_000)) + .catch(() => null) as { account?: { planType?: string; type?: string } | null } | null; + let answer: Record; + try { + // 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)." }); + 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 (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 (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." }); + } + 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) { + 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 subscription: workspace internal auth, no window limits." : "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/recovery.ts b/orchestrator/recovery.ts index f861000..598ad70 100644 --- a/orchestrator/recovery.ts +++ b/orchestrator/recovery.ts @@ -8,10 +8,11 @@ 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 } from "./launcher.ts"; +import { relaunchIntoSlot, buildTeamContext, ENRICHED_PATH, codexDeveloperInstructions, slotSettings } from "./launcher.ts"; +import { CODEX_APPROVAL_POLICY } from "./activity.ts"; const LOG_PREFIX = "recovery"; @@ -126,7 +127,8 @@ export interface RespawnResult { pid: number; process: import("bun").Subprocess; codexDriver?: CodexDriver; - claudeDriver?: ClaudeDriver; + claudeDriver?: ClaudeSession; + settings?: { model?: string; effort?: string; approvalMode?: string }; } /** @@ -213,6 +215,8 @@ export async function respawnAgent( delete spawnEnv.CLAUDECODE; const driver = await CD.spawn(projectDir, spawnEnv); + const settings = slotSettings(slot); + driver.settings = { approvalPolicy: CODEX_APPROVAL_POLICY[settings.approvalMode], model: settings.model, effort: settings.effort }; await brokerClient.updateSlot({ id: slotId, status: "connected" }); const developerInstructions = codexDeveloperInstructions(identity); @@ -257,7 +261,7 @@ export async function respawnAgent( }).catch((err) => log(LOG_PREFIX, `Codex respawn task failed: ${err}`)); log(LOG_PREFIX, `Respawned Codex into slot ${slotId} via CodexDriver (PID ${driver.pid})`); - return { pid: driver.pid, process: driver.process, codexDriver: driver }; + return { pid: driver.pid, process: driver.process, codexDriver: driver, settings }; } // Claude (driver, resumes its session when the snapshot has one) and Gemini (process) @@ -265,5 +269,5 @@ export async function respawnAgent( log(LOG_PREFIX, `Respawned into existing slot ${slotId} (PID ${result.pid})`); - return { pid: result.pid, process: result.process, codexDriver: result.codexDriver, claudeDriver: result.claudeDriver }; + return { pid: result.pid, process: result.process, codexDriver: result.codexDriver, claudeDriver: result.claudeDriver, settings: result.settings }; } diff --git a/orchestrator/runtime.ts b/orchestrator/runtime.ts new file mode 100644 index 0000000..d6a7b66 --- /dev/null +++ b/orchestrator/runtime.ts @@ -0,0 +1,2373 @@ +// ============================================================================ +// 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, Slot } from "../shared/types.ts"; +import { log, getGitRoot, uniqueSessionId, formatDuration, safeJsonParse } from "../shared/utils.ts"; +import { + GUARDRAIL_CHECK_INTERVAL, + CONFLICT_CHECK_INTERVAL, + FLAP_WINDOW_MS, + STUCK_THRESHOLD_MS, + BROKER_FEATURES, +} from "../shared/constants.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"; +import { monitorProcess, monitorCodexDriver, monitorClaudeDriver, clearSlotTracking, clearAllTracking, type AgentEvent } from "./monitor.ts"; +import { deliverPendingMessages, launchedSettings, type DriverSlot } from "./delivery.ts"; +import { RespawnGuard } from "./respawn-guard.ts"; +import type { CodexDriver } from "./codex-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"; +import { controlSession, broadcastToTeam, resolveTarget, pauseAgent } from "./session-control.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"; + +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(); + +// --- 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; + codexDriver?: CodexDriver; + claudeDriver?: ClaudeSession; + settings?: { model?: string; effort?: string; approvalMode?: string }; +} + +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) { + const recorder = new ActivityRecorder(sessionId, slotId, brokerClient); + 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; + } + + if (result.settings) state.applied = launchedSettings(state.kind, result.settings); + 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); + } + superviseDriver(sessionId, slotId, state, brokerClient); + state.driver.onExit((code) => onDriverExit(sessionId, slotId, state, code, brokerClient)); +} + +/** Key under which "always allow" remembers a tool (a shell command by its program). */ +function approvalKey(tool: string, input: Record): string { + if (tool === "Bash" && typeof input.command === "string") return `Bash:${input.command.trim().split(/\s+/)[0] ?? ""}`; + return tool; +} + +/** + * Feed the driver's steps into the activity timeline and route its tool + * approvals to a person (through the broker, so any dashboard can answer). + */ +function superviseDriver(sessionId: string, slotId: number, state: DriverSlot, brokerClient: BrokerClient): void { + 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(); + }); + + const ask = async (tool: string, title: string, input: unknown) => { + recorder.push({ kind: "approval", title: `Waiting for approval · ${title}`, data: { tool, input } }); + await recorder.flush(); + const outcome = await awaitApproval(brokerClient, { sessionId, slotId, tool, title, input }, stillWanted); + recorder.push({ + kind: "approval", + title: `${outcome.allowed ? (outcome.always ? "Always allowed" : "Approved") : outcome.note === "cancelled" ? "Cancelled" : "Denied"} · ${title}`, + detail: outcome.note && outcome.note !== "cancelled" ? outcome.note : null, + data: { tool, allowed: outcome.allowed }, + }); + return outcome; + }; + + if (state.kind === "claude") { + const tools = new Map(); + 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); + if (!outcome.allowed) { + return { behavior: "deny", message: outcome.note && outcome.note !== "cancelled" ? `A person denied this tool call: ${outcome.note}` : "A person denied this tool call." }; + } + if (outcome.always) alwaysAllowed.add(key); + return { behavior: "allow", always: outcome.always }; + }); + } else { + const items = new Map>(); // started items, for the context of an approval + state.driver.onNotification(({ method, params }) => { + if (method === "item/started" && params.item && typeof (params.item as { id?: unknown }).id === "string") { + 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) ?? []; + const title = kind === "command" + ? `$ ${command ?? "shell command"}` + : changes.length ? `Edit ${changes.map((c) => c.path).join(", ")}` : "Apply file changes"; + const tool = kind === "command" ? "shell" : kind === "fileChange" ? "apply_patch" : "permissions"; + const key = kind === "command" ? `shell:${(command ?? "").trim().split(/\s+/)[0] ?? ""}` : tool; + if (alwaysAllowed.has(key)) return "accept"; + const outcome = await ask(tool, title, { ...params, ...(item?.changes ? { changes: item.changes } : {}), ...(command ? { command } : {}) }); + if (!outcome.allowed) return "decline"; + if (outcome.always) { alwaysAllowed.add(key); return "acceptForSession"; } + return "accept"; + }); + } + (state as DriverSlot & { recorder?: ActivityRecorder }).recorder = recorder; +} + +/** Recorder attached by superviseDriver (for status lines from the loops). */ +function recorderOf(state: DriverSlot): ActivityRecorder | undefined { + return (state as DriverSlot & { recorder?: ActivityRecorder }).recorder; +} + +/** + * Bring a running driver in line with its slot: model, effort and approval + * 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, 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); + const applied = state.applied; + + if (state.kind === "claude") { + if (applied.approvalMode !== want.approvalMode) { + applied.approvalMode = want.approvalMode; + try { + await state.driver.setPermissionMode(CLAUDE_PERMISSION_MODE[want.approvalMode]); + recorder?.push({ kind: "status", title: `Approval mode → ${want.approvalMode}` }); + } catch (err) { + recorder?.push({ kind: "error", title: `Could not switch to approval mode ${want.approvalMode}`, detail: String(err instanceof Error ? err.message : err) }); + } + } + if (applied.model !== want.model) { + applied.model = want.model; + try { + await state.driver.setModel(want.model); + recorder?.push({ kind: "status", title: `Model → ${want.model ?? "default"}` }); + } catch (err) { + recorder?.push({ kind: "error", title: `Could not switch to model ${want.model ?? "default"}`, detail: String(err instanceof Error ? err.message : err) }); + } + } + if (applied.effort !== want.effort) { + applied.effort = want.effort; + recorder?.push({ kind: "status", title: `Effort → ${want.effort ?? "default"} (applies when the agent restarts)` }); + } + } else { + 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) { + state.pauseInterrupted = false; + } else if (!state.pauseInterrupted) { + state.pauseInterrupted = 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: "Paused — the running turn was interrupted" }); + } catch (err) { + log(LOG_PREFIX, `Interrupt on pause failed for slot ${slot.id}: ${err}`); + } + } + } +} + +/** + * Enforce the session's usage limits: an agent over its limit (or any agent of + * a session over the session limit) is paused, and its running turn stops. + */ +async function enforceUsageLimits(sessionId: string, brokerClient: BrokerClient): Promise { + const session = await brokerClient.getSession(sessionId); + const limits = (safeJsonParse>(session.config, {}).limits ?? {}) as UsageLimits; + const slots = await brokerClient.listSlots(sessionId); + for (const slot of slots) { + if (slot.task_state === "released") continue; + const reason = limitVerdict(slot, slots, limits, slotSettings(slot).tokenLimit); + const snapshot = safeJsonParse>(slot.context_snapshot, {}); + if (!reason) { + if (snapshot.limit_hit) await brokerClient.updateSlot({ id: slot.id, context_patch: { limit_hit: null } }); + continue; + } + if (snapshot.limit_hit !== reason) await brokerClient.updateSlot({ id: slot.id, context_patch: { limit_hit: reason } }); + const paused = slot.paused === true || (slot.paused as unknown as number) === 1; + if (paused) continue; + log(LOG_PREFIX, `${slot.display_name ?? `Slot ${slot.id}`}: ${reason} — pausing`); + await pauseAgent(sessionId, slot, brokerClient); + const state = activeDrivers.get(sessionId, slot.id); + if (state) { + recorderOf(state)?.push({ kind: "error", title: `Stopped: ${reason}`, detail: "Raise the limit in the dashboard, then resume the agent." }); + try { + if (state.kind === "claude") await state.driver.interrupt(); + else await state.driver.interrupt(state.threadId ?? state.driver.threadId ?? ""); + } catch { /* best effort */ } + } + pendingEvents.push({ type: "limit_reached", severity: "warning", slotId: slot.id, sessionId, message: `${slot.display_name ?? `Slot ${slot.id}`} paused: ${reason}` }); + } +} + +/** 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 --- + +/** 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)); +} + +/** + * 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 + * 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"); + 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) + 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) { + 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", "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." }, + 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/**'" }, + model: { type: "string", description: "Model for this agent, e.g. 'opus' / 'sonnet' (claude), 'gpt-5' (codex), 'gemini-2.5-pro' (gemini). Default: the CLI's default." }, + effort: { type: "string", description: "Reasoning effort: claude low|medium|high|xhigh|max, codex minimal|low|medium|high." }, + approval_mode: { type: "string", enum: ["bypass", "auto", "accept_edits", "ask"], description: "Who approves tool calls: bypass (never ask, default), auto (the CLI's reviewer; risky calls go to a person), accept_edits (edits pass, the rest asks), ask (a person approves every call). Gemini supports only bypass." }, + }, + 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", "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." }, + 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" }, + model: { type: "string", description: "Model for this agent, e.g. 'opus' / 'sonnet' (claude), 'gpt-5' (codex), 'gemini-2.5-pro' (gemini). Default: the CLI's default." }, + effort: { type: "string", description: "Reasoning effort: claude low|medium|high|xhigh|max, codex minimal|low|medium|high." }, + approval_mode: { type: "string", enum: ["bypass", "auto", "accept_edits", "ask"], description: "Who approves tool calls: bypass (never ask, default), auto (the CLI's reviewer; risky calls go to a person), accept_edits (edits pass, the rest asks), ask (a person approves every call). Gemini supports only bypass." }, + }, + 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" | "api"> = ["claude", "codex", "gemini", "api"]; + 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 existingIds: string[] = []; + try { + 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)) { + 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, model, effort, approval_mode, provider } = args as { + session_id: string; + agent_type: "claude" | "codex" | "gemini" | "api"; + provider?: string; + name: string; + role: string; + role_description: string; + initial_task: string; + file_ownership?: string[]; + model?: string; + effort?: string; + approval_mode?: AgentLaunchConfig["approval_mode"]; + }; + + const detection = await detectAgent(agent_type); + if (!detection.available) { + return fail(agent_type === "api" ? "Error: no API provider is configured — add one in the dashboard (API providers)." : `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, + model, + effort, + approval_mode, + provider, + }; + + 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; +const USAGE_LIMIT_CHECK_MS = Number(process.env.MULTIAGENTS_LIMIT_CHECK_MS) || 10_000; + +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); + + // 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) { + 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); + 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 */ } + } + }, 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()) { + 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, qa); } catch (err) { log(LOG_PREFIX, `Settings sync failed for slot ${slotId}: ${err}`); } + } + + 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); + + // Usage limits — pause agents that reached their token/cost limit + setInterval(nonOverlapping(async () => { + for (const sessionId of activeProcesses.keys()) { + try { await enforceUsageLimits(sessionId, brokerClient); } catch (err) { log(LOG_PREFIX, `Limit check failed for ${sessionId}: ${err}`); } + } + }), USAGE_LIMIT_CHECK_MS); + + // 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); + + // 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 { + // 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; + + // --- 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.`, + }); + } + } + + // --- 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" + ); + 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 { + // 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; + 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(); + + 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; + } + + // 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/orchestrator/supervision.ts b/orchestrator/supervision.ts index 755a22d..fb1ce9b 100644 --- a/orchestrator/supervision.ts +++ b/orchestrator/supervision.ts @@ -8,6 +8,45 @@ import type { DriverSlot } from "./delivery.ts"; import type { PeekResult } from "../shared/types.ts"; +export interface UsageLimits { + /** input + output tokens per agent */ + agent_tokens?: number; + agent_cost_usd?: number; + /** input + output tokens summed over the session's agents */ + session_tokens?: number; + session_cost_usd?: number; +} + +export interface SlotUsage { + input_tokens?: number | null; + output_tokens?: number | null; + cost_usd?: number | null; +} + +export function slotTokens(slot: SlotUsage): number { + return (slot.input_tokens ?? 0) + (slot.output_tokens ?? 0); +} + +/** + * Why an agent must stop (a limit it reached), or null. `agentTokenLimit` is + * the agent's own limit when set, else the session's per-agent limit. + */ +export function limitVerdict(slot: SlotUsage, all: SlotUsage[], limits: UsageLimits, agentTokenLimit?: number): string | null { + const positive = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined); + const perAgent = positive(agentTokenLimit) ?? positive(limits.agent_tokens); + const tokens = slotTokens(slot); + if (perAgent && tokens >= perAgent) return `token limit reached (${tokens.toLocaleString("en-US")} / ${perAgent.toLocaleString("en-US")})`; + const agentCost = positive(limits.agent_cost_usd); + if (agentCost && (slot.cost_usd ?? 0) >= agentCost) return `cost limit reached ($${(slot.cost_usd ?? 0).toFixed(2)} / $${agentCost.toFixed(2)})`; + const sessionTokens = positive(limits.session_tokens); + const total = all.reduce((a, s) => a + slotTokens(s), 0); + if (sessionTokens && total >= sessionTokens) return `session token limit reached (${total.toLocaleString("en-US")} / ${sessionTokens.toLocaleString("en-US")})`; + const sessionCost = positive(limits.session_cost_usd); + const cost = all.reduce((a, s) => a + (s.cost_usd ?? 0), 0); + if (sessionCost && cost >= sessionCost) return `session cost limit reached ($${cost.toFixed(2)} / $${sessionCost.toFixed(2)})`; + return null; +} + /** Driver-managed agents per session; a slot holds at most one driver. */ export class DriverRegistry { private bySession = new Map>(); @@ -124,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/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/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/shared/broker-client.ts b/shared/broker-client.ts index a93b05d..0301fff 100644 --- a/shared/broker-client.ts +++ b/shared/broker-client.ts @@ -41,6 +41,12 @@ import type { KnowledgeGetRequest, KnowledgeDeleteRequest, KnowledgeCategory, + AddPlanItemRequest, + EditPlanItemRequest, + ActivityAddRequest, + ActivityEntry, + LiveActivity, + ApprovalRequest, } from "./types.ts"; /** HTTP client for the multiagents broker API. */ @@ -252,12 +258,62 @@ 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 }> { return this.post("/slots/delete", { id: slotId }); } + // --- Activity timeline & approvals --- + + addActivity(req: ActivityAddRequest): Promise<{ ok: boolean; added: number }> { + if (req.entries.length === 0) return Promise.resolve({ ok: true, added: 0 }); + return this.post("/activity/add", req); + } + + listActivity(req: { session_id: string; slot_id?: number; after_id?: number; limit?: number }): Promise { + 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); + } + + getApproval(id: number): Promise { + return this.post("/approvals/get", { id }); + } + + listApprovals(sessionId: string, status?: string): Promise { + return this.post("/approvals/list", { session_id: sessionId, status }); + } + + decideApproval(req: { id: number; session_id: string; decision: "allow" | "deny" | "cancel"; note?: string; always?: boolean; decided_by?: string }): Promise { + return this.post("/approvals/decide", req); + } + // --- Knowledge Store --- putKnowledge(req: KnowledgePutRequest): Promise { diff --git a/shared/constants.ts b/shared/constants.ts index 51f7f6f..d0f8c31 100644 --- a/shared/constants.ts +++ b/shared/constants.ts @@ -18,6 +18,7 @@ export const POLL_INTERVALS: 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", }; @@ -148,3 +150,18 @@ 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")'; + +/** + * 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/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/session-ops.ts b/shared/session-ops.ts new file mode 100644 index 0000000..b21e147 --- /dev/null +++ b/shared/session-ops.ts @@ -0,0 +1,59 @@ +// ============================================================================ +// 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, uniqueSessionId } 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); + 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 }); + + 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..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 = @@ -128,6 +129,8 @@ export interface Slot { input_tokens: number; output_tokens: number; cache_read_tokens: number; + /** USD as reported by the CLI (Claude); 0 where the CLI reports none */ + cost_usd: number; } export interface FileLock { @@ -321,6 +324,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 { @@ -347,6 +369,8 @@ export interface UpdateSlotRequest { input_tokens?: number; output_tokens?: number; cache_read_tokens?: number; + /** Added to the slot's running cost */ + cost_usd?: number; } // --- Lifecycle handoff requests --- @@ -429,6 +453,79 @@ export interface AgentLaunchConfig { initial_task: string; file_ownership?: string[]; report_to?: string; + /** Model name or alias for the CLI (claude: opus/sonnet/haiku…, codex: gpt-…, gemini: gemini-…). */ + model?: string; + /** Reasoning effort (claude: low…max, codex: minimal…high). */ + 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; +} + +/** + * How an agent's tool calls are approved: + * bypass — never ask (fully autonomous) + * auto — the CLI's own reviewer decides; risky calls come to a person (Claude "auto", Codex "on-request") + * accept_edits — file edits go through, commands and other tools ask (Claude only; Codex treats it like "ask") + * ask — every tool call waits for a person + */ +export type ApprovalMode = "bypass" | "auto" | "accept_edits" | "ask"; + +/** One step of an agent's work as shown in the activity timeline. */ +export type ActivityKind = + | "thinking" | "text" | "tool_call" | "tool_result" | "command" | "file_change" + | "approval" | "turn_end" | "status" | "error"; + +export interface ActivityEntry { + id: number; + session_id: string; + slot_id: number; + kind: ActivityKind; + title: string; + detail: string | null; + /** JSON: tool input, diff, exit code, usage… */ + data: string | null; + ts: number; +} + +export interface ActivityAddRequest { + session_id: string; + slot_id: number; + 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 { + id: number; + session_id: string; + slot_id: number; + tool: string; + title: string; + /** JSON of the tool input */ + input: string | null; + status: ApprovalStatus; + /** Allow this tool for the rest of the agent's session */ + always: number; + note: string | null; + decided_by: string | null; + created_at: number; + decided_at: number | null; } export interface TeamConfig { 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/activity.test.ts b/tests/activity.test.ts new file mode 100644 index 0000000..e0d8417 --- /dev/null +++ b/tests/activity.test.ts @@ -0,0 +1,80 @@ +/** The activity timeline's view of each CLI's stream, and the usage-limit verdicts. */ + +import { test, expect, describe } from "bun:test"; +import { claudeActivity, codexActivity, describeTool, geminiActivity, isTeamTool, parseApprovalMode } from "../orchestrator/activity.ts"; +import { limitVerdict } from "../orchestrator/supervision.ts"; + +describe("describeTool", () => { + test("names the file, command or pattern a call works on", () => { + expect(describeTool("Edit", { file_path: "src/a.ts" })).toBe("Edit src/a.ts"); + expect(describeTool("Bash", { command: "npm test" })).toBe("$ npm test"); + expect(describeTool("Grep", { pattern: "TODO", path: "src" })).toBe('Grep "TODO" in src'); + expect(describeTool("mcp__multiagents-peer__send_message", {})).toBe("Team: send_message"); + expect(describeTool("mcp__github__create_issue", {})).toBe("github · create_issue"); + expect(isTeamTool("mcp__multiagents-peer__check_messages")).toBe(true); + expect(isTeamTool("Read")).toBe(false); + }); +}); + +describe("claudeActivity", () => { + test("thinking, tool calls and their results become steps", () => { + const tools = new Map(); + const steps = claudeActivity({ + type: "assistant", + message: { content: [ + { type: "thinking", thinking: "Check the parser first" }, + { type: "tool_use", id: "t1", name: "Read", input: { file_path: "src/parser.ts" } }, + ] }, + }, tools); + expect(steps.map((s) => [s.kind, s.title])).toEqual([["thinking", "Thinking"], ["tool_call", "Read src/parser.ts"]]); + const result = claudeActivity({ type: "user", message: { content: [{ type: "tool_result", tool_use_id: "t1", content: [{ type: "text", text: "export const x" }] }] } }, tools); + expect(result[0]).toMatchObject({ kind: "tool_result", title: "Result · Read src/parser.ts", detail: "export const x" }); + // Replayed user messages (our own prompts echoed back) are not steps + expect(claudeActivity({ type: "user", isReplay: true, message: { content: [{ type: "tool_result", tool_use_id: "x" }] } }, tools)).toEqual([]); + expect(claudeActivity({ type: "result", total_cost_usd: 0.0123, usage: {} }, tools)[0]!.title).toBe("Turn finished · $0.0123 so far"); + }); +}); + +describe("codexActivity", () => { + test("commands carry their output and exit code; file changes their diffs", () => { + const [cmd] = codexActivity("item/completed", { item: { type: "commandExecution", command: "npm test", aggregatedOutput: "ok", exitCode: 0 } }); + expect(cmd).toMatchObject({ kind: "command", title: "$ npm test", detail: "ok" }); + const [fc] = codexActivity("item/completed", { item: { type: "fileChange", changes: [{ path: "a.ts", kind: { type: "add" }, diff: "+x" }] } }); + expect(fc).toMatchObject({ kind: "file_change", title: "Created a.ts" }); + expect((fc!.data as any).changes).toEqual([{ path: "a.ts", kind: "add", diff: "+x" }]); + const [think] = codexActivity("item/completed", { item: { type: "reasoning", summary: ["plan it"], content: [] } }); + expect(think).toMatchObject({ kind: "thinking", detail: "plan it" }); + expect(codexActivity("item/started", { item: { type: "commandExecution" } })).toEqual([]); + }); +}); + +describe("geminiActivity", () => { + test("tool calls use the same names as Claude's", () => { + expect(geminiActivity({ type: "tool_use", tool_name: "read_file", parameters: { file_path: "x.md" }, tool_id: "g1" })[0]).toMatchObject({ kind: "tool_call", title: "Read x.md" }); + expect(geminiActivity({ type: "tool_result", tool_id: "g1", status: "error", output: "nope" })[0]).toMatchObject({ kind: "tool_result", title: "Failed" }); + }); +}); + +describe("approval modes", () => { + test("unknown values fall back to fully autonomous", () => { + expect(parseApprovalMode("ask")).toBe("ask"); + expect(parseApprovalMode("whatever")).toBe("bypass"); + expect(parseApprovalMode(undefined)).toBe("bypass"); + }); +}); + +describe("limitVerdict", () => { + const a = { input_tokens: 800, output_tokens: 300, cost_usd: 0.5 }; + const b = { input_tokens: 100, output_tokens: 0, cost_usd: 0 }; + test("an agent's own limit wins over the per-agent default", () => { + expect(limitVerdict(a, [a, b], { agent_tokens: 5000 })).toBeNull(); + expect(limitVerdict(a, [a, b], { agent_tokens: 5000 }, 1000)).toMatch(/^token limit reached \(1,100 \/ 1,000\)/); + expect(limitVerdict(a, [a, b], { agent_tokens: 1000 })).toMatch(/^token limit/); + }); + test("session limits stop every agent", () => { + expect(limitVerdict(b, [a, b], { session_tokens: 1200 })).toMatch(/^session token limit reached/); + expect(limitVerdict(b, [a, b], { session_cost_usd: 0.4 })).toMatch(/^session cost limit/); + expect(limitVerdict(a, [a, b], { agent_cost_usd: 0.5 })).toMatch(/^cost limit/); + expect(limitVerdict(a, [a, b], {})).toBeNull(); + }); +}); diff --git a/tests/api-agent.test.ts b/tests/api-agent.test.ts new file mode 100644 index 0000000..5e91b5d --- /dev/null +++ b/tests/api-agent.test.ts @@ -0,0 +1,228 @@ +/** + * "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"; +import { realThroughExistingAncestor } from "../orchestrator/api-driver.ts"; + +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), 35_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(); + + // 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"])); + 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("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); + 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); +}); + +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 }); + }); +}); diff --git a/tests/broker-restart.test.ts b/tests/broker-restart.test.ts new file mode 100644 index 0000000..831ec36 --- /dev/null +++ b/tests/broker-restart.test.ts @@ -0,0 +1,95 @@ +/** + * 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); + +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); diff --git a/tests/claude-driver.test.ts b/tests/claude-driver.test.ts index 7abedba..85215fb 100644 --- a/tests/claude-driver.test.ts +++ b/tests/claude-driver.test.ts @@ -157,3 +157,73 @@ describe("ClaudeDriver lifecycle", () => { expect(driver.alive).toBe(false); }); }); + +describe("ClaudeDriver control protocol", () => { + test("permission modes: bypass skips checks; other modes route prompts to the host and keep bypass switchable", () => { + const bypass = buildClaudeArgs({ mcpConfig: MCP }); + expect(bypass[bypass.indexOf("--permission-prompt-tool") + 1]).toBe("stdio"); + expect(bypass).toContain("--dangerously-skip-permissions"); + + const edits = buildClaudeArgs({ mcpConfig: MCP, permissionMode: "acceptEdits", effort: "high" }); + expect(edits).not.toContain("--dangerously-skip-permissions"); + expect(edits).toContain("--allow-dangerously-skip-permissions"); + expect(edits[edits.indexOf("--permission-mode") + 1]).toBe("acceptEdits"); + expect(edits[edits.indexOf("--effort") + 1]).toBe("high"); + + const ask = buildClaudeArgs({ mcpConfig: MCP, permissionMode: "default" }); + expect(ask).not.toContain("--permission-mode"); + expect(ask).not.toContain("--dangerously-skip-permissions"); + }); + + test("a tool call waits for the host's decision; a denial reaches the model as a failed tool result", async () => { + const { driver } = await spawnFake({ permissionMode: "default" }); + const asked: string[] = []; + driver.onPermissionRequest(async (req) => { + asked.push(`${req.toolName}:${String(req.input.command ?? req.input.file_path)}`); + return req.toolName === "Bash" ? { behavior: "deny", message: "not now" } : { behavior: "allow" }; + }); + const results: Array<{ content: string; is_error: boolean }> = []; + driver.onEvent((e) => { + if (e.type === "user") for (const b of (e.message as any)?.content ?? []) if (b.type === "tool_result") results.push({ content: b.content, is_error: b.is_error }); + }); + const turn = await driver.prompt("go TOOL:Bash:rm-rf TOOL:Read:notes.md"); + expect(turn.subtype).toBe("success"); + expect(asked).toEqual(["Bash:rm-rf", "Read:notes.md"]); + expect(results).toEqual([{ content: "denied: not now", is_error: true }, { content: "ran Read notes.md", is_error: false }]); + }); + + test("a prompt that arrives before a handler is set waits for it", async () => { + const { driver } = await spawnFake({ permissionMode: "default" }); + const turn = driver.prompt("TOOL:Write:a.txt"); + await Bun.sleep(300); + driver.onPermissionRequest(async () => ({ behavior: "allow" })); + expect((await turn).subtype).toBe("success"); + }); + + test("set_model and set_permission_mode are sent as control requests; a refused mode name falls back to its alias", async () => { + const { driver, entries } = await spawnFake({ firstPrompt: "hi", model: "haiku" }, { FAKE_CLAUDE_REJECT_MODES: "default" }); + expect(driver.model).toBe("haiku"); + await driver.setModel("opus"); + expect(driver.model).toBe("opus"); + await driver.setPermissionMode("default"); + expect(driver.permissionMode).toBe("default"); + const sent = entries().filter((e) => e.stdin?.type === "control_request").map((e) => e.stdin.request); + expect(sent).toEqual([ + { subtype: "set_model", model: "opus" }, + { subtype: "set_permission_mode", mode: "default" }, + { subtype: "set_permission_mode", mode: "manual" }, + ]); + }); + + test("interrupt ends the running turn", async () => { + const { driver } = await spawnFake({ permissionMode: "default" }); + let release!: () => void; + driver.onPermissionRequest(() => new Promise((resolve) => { release = () => resolve({ behavior: "allow" }); })); + const turn = driver.prompt("TOOL:Bash:long TOOL:Bash:more"); + await waitFor(() => typeof release === "function"); + await driver.interrupt(); + release(); + const result = await turn; + expect(result.isError).toBe(true); + }); +}); diff --git a/tests/codex-driver.test.ts b/tests/codex-driver.test.ts index 34197da..791f84c 100644 --- a/tests/codex-driver.test.ts +++ b/tests/codex-driver.test.ts @@ -202,3 +202,42 @@ describe("CodexDriver process lifecycle", () => { expect(driver.alive).toBe(false); }); }); + +describe("CodexDriver approvals and turn settings", () => { + test("an approval handler decides command and file-change requests", async () => { + const { driver, dir, entries } = await spawnFake(); + const seen: string[] = []; + driver.onApprovalRequest(async (req) => { + seen.push(`${req.kind}:${String(req.params.command ?? req.params.itemId)}`); + return req.kind === "command" ? "decline" : "acceptForSession"; + }); + const first = await driver.startSession({ prompt: "warm up", cwd: dir }); + await driver.reply(first.threadId, "SCENARIO:command"); + await driver.reply(first.threadId, "SCENARIO:filechange"); + await driver.reply(first.threadId, "SCENARIO:legacy"); + expect(seen).toEqual(["command:rm -rf build", "fileChange:item_fc", "command:ls"]); + const responses = Object.fromEntries(entries().filter((e) => e.serverRequest).map((e) => [e.serverRequest, e.response.result])); + expect(responses["item/commandExecution/requestApproval"]).toEqual({ decision: "decline" }); + expect(responses["item/fileChange/requestApproval"]).toEqual({ decision: "acceptForSession" }); + expect(responses["execCommandApproval"]).toEqual({ decision: "denied" }); + }); + + test("settings changed between turns apply to the next turn/start", async () => { + const { driver, dir, entries } = await spawnFake(); + const first = await driver.startSession({ prompt: "warm up", cwd: dir }); + driver.settings = { approvalPolicy: "untrusted", model: "gpt-5", effort: "high" }; + await driver.reply(first.threadId, "next"); + const turns = entries().filter((e) => e.request === "turn/start").map((e) => e.params); + 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 4b81726..5bb568a 100644 --- a/tests/fakes/fake-claude.ts +++ b/tests/fakes/fake-claude.ts @@ -8,6 +8,12 @@ * arrive during it are absorbed into the same turn, as Claude Code does with * default-priority stream-json input. Argv and all stdin lines go to * $FAKE_CLAUDE_LOG for assertions. + * + * Control protocol (as the Agent SDK speaks it): "TOOL::" in a + * prompt asks the host for permission with a can_use_tool control_request + * (unless the session bypasses permissions) and runs the tool only if allowed; + * "THINK:" emits a thinking block. set_model, set_permission_mode and + * interrupt requests are answered; a mode in $FAKE_CLAUDE_REJECT_MODES is refused. */ import * as fs from "node:fs"; @@ -22,6 +28,15 @@ 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"); +let permissionMode = args.includes("--dangerously-skip-permissions") ? "bypassPermissions" : modeIdx !== -1 ? args[modeIdx + 1]! : "default"; +const rejectedModes = (process.env.FAKE_CLAUDE_REJECT_MODES ?? "").split(",").filter(Boolean); +let controlSeq = 0; +const controlWaiters = new Map void>(); +let interrupted = false; let mcpServers: Array<{ name: string; status: string }> = []; const mcpIdx = args.indexOf("--mcp-config"); if (mcpIdx !== -1) { @@ -36,7 +51,7 @@ let initSent = false; function announceInit(): void { if (initSent || process.env.FAKE_CLAUDE_NO_INIT === "1") return; initSent = true; - out({ type: "system", subtype: "init", session_id: sessionId, cwd: process.cwd(), model: "fake-haiku", tools: ["Bash"], mcp_servers: mcpServers }); + out({ type: "system", subtype: "init", session_id: sessionId, cwd: process.cwd(), model, permissionMode, tools: ["Bash"], mcp_servers: mcpServers }); } const queue: any[] = []; @@ -73,6 +88,46 @@ async function runTurn(msg: any): Promise { for (const m of absorbed) echo(m); } + 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)) { + const [, toolName, arg] = m; + const toolUseId = `toolu_${toolName}_${turns}`; + const input = toolName === "Bash" ? { command: arg } : { file_path: arg }; + let allowed = true, reason = ""; + const needsAsk = permissionMode !== "bypassPermissions" && !(permissionMode === "acceptEdits" && /^(Edit|Write)$/.test(toolName!)); + if (needsAsk) { + const requestId = `cli_${++controlSeq}`; + const answer = new Promise((resolve) => controlWaiters.set(requestId, resolve)); + out({ type: "control_request", request_id: requestId, request: { subtype: "can_use_tool", tool_name: toolName, input, tool_use_id: toolUseId, permission_suggestions: [{ type: "addRules", rules: [{ toolName }], behavior: "allow", destination: "session" }] } }); + const response = await answer; + allowed = response?.behavior === "allow"; + reason = response?.message ?? ""; + } + out({ type: "assistant", message: { role: "assistant", content: [{ type: "tool_use", id: toolUseId, name: toolName, input }] } }); + out({ type: "user", message: { role: "user", content: [{ type: "tool_result", tool_use_id: toolUseId, content: allowed ? `ran ${toolName} ${arg}` : `denied: ${reason}`, is_error: !allowed }] } }); + if (interrupted) break; + } + + if (interrupted) { + interrupted = false; + out({ type: "result", subtype: "error_during_execution", is_error: true, num_turns: turns, session_id: sessionId, result: "interrupted", usage: { input_tokens: 0, output_tokens: 0 } }); + inTurn = false; + void pump(); + return; + } + if (process.env.FAKE_CLAUDE_CRASH === "mid-turn") { process.exit(3); } @@ -109,6 +164,42 @@ process.stdin.on("data", (chunk) => { let msg: any; try { msg = JSON.parse(line); } catch { continue; } log({ stdin: msg }); + if (msg.type === "control_response") { + const waiter = controlWaiters.get(msg.response?.request_id); + controlWaiters.delete(msg.response?.request_id); + waiter?.(msg.response?.response); + continue; + } + if (msg.type === "control_request") { + const req = msg.request ?? {}; + const ok = (response: unknown = {}) => out({ type: "control_response", response: { subtype: "success", request_id: msg.request_id, response } }); + const fail = (error: string) => out({ type: "control_response", response: { subtype: "error", request_id: msg.request_id, error } }); + if (req.subtype === "set_model") { model = req.model ?? "fake-haiku"; ok(); } + else if (req.subtype === "set_permission_mode") { + if (rejectedModes.includes(req.mode)) fail(`Invalid permission mode: ${req.mode}`); + else { permissionMode = req.mode; ok(); } + } else if (req.subtype === "interrupt") { + 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; + } if (msg.type !== "user") continue; announceInit(); if (process.env.FAKE_CLAUDE_CRASH === "after-init") process.exit(3); diff --git a/tests/fakes/fake-codex-app-server.ts b/tests/fakes/fake-codex-app-server.ts index 03f7db6..c4fd979 100644 --- a/tests/fakes/fake-codex-app-server.ts +++ b/tests/fakes/fake-codex-app-server.ts @@ -136,6 +136,17 @@ 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": + // 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", + 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/launcher.test.ts b/tests/launcher.test.ts index afe9693..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"); + }); }); // --------------------------------------------------------------------------- @@ -490,6 +479,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); 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); } 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/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/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"); + }); +}); diff --git a/tests/web-control.test.ts b/tests/web-control.test.ts new file mode 100644 index 0000000..db52cdb --- /dev/null +++ b/tests/web-control.test.ts @@ -0,0 +1,548 @@ +/** + * 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 { formatForwardedMessages } from "../orchestrator/delivery.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}`, + MULTIAGENTS_LIMIT_CHECK_MS: "300", + FAKE_CLAUDE_DELTA_MS: "150", + FAKE_CLAUDE_LOG: path.join(tmpDir, "fake-claude.log"), + }, + 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); + // 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); + }); +}); + +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); + }); +}); + +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); + }); +}); + +describe("approvals, activity, settings and limits", () => { + test("a step of an agent in ask mode waits for a person; the timeline shows it", async () => { + const r = await api("team/create", { + project_dir: path.join(tmpDir, "ask-team"), + session_name: "Ask Team", + goal: "List files", + agents: [{ agent_type: "claude", name: "Careful", role: "Software Engineer", initial_task: "TOOL:Bash:ls-la", approval_mode: "ask", model: "sonnet" }], + workflow: {}, + }); + expect(r.ok).toBe(true); + const sid = r.result.data.session_id; + const [slot] = await broker("/slots/list", { session_id: sid }); + expect(JSON.parse(slot.context_snapshot)).toMatchObject({ approval_mode: "ask", model: "sonnet" }); + + const pending = await waitFor(async () => { + const list = await broker("/approvals/list", { session_id: sid, status: "pending" }); + return list.length ? list : null; + }, 15_000); + expect(pending[0]).toMatchObject({ tool: "Bash", title: "$ ls-la", slot_id: slot.id }); + + // Another session cannot decide it + expect((await api("approvals/decide", { session_id: SESSION, approval_id: pending[0].id, decision: "allow" })).ok).toBe(false); + const decided = await api("approvals/decide", { session_id: sid, approval_id: pending[0].id, decision: "allow" }); + expect(decided.ok).toBe(true); + expect((await api("approvals/decide", { session_id: sid, approval_id: pending[0].id, decision: "deny" })).ok).toBe(false); + + const steps = await waitFor(async () => { + const res = await (await fetch(`${DASHBOARD_URL}/api/activity?session_id=${encodeURIComponent(sid)}`)).json() as any; + const kinds = res.result.map((e: any) => e.kind); + return kinds.includes("tool_result") && kinds.includes("turn_end") ? res.result : null; + }, 15_000); + const titles = steps.map((e: any) => e.title); + expect(titles).toContain("Waiting for approval · $ ls-la"); + expect(titles).toContain("Approved · $ ls-la"); + expect(titles).toContain("$ ls-la"); + expect(steps.find((e: any) => e.kind === "tool_result").detail).toBe("ran Bash ls-la"); + + // A model switch reaches the running session as a control request + expect((await api("agents/settings", { session_id: sid, slot_id: slot.id, model: "opus" })).ok).toBe(true); + await waitFor(() => Promise.resolve(fs.readFileSync(path.join(tmpDir, "fake-claude.log"), "utf-8").includes('"subtype":"set_model","model":"opus"')), 10_000); + + // A token limit below what the agent used pauses it + expect((await api("session/limits", { session_id: sid, limits: { agent_tokens: 50 } })).ok).toBe(true); + const paused = await waitFor(async () => { + const s = await broker("/slots/get", { id: slot.id }); + return s.paused ? s : null; + }, 10_000); + expect(JSON.parse(paused.context_snapshot).limit_hit).toMatch(/^token limit reached/); + + expect((await api("session/end", { session_id: sid })).ok).toBe(true); + }, 60_000); + + test("settings are validated per agent type", async () => { + expect((await api("agents/settings", { session_id: SESSION, slot_id: slotA, effort: "turbo" })).status).toBe(400); + expect((await api("agents/settings", { session_id: SESSION, slot_id: slotA, approval_mode: "sometimes" })).status).toBe(400); + expect((await api("agents/settings", { session_id: SESSION, slot_id: slotA, model: "bad model;" })).status).toBe(400); + const ok = await api("agents/settings", { session_id: SESSION, slot_id: slotA, model: "haiku", effort: "low", token_limit: 9000 }); + expect(ok.ok).toBe(true); + const snap = JSON.parse((await broker("/slots/get", { id: slotA })).context_snapshot); + expect(snap).toMatchObject({ model: "haiku", effort: "low", token_limit: 9000 }); + // Clearing + await api("agents/settings", { session_id: SESSION, slot_id: slotA, model: "", token_limit: null }); + expect(JSON.parse((await broker("/slots/get", { id: slotA })).context_snapshot)).toMatchObject({ model: null, token_limit: null }); + }); + + test("limits are stored on the session and shown in the dashboard state", async () => { + const r = await api("session/limits", { session_id: SESSION, limits: { session_tokens: 100000, agent_cost_usd: 2.5, agent_tokens: "" } }); + expect(r.result.limits).toEqual({ session_tokens: 100000, agent_cost_usd: 2.5 }); + const session = await broker("/sessions/get", { id: SESSION }); + expect(JSON.parse(session.config).limits).toEqual({ session_tokens: 100000, agent_cost_usd: 2.5 }); + 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-dashboard.test.ts b/tests/web-dashboard.test.ts index 8e0f6a5..8df7d3b 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,46 @@ 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"'); + // 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("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 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("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 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("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"); + }); +}); diff --git a/tests/web-i18n.test.ts b/tests/web-i18n.test.ts new file mode 100644 index 0000000..ebb43da --- /dev/null +++ b/tests/web-i18n.test.ts @@ -0,0 +1,82 @@ +/** + * 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("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?"); + 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"); + }); +});