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

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.
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.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).
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.
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.
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).
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.
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.
hooks/register.tsx 1611 lines1import { 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 lines1export 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