Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3ec651c
feat(agent): scaffold cate-control extension installer (spike validat…
May 30, 2026
6208ea1
feat(agent): cate-control wire types + action classifier
May 30, 2026
605e58c
feat(settings): add cateControlEnabled flag (default on)
May 30, 2026
d2dec33
feat(agent): pure placement + arrange geometry for cate-control
May 30, 2026
c858f1a
feat(agent): per-chat cateControlMode state in agentStore
May 30, 2026
8dc7d16
feat(agent): cate-control dispatcher core (registry + gating)
May 30, 2026
8399711
feat(agent): cate-control lifecycle executors (get_layout/open/close)
May 30, 2026
692afb5
feat(agent): cate-control management/content/viewport executors + map
May 30, 2026
0c6821a
feat(agent): route cate-control requests to dispatcher + approval gating
May 30, 2026
86bcbca
feat(agent): full cate-control tool surface in the pi extension
May 30, 2026
5fa6c89
feat(agent): guarded/auto toggle for cate-control
May 30, 2026
0387093
fix(settings): register cateControlEnabled in SETTINGS_SCHEMA (fixes …
May 30, 2026
576c88a
fix(agent): reliably run terminal commands + add markdown preview tool
May 30, 2026
1b205aa
test(e2e): cover cate-control terminal command execution + markdown p…
May 30, 2026
109d8a9
Merge remote-tracking branch 'origin/main' into feat/agent-controls-cate
May 30, 2026
ae3b18a
Merge remote-tracking branch 'origin/main' into feat/agent-controls-cate
May 31, 2026
d465932
refactor(agent): trim cate-control toolset, add terminal read, fix op…
May 31, 2026
b6ac76b
feat(agent): custom rendering + approval cards for cate-control actions
May 31, 2026
78297ab
Merge remote-tracking branch 'origin/main' into feat/agent-controls-cate
May 31, 2026
2e453cf
refactor(agent): consolidate cate-control to 4 op-based tools
May 31, 2026
2911677
fix(agent): refresh installed cate-control extension when it changes
May 31, 2026
cdaebdb
Merge branch 'main' into feat/agent-controls-cate
architawr May 31, 2026
6967e64
Merge remote-tracking branch 'origin/main' into feat/agent-controls-cate
May 31, 2026
5ab8465
Merge remote-tracking branch 'fork/feat/agent-controls-cate' into fea…
May 31, 2026
ba7bbcb
fix(agent): drop removed git/fileExplorer panel types from cate-control
May 31, 2026
f997cc4
refactor(agent): purge removed git/fileExplorer panel types from cate…
May 31, 2026
3beeca0
wip(agent): cate-control rendering, executors, and shared types
Anton-Horn May 31, 2026
c57caa9
Merge remote-tracking branch 'origin/main' into feat/agent-controls-cate
Anton-Horn May 31, 2026
0424c84
feat(agent): stable short-UUID panel ids for cate-control
Anton-Horn May 31, 2026
8a837c1
Merge branch 'main' into feat/agent-controls-cate
Anton-Horn May 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions e2e/cate-control.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// E2E coverage for the cate-control agent feature - drives the real renderer
// dispatcher (window.__cateE2E.cateControl) exactly as an agent tool call would,
// then observes the live app. Uses the lean, titles-only 4-tool surface
// (layout / panel{open,close,move} / browser / terminal{run,read}). Focused on:
// 1. terminal commands actually run (panel open + terminal run)
// 2. opening an editor straight into markdown preview
import { test, expect } from '@playwright/test'
import { launchApp, closeApp } from './fixtures/electron-app'
import type { ElectronApplication, Page } from 'playwright'

let app: ElectronApplication
let page: Page

test.beforeEach(async () => {
;({ electronApp: app, mainWindow: page } = await launchApp())
})
test.afterEach(async () => closeApp(app))

async function cate(p: Page, action: string, params: Record<string, unknown>): Promise<any> {
return p.evaluate(
({ action, params }) => window.__cateE2E!.cateControl(action, params),
{ action, params },
)
}

// cate tools report panel TITLES; resolve to a panelId for the harness reads.
async function panelId(p: Page, title: string): Promise<string | null> {
return p.evaluate((t) => window.__cateE2E!.panelIdByTitle(t), title)
}

test('terminal run executes the command in a live PTY', async () => {
const res = await cate(page, 'terminal', { op: 'run', command: 'echo $((6*7))_CATEOK', newPanel: true })
expect(res.ok).toBe(true)
const title = res.result.terminal as string
expect(title).toBeTruthy()
// "42_CATEOK" appears only in the command OUTPUT (the echoed input line shows
// the literal "echo $((6*7))_CATEOK"), so matching it proves the shell ran.
await expect
.poll(async () => {
const pid = await panelId(page, title)
return pid ? page.evaluate((p) => window.__cateE2E!.terminalText(p), pid) : ''
}, { timeout: 15_000, intervals: [250] })
.toContain('42_CATEOK')
})

test('panel open (terminal, command) runs the command', async () => {
const res = await cate(page, 'panel', { op: 'open', type: 'terminal', target: { command: 'echo $((8*8))_CATEOPEN' } })
expect(res.ok).toBe(true)
const title = res.result.title as string
await expect
.poll(async () => {
const pid = await panelId(page, title)
return pid ? page.evaluate((p) => window.__cateE2E!.terminalText(p), pid) : ''
}, { timeout: 15_000, intervals: [250] })
.toContain('64_CATEOPEN')
})

test('panel open with target.preview enters markdown preview', async () => {
const opened = await cate(page, 'panel', { op: 'open', type: 'editor', target: { path: 'CATE_NOTES.md', preview: true } })
expect(opened.ok).toBe(true)
const pid = await panelId(page, opened.result.title as string)
expect(pid).toBeTruthy()
const nodeId = await page.evaluate(
(p) => window.__cateE2E!.nodes().find((n) => n.panelId === p)?.id ?? null,
pid,
)
expect(nodeId).toBeTruthy()
const nodeSel = `[data-node-id="${nodeId}"]`
await page.waitForSelector(nodeSel)
// Preview active → the toggle reads "Source" (click to go back to source).
await expect(page.locator(`${nodeSel} button:has-text("Source")`)).toBeVisible()
})

test('closing a non-existent panel title errors', async () => {
const res = await cate(page, 'panel', { op: 'close', panel: 'does-not-exist' })
expect(res.ok).toBe(false)
})
119 changes: 119 additions & 0 deletions src/agent/extensions/cate-control/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
import { Type } from "typebox"

// Inlined sentinel (must equal CATE_SENTINEL in src/shared/cateControl.ts).
// Pi loads this file via jiti from the workspace dir, where @shared can't resolve.
const CATE_SENTINEL = "@@cate-control@@"

type CateResponse = { ok: boolean; result?: unknown; error?: string; denied?: boolean }

async function sendControlRequest(ctx: any, action: string, params: Record<string, unknown>): Promise<CateResponse> {
const payload = CATE_SENTINEL + JSON.stringify({ action, params })
const raw = await ctx.ui.input(payload)
if (typeof raw !== "string") return { ok: false, error: "no response from Cate (cancelled or timed out)" }
try { return JSON.parse(raw) as CateResponse }
catch { return { ok: false, error: "malformed response from Cate" } }
}

function toResult(action: string, res: CateResponse) {
const text = res.ok
? `${action} ok: ${JSON.stringify(res.result ?? {})}`
: res.denied ? `${action} denied by user` : `${action} failed: ${res.error ?? "unknown error"}`
return { content: [{ type: "text" as const, text }], details: res }
}

const Placement = Type.Optional(Type.Object({
relativeTo: Type.Optional(Type.String({ description: "panel id (e.g. \"a1b2c3\") or 'self'" })),
position: Type.Optional(Type.Union([Type.Literal("right"), Type.Literal("left"), Type.Literal("above"), Type.Literal("below")])),
}))

const CATE_TOOLS = ["cate_layout", "cate_panel", "cate_browser", "cate_terminal"]

export default function (pi: ExtensionAPI) {
// On/off without a reload: the tools are always registered, but we add/remove
// them from the session's ACTIVE set, which is what gets advertised to the
// model. Inactive => the agent never sees them and spends no tokens on their
// definitions. The renderer flips this live by firing /cate-on | /cate-off
// (like /plan); the env var seeds the initial state for a fresh session.
let desired = process.env.CATE_CONTROL_ENABLED !== "0"
const apply = () => {
const active = new Set(pi.getActiveTools())
for (const t of CATE_TOOLS) { if (desired) active.add(t); else active.delete(t) }
pi.setActiveTools([...active])
}
const setEnabled = (on: boolean) => { desired = on; apply() }
// Re-apply on every session start/resume/reload so the live state survives.
pi.on("session_start", () => apply())
pi.registerCommand("cate-on", { description: "Enable Cate panel control.", handler: async () => setEnabled(true) })
pi.registerCommand("cate-off", { description: "Disable Cate panel control.", handler: async () => setEnabled(false) })

const tool = (name: string, label: string, description: string, parameters: any, action: string) =>
pi.registerTool({
name, label, description, parameters,
async execute(_id, params, _signal, _onUpdate, ctx) {
return toResult(action, await sendControlRequest(ctx, action, params as Record<string, unknown>))
},
})

tool("cate_layout", "Read the canvas",
"Return the open panels - {id, title, type, focused, isSelf} for each. Target panels in the other cate tools by their `id` (e.g. \"a1b2c3\") - it is stable. `title` is only a display label and changes (a browser's title becomes the page title, etc.).",
Type.Object({}), "layout")

tool("cate_panel", "Open, close, or move a panel",
[
"Open, close, or move a canvas panel. Choose `op`:",
"- 'open': create a panel. {type: editor|terminal|browser|document, target?, placement?}. target: {path,line?,column?,preview?} for editor (preview:true opens a markdown file straight into rendered preview); {url} for browser; {cwd?,command?} for terminal. Returns the new panel's {id, title} - keep the id to target it later.",
"- 'close': {panel} - the panel id.",
"- 'move': {panel, placement:{relativeTo,position}} - reposition relative to another panel.",
"`panel` and placement.relativeTo are panel IDs like \"a1b2c3\" (from cate_layout or an open result), or 'self' for your own panel. This never pans or zooms the user's view.",
].join("\n"),
Type.Object({
op: Type.Union([Type.Literal("open"), Type.Literal("close"), Type.Literal("move")]),
panel: Type.Optional(Type.String({ description: "panel id (for close / move)" })),
type: Type.Optional(Type.String()),
target: Type.Optional(Type.Object({
path: Type.Optional(Type.String()), line: Type.Optional(Type.Number()), column: Type.Optional(Type.Number()),
url: Type.Optional(Type.String()), cwd: Type.Optional(Type.String()), command: Type.Optional(Type.String()),
preview: Type.Optional(Type.Boolean()),
})),
placement: Placement,
}), "panel")

tool("cate_browser", "Control a browser panel",
[
"Drive a browser panel. Choose `op`:",
"- 'navigate': load a url. {panel, url}.",
"- 'back' | 'forward' | 'reload' | 'stop': history / loading control. {panel}.",
"- 'info': report the current {url, title, canGoBack, canGoForward}. {panel}.",
"- 'read': the page's visible text, or one CSS selector's text. {panel, selector?}.",
"- 'eval': run JavaScript in the page and return its result (use this to click, fill, or scroll). {panel, js}.",
"- 'screenshot': capture the page to an image file. {panel}.",
"`panel` is a panel id (e.g. \"a1b2c3\", from cate_layout or the open result).",
].join("\n"),
Type.Object({
op: Type.Union([
Type.Literal("navigate"), Type.Literal("back"), Type.Literal("forward"),
Type.Literal("reload"), Type.Literal("stop"), Type.Literal("info"),
Type.Literal("read"), Type.Literal("eval"), Type.Literal("screenshot"),
]),
panel: Type.String({ description: "panel id, e.g. \"a1b2c3\"" }),
url: Type.Optional(Type.String()),
selector: Type.Optional(Type.String({ description: "CSS selector for read" })),
js: Type.Optional(Type.String({ description: "JavaScript to run in the page for eval" })),
}), "browser")

tool("cate_terminal", "Run or read a terminal",
[
"Drive a terminal panel. Choose `op`:",
"- 'run': run a shell command. {command, panel? (reuse an existing terminal by id), newPanel?:bool (force a fresh one)}. Returns the terminal's {id, title}.",
"- 'read': read recent output (visible screen + scrollback) as text. {panel, lines?:number (trailing lines, default 50, max 1000)}.",
"`panel` is a panel id (e.g. \"a1b2c3\").",
].join("\n"),
Type.Object({
op: Type.Union([Type.Literal("run"), Type.Literal("read")]),
panel: Type.Optional(Type.String()),
command: Type.Optional(Type.String()),
newPanel: Type.Optional(Type.Boolean()),
lines: Type.Optional(Type.Number()),
}), "terminal")
}
6 changes: 6 additions & 0 deletions src/agent/extensions/cate-control/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "cate-control",
"description": "Control Cate panels by title: read the canvas, open/close/move panels, run/read terminals, navigate browsers.",
"private": true,
"pi": { "extensions": ["./index.ts"] }
}
9 changes: 9 additions & 0 deletions src/agent/main/agentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import type {
import { AGENT_EVENT } from '../../shared/ipc-channels'
import { installSubagentExtension } from './installSubagents'
import { installPlanModeExtension } from './installPlanMode'
import { installCateControlExtension } from './installCateControl'
import { getSettingSync } from '../../main/store'
import { agentDirFor, prepareAgentDir, watchWorkspaceAuth, pushSharedToWorkspace } from './agentDir'
import { mirrorModelsToWorkspace } from './customModels'
import type { AuthManager } from './authManager'
Expand Down Expand Up @@ -92,6 +94,10 @@ function buildAgentEnv(cwd: string): Record<string, string> {
// Scope pi's entire config (extensions, sessions, settings, auth) to this
// workspace instead of the user's global ~/.pi/agent.
env.PI_CODING_AGENT_DIR = agentDirFor(cwd)
// The cate-control extension reads this at load time and registers its tools
// only when enabled, so disabling the feature costs the agent zero tokens
// (no tool definitions) without removing the extension from disk.
env.CATE_CONTROL_ENABLED = getSettingSync('cateControlEnabled') ? '1' : '0'
const needsShim = app.isPackaged || !nodeExistsOnPath(env)
if (needsShim) {
const shimDir = ensureElectronNodeShim()
Expand Down Expand Up @@ -191,6 +197,9 @@ export class AgentManager {
await mirrorModelsToWorkspace(opts.cwd)
await installSubagentExtension(opts.cwd)
await installPlanModeExtension(opts.cwd)
// Always installed; whether it registers any tools is gated at load time by
// CATE_CONTROL_ENABLED (see buildAgentEnv), so toggling never touches disk.
await installCateControlExtension(opts.cwd)

const extraArgs: string[] = []
if (opts.sessionFile) extraArgs.push('--session', opts.sessionFile)
Expand Down
53 changes: 53 additions & 0 deletions src/agent/main/installCateControl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import fs from 'fs/promises'
import os from 'os'
import path from 'path'

// installCateControl (and agentDir, which it imports) pull in electron + the
// main logger, neither of which load under the node test env. Stub them so the
// module graph evaluates.
vi.mock('electron', () => ({ app: { getAppPath: () => '/nonexistent', getPath: () => os.tmpdir() } }))
vi.mock('../../main/logger', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } }))

import { copyIfChanged } from './installCateControl'

let dir: string
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'cate-install-'))
})
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true })
})

