SLOPSHOPPER

agent-dock

Taskbar of coding-agent CLIs (Cline, agy, OpenCode, Codex) with live side panes of their runs

newpanebandspinnerguardcommand
v0.4.4MITupdated 2026-10-25silver0416/claude-agent-dock
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-dock
│ ┃ ◆ Cline ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ▀▀ Cline ⏺ Read(src/auth.ts) │ ┃ │ ▄▀▀▀▀▀▀▀▀▄ ○ idle ⎿ Read 6 lines │ ┃ │ ▀▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) │ ┃ │ ▀▀▀ ▀▀ ▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ │ ▀▀▄▄▀▀▄▄▀▀ ⏺ Bash(bun test) │ ┃ │ ▀▀▀▀▀▀▀▀▀▀ ⎿ 3 pass, 1 fail │ ┃ ╰─────────────────────────────────────────── │ ┃ No run yet. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Ask Claude, e.g. "let Cline …"; the run │ ┃ shows here live. ✻ Worked for 42s · done 4:20 PM │ ┃ ctrl+x d hides the tabs │ ┃ model: CLI default › /agent-dock │ │ toggle ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
toggle
Pane · ◆ Cline
╭─────────────────────────────────────────────────────────── │ ▀▀ Cline │ ▄▀▀▀▀▀▀▀▀▄ ○ idle │ ▀▀▀▀▀▀▀▀▀▀ │ ▀▀▀ ▀▀ ▀▀▀ │ ▀▀▄▄▀▀▄▄▀▀ │ ▀▀▀▀▀▀▀▀▀▀ ╰─────────────────────────────────────────────────────────── No run yet. Ask Claude, e.g. "let Cline …"; the run shows here live. ctrl+x d hides the tabs model: CLI default
Pane · ▲ agy
╭─────────────────────────────────────────────────────────── │ ▄▄▄▄ agy │ ▄▀▀▀▀▄ ○ idle │ ▀▀▀▀▀▀ │ ▀▀▀▀▀▀▀▀ │ ▄▀▀ ▀▄ │ ▀ ▀▀ ╰─────────────────────────────────────────────────────────── No run yet. Ask Claude, e.g. "let agy …"; the run shows here live. ctrl+x d hides the tabs model: CLI default
Pane · ■ OpenCode
╭─────────────────────────────────────────────────────────── │ ▄▄▄▄▄▄▄▄ OpenCode │ ▀▀▀▀▀▀▀▀ ○ idle │ ▀▀ ▀▀ │ ▀▀ ▀▀ │ ▀▀▄▄▄▄▀▀ │ ▀▀▀▀▀▀▀▀ ╰─────────────────────────────────────────────────────────── No run yet. Ask Claude, e.g. "let OpenCode …"; the run shows here live. ctrl+x d hides the tabs model: CLI default
Pane · ✦ Codex
╭─────────────────────────────────────────────────────────── │ ▄▄▀▀▀▀▄▄ Codex │ ▀▀▀▀▀▀▀▀▀▀ ○ idle │ ▀▀▄▀▀▀▀▀▀▀▀▀ │ ▀▀▀▀▄▀▀▀▀▀▀▀ │ ▀▀▄▀▀▄▄▄▄▀▀▀ │ ▀▀▀▀▀▀▀▀ ╰─────────────────────────────────────────────────────────── No run yet. Ask Claude, e.g. "let Codex …"; the run shows here live. ctrl+x d hides the tabs model: CLI default
Pane · ⚙ Policy
╭──────────────────────────────────────────────────────────╮ │ Dispatch policy │ │ Press a [ value ] to change it; Claude follows it from │ │ its next reply. │ ╰──────────────────────────────────────────────────────────╯ When [ only when I ask ▾ ] Unnamed [ Claude picks ▾ ] On fail [ report and stop ▾ ] Lifelike [ off ] ◆ Cline Use [ on ] Access [ full auto ] Model : CLI default · type, then Enter ⏎ save Use for : e.g. frontend · type, then Enter ⏎ save ▲ agy Use [ on ] Access [ full auto ] Model : CLI default · type, then Enter ⏎ save Use for : e.g. frontend · type, then Enter ⏎ save ■ OpenCode Use [ on ] Access [ full auto ] Model : CLI default · type, then Enter ⏎ save Use for : e.g. frontend · type, then Enter ⏎ save ✦ Codex Use [ on ] Access [ full auto ] Model : CLI default · type, then Enter ⏎ save Use for : e.g. frontend · type, then Enter ⏎ save reset to defaults ctrl+x d hides
README

agent-dock

A Claude Code mod that lets Claude hand work to other coding-agent CLIs — Cline (cline), Antigravity (agy), OpenCode (opencode) and Codex (codex) — and lets you watch each run live.

  • A small switch above the prompt, at the right: each agent's state (○ idle, a spinner while running, ✓ done, ✗ failed) and a button that shows or hides the side panel.
  • One tab per agent in the side panel, headed by the agent's icon in its colors, showing the task, elapsed time and the run's progress as it happens: reasoning, tool calls and the final answer, unpacked from each CLI's JSON output. The bottom row shows the model in use, with its reasoning effort for Codex and OpenCode.
  • Delegation built in: ask Claude "let opencode do …" and it starts the CLI in the background, keeps working, is notified when the run ends, reviews the result and carries on.
  • A Policy tab for how Claude delegates: when (only when asked, suggest first, on its own, or lead mode, where Claude acts as the lead and hands the real work to the agents by default, with a reminder beside each prompt), who takes work you don't assign by name, what happens on a failure, and per agent on/off, full auto or read-only, a model, and what it is best used for. Kept across sessions; a turned-off agent's runs are refused.
  • Lifelike mode (off by default, in the Policy tab): Clawd stands in each tab's header and chats with the agent in speech bubbles, nothing scripted. Clawd hands over the work in a line Claude writes; the agent acknowledges it at once and sums up at the end, in its own voice; once Claude has reviewed the run, Clawd reacts through the clawd_react tool: praise or thanks, what to fix (and the fix goes out as a new run, with Clawd a little impatient after several problems in a row), or concern when a run fails. Clawd has five faces: default, happy, surprised, confused, sad.
  • A toast when a run finishes, and the shortcut among the footer's mode labels.

Claude keeps calling each CLI directly in Bash; the mod copies the command's output to ~/.claude/agent-dock/<cli>.log for the panel (stderr is merged into stdout).

Install

At the prompt of a Claude Code terminal session:

/plugin install agent-dock --marketplace silver0416/claude-agent-dock

Answer y to add the marketplace, then press Enter for the user scope.

From a shell:

claude plugin install agent-dock --marketplace silver0416/claude-agent-dock

In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with silver0416/claude-agent-dock, then Add plugin.

Keyboard shortcut (optional)

The panel toggles with /agent-dock. To toggle it with ctrl+x d, add this to ~/.claude/keybindings.json:

{
  "$schema": "https://www.schemastore.org/claude-code-keybindings.json",
  "bindings": [
    { "context": "Global", "bindings": { "ctrl+x d": "command:agent-dock" } }
  ]
}

The [-] beside the switch above the prompt is Claude Code's own collapse mark; ctrl+x ctrl+a brings the row back.

Requirements

Install and sign in to the CLIs you want to use, on the same machine: cline, agy, opencode and/or codex. Each uses its own default model (for OpenCode, set "model" in ~/.config/opencode/opencode.jsonc, or opencode run falls back to a free model).

Warning: runs are fully automatic

The mod tells Claude to run these CLIs with their tools auto-approved (opencode --auto, agy --dangerously-skip-permissions, Cline's default auto-approval, and Codex's workspace-write sandbox, which keeps Codex to the directory and off the network), so a delegated agent edits files and runs commands in the directory it is started in without asking. For questions that need no changes Claude uses each CLI's read-only mode. Commit your work before delegating changes to a repository you care about.

License

MIT

The agent icons are drawn from LobeHub Icons (MIT). Cline, Antigravity, OpenCode and Codex names and marks belong to their owners; this mod is not affiliated with them.