describe('copyIfChanged', () => {
it('writes the file (and parent dirs) when the destination is missing', async () => {
const src = path.join(dir, 'src.ts')
const dest = path.join(dir, 'nested', 'dest.ts')
await fs.writeFile(src, 'NEW')
await copyIfChanged(src, dest)
expect(await fs.readFile(dest, 'utf8')).toBe('NEW')
})

it('overwrites a stale destination whose bytes differ (the protocol-desync bug)', async () => {
// Regression: skip-if-exists left an old extension installed, so the agent
// emitted action names (cate_open_panel) the renderer no longer handled.
const src = path.join(dir, 'src.ts')
const dest = path.join(dir, 'dest.ts')
await fs.writeFile(src, 'tool("cate_panel")')
await fs.writeFile(dest, 'tool("cate_open_panel")')
await copyIfChanged(src, dest)
expect(await fs.readFile(dest, 'utf8')).toBe('tool("cate_panel")')
})

it('does not rewrite when the destination already matches', async () => {
const src = path.join(dir, 'src.ts')
const dest = path.join(dir, 'dest.ts')
await fs.writeFile(src, 'SAME')
await fs.writeFile(dest, 'SAME')
const past = new Date(Date.now() - 60_000)
await fs.utimes(dest, past, past)
const before = (await fs.stat(dest)).mtimeMs
await copyIfChanged(src, dest)
expect((await fs.stat(dest)).mtimeMs).toBe(before) // skipped — not rewritten
})
})
75 changes: 75 additions & 0 deletions src/agent/main/installCateControl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// =============================================================================
// installCateControl - copy the bundled cate-control extension into a
// workspace's pi-agent extensions dir, where pi auto-discovers it.
//
// Source lives in our own tree at src/agent/extensions/cate-control/. Pi
// loads .ts directly via jiti, so we just ship the raw .ts and .json files.
//
// Dev: src/ is on disk under app.getAppPath().
// Prod: src/agent/extensions/cate-control/ is copied into resources via
// electron-builder.yml `extraResources`, so we resolve from
// process.resourcesPath there.
//
// Refresh-on-change (NOT skip-if-exists): the extension's tool/action protocol
// is tightly coupled to the renderer dispatcher (src/agent/renderer/cateControl
// + cateExecutors). A stale installed copy makes the agent emit action names the
// renderer no longer handles ("Unknown or unimplemented action"), so the bundled
// copy is authoritative and is rewritten whenever its bytes differ from the
// installed copy. (Previously skip-if-exists, which silently broke the feature
// after any extension update - dev or app upgrade.)
// =============================================================================