Source 2 files
hooks/register.tsx 1611 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {
5  AgentChat, AgentChats, AgentId, AgentPolicy, AgentRun, AgentRuns, ChatMessage, Entry, Face, Policy, RunMeta, RunResult,
6} from '../types'
7
8type Agent = { id: AgentId; label: string; glyph: string }
9
10const AGENTS: Agent[] = [
11  { id: 'cline', label: 'Cline', glyph: '◆' },
12  { id: 'agy', label: 'agy', glyph: '▲' },
13  { id: 'opencode', label: 'OpenCode', glyph: '■' },
14  { id: 'codex', label: 'Codex', glyph: '✦' },
15]
16
17// Each agent's own color: the icon's, or the text color for a one-color mark.
18const ACCENT: Record<AgentId, string> = {
19  cline: 'text',
20  agy: '#3186FF',
21  opencode: 'text',
22  codex: '#7A9DFF',
23}
24
25// Each agent's mark, drawn two pixels to a cell with half blocks:
26// "." is empty; "#" takes the agent's color (Codex's fades top to bottom);
27// a letter is a color of the icon's own palette.
28const ICONS: Record<AgentId, { rows: string[]; palette?: string[] }> = {
29  cline: { rows: [
30    ".....##.....",
31    ".....##.....",
32    "..########..",
33    ".##########.",
34    ".##########.",
35    ".##..##..##.",
36    "###..##..###",
37    "###..##..###",
38    ".##..##..##.",
39    ".##########.",
40    ".##########.",
41    "..########..",
42  ] },
43  agy: {
44    rows: [
45      "............",
46      "....aabb....",
47      "....ccdb....",
48      "...ceefdd...",
49      "...egggff...",
50      "...gghhhi...",
51      "..ghhhhhhi..",
52      "..hh....hh..",
53      "..hh.....h..",
54      ".hh......hh.",
55      ".h........hh",
56      "............",
57    ],
58    palette: ["#b7ae3b","#d46d38","#8caa55","#a96f50","#62a17b","#79727b","#4596a4","#3582d6","#7072ba"],
59  },
60  opencode: { rows: [
61    "............",
62    "..########..",
63    "..########..",
64    "..##....##..",
65    "..##....##..",
66    "..##....##..",
67    "..##....##..",
68    "..##....##..",
69    "..##....##..",
70    "..########..",
71    "..########..",
72    "............",
73  ] },
74  // Its >_ face drawn by hand: at this size the traced one blurs.
75  codex: { rows: [
76    "....####....",
77    "..########..",
78    ".##########.",
79    ".##########.",
80    "##.#########",
81    "###.########",
82    "####.#######",
83    "###.########",
84    "##.##....###",
85    ".##########.",
86    "..########..",
87    "....####....",
88  ] },
89}
90
91// The color of one pixel, or null where the icon is empty.
92function pixel(id: AgentId, x: number, y: number): string | null {
93  const icon = ICONS[id]
94  const ch = icon.rows[y]?.[x] ?? '.'
95  if (ch === '.') return null
96  if (ch !== '#') return icon.palette?.[ch.charCodeAt(0) - 97] ?? ACCENT[id]
97  if (id !== 'codex') return ACCENT[id]
98  const t = y / (icon.rows.length - 1)
99  return t < 0.5 ? mix(CODEX_FADE[0]!, CODEX_FADE[1]!, t * 2) : mix(CODEX_FADE[1]!, CODEX_FADE[2]!, t * 2 - 1)
100}
101
102// Each agent's own icon as vector art (from the LobeHub icon set), for the
103// surfaces that draw SVG; the terminal draws the pixel marks above. Codex's
104// >_ is laid in white over its cloud, as its app icon has it.
105const ICON_SVG: Record<AgentId, string> = {
106  cline: "<svg fill=\"#A8A8A8\" fill-rule=\"evenodd\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M17.035 3.991c2.75 0 4.98 2.24 4.98 5.003v1.667l1.45 2.896a1.01 1.01 0 01-.002.909l-1.448 2.864v1.668c0 2.762-2.23 5.002-4.98 5.002H7.074c-2.751 0-4.98-2.24-4.98-5.002V17.33l-1.48-2.855a1.01 1.01 0 01-.003-.927l1.482-2.887V8.994c0-2.763 2.23-5.003 4.98-5.003h9.962zM8.265 9.6a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 004.547 0v-4.042A2.274 2.274 0 008.265 9.6zm7.326 0a2.274 2.274 0 00-2.274 2.274v4.042a2.274 2.274 0 104.548 0v-4.042A2.274 2.274 0 0015.59 9.6z\"></path><path d=\"M12.054 5.558a2.779 2.779 0 100-5.558 2.779 2.779 0 000 5.558z\"></path></svg>",
107  agy: "<svg viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\"><mask height=\"23\" id=\"lobe-icons-antigravity-0-_R_0_\" maskUnits=\"userSpaceOnUse\" width=\"24\" x=\"0\" y=\"1\"><path d=\"M21.751 22.607c1.34 1.005 3.35.335 1.508-1.508C17.73 15.74 18.904 1 12.037 1 5.17 1 6.342 15.74.815 21.1c-2.01 2.009.167 2.511 1.507 1.506 5.192-3.517 4.857-9.714 9.715-9.714 4.857 0 4.522 6.197 9.714 9.715z\" fill=\"#fff\"></path></mask><g mask=\"url(#lobe-icons-antigravity-0-_R_0_)\"><g filter=\"url(#lobe-icons-antigravity-1-_R_0_)\"><path d=\"M-1.018-3.992c-.408 3.591 2.686 6.89 6.91 7.37 4.225.48 7.98-2.043 8.387-5.633.408-3.59-2.686-6.89-6.91-7.37-4.225-.479-7.98 2.043-8.387 5.633z\" fill=\"#FFE432\"></path></g><g filter=\"url(#lobe-icons-antigravity-2-_R_0_)\"><path d=\"M15.269 7.747c1.058 4.557 5.691 7.374 10.348 6.293 4.657-1.082 7.575-5.653 6.516-10.21-1.058-4.556-5.691-7.374-10.348-6.292-4.657 1.082-7.575 5.653-6.516 10.21z\" fill=\"#FC413D\"></path></g><g filter=\"url(#lobe-icons-antigravity-3-_R_0_)\"><path d=\"M-12.443 10.804c1.338 4.703 7.36 7.11 13.453 5.378 6.092-1.733 9.947-6.95 8.61-11.652C8.282-.173 2.26-2.58-3.833-.848-9.925.884-13.78 6.1-12.443 10.804z\" fill=\"#00B95C\"></path></g><g filter=\"url(#lobe-icons-antigravity-4-_R_0_)\"><path d=\"M-12.443 10.804c1.338 4.703 7.36 7.11 13.453 5.378 6.092-1.733 9.947-6.95 8.61-11.652C8.282-.173 2.26-2.58-3.833-.848-9.925.884-13.78 6.1-12.443 10.804z\" fill=\"#00B95C\"></path></g><g filter=\"url(#lobe-icons-antigravity-5-_R_0_)\"><path d=\"M-7.608 14.703c3.352 3.424 9.126 3.208 12.896-.483 3.77-3.69 4.108-9.459.756-12.883C2.69-2.087-3.083-1.871-6.853 1.82c-3.77 3.69-4.108 9.458-.755 12.883z\" fill=\"#00B95C\"></path></g><g filter=\"url(#lobe-icons-antigravity-6-_R_0_)\"><path d=\"M9.932 27.617c1.04 4.482 5.384 7.303 9.7 6.3 4.316-1.002 6.971-5.448 5.93-9.93-1.04-4.483-5.384-7.304-9.7-6.301-4.316 1.002-6.971 5.448-5.93 9.93z\" fill=\"#3186FF\"></path></g><g filter=\"url(#lobe-icons-antigravity-7-_R_0_)\"><path d=\"M2.572-8.185C.392-3.329 2.778 2.472 7.9 4.771c5.122 2.3 11.042.227 13.222-4.63 2.18-4.855-.205-10.656-5.327-12.955-5.122-2.3-11.042-.227-13.222 4.63z\" fill=\"#FBBC04\"></path></g><g filter=\"url(#lobe-icons-antigravity-8-_R_0_)\"><path d=\"M-3.267 38.686c-5.277-2.072 3.742-19.117 5.984-24.83 2.243-5.712 8.34-8.664 13.616-6.592 5.278 2.071 11.533 13.482 9.29 19.195-2.242 5.713-23.613 14.298-28.89 12.227z\" fill=\"#3186FF\"></path></g><g filter=\"url(#lobe-icons-antigravity-9-_R_0_)\"><path d=\"M28.71 17.471c-1.413 1.649-5.1.808-8.236-1.878-3.135-2.687-4.531-6.201-3.118-7.85 1.412-1.649 5.1-.808 8.235 1.878s4.532 6.2 3.119 7.85z\" fill=\"#749BFF\"></path></g><g filter=\"url(#lobe-icons-antigravity-10-_R_0_)\"><path d=\"M18.163 9.077c5.81 3.93 12.502 4.19 14.946.577 2.443-3.612-.287-9.727-6.098-13.658-5.81-3.931-12.502-4.19-14.946-.577-2.443 3.612.287 9.727 6.098 13.658z\" fill=\"#FC413D\"></path></g><g filter=\"url(#lobe-icons-antigravity-11-_R_0_)\"><path d=\"M-.915 2.684c-1.44 3.473-.97 6.967 1.05 7.804 2.02.837 4.824-1.3 6.264-4.772 1.44-3.473.97-6.967-1.05-7.804-2.02-.837-4.824 1.3-6.264 4.772z\" fill=\"#FFEE48\"></path></g></g><defs><filter color-interpolation-filters=\"sRGB\" filterUnits=\"userSpaceOnUse\" height=\"17.587\" id=\"lobe-icons-antigravity-1-_R_0_\" width=\"19.838\" x=\"-3.288\" y=\"-11.917\"><feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\"></feFlood><feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\"></feBlend><feGaussianBlur result=\"effect1_foregroundBlur_977_115\" stdDeviation=\"1.117\"></feGaussianBlur></filter><filter color-interpolation-filters=\"sRGB\" filterUnits=\"userSpaceOnUse\" height=\"38.565\" id=\"lobe-icons-antigravity-2-_R_0_\" width=\"38.9\" x=\"4.251\" y=\"-13.493\"><feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\"></feFlood><feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\"></feBlend><feGaussianBlur result=\"effect1_foregroundBlur_977_115\" stdDeviation=\"5.4\"></feGaussianBlur></filter><filter color-interpolation-filters=\"sRGB\" filterUnits=\"userSpaceOnUse\" height=\"36.517\" id=\"lobe-icons-antigravity-3-_R_0_\" width=\"40.955\" x=\"-21.889\" y=\"-10.592\"><feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\"></feFlood><feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\"></feBlend><feGaussianBlur result=\"effect1_foregroundBlur_977_115\" stdDeviation=\"4.591\"></feGaussianBlur></filter><filter color-interpolation-filters=\"sRGB\" filterUnits=\"userSpaceOnUse\" height=\"36.517\" id=\"lobe-icons-antigravity-4-_R_0_\" width=\"40.955\" x=\"-21.889\" y=\"-10.592\"><feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\"></feFlood><feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\"></feBlend><feGaussianBlur result=\"effect1_foregroundBlur_977_115\" stdDeviation=\"4.591\"></feGaussianBlur></filter><filter color-interpolation-filters=\"sRGB\" filterUnits=\"userSpaceOnUse\" height=\"36.595\" id=\"lobe-icons-antigravity-5-_R_0_\" width=\"36.632\" x=\"-19.099\" y=\"-10.278\"><feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\"></feFlood><feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\"></feBlend><feGaussianBlur result=\"effect1_foregroundBlur_977_115\" stdDeviation=\"4.591\"></feGaussianBlur></filter><filter color-interpolation-filters=\"sRGB\" filterUnits=\"userSpaceOnUse\" height=\"34.087\" id=\"lobe-icons-antigravity-6-_R_0_\" width=\"33.533\" x=\".981\" y=\"8.758\"><feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\"></feFlood><feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\"></feBlend><feGaussianBlur result=\"effect1_foregroundBlur_977_115\" stdDeviation=\"4.363\"></feGaussianBlur></filter><filter color-interpolation-filters=\"sRGB\" filterUnits=\"userSpaceOnUse\" height=\"35.276\" id=\"lobe-icons-antigravity-7-_R_0_\" width=\"35.978\" x=\"-6.143\" y=\"-21.659\"><feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\"></feFlood><feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\"></feBlend><feGaussianBlur result=\"effect1_foregroundBlur_977_115\" stdDeviation=\"3.954\"></feGaussianBlur></filter><filter color-interpolation-filters=\"sRGB\" filterUnits=\"userSpaceOnUse\" height=\"46.523\" id=\"lobe-icons-antigravity-8-_R_0_\" width=\"45.114\" x=\"-11.96\" y=\"-.46\"><feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\"></feFlood><feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\"></feBlend><feGaussianBlur result=\"effect1_foregroundBlur_977_115\" stdDeviation=\"3.531\"></feGaussianBlur></filter><filter color-interpolation-filters=\"sRGB\" filterUnits=\"userSpaceOnUse\" height=\"24.054\" id=\"lobe-icons-antigravity-9-_R_0_\" width=\"25.094\" x=\"10.485\" y=\".58\"><feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\"></feFlood><feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\"></feBlend><feGaussianBlur result=\"effect1_foregroundBlur_977_115\" stdDeviation=\"3.159\"></feGaussianBlur></filter><filter color-interpolation-filters=\"sRGB\" filterUnits=\"userSpaceOnUse\" height=\"30.007\" id=\"lobe-icons-antigravity-10-_R_0_\" width=\"33.508\" x=\"5.833\" y=\"-12.467\"><feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\"></feFlood><feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\"></feBlend><feGaussianBlur result=\"effect1_foregroundBlur_977_115\" stdDeviation=\"2.669\"></feGaussianBlur></filter><filter color-interpolation-filters=\"sRGB\" filterUnits=\"userSpaceOnUse\" height=\"26.151\" id=\"lobe-icons-antigravity-11-_R_0_\" width=\"22.194\" x=\"-8.355\" y=\"-8.876\"><feFlood flood-opacity=\"0\" result=\"BackgroundImageFix\"></feFlood><feBlend in=\"SourceGraphic\" in2=\"BackgroundImageFix\" result=\"shape\"></feBlend><feGaussianBlur result=\"effect1_foregroundBlur_977_115\" stdDeviation=\"3.303\"></feGaussianBlur></filter></defs></svg>",
108  opencode: "<svg fill=\"#A8A8A8\" fill-rule=\"evenodd\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M16 6H8v12h8V6zm4 16H4V2h16v20z\"></path></svg>",
109  codex: "<svg viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z\" fill=\"url(#codex-fade)\"></path><defs><linearGradient gradientUnits=\"userSpaceOnUse\" id=\"codex-fade\" x1=\"12\" x2=\"12\" y1=\"3\" y2=\"21\"><stop stop-color=\"#B1A7FF\"></stop><stop offset=\".5\" stop-color=\"#7A9DFF\"></stop><stop offset=\"1\" stop-color=\"#3941FF\"></stop></linearGradient></defs><path d=\"M12.546 13.909a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z\" fill=\"#FFFFFF\"/></svg>",
110}
111
112// Codex's fade, from its icon: top to bottom.
113const CODEX_FADE = ['#B1A7FF', '#7A9DFF', '#3941FF']
114
115const mix = (a: string, b: string, t: number) => {
116  const c = (s: string, i: number) => parseInt(s.slice(i, i + 2), 16)
117  return '#' + [1, 3, 5].map(i => Math.round(c(a, i) + (c(b, i) - c(a, i)) * t).toString(16).padStart(2, '0')).join('')
118}
119
120// Lifelike mode: Clawd stands at the right of each tab's header and talks
121// with the agent like a coworker, each in a speech bubble.
122
123
124// Clawd, 14×10 pixels, as the logo draws it with its height doubled: a top
125// row, the eyes (cut out per face), the arms right under them reaching past
126// the body, one more body row, four short legs. 't' is a tear.
127const CLAWD: Record<Face, string[]> = {
128  default: [
129    '..##########..',
130    '..##########..',
131    '..##.####.##..',
132    '..##.####.##..',
133    '##############',
134    '##############',
135    '..##########..',
136    '..##########..',
137    '...#.#..#.#...',
138    '...#.#..#.#...',
139  ],
140  happy: [
141    '..##########..',
142    '..##########..',
143    '..##.####.##..',
144    '..#.#.##.#.#..',
145    '##############',
146    '##############',
147    '..##########..',
148    '..##########..',
149    '...#.#..#.#...',
150    '...#.#..#.#...',
151  ],
152  surprised: [
153    '..##########..',
154    '..##########..',
155    '..##..##..##..',
156    '..##..##..##..',
157    '##############',
158    '##############',
159    '..##########..',
160    '..##########..',
161    '...#.#..#.#...',
162    '...#.#..#.#...',
163  ],
164  confused: [
165    '..##########..',
166    '..##########..',
167    '..##.#######..',
168    '..##.###...#..',
169    '##############',
170    '##############',
171    '..##########..',
172    '..##########..',
173    '...#.#..#.#...',
174    '...#.#..#.#...',
175  ],
176  sad: [
177    '..##########..',
178    '..##########..',
179    '..#.######.#..',
180    '..##.####.##..',
181    '####t#########',
182    '##############',
183    '..##########..',
184    '..##########..',
185    '...#.#..#.#...',
186    '...#.#..#.#...',
187  ],
188}
189
190// A mark beside Clawd's head for the faces that have one.
191const FACE_MARK: Record<Face, string> = { default: '', happy: '♪', surprised: '!', confused: '?', sad: '' }
192
193// The lifelike chat, nothing scripted. Each run adds, in order:
194// - handoff: Clawd's line, from the command's first line `# clawd(<face>): …`;
195// - ack: the agent's `ACK: …` line, the first thing its reply says;
196// - summary: its `SAY: …` line, the last (or, without one, its answer's
197//   first line, or the error);
198// - react: Clawd's reaction, which Claude sends through the clawd_react tool
199//   once it has reviewed the run.
200const CLAWD_LINE = /^\s*#\s*clawd(?:\((\w+)\))?:\s*(.+)$/m
201const SAY_LINE = /^\s*SAY:\s*(.+)$/gm
202const ACK_LINE = /^\s*ACK:\s*(.+)$/m
203
204// The agent's answer without its ACK and SAY lines, which go to the bubbles.
205const withoutSay = (text: string) =>
206  text.replace(SAY_LINE, '').replace(new RegExp(ACK_LINE.source, 'gm'), '').trim()
207
208const short = (text: string, max: number) => {
209  const line = text.split('\n').find(row => row.trim() !== '')?.trim() ?? ''
210  return line.length > max ? `${line.slice(0, max)}…` : line
211}
212
213const NO_CHAT: AgentChat = { messages: [], streak: 0 }
214const NO_CHATS: AgentChats = { cline: NO_CHAT, agy: NO_CHAT, opencode: NO_CHAT, codex: NO_CHAT }
215const chats = atom({ plugin: 'agent-dock', key: 'chats' } as const, NO_CHATS)
216
217// The chat kept per agent: its last lines, enough for the bubbles.
218const CHAT_KEEP = 12
219// The lines shown in the header's bubbles.
220const CHAT_SHOWN = 5
221
222const chatOf = async ($: EngineInterface, id: AgentId): Promise<AgentChat> =>
223  ({ ...NO_CHAT, ...(await read($, chats))[id] })
224
225// Adds a line once per run and kind; a reaction may replace the run's last.
226async function say($: EngineInterface, id: AgentId, message: ChatMessage) {
227  const chat = await chatOf($, id)
228  const same = (m: ChatMessage) => m.run === message.run && m.kind === message.kind
229  if (message.kind !== 'react' && chat.messages.some(same)) return
230  await update($, chats, all => {
231    const now = { ...NO_CHAT, ...all[id] }
232    const messages = [...now.messages.filter(m => !(message.kind === 'react' && same(m))), message]
233    return { ...all, [id]: { ...now, messages: messages.slice(-CHAT_KEEP) } }
234  })
235}
236
237// Clawd's line for a dispatch: the command's own, else the task in brief.
238function handoffOf(command: string): { text: string; face: Face | '' } {
239  const said = CLAWD_LINE.exec(command)
240  const face = said?.[1] as Face | undefined
241  return {
242    text: said?.[2]?.trim() ?? short(taskOf(command), 40),
243    face: face !== undefined && face in CLAWD ? face : '',
244  }
245}
246
247// The agent's last word on a finished run.
248function summaryOf(run: AgentRun): string {
249  const answer = run.result?.text ?? ''
250  const says = [...answer.matchAll(SAY_LINE)].map(m => m[1]?.trim() ?? '').filter(s => s !== '')
251  if (says.length > 0) return says[says.length - 1]!
252  const lastError = [...run.lines].reverse().find(l => l.kind === 'error')?.text ?? ''
253  return run.status === 'error' ? short(lastError || withoutSay(answer), 80) : short(withoutSay(answer), 80)
254}
255
256// Clawd's face now: the face of its last line in this run, else the run's mood.
257function faceOf(run: AgentRun, chat: AgentChat): Face {
258  const mine = [...chat.messages].reverse().find(m => m.who === 'clawd' && m.run === run.startedAt && m.face !== '')
259  if (mine !== undefined && mine.face !== '') return mine.face
260  switch (run.status) {
261    case 'running':
262      return 'happy'
263    case 'error':
264      return 'sad'
265    default:
266      return 'default'
267  }
268}
269
270// Each reaction's mood, its face, and what it does to the problem streak.
271const MOODS = {
272  praise: { face: 'happy', isProblem: false },
273  thanks: { face: 'happy', isProblem: false },
274  problem: { face: 'confused', isProblem: true },
275  concern: { face: 'sad', isProblem: true },
276} as const satisfies Record<string, { face: Face; isProblem: boolean }>
277type Mood = keyof typeof MOODS
278
279// A picture as SVG squares, for surfaces whose text rows do not meet (the
280// desktop app): half blocks there leave gaps between rows. Theme keys become
281// colors that read on a light or a dark ground.
282const SVG_COLOR: Record<string, string> = { text: '#A8A8A8', claude: '#D97757' }
283const PIXEL = 4
284
285function svgOf(width: number, height: number, colorAt: (x: number, y: number) => string | null): string {
286  const rects: string[] = []
287  for (let y = 0; y < height; y++) {
288    let x = 0
289    while (x < width) {
290      const color = colorAt(x, y)
291      let run = 1
292      while (x + run < width && colorAt(x + run, y) === color) run += 1
293      if (color !== null) {
294        rects.push(`<rect x="${x}" y="${y}" width="${run}" height="1" fill="${SVG_COLOR[color] ?? color}"/>`)
295      }
296      x += run
297    }
298  }
299  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width * PIXEL}" height="${height * PIXEL}" shape-rendering="crispEdges">${rects.join('')}</svg>`
300}
301
302const NO_META: RunMeta = { model: '', tokens: 0, cost: 0 }
303
304const IDLE: AgentRun = {
305  status: 'idle',
306  command: '',
307  log: '',
308  out: '',
309  outFrom: 0,
310  startedAt: 0,
311  endedAt: 0,
312  exitCode: null,
313  lines: [],
314  result: null,
315  meta: NO_META,
316}
317
318const NONE: AgentRuns = { cline: IDLE, agy: IDLE, opencode: IDLE, codex: IDLE }
319const runs = atom({ plugin: 'agent-dock', key: 'runs' } as const, NONE)
320
321// A run kept by an earlier version of the mod holds plain string lines.
322// One kept before an agent was added has no run for it.
323const settle = (run: AgentRun | undefined): AgentRun => ({
324  ...IDLE,
325  ...run,
326  lines: (run?.lines ?? []).map(line =>
327    typeof line === 'string' ? { kind: 'text', text: line } : line,
328  ),
329})
330
331const runOf = async ($: EngineInterface, id: AgentId) => settle((await read($, runs))[id])
332const setRun = ($: EngineInterface, id: AgentId, fn: (run: AgentRun) => AgentRun) =>
333  update($, runs, all => ({ ...all, [id]: fn(settle(all[id])) }))
334
335// A Bash command that runs one of the CLIs in command position.
336const CALL =
337  /(?:^|[;&|(\n]\s*|\btimeout\s+(?:-\S+\s+)*\S+\s+)(cline|agy|opencode|codex)(?=\s|$)/
338// Asking a CLI for its help or version is not a run.
339const HELP = /\b(?:cline|agy|opencode|codex)\s+(?:\S+\s+)?(?:--help|-h|--version|-v)\b/
340const START = '__AGENT_DOCK_START__'
341const EXIT = '__AGENT_DOCK_EXIT__'
342const READ = 3000
343const KEEP = 400
344
345// Runs read once more after finishing empty, so each is read only once.
346const reread = new Set<string>()
347// The last polling error shown, so a lasting one is shown once.
348let lastError = ''
349// Relative output paths belong to the session's working directory.
350let cwd = ''
351
352const paneId = (id: AgentId) => `agent-dock-${id}`
353const quote = (s: string) => `'${s.replaceAll("'", `'\\''`)}'`
354
355// The CLI's own stdout file, not redirects in its task or the next command.
356function redirectOf(command: string, home: string, cwd: string): { out: string; append: boolean } {
357  let out = ''
358  let append = false
359  let i = 0
360  const word = () => {
361    let text = ''
362    let literal = true
363    const stack: string[] = []
364    while (i < command.length) {
365      const ch = command[i]!
366      const top = stack[stack.length - 1] ?? ''
367      if (top === "'") {
368        if (ch === "'") stack.pop()
369        else text += ch
370        i += 1
371        continue
372      }
373      if (ch === '\\') {
374        const next = command[i + 1] ?? ''
375        if (top === '"' && !/["$`\\\n]/.test(next)) text += ch
376        else {
377          if (next !== '\n') text += next
378          i += 1
379        }
380        i += 1
381        continue
382      }
383      if (ch === '$' && command[i + 1] === '(') {
384        stack.push('(')
385        literal = false
386        i += 2
387        continue
388      }
389      if (top === '(' && ch === '(') stack.push('(')
390      else if (top === '(' && ch === ')') stack.pop()
391      else if (ch === '"') {
392        if (top === '"') stack.pop()
393        else stack.push('"')
394      } else if (top !== '"' && ch === "'") stack.push("'")
395      else {
396        if (stack.length === 0 && /[\s;&|<>()]/.test(ch)) break
397        if (ch === '$' || ch === '`') literal = false
398        text += ch
399      }
400      i += 1
401    }
402    return { text, literal: literal && stack.length === 0 }
403  }
404  while (i < command.length) {
405    const ch = command[i]!
406    if (ch === '\n' || ch === ';' || ch === '|' || ch === '(' || ch === ')') break
407    if (/\s/.test(ch)) { i += 1; continue }
408    const redirect = /^(?:(\d*)(>>|>\||>&|>)|(&>>|&>))/.exec(command.slice(i))
409    if (redirect !== null) {
410      i += redirect[0].length
411      while (i < command.length && /[ \t]/.test(command[i]!)) i += 1
412      const target = word()
413      const op = redirect[2] ?? redirect[3] ?? ''
414      if ((redirect[1] !== undefined && redirect[1] !== '' && redirect[1] !== '1') || op === '>&') continue
415      out = target.literal ? target.text : ''
416      if (out.startsWith('~/')) out = `${home}/${out.slice(2)}`
417      if (out !== '' && !/^(?:[A-Za-z]:)?\//.test(out)) out = cwd === '' ? '' : `${cwd}/${out}`
418      if (out !== '') {
419        const parts: string[] = []
420        for (const part of out.split('/')) {
421          if (part === '..') parts.pop()
422          else if (part !== '' && part !== '.') parts.push(part)
423        }
424        out = `${out.startsWith('/') ? '/' : ''}${parts.join('/')}`
425      }
426      if (out === '/dev/null') out = ''
427      append = out !== '' && op.endsWith('>>')
428      continue
429    }
430    if (ch === '&') break
431    if (ch === '<') { i += 1; continue }
432    word()
433  }
434  return { out, append }
435}
436
437// A command wrap() already made, not one whose task merely names the markers.
438const isWrapped = (command: string) => command.includes(`printf '%s\\n' ${START} > `)
439
440// Runs the command as written, copying its output to the log as it comes.
441// `${log}.run` names the run the log belongs to, written once the command
442// truly starts: until then the log still holds the run before.
443const wrap = (command: string, log: string, run: number) =>
444  [
445    `mkdir -p ${quote(log.replace(/\/[^/]+$/, ''))}`,
446    `rm -f ${quote(`${log}.rc`)}`,
447    `printf '%s\\n' ${START} > ${quote(log)}`,
448    `printf '%s' ${run} > ${quote(`${log}.run`)}`,
449    `{ (\n${command}\n) 2>&1; echo $? > ${quote(`${log}.rc`)}; } | tee -a ${quote(log)}`,
450    `__dock_rc=$(cat ${quote(`${log}.rc`)} 2>/dev/null || echo 1)`,
451    `printf '%s %s\\n' ${EXIT} "$__dock_rc" >> ${quote(log)}`,
452    `(exit $__dock_rc)`,
453  ].join('\n')
454
455// The task as the command spells it: its longest quoted string.
456function taskOf(command: string): string {
457  let best = ''
458  for (const m of command.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g)) {
459    const s = (m[1] ?? m[2] ?? '').replace(/\\(.)/g, '$1')
460    if (s.length > best.length) best = s
461  }
462  return best.replace(/\s+/g, ' ').trim()
463}
464
465// The model the command names, if it names one.
466const modelOf = (command: string) =>
467  /(?:^|\s)(?:-m|--model)[=\s]+["']?([^\s"']+)/.exec(command)?.[1] ?? ''
468
469type Parsed = {
470  entries: Entry[]
471  // A streamed piece of text, before its block ends (Cline).
472  delta?: string
473  result?: RunResult
474  meta?: Partial<RunMeta>
475  tokens?: number
476  cost?: number
477}
478
479const firstLine = (s: unknown) => {
480  const line = String(s ?? '').split('\n').find(row => row.trim() !== '') ?? ''
481  return line.length > 200 ? `${line.slice(0, 200)}…` : line
482}
483
484const brief = (input: unknown) => {
485  if (input === undefined || input === null) return ''
486  if (typeof input === 'string') return firstLine(input)
487  const values = Object.values(input as Record<string, unknown>)
488  const s = values.length === 1 && typeof values[0] === 'string'
489    ? values[0]
490    : JSON.stringify(input)
491  return firstLine(s)
492}
493
494const many = (kind: Entry['kind'], s: unknown): Entry[] =>
495  String(s ?? '')
496    .split('\n')
497    .map(text => ({ kind, text }))
498
499// A line of a CLI's styled (non-JSON) output: Cline's `[thinking] …`,
500// `[tool] …` and `└ result` rows, or plain text.
501function plain(text: string): Entry[] {
502  if (/hook dispatch failed|^Reading additional input from stdin/.test(text)) return []
503  // Codex's own log lines (`<time> ERROR codex_core::…: …`): a note, without
504  // the time or thread, so that its repeats fold into one.
505  const logged = /^\d{4}-\d\d-\d\dT[\d:.]+Z\s+(?:ERROR|WARN)\s+codex_[\w:]+:\s*(.*)$/.exec(text)
506  if (logged !== null) {
507    const said = (logged[1] ?? '').replace(/\s*(?:for )?thread [0-9a-f-]{36}(?: not found)?/g, '').trim()
508    return [{
509      kind: 'info',
510      text: /failed to record rollout/.test(said) ? 'Codex could not save this session\'s history (its state db is unavailable)' : `Codex: ${said}`,
511    }]
512  }
513  const tagged = /^\[([a-z_]+)\]\s*(.*)$/.exec(text)
514  if (tagged !== null) {
515    const [, tag = '', rest = ''] = tagged
516    return [tag === 'thinking' ? { kind: 'think', text: rest } : { kind: 'tool', text: `${tag}  ${rest}` }]
517  }
518  const result = /^\s*└\s*(.*)$/.exec(text)
519  if (result !== null) return [{ kind: 'out', text: result[1] ?? '' }]
520  return [{ kind: /\b(error|failed|exception)\b/i.test(text) ? 'error' : 'text', text }]
521}
522
523// One log line as the pane shows it; the JSON events of each CLI unpacked.
524function parse(line: string): Parsed {
525  const text = line.trimEnd()
526  if (text.trim() === '') return { entries: [] }
527  if (!text.trimStart().startsWith('{')) return { entries: plain(text) }
528
529  let o: any
530  try {
531    o = JSON.parse(text)
532  } catch {
533    return { entries: [{ kind: 'text', text }] }
534  }
535
536  // OpenCode: one event per part.
537  const part = o.part ?? {}
538  if (part.type === 'reasoning') return { entries: many('think', part.text) }
539  if (part.type === 'text' && typeof part.text === 'string') return { entries: many('text', part.text) }
540  if (part.type === 'tool') {
541    const state = part.state ?? {}
542    const mark = state.status === 'completed' ? ' ✓' : state.status === 'error' ? ' ✗' : ''
543    const entries: Entry[] = [{ kind: 'tool', text: `${part.tool}${mark}  ${brief(state.input)}` }]
544    if (state.status === 'error') entries.push({ kind: 'error', text: firstLine(state.error ?? state.output) })
545    else if (state.output !== undefined) entries.push({ kind: 'out', text: firstLine(state.output) })
546    return { entries }
547  }
548  if (part.type === 'step-finish') {
549    const t = part.tokens ?? {}
550    return { entries: [], tokens: (t.input ?? 0) + (t.output ?? 0), cost: part.cost ?? 0 }
551  }
552  if (/step[-_]start/.test(String(o.type))) return { entries: [] }
553
554  // Codex (exec --json): items start and complete; the turn ends with usage.
555  if (typeof o.type === 'string' && /^(thread|turn|item)\./.test(o.type)) {
556    const item = o.item ?? {}
557    const done = o.type === 'item.completed'
558    if (o.type === 'turn.completed') {
559      const u = o.usage ?? {}
560      return { entries: [], tokens: (u.input_tokens ?? 0) + (u.output_tokens ?? 0) }
561    }
562    if (o.type === 'turn.failed') return { entries: many('error', o.error?.message ?? 'turn failed') }
563    if (item.type === 'agent_message' && done) return { entries: many('text', item.text) }
564    if (item.type === 'reasoning' && done) return { entries: many('think', item.text) }
565    if (item.type === 'command_execution') {
566      // The shell's own wrapper, `/bin/bash -lc '…'`, left out.
567      const command = String(item.command ?? '').replace(/^\S*sh -lc '([\s\S]*)'$/, '$1')
568      if (!done) return { entries: [{ kind: 'tool', text: `shell  ${firstLine(command)}` }] }
569      const kind = item.exit_code === 0 ? 'out' : 'error'
570      return { entries: [{ kind, text: firstLine(item.aggregated_output) || `exit ${item.exit_code}` }] }
571    }
572    if (item.type === 'file_change' && done) {
573      const files = (item.changes ?? []).map((c: any) => `${c.kind} ${c.path}`).join(', ')
574      return { entries: [{ kind: 'tool', text: `edit  ${firstLine(files)}` }] }
575    }
576    if (item.type === 'mcp_tool_call' && !done) {
577      return { entries: [{ kind: 'tool', text: `${item.server}.${item.tool}  ${brief(item.arguments)}` }] }
578    }
579    if (item.type === 'web_search' && !done) return { entries: [{ kind: 'tool', text: `search  ${firstLine(item.query)}` }] }
580    if (item.type === 'error') return { entries: many('error', item.message) }
581    return { entries: [] }
582  }
583
584  // agy (stream-json, or the one json result).
585  if (o.event === 'init') return { entries: [{ kind: 'info', text: 'started' }] }
586  if (o.event === 'step_update') {
587    const step = o.step_update ?? {}
588    if (step.step_type === 'tool' && step.state === 'ACTIVE') {
589      return { entries: [{ kind: 'tool', text: `${step.tool_name}  ${brief(step.tool_info?.parameters)}` }] }
590    }
591    if (step.step_type === 'tool' && step.state === 'DONE' && step.tool_info?.output !== undefined) {
592      return { entries: [{ kind: 'out', text: firstLine(step.tool_info.output) }] }
593    }
594    if (typeof step.text === 'string') return { entries: many('text', step.text) }
595    return { entries: [] }
596  }
597  const agyResult = o.event === 'result' ? o.result ?? {} : typeof o.response === 'string' ? o : null
598  if (agyResult !== null) {
599    return {
600      entries: [],
601      result: {
602        isOk: agyResult.status === 'SUCCESS',
603        text: String(agyResult.response || agyResult.error || ''),
604      },
605      meta: { tokens: agyResult.usage?.total_tokens ?? 0 },
606    }
607  }
608
609  // Cline: streamed pieces are skipped, each block shown whole at its end.
610  if (o.type === 'agent_event') {
611    const ev = o.event ?? {}
612    if (ev.type === 'content_start' && ev.contentType === 'text' && typeof ev.text === 'string') {
613      return { entries: [], delta: ev.text }
614    }
615    if (ev.type === 'content_end' && ev.contentType === 'text') return { entries: many('text', ev.text) }
616    if (ev.type === 'content_end' && ev.contentType === 'reasoning') return { entries: many('think', ev.reasoning) }
617    if (ev.type === 'content_start' && ev.contentType === 'tool') {
618      return { entries: [{ kind: 'tool', text: `${ev.toolName}  ${brief(ev.input)}` }] }
619    }
620    if (ev.type === 'content_end' && ev.contentType === 'tool') {
621      const out = Array.isArray(ev.output) ? ev.output.map((x: any) => x?.result ?? x).join('\n') : ev.output
622      return { entries: [{ kind: 'out', text: firstLine(typeof out === 'string' ? out : JSON.stringify(out)) }] }
623    }
624    return { entries: [] }
625  }
626  if (o.type === 'run_result') {
627    const usage = o.usage ?? {}
628    return {
629      entries: [],
630      result: { isOk: o.finishReason === 'completed', text: String(o.text ?? '') },
631      meta: {
632        model: o.model?.info?.name ?? o.model?.id ?? '',
633        tokens: (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0),
634        cost: usage.totalCost ?? 0,
635      },
636    }
637  }
638  if (o.type === 'hook_event') return { entries: [] }
639
640  // Cline's own hook plumbing complains on every run; not the run's error.
641  if (o.type === 'error' && /^hook dispatch failed/.test(String(o.message))) return { entries: [] }
642  if (o.type === 'error' && typeof o.message === 'string') return { entries: many('error', o.message) }
643  if (typeof o.error === 'string') return { entries: many('error', o.error) }
644  if (typeof o.error?.message === 'string') return { entries: many('error', o.error.message) }
645  if (typeof o.text === 'string' && o.text !== '') return { entries: many('text', o.text) }
646  if (typeof o.message === 'string') return { entries: many('text', o.message) }
647  return { entries: [{ kind: 'info', text: String(o.type ?? o.event ?? 'event') }] }
648}
649
650// Cells a character takes: wide East Asian characters take two.
651const cells = (ch: string) => {
652  const c = ch.codePointAt(0) ?? 0
653  return (c >= 0x1100 && c <= 0x115f) ||
654    (c >= 0x2e80 && c <= 0xa4cf) ||
655    (c >= 0xac00 && c <= 0xd7a3) ||
656    (c >= 0xf900 && c <= 0xfaff) ||
657    (c >= 0xfe30 && c <= 0xfe4f) ||
658    (c >= 0xff00 && c <= 0xff60) ||
659    (c >= 0xffe0 && c <= 0xffe6) ||
660    (c >= 0x1f300 && c <= 0x1faff)
661    ? 2
662    : 1
663}
664
665function fold(line: string, columns: number): string[] {
666  const out: string[] = []
667  let row = ''
668  let width = 0
669  for (const ch of line) {
670    const w = cells(ch)
671    if (width + w > columns) {
672      out.push(row)
673      row = ''
674      width = 0
675    }
676    row += ch
677    width += w
678  }
679  out.push(row)
680  return out
681}
682
683const seconds = (ms: number) => {
684  const s = Math.max(0, Math.round(ms / 1000))
685  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
686}
687
688const count = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
689
690// The running mark: a spinner, a frame per SPIN milliseconds.
691const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
692const SPIN = 120
693
694function badge(run: AgentRun, now: number): { mark: string; word: string; color: string } {
695  switch (run.status) {
696    case 'running':
697      return {
698        mark: SPINNER[Math.floor(now / SPIN) % SPINNER.length] ?? '●',
699        word: `running ${seconds(now - run.startedAt)}`,
700        color: 'suggestion',
701      }
702    case 'done':
703      return { mark: '✓', word: `done ${seconds(run.endedAt - run.startedAt)}`, color: 'inactive' }
704    case 'error':
705      return {
706        mark: '✗',
707        word: `failed${run.exitCode === null ? '' : ` (exit ${run.exitCode})`} ${seconds(run.endedAt - run.startedAt)}`,
708        color: 'error',
709      }
710    default:
711      return { mark: '○', word: 'idle', color: 'inactive' }
712  }
713}
714
715// Windows: C:/Users/<name>, a path both the engine and Git Bash read.
716const homeDir = async ($: EngineInterface) =>
717  ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '/tmp').replaceAll('\\', '/')
718
719async function logPath($: EngineInterface, id: AgentId) {
720  return `${await homeDir($)}/.claude/agent-dock/${id}.log`
721}
722
723// The model each CLI runs when the command names none, from its own settings.
724const MODEL_FILES: Record<AgentId, string> = {
725  cline: '.cline/data/settings/providers.json',
726  agy: '.gemini/antigravity-cli/settings.json',
727  opencode: '.config/opencode/opencode.jsonc',
728  codex: '.codex/config.toml',
729}
730
731function modelIn(id: AgentId, text: string): string {
732  switch (id) {
733    case 'cline': {
734      const d = JSON.parse(text)
735      const provider = d.lastUsedProvider ?? ''
736      const model = d.providers?.[provider]?.settings?.model ?? ''
737      return model === '' ? '' : `${model} (${provider})`
738    }
739    case 'codex':
740      // A top-level key: before the first [table].
741      return /^\s*model\s*=\s*"([^"]+)"/m.exec(text.split(/^\s*\[/m)[0] ?? '')?.[1] ?? ''
742    default:
743      return /"model"\s*:\s*"([^"]+)"/.exec(text)?.[1] ?? ''
744  }
745}
746
747// Read again at most every few seconds, so a changed setting shows soon.
748const models = new Map<AgentId, { model: string; at: number }>()
749
750// The reasoning effort a CLI's settings give its model: Codex's
751// model_reasoning_effort; OpenCode's variant, which its TUI keeps per model.
752async function effortOf($: EngineInterface, id: AgentId, text: string, model: string): Promise<string> {
753  if (id === 'codex') {
754    return /^\s*model_reasoning_effort\s*=\s*"([^"]+)"/m.exec(text.split(/^\s*\[/m)[0] ?? '')?.[1] ?? ''
755  }
756  if (id === 'opencode' && model !== '') {
757    try {
758      const state = JSON.parse(await $.fs.read(`${await homeDir($)}/.local/state/opencode/model.json`))
759      const variant = state.variant?.[model]
760      return typeof variant === 'string' && variant !== 'default' ? variant : ''
761    } catch {}
762  }
763  return ''
764}
765
766async function configuredModel($: EngineInterface, id: AgentId): Promise<string> {
767  const now = await $.clock.now()
768  const kept = models.get(id)
769  if (kept !== undefined && now - kept.at < 5000) return kept.model
770  let model = ''
771  try {
772    const text = await $.fs.read(`${await homeDir($)}/${MODEL_FILES[id]}`)
773    model = modelIn(id, text)
774    const effort = await effortOf($, id, text, model)
775    if (effort !== '') model = `${model} (${effort})`
776  } catch {}
777  models.set(id, { model, at: now })
778  return model
779}
780
781// The model a command names, with the effort it names: OpenCode's
782// `-m provider/model#variant`, Codex's `-c model_reasoning_effort=…`.
783function commandModel(command: string): string {
784  const [model = '', variant = ''] = modelOf(command).split('#')
785  const effort = variant || (/model_reasoning_effort\s*=\s*\\?["']?(\w+)/.exec(command)?.[1] ?? '')
786  return model === '' ? '' : effort === '' ? model : `${model} (${effort})`
787}
788
789// The log's last lines, read by the engine itself (Windows has no tail); a log
790// too big for one read falls back to tail, then to the exit code alone.
791async function readLog($: EngineInterface, log: string, from = 0): Promise<string | null> {
792  try {
793    return (await $.fs.read(log)).slice(from).split('\n').slice(-READ).join('\n')
794  } catch {}
795  try {
796    // An append offset counts characters in the whole file, before tailing.
797    const { exitCode, stdout } = await $.process.run(from === 0 ? ['tail', '-n', String(READ), log] : ['cat', log])
798    if (exitCode === 0) return from === 0 ? stdout : stdout.slice(from).split('\n').slice(-READ).join('\n')
799  } catch {}
800  if (from !== 0) return null
801  try {
802    const rc = (await $.fs.read(`${log}.rc`)).trim()
803    return `log too large to show\n${EXIT} ${rc === '' ? 1 : rc}`
804  } catch {}
805  return null
806}
807
808// Terminal colours and other control characters, which a drawing refuses.
809const clean = (s: string) =>
810  s.replace(/\x1b\[[0-9;?]*[ -\/]*[@-~]/g, '')
811    .replaceAll('\t', '  ')
812    .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
813
814// Reads the run's log into its state; the exit line ends the run. `again`
815// reads a finished run's log once more, for a run whose reads came up empty.
816async function poll($: EngineInterface, id: AgentId, again = false) {
817  const run = await runOf($, id)
818  if ((run.status !== 'running' && !again) || run.log === '') return
819  // The log is this run's only once its command has started and said so.
820  let owner = ''
821  try {
822    owner = (await $.fs.read(`${run.log}.run`)).trim()
823  } catch {}
824  if (owner !== String(run.startedAt)) return
825
826  const stdout = await readLog($, run.log)
827  if (stdout === null) return
828  const output = run.out === '' ? null : await readLog($, run.out, run.outFrom)
829
830  let ended: number | null = null
831  const rows = stdout.split('\n').filter(line => {
832    if (line.startsWith(START)) return false
833    if (!line.startsWith(EXIT)) return true
834    ended = Number(line.slice(EXIT.length).trim())
835    return false
836  })
837  const dockRows = rows.length
838  if (output !== null) rows.push(...output.split('\n'))
839  let result: RunResult | null = null
840  let streamed = ''
841  let tokens = 0
842  let cost = 0
843  const meta: RunMeta = { ...NO_META, model: modelOf(run.command) }
844  const lines: Entry[] = []
845  let dockLines = 0
846  for (const [index, line] of rows.entries()) {
847    if (index === dockRows) dockLines = lines.length
848    const parsed = parse(clean(line))
849    streamed += parsed.delta ?? ''
850    lines.push(...parsed.entries.map(entry => ({ ...entry, text: clean(entry.text) })))
851    tokens += parsed.tokens ?? 0
852    cost += parsed.cost ?? 0
853    if (parsed.result !== undefined) result = { ...parsed.result, text: clean(parsed.result.text) }
854    if (parsed.meta !== undefined) Object.assign(meta, Object.fromEntries(
855      Object.entries(parsed.meta).filter(([, v]) => v !== '' && v !== 0),
856    ))
857  }
858  if (meta.tokens === 0) meta.tokens = tokens
859  if (meta.cost === 0) meta.cost = cost
860
861  // The agent's ACK line goes to its bubble, and out of the log.
862  const ack = ACK_LINE.exec([streamed, ...lines.filter(l => l.kind === 'text').map(l => l.text)].join('\n'))?.[1]?.trim()
863  if (ack !== undefined && ack !== '') await say($, id, { who: 'agent', kind: 'ack', text: ack, face: '', run: run.startedAt })
864  // A bare `ACK:` takes its words from the next line; both leave the log.
865  for (let i = lines.length - 1; i >= 0; i--) {
866    const text = lines[i]?.text ?? ''
867    if (lines[i]?.kind !== 'text' || !/^\s*ACK:/.test(text)) continue
868    const bare = /^\s*ACK:\s*$/.test(text) && lines[i + 1]?.kind === 'text'
869    const count = bare ? 2 : 1
870    if (i < dockLines) dockLines -= Math.min(count, dockLines - i)
871    lines.splice(i, count)
872  }
873
874  // The last block of text is the answer: kept once, in the answer box. A CLI
875  // with no result event (OpenCode, Codex) has only that block to give. Notes
876  // and errors a CLI logs after its answer stay below it in the log.
877  // Shell notes in the dock log stay out of a redirected answer.
878  const answerFrom = output === null ? 0 : dockLines
879  let end = lines.length
880  while (end > answerFrom && (lines[end - 1]?.kind === 'info' || lines[end - 1]?.kind === 'error')) end -= 1
881  let from = end
882  while (from > answerFrom && lines[from - 1]?.kind === 'text') from -= 1
883  const tail = lines.slice(from, end).map(entry => entry.text).join('\n').trim()
884  if (result !== null && tail !== '' && tail === result.text.trim()) lines.splice(from, end - from)
885  if (ended !== null && result === null && tail !== '') {
886    result = { isOk: ended === 0, text: tail }
887    lines.splice(from, end - from)
888  }
889  // A line a CLI repeats (a warning on every step) is shown once in a row.
890  for (let i = lines.length - 1; i > 0; i--) {
891    if (lines[i]?.kind === lines[i - 1]?.kind && lines[i]?.text === lines[i - 1]?.text) lines.splice(i, 1)
892  }
893  const now = await $.clock.now()
894
895  await setRun($, id, current => {
896    if (current.startedAt !== run.startedAt) return current
897    const next = { ...current, lines: lines.slice(-KEEP), result, meta }
898    if (ended === null) return next
899    return { ...next, status: ended === 0 ? 'done' : 'error', exitCode: ended, endedAt: now }
900  })
901  if (ended !== null && run.status === 'running') {
902    const finished: AgentRun = { ...run, status: ended === 0 ? 'done' : 'error', result, lines }
903    await say($, id, { who: 'agent', kind: 'summary', text: summaryOf(finished) || '…', face: '', run: run.startedAt })
904    const agent = AGENTS.find(a => a.id === id)
905    $.ui.toast(`${agent?.label ?? id} finished ${ended === 0 ? '✓' : `✗ ${ended}`}`)
906  }
907}
908
909// How Claude may hand work to the CLIs, as the person set it in the Policy tab;
910// kept in the mod's store, across sessions.
911const POLICY_KEY = 'policy'
912
913const DEFAULT_POLICY: Policy = {
914  when: 'asked',
915  pick: 'claude',
916  onFail: 'stop',
917  lifelike: false,
918  agents: {
919    cline: { enabled: true, access: 'full', model: '', useFor: '' },
920    agy: { enabled: true, access: 'full', model: '', useFor: '' },
921    opencode: { enabled: true, access: 'full', model: '', useFor: '' },
922    codex: { enabled: true, access: 'full', model: '', useFor: '' },
923  },
924}
925
926// A stored policy, filled out with the defaults for anything it lacks.
927const fillPolicy = (stored: unknown): Policy => {
928  const p = (typeof stored === 'object' && stored !== null ? stored : {}) as Partial<Policy>
929  const agents = { ...DEFAULT_POLICY.agents }
930  for (const agent of AGENTS) agents[agent.id] = { ...agents[agent.id], ...p.agents?.[agent.id] }
931  return { ...DEFAULT_POLICY, ...p, agents }
932}
933
934const policyOf = async ($: EngineInterface) => fillPolicy(await $.store.get(POLICY_KEY))
935
936async function setPolicy($: EngineInterface, fn: (policy: Policy) => Policy) {
937  await $.store.set(POLICY_KEY, fn(await policyOf($)))
938  $.ui.invalidate('ui.render')
939  $.ui.invalidate('prompt.section')
940}
941
942// The Policy tab's setting whose choices are open under it.
943let openMenu: string | null = null
944
945const setAgentPolicy = ($: EngineInterface, id: AgentId, change: Partial<AgentPolicy>) =>
946  setPolicy($, p => ({ ...p, agents: { ...p.agents, [id]: { ...p.agents[id], ...change } } }))
947
948// Each CLI's dispatch: full-auto and read-only forms; {M} is where a model goes.
949const DISPATCH: Record<AgentId, { full: string; read: string; model: string; notes: string }> = {
950  opencode: {
951    full: 'opencode run --format json --auto{M} "<task>" < /dev/null',
952    read: 'opencode run --format json --agent plan{M} "<task>" < /dev/null',
953    model: ' -m <model>',
954    notes: 'Continue: `-s <sessionID>`. Its answer is the `part.text` of the "text" events.',
955  },
956  cline: {
957    full: 'cline --json -c <dir> -t 1800{M} "<task>" < /dev/null',
958    read: 'cline --json -p -c <dir> -t 1800{M} "<task>" < /dev/null',
959    model: ' -m <model>',
960    notes: 'Continue: `--id <session>`. Its answer is the `text` of the "run_result" line. Always pass `--json`. Cline treats a prompt with no spaces as a subcommand name and exits, so a task written without spaces (e.g. all Chinese) needs a space or an English lead-in. Continuing a session with a reasoning model can fail with "`reasoning_content` … must be passed back to the API"; then start a fresh run that carries the needed context instead of `--id`.',
961  },
962  codex: {
963    full: 'codex exec --json --skip-git-repo-check -s workspace-write -C <dir>{M} "<task>" < /dev/null',
964    read: 'codex exec --json --skip-git-repo-check -s read-only -C <dir>{M} "<task>" < /dev/null',
965    model: ' -m <model>',
966    notes: 'Continue: `codex exec resume <thread_id> --json "<task>"`. With workspace-write it edits and runs commands inside `<dir>` only, with no network. Its answer is the `text` of the last "agent_message" item.',
967  },
968  agy: {
969    full: 'agy --output-format stream-json --dangerously-skip-permissions --print-timeout 1800s{M} -p "<task>" < /dev/null',
970    read: 'agy --output-format stream-json --mode plan --print-timeout 1800s{M} -p "<task>" < /dev/null',
971    model: ' --model "<model>"',
972    notes: '`-p "<task>"` goes last. Continue: `--conversation <conversation_id>`. Its answer is the `response` of the "result" event.',
973  },
974}
975
976const LIFELIKE_GUIDE = `Lifelike mode is on: each agent tab shows a chat between you, as Clawd (Claude Code's mascot), and the agent, in speech bubbles. Nothing in it is scripted, and none of it changes the work. Each run goes:
9771. Clawd hands over the work. Make the command's first line a shell comment, \`# clawd: <line>\` or \`# clawd(happy|surprised|confused|sad): <line>\` to set Clawd's face, with the CLI command on the next line. Write it in the person's language, short (about 25 characters in Chinese or Japanese, 60 otherwise), casual and cheerful, a coworker handing over the work in a fun way, not the task text. When you send work back to fix, say what to fix in the same voice. The task you pass stays complete and plain.
9782. The agent answers at once and at the end. Begin the task you pass with: "Open your first message with a line of its own: ACK: and one short line saying you got the task (not what you found), in the voice of <persona>, in <the person's language>. Write it before you read any file or think it through, and in that same message go straight on to the work: call your first tool right away. Do not end your turn after the ACK line." (Some CLIs end the run at a message with no tool call, so the ACK must not stand alone.) End it with: "Finally, on a line of its own, write SAY: and one or two short lines to Claude summing up what you did or found, in the voice of <persona>, in <the person's language>." Personas: Cline, an earnest and orderly robot, polite; agy, floaty and dreamy, drifting in zero gravity; OpenCode, a terse hacker of few words; Codex, confident and a little cocky.
9793. Clawd reacts. Once the run has ended and you have reviewed its result, call mcp__agent-dock__clawd_react once: praise or thanks when the work is good; problem when something is wrong, saying what, and then send the fix as a new run; concern when the run failed or errored, caring first. Its result tells you how many problems in a row that agent has had; past a few, Clawd gets a little impatient, playfully, never rude.
980When you relay an agent's answer, leave its ACK and SAY lines out.`
981
982const WHEN: Record<Policy['when'], string> = {
983  asked: 'Delegate only when the person asks you to hand work to an agent or names one; never on your own.',
984  suggest: 'When a task suits an enabled agent (independent, well specified), you may suggest delegating it, naming the agent; dispatch only after the person agrees.',
985  auto: 'You may delegate on your own judgment when a task suits an enabled agent (independent, well specified work that can run while you continue); say which agent you started and why.',
986  lead: 'Lead mode, the person\'s standing request: you are the lead and the enabled agents are your team, and the person wants the work done by them, not by you. This overrides any general guidance to avoid delegating or to do work inline. Hand every task that writes or changes code, runs or fixes tests, investigates a bug, reads through a codebase or researches something to an agent before doing it yourself, and split a larger task into independent parts run on different agents at once. Your own part is the lead\'s: understand the request, gather just enough context to write a complete task, pick the agent, review what comes back (the diff, a test run), send fixes back to the agent, then integrate and report. When every suitable agent is busy, wait for one to finish rather than doing the work yourself. Do it yourself only when it is trivial (an answer you already know, a one-line change quicker than writing the task), when it needs what an agent lacks (this conversation\'s tools, a push, a question to the person), or after a failed run as the failure rule below says; then say in one line why no agent took it.',
987}
988
989// Lead mode's nudge, read beside each prompt the person types.
990async function leadNote($: EngineInterface, policy: Policy): Promise<string> {
991  const on = AGENTS.filter(a => policy.agents[a.id].enabled)
992  const busy = (await Promise.all(on.map(async a => ((await runOf($, a.id)).status === 'running' ? a : undefined))))
993    .filter(a => a !== undefined)
994  const idle = on.filter(a => !busy.includes(a))
995  const now = `Idle: ${idle.map(a => a.label).join(', ') || 'none'}.${busy.length === 0 ? '' : ` Running: ${busy.map(a => a.label).join(', ')}.`}`
996  return `Agent dock is in lead mode: hand this request's real work (code, tests, debugging, codebase reading, research) to the enabled agents as the dispatch guide says, and keep the lead's part (task writing, review, integration) for yourself; do it yourself only when it is trivial or needs what an agent lacks, and say why. ${now}`
997}
998
999const ON_FAIL: Record<Policy['onFail'], string> = {
1000  stop: 'When a run fails (non-zero exit, an error event, "model unavailable", an auth or quota error), report the error to the person in one short message and stop: do not retry it, re-dispatch it, switch it to another CLI or model, or start another run unless the person asks.',
1001  other: 'When a run fails (non-zero exit, an error event, "model unavailable", an auth or quota error), report the error in one short message, then hand the same task once to another enabled agent that suits it; if that fails too, report and stop. Never more than one retry per task, and never the same agent again.',
1002}
1003
1004function guideFor(policy: Policy): string {
1005  const on = AGENTS.filter(a => policy.agents[a.id].enabled)
1006  const off = AGENTS.filter(a => !policy.agents[a.id].enabled)
1007  if (on.length === 0) {
1008    return `# Agent dock\n\nThe person has turned off every coding-agent CLI in the agent dock's Policy tab (${KEY}). Do not dispatch work to Cline, agy, OpenCode or Codex; if asked to, say they are turned off there.`
1009  }
1010
1011  const lines = on.map(agent => {
1012    const a = policy.agents[agent.id]
1013    const d = DISPATCH[agent.id]
1014    const model = a.model === '' ? '' : d.model.replace('<model>', a.model)
1015    const form = (s: string) => `\`${s.replace('{M}', model)}\``
1016    const access = a.access === 'read'
1017      ? `${form(d.read)}. Read-only by the person's choice: never give it work that changes files.`
1018      : `${form(d.full)}; read-only form: ${form(d.read)}.`
1019    const pinned = a.model === '' ? ' Uses its own default model unless the person names one.' : ` Model set by the person: ${a.model}.`
1020    const useFor = a.useFor === '' ? '' : ` The person prefers it for: ${a.useFor}.`
1021    return `- ${agent.label}: ${access}${pinned}${useFor} ${d.notes}`
1022  })
1023
1024  const pick = policy.pick === 'claude'
1025    ? 'When the person asks for "an agent" without naming one, pick the enabled agent that suits the task best, using the preferences above.'
1026    : `When the person asks for "an agent" without naming one, use ${AGENTS.find(a => a.id === policy.pick)?.label ?? policy.pick}${policy.agents[policy.pick].enabled ? '' : ' if it is enabled, else the enabled agent that suits best'}.`
1027
1028  return [
1029    '# Agent dock: delegating to other coding-agent CLIs',
1030    `The person can hand work to these coding-agent CLIs on this machine: ${on.map(a => a.label).join(', ')}. Their dispatch policy is set in the agent dock's Policy tab (${KEY}) and is written out below; follow it.`,
1031    WHEN[policy.when],
1032    'Dispatch with the Bash tool and `run_in_background: true`, so you can keep working; you are notified when it exits, then read its output file and carry on. The person watches each run live in the agent tabs (log: ~/.claude/agent-dock/<cli>.log), so do not pipe the command into tail or head, nor redirect its output to a file (`> file`, `&> file`): the tab then sees nothing. To keep a copy, end it with `| tee <file>`, which the tab still sees. One run per CLI at a time. Agents marked full auto edit files and run commands without asking; for a question that needs no changes use the read-only form.',
1033    lines.join('\n'),
1034    pick,
1035    ...(policy.lifelike ? [LIFELIKE_GUIDE] : []),
1036    ...(off.length === 0 ? [] : [`Turned off by the person: ${off.map(a => a.label).join(', ')}. Do not dispatch to them; if asked to, say they are turned off in the Policy tab.`]),
1037    'The other agent cannot see this conversation: write the task with full context (absolute paths, the goal, how to check it is done). It works in the directory you start it in, so start it in the project the task is about. When it has edited files, review what it changed (git diff, a test run) before you report the work as done.',
1038    `${ON_FAIL[policy.onFail]} A notification about a run that already ended needs no new dispatch. When the error is about the model (unavailable, no longer free, unauthorized, out of credit), say which CLI and model failed and where the person picks another: the model field in the Policy tab, or the CLI's own settings (Cline: \`cline auth\`; Codex: \`model\` in ~/.codex/config.toml; OpenCode: \`model\` in ~/.config/opencode/opencode.jsonc; agy: \`agy models\`).`,
1039  ].join('\n\n')
1040}
1041
1042const KEY = 'ctrl+x d'
1043
1044const TABS = [
1045  ...AGENTS.map(agent => ({ id: paneId(agent.id), title: `  ${agent.glyph} ${agent.label}  ` })),
1046  { id: 'agent-dock-policy', title: '  ⚙ Policy  ' },
1047]
1048
1049// The tab shown when the dock was last hidden, kept across sessions.
1050const LAST_TAB_KEY = 'lastTab'
1051
1052// Opens every tab in order (cline, agy, opencode, codex, policy), then asks
1053// for the one shown last time; where the surface does not raise it, the
1054// last tab shows, and the order stays.
1055async function openPanes($: EngineInterface) {
1056  for (const tab of TABS) await $.ui.open({ ...tab, columns: 72 })
1057  const lastId = String((await $.store.get(LAST_TAB_KEY)) ?? '')
1058  const tab = TABS.find(t => t.id === lastId)
1059  if (tab === undefined || tab === TABS[TABS.length - 1]) return
1060  // Asked to the front, never closed and reopened: the tabs keep their order.
1061  await $.ui.open({ ...tab, columns: 72, focus: true })
1062}
1063
1064const isDockOpen = async ($: EngineInterface) =>
1065  (await $.ui.panes()).some(pane => pane.id.startsWith('agent-dock-'))
1066
1067// The one switch: all the tabs come and go together.
1068async function toggleDock($: EngineInterface) {
1069  if (await isDockOpen($)) {
1070    const shown = (await $.ui.panes()).find(pane => pane.isShown && pane.id.startsWith('agent-dock-'))
1071    if (shown !== undefined) await $.store.set(LAST_TAB_KEY, shown.id)
1072    for (const tab of TABS) await $.ui.close({ id: tab.id })
1073  } else {
1074    await openPanes($)
1075  }
1076  $.ui.invalidate('ui.render')
1077}
1078
1079const PREFIX: Record<Entry['kind'], string> = {
1080  text: '',
1081  think: '┊ ',
1082  tool: '⚙ ',
1083  out: '  ↳ ',
1084  error: '✗ ',
1085  info: '· ',
1086}
1087
1088// One timer, which only asks for a redraw while an agent runs: SPIN ms turns
1089// the spinners, and the drawing reads the logs, at most once a second.
1090let ticker: { cancel: () => void } | null = null
1091let tickedAt = 0
1092let polledAt = 0
1093let anyRunning = false
1094
1095// Started from events that act; again when it has not ticked lately, since
1096// the engine can end an interval without a word.
1097function startTicker($: EngineInterface) {
1098  if (ticker !== null && Date.now() - tickedAt < 2000) return
1099  ticker?.cancel()
1100  tickedAt = Date.now()
1101  ticker = $.clock.every(SPIN, () => {
1102    tickedAt = Date.now()
1103    if (anyRunning) $.ui.invalidate('ui.render')
1104  }) as unknown as { cancel: () => void }
1105}
1106
1107// From a drawing: note whether any agent runs, and read the running ones'
1108// logs when a second has passed since the last read.
1109function pollFromDrawing($: EngineInterface, all: AgentRuns, now: number) {
1110  const running = AGENTS.filter(agent => settle(all[agent.id]).status === 'running')
1111  anyRunning = running.length > 0
1112  if (!anyRunning || now - polledAt < 1000) return
1113  polledAt = now
1114  void (async () => {
1115    for (const agent of running) await poll($, agent.id)
1116    $.ui.invalidate('ui.render')
1117  })().catch(err => {
1118    if (String(err) === lastError) return
1119    lastError = String(err)
1120    $.ui.toast(`agent-dock: ${lastError}`)
1121  })
1122}
1123
1124export const register: Register = on => {
1125  on('session.start', async ($, e, next) => {
1126    cwd = e.cwd.replaceAll('\\', '/')
1127    const started = await next(e)
1128    // The pane of the earlier single-pane layout.
1129    await $.ui.close({ id: 'agent-dock' })
1130    // The status line entry of earlier versions; the hint now sits in the footer.
1131    $.ui.status(undefined)
1132    startTicker($)
1133    await $.tool.register({
1134      name: 'clawd_react',
1135      description:
1136        "Lifelike mode of the agent dock: shows Clawd's reaction to an agent's finished run, in a speech bubble in that agent's tab. Call it once per run, after you have reviewed the result.",
1137      inputSchema: {
1138        type: 'object',
1139        properties: {
1140          agent: { type: 'string', enum: AGENTS.map(a => a.id), description: 'The agent whose run Clawd reacts to.' },
1141          mood: {
1142            type: 'string',
1143            enum: Object.keys(MOODS),
1144            description: 'praise or thanks when the work is good; problem when something is wrong and you will send it back; concern when the run failed or errored.',
1145          },
1146          text: { type: 'string', description: "Clawd's line: short, casual and cheerful, in the person's language." },
1147        },
1148        required: ['agent', 'mood', 'text'],
1149      },
1150      isDeferred: false,
1151    })
1152    await $.command.register({
1153      name: 'agent-dock',
1154      description: `Show or hide the coding-agent CLI tabs (Cline, agy, OpenCode, Codex); ${KEY}`,
1155      immediate: true,
1156    })
1157    return started
1158  })
1159
1160  // Clawd's reaction to a finished run, from Claude: the bubble, the face, and
1161  // the problem streak, which the result reports back so Clawd's tone can follow.
1162  on('tool.call', { tool: /^mcp__agent-dock__clawd_react$/ }, async ($, e) => {
1163    const input = e as unknown as { agent?: string; mood?: string; text?: string }
1164    const agent = AGENTS.find(a => a.id === input.agent)
1165    const mood = MOODS[input.mood as Mood]
1166    if (agent === undefined || mood === undefined || typeof input.text !== 'string' || input.text.trim() === '') {
1167      return { deny: `Give agent (${AGENTS.map(a => a.id).join(', ')}), mood (${Object.keys(MOODS).join(', ')}) and text.` }
1168    }
1169    const run = await runOf($, agent.id)
1170    const streak = mood.isProblem ? (await chatOf($, agent.id)).streak + 1 : 0
1171    await update($, chats, all => ({ ...all, [agent.id]: { ...NO_CHAT, ...all[agent.id], streak } }))
1172    await say($, agent.id, { who: 'clawd', kind: 'react', text: input.text.trim(), face: mood.face, run: run.startedAt })
1173    $.ui.invalidate('ui.render')
1174    const tone = streak >= 3
1175      ? ' That is a lot in a row: let Clawd sound a little impatient now, playfully, never rude.'
1176      : ''
1177    return { result: `Shown in the ${agent.label} tab. Problems in a row with ${agent.label}: ${streak}.${tone}` }
1178  })
1179
1180  on('prompt.compose', async ($, e, next) => {
1181    startTicker($)
1182    const composed = await next(e)
1183    return {
1184      sections: [...composed.sections, { id: 'agent-dock:guide', text: guideFor(await policyOf($)), scope: 'session' }],
1185    }
1186  })
1187
1188  on('prompt.submit', async ($, e, next) => {
1189    if (e.command !== undefined || e.isMeta === true) return next(e)
1190    const policy = await policyOf($)
1191    if (policy.when !== 'lead' || !AGENTS.some(a => policy.agents[a.id].enabled)) return next(e)
1192    return next({ ...e, context: [...(e.context ?? []), await leadNote($, policy)] })
1193  })
1194
1195  on('command.run', { command: 'agent-dock' }, async $ => {
1196    await toggleDock($)
1197    return { text: '' }
1198  })
1199
1200  on('ui.close', async ($, e, next) => {
types/index.d.ts 63 lines
1export type AgentId = 'cline' | 'agy' | 'opencode' | 'codex'
2
3export type EntryKind = 'text' | 'think' | 'tool' | 'out' | 'error' | 'info'
4
5export type Entry = { kind: EntryKind; text: string }
6
7export type RunMeta = { model: string; tokens: number; cost: number }
8
9export type RunResult = { isOk: boolean; text: string }
10
11export type AgentRun = {
12  status: 'idle' | 'running' | 'done' | 'error'
13  command: string
14  log: string
15  out: string
16  outFrom: number
17  startedAt: number
18  endedAt: number
19  exitCode: number | null
20  lines: Entry[]
21  result: RunResult | null
22  meta: RunMeta
23}
24
25export type AgentRuns = Record<AgentId, AgentRun>
26
27export type AgentPolicy = {
28  enabled: boolean
29  access: 'full' | 'read'
30  model: string
31  useFor: string
32}
33
34export type Policy = {
35  when: 'asked' | 'suggest' | 'auto' | 'lead'
36  pick: AgentId | 'claude'
37  onFail: 'stop' | 'other'
38  lifelike: boolean
39  agents: Record<AgentId, AgentPolicy>
40}
41
42export type Face = 'default' | 'happy' | 'surprised' | 'confused' | 'sad'
43
44// One line of the lifelike chat between Clawd and an agent, tied to its run.
45export type ChatMessage = {
46  who: 'clawd' | 'agent'
47  kind: 'handoff' | 'ack' | 'summary' | 'react'
48  text: string
49  face: Face | ''
50  run: number
51}
52
53// `streak` counts Clawd's reactions in a row that found a problem.
54export type AgentChat = { messages: ChatMessage[]; streak: number }
55
56export type AgentChats = Record<AgentId, AgentChat>
57
58declare module 'claude-code' {
59  interface PluginState {
60    'agent-dock': { runs: AgentRuns; chats: AgentChats }
61  }
62}
63