import fs from 'fs'
import fsp from 'fs/promises'
import path from 'path'
import { app } from 'electron'
import log from '../../main/logger'
import { agentDirFor } from './agentDir'

const installed = new Set<string>()

/** Source dir of the bundled extension. Tries dev path first (src/ on disk),
* then production extraResources copy. */
function sourceDir(): string | null {
const candidates = [
path.join(app.getAppPath(), 'src', 'agent', 'extensions', 'cate-control'),
path.join(process.resourcesPath ?? '', 'cate-extensions', 'cate-control'),
]
for (const c of candidates) {
if (c && fs.existsSync(c)) return c
}
return null
}

/** Write `src` → `dest` when the destination is missing or its bytes differ.
* Keeps the installed extension in lock-step with the bundled source so the
* agent never emits a stale action the renderer dispatcher can't handle. */
export async function copyIfChanged(src: string, dest: string): Promise<void> {
const srcData = await fsp.readFile(src)
try {
const destData = await fsp.readFile(dest)
if (destData.equals(srcData)) return // up to date - nothing to do
} catch { /* missing - fall through to write */ }
await fsp.mkdir(path.dirname(dest), { recursive: true })
await fsp.writeFile(dest, srcData)
log.info('[installCateControl] installed/updated %s', dest)
}

/** Idempotent - safe to call from AgentManager.create() on every session. */
export async function installCateControlExtension(cwd: string): Promise<void> {
const home = agentDirFor(cwd)
if (installed.has(home)) return
installed.add(home)
try {
const src = sourceDir()
if (!src) {
log.warn('[installCateControl] source dir not found - cate-control not installed')
return
}
const destDir = path.join(home, 'extensions', 'cate-control')
await copyIfChanged(path.join(src, 'index.ts'), path.join(destDir, 'index.ts'))
await copyIfChanged(path.join(src, 'package.json'), path.join(destDir, 'package.json'))
} catch (err) {
log.warn('[installCateControl] install failed: %O', err)
}
}
Loading
Loading