From eb301ce130cf9fce0fbd4b7442290ffe23e5954c Mon Sep 17 00:00:00 2001 From: Anton Date: Sun, 26 Jul 2026 18:10:47 +0200 Subject: [PATCH 01/12] feat: make Cate Agent orchestrate coding agents --- .../cate-orchestrator/index.test.ts | 69 +++++ .../extensions/cate-orchestrator/index.ts | 149 +++++++++ .../extensions/cate-orchestrator/package.json | 8 + src/cateAgent/main/codingManager.test.ts | 3 +- src/cateAgent/main/codingManager.ts | 13 +- src/cateAgent/main/installOrchestrator.ts | 34 +++ .../renderer/ChatCodingAgentCard.tsx | 81 +++++ src/cateAgent/renderer/ChatMessageRow.tsx | 4 + src/main/extensions/cateApiEndpointManager.ts | 8 +- src/main/extensions/cateApiHandlers.test.ts | 56 +++- src/main/extensions/cateApiHandlers.ts | 44 ++- src/main/extensions/cateApiReverse.ts | 9 +- src/main/extensions/workspaceCateApi.test.ts | 28 +- src/main/extensions/workspaceCateApi.ts | 56 ++++ src/main/ipc/terminal.ts | 6 + src/main/runtime/types.ts | 4 + .../hooks/useCateHostActionResponder.ts | 8 + src/renderer/lib/agent/codingAgentDriver.ts | 287 ++++++++++++++++++ .../lib/terminal/terminalLifecycle.ts | 15 + .../sessionSerialize.roundTrip.test.ts | 30 ++ .../lib/workspace/sessionSerialize.ts | 10 +- src/renderer/panels/TerminalPanel.tsx | 4 +- .../panels/keepMountedPanels.test.tsx | 26 ++ src/renderer/panels/keepMountedPanels.ts | 7 +- src/renderer/panels/registry.ts | 5 +- src/renderer/panels/types.ts | 2 + src/renderer/stores/appStore/panelSlice.ts | 28 +- src/renderer/stores/appStore/types.ts | 12 +- src/renderer/stores/useWorktreeActions.ts | 56 ++-- .../capabilities/process.agentHooks.test.ts | 30 ++ src/runtime/capabilities/process.ts | 11 +- src/shared/codingAgentRuns.test.ts | 29 ++ src/shared/codingAgentRuns.ts | 77 +++++ src/shared/electron-api.d.ts | 3 + src/shared/types.ts | 12 + 35 files changed, 1174 insertions(+), 50 deletions(-) create mode 100644 src/cateAgent/extensions/cate-orchestrator/index.test.ts create mode 100644 src/cateAgent/extensions/cate-orchestrator/index.ts create mode 100644 src/cateAgent/extensions/cate-orchestrator/package.json create mode 100644 src/cateAgent/main/installOrchestrator.ts create mode 100644 src/cateAgent/renderer/ChatCodingAgentCard.tsx create mode 100644 src/renderer/lib/agent/codingAgentDriver.ts create mode 100644 src/shared/codingAgentRuns.test.ts create mode 100644 src/shared/codingAgentRuns.ts diff --git a/src/cateAgent/extensions/cate-orchestrator/index.test.ts b/src/cateAgent/extensions/cate-orchestrator/index.test.ts new file mode 100644 index 00000000..6f43e2c7 --- /dev/null +++ b/src/cateAgent/extensions/cate-orchestrator/index.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import registerOrchestrator from "./index" + +function registeredTools() { + const tools = new Map() + registerOrchestrator({ + registerTool: (tool: any) => tools.set(tool.name, tool), + } as any) + return tools +} + +beforeEach(() => { + process.env.CATE_API = "http://127.0.0.1:1234" + process.env.CATE_TOKEN = "supervisor-token" + vi.unstubAllGlobals() +}) + +describe("cate-orchestrator", () => { + it("registers the complete worker lifecycle surface", () => { + expect([...registeredTools().keys()]).toEqual([ + "create_coding_agent", + "send_to_coding_agent", + "wait_for_coding_agents", + "inspect_coding_agent", + "stop_coding_agent", + ]) + }) + + it("asks once per session before creating workers and invokes the scoped API", async () => { + const fetch = vi.fn(async (_url: string, init: RequestInit) => ({ + ok: true, + status: 200, + json: async () => ({ result: { id: "run-1", panelId: "panel-1" } }), + init, + })) + vi.stubGlobal("fetch", fetch) + const confirm = vi.fn(async () => true) + const tool = registeredTools().get("create_coding_agent") + const ctx = { ui: { confirm } } + + await tool.execute("call-1", { agentId: "codex", prompt: "Implement it" }, undefined, undefined, ctx) + await tool.execute("call-2", { agentId: "codex", prompt: "Test it" }, undefined, undefined, ctx) + + expect(confirm).toHaveBeenCalledTimes(1) + expect(fetch).toHaveBeenCalledTimes(2) + const [, init] = fetch.mock.calls[0] + expect(init.headers).toMatchObject({ Authorization: "Bearer supervisor-token" }) + expect(JSON.parse(String(init.body))).toEqual({ + method: "cate.codingAgent.create", + args: { agentId: "codex", prompt: "Implement it" }, + }) + }) + + it("remembers a denied mission and never reaches the API", async () => { + const fetch = vi.fn() + vi.stubGlobal("fetch", fetch) + const confirm = vi.fn(async () => false) + const tool = registeredTools().get("create_coding_agent") + const ctx = { ui: { confirm } } + + const first = await tool.execute("call-1", { agentId: "codex", prompt: "No" }, undefined, undefined, ctx) + const second = await tool.execute("call-2", { agentId: "codex", prompt: "Still no" }, undefined, undefined, ctx) + + expect(first.details).toEqual({ approved: false }) + expect(second.details).toEqual({ approved: false }) + expect(confirm).toHaveBeenCalledTimes(1) + expect(fetch).not.toHaveBeenCalled() + }) +}) diff --git a/src/cateAgent/extensions/cate-orchestrator/index.ts b/src/cateAgent/extensions/cate-orchestrator/index.ts new file mode 100644 index 00000000..1ab0c1f2 --- /dev/null +++ b/src/cateAgent/extensions/cate-orchestrator/index.ts @@ -0,0 +1,149 @@ +// Native coding-agent orchestration tools for Cate Agent. These call a +// dedicated, panel-bound CATE_API endpoint. Ordinary terminals and the workers +// created by these tools receive a different token without this capability. + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" +import { Type } from "typebox" + +const AgentId = Type.Union([ + Type.Literal("claude-code"), + Type.Literal("codex"), + Type.Literal("cursor"), + Type.Literal("grok"), + Type.Literal("opencode"), + Type.Literal("pi"), +]) + +async function invoke( + method: string, + args: Record, + signal?: AbortSignal, +): Promise { + const api = process.env.CATE_API + const token = process.env.CATE_TOKEN + if (!api || !token) throw new Error("Cate orchestration is unavailable in this session") + const response = await fetch(api, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ method, args }), + signal, + }) + const body = await response.json() as { result?: unknown; error?: string } + if (!response.ok) throw new Error(body.error || `Cate API failed (${response.status})`) + const result = body.result + if (result && typeof result === "object" && "error" in result) { + throw new Error(String((result as { error: unknown }).error)) + } + return result +} + +function toolResult(result: unknown) { + return { + content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], + details: result, + } +} + +export default function (pi: ExtensionAPI) { + // Approval is scoped to this live Cate Agent session. A restart or a new chat + // asks again, keeping autonomous spend visible without interrupting every + // individual worker in an approved mission. + let delegationApproved: boolean | null = null + + pi.registerTool({ + name: "create_coding_agent", + label: "Create coding agent", + description: + "Create a visible coding-agent terminal, bind it to a registered worktree, and give it an initial implementation task. Use this for bounded parallel work whose result you will inspect and integrate. Returns a runId and panelId.", + promptSnippet: + "create_coding_agent - start a visible Codex, Claude Code, Cursor, Grok, OpenCode, or Pi worker in a Cate worktree with an initial task.", + promptGuidelines: [ + "Delegate bounded implementation or investigation tasks when parallel work materially helps; keep architectural ownership and final verification yourself.", + "Give each worker a self-contained prompt with scope, constraints, and concrete success criteria. Never ask a worker to create more workers.", + "After delegation, use wait_for_coding_agents and inspect_coding_agent. Send targeted follow-ups when needed, then verify and integrate the results yourself.", + "Never create more than five live workers. Reuse a run with send_to_coding_agent when follow-up belongs to the same task.", + "OpenCode runs are one-shot; create a fresh OpenCode run instead of sending a follow-up after it exits.", + ], + parameters: Type.Object({ + agentId: AgentId, + prompt: Type.String({ minLength: 1, description: "Self-contained task, constraints, and success criteria." }), + worktreeId: Type.Optional( + Type.String({ description: "Registered Cate worktree id. Omit to inherit this Cate Agent panel's worktree or use the primary checkout." }), + ), + newWorktree: Type.Optional( + Type.String({ description: "Create a new isolated branch/worktree with this name before launching. Mutually exclusive with worktreeId." }), + ), + baseRef: Type.Optional( + Type.String({ description: "Optional git base ref for newWorktree. Omit to use the repository default." }), + ), + }), + async execute(_id, params, signal, _onUpdate, ctx) { + if (delegationApproved === null) { + delegationApproved = await ctx.ui.confirm( + "Start coding agents?", + "Cate wants to create visible coding-agent terminals for this mission. They can edit the selected checkouts and use your configured agent subscriptions. Allow up to five concurrent workers?", + ) + } + if (!delegationApproved) { + return { + content: [{ type: "text" as const, text: "The user did not approve coding-agent delegation for this mission." }], + details: { approved: false }, + } + } + return toolResult(await invoke("cate.codingAgent.create", params, signal)) + }, + }) + + pi.registerTool({ + name: "send_to_coding_agent", + label: "Prompt coding agent", + description: + "Send a follow-up prompt to a live Cate-owned coding agent. The prompt is pasted atomically and submitted to that worker's terminal.", + parameters: Type.Object({ + runId: Type.String(), + prompt: Type.String({ minLength: 1 }), + }), + async execute(_id, params, signal) { + return toolResult(await invoke("cate.codingAgent.send", params, signal)) + }, + }) + + pi.registerTool({ + name: "wait_for_coding_agents", + label: "Wait for coding agents", + description: + "Wait briefly for one or more Cate-owned coding agents to change state, then return fresh snapshots. Use in a loop when workers are still working.", + parameters: Type.Object({ + runIds: Type.Optional(Type.Array(Type.String(), { minItems: 1, maxItems: 5 })), + timeoutSeconds: Type.Optional(Type.Number({ minimum: 0, maximum: 8, default: 4 })), + }), + async execute(_id, params, signal) { + return toolResult(await invoke("cate.codingAgent.wait", params, signal)) + }, + }) + + pi.registerTool({ + name: "inspect_coding_agent", + label: "Inspect coding agent", + description: + "Inspect a Cate-owned coding agent's live state and recent visible terminal output without focusing or moving the user's canvas.", + parameters: Type.Object({ runId: Type.String() }), + async execute(_id, params, signal) { + return toolResult(await invoke("cate.codingAgent.inspect", params, signal)) + }, + }) + + pi.registerTool({ + name: "stop_coding_agent", + label: "Stop coding agent", + description: + "Stop a Cate-owned coding agent process. Use for obsolete, stuck, or explicitly cancelled work.", + parameters: Type.Object({ runId: Type.String() }), + async execute(_id, params, signal) { + return toolResult(await invoke("cate.codingAgent.stop", params, signal)) + }, + }) +} diff --git a/src/cateAgent/extensions/cate-orchestrator/package.json b/src/cateAgent/extensions/cate-orchestrator/package.json new file mode 100644 index 00000000..05eb83f4 --- /dev/null +++ b/src/cateAgent/extensions/cate-orchestrator/package.json @@ -0,0 +1,8 @@ +{ + "name": "cate-orchestrator", + "description": "Native tools for Cate Agent to create, prompt, inspect, wait for, and stop visible coding agents.", + "private": true, + "pi": { + "extensions": ["./index.ts"] + } +} diff --git a/src/cateAgent/main/codingManager.test.ts b/src/cateAgent/main/codingManager.test.ts index 946196ea..f3e351e5 100644 --- a/src/cateAgent/main/codingManager.test.ts +++ b/src/cateAgent/main/codingManager.test.ts @@ -18,6 +18,7 @@ vi.mock('./piRpcClient', () => ({ PiRpcClient: vi.fn() })) vi.mock('./installPlanMode', () => ({ installPlanModeExtension: vi.fn() })) vi.mock('./installCanvasMode', () => ({ installCanvasModeExtension: vi.fn() })) vi.mock('./installAskUser', () => ({ installAskUserExtension: vi.fn() })) +vi.mock('./installOrchestrator', () => ({ installOrchestratorExtension: vi.fn() })) vi.mock('./installMcpAdapter', () => ({ installMcpAdapter: vi.fn() })) vi.mock('./codingDir', () => ({ hostCodingDir: vi.fn(() => '/agent'), @@ -31,7 +32,7 @@ vi.mock('./codingDir', () => ({ vi.mock('./customModels', () => ({ mirrorModelsToWorkspace: vi.fn() })) vi.mock('../../skills/main/skillsMirror', () => ({ syncWorkspaceSkills: vi.fn() })) vi.mock('../../main/extensions/workspaceCateApi', () => ({ - workspaceCateApi: { ensureEndpoint: vi.fn().mockResolvedValue(null) }, + workspaceCateApi: { ensureCateAgentEndpoint: vi.fn().mockResolvedValue(null) }, })) vi.mock('../../main/workspaceStateStore', () => ({ isProjectTrusted: vi.fn(() => false) })) diff --git a/src/cateAgent/main/codingManager.ts b/src/cateAgent/main/codingManager.ts index d707cb1e..b6759e3e 100644 --- a/src/cateAgent/main/codingManager.ts +++ b/src/cateAgent/main/codingManager.ts @@ -38,6 +38,7 @@ import { broadcastToAll } from '../../main/windowRegistry' import { installPlanModeExtension } from './installPlanMode' import { installCanvasModeExtension } from './installCanvasMode' import { installAskUserExtension } from './installAskUser' +import { installOrchestratorExtension } from './installOrchestrator' import { installMcpAdapter } from './installMcpAdapter' import { isProjectTrusted } from '../../main/workspaceStateStore' import { syncWorkspaceSkills } from '../../skills/main/skillsMirror' @@ -52,6 +53,7 @@ import { agentMessageText, lastAssistantMessage } from '../../shared/agentMessag interface AgentSession { panelId: string + workspaceId: string /** The runtime hosting this session (local or remote). */ runtime: Runtime /** Runtime-absolute workspace path (the locator's path part). */ @@ -177,6 +179,7 @@ export class CodingManager { await installPlanModeExtension(runtime, cwd) await installCanvasModeExtension(runtime, cwd) await installAskUserExtension(runtime, cwd) + await installOrchestratorExtension(runtime, cwd) // Register pi-mcp-adapter in /.cate/cate-agent/settings.json so pi // auto-installs + loads it on session start (MCP driven by /.pi/mcp.json). // @@ -196,10 +199,10 @@ export class CodingManager { PI_CODING_AGENT_DIR: hostCodingDir(runtimeId, cwd), } - // First-party CATE_API endpoint: give pi CATE_API/CATE_TOKEN so a `cate` - // CLI run from a tool can reach the dispatch core. Null when the CLI - // setting is disabled (the gate) — then nothing is injected (fail closed). - const cateApi = await workspaceCateApi.ensureEndpoint(opts.workspaceId) + // The embedded supervisor gets a panel-bound endpoint with orchestration + // scope. Worker terminals receive the ordinary first-party endpoint and + // therefore cannot recursively spawn agents. + const cateApi = await workspaceCateApi.ensureCateAgentEndpoint(opts.workspaceId, opts.panelId, cwd) if (cateApi) { env.CATE_API = `http://127.0.0.1:${cateApi.port}` env.CATE_TOKEN = cateApi.token @@ -255,6 +258,7 @@ export class CodingManager { this.sessions.set(opts.panelId, { panelId: opts.panelId, + workspaceId: opts.workspaceId, runtime, cwd, client, @@ -512,6 +516,7 @@ export class CodingManager { try { session.client.rejectAllPending('Pi session disposed') } catch { /* noop */ } try { await session.client.stop() } catch { /* noop */ } this.sessions.delete(panelId) + workspaceCateApi.disposeCateAgentEndpoint(session.workspaceId, panelId) // Drop any extension handle that routed to this session (e.g. the owning // window went away) so a stale handle can't outlive its client. for (const [handle, ext] of this.extSessions) { diff --git a/src/cateAgent/main/installOrchestrator.ts b/src/cateAgent/main/installOrchestrator.ts new file mode 100644 index 00000000..187c4bf6 --- /dev/null +++ b/src/cateAgent/main/installOrchestrator.ts @@ -0,0 +1,34 @@ +import path from 'path' +import { app } from 'electron' +import log from '../../main/logger' +import { hostCodingDir, hostJoin } from './codingDir' +import { copyFileToHost, createIdempotencyTracker, findSourceDir } from './extensionInstall' +import type { Runtime } from '../../main/runtime/types' + +function sourceDir(): string | null { + return findSourceDir([ + path.join(app.getAppPath(), 'src', 'cateAgent', 'extensions', 'cate-orchestrator'), + path.join(process.resourcesPath ?? '', 'cate-extensions', 'cate-orchestrator'), + ]) +} + +const installed = createIdempotencyTracker() + +export async function installOrchestratorExtension(runtime: Runtime, cwd: string): Promise { + const home = hostCodingDir(runtime.id, cwd) + const key = runtime.id + '\0' + home + if (!installed.shouldInstall(key)) return + installed.markInstalled(key) + try { + const src = sourceDir() + if (!src) { + log.warn('[installOrchestrator] source dir not found — orchestration tools not installed') + return + } + const destDir = hostJoin(runtime.id, home, 'extensions', 'cate-orchestrator') + await copyFileToHost(runtime, path.join(src, 'index.ts'), destDir, 'index.ts', 'if-changed', '[installOrchestrator]') + await copyFileToHost(runtime, path.join(src, 'package.json'), destDir, 'package.json', 'if-changed', '[installOrchestrator]') + } catch (err) { + log.warn('[installOrchestrator] install failed: %O', err) + } +} diff --git a/src/cateAgent/renderer/ChatCodingAgentCard.tsx b/src/cateAgent/renderer/ChatCodingAgentCard.tsx new file mode 100644 index 00000000..d4fec620 --- /dev/null +++ b/src/cateAgent/renderer/ChatCodingAgentCard.tsx @@ -0,0 +1,81 @@ +import { useMemo, useState } from 'react' +import { ArrowSquareOut, Robot } from '@phosphor-icons/react' +import type { ToolMessage } from './codingStore' +import { useAppStore } from '../../renderer/stores/appStore' +import { revealPanel } from '../../renderer/lib/workspace/panelReveal' +import { useAgentTerminalStatus, agentStateLabel } from './useAgentTerminalStatus' +import { codingAgentDisplayName, parseCodingAgentId } from '../../shared/codingAgentRuns' + +function resultObject(result: string | undefined): Record { + if (!result) return {} + try { + const parsed = JSON.parse(result) + return parsed && typeof parsed === 'object' ? parsed as Record : {} + } catch { + return {} + } +} + +/** Native card for a worker created by Cate Agent. The terminal remains a real, + * visible canvas panel; this is its live mission-level summary and jump target. */ +export function CodingAgentCard({ msg, shimmer }: { msg: ToolMessage; shimmer?: boolean }) { + const result = useMemo(() => resultObject(msg.result), [msg.result]) + const args = (msg.args ?? {}) as Record + const panelId = typeof result.panelId === 'string' ? result.panelId : '' + const agentId = parseCodingAgentId(result.agentId ?? args.agentId) + const prompt = typeof args.prompt === 'string' ? args.prompt : 'Coding task' + const workspaceId = useAppStore((state) => + state.workspaces.find((ws) => panelId && ws.panels[panelId])?.id ?? '', + ) + const terminalStatus = useAgentTerminalStatus(workspaceId, panelId) + const [expanded, setExpanded] = useState(false) + const running = msg.status === 'running' || msg.status === 'pending' + const label = agentId ? codingAgentDisplayName(agentId) : 'Coding agent' + const status = panelId + ? agentStateLabel(terminalStatus.agentState) + : running ? 'Starting…' : msg.error ? 'Failed' : 'Created' + + return ( +
+
+ + + {panelId && workspaceId && ( + + )} +
+ {expanded && ( +
+
{prompt}
+ {terminalStatus.line && ( +
+ {terminalStatus.line} +
+ )} + {msg.error &&
{msg.error}
} +
+ )} +
+ ) +} diff --git a/src/cateAgent/renderer/ChatMessageRow.tsx b/src/cateAgent/renderer/ChatMessageRow.tsx index eb648ec6..310cee59 100644 --- a/src/cateAgent/renderer/ChatMessageRow.tsx +++ b/src/cateAgent/renderer/ChatMessageRow.tsx @@ -16,6 +16,7 @@ import { Markdown, CursorBlink } from './ChatMarkdown' import { ToolCard, AskUserToolView } from './ChatToolCard' import { SubagentCard } from './ChatSubagentCard' import { PlanReadyCard } from './ChatPlanCard' +import { CodingAgentCard } from './ChatCodingAgentCard' import { formatTime } from './chatShared' interface MessageRowProps { @@ -125,6 +126,9 @@ export const MessageRow = memo(function MessageRow({ /> ) } + if (msg.type === 'tool' && msg.name === 'create_coding_agent') { + return + } return }) diff --git a/src/main/extensions/cateApiEndpointManager.ts b/src/main/extensions/cateApiEndpointManager.ts index afdf46bb..b071d689 100644 --- a/src/main/extensions/cateApiEndpointManager.ts +++ b/src/main/extensions/cateApiEndpointManager.ts @@ -7,7 +7,7 @@ import type { Runtime } from '../runtime/types' import { getWorkspaceInfo } from '../workspaceManager' import type { ReverseTunnelBinding } from './cateApiReverse' -type CateApiEndpointOwner = 'extension' | 'first-party' +type CateApiEndpointOwner = 'extension' | 'first-party' | 'cate-agent' interface CateApiEndpoint { runtime: Runtime @@ -28,7 +28,9 @@ interface CateApiEndpointOptions { extensionId: string workspaceId: string listenerId: string - caller?: 'first-party' + caller?: 'first-party' | 'cate-agent' + panelId?: string + originCwd?: string grantedScopes?: string[] } @@ -73,6 +75,8 @@ export class CateApiEndpointManager { token, runtime, caller: options.caller, + panelId: options.panelId, + originCwd: options.originCwd, grantedScopes: options.grantedScopes, }) let binding: ReverseTunnelBinding diff --git a/src/main/extensions/cateApiHandlers.test.ts b/src/main/extensions/cateApiHandlers.test.ts index d00b2e56..35781f50 100644 --- a/src/main/extensions/cateApiHandlers.test.ts +++ b/src/main/extensions/cateApiHandlers.test.ts @@ -147,7 +147,7 @@ import { cliPermissionDenied, cliPermissionForMethod, } from '../../shared/cliPermissions' -import { GRANTED_SCOPES } from './workspaceCateApi' +import { CATE_AGENT_GRANTED_SCOPES, GRANTED_SCOPES } from './workspaceCateApi' const EXT = 'cate.kitchensink' const WS = 'ws-1' @@ -192,7 +192,7 @@ beforeEach(() => { describe('dispatchCateInvoke — Kitchen Sink reverse API', () => { it('reports the API version for feature detection', async () => { - expect(await dispatchCateInvoke(scope(), 'cate.version', undefined)).toBe(5) + expect(await dispatchCateInvoke(scope(), 'cate.version', undefined)).toBe(6) }) it('resolves the workspace root from the locator', async () => { @@ -328,7 +328,7 @@ describe('dispatchCateInvoke — Kitchen Sink reverse API', () => { // panel.* stay allowed (feature detection + panel self-control). state.scopes = undefined const forward = vi.fn() - expect(await dispatchCateInvoke(scope(forward), 'cate.version', undefined)).toBe(5) + expect(await dispatchCateInvoke(scope(forward), 'cate.version', undefined)).toBe(6) expect(await dispatchCateInvoke(scope(forward), 'cate.storage.get', { key: 'k' })).toEqual({ error: 'scope-denied', method: 'cate.storage.get' }) expect(await dispatchCateInvoke(scope(forward), 'cate.editor.openFile', { path: 'x' })).toEqual({ error: 'scope-denied', method: 'cate.editor.openFile' }) expect(await dispatchCateInvoke(scope(forward), 'cate.theme.get', undefined)).toEqual({ error: 'scope-denied', method: 'cate.theme.get' }) @@ -352,6 +352,56 @@ describe('dispatchCateInvoke — Kitchen Sink reverse API', () => { }) }) +describe('dispatchCateInvoke — Cate Agent orchestration boundary', () => { + it('maps every native coding-agent method to its dedicated scope', () => { + for (const verb of ['create', 'send', 'wait', 'inspect', 'stop']) { + expect(requiredScopeFor(`cate.codingAgent.${verb}`)).toBe('coding-agent') + } + }) + + it('forwards orchestration only for the embedded Cate Agent and carries its cwd', async () => { + const forward = vi.fn(async () => ({ id: 'run-1' })) + const result = await dispatchCateInvoke({ + extensionId: 'cate-agent', + workspaceId: WS, + panelId: 'cate-direct:chat-1', + caller: 'cate-agent', + originCwd: '/ws/worktrees/feature', + grantedScopes: [...CATE_AGENT_GRANTED_SCOPES], + forward, + }, 'cate.codingAgent.create', { agentId: 'codex', prompt: 'Implement it' }) + + expect(result).toEqual({ id: 'run-1' }) + expect(forward).toHaveBeenCalledWith(expect.objectContaining({ + method: 'cate.codingAgent.create', + panelId: 'cate-direct:chat-1', + args: { + agentId: 'codex', + prompt: 'Implement it', + _cateOriginCwd: '/ws/worktrees/feature', + }, + })) + }) + + it('denies ordinary first-party terminals and extensions even with the scope', async () => { + const forward = vi.fn() + for (const caller of ['first-party', 'extension'] as const) { + expect(await dispatchCateInvoke({ + extensionId: caller === 'extension' ? EXT : 'terminal', + workspaceId: WS, + panelId: undefined, + caller, + grantedScopes: ['coding-agent'], + forward, + }, 'cate.codingAgent.create', { agentId: 'codex', prompt: 'No' })).toEqual({ + error: 'cate-agent-only', + method: 'cate.codingAgent.create', + }) + } + expect(forward).not.toHaveBeenCalled() + }) +}) + describe('dispatchCateInvoke — cate.agent.* (open/send/dispose; run is gone)', () => { const fakeWin = { isDestroyed: () => false, webContents: {} } // Consent is granted once per extension for the app session, so each test diff --git a/src/main/extensions/cateApiHandlers.ts b/src/main/extensions/cateApiHandlers.ts index f4c253ab..f8d66167 100644 --- a/src/main/extensions/cateApiHandlers.ts +++ b/src/main/extensions/cateApiHandlers.ts @@ -88,7 +88,7 @@ import type { PanelType } from '../../shared/types' * editor.active (cate.panel.list is the single PANEL enumeration surface — * browser panels carry `url`, the focused entry answers "what is the user * looking at") and agent.run (compose open -> send -> dispose). */ -const CATE_API_VERSION = 5 +const CATE_API_VERSION = 6 const FORWARD_TIMEOUT_MS = 10_000 @@ -122,11 +122,13 @@ export interface InvokeScope { /** Who is calling. First-party (terminal/agent via the CLI/reverse endpoint) * callers are trusted: they skip the extension-enabled gate and the browser * consent prompt. Undefined is treated as 'extension'. */ - caller?: 'extension' | 'first-party' + caller?: 'extension' | 'first-party' | 'cate-agent' /** Scopes the caller was granted. For first-party callers this is supplied by * the env-manager instead of a manifest; when absent the extension manifest's * `cateApi` is used. */ grantedScopes?: string[] + /** Runtime-absolute cwd of an embedded supervisor session. */ + originCwd?: string } // --------------------------------------------------------------------------- @@ -263,6 +265,12 @@ export function requiredScopeFor(method: string): string | null | undefined { return 'theme' case 'cate.ui.notify': return 'ui' + case 'cate.codingAgent.create': + case 'cate.codingAgent.send': + case 'cate.codingAgent.wait': + case 'cate.codingAgent.inspect': + case 'cate.codingAgent.stop': + return 'coding-agent' case 'cate.editor.openFile': return 'editor.write' // Unlike the self-identity panel.* methods below, these read or steer OTHER @@ -413,16 +421,24 @@ export async function dispatchCateInvoke( args: unknown, ): Promise { const { extensionId, workspaceId, panelId } = scope + const trustedCaller = scope.caller === 'first-party' || scope.caller === 'cate-agent' // Security: only enabled, known extensions may call the host. First-party - // (terminal/agent) callers are trusted and skip this gate. - if (scope.caller !== 'first-party' && (!extensionManager.isKnown(extensionId) || !extensionManager.isEnabled(extensionId))) { + // terminals and the embedded Cate Agent are trusted and skip this gate. + if (!trustedCaller && (!extensionManager.isKnown(extensionId) || !extensionManager.isEnabled(extensionId))) { return { error: 'not-enabled', method } } + // Coding-agent orchestration is the embedded supervisor's privileged surface. + // Ordinary terminals, extension webviews, and spawned workers never receive + // its token, even if they self-declare a matching scope. + if (method.startsWith('cate.codingAgent.') && scope.caller !== 'cate-agent') { + return { error: 'cate-agent-only', method } + } + // The terminal/agent endpoint must never move the user's window, panel focus, // or canvas camera. Extensions retain panel.focus behind their panel scope. - if (scope.caller === 'first-party' && method === 'cate.panel.focus') { + if (trustedCaller && method === 'cate.panel.focus') { return unsupported(method) } @@ -457,6 +473,20 @@ export async function dispatchCateInvoke( } } + if (method.startsWith('cate.codingAgent.')) { + const routedArgs = { + ...((args ?? {}) as Record), + _cateOriginCwd: scope.originCwd, + } + return scope.forward({ + extensionId, + workspaceId, + panelId: panelId ?? '', + method, + args: routedArgs, + }) + } + // Storage (handled in main, backed by storage.ts). Routed by prefix — mirrors // requiredScopeFor's storage.* branch — so dispatchStorage's switch is the sole // enumeration of the six storage methods. @@ -474,7 +504,7 @@ export async function dispatchCateInvoke( // Two flavors of the same gate: extensions get a one-time-per-session // consent prompt (mirroring agent); the first-party CLI was already checked // against its Browser read/control cells above, so it needs no prompt. - if (scope.caller !== 'first-party' && !(await ensureConsent(extensionId, 'browser'))) { + if (!trustedCaller && !(await ensureConsent(extensionId, 'browser'))) { return { error: 'consent-denied', method } } const result = await forwardToOwner(target.wc, { extensionId, workspaceId, panelId: panelId ?? '', method, args }) @@ -504,7 +534,7 @@ export async function dispatchCateInvoke( // self-declared, and the terminal consent story (prompt vs toggle) is // deferred until a real extension consumer exists. Revisit alongside // ConsentCapability if one appears. - if (scope.caller !== 'first-party') { + if (!trustedCaller) { return { error: 'terminal-first-party-only', method } } const a = (args ?? {}) as { panelId?: string } diff --git a/src/main/extensions/cateApiReverse.ts b/src/main/extensions/cateApiReverse.ts index c888fe44..cbc385f3 100644 --- a/src/main/extensions/cateApiReverse.ts +++ b/src/main/extensions/cateApiReverse.ts @@ -31,7 +31,11 @@ export interface ReverseSession { /** First-party (terminal/agent) callers skip the extension-enabled gate and * browser consent prompt. Absent for extension-server sessions (the default). * `extensionId` may be a sentinel string for first-party sessions. */ - caller?: 'first-party' + caller?: 'first-party' | 'cate-agent' + /** Owning Cate Agent session/panel for native worktree affinity. */ + panelId?: string + /** Runtime-absolute cwd of the embedded supervisor session. */ + originCwd?: string /** Scopes granted to a first-party caller (used instead of a manifest's * `cateApi`). Absent for extension-server sessions. */ grantedScopes?: string[] @@ -100,7 +104,7 @@ export function createCateApiReverse(session: ReverseSession): CateApiReverseEnd workspaceId: session.workspaceId, // No owning panel/sender on the server side: panel-scoped storage and // forwarded methods target the workspace best-effort. - panelId: undefined, + panelId: session.panelId, // State-mutating methods (editor.openFile / canvas.createPanel / // panel.setTitle) need a renderer. The server has no sender, so we // forward to the active main window (best-effort — there's no @@ -110,6 +114,7 @@ export function createCateApiReverse(session: ReverseSession): CateApiReverseEnd // gate + manifest scopes); set for first-party terminal/agent callers. caller: session.caller, grantedScopes: session.grantedScopes, + originCwd: session.originCwd, }, method, parsed.args, diff --git a/src/main/extensions/workspaceCateApi.test.ts b/src/main/extensions/workspaceCateApi.test.ts index c4b498d3..f90c52de 100644 --- a/src/main/extensions/workspaceCateApi.test.ts +++ b/src/main/extensions/workspaceCateApi.test.ts @@ -42,7 +42,11 @@ vi.mock('./cateApiReverse', async (importActual) => ({ })) vi.mock('../logger', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } })) -import { WorkspaceCateApiManager, GRANTED_SCOPES } from './workspaceCateApi' +import { + WorkspaceCateApiManager, + CATE_AGENT_GRANTED_SCOPES, + GRANTED_SCOPES, +} from './workspaceCateApi' beforeEach(() => { settingsState.cliEnabled = true @@ -65,6 +69,11 @@ describe('GRANTED_SCOPES contract', () => { // workspace root, so the CLI has no verbs (and thus no grants) for them. expect([...GRANTED_SCOPES]).toEqual(['browser', 'ui', 'editor', 'canvas', 'panel', 'terminal']) }) + + it('reserves coding-agent orchestration for the embedded supervisor', () => { + expect(GRANTED_SCOPES).not.toContain('coding-agent') + expect(CATE_AGENT_GRANTED_SCOPES).toContain('coding-agent') + }) }) describe('WorkspaceCateApiManager.ensureEndpoint', () => { @@ -178,6 +187,23 @@ describe('WorkspaceCateApiManager.ensureEndpoint', () => { }) }) +describe('WorkspaceCateApiManager.ensureCateAgentEndpoint', () => { + it('mints a panel-bound endpoint even when the terminal CLI is disabled', async () => { + settingsState.cliEnabled = false + const mgr = new WorkspaceCateApiManager() + const endpoint = await mgr.ensureCateAgentEndpoint('ws1', 'chat-1', '/ws/worktree') + + expect(endpoint).toEqual({ port: 54321, token: expect.any(String) }) + expect(reverseCalls).toHaveLength(1) + expect(reverseCalls[0]).toMatchObject({ + caller: 'cate-agent', + panelId: 'chat-1', + originCwd: '/ws/worktree', + grantedScopes: expect.arrayContaining(['coding-agent']), + }) + }) +}) + describe('WorkspaceCateApiManager tunnel connection callbacks', () => { // Pull the (onConnection, onData, onClose) trio the manager wired into // runtime.tunnel.listen for the freshly-minted endpoint. diff --git a/src/main/extensions/workspaceCateApi.ts b/src/main/extensions/workspaceCateApi.ts index 778106f7..2f7f758c 100644 --- a/src/main/extensions/workspaceCateApi.ts +++ b/src/main/extensions/workspaceCateApi.ts @@ -8,6 +8,8 @@ import { CateApiEndpointManager, cateApiEndpointManager } from './cateApiEndpoin const FIRST_PARTY_ID = 'terminal' const endpointKey = (workspaceId: string): string => `first-party:${workspaceId}` +const cateAgentEndpointKey = (workspaceId: string, panelId: string): string => + `cate-agent:${workspaceId}:${panelId}` // Only scopes the CLI has verbs for. workspace.read/theme exist for extensions // (webviews with no filesystem) — a terminal's cwd IS the workspace root, so @@ -21,12 +23,19 @@ export const GRANTED_SCOPES: readonly string[] = [ 'terminal', ] +export const CATE_AGENT_GRANTED_SCOPES: readonly string[] = [ + ...GRANTED_SCOPES, + 'coding-agent', +] + export interface WorkspaceCateApiEndpoint { port: number token: string } export class WorkspaceCateApiManager { + private readonly cateAgentKeys = new Map>() + constructor(private readonly endpoints = new CateApiEndpointManager()) {} async ensureEndpoint(workspaceId: string): Promise { @@ -49,19 +58,66 @@ export class WorkspaceCateApiManager { } } + /** A token minted only for the embedded Cate Agent. Unlike the token injected + * into ordinary terminals, it may create and supervise coding agents. The + * endpoint is bound to the originating agent panel/session for worktree + * affinity and cannot be obtained by a spawned worker. */ + async ensureCateAgentEndpoint( + workspaceId: string, + panelId: string, + originCwd: string, + ): Promise { + try { + const endpoint = await this.endpoints.ensure({ + key: cateAgentEndpointKey(workspaceId, panelId), + owner: 'cate-agent', + extensionId: 'cate-agent', + workspaceId, + panelId, + originCwd, + listenerId: `cateapi-agent-${workspaceId}-${panelId}`, + caller: 'cate-agent', + grantedScopes: [...CATE_AGENT_GRANTED_SCOPES], + }) + const keys = this.cateAgentKeys.get(workspaceId) ?? new Set() + keys.add(cateAgentEndpointKey(workspaceId, panelId)) + this.cateAgentKeys.set(workspaceId, keys) + log.info('[workspace-cateapi] Cate Agent endpoint up ws=%s panel=%s port=%d', workspaceId, panelId, endpoint.port) + return { port: endpoint.port, token: endpoint.token } + } catch (err) { + log.warn('[workspace-cateapi] failed to open Cate Agent listener for %s: %O', workspaceId, err) + return null + } + } + + disposeCateAgentEndpoint(workspaceId: string, panelId: string): void { + const key = cateAgentEndpointKey(workspaceId, panelId) + this.endpoints.dispose(key) + const keys = this.cateAgentKeys.get(workspaceId) + keys?.delete(key) + if (keys?.size === 0) this.cateAgentKeys.delete(workspaceId) + } + /** Tear down a single workspace's first-party endpoint. The local runtime never * disconnects during app life, so without this every opened-then-closed * workspace would leak its loopback listener + http.Server for the session. */ disposeForWorkspace(workspaceId: string): void { this.endpoints.dispose(endpointKey(workspaceId)) + for (const key of this.cateAgentKeys.get(workspaceId) ?? []) { + this.endpoints.dispose(key) + } + this.cateAgentKeys.delete(workspaceId) } disposeForRuntime(runtimeId: string): void { this.endpoints.disposeForRuntime('first-party', runtimeId) + this.endpoints.disposeForRuntime('cate-agent', runtimeId) } disposeAll(): void { this.endpoints.disposeAll('first-party') + this.endpoints.disposeAll('cate-agent') + this.cateAgentKeys.clear() } } diff --git a/src/main/ipc/terminal.ts b/src/main/ipc/terminal.ts index 63aa1cfb..9d82c7e9 100644 --- a/src/main/ipc/terminal.ts +++ b/src/main/ipc/terminal.ts @@ -48,6 +48,7 @@ import { workspaceCateApi } from '../extensions/workspaceCateApi' import { getWorkspaceInfo } from '../workspaceManager' import { syncWorkspaceSkills } from '../../skills/main/skillsMirror' import { resolveWorktreeContext, validateWorktreeContext } from '../worktreeContext' +import { codingAgentCommand, type CodingAgentLaunch } from '../../shared/codingAgentRuns' // Set true during app shutdown so PTY data/exit callbacks no-op instead of // calling into a torn-down JS environment. @@ -238,6 +239,7 @@ async function spawnTerminal( workspaceId?: string panelId?: string placementGroupId?: string + codingAgentLaunch?: CodingAgentLaunch }, ownerWindowId: number, ): Promise { @@ -359,6 +361,9 @@ async function spawnTerminal( rows: options.rows, cwd, shell: options.shell, + ...(options.codingAgentLaunch + ? { command: codingAgentCommand(options.codingAgentLaunch) } + : {}), env: cateApiEnv, agentHooks: true, agentHookConfig, @@ -433,6 +438,7 @@ export function registerHandlers(): void { workspaceId?: string panelId?: string placementGroupId?: string + codingAgentLaunch?: CodingAgentLaunch }): Promise => { const win = windowFromEvent(event) const windowId = win?.id ?? -1 diff --git a/src/main/runtime/types.ts b/src/main/runtime/types.ts index c6bd7767..18bb94aa 100644 --- a/src/main/runtime/types.ts +++ b/src/main/runtime/types.ts @@ -29,6 +29,10 @@ export interface PtyCreateOptions { cwd: string /** Requested shell; the host resolves + falls back as today (resolveShell). */ shell?: string + /** Optional exact process launch. Only main-process trusted code may set + * this; renderer IPC accepts a closed CodingAgentLaunch instead and main + * resolves it through the canonical agent registry. */ + command?: { executable: string; args: string[] } /** Caller-provided id. Used over the wire so the client registers its data * stream before the create round-trip resolves (no early-output race). */ id?: string diff --git a/src/renderer/hooks/useCateHostActionResponder.ts b/src/renderer/hooks/useCateHostActionResponder.ts index e788cd7b..8edae9b7 100644 --- a/src/renderer/hooks/useCateHostActionResponder.ts +++ b/src/renderer/hooks/useCateHostActionResponder.ts @@ -25,6 +25,7 @@ import { toAbsolutePath, pathKey } from '../../shared/pathUtils' import { parseLocator, formatLocator } from '../../shared/runtimeLocator' import { handleBrowserMethod } from '../lib/browser/browserDriver' import { handleTerminalMethod } from '../lib/terminal/terminalDriver' +import { handleCodingAgentMethod } from '../lib/agent/codingAgentDriver' import { browserPanelUrl, isStartPageUrl, type PanelType, type Point } from '../../shared/types' import type { PanelPlacement } from '../stores/appStore' @@ -139,6 +140,13 @@ export function useCateHostActionResponder(): void { : reply(false, { error: outcome.error }) } + if (method.startsWith('cate.codingAgent.')) { + const outcome = await handleCodingAgentMethod(workspaceId, method, args) + return outcome.ok + ? reply(true, { result: outcome.result }) + : reply(false, { error: outcome.error }) + } + switch (method) { case 'cate.editor.openFile': { const filePath = typeof args.path === 'string' ? args.path : undefined diff --git a/src/renderer/lib/agent/codingAgentDriver.ts b/src/renderer/lib/agent/codingAgentDriver.ts new file mode 100644 index 00000000..7f120561 --- /dev/null +++ b/src/renderer/lib/agent/codingAgentDriver.ts @@ -0,0 +1,287 @@ +import { useAppStore } from '../../stores/appStore' +import { useStatusStore } from '../../stores/statusStore' +import { terminalRegistry } from '../terminal/terminalRegistry' +import { placementForBackgroundPanel } from '../workspace/canvasAccess' +import { parseLocator, formatLocator } from '../../../shared/runtimeLocator' +import { pathKey } from '../../../shared/pathUtils' +import { createWorktreeForWorkspace } from '../../stores/useWorktreeActions' +import { + MAX_CONCURRENT_CODING_AGENTS, + codingAgentDisplayName, + parseCodingAgentId, + type CodingAgentRun, + type CodingAgentRunSnapshot, + type CodingAgentRunStatus, +} from '../../../shared/codingAgentRuns' + +export type CodingAgentOutcome = + | { ok: true; result: unknown } + | { ok: false; error: string } + +function workspace(workspaceId: string) { + return useAppStore.getState().workspaces.find((candidate) => candidate.id === workspaceId) +} + +function runPanel(workspaceId: string, runId: string) { + const ws = workspace(workspaceId) + return Object.values(ws?.panels ?? {}).find((panel) => panel.codingAgentRun?.id === runId) +} + +function terminalText(panelId: string, maxChars = 4_000): string { + const terminal = terminalRegistry.getEntry(panelId)?.terminal + if (!terminal) return '' + const buffer = terminal.buffer.active + const lines: string[] = [] + for (let index = 0; index < buffer.length; index++) { + lines.push(buffer.getLine(index)?.translateToString(true) ?? '') + } + while (lines.length > 0 && !lines[lines.length - 1]) lines.pop() + return lines.join('\n').slice(-maxChars) +} + +function runStatus(workspaceId: string, panelId: string, run: CodingAgentRun): CodingAgentRunStatus { + if (run.stoppedAt) return 'stopped' + if (run.endedAt) return run.exitCode === 0 ? 'ready' : 'failed' + const failure = terminalRegistry.getFailure(panelId) + if (failure) return 'failed' + const entry = terminalRegistry.getEntry(panelId) + if (!entry) return 'starting' + if (!entry.alive) return 'ready' + const runtime = entry.ptyId + ? useStatusStore.getState().workspaces[workspaceId]?.terminals[entry.ptyId] + : undefined + switch (runtime?.agentState) { + case 'running': return 'working' + case 'waitingForInput': return 'waiting' + case 'finished': return 'ready' + case 'notRunning': + default: + return runtime?.agentPresent || runtime?.agentName ? 'working' : 'starting' + } +} + +export function codingAgentSnapshot( + workspaceId: string, + runId: string, +): CodingAgentRunSnapshot | null { + const panel = runPanel(workspaceId, runId) + const run = panel?.codingAgentRun + if (!panel || !run) return null + const entry = terminalRegistry.getEntry(panel.id) + const output = terminalText(panel.id) + const lastLine = output.split('\n').reverse().find((line) => line.trim())?.trim() + return { + ...run, + status: runStatus(workspaceId, panel.id, run), + agentName: codingAgentDisplayName(run.agentId), + cwd: panel.cwd ?? workspace(workspaceId)?.rootPath ?? '', + alive: entry?.alive === true, + followUpSupported: run.agentId !== 'opencode', + ...(lastLine ? { statusLine: lastLine.slice(0, 200) } : {}), + } +} + +function allSnapshots(workspaceId: string): CodingAgentRunSnapshot[] { + const ws = workspace(workspaceId) + return Object.values(ws?.panels ?? {}) + .filter((panel) => panel.codingAgentRun) + .map((panel) => codingAgentSnapshot(workspaceId, panel.codingAgentRun!.id)) + .filter((snapshot): snapshot is CodingAgentRunSnapshot => snapshot !== null) + .sort((a, b) => a.createdAt - b.createdAt) +} + +function runtimeLocatorForPath(rootLocator: string, candidate: string): string { + const root = parseLocator(rootLocator) + const parsed = parseLocator(candidate) + // Worktree metadata may store a bare host path. Keep it on the workspace's + // runtime rather than accidentally treating it as a local path. + return formatLocator({ + runtimeId: parsed.runtimeId === 'local' ? root.runtimeId : parsed.runtimeId, + path: parsed.path, + }) +} + +function resolveTarget( + workspaceId: string, + args: Record, +): { cwd: string; worktreeId?: string } | { error: string } { + const ws = workspace(workspaceId) + if (!ws?.rootPath) return { error: 'workspace-not-found' } + const worktrees = ws.worktrees ?? [] + const requested = typeof args.worktreeId === 'string' ? args.worktreeId : undefined + if (requested) { + const worktree = worktrees.find((candidate) => candidate.id === requested) + if (!worktree) return { error: 'worktree-not-registered' } + return { cwd: runtimeLocatorForPath(ws.rootPath, worktree.path), worktreeId: worktree.id } + } + + const origin = typeof args._cateOriginCwd === 'string' ? args._cateOriginCwd : '' + const rootPath = parseLocator(ws.rootPath).path + if (origin && pathKey(origin) === pathKey(rootPath)) return { cwd: ws.rootPath } + const inherited = worktrees.find((candidate) => + pathKey(parseLocator(candidate.path).path) === pathKey(origin), + ) + if (inherited) { + return { cwd: runtimeLocatorForPath(ws.rootPath, inherited.path), worktreeId: inherited.id } + } + return { cwd: ws.rootPath } +} + +function findRequestedRuns( + workspaceId: string, + raw: unknown, +): CodingAgentRunSnapshot[] | { error: string } { + if (raw !== undefined && !Array.isArray(raw)) return { error: 'runIds-must-be-an-array' } + const ids = raw as unknown[] | undefined + if (!ids) return allSnapshots(workspaceId) + const snapshots: CodingAgentRunSnapshot[] = [] + for (const value of ids) { + if (typeof value !== 'string') return { error: 'invalid-run-id' } + const snapshot = codingAgentSnapshot(workspaceId, value) + if (!snapshot) return { error: 'coding-agent-not-found' } + snapshots.push(snapshot) + } + return snapshots +} + +export async function handleCodingAgentMethod( + workspaceId: string, + method: string, + args: Record, +): Promise { + const name = method.slice('cate.codingAgent.'.length) + + if (name === 'create') { + const agentId = parseCodingAgentId(args.agentId) + const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : '' + if (!agentId) return { ok: false, error: 'unsupported-agent' } + if (!prompt) return { ok: false, error: 'prompt-required' } + if (prompt.includes('\0')) return { ok: false, error: 'invalid-prompt' } + if (prompt.length > 50_000) return { ok: false, error: 'prompt-too-long' } + const active = allSnapshots(workspaceId).filter((run) => + run.status === 'starting' || run.status === 'working' || run.status === 'waiting', + ) + if (active.length >= MAX_CONCURRENT_CODING_AGENTS) { + return { ok: false, error: 'coding-agent-limit' } + } + if (args.worktreeId && args.newWorktree) { + return { ok: false, error: 'choose-worktreeId-or-newWorktree' } + } + if (typeof args.newWorktree === 'string' && args.newWorktree.trim()) { + const ws = workspace(workspaceId) + if (!ws?.rootPath) return { ok: false, error: 'workspace-not-found' } + try { + const created = await createWorktreeForWorkspace( + ws.rootPath, + workspaceId, + args.newWorktree, + typeof args.baseRef === 'string' ? args.baseRef : undefined, + ) + args = { ...args, worktreeId: created.id } + } catch (error) { + return { + ok: false, + error: error instanceof Error ? `worktree-create-failed: ${error.message}` : 'worktree-create-failed', + } + } + } + const target = resolveTarget(workspaceId, args) + if ('error' in target) return { ok: false, error: target.error } + + const runId = crypto.randomUUID() + const placementGroupId = target.worktreeId + ? `coding-agent:${target.worktreeId}` + : 'coding-agent:primary' + const panelId = useAppStore.getState().createTerminal( + workspaceId, + undefined, + undefined, + placementForBackgroundPanel(workspaceId, placementGroupId), + target.cwd, + { runId, agentId, prompt }, + ) + if (!panelId) return { ok: false, error: 'panel-creation-failed' } + const store = useAppStore.getState() + if (target.worktreeId) store.setPanelWorktreeId(workspaceId, panelId, target.worktreeId) + const panel = workspace(workspaceId)?.panels[panelId] + if (panel?.codingAgentRun) { + store.setPanelCodingAgentRun(workspaceId, panelId, { + ...panel.codingAgentRun, + worktreeId: target.worktreeId, + }) + } + const label = prompt.replace(/\s+/g, ' ').slice(0, 54) + store.updatePanelTitle(workspaceId, panelId, `${codingAgentDisplayName(agentId)} · ${label}`) + return { + ok: true, + result: codingAgentSnapshot(workspaceId, runId) ?? { + id: runId, + panelId, + agentId, + status: 'starting', + }, + } + } + + const runId = typeof args.runId === 'string' ? args.runId : '' + if (name !== 'wait' && !runId) return { ok: false, error: 'runId-required' } + + if (name === 'inspect') { + const snapshot = codingAgentSnapshot(workspaceId, runId) + if (!snapshot) return { ok: false, error: 'coding-agent-not-found' } + return { + ok: true, + result: { ...snapshot, recentOutput: terminalText(snapshot.panelId) }, + } + } + + if (name === 'send') { + const panel = runPanel(workspaceId, runId) + const run = panel?.codingAgentRun + const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : '' + if (!panel || !run) return { ok: false, error: 'coding-agent-not-found' } + if (!prompt) return { ok: false, error: 'prompt-required' } + if (run.stoppedAt) return { ok: false, error: 'coding-agent-stopped' } + const entry = terminalRegistry.getEntry(panel.id) + if (!entry?.ptyId || !entry.alive) return { ok: false, error: 'coding-agent-not-ready' } + entry.terminal.paste(prompt) + await new Promise((resolve) => setTimeout(resolve, 0)) + await window.electronAPI.terminalWrite(entry.ptyId, '\r') + useAppStore.getState().setPanelCodingAgentRun(workspaceId, panel.id, { + ...run, + followUps: [...(run.followUps ?? []), { prompt, sentAt: Date.now() }], + }) + return { ok: true, result: codingAgentSnapshot(workspaceId, runId) } + } + + if (name === 'stop') { + const panel = runPanel(workspaceId, runId) + const run = panel?.codingAgentRun + if (!panel || !run) return { ok: false, error: 'coding-agent-not-found' } + terminalRegistry.dispose(panel.id) + useAppStore.getState().setPanelCodingAgentRun(workspaceId, panel.id, { + ...run, + stoppedAt: Date.now(), + }) + return { ok: true, result: codingAgentSnapshot(workspaceId, runId) } + } + + if (name === 'wait') { + const timeout = Math.max(0, Math.min(8_000, Number(args.timeoutSeconds ?? 4) * 1_000)) + const startedAt = Date.now() + while (true) { + const snapshots = findRequestedRuns(workspaceId, args.runIds) + if ('error' in snapshots) return { ok: false, error: snapshots.error } + const settled = snapshots.some((run) => + run.status === 'ready' || run.status === 'waiting' || + run.status === 'stopped' || run.status === 'failed', + ) + if (settled || Date.now() - startedAt >= timeout) { + return { ok: true, result: { timedOut: !settled, runs: snapshots } } + } + await new Promise((resolve) => setTimeout(resolve, 200)) + } + } + + return { ok: false, error: 'unsupported' } +} diff --git a/src/renderer/lib/terminal/terminalLifecycle.ts b/src/renderer/lib/terminal/terminalLifecycle.ts index 1c3d0be4..95bd843e 100644 --- a/src/renderer/lib/terminal/terminalLifecycle.ts +++ b/src/renderer/lib/terminal/terminalLifecycle.ts @@ -39,11 +39,13 @@ import { getActiveTheme } from '../themeManager' import { useStatusStore } from '../../stores/statusStore' import { awaitWorkspaceSync, useAppStore } from '../../stores/appStore' import { replayTerminalLog } from '../workspace/session' +import type { CodingAgentLaunch } from '../../../shared/codingAgentRuns' interface CreateOpts { workspaceId: string cwd?: string initialInput?: string + codingAgentLaunch?: CodingAgentLaunch placementGroupId?: string /** Terminal session-restore: a full agent resume command (e.g. * `claude --resume `) typed into the fresh shell right after spawn, via @@ -189,6 +191,15 @@ export function wireTerminalListeners(args: { if (id === ptyId) { const e = registry.get(panelId) if (e) e.alive = false + const panel = useAppStore.getState().workspaces + .find((workspace) => workspace.id === opts.workspaceId)?.panels[panelId] + if (panel?.codingAgentRun && !panel.codingAgentRun.stoppedAt) { + useAppStore.getState().setPanelCodingAgentRun(opts.workspaceId, panelId, { + ...panel.codingAgentRun, + endedAt: Date.now(), + exitCode, + }) + } terminal.write( `\r\n\x1b[90m[Process exited with code ${exitCode}]\x1b[0m\r\n`, ) @@ -313,6 +324,7 @@ export async function getOrCreate(panelId: string, opts: CreateOpts): Promisexterm listeners + shell registration (shared with // reconnectTerminal via wireTerminalListeners). freshSpawn: this is a diff --git a/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts b/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts index ae522ce1..9bbd06a4 100644 --- a/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts +++ b/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts @@ -190,6 +190,36 @@ describe('workspace.json + session.json round-trip', () => { expect(JSON.stringify(wsFile)).not.toContain(agentSession.sessionId) }) + it('round-trips Cate-owned run metadata but never its one-shot launch', () => { + const { snapshot } = buildSnapshot() + const codingAgentRun = { + id: 'run-1', + agentId: 'codex' as const, + panelId: 'term-1', + prompt: 'Implement the parser', + createdAt: 123, + worktreeId: 'wt-1', + followUps: [{ prompt: 'Add the edge-case test', sentAt: 456 }], + } + snapshot.panels!['term-1'] = { + ...snapshot.panels!['term-1'], + codingAgentRun, + codingAgentLaunch: { + runId: 'run-1', + agentId: 'codex', + prompt: 'Implement the parser', + }, + } + + const wsFile = throughDisk(buildWorkspaceFile(snapshot, ROOT, '')) + const sessFile = throughDisk(buildSessionFile(snapshot)) + const restored = projectFilesToSnapshot(wsFile, sessFile, ROOT) + + expect(restored.panels!['term-1'].codingAgentRun).toEqual(codingAgentRun) + expect(restored.panels!['term-1'].codingAgentLaunch).toBeUndefined() + expect(JSON.stringify(wsFile)).not.toContain('Implement the parser') + }) + it('a file outside the workspace root keeps its absolute path through the round trip', () => { const { snapshot } = buildSnapshot() snapshot.panels!['ed-out'] = panel({ diff --git a/src/renderer/lib/workspace/sessionSerialize.ts b/src/renderer/lib/workspace/sessionSerialize.ts index edea2f00..c9a06d79 100644 --- a/src/renderer/lib/workspace/sessionSerialize.ts +++ b/src/renderer/lib/workspace/sessionSerialize.ts @@ -89,13 +89,20 @@ export function buildSessionFile( const panels: Record = {} for (const p of Object.values(snapshot.panels ?? {})) { const workingDirectory = snapshot.terminalCwds?.[p.id] - if (!p.worktreeId && !workingDirectory && !p.unsavedContent && !p.agentSession) continue + if ( + !p.worktreeId && + !workingDirectory && + !p.unsavedContent && + !p.agentSession && + !p.codingAgentRun + ) continue panels[p.id] = { panelId: p.id, workingDirectory, unsavedContent: p.unsavedContent, worktreeId: p.worktreeId, agentSession: p.agentSession, + codingAgentRun: p.codingAgentRun, } } @@ -143,6 +150,7 @@ export function projectFilesToSnapshot( // The agent session to resume in this terminal — TerminalPanel types // the resume command into the fresh shell and clears the field. agentSession: sp?.agentSession, + codingAgentRun: sp?.codingAgentRun, // Restore the per-panel cwd (worktree path / dropped folder) so the // terminal respawns there. TerminalPanel reads panel.cwd directly. The // terminalCwds map below feeds the separate scrollback-restore path. diff --git a/src/renderer/panels/TerminalPanel.tsx b/src/renderer/panels/TerminalPanel.tsx index 1833c30e..a45892a9 100644 --- a/src/renderer/panels/TerminalPanel.tsx +++ b/src/renderer/panels/TerminalPanel.tsx @@ -66,6 +66,7 @@ export default function TerminalPanel({ workspaceId, nodeId, initialInput, + codingAgentLaunch, }: TerminalPanelProps) { const containerRef = useRef(null) const renderBoxRef = useRef(null) @@ -356,6 +357,7 @@ export default function TerminalPanel({ workspaceId, cwd: rootPathRef.current || undefined, initialInput, + codingAgentLaunch, resumeCommand, placementGroupId, }) @@ -403,7 +405,7 @@ export default function TerminalPanel({ detachAndDisconnect() } - }, [panelId, workspaceId, nodeId, initialInput, placementGroupId, retryKey, ptyEpoch]) + }, [panelId, workspaceId, nodeId, initialInput, codingAgentLaunch, placementGroupId, retryKey, ptyEpoch]) // ------------------------------------------------------------------------- // Focus xterm when this node becomes the focused node diff --git a/src/renderer/panels/keepMountedPanels.test.tsx b/src/renderer/panels/keepMountedPanels.test.tsx index 6c869a7a..3702fdbe 100644 --- a/src/renderer/panels/keepMountedPanels.test.tsx +++ b/src/renderer/panels/keepMountedPanels.test.tsx @@ -43,6 +43,32 @@ describe('keepMountedOffscreenPanelIds', () => { expect(keepMountedOffscreenPanelIds(panels).has('p-editor')).toBe(false) }) + it('keeps a live Cate-owned coding-agent terminal mounted', () => { + const runPanel: PanelState = { + id: 'worker', + type: 'terminal', + title: 'Codex worker', + isDirty: false, + codingAgentRun: { + id: 'run-1', + agentId: 'codex', + panelId: 'worker', + prompt: 'Implement it', + createdAt: 1, + }, + } + expect(keepMountedOffscreenPanelIds({ worker: runPanel }).has('worker')).toBe(true) + runPanel.codingAgentRun = { ...runPanel.codingAgentRun!, stoppedAt: 2 } + expect(keepMountedOffscreenPanelIds({ worker: runPanel }).has('worker')).toBe(false) + runPanel.codingAgentRun = { + ...runPanel.codingAgentRun!, + stoppedAt: undefined, + endedAt: 3, + exitCode: 0, + } + expect(keepMountedOffscreenPanelIds({ worker: runPanel }).has('worker')).toBe(false) + }) + it('tolerates a workspace with no panels', () => { expect(keepMountedOffscreenPanelIds(undefined).size).toBe(0) }) diff --git a/src/renderer/panels/keepMountedPanels.ts b/src/renderer/panels/keepMountedPanels.ts index ea80d885..043c3f82 100644 --- a/src/renderer/panels/keepMountedPanels.ts +++ b/src/renderer/panels/keepMountedPanels.ts @@ -25,7 +25,12 @@ export function keepMountedOffscreenPanelIds( const ids = new Set() if (!panels) return ids for (const p of Object.values(panels)) { - if (keepsMountedOffscreen(p.type)) ids.add(p.id) + if ( + keepsMountedOffscreen(p.type) || + (p.codingAgentRun && !p.codingAgentRun.stoppedAt && !p.codingAgentRun.endedAt) + ) { + ids.add(p.id) + } } return ids } diff --git a/src/renderer/panels/registry.ts b/src/renderer/panels/registry.ts index 35cd51a4..57179dfb 100644 --- a/src/renderer/panels/registry.ts +++ b/src/renderer/panels/registry.ts @@ -105,7 +105,10 @@ export const PANEL_REGISTRY: Record = { Component: TerminalPanel, create: ({ workspaceId, canvasPoint, placement, initialInput }) => trackCreated('terminal', useAppStore.getState().createTerminal(workspaceId, initialInput, canvasPoint, placement) || null), - props: baseProps, + props: (panel, ctx) => ({ + ...baseProps(panel, ctx), + codingAgentLaunch: panel.codingAgentLaunch, + }), }, browser: { ...PANEL_DEFINITIONS.browser, diff --git a/src/renderer/panels/types.ts b/src/renderer/panels/types.ts index fd32c1ee..cbc6a2fb 100644 --- a/src/renderer/panels/types.ts +++ b/src/renderer/panels/types.ts @@ -3,6 +3,7 @@ // ============================================================================= import type { BrowserTab } from '../../shared/types' +import type { CodingAgentLaunch } from '../../shared/codingAgentRuns' // ----------------------------------------------------------------------------- // Base panel props @@ -20,6 +21,7 @@ export interface PanelProps { export interface TerminalPanelProps extends PanelProps { initialInput?: string + codingAgentLaunch?: CodingAgentLaunch } export interface EditorPanelProps extends PanelProps { diff --git a/src/renderer/stores/appStore/panelSlice.ts b/src/renderer/stores/appStore/panelSlice.ts index 7d94939b..7eeeeb34 100644 --- a/src/renderer/stores/appStore/panelSlice.ts +++ b/src/renderer/stores/appStore/panelSlice.ts @@ -55,6 +55,8 @@ type PanelSliceActions = Pick< | 'setPanelUnsavedContent' | 'setPanelInitialChat' | 'setPanelAgentSession' + | 'setPanelCodingAgentLaunch' + | 'setPanelCodingAgentRun' | 'addPanel' | 'removePanelRecord' | 'clearCanvas' @@ -79,7 +81,7 @@ export function createPanelSlice(set: AppSet, get: AppGet): PanelSliceActions { return { // --- Panel creation --- - createTerminal(workspaceId, initialInput?, position?, placement?, cwd?) { + createTerminal(workspaceId, initialInput?, position?, placement?, cwd?, codingAgentLaunch?) { const panelId = generateId() // Auto-number terminal titles so `cate ask "Terminal 2"` and similar // inter-panel calls address each one unambiguously — unique across ALL @@ -90,6 +92,16 @@ export function createPanelSlice(set: AppSet, get: AppGet): PanelSliceActions { title: nextNumberedTitle(get, workspaceId, 'terminal', 'Terminal'), isDirty: false, ...(cwd ? { cwd } : {}), + ...(codingAgentLaunch ? { + codingAgentLaunch, + codingAgentRun: { + id: codingAgentLaunch.runId, + agentId: codingAgentLaunch.agentId, + panelId, + prompt: codingAgentLaunch.prompt, + createdAt: Date.now(), + }, + } : {}), } return addAndPlacePanel(set, get, workspaceId, panel, withDefaultSize('terminal', placement), position) }, @@ -331,6 +343,20 @@ export function createPanelSlice(set: AppSet, get: AppGet): PanelSliceActions { }) }, + setPanelCodingAgentLaunch(workspaceId, panelId, launch) { + setPanelField(set, workspaceId, panelId, (panel) => ({ + ...panel, + codingAgentLaunch: launch, + })) + }, + + setPanelCodingAgentRun(workspaceId, panelId, run) { + setPanelField(set, workspaceId, panelId, (panel) => ({ + ...panel, + codingAgentRun: run, + })) + }, + addPanel(workspaceId, panel) { set((state) => ({ workspaces: state.workspaces.map((ws) => diff --git a/src/renderer/stores/appStore/types.ts b/src/renderer/stores/appStore/types.ts index b47fedbe..fcba0e64 100644 --- a/src/renderer/stores/appStore/types.ts +++ b/src/renderer/stores/appStore/types.ts @@ -17,6 +17,7 @@ import type { RuntimeConnection, RuntimePhase, } from '../../../shared/types' +import type { CodingAgentLaunch, CodingAgentRun } from '../../../shared/codingAgentRuns' // ----------------------------------------------------------------------------- // Panel placement — specifies where a newly created panel should go @@ -81,7 +82,14 @@ export interface AppStoreActions { removeWorkspace: (id: string, forgetRecent?: boolean) => void // Panel creation — each adds a PanelState to the workspace AND places it - createTerminal: (workspaceId: string, initialInput?: string, position?: Point, placement?: PanelPlacement, cwd?: string) => string + createTerminal: ( + workspaceId: string, + initialInput?: string, + position?: Point, + placement?: PanelPlacement, + cwd?: string, + codingAgentLaunch?: CodingAgentLaunch, + ) => string createBrowser: (workspaceId: string, url?: string, position?: Point, placement?: PanelPlacement, proxyUrl?: string) => string createEditor: (workspaceId: string, filePath?: string, position?: Point, placement?: PanelPlacement) => string createDiffEditor: (workspaceId: string, filePath: string, diffMode: 'staged' | 'working', position?: Point, placement?: PanelPlacement) => string @@ -119,6 +127,8 @@ export interface AppStoreActions { setPanelUnsavedContent: (workspaceId: string, panelId: string, content: string | undefined) => void setPanelInitialChat: (workspaceId: string, panelId: string, chatId: string) => void setPanelAgentSession: (workspaceId: string, panelId: string, session: TerminalAgentSession | null) => void + setPanelCodingAgentLaunch: (workspaceId: string, panelId: string, launch: CodingAgentLaunch | undefined) => void + setPanelCodingAgentRun: (workspaceId: string, panelId: string, run: CodingAgentRun | undefined) => void addPanel: (workspaceId: string, panel: PanelState) => void removePanelRecord: (workspaceId: string, panelId: string) => void diff --git a/src/renderer/stores/useWorktreeActions.ts b/src/renderer/stores/useWorktreeActions.ts index 72e270a9..372362e5 100644 --- a/src/renderer/stores/useWorktreeActions.ts +++ b/src/renderer/stores/useWorktreeActions.ts @@ -50,6 +50,38 @@ export interface WorktreeActions { checkoutPr: (pr: PrListItem) => Promise } +/** Imperative core shared by the React hook and Cate Agent's orchestration + * driver. Keeping one path preserves branch sanitization, symlink settings, + * metadata colors, additional-root registration, and git refresh behavior. */ +export async function createWorktreeForWorkspace( + rootPath: string, + workspaceId: string, + rawName: string, + baseRef?: string, +): Promise { + const branch = toBranchName(rawName) + if (!branch) throw new Error('Please enter a name') + const targetPath = worktreePathFor(rootPath, branch) + await window.electronAPI.gitWorktreeAdd(rootPath, branch, targetPath, { + createBranch: true, + baseRef, + symlinkPaths: configuredSymlinkPaths(), + }, workspaceId) + + const store = useAppStore.getState() + const ws = store.workspaces.find((workspace) => workspace.id === workspaceId) + const meta: WorktreeMeta = { + id: newWorktreeId(), + path: targetPath, + label: rawName.trim() !== branch ? rawName.trim() : undefined, + color: pickWorktreeColor(ws?.worktrees ?? []), + } + store.upsertWorktree(workspaceId, meta) + store.addAdditionalRoot(workspaceId, targetPath) + gitStatusStore.refresh(rootPath) + return meta +} + export function useWorktreeActions(rootPath: string, workspaceId: string | null): WorktreeActions { const upsertWorktree = useAppStore((s) => s.upsertWorktree) const addAdditionalRoot = useAppStore((s) => s.addAdditionalRoot) @@ -57,29 +89,9 @@ export function useWorktreeActions(rootPath: string, workspaceId: string | null) const createWorktree = useCallback( async (rawName: string, baseRef?: string) => { if (!rootPath || !workspaceId) return null - const branch = toBranchName(rawName) - if (!branch) throw new Error('Please enter a name') - const targetPath = worktreePathFor(rootPath, branch) - await window.electronAPI.gitWorktreeAdd(rootPath, branch, targetPath, { - createBranch: true, - baseRef, - symlinkPaths: configuredSymlinkPaths(), - }, workspaceId) - - const ws = useAppStore.getState().workspaces.find((w) => w.id === workspaceId) - const meta: WorktreeMeta = { - id: newWorktreeId(), - path: targetPath, - // Keep the friendly name when it differs from the slugged branch. - label: rawName.trim() !== branch ? rawName.trim() : undefined, - color: pickWorktreeColor(ws?.worktrees ?? []), - } - upsertWorktree(workspaceId, meta) - addAdditionalRoot(workspaceId, targetPath) - gitStatusStore.refresh(rootPath) - return meta + return createWorktreeForWorkspace(rootPath, workspaceId, rawName, baseRef) }, - [rootPath, workspaceId, upsertWorktree, addAdditionalRoot], + [rootPath, workspaceId], ) const checkoutPr = useCallback( diff --git a/src/runtime/capabilities/process.agentHooks.test.ts b/src/runtime/capabilities/process.agentHooks.test.ts index 4f91d43c..d83404fd 100644 --- a/src/runtime/capabilities/process.agentHooks.test.ts +++ b/src/runtime/capabilities/process.agentHooks.test.ts @@ -50,4 +50,34 @@ describe('process agent hook preparation', () => { ) processCapability.kill(handle.id) }) + + test('spawns an exact trusted command instead of interpolating it through a shell', async () => { + const processCapability = createProcessCapability({ + resolveShell: () => ({ path: '/bin/sh', args: ['-l'] }), + getEnv: () => ({ PATH: '/usr/bin:/bin' }), + }) + + const handle = await processCapability.create( + { + id: 'pty-codex', + cols: 80, + rows: 24, + cwd: '/repo/worktree', + command: { + executable: 'codex', + args: ['Fix it; touch /tmp/not-shell-syntax'], + }, + }, + () => {}, + () => {}, + ) + + expect(ptySpawn).toHaveBeenCalledWith( + 'codex', + ['Fix it; touch /tmp/not-shell-syntax'], + expect.objectContaining({ cwd: '/repo/worktree' }), + ) + expect(handle.shell).toBe('codex') + processCapability.kill(handle.id) + }) }) diff --git a/src/runtime/capabilities/process.ts b/src/runtime/capabilities/process.ts index 55547fad..66f43d8c 100644 --- a/src/runtime/capabilities/process.ts +++ b/src/runtime/capabilities/process.ts @@ -275,6 +275,8 @@ export function createProcessCapability(deps: ProcessDeps): ProcessCapability { const id = opts.id ?? `pty-${Date.now()}-${Math.round(seq++ + Math.random() * 1e6).toString(36)}` const ptySpawn = await getPtySpawn() const shell = deps.resolveShell(opts.shell) + const executable = opts.command?.executable ?? shell.path + const args = opts.command?.args ?? shell.args const cwd = opts.cwd || os.homedir() // Merge caller env over the host env; when a CLI endpoint was injected // (CATE_API), also put the bundled `cate` on PATH so agents can run it. @@ -289,7 +291,7 @@ export function createProcessCapability(deps: ProcessDeps): ProcessCapability { await deps.hooks.prepareWorkspace(cwd, opts.agentHookConfig, opts.workspaceBaseCwd) } catch { /* hook injection unavailable */ } } - const pty = ptySpawn(shell.path, shell.args, { + const pty = ptySpawn(executable, args, { name: 'xterm-256color', cols: opts.cols, rows: opts.rows, @@ -314,7 +316,12 @@ export function createProcessCapability(deps: ProcessDeps): ProcessCapability { deps.agentPresence?.drop(id) onExit(id, exitCode) }) - return { id, pid: pty.pid, notice: shell.notice, shell: shell.path } + return { + id, + pid: pty.pid, + notice: opts.command ? undefined : shell.notice, + shell: executable, + } }, write(id: string, data: string): void { diff --git a/src/shared/codingAgentRuns.test.ts b/src/shared/codingAgentRuns.test.ts new file mode 100644 index 00000000..363c7f68 --- /dev/null +++ b/src/shared/codingAgentRuns.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { codingAgentCommand, parseCodingAgentId } from './codingAgentRuns' + +describe('codingAgentCommand', () => { + it('resolves only canonical agent ids to exact argv without a shell', () => { + expect(codingAgentCommand({ + agentId: 'codex', + prompt: 'Fix it; touch /tmp/pwned', + })).toEqual({ + executable: 'codex', + args: ['Fix it; touch /tmp/pwned'], + }) + expect(codingAgentCommand({ + agentId: 'opencode', + prompt: 'Implement the parser', + })).toEqual({ + executable: 'opencode', + args: ['run', 'Implement the parser'], + }) + }) + + it('rejects unknown ids and blank tasks', () => { + expect(parseCodingAgentId('/tmp/fake-agent')).toBeNull() + expect(parseCodingAgentId('codex')).toBe('codex') + expect(() => codingAgentCommand({ agentId: 'pi', prompt: ' ' })).toThrow( + 'A coding-agent prompt is required', + ) + }) +}) diff --git a/src/shared/codingAgentRuns.ts b/src/shared/codingAgentRuns.ts new file mode 100644 index 00000000..c1715042 --- /dev/null +++ b/src/shared/codingAgentRuns.ts @@ -0,0 +1,77 @@ +import { AGENTS, type AgentId } from './agents' + +/** A coding agent process Cate created and owns inside a terminal panel. */ +export interface CodingAgentRun { + id: string + agentId: AgentId + panelId: string + prompt: string + createdAt: number + worktreeId?: string + /** Follow-up prompts sent after the initial task. Kept with panel state so + * mission context survives a Cate restart. */ + followUps?: Array<{ prompt: string; sentAt: number }> + endedAt?: number + exitCode?: number + stoppedAt?: number +} + +/** One-shot launch data consumed when the terminal's PTY is first spawned. */ +export interface CodingAgentLaunch { + runId: string + agentId: AgentId + prompt: string +} + +export type CodingAgentRunStatus = + | 'starting' + | 'working' + | 'waiting' + | 'ready' + | 'stopped' + | 'failed' + +export interface CodingAgentRunSnapshot extends CodingAgentRun { + status: CodingAgentRunStatus + agentName: string + cwd: string + alive: boolean + /** OpenCode's prompt-bearing `run` surface is one-shot; the other registered + * interactive CLIs can accept follow-up prompts in the same terminal. */ + followUpSupported: boolean + statusLine?: string +} + +export const MAX_CONCURRENT_CODING_AGENTS = 5 + +/** Resolve an untrusted tool argument to the closed, canonical agent registry. */ +export function parseCodingAgentId(value: unknown): AgentId | null { + if (typeof value !== 'string') return null + return AGENTS.some((agent) => agent.id === value) ? (value as AgentId) : null +} + +/** + * Build the exact executable + argv for a Cate-owned coding-agent PTY. + * + * No shell is involved, so task text cannot become shell syntax. Every + * executable comes from AGENTS; callers cannot provide a path or extra flags. + * OpenCode's prompt-bearing surface is its `run` command. Other supported CLIs + * accept the first positional prompt while remaining interactive. + */ +export function codingAgentCommand( + launch: Pick, +): { executable: string; args: string[] } { + const agent = AGENTS.find((candidate) => candidate.id === launch.agentId) + if (!agent) throw new Error(`Unsupported coding agent: ${launch.agentId}`) + const prompt = launch.prompt.trim() + if (!prompt) throw new Error('A coding-agent prompt is required') + if (prompt.includes('\0')) throw new Error('Coding-agent prompts cannot contain NUL bytes') + return { + executable: agent.command, + args: launch.agentId === 'opencode' ? ['run', prompt] : [prompt], + } +} + +export function codingAgentDisplayName(agentId: AgentId): string { + return AGENTS.find((agent) => agent.id === agentId)?.displayName ?? agentId +} diff --git a/src/shared/electron-api.d.ts b/src/shared/electron-api.d.ts index 289fcb45..8094278f 100644 --- a/src/shared/electron-api.d.ts +++ b/src/shared/electron-api.d.ts @@ -3,6 +3,7 @@ // ============================================================================= import type { CodingCreateOptions, CodingEventEnvelope, CodingExtensionUIResponse, CodingImageAttachment, CateAgentModelRef, CodingModelDescriptor, CodingRpcState, CodingSessionListEntry, CodingSessionStats, CodingSlashCommand, CodingThinkingLevel, AppSettings, AgentState, AuthProviderDescriptor, AuthProviderStatus, CustomOpenAIProvider, DockWindowInitPayload, DockWindowSyncState, DetachedDockWindowSnapshot, WindowPanelInfo, WindowPanelReport, FileSearchOptions, FileSearchResult, FileTreeNode, SearchOptions, SearchResultBatch, SearchDoneEvent, NotificationAction, OAuthFlowEvent, PanelTransferSnapshot, PerfSnapshot, Point, ProviderVerification, SidebarSession, TerminalActivity, TerminalAgentSession, WorkspaceInfo, WorkspaceMutationResult, RemoteConnectSpec, RuntimeConnectResult, RuntimeStatusEvent, RuntimeConnection, RuntimePhase, RemoteProjectEntry, SshHostEntry, UIState } from './types' +import type { CodingAgentLaunch } from './codingAgentRuns' import type { SavedSkill, InstalledSkill, SkillEntry, SkillSource, SkillTargetId } from './skills' import type { AgentHookEvent, AgentHookAgentState } from './agentHooks' import type { ExtensionListEntry, ExtensionManifest } from './extensions' @@ -61,6 +62,8 @@ export interface ElectronAPI { panelId?: string /** Opaque canvas affinity, exposed to the shell for host-API creates. */ placementGroupId?: string + /** Closed agent launch contract; main resolves the executable and argv. */ + codingAgentLaunch?: CodingAgentLaunch }): Promise /** Write data (keystrokes) to a terminal. */ diff --git a/src/shared/types.ts b/src/shared/types.ts index 038584a1..9baad3df 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -7,6 +7,7 @@ import type { Theme } from './theme' export type { Theme } from './theme' import type { AgentId } from './agents' import type { AgentHookMode } from './agentHooks' +import type { CodingAgentLaunch, CodingAgentRun } from './codingAgentRuns' // ----------------------------------------------------------------------------- // Geometry primitives @@ -133,6 +134,13 @@ export interface PanelState { * On restore, TerminalPanel types the agent's resume command into the * fresh shell and clears this. */ agentSession?: TerminalAgentSession + /** Terminal panels created by Cate Agent carry durable ownership metadata. + * This is the native mission/run record; live status is derived from the + * terminal registry and agent hooks rather than persisted stale state. */ + codingAgentRun?: CodingAgentRun + /** One-shot direct-process launch. Cleared immediately after PTY creation so + * restoring a workspace never repeats an already-started task. */ + codingAgentLaunch?: CodingAgentLaunch /** Extension panels only: which installed extension + which of its declared * panels this instance renders. */ extensionId?: string @@ -1068,6 +1076,10 @@ export interface ProjectSessionPanel { /** Agent-CLI session running in this terminal at save time. Machine-local * (session ids reference stores on this machine's runtime host). */ agentSession?: TerminalAgentSession + /** Cate-owned coding-agent mission metadata. The initial one-shot launch is + * deliberately excluded; restoring may resume a stamped CLI session but + * never repeats the original task. */ + codingAgentRun?: CodingAgentRun } // ----------------------------------------------------------------------------- From f617ab00011fa790f4471ef537f8a5412e4d8f87 Mon Sep 17 00:00:00 2001 From: Anton Date: Sun, 26 Jul 2026 18:27:20 +0200 Subject: [PATCH 02/12] fix: stage runtime watcher dependencies in archive --- scripts/build-runtime-tarball.mjs | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/scripts/build-runtime-tarball.mjs b/scripts/build-runtime-tarball.mjs index 2203941b..8f01a26f 100644 --- a/scripts/build-runtime-tarball.mjs +++ b/scripts/build-runtime-tarball.mjs @@ -224,15 +224,15 @@ async function stageParcelWatcher(outRoot) { } // Stage @parcel/watcher's JS dependency closure. wrapper.js requires // picomatch + is-glob (→ is-extglob); index.js requires detect-libc on linux. - // Resolve EACH from @parcel/watcher's own resolution root rather than assuming - // a fixed location: npm may nest a dep under @parcel/watcher/node_modules (a - // version-pin conflict) OR hoist it to the top-level node_modules (no conflict) - // — and which one happens varies by the full install tree. A previous version - // copied the nested dir if present, else a hardcoded list that OMITTED - // picomatch; when npm hoisted picomatch the nested dir was absent AND it wasn't - // in the list, so it shipped missing and the daemon crashed at startup with - // `Cannot find module 'picomatch'` (require'd by wrapper.js). createRequire - // finds the exact copy node would load, at whatever depth, every time. + // Resolve EACH from @parcel/watcher's own resolution root so we copy the exact + // installed version, but always stage it at the tarball's top-level + // node_modules. `require()` from @parcel/watcher resolves that location. + // + // Do not derive a destination with path.relative(repoRoot, pkgDir): in a git + // worktree node_modules is commonly a symlink to the primary checkout, and + // createRequire resolves the REAL path. Relativizing that path contains `..` + // segments and silently copies outside stageDir, producing a tarball that + // crashes with `Cannot find module 'picomatch'`. const requireFrom = createRequire(path.join(src, 'package.json')) for (const dep of ['picomatch', 'is-glob', 'is-extglob', 'detect-libc']) { let pkgDir @@ -243,17 +243,18 @@ async function stageParcelWatcher(outRoot) { if (dep === 'detect-libc') continue throw new Error(`@parcel/watcher dependency "${dep}" not resolvable from ${src} — run \`npm install\` first`) } - // Stage into the SAME relative location node resolved it from (nested under - // @parcel/watcher or hoisted to the top level) so resolution matches at runtime. - const rel = path.relative(repoRoot, pkgDir) - cpSync(pkgDir, path.join(outRoot, rel), { recursive: true, dereference: true }) + cpSync(pkgDir, path.join(nm, dep), { recursive: true, dereference: true }) } // Fail loudly if the staged tree can't resolve wrapper.js's requires — the exact // crash that shipped before. Mirrors the watcher.node assert below. const stagedRequire = createRequire(path.join(dest, 'wrapper.js')) for (const dep of ['picomatch', 'is-glob']) { try { - stagedRequire.resolve(dep) + const resolved = stagedRequire.resolve(dep) + const stagedModules = path.resolve(nm) + path.sep + if (!path.resolve(resolved).startsWith(stagedModules)) { + throw new Error(`resolved outside stage: ${resolved}`) + } } catch { throw new Error( `staged @parcel/watcher cannot resolve "${dep}" from ${path.join(dest, 'wrapper.js')}. ` + From 3fe63d3e6f405296b3cfedd2f971b51bba351f47 Mon Sep 17 00:00:00 2001 From: Anton Date: Sun, 26 Jul 2026 18:58:08 +0200 Subject: [PATCH 03/12] feat: refine coding agent supervision --- .../cate-orchestrator/index.test.ts | 5 +- .../extensions/cate-orchestrator/index.ts | 6 +- .../renderer/ChatCodingAgentCard.tsx | 24 ++- src/cateAgent/renderer/ChatMessageRow.tsx | 7 + .../ChatOrchestrationToolCard.test.tsx | 97 ++++++++++++ .../renderer/ChatOrchestrationToolCard.tsx | 146 ++++++++++++++++++ src/main/extensions/cateApiHandlers.test.ts | 6 + src/main/extensions/cateApiHandlers.ts | 9 +- src/renderer/lib/agent/codingAgentDriver.ts | 126 ++++++++++++--- .../lib/agent/codingAgentWait.test.ts | 70 +++++++++ src/renderer/lib/agent/codingAgentWait.ts | 59 +++++++ 11 files changed, 529 insertions(+), 26 deletions(-) create mode 100644 src/cateAgent/renderer/ChatOrchestrationToolCard.test.tsx create mode 100644 src/cateAgent/renderer/ChatOrchestrationToolCard.tsx create mode 100644 src/renderer/lib/agent/codingAgentWait.test.ts create mode 100644 src/renderer/lib/agent/codingAgentWait.ts diff --git a/src/cateAgent/extensions/cate-orchestrator/index.test.ts b/src/cateAgent/extensions/cate-orchestrator/index.test.ts index 6f43e2c7..30380c2a 100644 --- a/src/cateAgent/extensions/cate-orchestrator/index.test.ts +++ b/src/cateAgent/extensions/cate-orchestrator/index.test.ts @@ -17,13 +17,16 @@ beforeEach(() => { describe("cate-orchestrator", () => { it("registers the complete worker lifecycle surface", () => { - expect([...registeredTools().keys()]).toEqual([ + const tools = registeredTools() + expect([...tools.keys()]).toEqual([ "create_coding_agent", "send_to_coding_agent", "wait_for_coding_agents", "inspect_coding_agent", "stop_coding_agent", ]) + expect(tools.get("wait_for_coding_agents").parameters.properties.timeoutSeconds) + .toMatchObject({ minimum: 15, maximum: 120, default: 60 }) }) it("asks once per session before creating workers and invokes the scoped API", async () => { diff --git a/src/cateAgent/extensions/cate-orchestrator/index.ts b/src/cateAgent/extensions/cate-orchestrator/index.ts index 1ab0c1f2..a9351c9a 100644 --- a/src/cateAgent/extensions/cate-orchestrator/index.ts +++ b/src/cateAgent/extensions/cate-orchestrator/index.ts @@ -63,7 +63,7 @@ export default function (pi: ExtensionAPI) { promptGuidelines: [ "Delegate bounded implementation or investigation tasks when parallel work materially helps; keep architectural ownership and final verification yourself.", "Give each worker a self-contained prompt with scope, constraints, and concrete success criteria. Never ask a worker to create more workers.", - "After delegation, use wait_for_coding_agents and inspect_coding_agent. Send targeted follow-ups when needed, then verify and integrate the results yourself.", + "After delegation, call wait_for_coding_agents once and let it block until worker state changes. Do not repeatedly inspect a worker that is still working; inspect after a change or timeout, then send a targeted follow-up only when needed.", "Never create more than five live workers. Reuse a run with send_to_coding_agent when follow-up belongs to the same task.", "OpenCode runs are one-shot; create a fresh OpenCode run instead of sending a follow-up after it exits.", ], @@ -115,10 +115,10 @@ export default function (pi: ExtensionAPI) { name: "wait_for_coding_agents", label: "Wait for coding agents", description: - "Wait briefly for one or more Cate-owned coding agents to change state, then return fresh snapshots. Use in a loop when workers are still working.", + "Efficiently monitor one or more Cate-owned coding agents. Blocks for up to 60 seconds by default but returns immediately when a worker changes state or needs input. Prefer one long wait over repeated polling; inspect only after this reports a change or timeout.", parameters: Type.Object({ runIds: Type.Optional(Type.Array(Type.String(), { minItems: 1, maxItems: 5 })), - timeoutSeconds: Type.Optional(Type.Number({ minimum: 0, maximum: 8, default: 4 })), + timeoutSeconds: Type.Optional(Type.Number({ minimum: 15, maximum: 120, default: 60 })), }), async execute(_id, params, signal) { return toolResult(await invoke("cate.codingAgent.wait", params, signal)) diff --git a/src/cateAgent/renderer/ChatCodingAgentCard.tsx b/src/cateAgent/renderer/ChatCodingAgentCard.tsx index d4fec620..63a22a8b 100644 --- a/src/cateAgent/renderer/ChatCodingAgentCard.tsx +++ b/src/cateAgent/renderer/ChatCodingAgentCard.tsx @@ -5,6 +5,7 @@ import { useAppStore } from '../../renderer/stores/appStore' import { revealPanel } from '../../renderer/lib/workspace/panelReveal' import { useAgentTerminalStatus, agentStateLabel } from './useAgentTerminalStatus' import { codingAgentDisplayName, parseCodingAgentId } from '../../shared/codingAgentRuns' +import { prettyArgs } from './chatShared' function resultObject(result: string | undefined): Record { if (!result) return {} @@ -66,10 +67,27 @@ export function CodingAgentCard({ msg, shimmer }: { msg: ToolMessage; shimmer?: )} {expanded && ( -
-
{prompt}
+
+
+
+ Input +
+
+              {prettyArgs(msg.args)}
+            
+
+ {msg.result && ( +
+
+ Output +
+
+                {msg.result}
+              
+
+ )} {terminalStatus.line && ( -
+
{terminalStatus.line}
)} diff --git a/src/cateAgent/renderer/ChatMessageRow.tsx b/src/cateAgent/renderer/ChatMessageRow.tsx index 310cee59..d2a7ea7a 100644 --- a/src/cateAgent/renderer/ChatMessageRow.tsx +++ b/src/cateAgent/renderer/ChatMessageRow.tsx @@ -17,6 +17,10 @@ import { ToolCard, AskUserToolView } from './ChatToolCard' import { SubagentCard } from './ChatSubagentCard' import { PlanReadyCard } from './ChatPlanCard' import { CodingAgentCard } from './ChatCodingAgentCard' +import { + ORCHESTRATION_TOOL_NAMES, + OrchestrationToolCard, +} from './ChatOrchestrationToolCard' import { formatTime } from './chatShared' interface MessageRowProps { @@ -129,6 +133,9 @@ export const MessageRow = memo(function MessageRow({ if (msg.type === 'tool' && msg.name === 'create_coding_agent') { return } + if (msg.type === 'tool' && ORCHESTRATION_TOOL_NAMES.has(msg.name)) { + return + } return }) diff --git a/src/cateAgent/renderer/ChatOrchestrationToolCard.test.tsx b/src/cateAgent/renderer/ChatOrchestrationToolCard.test.tsx new file mode 100644 index 00000000..6846b6e5 --- /dev/null +++ b/src/cateAgent/renderer/ChatOrchestrationToolCard.test.tsx @@ -0,0 +1,97 @@ +import React, { act } from 'react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createRoot, type Root } from 'react-dom/client' +import type { ToolMessage } from './codingStore' +import { + OrchestrationToolCard, + orchestrationToolSummary, +} from './ChatOrchestrationToolCard' + +;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +function message( + name: string, + args: unknown, + result?: unknown, + status: ToolMessage['status'] = 'success', +): ToolMessage { + return { + type: 'tool', + id: `message-${name}`, + toolCallId: `call-${name}`, + name, + args, + status, + ...(result === undefined ? {} : { result: JSON.stringify(result, null, 2) }), + } +} + +describe('orchestration tool presentation', () => { + let host: HTMLDivElement + let root: Root + + beforeEach(() => { + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + }) + + it('uses contextual summaries instead of the generic Used label', () => { + expect(orchestrationToolSummary(message( + 'send_to_coding_agent', + { runId: '12345678-abcd', prompt: 'Run the focused tests' }, + { agentName: 'Codex', status: 'working' }, + ))).toEqual({ + verb: 'Steered', + detail: 'Codex · Run the focused tests', + }) + + expect(orchestrationToolSummary(message( + 'wait_for_coding_agents', + { runIds: ['one'], timeoutSeconds: 60 }, + { timedOut: true, runs: [{ id: 'one', status: 'working' }] }, + ))).toEqual({ + verb: 'Monitored', + detail: '1 coding agent · no change after 60s', + }) + + expect(orchestrationToolSummary(message( + 'wait_for_coding_agents', + { runIds: ['one'] }, + { + timedOut: false, + changedRunIds: ['one'], + runs: [{ id: 'one', agentName: 'Codex', status: 'waiting' }], + }, + ))).toEqual({ + verb: 'Agent update', + detail: 'Codex · waiting', + }) + }) + + it('reveals the complete tool input and output from its collapsed summary', () => { + const msg = message( + 'inspect_coding_agent', + { runId: 'run-1' }, + { agentName: 'Codex', status: 'ready', recentOutput: 'All tests passed' }, + ) + act(() => root.render()) + + expect(host.textContent).toContain('Inspected') + expect(host.textContent).not.toContain('recentOutput') + + act(() => { + host.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(host.textContent).toContain('Input') + expect(host.textContent).toContain('"runId": "run-1"') + expect(host.textContent).toContain('Output') + expect(host.textContent).toContain('"recentOutput": "All tests passed"') + }) +}) diff --git a/src/cateAgent/renderer/ChatOrchestrationToolCard.tsx b/src/cateAgent/renderer/ChatOrchestrationToolCard.tsx new file mode 100644 index 00000000..abede2e8 --- /dev/null +++ b/src/cateAgent/renderer/ChatOrchestrationToolCard.tsx @@ -0,0 +1,146 @@ +import { useMemo, useState } from 'react' +import type { ToolMessage } from './codingStore' +import { prettyArgs } from './chatShared' + +export const ORCHESTRATION_TOOL_NAMES: ReadonlySet = new Set([ + 'send_to_coding_agent', + 'wait_for_coding_agents', + 'inspect_coding_agent', + 'stop_coding_agent', +]) + +function resultObject(result: string | undefined): Record { + if (!result) return {} + try { + const parsed = JSON.parse(result) + return parsed && typeof parsed === 'object' ? parsed as Record : {} + } catch { + return {} + } +} + +function compact(text: string, max = 120): string { + const line = text.replace(/\s+/g, ' ').trim() + return line.length > max ? `${line.slice(0, max - 1)}…` : line +} + +function runLabel(args: Record, result: Record): string { + if (typeof result.agentName === 'string') return result.agentName + const runId = typeof args.runId === 'string' ? args.runId : '' + return runId ? `agent ${runId.slice(0, 8)}` : 'coding agent' +} + +export function orchestrationToolSummary(msg: ToolMessage): { verb: string; detail: string } { + const args = (msg.args ?? {}) as Record + const result = resultObject(msg.result) + const running = msg.status === 'running' || msg.status === 'pending' + const failed = msg.status === 'error' || msg.status === 'denied' + + switch (msg.name) { + case 'send_to_coding_agent': + return { + verb: failed ? 'Steering failed' : running ? 'Steering' : 'Steered', + detail: `${runLabel(args, result)} · ${compact(String(args.prompt ?? 'follow-up prompt'))}`, + } + case 'inspect_coding_agent': + return { + verb: failed ? 'Inspection failed' : running ? 'Inspecting' : 'Inspected', + detail: `${runLabel(args, result)}${ + typeof result.status === 'string' ? ` · ${result.status}` : '' + }`, + } + case 'stop_coding_agent': + return { + verb: failed ? 'Stop failed' : running ? 'Stopping' : 'Stopped', + detail: runLabel(args, result), + } + case 'wait_for_coding_agents': { + const runs = Array.isArray(result.runs) ? result.runs as Array> : [] + const requested = Array.isArray(args.runIds) ? args.runIds.length : undefined + const count = runs.length || requested + const subject = count ? `${count} coding agent${count === 1 ? '' : 's'}` : 'coding agents' + if (failed) return { verb: 'Monitoring failed', detail: subject } + if (running) return { verb: 'Monitoring', detail: `${subject} for meaningful changes` } + if (result.timedOut === true) { + return { + verb: 'Monitored', + detail: `${subject} · no change after ${Number(args.timeoutSeconds ?? 60)}s`, + } + } + const changedIds = Array.isArray(result.changedRunIds) + ? result.changedRunIds.filter((id): id is string => typeof id === 'string') + : [] + const changedRuns = runs.filter((run) => changedIds.includes(String(run.id))) + if (changedRuns.length === 1) { + const changedRun = changedRuns[0] + const agent = typeof changedRun.agentName === 'string' ? changedRun.agentName : 'Coding agent' + const status = typeof changedRun.status === 'string' ? changedRun.status : 'updated' + return { verb: 'Agent update', detail: `${agent} · ${status}` } + } + return { + verb: 'Agent update', + detail: changedIds.length ? `${changedIds.length} of ${subject} changed state` : subject, + } + } + default: + return { verb: 'Used', detail: msg.name } + } +} + +export function OrchestrationToolCard({ + msg, + shimmer, +}: { + msg: ToolMessage + shimmer?: boolean +}) { + const [expanded, setExpanded] = useState(false) + const summary = useMemo(() => orchestrationToolSummary(msg), [msg]) + const liveOutput = msg.status === 'running' ? msg.partialText : undefined + const output = liveOutput ?? msg.result + const running = msg.status === 'running' || msg.status === 'pending' + const hasDetails = msg.args != null || !!output || !!msg.error + + return ( +
+ + {expanded && hasDetails && ( +
+ {msg.args != null && ( +
+
+ Input +
+
+                {prettyArgs(msg.args)}
+              
+
+ )} + {output && ( +
+
+ Output +
+
+                {output}
+              
+
+ )} + {msg.error && ( +
+              {msg.error}
+            
+ )} +
+ )} +
+ ) +} diff --git a/src/main/extensions/cateApiHandlers.test.ts b/src/main/extensions/cateApiHandlers.test.ts index 35781f50..491cc323 100644 --- a/src/main/extensions/cateApiHandlers.test.ts +++ b/src/main/extensions/cateApiHandlers.test.ts @@ -135,6 +135,7 @@ vi.mock('./storage', () => ({ import { dispatchCateInvoke, + forwardTimeoutMs, requiredScopeFor, TERMINAL_INPUT_DISABLED, TERMINAL_READ_DISABLED, @@ -353,6 +354,11 @@ describe('dispatchCateInvoke — Kitchen Sink reverse API', () => { }) describe('dispatchCateInvoke — Cate Agent orchestration boundary', () => { + it('allows a long monitor call without extending unrelated host actions', () => { + expect(forwardTimeoutMs('cate.codingAgent.wait')).toBe(125_000) + expect(forwardTimeoutMs('cate.editor.openFile')).toBe(10_000) + }) + it('maps every native coding-agent method to its dedicated scope', () => { for (const verb of ['create', 'send', 'wait', 'inspect', 'stop']) { expect(requiredScopeFor(`cate.codingAgent.${verb}`)).toBe('coding-agent') diff --git a/src/main/extensions/cateApiHandlers.ts b/src/main/extensions/cateApiHandlers.ts index f8d66167..5b90db33 100644 --- a/src/main/extensions/cateApiHandlers.ts +++ b/src/main/extensions/cateApiHandlers.ts @@ -91,6 +91,13 @@ import type { PanelType } from '../../shared/types' const CATE_API_VERSION = 6 const FORWARD_TIMEOUT_MS = 10_000 +const CODING_AGENT_WAIT_FORWARD_TIMEOUT_MS = 125_000 + +export function forwardTimeoutMs(method: string): number { + return method === 'cate.codingAgent.wait' + ? CODING_AGENT_WAIT_FORWARD_TIMEOUT_MS + : FORWARD_TIMEOUT_MS +} /** Stable errors for CLI permission cells (Settings → CLI) that are off. Each * tells the caller how to get the feature enabled, not just that it is denied. @@ -174,7 +181,7 @@ export function forwardToOwner( const timer = setTimeout(() => { pendingForwards.delete(requestId) resolve({ error: 'timeout', method: payload.method }) - }, FORWARD_TIMEOUT_MS) + }, forwardTimeoutMs(payload.method)) pendingForwards.set(requestId, { resolve, timer }) try { owner.send(CATE_HOST_FORWARD, { diff --git a/src/renderer/lib/agent/codingAgentDriver.ts b/src/renderer/lib/agent/codingAgentDriver.ts index 7f120561..edee9dec 100644 --- a/src/renderer/lib/agent/codingAgentDriver.ts +++ b/src/renderer/lib/agent/codingAgentDriver.ts @@ -13,6 +13,12 @@ import { type CodingAgentRunSnapshot, type CodingAgentRunStatus, } from '../../../shared/codingAgentRuns' +import { + actionableCodingAgentRunIds, + changedCodingAgentRunIds, + codingAgentWaitMs, + compactCodingAgentSnapshot, +} from './codingAgentWait' export type CodingAgentOutcome = | { ok: true; result: unknown } @@ -144,6 +150,93 @@ function findRequestedRuns( return snapshots } +async function waitForCodingAgentChange( + workspaceId: string, + rawRunIds: unknown, + timeoutMs: number, +): Promise { + const initial = findRequestedRuns(workspaceId, rawRunIds) + if ('error' in initial) return { ok: false, error: initial.error } + // With no explicit target, monitor only live mission work. Historical ready + // runs must not make every future wait return immediately. + const watched = rawRunIds === undefined + ? initial.filter((run) => + run.status === 'starting' || run.status === 'working' || run.status === 'waiting') + : initial + if (watched.length === 0) { + return { ok: true, result: { timedOut: false, changedRunIds: [], runs: [] } } + } + const actionable = actionableCodingAgentRunIds(watched) + if (actionable.length > 0) { + return { + ok: true, + result: { + timedOut: false, + changedRunIds: actionable, + runs: watched.map(compactCodingAgentSnapshot), + }, + } + } + const ids = watched.map((run) => run.id) + const baseline = new Map(watched.map((run) => [run.id, run.status])) + + return new Promise((resolve) => { + let settled = false + let unsubscribeApp = () => {} + let unsubscribeStatus = () => {} + let unsubscribeFailure = () => {} + + const finish = (outcome: CodingAgentOutcome): void => { + if (settled) return + settled = true + clearTimeout(timer) + unsubscribeApp() + unsubscribeStatus() + unsubscribeFailure() + resolve(outcome) + } + const current = (): CodingAgentRunSnapshot[] | { error: string } => + findRequestedRuns(workspaceId, ids) + const check = (): void => { + const snapshots = current() + if ('error' in snapshots) { + finish({ ok: false, error: snapshots.error }) + return + } + const changedRunIds = changedCodingAgentRunIds(baseline, snapshots) + if (changedRunIds.length > 0) { + finish({ + ok: true, + result: { + timedOut: false, + changedRunIds, + runs: snapshots.map(compactCodingAgentSnapshot), + }, + }) + } + } + + const timer = setTimeout(() => { + const snapshots = current() + finish('error' in snapshots + ? { ok: false, error: snapshots.error } + : { + ok: true, + result: { + timedOut: true, + changedRunIds: [], + runs: snapshots.map(compactCodingAgentSnapshot), + }, + }) + }, timeoutMs) + unsubscribeApp = useAppStore.subscribe(check) + unsubscribeStatus = useStatusStore.subscribe(check) + unsubscribeFailure = terminalRegistry.subscribeFailure(check) + // Close the gap between the initial snapshot and listener registration. + check() + }) +} + export async function handleCodingAgentMethod( workspaceId: string, method: string, @@ -212,9 +305,10 @@ export async function handleCodingAgentMethod( } const label = prompt.replace(/\s+/g, ' ').slice(0, 54) store.updatePanelTitle(workspaceId, panelId, `${codingAgentDisplayName(agentId)} · ${label}`) + const snapshot = codingAgentSnapshot(workspaceId, runId) return { ok: true, - result: codingAgentSnapshot(workspaceId, runId) ?? { + result: snapshot ? compactCodingAgentSnapshot(snapshot) : { id: runId, panelId, agentId, @@ -231,7 +325,10 @@ export async function handleCodingAgentMethod( if (!snapshot) return { ok: false, error: 'coding-agent-not-found' } return { ok: true, - result: { ...snapshot, recentOutput: terminalText(snapshot.panelId) }, + result: { + ...compactCodingAgentSnapshot(snapshot), + recentOutput: terminalText(snapshot.panelId), + }, } } @@ -251,7 +348,8 @@ export async function handleCodingAgentMethod( ...run, followUps: [...(run.followUps ?? []), { prompt, sentAt: Date.now() }], }) - return { ok: true, result: codingAgentSnapshot(workspaceId, runId) } + const snapshot = codingAgentSnapshot(workspaceId, runId) + return { ok: true, result: snapshot ? compactCodingAgentSnapshot(snapshot) : null } } if (name === 'stop') { @@ -263,24 +361,16 @@ export async function handleCodingAgentMethod( ...run, stoppedAt: Date.now(), }) - return { ok: true, result: codingAgentSnapshot(workspaceId, runId) } + const snapshot = codingAgentSnapshot(workspaceId, runId) + return { ok: true, result: snapshot ? compactCodingAgentSnapshot(snapshot) : null } } if (name === 'wait') { - const timeout = Math.max(0, Math.min(8_000, Number(args.timeoutSeconds ?? 4) * 1_000)) - const startedAt = Date.now() - while (true) { - const snapshots = findRequestedRuns(workspaceId, args.runIds) - if ('error' in snapshots) return { ok: false, error: snapshots.error } - const settled = snapshots.some((run) => - run.status === 'ready' || run.status === 'waiting' || - run.status === 'stopped' || run.status === 'failed', - ) - if (settled || Date.now() - startedAt >= timeout) { - return { ok: true, result: { timedOut: !settled, runs: snapshots } } - } - await new Promise((resolve) => setTimeout(resolve, 200)) - } + return waitForCodingAgentChange( + workspaceId, + args.runIds, + codingAgentWaitMs(args.timeoutSeconds), + ) } return { ok: false, error: 'unsupported' } diff --git a/src/renderer/lib/agent/codingAgentWait.test.ts b/src/renderer/lib/agent/codingAgentWait.test.ts new file mode 100644 index 00000000..6b819b2b --- /dev/null +++ b/src/renderer/lib/agent/codingAgentWait.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import type { CodingAgentRunSnapshot } from '../../../shared/codingAgentRuns' +import { + actionableCodingAgentRunIds, + changedCodingAgentRunIds, + codingAgentWaitMs, + compactCodingAgentSnapshot, +} from './codingAgentWait' + +function run(id: string, status: CodingAgentRunSnapshot['status']): CodingAgentRunSnapshot { + return { + id, + status, + agentId: 'codex', + agentName: 'Codex', + panelId: `panel-${id}`, + prompt: 'Test', + createdAt: 1, + cwd: '/repo', + alive: true, + followUpSupported: true, + } +} + +describe('coding-agent wait policy', () => { + it('uses a long default while bounding explicit waits', () => { + expect(codingAgentWaitMs(undefined)).toBe(60_000) + expect(codingAgentWaitMs(1)).toBe(15_000) + expect(codingAgentWaitMs(45)).toBe(45_000) + expect(codingAgentWaitMs(999)).toBe(120_000) + }) + + it('wakes for actionable current states and meaningful transitions', () => { + expect(actionableCodingAgentRunIds([ + run('working', 'working'), + run('blocked', 'waiting'), + run('done', 'ready'), + ])).toEqual(['blocked', 'done']) + + const baseline = new Map([ + ['starting', 'starting'], + ['steady', 'working'], + ]) + expect(changedCodingAgentRunIds(baseline, [ + run('starting', 'working'), + run('steady', 'waiting'), + ])).toEqual(['steady']) + }) + + it('keeps routine results free of repeated prompts and follow-up history', () => { + const snapshot = { + ...run('worker', 'working'), + prompt: 'A very long task the supervisor already sent', + followUps: [{ prompt: 'Another long prompt', sentAt: 2 }], + statusLine: 'Running tests', + } + + expect(compactCodingAgentSnapshot(snapshot)).toEqual({ + id: 'worker', + agentId: 'codex', + agentName: 'Codex', + panelId: 'panel-worker', + status: 'working', + cwd: '/repo', + alive: true, + followUpSupported: true, + statusLine: 'Running tests', + }) + }) +}) diff --git a/src/renderer/lib/agent/codingAgentWait.ts b/src/renderer/lib/agent/codingAgentWait.ts new file mode 100644 index 00000000..14b375c6 --- /dev/null +++ b/src/renderer/lib/agent/codingAgentWait.ts @@ -0,0 +1,59 @@ +import type { CodingAgentRunSnapshot } from '../../../shared/codingAgentRuns' + +export const DEFAULT_CODING_AGENT_WAIT_SECONDS = 60 +export const MIN_CODING_AGENT_WAIT_SECONDS = 15 +export const MAX_CODING_AGENT_WAIT_SECONDS = 120 + +/** Routine lifecycle calls return this compact view instead of replaying the + * original task and follow-up history into the supervisor's context. */ +export function compactCodingAgentSnapshot(snapshot: CodingAgentRunSnapshot) { + return { + id: snapshot.id, + agentId: snapshot.agentId, + agentName: snapshot.agentName, + panelId: snapshot.panelId, + status: snapshot.status, + cwd: snapshot.cwd, + alive: snapshot.alive, + followUpSupported: snapshot.followUpSupported, + ...(snapshot.worktreeId ? { worktreeId: snapshot.worktreeId } : {}), + ...(snapshot.statusLine ? { statusLine: snapshot.statusLine } : {}), + } +} + +export function codingAgentWaitMs(value: unknown): number { + const requested = Number(value ?? DEFAULT_CODING_AGENT_WAIT_SECONDS) + const seconds = Number.isFinite(requested) + ? Math.max(MIN_CODING_AGENT_WAIT_SECONDS, Math.min(MAX_CODING_AGENT_WAIT_SECONDS, requested)) + : DEFAULT_CODING_AGENT_WAIT_SECONDS + return seconds * 1_000 +} + +export function actionableCodingAgentRunIds(runs: CodingAgentRunSnapshot[]): string[] { + return runs + .filter((run) => + run.status === 'waiting' || + run.status === 'ready' || + run.status === 'stopped' || + run.status === 'failed', + ) + .map((run) => run.id) +} + +/** Only actionable status transitions should wake the supervisor. Terminal + * output changes continuously, and starting → working is normal progress; + * waking for either would recreate the token-heavy poll loop. */ +export function changedCodingAgentRunIds( + baseline: ReadonlyMap, + runs: CodingAgentRunSnapshot[], +): string[] { + return runs + .filter((run) => + baseline.get(run.id) !== run.status && + (run.status === 'waiting' || + run.status === 'ready' || + run.status === 'stopped' || + run.status === 'failed'), + ) + .map((run) => run.id) +} From 942bdad109f7c1652a77593bf5ebfe2b9b593d03 Mon Sep 17 00:00:00 2001 From: Anton Date: Sun, 26 Jul 2026 20:12:20 +0200 Subject: [PATCH 04/12] feat: structure orchestration tool details --- .../renderer/ChatCodingAgentCard.tsx | 22 +- .../ChatOrchestrationToolCard.test.tsx | 54 +++- .../renderer/ChatOrchestrationToolCard.tsx | 230 +++++++++++++++--- 3 files changed, 253 insertions(+), 53 deletions(-) diff --git a/src/cateAgent/renderer/ChatCodingAgentCard.tsx b/src/cateAgent/renderer/ChatCodingAgentCard.tsx index 63a22a8b..be756c84 100644 --- a/src/cateAgent/renderer/ChatCodingAgentCard.tsx +++ b/src/cateAgent/renderer/ChatCodingAgentCard.tsx @@ -5,7 +5,7 @@ import { useAppStore } from '../../renderer/stores/appStore' import { revealPanel } from '../../renderer/lib/workspace/panelReveal' import { useAgentTerminalStatus, agentStateLabel } from './useAgentTerminalStatus' import { codingAgentDisplayName, parseCodingAgentId } from '../../shared/codingAgentRuns' -import { prettyArgs } from './chatShared' +import { OrchestrationToolDetails } from './ChatOrchestrationToolCard' function resultObject(result: string | undefined): Record { if (!result) return {} @@ -68,30 +68,12 @@ export function CodingAgentCard({ msg, shimmer }: { msg: ToolMessage; shimmer?:
{expanded && (
-
-
- Input -
-
-              {prettyArgs(msg.args)}
-            
-
- {msg.result && ( -
-
- Output -
-
-                {msg.result}
-              
-
- )} + {terminalStatus.line && (
{terminalStatus.line}
)} - {msg.error &&
{msg.error}
}
)}
diff --git a/src/cateAgent/renderer/ChatOrchestrationToolCard.test.tsx b/src/cateAgent/renderer/ChatOrchestrationToolCard.test.tsx index 6846b6e5..b19e9a9a 100644 --- a/src/cateAgent/renderer/ChatOrchestrationToolCard.test.tsx +++ b/src/cateAgent/renderer/ChatOrchestrationToolCard.test.tsx @@ -74,7 +74,7 @@ describe('orchestration tool presentation', () => { }) }) - it('reveals the complete tool input and output from its collapsed summary', () => { + it('reveals structured tool input and output from its collapsed summary', () => { const msg = message( 'inspect_coding_agent', { runId: 'run-1' }, @@ -90,8 +90,56 @@ describe('orchestration tool presentation', () => { }) expect(host.textContent).toContain('Input') - expect(host.textContent).toContain('"runId": "run-1"') + expect(host.textContent).toContain('Run') + expect(host.textContent).toContain('run-1') expect(host.textContent).toContain('Output') - expect(host.textContent).toContain('"recentOutput": "All tests passed"') + expect(host.textContent).toContain('Codex') + expect(host.textContent).toContain('ready') + expect(host.textContent).toContain('Terminal output') + expect(host.textContent).toContain('All tests passed') + expect(host.textContent).not.toContain('"runId"') + expect(host.textContent).not.toContain('"recentOutput"') + }) + + it('renders legacy wait results without exposing their raw prompt payload', () => { + const msg = message( + 'wait_for_coding_agents', + { + runIds: ['fae0053c-7710-43cf-8767-66304ab47e91'], + timeoutSeconds: 8, + }, + { + timedOut: true, + runs: [{ + id: 'fae0053c-7710-43cf-8767-66304ab47e91', + agentId: 'codex', + panelId: '16dbc81e-ad5b-4ae2-b373-7df2861b26ae', + prompt: 'Mission: noisy legacy prompt that should stay hidden', + createdAt: 1_786_000_000_000, + status: 'working', + agentName: 'Codex', + cwd: '/repo', + alive: true, + followUpSupported: true, + statusLine: 'Running tests', + }], + }, + ) + act(() => root.render()) + + act(() => { + host.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(host.textContent).toContain('Targets') + expect(host.textContent).toContain('8 seconds') + expect(host.textContent).toContain('No meaningful state change before timeout') + expect(host.textContent).toContain('Codex') + expect(host.textContent).toContain('working') + expect(host.textContent).toContain('/repo') + expect(host.textContent).toContain('Running tests') + expect(host.textContent).not.toContain('Mission: noisy legacy prompt') + expect(host.textContent).not.toContain('"timedOut"') + expect(host.textContent).not.toContain('"prompt"') }) }) diff --git a/src/cateAgent/renderer/ChatOrchestrationToolCard.tsx b/src/cateAgent/renderer/ChatOrchestrationToolCard.tsx index abede2e8..ee27fc5a 100644 --- a/src/cateAgent/renderer/ChatOrchestrationToolCard.tsx +++ b/src/cateAgent/renderer/ChatOrchestrationToolCard.tsx @@ -1,6 +1,9 @@ import { useMemo, useState } from 'react' import type { ToolMessage } from './codingStore' -import { prettyArgs } from './chatShared' +import { + codingAgentDisplayName, + parseCodingAgentId, +} from '../../shared/codingAgentRuns' export const ORCHESTRATION_TOOL_NAMES: ReadonlySet = new Set([ 'send_to_coding_agent', @@ -30,6 +33,199 @@ function runLabel(args: Record, result: Record return runId ? `agent ${runId.slice(0, 8)}` : 'coding agent' } +function text(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function agentLabel(value: unknown): string { + const agentId = parseCodingAgentId(value) + return agentId ? codingAgentDisplayName(agentId) : text(value) || 'Coding agent' +} + +function DetailField({ + label, + value, + mono = false, +}: { + label: string + value: string + mono?: boolean +}) { + if (!value) return null + return ( +
+ {label} + + {value} + +
+ ) +} + +function RunDetails({ run }: { run: Record }) { + const agent = text(run.agentName) || agentLabel(run.agentId) + return ( +
+
+ {agent} + {typeof run.status === 'string' && ( + + {run.status} + + )} +
+ + + + +
+ ) +} + +function InputDetails({ + name, + args, +}: { + name: string + args: Record +}) { + if (name === 'create_coding_agent') { + return ( + <> + + + + + + ) + } + if (name === 'send_to_coding_agent') { + return ( + <> + + + + ) + } + if (name === 'wait_for_coding_agents') { + const runIds = Array.isArray(args.runIds) + ? args.runIds.filter((id): id is string => typeof id === 'string') + : [] + return ( + <> + 0 ? runIds.join('\n') : 'All active coding agents'} + mono={runIds.length > 0} + /> + + + ) + } + return +} + +function OutputDetails({ + name, + result, + rawResult, +}: { + name: string + result: Record + rawResult?: string +}) { + if (name === 'wait_for_coding_agents') { + const runs = Array.isArray(result.runs) + ? result.runs.filter((run): run is Record => + !!run && typeof run === 'object') + : [] + const changed = Array.isArray(result.changedRunIds) + ? result.changedRunIds.filter((id) => typeof id === 'string').length + : 0 + return ( + <> + 0 + ? `${changed} coding agent${changed === 1 ? '' : 's'} need attention` + : 'No active coding agents' + } + /> +
+ {runs.map((run, index) => ( + + ))} +
+ + ) + } + if (Object.keys(result).length > 0) { + return ( + <> + + {typeof result.recentOutput === 'string' && result.recentOutput && ( +
+
Terminal output
+
+              {result.recentOutput}
+            
+
+ )} + + ) + } + return rawResult ? ( +
+ {rawResult} +
+ ) : null +} + +export function OrchestrationToolDetails({ msg }: { msg: ToolMessage }) { + const args = (msg.args ?? {}) as Record + const result = resultObject(msg.result) + const parsed = Object.keys(result).length > 0 + return ( +
+
+
+ Input +
+
+ +
+
+ {(msg.result || msg.error) && ( +
+
+ Output +
+
+ + {msg.error &&
{msg.error}
} +
+
+ )} +
+ ) +} + export function orchestrationToolSummary(msg: ToolMessage): { verb: string; detail: string } { const args = (msg.args ?? {}) as Record const result = resultObject(msg.result) @@ -96,10 +292,8 @@ export function OrchestrationToolCard({ }) { const [expanded, setExpanded] = useState(false) const summary = useMemo(() => orchestrationToolSummary(msg), [msg]) - const liveOutput = msg.status === 'running' ? msg.partialText : undefined - const output = liveOutput ?? msg.result const running = msg.status === 'running' || msg.status === 'pending' - const hasDetails = msg.args != null || !!output || !!msg.error + const hasDetails = msg.args != null || !!msg.result || !!msg.error return (
@@ -113,32 +307,8 @@ export function OrchestrationToolCard({ {summary.detail} {expanded && hasDetails && ( -
- {msg.args != null && ( -
-
- Input -
-
-                {prettyArgs(msg.args)}
-              
-
- )} - {output && ( -
-
- Output -
-
-                {output}
-              
-
- )} - {msg.error && ( -
-              {msg.error}
-            
- )} +
+
)}
From 6e273a32d3ad403218259ed808363836e8e6c690 Mon Sep 17 00:00:00 2001 From: Anton Date: Sun, 26 Jul 2026 20:27:52 +0200 Subject: [PATCH 05/12] refactor: flatten coding agent activity UI --- .../renderer/ChatCodingAgentCard.test.tsx | 88 +++++++++++++++++++ .../renderer/ChatCodingAgentCard.tsx | 80 ++++++++++++----- .../ChatOrchestrationToolCard.test.tsx | 2 + .../renderer/ChatOrchestrationToolCard.tsx | 23 +++-- 4 files changed, 158 insertions(+), 35 deletions(-) create mode 100644 src/cateAgent/renderer/ChatCodingAgentCard.test.tsx diff --git a/src/cateAgent/renderer/ChatCodingAgentCard.test.tsx b/src/cateAgent/renderer/ChatCodingAgentCard.test.tsx new file mode 100644 index 00000000..29e56fa7 --- /dev/null +++ b/src/cateAgent/renderer/ChatCodingAgentCard.test.tsx @@ -0,0 +1,88 @@ +import React, { act } from 'react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createRoot, type Root } from 'react-dom/client' +import type { ToolMessage } from './codingStore' +import { useAppStore } from '../../renderer/stores/appStore' +import { CodingAgentCard } from './ChatCodingAgentCard' + +;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const initialAppState = useAppStore.getState() + +function message(): ToolMessage { + return { + type: 'tool', + id: 'create-message', + toolCallId: 'create-call', + name: 'create_coding_agent', + args: { + agentId: 'codex', + prompt: 'Make the default test command deterministic', + newWorktree: 'dx/deterministic-tests', + }, + status: 'success', + result: JSON.stringify({ + id: 'run-1', + panelId: 'panel-1', + agentId: 'codex', + agentName: 'Codex', + status: 'starting', + cwd: '/repo', + worktreeId: 'worktree-1', + }), + } +} + +describe('coding agent launch presentation', () => { + let host: HTMLDivElement + let root: Root + + beforeEach(() => { + useAppStore.setState({ + workspaces: [{ + id: 'workspace-1', + panels: { + 'panel-1': { + id: 'panel-1', + type: 'terminal', + title: 'Codex 3', + }, + }, + }], + selectedWorkspaceId: 'workspace-1', + } as never) + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + useAppStore.setState(initialAppState, true) + }) + + it('renders a flat Cate row with a tab-style terminal chip', () => { + act(() => root.render()) + + const row = host.querySelector('[data-tool-name="create_coding_agent"]')! + const terminalLink = host.querySelector('[data-coding-agent-terminal-link]')! + + expect(row.className).not.toContain('border') + expect(row.className).not.toContain('rounded') + expect(row.className).not.toContain('bg-surface') + expect(host.querySelector('[aria-label="Cate"]')).not.toBeNull() + expect(terminalLink.textContent).toContain('Codex') + expect(terminalLink.className).toContain('rounded-[10px]') + expect(host.textContent).toContain('Make the default test command deterministic') + + act(() => { + host.querySelector('[aria-label="Show coding agent details"]') + ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(host.textContent).toContain('Input') + expect(host.textContent).toContain('Output') + expect(host.querySelector('.rounded-md')).toBeNull() + }) +}) diff --git a/src/cateAgent/renderer/ChatCodingAgentCard.tsx b/src/cateAgent/renderer/ChatCodingAgentCard.tsx index be756c84..56d8741a 100644 --- a/src/cateAgent/renderer/ChatCodingAgentCard.tsx +++ b/src/cateAgent/renderer/ChatCodingAgentCard.tsx @@ -1,10 +1,11 @@ import { useMemo, useState } from 'react' -import { ArrowSquareOut, Robot } from '@phosphor-icons/react' import type { ToolMessage } from './codingStore' import { useAppStore } from '../../renderer/stores/appStore' import { revealPanel } from '../../renderer/lib/workspace/panelReveal' import { useAgentTerminalStatus, agentStateLabel } from './useAgentTerminalStatus' import { codingAgentDisplayName, parseCodingAgentId } from '../../shared/codingAgentRuns' +import { getAgentLogoById } from '../../renderer/lib/agent/agentLogos' +import { CateLogo } from '../../renderer/ui/CateLogo' import { OrchestrationToolDetails } from './ChatOrchestrationToolCard' function resultObject(result: string | undefined): Record { @@ -24,6 +25,7 @@ export function CodingAgentCard({ msg, shimmer }: { msg: ToolMessage; shimmer?: const args = (msg.args ?? {}) as Record const panelId = typeof result.panelId === 'string' ? result.panelId : '' const agentId = parseCodingAgentId(result.agentId ?? args.agentId) + const agentLogo = getAgentLogoById(agentId) const prompt = typeof args.prompt === 'string' ? args.prompt : 'Coding task' const workspaceId = useAppStore((state) => state.workspaces.find((ws) => panelId && ws.panels[panelId])?.id ?? '', @@ -35,39 +37,71 @@ export function CodingAgentCard({ msg, shimmer }: { msg: ToolMessage; shimmer?: const status = panelId ? agentStateLabel(terminalStatus.agentState) : running ? 'Starting…' : msg.error ? 'Failed' : 'Created' + const canOpenTerminal = Boolean(panelId && workspaceId) + const statusColor = msg.error + ? 'bg-danger' + : terminalStatus.agentState === 'waitingForInput' + ? 'bg-warning' + : terminalStatus.agentState === 'finished' + ? 'bg-[#34C759]' + : terminalStatus.agentState === 'running' + ? 'bg-accent' + : 'bg-muted' return (
-
- +
+ + - {panelId && workspaceId && ( - - )}
{expanded && ( -
+
{terminalStatus.line && (
diff --git a/src/cateAgent/renderer/ChatOrchestrationToolCard.test.tsx b/src/cateAgent/renderer/ChatOrchestrationToolCard.test.tsx index b19e9a9a..85e64748 100644 --- a/src/cateAgent/renderer/ChatOrchestrationToolCard.test.tsx +++ b/src/cateAgent/renderer/ChatOrchestrationToolCard.test.tsx @@ -99,6 +99,7 @@ describe('orchestration tool presentation', () => { expect(host.textContent).toContain('All tests passed') expect(host.textContent).not.toContain('"runId"') expect(host.textContent).not.toContain('"recentOutput"') + expect(host.querySelector('.rounded-md')).toBeNull() }) it('renders legacy wait results without exposing their raw prompt payload', () => { @@ -141,5 +142,6 @@ describe('orchestration tool presentation', () => { expect(host.textContent).not.toContain('Mission: noisy legacy prompt') expect(host.textContent).not.toContain('"timedOut"') expect(host.textContent).not.toContain('"prompt"') + expect(host.querySelector('.rounded-md')).toBeNull() }) }) diff --git a/src/cateAgent/renderer/ChatOrchestrationToolCard.tsx b/src/cateAgent/renderer/ChatOrchestrationToolCard.tsx index ee27fc5a..e55abd19 100644 --- a/src/cateAgent/renderer/ChatOrchestrationToolCard.tsx +++ b/src/cateAgent/renderer/ChatOrchestrationToolCard.tsx @@ -67,19 +67,13 @@ function DetailField({ function RunDetails({ run }: { run: Record }) { const agent = text(run.agentName) || agentLabel(run.agentId) return ( -
-
- {agent} - {typeof run.status === 'string' && ( - - {run.status} - - )} -
+
+ + - +
) } @@ -163,9 +157,14 @@ function OutputDetails({ : 'No active coding agents' } /> -
+
{runs.map((run, index) => ( - +
0 ? 'mt-2 border-t border-strong/40 pt-2' : ''} + > + +
))}
From 10d292ab63f6cc0381a0aacd6d766a152d3b530e Mon Sep 17 00:00:00 2001 From: Anton Date: Sun, 26 Jul 2026 20:37:23 +0200 Subject: [PATCH 06/12] feat: gate agent orchestration behind prompt mode --- .../cate-orchestrator/index.test.ts | 74 ++++++++++++++++--- .../extensions/cate-orchestrator/index.ts | 62 ++++++++++++++++ src/cateAgent/renderer/useCodingChat.ts | 13 +++- .../chat/ChatComposer.sendStop.test.tsx | 17 +++++ src/renderer/chat/ChatComposer.tsx | 29 +++++++- 5 files changed, 177 insertions(+), 18 deletions(-) diff --git a/src/cateAgent/extensions/cate-orchestrator/index.test.ts b/src/cateAgent/extensions/cate-orchestrator/index.test.ts index 30380c2a..985a3c00 100644 --- a/src/cateAgent/extensions/cate-orchestrator/index.test.ts +++ b/src/cateAgent/extensions/cate-orchestrator/index.test.ts @@ -1,12 +1,36 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import registerOrchestrator from "./index" -function registeredTools() { +const TOOL_NAMES = [ + "create_coding_agent", + "send_to_coding_agent", + "wait_for_coding_agents", + "inspect_coding_agent", + "stop_coding_agent", +] + +function makeApi() { const tools = new Map() - registerOrchestrator({ - registerTool: (tool: any) => tools.set(tool.name, tool), - } as any) - return tools + const commands = new Map() + const handlers = new Map Promise>() + let activeTools = ["read", "bash"] + const pi = { + registerTool: (tool: any) => { + tools.set(tool.name, tool) + activeTools = [...activeTools, tool.name] + }, + registerCommand: (name: string, command: any) => commands.set(name, command), + on: (event: string, handler: (value: any) => Promise) => + handlers.set(event, handler), + getActiveTools: () => [...activeTools], + setActiveTools: (names: string[]) => { activeTools = [...names] }, + } + registerOrchestrator(pi as any) + return { tools, commands, handlers, getActiveTools: () => [...activeTools] } +} + +function registeredTools() { + return makeApi().tools } beforeEach(() => { @@ -18,17 +42,43 @@ beforeEach(() => { describe("cate-orchestrator", () => { it("registers the complete worker lifecycle surface", () => { const tools = registeredTools() - expect([...tools.keys()]).toEqual([ - "create_coding_agent", - "send_to_coding_agent", - "wait_for_coding_agents", - "inspect_coding_agent", - "stop_coding_agent", - ]) + expect([...tools.keys()]).toEqual(TOOL_NAMES) expect(tools.get("wait_for_coding_agents").parameters.properties.timeoutSeconds) .toMatchObject({ minimum: 15, maximum: 120, default: 60 }) }) + it("keeps orchestration tools inactive until orchestration mode is enabled", async () => { + const api = makeApi() + const setStatus = vi.fn() + const ctx = { ui: { setStatus } } + + expect(api.getActiveTools()).toEqual(["read", "bash"]) + expect(await api.handlers.get("before_agent_start")!({ systemPrompt: "base" })) + .toBeUndefined() + expect(await api.handlers.get("tool_call")!({ toolName: "create_coding_agent" })) + .toEqual({ + block: true, + reason: "Coding-agent orchestration mode is not active.", + }) + + await api.commands.get("orchestrate").handler("", ctx) + + expect(setStatus).toHaveBeenCalledWith("orchestrator-mode", "Orchestration mode") + expect(api.getActiveTools()).toEqual(["read", "bash", ...TOOL_NAMES]) + const prompt = await api.handlers.get("before_agent_start")!({ systemPrompt: "base" }) + expect(prompt.systemPrompt).toContain("Orchestration mode is ACTIVE") + expect(prompt.systemPrompt).toContain("Act as the mission lead") + expect(await api.handlers.get("tool_call")!({ toolName: "create_coding_agent" })) + .toBeUndefined() + + await api.commands.get("orchestrate").handler("", ctx) + + expect(setStatus).toHaveBeenLastCalledWith("orchestrator-mode", undefined) + expect(api.getActiveTools()).toEqual(["read", "bash"]) + expect(await api.handlers.get("before_agent_start")!({ systemPrompt: "base" })) + .toBeUndefined() + }) + it("asks once per session before creating workers and invokes the scoped API", async () => { const fetch = vi.fn(async (_url: string, init: RequestInit) => ({ ok: true, diff --git a/src/cateAgent/extensions/cate-orchestrator/index.ts b/src/cateAgent/extensions/cate-orchestrator/index.ts index a9351c9a..3c1c7e04 100644 --- a/src/cateAgent/extensions/cate-orchestrator/index.ts +++ b/src/cateAgent/extensions/cate-orchestrator/index.ts @@ -5,6 +5,26 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { Type } from "typebox" +const STATUS_KEY = "orchestrator-mode" +const TOOL_NAMES = [ + "create_coding_agent", + "send_to_coding_agent", + "wait_for_coding_agents", + "inspect_coding_agent", + "stop_coding_agent", +] as const +const TOOL_NAME_SET: ReadonlySet = new Set(TOOL_NAMES) + +const ORCHESTRATOR_PROMPT = ` + +Orchestration mode is ACTIVE. Act as the mission lead for the user's coding +task. Create coding agents only for bounded work that benefits from delegation, +give each one a self-contained prompt and isolated worktree when appropriate, +then supervise, steer, inspect, and verify their results. You retain ownership +of architecture, integration, and the final answer. + +`.trim() + const AgentId = Type.Union([ Type.Literal("claude-code"), Type.Literal("codex"), @@ -52,6 +72,21 @@ export default function (pi: ExtensionAPI) { // asks again, keeping autonomous spend visible without interrupting every // individual worker in an approved mission. let delegationApproved: boolean | null = null + let active = false + + const setMode = ( + enabled: boolean, + ctx: { ui: { setStatus: (key: string, value: string | undefined) => void } }, + ): void => { + active = enabled + const current = pi.getActiveTools() + pi.setActiveTools( + enabled + ? [...new Set([...current, ...TOOL_NAMES])] + : current.filter((name) => !TOOL_NAME_SET.has(name)), + ) + ctx.ui.setStatus(STATUS_KEY, enabled ? "Orchestration mode" : undefined) + } pi.registerTool({ name: "create_coding_agent", @@ -146,4 +181,31 @@ export default function (pi: ExtensionAPI) { return toolResult(await invoke("cate.codingAgent.stop", params, signal)) }, }) + + pi.registerCommand("orchestrate", { + description: "Toggle coding-agent orchestration mode.", + handler: async (_args, ctx) => { + setMode(!active, ctx) + }, + }) + + pi.on("before_agent_start", async (event) => { + if (!active) return + return { systemPrompt: `${event.systemPrompt}\n\n${ORCHESTRATOR_PROMPT}` } + }) + + // Inactive tools are absent from the provider payload. This hook is a + // defensive backstop for a stale tool call already emitted while mode changed. + pi.on("tool_call", async (event) => { + if (!active && TOOL_NAME_SET.has(event.toolName)) { + return { + block: true, + reason: "Coding-agent orchestration mode is not active.", + } + } + }) + + // registerTool activates extension tools by default. Keep them out of normal + // Cate conversations until the user chooses the mode from the composer. + pi.setActiveTools(pi.getActiveTools().filter((name) => !TOOL_NAME_SET.has(name))) } diff --git a/src/cateAgent/renderer/useCodingChat.ts b/src/cateAgent/renderer/useCodingChat.ts index 72cdd935..d62d541a 100644 --- a/src/cateAgent/renderer/useCodingChat.ts +++ b/src/cateAgent/renderer/useCodingChat.ts @@ -143,8 +143,17 @@ export function useCodingChat({ const extensionWidgets = slice?.extensionWidgets ?? [] const planModeActive = extensionStatuses.some((status) => status.key === 'plan-mode') const canvasModeActive = extensionStatuses.some((status) => status.key === 'canvas-mode') + const orchestratorModeActive = extensionStatuses.some( + (status) => status.key === 'orchestrator-mode', + ) const activePromptMode: ComposerPromptMode | null = - planModeActive ? 'plan' : canvasModeActive ? 'canvas' : null + planModeActive + ? 'plan' + : canvasModeActive + ? 'canvas' + : orchestratorModeActive + ? 'orchestrate' + : null // Composer draft lives in the active chat's slice so switching chats keeps // each chat's own in-progress message + image attachments. const draft = slice?.draft ?? '' @@ -448,7 +457,7 @@ export function useCodingChat({ }, [agentKey, forkMap, refreshStatsAndState, setDraft]) // --------------------------------------------------------------------------- - // Prompt modes (cate-plan-mode / cate-canvas-mode extensions) + // Prompt modes (plan, canvas, and coding-agent orchestration extensions) // --------------------------------------------------------------------------- const handlePromptModeChange = useCallback(async (mode: ComposerPromptMode | null) => { diff --git a/src/renderer/chat/ChatComposer.sendStop.test.tsx b/src/renderer/chat/ChatComposer.sendStop.test.tsx index 096fd618..6016b9b4 100644 --- a/src/renderer/chat/ChatComposer.sendStop.test.tsx +++ b/src/renderer/chat/ChatComposer.sendStop.test.tsx @@ -132,6 +132,23 @@ describe('ChatComposer send/stop', () => { expect(host.textContent).toContain('Manage canvas') }) + it('selects orchestration mode from the plus menu and renders it inline', () => { + const onPromptModeChange = vi.fn() + renderComposer({ onPromptModeChange }) + + act(() => button('Add prompt mode')?.click()) + const orchestrate = Array.from(document.body.querySelectorAll('button')) + .find((candidate) => candidate.textContent?.includes('Orchestrate agents')) as HTMLButtonElement + expect(orchestrate).toBeTruthy() + + act(() => orchestrate.click()) + expect(onPromptModeChange).toHaveBeenCalledWith('orchestrate') + + renderComposer({ promptMode: 'orchestrate', onPromptModeChange }) + expect(button('Remove Orchestrate agents mode')).toBeTruthy() + expect(host.textContent).toContain('Orchestrate agents') + }) + }) describe('ChatComposer popovers', () => { diff --git a/src/renderer/chat/ChatComposer.tsx b/src/renderer/chat/ChatComposer.tsx index bb57cb4b..a9f39284 100644 --- a/src/renderer/chat/ChatComposer.tsx +++ b/src/renderer/chat/ChatComposer.tsx @@ -28,6 +28,7 @@ import { Plus, ClipboardText, BoundingBox, + GitBranch, X, Spinner, ArrowsClockwise, @@ -57,10 +58,13 @@ import type { const MAX_HEIGHT = 160 export type ModelOption = { provider: string; model: string; label?: string } -export type ComposerPromptMode = 'plan' | 'canvas' +export type ComposerPromptMode = 'plan' | 'canvas' | 'orchestrate' -const promptModeLabel = (mode: ComposerPromptMode): string => - mode === 'plan' ? 'Create plan' : 'Manage canvas' +const promptModeLabel = (mode: ComposerPromptMode): string => { + if (mode === 'plan') return 'Create plan' + if (mode === 'canvas') return 'Manage canvas' + return 'Orchestrate agents' +} const worktreeLabel = (wt: JoinedWorktree | undefined): string => wt?.label || wt?.branch || (wt?.isPrimary ? 'main' : 'worktree') @@ -434,7 +438,11 @@ export const ChatComposer: React.FC = ({ aria-label={`Remove ${promptModeLabel(promptMode)} mode`} title="Remove prompt mode" > - {promptMode === 'plan' ? : } + {promptMode === 'plan' + ? + : promptMode === 'canvas' + ? + : } {promptModeLabel(promptMode)} @@ -590,6 +598,19 @@ export const ChatComposer: React.FC = ({ Control Cate panels with the Cate CLI + { + onPromptModeChange(promptMode === 'orchestrate' ? null : 'orchestrate') + setPromptModeOpen(false) + }} + > + + + Orchestrate agents + Create and supervise coding agents + +
)} From 8809086bdb82f92a46679d489ef39c35ad4fbebb Mon Sep 17 00:00:00 2001 From: Anton Date: Sun, 26 Jul 2026 20:41:28 +0200 Subject: [PATCH 07/12] fix: keep orchestration mode activation idle --- .../cate-orchestrator/index.test.ts | 8 ++++++-- .../extensions/cate-orchestrator/index.ts | 20 +++++++++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/cateAgent/extensions/cate-orchestrator/index.test.ts b/src/cateAgent/extensions/cate-orchestrator/index.test.ts index 985a3c00..c4581001 100644 --- a/src/cateAgent/extensions/cate-orchestrator/index.test.ts +++ b/src/cateAgent/extensions/cate-orchestrator/index.test.ts @@ -64,8 +64,11 @@ describe("cate-orchestrator", () => { await api.commands.get("orchestrate").handler("", ctx) expect(setStatus).toHaveBeenCalledWith("orchestrator-mode", "Orchestration mode") - expect(api.getActiveTools()).toEqual(["read", "bash", ...TOOL_NAMES]) + // The command only flips mode state. Tool changes happen at the next real + // prompt so selecting the mode cannot itself start the agent loop. + expect(api.getActiveTools()).toEqual(["read", "bash"]) const prompt = await api.handlers.get("before_agent_start")!({ systemPrompt: "base" }) + expect(api.getActiveTools()).toEqual(["read", "bash", ...TOOL_NAMES]) expect(prompt.systemPrompt).toContain("Orchestration mode is ACTIVE") expect(prompt.systemPrompt).toContain("Act as the mission lead") expect(await api.handlers.get("tool_call")!({ toolName: "create_coding_agent" })) @@ -74,9 +77,10 @@ describe("cate-orchestrator", () => { await api.commands.get("orchestrate").handler("", ctx) expect(setStatus).toHaveBeenLastCalledWith("orchestrator-mode", undefined) - expect(api.getActiveTools()).toEqual(["read", "bash"]) + expect(api.getActiveTools()).toEqual(["read", "bash", ...TOOL_NAMES]) expect(await api.handlers.get("before_agent_start")!({ systemPrompt: "base" })) .toBeUndefined() + expect(api.getActiveTools()).toEqual(["read", "bash"]) }) it("asks once per session before creating workers and invokes the scoped API", async () => { diff --git a/src/cateAgent/extensions/cate-orchestrator/index.ts b/src/cateAgent/extensions/cate-orchestrator/index.ts index 3c1c7e04..b3fcc05d 100644 --- a/src/cateAgent/extensions/cate-orchestrator/index.ts +++ b/src/cateAgent/extensions/cate-orchestrator/index.ts @@ -79,15 +79,20 @@ export default function (pi: ExtensionAPI) { ctx: { ui: { setStatus: (key: string, value: string | undefined) => void } }, ): void => { active = enabled - const current = pi.getActiveTools() - pi.setActiveTools( - enabled - ? [...new Set([...current, ...TOOL_NAMES])] - : current.filter((name) => !TOOL_NAME_SET.has(name)), - ) ctx.ui.setStatus(STATUS_KEY, enabled ? "Orchestration mode" : undefined) } + const syncActiveTools = (): void => { + const current = pi.getActiveTools() + const next = active + ? [...new Set([...current, ...TOOL_NAMES])] + : current.filter((name) => !TOOL_NAME_SET.has(name)) + if (next.length === current.length && next.every((name, index) => name === current[index])) { + return + } + pi.setActiveTools(next) + } + pi.registerTool({ name: "create_coding_agent", label: "Create coding agent", @@ -190,6 +195,9 @@ export default function (pi: ExtensionAPI) { }) pi.on("before_agent_start", async (event) => { + // Changing Pi's active tools inside the /orchestrate command can resume the + // agent loop. Defer that rebuild until a real user prompt is about to start. + syncActiveTools() if (!active) return return { systemPrompt: `${event.systemPrompt}\n\n${ORCHESTRATOR_PROMPT}` } }) From 9e8a26ae6f3046d4c8566c74bf3610c7a5c8b6ef Mon Sep 17 00:00:00 2001 From: Anton Date: Sun, 26 Jul 2026 20:54:15 +0200 Subject: [PATCH 08/12] fix: initialize orchestration safely and sanitize errors --- .../cate-orchestrator/index.test.ts | 18 +++++- .../extensions/cate-orchestrator/index.ts | 8 ++- src/cateAgent/main/codingManager.test.ts | 50 +++++++++++++++++ src/cateAgent/main/codingManager.ts | 15 ++++- src/cateAgent/renderer/codingStore.ts | 16 ++++-- src/cateAgent/renderer/directChatSession.ts | 10 +++- src/cateAgent/renderer/useCodingChat.ts | 2 +- src/shared/agentErrorMessage.test.ts | 31 ++++++++++ src/shared/agentErrorMessage.ts | 56 +++++++++++++++++++ 9 files changed, 190 insertions(+), 16 deletions(-) create mode 100644 src/shared/agentErrorMessage.test.ts create mode 100644 src/shared/agentErrorMessage.ts diff --git a/src/cateAgent/extensions/cate-orchestrator/index.test.ts b/src/cateAgent/extensions/cate-orchestrator/index.test.ts index c4581001..fe07c66a 100644 --- a/src/cateAgent/extensions/cate-orchestrator/index.test.ts +++ b/src/cateAgent/extensions/cate-orchestrator/index.test.ts @@ -14,6 +14,7 @@ function makeApi() { const commands = new Map() const handlers = new Map Promise>() let activeTools = ["read", "bash"] + let setActiveToolsCalls = 0 const pi = { registerTool: (tool: any) => { tools.set(tool.name, tool) @@ -23,10 +24,19 @@ function makeApi() { on: (event: string, handler: (value: any) => Promise) => handlers.set(event, handler), getActiveTools: () => [...activeTools], - setActiveTools: (names: string[]) => { activeTools = [...names] }, + setActiveTools: (names: string[]) => { + setActiveToolsCalls += 1 + activeTools = [...names] + }, } registerOrchestrator(pi as any) - return { tools, commands, handlers, getActiveTools: () => [...activeTools] } + return { + tools, + commands, + handlers, + getActiveTools: () => [...activeTools], + getSetActiveToolsCalls: () => setActiveToolsCalls, + } } function registeredTools() { @@ -52,7 +62,11 @@ describe("cate-orchestrator", () => { const setStatus = vi.fn() const ctx = { ui: { setStatus } } + expect(api.getSetActiveToolsCalls()).toBe(0) + expect(api.getActiveTools()).toEqual(["read", "bash", ...TOOL_NAMES]) + await api.handlers.get("session_start")!({}) expect(api.getActiveTools()).toEqual(["read", "bash"]) + expect(api.getSetActiveToolsCalls()).toBe(1) expect(await api.handlers.get("before_agent_start")!({ systemPrompt: "base" })) .toBeUndefined() expect(await api.handlers.get("tool_call")!({ toolName: "create_coding_agent" })) diff --git a/src/cateAgent/extensions/cate-orchestrator/index.ts b/src/cateAgent/extensions/cate-orchestrator/index.ts index b3fcc05d..987209da 100644 --- a/src/cateAgent/extensions/cate-orchestrator/index.ts +++ b/src/cateAgent/extensions/cate-orchestrator/index.ts @@ -213,7 +213,9 @@ export default function (pi: ExtensionAPI) { } }) - // registerTool activates extension tools by default. Keep them out of normal - // Cate conversations until the user chooses the mode from the composer. - pi.setActiveTools(pi.getActiveTools().filter((name) => !TOOL_NAME_SET.has(name))) + // Extension loading happens before Pi's runtime action methods are available, + // so the initial gate belongs in session_start rather than module setup. + pi.on("session_start", async () => { + syncActiveTools() + }) } diff --git a/src/cateAgent/main/codingManager.test.ts b/src/cateAgent/main/codingManager.test.ts index f3e351e5..847ca40a 100644 --- a/src/cateAgent/main/codingManager.test.ts +++ b/src/cateAgent/main/codingManager.test.ts @@ -42,6 +42,7 @@ import { runtimes } from '../../main/runtime/runtimeManager' import { PiRpcClient } from './piRpcClient' import { prepareCodingDir } from './codingDir' import { syncWorkspaceSkills } from '../../skills/main/skillsMirror' +import { CODING_EVENT } from '../../shared/ipc-channels' const fakeAuthManager = { setOnChange: vi.fn() } as unknown as AuthManager @@ -82,6 +83,55 @@ describe('CodingManager worktree skill preparation', () => { ) expect(client.start).toHaveBeenCalledOnce() }) + + it('logs process diagnostics but sends only a bounded error to the panel', async () => { + const runtime = { agent: { ensurePi: vi.fn().mockResolvedValue(undefined) } } + vi.mocked(runtimes.resolve).mockReturnValue(runtime as never) + let exitListener: ((code: number | null, stderr: string) => void) | undefined + const client = { + start: vi.fn().mockResolvedValue(undefined), + onEvent: vi.fn(() => vi.fn()), + onExit: vi.fn((listener: typeof exitListener) => { + exitListener = listener + return vi.fn() + }), + } + vi.mocked(PiRpcClient).mockImplementation(() => client as never) + vi.mocked(syncWorkspaceSkills).mockResolvedValue({ + copied: [], + updated: [], + removed: [], + preserved: [], + warnings: [], + }) + const send = vi.fn() + const manager = new CodingManager(fakeAuthManager) + + await manager.create( + { + panelId: 'panel-crash', + workspaceId: 'workspace-1', + workspaceRoot: '/repo/base', + cwd: '/repo/worktree', + }, + { id: 4, isDestroyed: () => false, send } as never, + ) + exitListener?.( + 1, + 'Failed to load extension "/Users/anton/Dev/repo/private-extension.ts": ' + + 'Extension runtime not initialized.', + ) + + expect(send).toHaveBeenCalledWith(CODING_EVENT, { + panelId: 'panel-crash', + event: { + type: 'error', + message: 'Cate couldn’t load its agent tools. Restart Cate and start a new chat.', + }, + }) + expect(JSON.stringify(send.mock.calls)).not.toContain('/Users/') + expect(JSON.stringify(send.mock.calls)).not.toContain('Extension runtime') + }) }) function makeManager() { diff --git a/src/cateAgent/main/codingManager.ts b/src/cateAgent/main/codingManager.ts index b6759e3e..e735343a 100644 --- a/src/cateAgent/main/codingManager.ts +++ b/src/cateAgent/main/codingManager.ts @@ -50,6 +50,7 @@ import { getSetting } from '../../main/settingsFile' import { workspaceCateApi } from '../../main/extensions/workspaceCateApi' import { KeyedLock } from '../../main/keyedLock' import { agentMessageText, lastAssistantMessage } from '../../shared/agentMessages' +import { agentErrorMessage } from '../../shared/agentErrorMessage' interface AgentSession { panelId: string @@ -226,7 +227,11 @@ export class CodingManager { } catch (err) { const message = err instanceof Error ? err.message : String(err) log.warn('[codingManager] failed to start pi for %s: %s', opts.panelId, message) - this.sendErrorEvent(sender, opts.panelId, `Failed to start pi: ${message}`) + this.sendErrorEvent( + sender, + opts.panelId, + agentErrorMessage(message, 'Cate couldn’t start the agent. Start a new chat and try again.'), + ) throw err } @@ -249,7 +254,11 @@ export class CodingManager { const disposeExitWatcher = client.onExit((code, stderr) => { const reason = stderr ? `\n${stderr}` : '' log.warn('[codingManager] pi exited unexpectedly panel=%s code=%s%s', opts.panelId, code, reason) - this.sendErrorEvent(sender, opts.panelId, `Agent process exited (code ${code}).${reason}`) + this.sendErrorEvent( + sender, + opts.panelId, + agentErrorMessage(`Agent process exited (code ${code}).${reason}`), + ) }) // Watch the host's auth.json so OAuth token refreshes written by pi @@ -316,7 +325,7 @@ export class CodingManager { } catch (err) { const message = err instanceof Error ? err.message : String(err) log.warn('[codingManager] prompt failed for %s: %s', panelId, message) - this.sendErrorEvent(session.sender, panelId, message) + this.sendErrorEvent(session.sender, panelId, agentErrorMessage(message)) throw err } } diff --git a/src/cateAgent/renderer/codingStore.ts b/src/cateAgent/renderer/codingStore.ts index c5f779a3..066f3ba3 100644 --- a/src/cateAgent/renderer/codingStore.ts +++ b/src/cateAgent/renderer/codingStore.ts @@ -15,6 +15,7 @@ import { create } from 'zustand' import log from '../../renderer/lib/logger' +import { agentErrorMessage } from '../../shared/agentErrorMessage' import type { CodingExtensionUIRequest, CodingImageAttachment, @@ -897,7 +898,7 @@ export function handleCodingEvent(panelId: string, event: { type: string; [key: case 'compaction_end': { const reason = asString(event.reason) as CompactionState['reason'] const result = event.result as Record | null - const errorMessage = asString(event.errorMessage) + const rawError = asString(event.errorMessage) useCodingStore.getState().setCompaction(panelId, { active: false, reason, @@ -907,7 +908,7 @@ export function handleCodingEvent(panelId: string, event: { type: string; [key: tokensBefore: asNumber(result.tokensBefore), } : undefined, - lastErrorMessage: errorMessage, + lastErrorMessage: rawError ? agentErrorMessage(rawError) : undefined, }) return } @@ -917,7 +918,7 @@ export function handleCodingEvent(panelId: string, event: { type: string; [key: attempt: asNumber(event.attempt), maxAttempts: asNumber(event.maxAttempts), delayMs: asNumber(event.delayMs), - errorMessage: asString(event.errorMessage), + errorMessage: event.errorMessage ? agentErrorMessage(event.errorMessage) : undefined, finalError: undefined, succeededOnAttempt: undefined, }) @@ -928,7 +929,9 @@ export function handleCodingEvent(panelId: string, event: { type: string; [key: useCodingStore.getState().setRetry(panelId, { active: false, succeededOnAttempt: success ? asNumber(event.attempt) : undefined, - finalError: success ? undefined : asString(event.finalError), + finalError: success || !event.finalError + ? undefined + : agentErrorMessage(event.finalError), }) return } @@ -936,9 +939,10 @@ export function handleCodingEvent(panelId: string, event: { type: string; [key: const extensionPath = asString(event.extensionPath) ?? '(unknown extension)' const ev = asString(event.event) ?? 'event' const errMsg = asString(event.error) ?? 'unknown error' + log.warn('[agentStore] extension error (%s during %s): %s', extensionPath, ev, errMsg) useCodingStore .getState() - .appendSystem(panelId, `Extension error (${extensionPath} during ${ev}): ${errMsg}`, 'error') + .appendSystem(panelId, agentErrorMessage(`Extension error: ${errMsg}`), 'error') return } case 'extension_ui_request': { @@ -982,7 +986,7 @@ export function handleCodingEvent(panelId: string, event: { type: string; [key: return } case 'error': { - const message = asString(event.message) ?? 'Agent error' + const message = agentErrorMessage(event.message) useCodingStore.getState().appendSystem(panelId, message, 'error') useCodingStore.getState().setRunning(panelId, false) return diff --git a/src/cateAgent/renderer/directChatSession.ts b/src/cateAgent/renderer/directChatSession.ts index 6be0b997..366bef0a 100644 --- a/src/cateAgent/renderer/directChatSession.ts +++ b/src/cateAgent/renderer/directChatSession.ts @@ -9,6 +9,7 @@ import { codingClient } from './codingClient' import { resolveSessionModel } from './codingModelPrefs' import log from '../../renderer/lib/logger' import type { ComposerPromptMode } from '../../renderer/chat/ChatComposer' +import { agentErrorMessage } from '../../shared/agentErrorMessage' export interface DirectChatTurnOptions { images?: CodingImageAttachment[] @@ -57,7 +58,14 @@ export async function ensureDirectChatSession( sessionFile: chat.sessionFile ?? undefined, }) if (!result.ok) { - store.appendSystem(panelId, `Failed to start agent: ${result.error}`, 'error') + store.appendSystem( + panelId, + agentErrorMessage( + result.error, + 'Cate couldn’t start the agent. Start a new chat and try again.', + ), + 'error', + ) return false } return true diff --git a/src/cateAgent/renderer/useCodingChat.ts b/src/cateAgent/renderer/useCodingChat.ts index d62d541a..6673b6e0 100644 --- a/src/cateAgent/renderer/useCodingChat.ts +++ b/src/cateAgent/renderer/useCodingChat.ts @@ -15,7 +15,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import log from '../../renderer/lib/logger' -import { errorMessage as toErrorMessage } from '../../renderer/lib/errorMessage' +import { agentErrorMessage as toErrorMessage } from '../../shared/agentErrorMessage' import { useCodingStore } from './codingStore' import { codingClient } from './codingClient' import { useChatsStore } from '../../renderer/stores/chatsStore' diff --git a/src/shared/agentErrorMessage.test.ts b/src/shared/agentErrorMessage.test.ts new file mode 100644 index 00000000..da60c12b --- /dev/null +++ b/src/shared/agentErrorMessage.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { agentErrorMessage } from './agentErrorMessage' + +describe('agentErrorMessage', () => { + it('hides extension paths and runtime diagnostics', () => { + const raw = + 'Agent process exited (code 1). Error: Failed to load extension ' + + '"/Users/anton/Dev/repo/.cate/cate-agent/extensions/cate-orchestrator/index.ts": ' + + 'Extension runtime not initialized. Action methods cannot be called during extension loading.' + + const message = agentErrorMessage(raw) + + expect(message).toBe( + 'Cate couldn’t load its agent tools. Restart Cate and start a new chat.', + ) + expect(message).not.toContain('/Users/') + expect(message).not.toContain('Extension runtime') + }) + + it('maps common provider failures to actionable copy', () => { + expect(agentErrorMessage('HTTP 429 rate_limit_exceeded')).toContain('rate-limiting') + expect(agentErrorMessage('model not supported')).toContain('Choose another model') + expect(agentErrorMessage('connect ECONNREFUSED 127.0.0.1')).toContain('Check your connection') + }) + + it('never echoes an unknown raw failure', () => { + expect(agentErrorMessage('secret internal stack and /private/path')).toBe( + 'The Cate agent ran into a problem. Try again.', + ) + }) +}) diff --git a/src/shared/agentErrorMessage.ts b/src/shared/agentErrorMessage.ts new file mode 100644 index 00000000..fc8af1b3 --- /dev/null +++ b/src/shared/agentErrorMessage.ts @@ -0,0 +1,56 @@ +const SAFE_PREFIXES = [ + 'Cate couldn’t', + 'The Cate agent', + 'The selected model', + 'The model provider', + 'This chat', +] + +function rawMessage(error: unknown): string { + if (typeof error === 'string') return error + if (error instanceof Error) return error.message + if ( + error && + typeof error === 'object' && + 'message' in error && + typeof (error as { message?: unknown }).message === 'string' + ) { + return (error as { message: string }).message + } + return '' +} + +/** Convert agent/runtime failures into bounded user-facing copy. Technical + * details stay in logs; unknown messages are never echoed into the chat UI. */ +export function agentErrorMessage( + error: unknown, + fallback = 'The Cate agent ran into a problem. Try again.', +): string { + const message = rawMessage(error).trim() + if (!message) return fallback + if (!message.includes('\n') && SAFE_PREFIXES.some((prefix) => message.startsWith(prefix))) { + return message + } + if (/failed to load extension|extension runtime not initialized|extension[_\s-]error/i.test(message)) { + return 'Cate couldn’t load its agent tools. Restart Cate and start a new chat.' + } + if (/agent process exited|pi process exited|agent exited/i.test(message)) { + return 'The Cate agent stopped unexpectedly. Start a new chat and try again.' + } + if (/authentication|unauthorized|invalid api key|HTTP 401|HTTP 403/i.test(message)) { + return 'The selected model couldn’t authenticate. Check its provider connection.' + } + if (/rate.?limit|too many requests|HTTP 429/i.test(message)) { + return 'The model provider is rate-limiting requests. Wait a moment and try again.' + } + if (/model not supported|unknown model|model .* not found/i.test(message)) { + return 'The selected model is unavailable. Choose another model and try again.' + } + if (/context length|context window|too many tokens|maximum context/i.test(message)) { + return 'This chat exceeded the model’s context limit. Compact it or start a new chat.' + } + if (/ECONNREFUSED|ETIMEDOUT|ENOTFOUND|network|socket hang up|fetch failed/i.test(message)) { + return 'Cate couldn’t reach the model provider. Check your connection and try again.' + } + return fallback +} From aebf0c82e019e279286316830d6ccc936e1618a3 Mon Sep 17 00:00:00 2001 From: Anton Date: Sun, 26 Jul 2026 23:13:40 +0200 Subject: [PATCH 09/12] fix: scope agent worktrees to chats --- .codex/skills/cate-cli/SKILL.md | 409 ++++++++++++++++++ .codex/skills/karpathy-guidelines/SKILL.md | 67 +++ src/cateAgent/renderer/CateAgentChatView.tsx | 33 +- src/cateAgent/renderer/CateAgentComposer.tsx | 12 +- .../renderer/CateAgentEmptyState.tsx | 18 + src/cateAgent/renderer/CateAgentPanel.tsx | 11 +- .../renderer/CateAgentPanelChrome.tsx | 78 +++- .../renderer/ChatMessageRow.test.tsx | 45 ++ src/cateAgent/renderer/ChatMessageRow.tsx | 2 +- src/cateAgent/renderer/CodingChatView.tsx | 24 +- src/cateAgent/renderer/cateAgentSend.test.ts | 55 +++ src/cateAgent/renderer/cateAgentSend.ts | 4 +- .../renderer/cateAgentStore.test.tsx | 55 +++ src/cateAgent/renderer/cateAgentStore.ts | 49 +++ .../renderer/cateAgentWorktreeTarget.test.ts | 24 - .../renderer/cateAgentWorktreeTarget.ts | 35 -- .../renderer/directChatSession.test.ts | 38 ++ src/cateAgent/renderer/directChatSession.ts | 13 +- src/cateAgent/renderer/seedWorktreeChat.ts | 22 + src/main/projectChatsStore.test.ts | 1 + src/main/projectChatsStore.ts | 1 + src/renderer/canvas/Canvas.tsx | 10 +- src/renderer/canvas/CanvasNode.tsx | 19 +- src/renderer/canvas/WorktreePill.tsx | 4 +- src/renderer/canvas/WorktreeToolbarMenu.tsx | 9 +- .../chat/ChatComposer.sendStop.test.tsx | 61 ++- src/renderer/chat/ChatComposer.tsx | 65 ++- .../chat/ChatComposer.worktreeToggle.test.tsx | 48 ++ src/renderer/docking/DockTabBar.tsx | 14 +- src/renderer/lib/agent/codingAgentDriver.ts | 2 +- src/renderer/lib/inheritWorktree.test.ts | 7 +- src/renderer/lib/inheritWorktree.ts | 5 + src/renderer/lib/runAction.ts | 10 +- .../lib/terminal/terminalLifecycle.test.ts | 26 ++ .../lib/terminal/terminalLifecycle.ts | 20 + src/renderer/lib/terminal/terminalRegistry.ts | 2 + .../sessionSerialize.roundTrip.test.ts | 17 + .../lib/workspace/sessionSerialize.ts | 9 +- src/renderer/lib/workspace/windowPanelSync.ts | 13 +- .../lib/workspace/worktreePersistence.test.ts | 19 +- src/renderer/panels/CanvasPanel.tsx | 6 +- src/renderer/sidebar/WorkspaceTab.tsx | 10 +- src/renderer/stores/appStore/worktreeSlice.ts | 28 +- src/renderer/stores/chatsStore.test.ts | 3 +- src/renderer/stores/chatsStore.ts | 5 +- src/renderer/stores/useParallelWork.ts | 19 +- src/shared/types.ts | 11 +- 47 files changed, 1244 insertions(+), 194 deletions(-) create mode 100644 .codex/skills/cate-cli/SKILL.md create mode 100644 .codex/skills/karpathy-guidelines/SKILL.md create mode 100644 src/cateAgent/renderer/CateAgentEmptyState.tsx create mode 100644 src/cateAgent/renderer/ChatMessageRow.test.tsx create mode 100644 src/cateAgent/renderer/cateAgentSend.test.ts create mode 100644 src/cateAgent/renderer/cateAgentStore.test.tsx delete mode 100644 src/cateAgent/renderer/cateAgentWorktreeTarget.test.ts delete mode 100644 src/cateAgent/renderer/cateAgentWorktreeTarget.ts create mode 100644 src/cateAgent/renderer/directChatSession.test.ts create mode 100644 src/cateAgent/renderer/seedWorktreeChat.ts diff --git a/.codex/skills/cate-cli/SKILL.md b/.codex/skills/cate-cli/SKILL.md new file mode 100644 index 00000000..c5a45afa --- /dev/null +++ b/.codex/skills/cate-cli/SKILL.md @@ -0,0 +1,409 @@ +--- +name: cate-cli +description: Drive the Cate IDE from inside a Cate terminal with the `cate` CLI — control the built-in browser panel (navigate, tabs, snapshots, inspect, trusted input, evaluate, console, dialogs, and screenshots), read and drive terminal panels, open files, and manage panels. Use when an agent or user working in a Cate terminal needs to steer a web page, capture a screenshot, read another terminal, or open a Cate panel from the shell. +user-invocable: true +--- + +# Driving Cate from the terminal with `cate` + +`cate` is a small CLI, preinstalled on PATH **inside Cate terminals and Cate +agent shells**. It lets you control Cate — its browser panels, plus each granted +`cate.*` host scope through a matching command group (`browser`, `editor`, +`panel`, `terminal`). Every reachable CLI method has a named verb; `cate --help` is the +complete surface. There are no workspace/theme verbs: your cwd IS the workspace +root, and git knows the branch. It talks to a per-workspace loopback endpoint +Cate injects as `CATE_API` + `CATE_TOKEN`. + +**It only works inside a Cate terminal, and only when command-line control is +enabled.** It is on by default; the user can turn it off in Settings → CLI +("Command-line control"). While it is off — or outside a Cate terminal — the env +vars are unset and every command exits `3` with a message explaining how to +enable the setting. There is nothing to install. The same Settings → CLI section +holds a permission matrix — Browser, Terminal, Panels, Editor and Notifications, +each split into Read (observe) and Control (act). A verb whose cell is off fails +with a stable error naming the cell (e.g. `panel-control-disabled: enable Panels +→ Control in Cate Settings → CLI`); Terminal → Control is the only cell off by +default. + +## Start here: the reliable workflow + +Orient before acting: + +```bash +cate panel list +``` + +This is the one list of every open panel. It shows short ids, panel types, useful +paths/urls, and marks the focused panel with `*`. + +Then choose one targeting mode and keep it for the whole flow: + +- To drive an existing browser, copy its id and pass `--panel ` on every + browser command. This is the most explicit option when several browsers exist. +- Without `--panel`, Cate normally keeps browser and newly created panel calls + inside the calling terminal/agent's placement group. `cate browser open ` + reuses that group's browser or creates a background browser when the group has + none. On shells without placement affinity, Cate falls back to the focused + browser and then the first browser in the workspace. + +A complete browser loop is **orient → open → inspect → act → wait → verify**: + +```bash +cate panel list +cate browser open https://example.com +cate browser snapshot +cate browser fill label=Email user@example.com +cate browser click text=Continue --exact --snapshot +cate browser wait url '**/dashboard' --snapshot +``` + +Prefer a condition such as `wait text`, `wait gone`, or `wait url` over a fixed +delay. `--snapshot` combines an action and its immediate observation, but still +use a conditional wait when the page updates asynchronously. After navigation, +take a new snapshot before reusing refs. + +For an existing browser, keep the explicit id throughout: + +```bash +browser=1a2b3c4d +cate browser snapshot --panel "$browser" +cate browser click text=Continue --exact --snapshot --panel "$browser" +cate browser wait text=Done --snapshot --panel "$browser" +``` + +Panel and file creation is intentionally background-only. The first-party CLI +cannot focus panels, move the canvas camera, or otherwise change the user's +view. If a human needs to inspect the result, identify the panel from +`cate panel list` and tell them which one to open. + +## Browser control + +A Cate window can host browser panels. An explicit `--panel ` always wins. +Without it, commands follow the placement-group targeting described above. +Browser rows from `cate panel list` show their current url. + +Everything you do is **visible to the user**: Cate draws a ghost cursor, a +highlight around the element you are acting on, and a label naming the action, +so a person watching the panel can see what you targeted and why. + +### Navigating + +```bash +cate browser open https://x.com # navigate; prints the resulting url +cate browser open https://x.com --new # always create another browser panel +cate browser back # history back (fails no-history at the start) +cate browser forward +cate browser reload +``` + +### Tabs + +A browser panel holds several tabs; these list and switch them. + +```bash +cate browser tab list # "* " per tab; * = active +cate browser tab new # open a tab; prints its short id +cate browser tab new https://x.com +cate browser tab select 3f2a1b0c +cate browser tab close 3f2a1b0c +``` + +### Finding things: refs and locators + +Anywhere a `` is accepted you may pass either a **snapshot ref** +(`@s1e12`) or a **locator**: + +| locator | matches | +| --- | --- | +| `role=button` | ARIA role, else tag name | +| `text=Sign in` | element most tightly wrapping that text | +| `label=Email` | aria-label or an associated `
diff --git a/src/cateAgent/renderer/CateAgentPanelChrome.tsx b/src/cateAgent/renderer/CateAgentPanelChrome.tsx index 5bc61fda..ec216a68 100644 --- a/src/cateAgent/renderer/CateAgentPanelChrome.tsx +++ b/src/cateAgent/renderer/CateAgentPanelChrome.tsx @@ -623,26 +623,70 @@ function ThinkingBars({ count, size = 10 }: { count: number; size?: number }) { export const COMPOSER_POPOVER_SURFACE = 'rounded-lg border border-strong bg-surface-4/98 backdrop-blur-xl shadow-[0_12px_32px_var(--shadow-node)]' +export type ViewportPopoverPosition = { + top: number + left: number + placement: 'above' | 'below' +} + +export function verticalPopoverPosition( + rect: DOMRect, + gap: number, + popoverHeight?: number, +): Pick { + const viewportMargin = 8 + const above = rect.top - gap - viewportMargin + const below = window.innerHeight - rect.bottom - gap - viewportMargin + const placement = + popoverHeight != null && below >= popoverHeight + ? 'below' + : popoverHeight != null && above >= popoverHeight + ? 'above' + : below >= above + ? 'below' + : 'above' + + return { + placement, + top: placement === 'below' ? rect.bottom + gap : rect.top - gap, + } +} + // Keep anchored popovers in viewport space. Portalling into a transformed // canvas node makes CSS widths and offsets inherit the canvas zoom; it also // leaves sidebar/dock composers without a portal target at all. export function useViewportPopoverPosition( btnRef: React.RefObject, open: boolean, - layout: (rect: DOMRect) => { top: number; left: number }, + layout: (rect: DOMRect) => { left: number; gap: number; height?: number }, + popoverRef?: React.RefObject, ) { - const [pos, setPos] = useState<{ top: number; left: number } | null>(null) + const [pos, setPos] = useState(null) const layoutRef = useRef(layout) useLayoutEffect(() => { layoutRef.current = layout }, [layout]) const updatePosition = useCallback(() => { if (!open || !btnRef.current) return - const next = layoutRef.current(btnRef.current.getBoundingClientRect()) + const rect = btnRef.current.getBoundingClientRect() + const layoutResult = layoutRef.current(rect) + const measuredHeight = popoverRef?.current?.getBoundingClientRect().height + const next = { + left: layoutResult.left, + ...verticalPopoverPosition( + rect, + layoutResult.gap, + measuredHeight && measuredHeight > 0 ? measuredHeight : layoutResult.height, + ), + } setPos((current) => ( - current?.top === next.top && current.left === next.left ? current : next + current?.top === next.top && + current.left === next.left && + current.placement === next.placement + ? current + : next )) - }, [btnRef, open]) + }, [btnRef, open, popoverRef]) useLayoutEffect(() => { if (!open) { @@ -659,6 +703,12 @@ export function useViewportPopoverPosition( } }, [open, updatePosition]) + // The first pass positions an unmounted popover from the available space. + // Once it has rendered, refine the choice with its real height before paint. + useLayoutEffect(() => { + if (open && pos) updatePosition() + }) + return { pos, portalTarget: typeof document === 'undefined' ? null : document.body, @@ -669,11 +719,11 @@ export function useViewportPopoverPosition( // Owns open state, outside-click dismissal, and viewport-relative positioning. export function useNodePopover( btnRef: React.RefObject, - layout: (rect: DOMRect) => { top: number; left: number }, + layout: (rect: DOMRect) => { left: number; gap: number; height?: number }, ) { const [open, setOpen] = useState(false) const popoverRef = useRef(null) - const { pos, portalTarget } = useViewportPopoverPosition(btnRef, open, layout) + const { pos, portalTarget } = useViewportPopoverPosition(btnRef, open, layout, popoverRef) useEffect(() => { if (!open) return const handler = (e: MouseEvent) => { @@ -695,7 +745,7 @@ export function useNodePopover( } // Shared shell for composer popovers: a blurred, bordered card portalled to the -// document body and pinned above its trigger (translateY(-100%)). +// document body and placed on whichever side of its trigger has room. export function NodePopover({ popoverRef, pos, @@ -705,7 +755,7 @@ export function NodePopover({ children, }: { popoverRef: React.RefObject - pos: { top: number; left: number } | null + pos: ViewportPopoverPosition | null portalTarget: HTMLElement | null width: number bodyClassName?: string @@ -716,7 +766,13 @@ export function NodePopover({
{children}
, @@ -740,7 +796,7 @@ export function ThinkingLevelPicker({ const popW = 160 let left = r.right - popW if (left < 4) left = 4 - return { top: r.top - 4, left } + return { left, gap: 4 } }, ) const current = level ?? 'medium' diff --git a/src/cateAgent/renderer/ChatMessageRow.test.tsx b/src/cateAgent/renderer/ChatMessageRow.test.tsx new file mode 100644 index 00000000..fc86e20d --- /dev/null +++ b/src/cateAgent/renderer/ChatMessageRow.test.tsx @@ -0,0 +1,45 @@ +import React, { act } from 'react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { createRoot, type Root } from 'react-dom/client' +import type { AssistantMessage } from './codingStore' +import { MessageRow } from './ChatMessageRow' + +;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +describe('assistant thinking presentation', () => { + let host: HTMLDivElement + let root: Root + + beforeEach(() => { + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) + }) + + afterEach(() => { + act(() => root.unmount()) + host.remove() + }) + + it('keeps live thinking text outside the transparent shimmer container', () => { + const msg: AssistantMessage = { + type: 'assistant', + id: 'assistant-thinking', + text: '', + thinking: 'Reasoning remains readable while it streams.', + streaming: true, + } + + act(() => root.render()) + + const row = host.firstElementChild as HTMLElement + expect(row.className).not.toContain('cate-notif-pulse') + expect(host.querySelector('button span')?.className).toContain('cate-notif-pulse') + + act(() => { + host.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(host.querySelector('pre')?.textContent).toContain('Reasoning remains readable') + }) +}) diff --git a/src/cateAgent/renderer/ChatMessageRow.tsx b/src/cateAgent/renderer/ChatMessageRow.tsx index d2a7ea7a..bb5bbd26 100644 --- a/src/cateAgent/renderer/ChatMessageRow.tsx +++ b/src/cateAgent/renderer/ChatMessageRow.tsx @@ -75,7 +75,7 @@ export const MessageRow = memo(function MessageRow({ } if (msg.type === 'assistant') { return ( -
+
{msg.thinking && }
diff --git a/src/cateAgent/renderer/CodingChatView.tsx b/src/cateAgent/renderer/CodingChatView.tsx index f855155b..e8668b33 100644 --- a/src/cateAgent/renderer/CodingChatView.tsx +++ b/src/cateAgent/renderer/CodingChatView.tsx @@ -15,12 +15,12 @@ // readyByKey ref, so it too arrives as a prop. // ============================================================================= -import { ChatCircle } from '@phosphor-icons/react' import { ChatThread } from './ChatThread' import { ChatComposer } from '../../renderer/chat/ChatComposer' import { ExtensionDialog, ExtensionWidget, QueueBadges } from './CateAgentPanelChrome' import { useCodingChat, type CodingChatComposerExtras } from './useCodingChat' import type { CodingSlashCommand } from '../../shared/types' +import { CateAgentEmptyState } from './CateAgentEmptyState' export type { CodingChatComposerExtras } @@ -147,22 +147,12 @@ export function CodingChatView({ {messages.length === 0 ? ( -
-
-
- -
-
- What should we work on? -
-
- -
-
-
+ + + ) : ( <> ({ + promptDirectChat: vi.fn(), +})) + +const ROOT = '/repo' +const WS_ID = 'workspace-1' + +beforeEach(() => { + vi.clearAllMocks() + ;(globalThis as unknown as { window: unknown }).window = { + electronAPI: { + projectChatsSave: vi.fn(), + projectChatsLoad: vi.fn(async () => [] as Chat[]), + }, + } + useChatsStore.setState({ + chatsByRoot: { [ROOT]: [] }, + loadedRoots: { [ROOT]: true }, + }) + useCateAgentStore.setState({ byWs: {} }) +}) + +describe('sendDirectAgentMessage', () => { + it('creates and selects a sidebar chat for the first message', () => { + const chatId = sendDirectAgentMessage(WS_ID, ROOT, 'Start here') + const chat = useChatsStore.getState().getChat(ROOT, chatId) + + expect(chat).toMatchObject({ id: chatId, title: 'Start here' }) + expect(useCateAgentStore.getState().byWs[WS_ID]?.activeChatId).toBe(chatId) + expect(promptDirectChat).toHaveBeenCalledWith(chat, WS_ID, ROOT, 'Start here', undefined, undefined) + }) + + it('creates the first message in the requesting agent panel', () => { + const chatId = sendDirectAgentMessage( + WS_ID, + ROOT, + 'Panel task', + undefined, + undefined, + undefined, + 'agent-panel-1', + ) + const chat = useChatsStore.getState().getChat(ROOT, chatId) + + expect(chat?.hostPanelId).toBe('agent-panel-1') + expect(promptDirectChat).toHaveBeenCalledWith(chat, WS_ID, ROOT, 'Panel task', undefined, undefined) + }) +}) diff --git a/src/cateAgent/renderer/cateAgentSend.ts b/src/cateAgent/renderer/cateAgentSend.ts index 1f4b633c..8f622be0 100644 --- a/src/cateAgent/renderer/cateAgentSend.ts +++ b/src/cateAgent/renderer/cateAgentSend.ts @@ -1,6 +1,5 @@ import { useChatsStore } from '../../renderer/stores/chatsStore' import { useCateAgentStore } from './cateAgentStore' -import { setTargetWorktree } from './cateAgentWorktreeTarget' import { promptDirectChat, type DirectChatTurnOptions, @@ -23,8 +22,7 @@ export function sendDirectAgentMessage( hostPanelId?: string, ): string { const chats = useChatsStore.getState() - const chat = chats.createChat(rootPath, deriveChatTitle(text), hostPanelId) - if (worktreeId) setTargetWorktree(chat.id, worktreeId) + const chat = chats.createChat(rootPath, deriveChatTitle(text), hostPanelId, worktreeId) if (!hostPanelId) useCateAgentStore.getState().setActiveChat(wsId, chat.id) void promptDirectChat(chat, wsId, rootPath, text, options, cwd) return chat.id diff --git a/src/cateAgent/renderer/cateAgentStore.test.tsx b/src/cateAgent/renderer/cateAgentStore.test.tsx new file mode 100644 index 00000000..6c5dfcbf --- /dev/null +++ b/src/cateAgent/renderer/cateAgentStore.test.tsx @@ -0,0 +1,55 @@ +// @vitest-environment jsdom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { useChatsStore } from '../../renderer/stores/chatsStore' +import { + useActiveChatWorktreeByPanel, + useCateAgentStore, +} from './cateAgentStore' + +;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +beforeEach(() => { + useCateAgentStore.setState({ byWs: {}, activeChatByPanel: {} }) + useChatsStore.setState({ + chatsByRoot: { + '/repo': [ + { id: 'chat-a', title: 'A', createdAt: 1, updatedAt: 1, worktreeId: 'wt-a' }, + { id: 'chat-b', title: 'B', createdAt: 2, updatedAt: 2, worktreeId: 'wt-b' }, + ], + }, + loadedRoots: { '/repo': true }, + }) +}) + +let root: Root | null = null +afterEach(() => { + act(() => root?.unmount()) + root = null + document.body.innerHTML = '' +}) + +describe('active Cate Agent chat worktree projection', () => { + it('reactively follows chat switches for canvas terrace membership', () => { + let current: Record = {} + const Probe = () => { + current = useActiveChatWorktreeByPanel() + return null + } + const host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) + act(() => root?.render()) + + act(() => useCateAgentStore.getState().setPanelActiveChat('agent-panel', 'chat-a')) + expect(current['agent-panel']).toBe('wt-a') + + act(() => useCateAgentStore.getState().setPanelActiveChat('agent-panel', 'chat-b')) + expect(current['agent-panel']).toBe('wt-b') + + act(() => useCateAgentStore.getState().setPanelActiveChat('agent-panel', null)) + expect(current['agent-panel']).toBeUndefined() + }) +}) diff --git a/src/cateAgent/renderer/cateAgentStore.ts b/src/cateAgent/renderer/cateAgentStore.ts index e1b7c94b..1568c0b1 100644 --- a/src/cateAgent/renderer/cateAgentStore.ts +++ b/src/cateAgent/renderer/cateAgentStore.ts @@ -1,4 +1,6 @@ import { create } from 'zustand' +import { useMemo } from 'react' +import { useChatsStore } from '../../renderer/stores/chatsStore' export interface CateAgentWsState { /** Empty means the sidebar is showing the new-chat surface. */ @@ -11,12 +13,15 @@ export const DEFAULT_CATE_AGENT_WS: CateAgentWsState = { interface CateAgentStore { byWs: Record + activeChatByPanel: Record setActiveChat: (wsId: string, chatId: string) => void + setPanelActiveChat: (panelId: string, chatId: string | null) => void reset: (wsId: string) => void } export const useCateAgentStore = create((set) => ({ byWs: {}, + activeChatByPanel: {}, setActiveChat(wsId, chatId) { set((state) => ({ @@ -27,6 +32,16 @@ export const useCateAgentStore = create((set) => ({ })) }, + setPanelActiveChat(panelId, chatId) { + set((state) => { + if ((state.activeChatByPanel[panelId] ?? '') === (chatId ?? '')) return state + const activeChatByPanel = { ...state.activeChatByPanel } + if (chatId) activeChatByPanel[panelId] = chatId + else delete activeChatByPanel[panelId] + return { activeChatByPanel } + }) + }, + reset(wsId) { set((state) => ({ byWs: { @@ -42,3 +57,37 @@ export function useCateAgentWs(wsId: string | null | undefined): CateAgentWsStat wsId ? state.byWs[wsId] ?? DEFAULT_CATE_AGENT_WS : DEFAULT_CATE_AGENT_WS )) } + +/** Resolve the active chat target for non-React creation paths that inherit the + * selected canvas node's worktree. Chat ids are globally generated, so scanning + * the already-loaded workspace lists is unambiguous. */ +export function activeChatWorktreeIdForPanel(panelId: string): string | undefined { + const chatId = useCateAgentStore.getState().activeChatByPanel[panelId] + if (!chatId) return undefined + for (const chats of Object.values(useChatsStore.getState().chatsByRoot)) { + const chat = chats.find((candidate) => candidate.id === chatId) + if (chat) return chat.worktreeId + } + return undefined +} + +/** Reactive panel projection used only by canvas/tab chrome. The worktree value + * remains owned by Chat; this map is derived UI state, never persisted. */ +export function useActiveChatWorktreeByPanel(): Record { + const activeChatByPanel = useCateAgentStore((state) => state.activeChatByPanel) + const chatsByRoot = useChatsStore((state) => state.chatsByRoot) + return useMemo(() => { + const worktreeByChat = new Map() + for (const chats of Object.values(chatsByRoot)) { + for (const chat of chats) { + if (chat.worktreeId) worktreeByChat.set(chat.id, chat.worktreeId) + } + } + const out: Record = {} + for (const [panelId, chatId] of Object.entries(activeChatByPanel)) { + const worktreeId = worktreeByChat.get(chatId) + if (worktreeId) out[panelId] = worktreeId + } + return out + }, [activeChatByPanel, chatsByRoot]) +} diff --git a/src/cateAgent/renderer/cateAgentWorktreeTarget.test.ts b/src/cateAgent/renderer/cateAgentWorktreeTarget.test.ts deleted file mode 100644 index fc4a1bc4..00000000 --- a/src/cateAgent/renderer/cateAgentWorktreeTarget.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -// @vitest-environment jsdom - -import { beforeEach, describe, expect, it } from 'vitest' -import { - resolveTargetWorktree, - setTargetWorktree, -} from './cateAgentWorktreeTarget' - -beforeEach(() => { - localStorage.clear() -}) - -describe('resolveTargetWorktree', () => { - it('defaults a new panel chat to the worktree the panel was launched from', () => { - expect(resolveTargetWorktree('', 'worktree-from-popup')).toBe('worktree-from-popup') - }) - - it('keeps a chat-specific composer choice ahead of the panel default', () => { - setTargetWorktree('chat-1', 'worktree-picked-below-input') - - expect(resolveTargetWorktree('chat-1', 'worktree-from-popup')) - .toBe('worktree-picked-below-input') - }) -}) diff --git a/src/cateAgent/renderer/cateAgentWorktreeTarget.ts b/src/cateAgent/renderer/cateAgentWorktreeTarget.ts deleted file mode 100644 index 243cb01a..00000000 --- a/src/cateAgent/renderer/cateAgentWorktreeTarget.ts +++ /dev/null @@ -1,35 +0,0 @@ -// ============================================================================= -// cateAgentWorktreeTarget — the WORKTREE a chat works against, picked in the -// composer's worktree pill. The main agent uses it as its cwd. Stored as the -// worktree's stable id (never a branch name or path). -// -// Kept per-chat in localStorage, like the composer draft — ephemeral across -// restarts. Resolve an id to its live branch with `worktreeBranchFor`. -// ============================================================================= - -const key = (chatId: string): string => `cate.targetWorktree.${chatId}` - -export const getTargetWorktree = (chatId: string): string | null => { - try { - return chatId ? localStorage.getItem(key(chatId)) : null - } catch { - return null - } -} - -/** A chat's explicit target wins; a newly-created Agent panel falls back to - * the worktree it was launched from until the chat records its own choice. */ -export const resolveTargetWorktree = ( - chatId: string, - defaultWorktreeId?: string, -): string | null => getTargetWorktree(chatId) ?? defaultWorktreeId ?? null - -export const setTargetWorktree = (chatId: string, worktreeId: string | null): void => { - try { - if (!chatId) return - if (worktreeId) localStorage.setItem(key(chatId), worktreeId) - else localStorage.removeItem(key(chatId)) - } catch { - /* best-effort */ - } -} diff --git a/src/cateAgent/renderer/directChatSession.test.ts b/src/cateAgent/renderer/directChatSession.test.ts new file mode 100644 index 00000000..693ad9d5 --- /dev/null +++ b/src/cateAgent/renderer/directChatSession.test.ts @@ -0,0 +1,38 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Chat } from '../../shared/types' +import { useCodingStore } from './codingStore' +import { + directAgentKey, + promptDirectChat, +} from './directChatSession' + +const chat: Chat = { + id: 'chat-1', + title: 'New chat', + createdAt: 1, + updatedAt: 1, + model: { provider: 'test-provider', model: 'test-model' }, +} + +beforeEach(() => { + vi.clearAllMocks() + useCodingStore.setState({ panels: {} }) + ;(globalThis as unknown as { window: unknown }).window = { + electronAPI: { + agentCreate: vi.fn(async () => ({ ok: true })), + agentPrompt: vi.fn(async () => {}), + }, + } +}) + +describe('promptDirectChat', () => { + it('appends a new chat’s first message before asynchronous session startup', async () => { + const sending = promptDirectChat(chat, 'workspace-1', '/repo', 'First message') + const agentKey = directAgentKey(chat.id) + + expect(useCodingStore.getState().panels[agentKey]?.messages).toMatchObject([ + { type: 'user', text: 'First message' }, + ]) + await expect(sending).resolves.toBe(true) + }) +}) diff --git a/src/cateAgent/renderer/directChatSession.ts b/src/cateAgent/renderer/directChatSession.ts index 366bef0a..93e21b3f 100644 --- a/src/cateAgent/renderer/directChatSession.ts +++ b/src/cateAgent/renderer/directChatSession.ts @@ -89,9 +89,16 @@ export async function promptDirectChat( cwd = rootPath, ): Promise { const panelId = directAgentKey(chat.id) - if (!(await ensureDirectChatSession(chat, workspaceId, rootPath, cwd))) return false - + const session = ensureDirectChatSession(chat, workspaceId, rootPath, cwd) const store = useCodingStore.getState() + // A brand-new chat has no transcript to hydrate, so show its first message + // immediately while the agent session starts. Otherwise selecting the newly + // created chat briefly renders the empty-chat composer before this message + // arrives and makes the composer jump between layouts. + const appendedOptimistically = !chat.sessionFile + if (appendedOptimistically) store.appendUser(panelId, text) + if (!(await session)) return false + const controlUpdates: Promise[] = [] if (options.thinkingLevel) { store.setThinkingLevel(panelId, options.thinkingLevel) @@ -105,7 +112,7 @@ export async function promptDirectChat( try { await Promise.all(controlUpdates) if (options.promptMode) await codingClient.prompt(panelId, `/${options.promptMode}`) - store.appendUser(panelId, text) + if (!appendedOptimistically) store.appendUser(panelId, text) await codingClient.prompt(panelId, text, options.images) return true } catch (error) { diff --git a/src/cateAgent/renderer/seedWorktreeChat.ts b/src/cateAgent/renderer/seedWorktreeChat.ts new file mode 100644 index 00000000..88fb4ad5 --- /dev/null +++ b/src/cateAgent/renderer/seedWorktreeChat.ts @@ -0,0 +1,22 @@ +import { useAppStore } from '../../renderer/stores/appStore' +import { useChatsStore } from '../../renderer/stores/chatsStore' + +/** Give a newly-created Agent panel its initial worktree by creating a tagged + * chat. The panel itself stays worktree-agnostic. */ +export async function seedAgentPanelWithWorktreeChat( + workspaceId: string, + rootPath: string, + panelId: string, + worktreeId: string, +): Promise { + const chats = useChatsStore.getState() + try { + await chats.loadChats(rootPath) + } catch { + return + } + const panel = useAppStore.getState().getWorkspace(workspaceId)?.panels[panelId] + if (panel?.type !== 'cateAgent') return + const chat = useChatsStore.getState().createChat(rootPath, 'New chat', panelId, worktreeId) + useAppStore.getState().setPanelInitialChat(workspaceId, panelId, chat.id) +} diff --git a/src/main/projectChatsStore.test.ts b/src/main/projectChatsStore.test.ts index dc8fc9d1..4a3b5789 100644 --- a/src/main/projectChatsStore.test.ts +++ b/src/main/projectChatsStore.test.ts @@ -54,6 +54,7 @@ describe('projectChatsStore', () => { createdAt: 1, updatedAt: 2, hostPanelId: 'agent-panel-1', + worktreeId: 'wt-feature', sessionFile: '/tmp/direct-chat.jsonl', model: { provider: 'anthropic', model: 'claude-sonnet' }, } diff --git a/src/main/projectChatsStore.ts b/src/main/projectChatsStore.ts index bed7ed8b..e7de61b3 100644 --- a/src/main/projectChatsStore.ts +++ b/src/main/projectChatsStore.ts @@ -43,6 +43,7 @@ function normalizeChat(raw: unknown): Chat | null { updatedAt: typeof o.updatedAt === 'number' ? o.updatedAt : 0, } if (typeof o.hostPanelId === 'string') chat.hostPanelId = o.hostPanelId + if (typeof o.worktreeId === 'string') chat.worktreeId = o.worktreeId if (typeof o.sessionFile === 'string') chat.sessionFile = o.sessionFile const m = o.model if (m && typeof m === 'object' && typeof (m as Record).provider === 'string' && typeof (m as Record).model === 'string') { diff --git a/src/renderer/canvas/Canvas.tsx b/src/renderer/canvas/Canvas.tsx index 9df94d25..4fcba4d4 100644 --- a/src/renderer/canvas/Canvas.tsx +++ b/src/renderer/canvas/Canvas.tsx @@ -25,6 +25,7 @@ import { setPendingReveal } from '../lib/editor/editorReveal' import { CHAT_DRAG_MIME, readChatDrag } from '../drag/fileDragPayload' import { createSeededChatPanel } from '../drag/openChatDrop' import { endChatDrag } from '../drag/chatDragState' +import { seedAgentPanelWithWorktreeChat } from '../../cateAgent/renderer/seedWorktreeChat' // Module-level style injection — shared across all Canvas instances let canvasStyleInjected = false @@ -409,7 +410,14 @@ const Canvas: React.FC = ({ children, onCreateAtPoint, panelId }) = ? store.createTerminal(wsId, undefined, pos, here(), spec.cwd) : store.createCateAgent(wsId, pos, here()) if (panelId && spec.worktreeId) { - store.setPanelWorktreeId(wsId, panelId, spec.worktreeId) + if (spec.panelType === 'terminal') { + store.setPanelWorktreeId(wsId, panelId, spec.worktreeId) + } else { + const rootPath = store.getWorkspace(wsId)?.rootPath + if (rootPath) { + await seedAgentPanelWithWorktreeChat(wsId, rootPath, panelId, spec.worktreeId) + } + } } return } diff --git a/src/renderer/canvas/CanvasNode.tsx b/src/renderer/canvas/CanvasNode.tsx index 604da901..56f9b14e 100644 --- a/src/renderer/canvas/CanvasNode.tsx +++ b/src/renderer/canvas/CanvasNode.tsx @@ -35,6 +35,8 @@ import { collectPanelIds } from '../../shared/collectPanelIds' import { ArrowsOutSimple, ArrowsInSimple, X, Lock, LockOpen } from '@phosphor-icons/react' import { isWorktreePanelType, PANEL_DEFINITIONS } from '../../shared/panels' import { captureRendererException } from '../lib/sentry' +import { useCateAgentStore } from '../../cateAgent/renderer/cateAgentStore' +import { useChatsStore } from '../stores/chatsStore' // Node ids already reported for missing geometry, so a bad node that keeps // re-rendering warns/reports once instead of spamming. @@ -384,21 +386,34 @@ const CanvasNode: React.FC = ({ if (!id) return primaryPanel return currentWorkspace?.panels[id] ?? primaryPanel }, [layout, currentWorkspace, primaryPanel]) + const activeAgentChatId = useCateAgentStore((state) => ( + activePanel?.type === 'cateAgent' ? state.activeChatByPanel[activePanel.id] : undefined + )) + const activeAgentChatWorktreeId = useChatsStore((state) => ( + activeAgentChatId + ? (state.chatsByRoot[currentWorkspace?.rootPath ?? ''] ?? []) + .find((chat) => chat.id === activeAgentChatId)?.worktreeId + : undefined + )) // --- Worktree identity: follows the ACTIVE tab -------------------------- // The node adopts whichever tab is open. Gated on 2+ worktrees (matching the // chip) so single-branch flows show no tint/sludge. const worktrees = currentWorkspace?.worktrees ?? [] const wtEnabled = worktrees.length >= 2 - // Resolve the active tab's worktree. A terminal/agent panel with no explicit + // Resolve the active tab's worktree. Terminals read their panel tag; Cate + // Agent panels read the active chat's tag. A worktree panel with no explicit // tag belongs to the PRIMARY worktree (the record keyed by the workspace root), // so the main checkout gets the same tint / terrace / focus-lens as the others // — mirroring the WorktreePill + tab-title fallback. Non-terminal panels stay // untagged (no territory). const primaryWorktree = worktrees.find((w) => w.path === currentWorkspace?.rootPath) const isWorktreePanel = isWorktreePanelType(activePanel?.type) + const explicitWorktreeId = activePanel?.type === 'cateAgent' + ? activeAgentChatWorktreeId + : activePanel?.worktreeId const activeWorktree = wtEnabled - ? worktrees.find((w) => w.id === activePanel?.worktreeId) ?? (isWorktreePanel ? primaryWorktree : undefined) + ? worktrees.find((w) => w.id === explicitWorktreeId) ?? (isWorktreePanel ? primaryWorktree : undefined) : undefined const activeWorktreeId = activeWorktree?.id ?? null const worktreeColor = activeWorktree?.color ?? null diff --git a/src/renderer/canvas/WorktreePill.tsx b/src/renderer/canvas/WorktreePill.tsx index 0a59d299..ba269293 100644 --- a/src/renderer/canvas/WorktreePill.tsx +++ b/src/renderer/canvas/WorktreePill.tsx @@ -78,8 +78,8 @@ export const WorktreePill: React.FC = ({ panel, workspaceId } useAppStore.getState().respawnPanelTerminal(workspaceId, panel.id, target.path, target.id) }, [worktrees, current, focusedWorktreeId, panel, workspaceId, focusWorktree]) - // The chip is terminal chrome only. Agent worktree selection lives below its - // composer while panel.worktreeId still records its worktree association. + // The chip is terminal chrome only. Agent worktree selection lives on Chat + // and is rendered below its composer. if (panel.type !== 'terminal') return null if (worktrees.length < 2 || !current) return null diff --git a/src/renderer/canvas/WorktreeToolbarMenu.tsx b/src/renderer/canvas/WorktreeToolbarMenu.tsx index c0df5a31..f2661a53 100644 --- a/src/renderer/canvas/WorktreeToolbarMenu.tsx +++ b/src/renderer/canvas/WorktreeToolbarMenu.tsx @@ -38,6 +38,7 @@ import { useAppStore, getWorktreeColorPalette } from '../stores/appStore' import { useParallelWork, runWorktreeContextMenu, type CardCallbacks } from '../stores/useParallelWork' import { useWorktreeStatuses, humanStatus, type PrStatus } from '../stores/useWorktreeStatuses' import type { WorktreePanelType } from '../../shared/panels' +import { useActiveChatWorktreeByPanel } from '../../cateAgent/renderer/cateAgentStore' interface WorktreeToolbarMenuProps { canvasPanelId: string @@ -168,16 +169,18 @@ const WorktreeMenuPopover: React.FC = ({ // What's already open on the canvas, per worktree. const panels = useAppStore((s) => s.workspaces.find((w) => w.id === workspaceId)?.panels) + const activeChatWorktreeByPanel = useActiveChatWorktreeByPanel() const panelCounts = useMemo(() => { const counts: Record = {} for (const p of Object.values(panels ?? {})) { - if (!p.worktreeId) continue - const c = counts[p.worktreeId] ?? (counts[p.worktreeId] = { terminals: 0, agents: 0 }) + const worktreeId = p.type === 'cateAgent' ? activeChatWorktreeByPanel[p.id] : p.worktreeId + if (!worktreeId) continue + const c = counts[worktreeId] ?? (counts[worktreeId] = { terminals: 0, agents: 0 }) if (p.type === 'terminal') c.terminals += 1 else if (p.type === 'cateAgent') c.agents += 1 } return counts - }, [panels]) + }, [activeChatWorktreeByPanel, panels]) const { statusByPath, prByPath, refreshPr } = useWorktreeStatuses(rootPath, live) const { createWorktree, checkoutPr, launchInWorktree, handlePrune, makeCallbacks } = useParallelWork( diff --git a/src/renderer/chat/ChatComposer.sendStop.test.tsx b/src/renderer/chat/ChatComposer.sendStop.test.tsx index 6016b9b4..f887fe71 100644 --- a/src/renderer/chat/ChatComposer.sendStop.test.tsx +++ b/src/renderer/chat/ChatComposer.sendStop.test.tsx @@ -152,7 +152,7 @@ describe('ChatComposer send/stop', () => { }) describe('ChatComposer popovers', () => { - it('renders thinking controls in viewport space without a canvas node', () => { + it('opens thinking controls below a centred composer', () => { renderComposer({ onPickThinkingLevel: vi.fn() }) const trigger = button('Reasoning effort: medium') as HTMLButtonElement trigger.getBoundingClientRect = () => ({ @@ -175,11 +175,37 @@ describe('ChatComposer popovers', () => { expect(popover).toBeTruthy() expect(host.contains(popover ?? null)).toBe(false) expect(popover?.classList.contains('fixed')).toBe(true) - expect(popover?.style.top).toBe('296px') + expect(popover?.dataset.placement).toBe('below') + expect(popover?.style.top).toBe('324px') expect(popover?.style.left).toBe('260px') }) - it('portals the model picker above overflow-clipped composers', () => { + it('opens thinking controls above a bottom composer', () => { + renderComposer({ onPickThinkingLevel: vi.fn() }) + const trigger = button('Reasoning effort: medium') as HTMLButtonElement + trigger.getBoundingClientRect = () => ({ + top: 700, + right: 420, + bottom: 720, + left: 400, + width: 20, + height: 20, + x: 400, + y: 700, + toJSON: () => ({}), + }) + + act(() => trigger.click()) + + const heading = Array.from(document.body.querySelectorAll('div')) + .find((candidate) => candidate.textContent === 'Thinking level') + const popover = heading?.parentElement + expect(popover?.dataset.placement).toBe('above') + expect(popover?.style.top).toBe('696px') + expect(popover?.style.transform).toBe('translateY(-100%)') + }) + + it('portals the model picker below a centred composer', () => { renderComposer({ models: [{ provider: 'openai', model: 'gpt-test' }], onPickModel: vi.fn(), @@ -204,7 +230,34 @@ describe('ChatComposer popovers', () => { expect(popover).toBeTruthy() expect(host.contains(popover)).toBe(false) expect(popover?.style.position).toBe('fixed') - expect(popover?.style.top).toBe('292px') + expect(popover?.style.top).toBe('328px') expect(popover?.style.left).toBe('100px') + expect(popover?.style.transform).toBe('') + }) + + it('opens slash commands on the available side of the composer', () => { + const commands = [{ name: 'review', description: 'Review changes', source: 'skill' as const }] + renderComposer({ commands }) + const card = host.querySelector('textarea')?.closest('.relative.z-10') as HTMLDivElement + let top = 300 + card.getBoundingClientRect = () => ({ + top, + right: 520, + bottom: top + 80, + left: 100, + width: 420, + height: 80, + x: 100, + y: top, + toJSON: () => ({}), + }) + + renderComposer({ draft: '/', commands }) + expect((host.querySelector('[data-placement]') as HTMLElement).dataset.placement).toBe('below') + + renderComposer({ draft: '', commands }) + top = 680 + renderComposer({ draft: '/', commands }) + expect((host.querySelector('[data-placement]') as HTMLElement).dataset.placement).toBe('above') }) }) diff --git a/src/renderer/chat/ChatComposer.tsx b/src/renderer/chat/ChatComposer.tsx index a9f39284..c9d23cd8 100644 --- a/src/renderer/chat/ChatComposer.tsx +++ b/src/renderer/chat/ChatComposer.tsx @@ -6,8 +6,9 @@ // textarea with a control row beneath it — model picker on the left, the run // controls on the right, so the send button always sits at the bottom-right, // never floating mid-height. A second card tucks under the main one and sticks -// out below: the worktree selector. Both menus open UPWARD (the composer lives -// at its panel's bottom edge). +// out below: the worktree selector. Menus choose above or below at runtime +// because the composer can live either in an empty-state centre or at the +// panel's bottom edge. // // Capabilities (from the agent panel composer): image attachments, thinking // level, prompt modes, context compaction, the stats chip, the slash-command @@ -44,6 +45,8 @@ import { NodePopover, useNodePopover, useViewportPopoverPosition, + verticalPopoverPosition, + type ViewportPopoverPosition, COMPOSER_POPOVER_SURFACE, } from '../../cateAgent/renderer/CateAgentPanelChrome' import type { JoinedWorktree } from '../stores/useWorktrees' @@ -69,15 +72,15 @@ const promptModeLabel = (mode: ComposerPromptMode): string => { const worktreeLabel = (wt: JoinedWorktree | undefined): string => wt?.label || wt?.branch || (wt?.isPrimary ? 'main' : 'worktree') -// --- an upward-opening portal menu anchored above a trigger -------------------- -const UpwardMenu: React.FC<{ - anchor: DOMRect +// --- a portal menu anchored on the roomier side of a trigger ------------------- +const AnchoredMenu: React.FC<{ + pos: ViewportPopoverPosition width: number onClose: () => void triggerRef?: React.RefObject children: React.ReactNode }> = ({ - anchor, + pos, width, onClose, triggerRef, @@ -106,7 +109,13 @@ const UpwardMenu: React.FC<{ ref={ref} role="listbox" className={`fixed z-[9999] max-h-[340px] overflow-y-auto no-scrollbar p-1.5 ${COMPOSER_POPOVER_SURFACE}`} - style={{ left: anchor.left, bottom: window.innerHeight - anchor.top + 6, width }} + data-placement={pos.placement} + style={{ + top: pos.top, + left: pos.left, + width, + transform: pos.placement === 'above' ? 'translateY(-100%)' : undefined, + }} > {children}
, @@ -272,8 +281,9 @@ export const ChatComposer: React.FC = ({ modelPillRef, modelOpen, (rect) => ({ - top: rect.top - 8, left: Math.max(8, Math.min(rect.left, window.innerWidth - 288)), + gap: 8, + height: 320, }), ) const [wtAnchor, setWtAnchor] = React.useState(null) @@ -290,10 +300,11 @@ export const ChatComposer: React.FC = ({ } = useNodePopover( promptModeBtn, (rect) => ({ - top: rect.top - 8, left: Math.max(8, Math.min(rect.left, window.innerWidth - 228)), + gap: 8, }), ) + const composerCardRef = React.useRef(null) // Slash popup is active when the draft starts with "/" and has no spaces // before the cursor — i.e. the user is still picking a command name. @@ -372,6 +383,7 @@ export const ChatComposer: React.FC = ({
{/* Main composer card */}
{ @@ -422,6 +434,11 @@ export const ChatComposer: React.FC = ({ selectedIdx={selectedIdx} onPick={acceptCommand} onHover={setSelectedIdx} + placement={ + composerCardRef.current + ? verticalPopoverPosition(composerCardRef.current.getBoundingClientRect(), 6, 240).placement + : 'above' + } /> )} {onRemoveImage && } @@ -523,7 +540,7 @@ export const ChatComposer: React.FC = ({ top: modelPos.top, left: modelPos.left, width: 280, - transform: 'translateY(-100%)', + transform: modelPos.placement === 'above' ? 'translateY(-100%)' : undefined, zIndex: 9999, }} onPick={(m) => { @@ -688,7 +705,15 @@ export const ChatComposer: React.FC = ({ )} {onPickWorktree && wtAnchor && ( - + {creating && onCreateWorktree ? ( = ({ )} )} - + )}
) @@ -760,7 +785,7 @@ function CompactButton({ const popW = 200 let left = r.left if (left + popW > window.innerWidth - 8) left = window.innerWidth - popW - 8 - return { top: r.top - 6, left } + return { left, gap: 6 } }, ) return ( @@ -837,7 +862,10 @@ function StatsChip({ const btnRef = React.useRef(null) const { open, setOpen, popoverRef, pos, portalTarget } = useNodePopover( btnRef, - (r) => ({ top: r.top - 6, left: r.left }), + (r) => ({ + left: Math.max(8, Math.min(r.left, window.innerWidth - 268)), + gap: 6, + }), ) if (!stats) return null const ctx = stats.contextUsage @@ -952,14 +980,21 @@ function SlashPopup({ selectedIdx, onPick, onHover, + placement, }: { commands: CodingSlashCommand[] selectedIdx: number onPick: (cmd: CodingSlashCommand) => void onHover: (idx: number) => void + placement: 'above' | 'below' }) { return ( -
+
{commands.map((cmd, i) => { const active = i === selectedIdx return ( diff --git a/src/renderer/chat/ChatComposer.worktreeToggle.test.tsx b/src/renderer/chat/ChatComposer.worktreeToggle.test.tsx index 1fec456d..0a99a71c 100644 --- a/src/renderer/chat/ChatComposer.worktreeToggle.test.tsx +++ b/src/renderer/chat/ChatComposer.worktreeToggle.test.tsx @@ -62,4 +62,52 @@ describe('ChatComposer worktree selector', () => { mouseClick(selector) expect(document.body.querySelector('[role="listbox"]')).toBeNull() }) + + it('opens on the side of the selector with enough viewport space', () => { + act(() => { + root.render( + , + ) + }) + const selector = host.querySelector('button[title="Worktree this task branches off and lands back into"]') as HTMLButtonElement + let top = 300 + selector.getBoundingClientRect = () => ({ + top, + right: 180, + bottom: top + 20, + left: 100, + width: 80, + height: 20, + x: 100, + y: top, + toJSON: () => ({}), + }) + + mouseClick(selector) + expect((document.body.querySelector('[role="listbox"]') as HTMLElement).dataset.placement).toBe('below') + + mouseClick(selector) + top = 700 + mouseClick(selector) + expect((document.body.querySelector('[role="listbox"]') as HTMLElement).dataset.placement).toBe('above') + }) }) diff --git a/src/renderer/docking/DockTabBar.tsx b/src/renderer/docking/DockTabBar.tsx index 736d56b6..50828206 100644 --- a/src/renderer/docking/DockTabBar.tsx +++ b/src/renderer/docking/DockTabBar.tsx @@ -16,6 +16,7 @@ import { useAgentInfoByPanel } from '../hooks/useAgentPanelInfo' import { worktreeTitleStyle } from '../lib/worktreeTitleStyle' import { isMiddleClick } from '../lib/mouse' import { isWorktreePanelType } from '../../shared/panels' +import { useActiveChatWorktreeByPanel } from '../../cateAgent/renderer/cateAgentStore' const AWAIT_COLOR = '#c08a5a' @@ -26,9 +27,11 @@ const AWAIT_COLOR = '#c08a5a' // an agent logo (an , which ignores `color`), and tinting it would clash // with the per-agent icon swap. function useWorktreeColorByPanel(): Record { - return useAppStore(useShallow((s) => { + const workspaces = useAppStore(useShallow((s) => s.workspaces)) + const activeChatWorktreeByPanel = useActiveChatWorktreeByPanel() + return React.useMemo(() => { const out: Record = {} - for (const ws of s.workspaces) { + for (const ws of workspaces) { const worktrees = ws.worktrees ?? [] if (worktrees.length < 2) continue // isPrimary is a live-git fact, no longer persisted; the primary record is @@ -36,12 +39,15 @@ function useWorktreeColorByPanel(): Record { const primary = worktrees.find((w) => w.path === ws.rootPath) for (const panel of Object.values(ws.panels)) { if (!isWorktreePanelType(panel.type)) continue - const wt = worktrees.find((w) => w.id === panel.worktreeId) ?? primary + const worktreeId = panel.type === 'cateAgent' + ? activeChatWorktreeByPanel[panel.id] + : panel.worktreeId + const wt = worktrees.find((w) => w.id === worktreeId) ?? primary if (wt?.color) out[panel.id] = wt.color } } return out - })) + }, [activeChatWorktreeByPanel, workspaces]) } // Type → icon/tint mirrors the Spotlight overlay so tabs, search results, and diff --git a/src/renderer/lib/agent/codingAgentDriver.ts b/src/renderer/lib/agent/codingAgentDriver.ts index edee9dec..d2e0e92e 100644 --- a/src/renderer/lib/agent/codingAgentDriver.ts +++ b/src/renderer/lib/agent/codingAgentDriver.ts @@ -356,7 +356,7 @@ export async function handleCodingAgentMethod( const panel = runPanel(workspaceId, runId) const run = panel?.codingAgentRun if (!panel || !run) return { ok: false, error: 'coding-agent-not-found' } - terminalRegistry.dispose(panel.id) + terminalRegistry.terminate(panel.id) useAppStore.getState().setPanelCodingAgentRun(workspaceId, panel.id, { ...run, stoppedAt: Date.now(), diff --git a/src/renderer/lib/inheritWorktree.test.ts b/src/renderer/lib/inheritWorktree.test.ts index 27bf391f..08dd9c97 100644 --- a/src/renderer/lib/inheritWorktree.test.ts +++ b/src/renderer/lib/inheritWorktree.test.ts @@ -40,10 +40,11 @@ describe('inheritedWorktreeFromSelection', () => { expect(inheritedWorktreeFromSelection(state, ps)).toEqual({ cwd: '/repo/wt', worktreeId: 'wt-a' }) }) - it('inherits the worktreeId from a selected agent (agents carry no cwd)', () => { + it('inherits the active chat worktree from a selected agent, ignoring panel residue', () => { const state = canvasState(['n1'], true, { n1: node('a1') }) - const ps = panels([{ id: 'a1', type: 'cateAgent', title: 'Agent 1', isDirty: false, worktreeId: 'wt-b' }]) - expect(inheritedWorktreeFromSelection(state, ps)).toEqual({ cwd: undefined, worktreeId: 'wt-b' }) + const ps = panels([{ id: 'a1', type: 'cateAgent', title: 'Agent 1', isDirty: false, worktreeId: 'wt-residue' }]) + expect(inheritedWorktreeFromSelection(state, ps, () => 'wt-chat')) + .toEqual({ cwd: undefined, worktreeId: 'wt-chat' }) }) it('returns {} when the selected node is not a terminal or agent', () => { diff --git a/src/renderer/lib/inheritWorktree.ts b/src/renderer/lib/inheritWorktree.ts index f1580768..4cdb334e 100644 --- a/src/renderer/lib/inheritWorktree.ts +++ b/src/renderer/lib/inheritWorktree.ts @@ -15,6 +15,7 @@ import { isWorktreePanelType } from '../../shared/panels' import type { CanvasStoreState } from '../stores/canvas/storeTypes' import { focusedNodeId } from '../stores/canvas/selectionModel' import { activeDockPanelId } from '../../shared/collectPanelIds' +import { activeChatWorktreeIdForPanel } from '../../cateAgent/renderer/cateAgentStore' export interface InheritedWorktree { /** Explicit working directory of the selected terminal (a dropped folder or a @@ -32,11 +33,15 @@ export interface InheritedWorktree { export function inheritedWorktreeFromSelection( canvasState: Pick, panels: Record | undefined, + agentWorktreeIdForPanel: (panelId: string) => string | undefined = activeChatWorktreeIdForPanel, ): InheritedWorktree { const nodeId = focusedNodeId(canvasState) if (!nodeId || !panels) return {} const panelId = activeDockPanelId(canvasState.nodes[nodeId]?.dockLayout) const panel = panelId ? panels[panelId] : undefined if (!panel || !isWorktreePanelType(panel.type)) return {} + if (panel.type === 'cateAgent') { + return { cwd: undefined, worktreeId: agentWorktreeIdForPanel(panel.id) } + } return { cwd: panel.cwd, worktreeId: panel.worktreeId } } diff --git a/src/renderer/lib/runAction.ts b/src/renderer/lib/runAction.ts index 55a92b65..b4603e1a 100644 --- a/src/renderer/lib/runAction.ts +++ b/src/renderer/lib/runAction.ts @@ -22,6 +22,7 @@ import { provideAppStoreForHistory } from '../stores/canvas/historySlice' import { inheritedWorktreeFromSelection } from './inheritWorktree' import { closePanelWithConfirm } from './closePanelWithConfirm' import { activeDockPanelId } from '../../shared/collectPanelIds' +import { seedAgentPanelWithWorktreeChat } from '../../cateAgent/renderer/seedWorktreeChat' /** * Ensures the workspace has a rootPath before proceeding. @@ -119,12 +120,15 @@ export async function runAction( const placement = placementForActivePanel() const wsId = await ensureWorkspaceFolder(selectedWorkspaceId) if (wsId) { - // Same worktree inheritance as newTerminal — an agent carries only a - // worktreeId (no cwd), so bind it when the selection has one. + // Same worktree inheritance as newTerminal, but the target belongs to + // the new agent's first chat rather than its panel record. const canvas = canvasStore() const wt = canvas ? inheritedWorktreeFromSelection(canvas, appStore().getWorkspace(wsId)?.panels) : {} const panelId = appStore().createCateAgent(wsId, undefined, placement) - if (panelId && wt.worktreeId) appStore().setPanelWorktreeId(wsId, panelId, wt.worktreeId) + const rootPath = appStore().getWorkspace(wsId)?.rootPath + if (panelId && rootPath && wt.worktreeId) { + await seedAgentPanelWithWorktreeChat(wsId, rootPath, panelId, wt.worktreeId) + } } break } diff --git a/src/renderer/lib/terminal/terminalLifecycle.test.ts b/src/renderer/lib/terminal/terminalLifecycle.test.ts index 3f984461..796f9d0d 100644 --- a/src/renderer/lib/terminal/terminalLifecycle.test.ts +++ b/src/renderer/lib/terminal/terminalLifecycle.test.ts @@ -269,6 +269,32 @@ describe('spawn → wire → dispose happy path', () => { LC.dispose('panel-race') }) + it('terminates the PTY without disposing the xterm kept by a stopped panel', async () => { + terminalCreate.mockResolvedValueOnce('pty-stopped') + const entry = await LC.getOrCreate('panel-stopped', { workspaceId: 'ws-1' }) + const fake = terminalInstances[0] + + LC.terminate('panel-stopped') + + expect(terminalKill).toHaveBeenCalledTimes(1) + expect(terminalKill).toHaveBeenCalledWith('pty-stopped') + expect(statusUnregisterTerminal).toHaveBeenCalledWith('pty-stopped', 'ws-1') + expect(RS.has('panel-stopped')).toBe(true) + expect(RS.ptyIdForPanel('panel-stopped')).toBeNull() + expect(RS.panelIdForPty('pty-stopped')).toBeNull() + expect(entry.alive).toBe(false) + expect(fake.disposeCount).toBe(0) + expect(dataDisposers[0]).not.toHaveBeenCalled() + + // Closing the panel still performs the final renderer teardown without + // trying to kill or unregister the already-terminated PTY again. + LC.dispose('panel-stopped') + expect(fake.disposeCount).toBe(1) + expect(dataDisposers[0]).toHaveBeenCalledTimes(1) + expect(terminalKill).toHaveBeenCalledTimes(1) + expect(statusUnregisterTerminal).toHaveBeenCalledTimes(1) + }) + it('writes initialInput into the terminal after spawn', async () => { await LC.getOrCreate('panel-input', { workspaceId: 'ws-1', initialInput: 'npm test\r' }) expect(terminalInstances[0].writes).toContain('npm test\r') diff --git a/src/renderer/lib/terminal/terminalLifecycle.ts b/src/renderer/lib/terminal/terminalLifecycle.ts index 95bd843e..e0692fcf 100644 --- a/src/renderer/lib/terminal/terminalLifecycle.ts +++ b/src/renderer/lib/terminal/terminalLifecycle.ts @@ -558,6 +558,26 @@ function teardownEntry(entry: RegistryEntry): void { try { terminal.dispose() } catch { /* ignore */ } } +/** + * Kill a terminal's PTY while retaining its xterm instance and scrollback. + * Used when a process stops but its panel remains on the canvas; dispose() + * would remove the xterm DOM and leave the still-mounted panel blank. + */ +export function terminate(panelId: string): void { + const entry = registry.get(panelId) + if (!entry) return + + const { ptyId, workspaceId } = entry + entry.alive = false + entry.ptyId = '' + if (ptyId) ptyToPanel.delete(ptyId) + + if (ptyId) { + window.electronAPI.terminalKill(ptyId).catch((err) => log.warn('[terminal] Kill failed:', err)) + useStatusStore.getState().unregisterTerminal(ptyId, workspaceId) + } +} + /** * Fully tears down a terminal: kills the PTY, disposes all xterm addons and * the Terminal instance, removes IPC listeners, and removes the entry from diff --git a/src/renderer/lib/terminal/terminalRegistry.ts b/src/renderer/lib/terminal/terminalRegistry.ts index 94e6a6d1..fd2ba3cb 100644 --- a/src/renderer/lib/terminal/terminalRegistry.ts +++ b/src/renderer/lib/terminal/terminalRegistry.ts @@ -17,6 +17,7 @@ import './terminalSettings' import { getOrCreate, + terminate, dispose, disposeWorkspace, release, @@ -49,6 +50,7 @@ export { isTerminalPasteChord, isTerminalCopyChord } from './terminalInput' export const terminalRegistry = { getOrCreate, + terminate, attach, detach, dispose, diff --git a/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts b/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts index 9bbd06a4..44d1219d 100644 --- a/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts +++ b/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts @@ -335,6 +335,23 @@ describe('workspace.json + session.json round-trip', () => { expect(restored.terminalCwds).toEqual({ 'term-1': WORKTREE_PATH }) }) + it('drops legacy worktree tags from Cate Agent panel records', () => { + const { snapshot } = buildSnapshot() + snapshot.panels!['agent-1'] = panel({ + id: 'agent-1', + type: 'cateAgent', + worktreeId: 'wt-residue', + }) + + const wsFile = throughDisk(buildWorkspaceFile(snapshot, ROOT)) + const sessFile = throughDisk(buildSessionFile(snapshot)) + expect(sessFile.panels['agent-1']).toBeUndefined() + + sessFile.panels['agent-1'] = { panelId: 'agent-1', worktreeId: 'wt-residue' } + const restored = projectFilesToSnapshot(wsFile, sessFile, ROOT) + expect(restored.panels!['agent-1'].worktreeId).toBeUndefined() + }) + it('a Windows-style root round-trips editor paths with native separators', () => { const winRoot = 'C:\\Users\\dev\\repo' const { snapshot } = buildSnapshot() diff --git a/src/renderer/lib/workspace/sessionSerialize.ts b/src/renderer/lib/workspace/sessionSerialize.ts index c9a06d79..12c873b6 100644 --- a/src/renderer/lib/workspace/sessionSerialize.ts +++ b/src/renderer/lib/workspace/sessionSerialize.ts @@ -84,13 +84,14 @@ export function buildSessionFile( dockWindows?: DetachedDockWindowSnapshot[], ): ProjectSessionFile { // Machine-local per-panel facts for every placed panel, keyed by id: the - // worktree tag, the terminal's live working directory, and unsaved scratch + // terminal worktree tag, live working directory, and unsaved scratch // content — all kept out of the committed workspace.json. const panels: Record = {} for (const p of Object.values(snapshot.panels ?? {})) { const workingDirectory = snapshot.terminalCwds?.[p.id] + const worktreeId = p.type === 'terminal' ? p.worktreeId : undefined if ( - !p.worktreeId && + !worktreeId && !workingDirectory && !p.unsavedContent && !p.agentSession && @@ -100,7 +101,7 @@ export function buildSessionFile( panelId: p.id, workingDirectory, unsavedContent: p.unsavedContent, - worktreeId: p.worktreeId, + worktreeId, agentSession: p.agentSession, codingAgentRun: p.codingAgentRun, } @@ -145,7 +146,7 @@ export function projectFilesToSnapshot( filePath: ref.filePath ? toAbsolutePath(ref.filePath, rootPath) : undefined, ...pickPassthroughPanelFields(ref), // Re-attach the machine-local facts kept out of the committed file. - worktreeId: sp?.worktreeId, + worktreeId: ref.type === 'terminal' ? sp?.worktreeId : undefined, unsavedContent: sp?.unsavedContent, // The agent session to resume in this terminal — TerminalPanel types // the resume command into the fresh shell and clears the field. diff --git a/src/renderer/lib/workspace/windowPanelSync.ts b/src/renderer/lib/workspace/windowPanelSync.ts index aeeb4baf..38d03ec6 100644 --- a/src/renderer/lib/workspace/windowPanelSync.ts +++ b/src/renderer/lib/workspace/windowPanelSync.ts @@ -24,6 +24,11 @@ import { panelRowLabel } from '../panelTitle' import { useActivePanelStore } from '../activePanel' import { parseLocator } from '../../../shared/runtimeLocator' import { browserPanelUrl, isStartPageUrl, type WindowPanelReport } from '../../../shared/types' +import { + activeChatWorktreeIdForPanel, + useCateAgentStore, +} from '../../../cateAgent/renderer/cateAgentStore' +import { useChatsStore } from '../../stores/chatsStore' let cleanup: (() => void) | null = null @@ -133,7 +138,9 @@ export function setupWindowPanelSync(): () => void { : {}), focused: p.id === activePanelId, parentCanvasId: childToCanvas.get(p.id), - worktreeId: p.worktreeId, + worktreeId: p.type === 'cateAgent' + ? activeChatWorktreeIdForPanel(p.id) + : p.type === 'terminal' ? p.worktreeId : undefined, agentState: agentInfo[p.id]?.state, agentName: agentInfo[p.id]?.name ?? null, hasPorts: withPorts.has(p.id), @@ -175,6 +182,8 @@ export function setupWindowPanelSync(): () => void { schedule() }) const unsubscribeActivePanel = useActivePanelStore.subscribe(schedule) + const unsubscribeActiveChats = useCateAgentStore.subscribe(schedule) + const unsubscribeChats = useChatsStore.subscribe(schedule) syncCanvasSubscriptions() // Re-report when agent state / ports change so detached rows track the owner's @@ -192,6 +201,8 @@ export function setupWindowPanelSync(): () => void { cleanup = () => { unsubscribeApp() unsubscribeActivePanel() + unsubscribeActiveChats() + unsubscribeChats() unsubscribeStatus() for (const unsub of canvasSubs.values()) unsub() canvasSubs.clear() diff --git a/src/renderer/lib/workspace/worktreePersistence.test.ts b/src/renderer/lib/workspace/worktreePersistence.test.ts index ccc05085..f6d1dc25 100644 --- a/src/renderer/lib/workspace/worktreePersistence.test.ts +++ b/src/renderer/lib/workspace/worktreePersistence.test.ts @@ -1,6 +1,6 @@ // ============================================================================= // Worktree session-persistence regression tests. Worktree colors/labels and each -// terminal/agent panel's worktree tag are machine-local (gitignored checkouts), +// terminal panel's worktree tag are machine-local (gitignored checkouts), // so they live in session.json. These tests pin that they survive a save/restore // round-trip — previously both were dropped, so colors got re-rolled from the // palette on restart and terminals came back tagged to the primary worktree even @@ -89,6 +89,23 @@ function snapshotWithWorktrees(): SessionSnapshot { describe('worktree session persistence', () => { beforeEach(reset) + it('allows terminal tags but strips Cate Agent panel residue', () => { + const ws = useAppStore.getState().addWorkspace('WT', ROOT, 'ws') + useAppStore.getState().addPanel(ws, { + id: 'terminal-1', type: 'terminal', title: 'Terminal', isDirty: false, + }) + useAppStore.getState().addPanel(ws, { + id: 'agent-1', type: 'cateAgent', title: 'Agent', isDirty: false, worktreeId: 'legacy', + }) + + useAppStore.getState().setPanelWorktreeId(ws, 'terminal-1', 'wt-x') + useAppStore.getState().setPanelWorktreeId(ws, 'agent-1', 'wt-x') + + const panels = useAppStore.getState().getWorkspace(ws)!.panels + expect(panels['terminal-1'].worktreeId).toBe('wt-x') + expect(panels['agent-1'].worktreeId).toBeUndefined() + }) + it('restoreSession hydrates the worktree registry (colors/labels) into the workspace', async () => { const ws = useAppStore.getState().addWorkspace('WT', ROOT, 'ws') await restoreSession(snapshotWithWorktrees(), ws) diff --git a/src/renderer/panels/CanvasPanel.tsx b/src/renderer/panels/CanvasPanel.tsx index 9bb55a87..ce641788 100644 --- a/src/renderer/panels/CanvasPanel.tsx +++ b/src/renderer/panels/CanvasPanel.tsx @@ -32,6 +32,7 @@ import { import { getPanelDef } from '../panels/registry' import { inheritedWorktreeFromSelection } from '../lib/inheritWorktree' import { activeDockPanelId } from '../../shared/collectPanelIds' +import { seedAgentPanelWithWorktreeChat } from '../../cateAgent/renderer/seedWorktreeChat' // Re-export the lookup helpers so existing callers (drag dispatcher, drop // resolver) keep working through the same import path. New code should import @@ -257,7 +258,10 @@ export default function CanvasPanel({ panelId, workspaceId, renderPanelContent } const app = useAppStore.getState() const wt = inheritedWorktreeFromSelection(store.getState(), app.getWorkspace(wsId)?.panels) const newId = app.createCateAgent(wsId, undefined, here()) - if (newId && wt.worktreeId) app.setPanelWorktreeId(wsId, newId, wt.worktreeId) + const rootPath = app.getWorkspace(wsId)?.rootPath + if (newId && rootPath && wt.worktreeId) { + await seedAgentPanelWithWorktreeChat(wsId, rootPath, newId, wt.worktreeId) + } }, [workspaceId, here, store]) return ( diff --git a/src/renderer/sidebar/WorkspaceTab.tsx b/src/renderer/sidebar/WorkspaceTab.tsx index d068a986..29eb2081 100644 --- a/src/renderer/sidebar/WorkspaceTab.tsx +++ b/src/renderer/sidebar/WorkspaceTab.tsx @@ -31,6 +31,7 @@ import { InlineEditInput } from './InlineEditInput' import { WorkspaceSkillsTree } from './WorkspaceSkillsTree' import { canvasKey, toggleCollapsed, useTreeCollapseStore } from './treeCollapse' import { Tooltip } from '../ui/Tooltip' +import { useActiveChatWorktreeByPanel } from '../../cateAgent/renderer/cateAgentStore' // Stable empty map so the ports selector returns a referentially-constant value // when a workspace has no status entry (a fresh `{}` each render would defeat @@ -291,6 +292,7 @@ export const WorkspaceTab: React.FC = ({ const ws = s.workspaces.find((w) => w.id === workspace.id) return ws?.worktrees ?? workspace.worktrees ?? [] })) + const activeChatWorktreeByPanel = useActiveChatWorktreeByPanel() // Ports in the status store are keyed by ptyId, but panel rows are keyed by @@ -567,8 +569,12 @@ export const WorkspaceTab: React.FC = ({ const wt = worktrees.find((w) => w.id === worktreeId) ?? worktrees.find((w) => w.path === workspace.rootPath) return wt?.color } - const worktreeColorFor = (panelId: string): string | undefined => - worktreeColorForId(panels[panelId]?.worktreeId) + const worktreeColorFor = (panelId: string): string | undefined => { + const panel = panels[panelId] + return worktreeColorForId( + panel?.type === 'cateAgent' ? activeChatWorktreeByPanel[panelId] : panel?.worktreeId, + ) + } // A panel living in another window — click focuses that window and reveals it. // Read-only (no rename/close), since it isn't hosted here, but otherwise diff --git a/src/renderer/stores/appStore/worktreeSlice.ts b/src/renderer/stores/appStore/worktreeSlice.ts index a735f8c7..5e0823a6 100644 --- a/src/renderer/stores/appStore/worktreeSlice.ts +++ b/src/renderer/stores/appStore/worktreeSlice.ts @@ -8,7 +8,8 @@ import type { AppSet, AppGet, AppStoreActions } from './types' import { pickWorktreeColor, setPanelField } from './helpers' import { terminalRegistry } from '../../lib/terminal/terminalRegistry' import { useSettingsStore } from '../settingsStore' -import { isWorktreePanelType } from '../../../shared/panels' +import { activeChatWorktreeIdForPanel } from '../../../cateAgent/renderer/cateAgentStore' +import { useChatsStore } from '../chatsStore' type WorktreeSliceActions = Pick< AppStoreActions, @@ -77,16 +78,29 @@ export function createWorktreeSlice(set: AppSet, get: AppGet): WorktreeSliceActi }, removeWorktree(wsId, worktreeId) { - // Optionally destroy the worktree's terminal/agent panels (PTYs killed, - // pi sessions disposed) before we drop the worktree record. Done outside + // Optionally destroy the worktree's terminal panels (PTYs killed) before + // we drop the worktree record. Agent targets live on chats, not panels. + // Done outside // the set() updater because closePanel runs its own teardown + set(). if (useSettingsStore.getState().closeWorktreePanelsOnDelete) { const ws = get().workspaces.find((w) => w.id === wsId) const doomed = Object.values(ws?.panels ?? {}).filter( - (p) => p.worktreeId === worktreeId && isWorktreePanelType(p.type), + (p) => ( + (p.type === 'terminal' && p.worktreeId === worktreeId) || + (p.type === 'cateAgent' && activeChatWorktreeIdForPanel(p.id) === worktreeId) + ), ) for (const p of doomed) get().closePanel(wsId, p.id) } + const ws = get().workspaces.find((w) => w.id === wsId) + const chats = useChatsStore.getState() + if (ws?.rootPath && chats.loadedRoots[ws.rootPath]) { + for (const chat of chats.getChats(ws.rootPath)) { + if (chat.worktreeId === worktreeId) { + chats.patchChat(ws.rootPath, chat.id, { worktreeId: undefined }) + } + } + } set((state) => ({ workspaces: state.workspaces.map((ws) => { if (ws.id !== wsId) return ws @@ -130,7 +144,11 @@ export function createWorktreeSlice(set: AppSet, get: AppGet): WorktreeSliceActi }, setPanelWorktreeId(wsId, panelId, worktreeId) { - setPanelField(set, wsId, panelId, (panel) => ({ ...panel, worktreeId })) + setPanelField(set, wsId, panelId, (panel) => ( + panel.type === 'terminal' + ? { ...panel, worktreeId } + : { ...panel, worktreeId: undefined } + )) }, respawnPanelTerminal(wsId, panelId, cwd, worktreeId) { diff --git a/src/renderer/stores/chatsStore.test.ts b/src/renderer/stores/chatsStore.test.ts index 8317f8b1..725b68f3 100644 --- a/src/renderer/stores/chatsStore.test.ts +++ b/src/renderer/stores/chatsStore.test.ts @@ -69,10 +69,11 @@ describe('chat host ownership', () => { it('creates new chats in the requested host', () => { const sidebar = useChatsStore.getState().createChat(ROOT, 'Sidebar chat') - const panel = useChatsStore.getState().createChat(ROOT, 'Panel chat', 'agent-a') + const panel = useChatsStore.getState().createChat(ROOT, 'Panel chat', 'agent-a', 'wt-feature') expect(sidebar.hostPanelId).toBeUndefined() expect(panel.hostPanelId).toBe('agent-a') + expect(panel.worktreeId).toBe('wt-feature') }) it('coalesces concurrent loads so a late response cannot overwrite a move', async () => { diff --git a/src/renderer/stores/chatsStore.ts b/src/renderer/stores/chatsStore.ts index b52b2690..99fe4ee0 100644 --- a/src/renderer/stores/chatsStore.ts +++ b/src/renderer/stores/chatsStore.ts @@ -38,7 +38,7 @@ interface ChatsStoreActions { /** Find one chat by id (undefined if absent). */ getChat: (rootPath: string, id: string) => Chat | undefined /** Create a fresh empty Cate Agent chat in the sidebar (default) or one panel. */ - createChat: (rootPath: string, title: string, hostPanelId?: string) => Chat + createChat: (rootPath: string, title: string, hostPanelId?: string, worktreeId?: string) => Chat /** Move one chat to an Agent panel, or to the sidebar when panelId is null. */ moveChat: (rootPath: string, id: string, panelId: string | null) => void /** Return every chat owned by any of these closing panels to the sidebar. */ @@ -100,7 +100,7 @@ export const useChatsStore = create((set, get) => ({ return (get().chatsByRoot[rootPath] ?? []).find((c) => c.id === id) }, - createChat(rootPath, title, hostPanelId) { + createChat(rootPath, title, hostPanelId, worktreeId) { const now = Date.now() const chat: Chat = { id: generateId(), @@ -108,6 +108,7 @@ export const useChatsStore = create((set, get) => ({ createdAt: now, updatedAt: now, ...(hostPanelId ? { hostPanelId } : {}), + ...(worktreeId ? { worktreeId } : {}), } const next = [...(get().chatsByRoot[rootPath] ?? []), chat] set((s) => ({ diff --git a/src/renderer/stores/useParallelWork.ts b/src/renderer/stores/useParallelWork.ts index aae97755..27346299 100644 --- a/src/renderer/stores/useParallelWork.ts +++ b/src/renderer/stores/useParallelWork.ts @@ -20,9 +20,11 @@ import type { WorktreeMeta } from '../../shared/types' import type { PrListItem } from '../sidebar/CreateWorktreeForm' import type { NativeContextMenuItem } from '../../shared/electron-api' import { isLocalLocator } from '../../shared/runtimeLocator' -import { isWorktreePanelType, type WorktreePanelType } from '../../shared/panels' +import type { WorktreePanelType } from '../../shared/panels' import { errorMessage } from '../lib/errorMessage' import { pathKey } from '../../shared/pathUtils' +import { seedAgentPanelWithWorktreeChat } from '../../cateAgent/renderer/seedWorktreeChat' +import { activeChatWorktreeIdForPanel } from '../../cateAgent/renderer/cateAgentStore' /** Apply a color/label change to a worktree's UI metadata, creating the metadata * record when none exists yet (a worktree discovered only from git has its path @@ -166,9 +168,11 @@ export function useParallelWork( type === 'terminal' ? s.createTerminal(workspaceId, undefined, undefined, placement, wt.path) : s.createCateAgent(workspaceId, undefined, placement) - if (panelId) s.setPanelWorktreeId(workspaceId, panelId, wt.id) + if (!panelId) return + if (type === 'terminal') s.setPanelWorktreeId(workspaceId, panelId, wt.id) + else void seedAgentPanelWithWorktreeChat(workspaceId, rootPath, panelId, wt.id) }, - [workspaceId], + [rootPath, workspaceId], ) const handlePublish = useCallback( @@ -287,12 +291,15 @@ export function useParallelWork( } const dirty = !!status?.dirty const branchAhead = (status?.ahead ?? 0) > 0 - // When the close-on-delete setting is on, count the terminal/agent panels - // bound to this worktree so the prompt warns about what it'll tear down. + // When close-on-delete is on, count terminals by their panel tag and + // agents by their active chat tag. const ws = useAppStore.getState().workspaces.find((w) => w.id === workspaceId) const panelCount = useSettingsStore.getState().closeWorktreePanelsOnDelete ? Object.values(ws?.panels ?? {}).filter( - (p) => p.worktreeId === wt.id && isWorktreePanelType(p.type), + (p) => ( + (p.type === 'terminal' && p.worktreeId === wt.id) || + (p.type === 'cateAgent' && activeChatWorktreeIdForPanel(p.id) === wt.id) + ), ).length : 0 const ok = window.confirm( diff --git a/src/shared/types.ts b/src/shared/types.ts index 9baad3df..e9bc9294 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -115,9 +115,8 @@ export interface PanelState { cwd?: string /** Document panels only: sub-type discriminator for the viewer. */ documentType?: 'pdf' | 'docx' | 'image' - /** Id of the WorktreeMeta in the parent workspace that this panel is - * associated with. Drives the per-panel color accent for terminal + agent - * panels; terminals also expose the title-bar "switch worktree" pill. */ + /** Terminal panels only: id of the WorktreeMeta in the parent workspace that + * this terminal is associated with. Cate Agent worktrees live on Chat. */ worktreeId?: string /** Terminal panels only. Set to true the first time the user renames the * tab so that subsequent OSC-0/1/2 title escapes from the running agent @@ -1070,8 +1069,8 @@ export interface ProjectSessionPanel { ptyId?: string workingDirectory?: string unsavedContent?: string - /** Worktree this terminal/agent panel is tagged with. Machine-local (worktree - * ids are runtime uuids), so it lives in session.json, not workspace.json. */ + /** Worktree this terminal panel is tagged with. Machine-local (worktree ids + * are runtime uuids), so it lives in session.json, not workspace.json. */ worktreeId?: string /** Agent-CLI session running in this terminal at save time. Machine-local * (session ids reference stores on this machine's runtime host). */ @@ -1097,6 +1096,8 @@ export interface Chat { /** The Agent panel that owns this chat. Absence means the workspace Cate * sidebar owns it. A chat is rendered by exactly one of those hosts. */ hostPanelId?: string + /** Worktree this chat's agent runs in. Follows the chat between hosts. */ + worktreeId?: string /** Per-chat model override. The Cate Agent otherwise uses the global default. */ model?: CateAgentModelRef /** On-disk Pi transcript for this chat's sole main-agent session. */ From b192714113c64855e5bdea28fe134285c6c2292c Mon Sep 17 00:00:00 2001 From: Anton Date: Mon, 27 Jul 2026 11:17:50 +0200 Subject: [PATCH 10/12] fix mission agent orchestration integration --- .../cate-orchestrator/index.test.ts | 4 + .../extensions/cate-orchestrator/index.ts | 33 +-- src/cateAgent/main/codingManager.test.ts | 22 +- src/cateAgent/main/codingManager.ts | 11 +- src/cateAgent/main/extensionInstall.test.ts | 57 +++++ src/cateAgent/main/extensionInstall.ts | 45 +++- src/cateAgent/main/installAskUser.ts | 42 +--- src/cateAgent/main/installCanvasMode.ts | 36 +--- src/cateAgent/main/installOrchestrator.ts | 36 +--- src/cateAgent/main/installPlanMode.ts | 60 +----- src/main/extensions/cateApiEndpointManager.ts | 4 + src/main/extensions/cateApiHandlers.test.ts | 124 ++++++++++- src/main/extensions/cateApiHandlers.ts | 201 +++++++++++++++++- src/main/extensions/cateApiReverse.test.ts | 30 +++ src/main/extensions/cateApiReverse.ts | 10 +- src/main/extensions/workspaceCateApi.test.ts | 4 +- src/main/extensions/workspaceCateApi.ts | 3 + src/main/windowPanels.ts | 17 +- .../hooks/useCateHostActionResponder.ts | 2 +- .../lib/agent/codingAgentDriver.test.ts | 196 +++++++++++++++++ src/renderer/lib/agent/codingAgentDriver.ts | 132 +++++++----- .../lib/agent/codingAgentWait.test.ts | 1 + src/renderer/lib/agent/codingAgentWait.ts | 1 + src/renderer/lib/terminal/terminalBuffer.ts | 17 ++ src/renderer/lib/terminal/terminalDriver.ts | 34 +-- .../sessionSerialize.roundTrip.test.ts | 2 + src/renderer/lib/workspace/windowPanelSync.ts | 17 ++ .../panels/keepMountedPanels.test.tsx | 1 + src/renderer/stores/appStore/panelSlice.ts | 1 + src/shared/agents.ts | 16 ++ src/shared/codingAgentRuns.ts | 43 +++- src/shared/types.ts | 7 +- 32 files changed, 952 insertions(+), 257 deletions(-) create mode 100644 src/cateAgent/main/extensionInstall.test.ts create mode 100644 src/renderer/lib/agent/codingAgentDriver.test.ts create mode 100644 src/renderer/lib/terminal/terminalBuffer.ts diff --git a/src/cateAgent/extensions/cate-orchestrator/index.test.ts b/src/cateAgent/extensions/cate-orchestrator/index.test.ts index fe07c66a..4e37e587 100644 --- a/src/cateAgent/extensions/cate-orchestrator/index.test.ts +++ b/src/cateAgent/extensions/cate-orchestrator/index.test.ts @@ -46,6 +46,7 @@ function registeredTools() { beforeEach(() => { process.env.CATE_API = "http://127.0.0.1:1234" process.env.CATE_TOKEN = "supervisor-token" + process.env.CATE_CODING_AGENT_IDS = JSON.stringify(["codex", "pi"]) vi.unstubAllGlobals() }) @@ -55,6 +56,9 @@ describe("cate-orchestrator", () => { expect([...tools.keys()]).toEqual(TOOL_NAMES) expect(tools.get("wait_for_coding_agents").parameters.properties.timeoutSeconds) .toMatchObject({ minimum: 15, maximum: 120, default: 60 }) + expect(tools.get("create_coding_agent").parameters.properties.agentId.anyOf) + .toEqual([{ const: "codex", type: "string" }, { const: "pi", type: "string" }]) + expect(tools.get("wait_for_coding_agents").parameters.required).toContain("runIds") }) it("keeps orchestration tools inactive until orchestration mode is enabled", async () => { diff --git a/src/cateAgent/extensions/cate-orchestrator/index.ts b/src/cateAgent/extensions/cate-orchestrator/index.ts index 987209da..5e1bad63 100644 --- a/src/cateAgent/extensions/cate-orchestrator/index.ts +++ b/src/cateAgent/extensions/cate-orchestrator/index.ts @@ -25,14 +25,21 @@ of architecture, integration, and the final answer. `.trim() -const AgentId = Type.Union([ - Type.Literal("claude-code"), - Type.Literal("codex"), - Type.Literal("cursor"), - Type.Literal("grok"), - Type.Literal("opencode"), - Type.Literal("pi"), -]) +function agentIdSchema() { + let ids: string[] = [] + try { + const parsed = JSON.parse(process.env.CATE_CODING_AGENT_IDS || "[]") + if (Array.isArray(parsed)) { + ids = [...new Set(parsed.filter((id): id is string => typeof id === "string" && id.length > 0))] + } + } catch { + // Renderer validation remains authoritative if a hand-run extension has a + // malformed environment; Cate-managed sessions always provide this value. + } + return ids.length > 0 + ? Type.Union(ids.map((id) => Type.Literal(id)) as [ReturnType, ...ReturnType[]]) + : Type.String({ minLength: 1, description: "A coding agent registered by Cate." }) +} async function invoke( method: string, @@ -97,18 +104,18 @@ export default function (pi: ExtensionAPI) { name: "create_coding_agent", label: "Create coding agent", description: - "Create a visible coding-agent terminal, bind it to a registered worktree, and give it an initial implementation task. Use this for bounded parallel work whose result you will inspect and integrate. Returns a runId and panelId.", + "Create a visible coding-agent terminal, bind it to a registered worktree, and give it an initial implementation task. Omit agentId to use Cate's first hook-ready registered agent. An explicit choice is accepted only when its hooks are ready in the target checkout. Returns a runId and panelId.", promptSnippet: - "create_coding_agent - start a visible Codex, Claude Code, Cursor, Grok, OpenCode, or Pi worker in a Cate worktree with an initial task.", + "create_coding_agent - start a visible, registered coding-agent worker in a Cate worktree with an initial task.", promptGuidelines: [ "Delegate bounded implementation or investigation tasks when parallel work materially helps; keep architectural ownership and final verification yourself.", "Give each worker a self-contained prompt with scope, constraints, and concrete success criteria. Never ask a worker to create more workers.", "After delegation, call wait_for_coding_agents once and let it block until worker state changes. Do not repeatedly inspect a worker that is still working; inspect after a change or timeout, then send a targeted follow-up only when needed.", "Never create more than five live workers. Reuse a run with send_to_coding_agent when follow-up belongs to the same task.", - "OpenCode runs are one-shot; create a fresh OpenCode run instead of sending a follow-up after it exits.", + "Respect followUpSupported in each run result; create a fresh run when that capability is false.", ], parameters: Type.Object({ - agentId: AgentId, + agentId: Type.Optional(agentIdSchema()), prompt: Type.String({ minLength: 1, description: "Self-contained task, constraints, and success criteria." }), worktreeId: Type.Optional( Type.String({ description: "Registered Cate worktree id. Omit to inherit this Cate Agent panel's worktree or use the primary checkout." }), @@ -157,7 +164,7 @@ export default function (pi: ExtensionAPI) { description: "Efficiently monitor one or more Cate-owned coding agents. Blocks for up to 60 seconds by default but returns immediately when a worker changes state or needs input. Prefer one long wait over repeated polling; inspect only after this reports a change or timeout.", parameters: Type.Object({ - runIds: Type.Optional(Type.Array(Type.String(), { minItems: 1, maxItems: 5 })), + runIds: Type.Array(Type.String(), { minItems: 1, maxItems: 5 }), timeoutSeconds: Type.Optional(Type.Number({ minimum: 15, maximum: 120, default: 60 })), }), async execute(_id, params, signal) { diff --git a/src/cateAgent/main/codingManager.test.ts b/src/cateAgent/main/codingManager.test.ts index 847ca40a..a8eb55d7 100644 --- a/src/cateAgent/main/codingManager.test.ts +++ b/src/cateAgent/main/codingManager.test.ts @@ -43,6 +43,7 @@ import { PiRpcClient } from './piRpcClient' import { prepareCodingDir } from './codingDir' import { syncWorkspaceSkills } from '../../skills/main/skillsMirror' import { CODING_EVENT } from '../../shared/ipc-channels' +import { workspaceCateApi } from '../../main/extensions/workspaceCateApi' const fakeAuthManager = { setOnChange: vi.fn() } as unknown as AuthManager @@ -67,6 +68,7 @@ describe('CodingManager worktree skill preparation', () => { }) const manager = new CodingManager(fakeAuthManager) + const sender = { id: 4, isDestroyed: () => false, send: vi.fn() } as never await manager.create( { panelId: 'panel-worktree', @@ -74,7 +76,7 @@ describe('CodingManager worktree skill preparation', () => { workspaceRoot: '/repo/base', cwd: '/repo/worktree', }, - { id: 4, isDestroyed: () => false, send: vi.fn() } as never, + sender, ) expect(syncWorkspaceSkills).toHaveBeenCalledWith('/repo/base', '/repo/worktree') @@ -82,6 +84,24 @@ describe('CodingManager worktree skill preparation', () => { vi.mocked(prepareCodingDir).mock.invocationCallOrder[0], ) expect(client.start).toHaveBeenCalledOnce() + expect(workspaceCateApi.ensureCateAgentEndpoint).toHaveBeenCalledWith( + 'workspace-1', + 'panel-worktree', + '/repo/worktree', + sender, + ) + expect(PiRpcClient).toHaveBeenCalledWith(runtime, expect.objectContaining({ + env: expect.objectContaining({ + CATE_CODING_AGENT_IDS: JSON.stringify([ + 'claude-code', + 'codex', + 'cursor', + 'grok', + 'opencode', + 'pi', + ]), + }), + })) }) it('logs process diagnostics but sends only a bounded error to the panel', async () => { diff --git a/src/cateAgent/main/codingManager.ts b/src/cateAgent/main/codingManager.ts index e735343a..bc9e665f 100644 --- a/src/cateAgent/main/codingManager.ts +++ b/src/cateAgent/main/codingManager.ts @@ -51,6 +51,7 @@ import { workspaceCateApi } from '../../main/extensions/workspaceCateApi' import { KeyedLock } from '../../main/keyedLock' import { agentMessageText, lastAssistantMessage } from '../../shared/agentMessages' import { agentErrorMessage } from '../../shared/agentErrorMessage' +import { AGENTS } from '../../shared/agents' interface AgentSession { panelId: string @@ -198,12 +199,20 @@ export class CodingManager { const env: Record = { PI_CODING_AGENT_DIR: hostCodingDir(runtimeId, cwd), + // The standalone Pi extension cannot import Cate's source tree. Feed + // it the canonical registry so its tool schema cannot drift. + CATE_CODING_AGENT_IDS: JSON.stringify(AGENTS.map((agent) => agent.id)), } // The embedded supervisor gets a panel-bound endpoint with orchestration // scope. Worker terminals receive the ordinary first-party endpoint and // therefore cannot recursively spawn agents. - const cateApi = await workspaceCateApi.ensureCateAgentEndpoint(opts.workspaceId, opts.panelId, cwd) + const cateApi = await workspaceCateApi.ensureCateAgentEndpoint( + opts.workspaceId, + opts.panelId, + cwd, + sender, + ) if (cateApi) { env.CATE_API = `http://127.0.0.1:${cateApi.port}` env.CATE_TOKEN = cateApi.token diff --git a/src/cateAgent/main/extensionInstall.test.ts b/src/cateAgent/main/extensionInstall.test.ts new file mode 100644 index 00000000..0274ad30 --- /dev/null +++ b/src/cateAgent/main/extensionInstall.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + app: { getAppPath: () => process.cwd() }, +})) +vi.mock('./codingDir', () => ({ + hostCodingDir: () => '/host/.cate/cate-agent', + hostJoin: (_runtimeId: string, ...segments: string[]) => segments.join('/'), +})) +vi.mock('../../main/logger', () => ({ + default: { info: vi.fn(), warn: vi.fn() }, +})) + +import { createBundledExtensionInstaller } from './extensionInstall' + +function runtime(writeFile = vi.fn(async () => undefined)) { + return { + id: 'local', + file: { + stat: vi.fn(async () => { throw new Error('missing') }), + readFile: vi.fn(), + mkdir: vi.fn(async () => undefined), + writeFile, + }, + } as any +} + +describe('createBundledExtensionInstaller', () => { + beforeEach(() => vi.clearAllMocks()) + + it('shares concurrent work and skips a completed install', async () => { + const host = runtime() + const install = createBundledExtensionInstaller('cate-plan-mode', '[test]') + + await Promise.all([install(host, '/repo'), install(host, '/repo')]) + await install(host, '/repo') + + expect(host.file.writeFile).toHaveBeenCalledTimes(2) + expect(host.file.writeFile.mock.calls.map((call: unknown[]) => call[0])).toEqual([ + '/host/.cate/cate-agent/extensions/cate-plan-mode/index.ts', + '/host/.cate/cate-agent/extensions/cate-plan-mode/package.json', + ]) + }) + + it('does not poison the install-once cache after a transient write failure', async () => { + const writeFile = vi.fn() + .mockRejectedValueOnce(new Error('remote disconnected')) + .mockResolvedValue(undefined) + const host = runtime(writeFile) + const install = createBundledExtensionInstaller('cate-plan-mode', '[test]') + + await install(host, '/repo') + await install(host, '/repo') + + expect(writeFile).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/cateAgent/main/extensionInstall.ts b/src/cateAgent/main/extensionInstall.ts index 1852df46..5c0b6d39 100644 --- a/src/cateAgent/main/extensionInstall.ts +++ b/src/cateAgent/main/extensionInstall.ts @@ -10,8 +10,10 @@ import fs from 'fs' import fsp from 'fs/promises' +import path from 'path' +import { app } from 'electron' import log from '../../main/logger' -import { hostJoin } from './codingDir' +import { hostCodingDir, hostJoin } from './codingDir' import type { Runtime } from '../../main/runtime/types' /** Install-once gate keyed on an arbitrary string. */ @@ -41,6 +43,47 @@ export function findSourceDir(candidates: string[]): string | null { return null } +/** + * Build an installer for a Cate-managed extension bundled as index.ts + + * package.json. Concurrent sessions share one in-flight copy, successful + * installs are skipped thereafter, and transient failures remain retryable. + */ +export function createBundledExtensionInstaller( + slug: string, + logLabel: string, +): (runtime: Runtime, cwd: string) => Promise { + const installed = new Set() + const pending = new Map>() + + return async (runtime: Runtime, cwd: string): Promise => { + const home = hostCodingDir(runtime.id, cwd) + const key = `${runtime.id}\0${home}` + if (installed.has(key)) return + const existing = pending.get(key) + if (existing) return existing + + const install = (async () => { + const src = findSourceDir([ + path.join(app.getAppPath(), 'src', 'cateAgent', 'extensions', slug), + path.join(process.resourcesPath ?? '', 'cate-extensions', slug), + ]) + if (!src) throw new Error(`bundled source directory not found for ${slug}`) + const destDir = hostJoin(runtime.id, home, 'extensions', slug) + await copyFileToHost(runtime, path.join(src, 'index.ts'), destDir, 'index.ts', 'if-changed', logLabel) + await copyFileToHost(runtime, path.join(src, 'package.json'), destDir, 'package.json', 'if-changed', logLabel) + installed.add(key) + })() + pending.set(key, install) + try { + await install + } catch (err) { + log.warn('%s install failed: %O', logLabel, err) + } finally { + pending.delete(key) + } + } +} + /** True when the host already has a file/dir at `hostPath`. */ export async function hostFileExists(runtime: Runtime, hostPath: string): Promise { try { diff --git a/src/cateAgent/main/installAskUser.ts b/src/cateAgent/main/installAskUser.ts index 98265451..298f7020 100644 --- a/src/cateAgent/main/installAskUser.ts +++ b/src/cateAgent/main/installAskUser.ts @@ -5,43 +5,7 @@ // copy-if-changed semantics so a shipped update reaches hosts with an older copy). // ============================================================================= -import path from 'path' -import { app } from 'electron' -import log from '../../main/logger' -import { hostCodingDir, hostJoin } from './codingDir' -import { copyFileToHost, createIdempotencyTracker, findSourceDir } from './extensionInstall' -import type { Runtime } from '../../main/runtime/types' +import { createBundledExtensionInstaller } from './extensionInstall' -/** Source dir of the bundled extension. Dev path first (src/ on disk), then the - * production extraResources copy. */ -function sourceDir(): string | null { - return findSourceDir([ - path.join(app.getAppPath(), 'src', 'cateAgent', 'extensions', 'cate-ask-user'), - path.join(process.resourcesPath ?? '', 'cate-extensions', 'cate-ask-user'), - ]) -} - -// Keyed on runtimeId + host path so the same host path on different runtimes -// doesn't collide. -const installed = createIdempotencyTracker() - -/** Idempotent — safe to call from CodingManager.create() on every session. - * `cwd` is the HOST path on whichever machine pi runs. */ -export async function installAskUserExtension(runtime: Runtime, cwd: string): Promise { - const home = hostCodingDir(runtime.id, cwd) - const key = runtime.id + '\0' + home - if (!installed.shouldInstall(key)) return - installed.markInstalled(key) - try { - const src = sourceDir() - if (!src) { - log.warn('[installAskUser] source dir not found — ask_user extension not installed') - return - } - const destDir = hostJoin(runtime.id, home, 'extensions', 'cate-ask-user') - await copyFileToHost(runtime, path.join(src, 'index.ts'), destDir, 'index.ts', 'if-changed', '[installAskUser]') - await copyFileToHost(runtime, path.join(src, 'package.json'), destDir, 'package.json', 'if-changed', '[installAskUser]') - } catch (err) { - log.warn('[installAskUser] install failed: %O', err) - } -} +export const installAskUserExtension = + createBundledExtensionInstaller('cate-ask-user', '[installAskUser]') diff --git a/src/cateAgent/main/installCanvasMode.ts b/src/cateAgent/main/installCanvasMode.ts index 2090219c..99c79870 100644 --- a/src/cateAgent/main/installCanvasMode.ts +++ b/src/cateAgent/main/installCanvasMode.ts @@ -3,37 +3,7 @@ // workspace's Cate agent directory. Mirrors the plan/ask-user installers. // ============================================================================= -import path from 'path' -import { app } from 'electron' -import log from '../../main/logger' -import { hostCodingDir, hostJoin } from './codingDir' -import { copyFileToHost, createIdempotencyTracker, findSourceDir } from './extensionInstall' -import type { Runtime } from '../../main/runtime/types' +import { createBundledExtensionInstaller } from './extensionInstall' -function sourceDir(): string | null { - return findSourceDir([ - path.join(app.getAppPath(), 'src', 'cateAgent', 'extensions', 'cate-canvas-mode'), - path.join(process.resourcesPath ?? '', 'cate-extensions', 'cate-canvas-mode'), - ]) -} - -const installed = createIdempotencyTracker() - -export async function installCanvasModeExtension(runtime: Runtime, cwd: string): Promise { - const home = hostCodingDir(runtime.id, cwd) - const key = runtime.id + '\0' + home - if (!installed.shouldInstall(key)) return - installed.markInstalled(key) - try { - const src = sourceDir() - if (!src) { - log.warn('[installCanvasMode] source dir not found — canvas mode extension not installed') - return - } - const destDir = hostJoin(runtime.id, home, 'extensions', 'cate-canvas-mode') - await copyFileToHost(runtime, path.join(src, 'index.ts'), destDir, 'index.ts', 'if-changed', '[installCanvasMode]') - await copyFileToHost(runtime, path.join(src, 'package.json'), destDir, 'package.json', 'if-changed', '[installCanvasMode]') - } catch (err) { - log.warn('[installCanvasMode] install failed: %O', err) - } -} +export const installCanvasModeExtension = + createBundledExtensionInstaller('cate-canvas-mode', '[installCanvasMode]') diff --git a/src/cateAgent/main/installOrchestrator.ts b/src/cateAgent/main/installOrchestrator.ts index 187c4bf6..85c0499b 100644 --- a/src/cateAgent/main/installOrchestrator.ts +++ b/src/cateAgent/main/installOrchestrator.ts @@ -1,34 +1,4 @@ -import path from 'path' -import { app } from 'electron' -import log from '../../main/logger' -import { hostCodingDir, hostJoin } from './codingDir' -import { copyFileToHost, createIdempotencyTracker, findSourceDir } from './extensionInstall' -import type { Runtime } from '../../main/runtime/types' +import { createBundledExtensionInstaller } from './extensionInstall' -function sourceDir(): string | null { - return findSourceDir([ - path.join(app.getAppPath(), 'src', 'cateAgent', 'extensions', 'cate-orchestrator'), - path.join(process.resourcesPath ?? '', 'cate-extensions', 'cate-orchestrator'), - ]) -} - -const installed = createIdempotencyTracker() - -export async function installOrchestratorExtension(runtime: Runtime, cwd: string): Promise { - const home = hostCodingDir(runtime.id, cwd) - const key = runtime.id + '\0' + home - if (!installed.shouldInstall(key)) return - installed.markInstalled(key) - try { - const src = sourceDir() - if (!src) { - log.warn('[installOrchestrator] source dir not found — orchestration tools not installed') - return - } - const destDir = hostJoin(runtime.id, home, 'extensions', 'cate-orchestrator') - await copyFileToHost(runtime, path.join(src, 'index.ts'), destDir, 'index.ts', 'if-changed', '[installOrchestrator]') - await copyFileToHost(runtime, path.join(src, 'package.json'), destDir, 'package.json', 'if-changed', '[installOrchestrator]') - } catch (err) { - log.warn('[installOrchestrator] install failed: %O', err) - } -} +export const installOrchestratorExtension = + createBundledExtensionInstaller('cate-orchestrator', '[installOrchestrator]') diff --git a/src/cateAgent/main/installPlanMode.ts b/src/cateAgent/main/installPlanMode.ts index f0eb744d..76b8a66a 100644 --- a/src/cateAgent/main/installPlanMode.ts +++ b/src/cateAgent/main/installPlanMode.ts @@ -18,61 +18,7 @@ // copy without rewriting on every launch. // ============================================================================= -import path from 'path' -import { app } from 'electron' -import log from '../../main/logger' -import { hostCodingDir, hostJoin } from './codingDir' -import { copyFileToHost, createIdempotencyTracker, findSourceDir } from './extensionInstall' -import type { Runtime } from '../../main/runtime/types' +import { createBundledExtensionInstaller } from './extensionInstall' -/** Source dir of the bundled extension. Tries dev path first (src/ on disk), - * then production extraResources copy. */ -function sourceDir(): string | null { - return findSourceDir([ - path.join(app.getAppPath(), 'src', 'cateAgent', 'extensions', 'cate-plan-mode'), - path.join(process.resourcesPath ?? '', 'cate-extensions', 'cate-plan-mode'), - ]) -} - -/** Copy a single source file (read locally) to a host destination, overwriting - * only when the host copy differs from the bundled source. This is a - * Cate-managed extension, so the bundled version is authoritative — comparing - * first means we still skip the write when nothing changed (the common case), - * but a shipped update reliably reaches hosts that already have an older copy. */ -async function copyIfChanged( - runtime: Runtime, - src: string, - destDir: string, - destName: string, -): Promise { - await copyFileToHost(runtime, src, destDir, destName, 'if-changed', '[installPlanMode]') -} - -// Keyed on runtimeId + host path so the same host path on different runtimes -// doesn't collide. -const installed = createIdempotencyTracker() - -/** Idempotent — safe to call from CodingManager.create() on every session. - * `cwd` is the HOST path on whichever machine pi runs (local fs path for the - * local runtime, POSIX path on a remote host). */ -export async function installPlanModeExtension( - runtime: Runtime, - cwd: string, -): Promise { - const home = hostCodingDir(runtime.id, cwd) - const key = runtime.id + '\0' + home - if (!installed.shouldInstall(key)) return - installed.markInstalled(key) - try { - const src = sourceDir() - if (!src) { - log.warn('[installPlanMode] source dir not found — plan mode extension not installed') - return - } - const destDir = hostJoin(runtime.id, home, 'extensions', 'cate-plan-mode') - await copyIfChanged(runtime, path.join(src, 'index.ts'), destDir, 'index.ts') - await copyIfChanged(runtime, path.join(src, 'package.json'), destDir, 'package.json') - } catch (err) { - log.warn('[installPlanMode] install failed: %O', err) - } -} +export const installPlanModeExtension = + createBundledExtensionInstaller('cate-plan-mode', '[installPlanMode]') diff --git a/src/main/extensions/cateApiEndpointManager.ts b/src/main/extensions/cateApiEndpointManager.ts index b071d689..2c915db2 100644 --- a/src/main/extensions/cateApiEndpointManager.ts +++ b/src/main/extensions/cateApiEndpointManager.ts @@ -6,6 +6,7 @@ import { runtimes } from '../runtime/runtimeManager' import type { Runtime } from '../runtime/types' import { getWorkspaceInfo } from '../workspaceManager' import type { ReverseTunnelBinding } from './cateApiReverse' +import type { WebContents } from 'electron' type CateApiEndpointOwner = 'extension' | 'first-party' | 'cate-agent' @@ -32,6 +33,8 @@ interface CateApiEndpointOptions { panelId?: string originCwd?: string grantedScopes?: string[] + /** Renderer that owns the embedded supervisor's panel/session. */ + ownerWebContents?: WebContents } export function resolveWorkspaceRuntime(workspaceId: string): { runtime: Runtime; cwd: string } { @@ -78,6 +81,7 @@ export class CateApiEndpointManager { panelId: options.panelId, originCwd: options.originCwd, grantedScopes: options.grantedScopes, + ownerWebContents: options.ownerWebContents, }) let binding: ReverseTunnelBinding try { diff --git a/src/main/extensions/cateApiHandlers.test.ts b/src/main/extensions/cateApiHandlers.test.ts index 5f6979b7..a5e330d0 100644 --- a/src/main/extensions/cateApiHandlers.test.ts +++ b/src/main/extensions/cateApiHandlers.test.ts @@ -72,7 +72,7 @@ vi.mock('./ExtensionManager', () => ({ // importing cateApiHandlers doesn't drag in the proxy/server/IPC machinery. vi.mock('./proxyServer', () => ({ getProxyUrlFor: vi.fn() })) vi.mock('./ExtensionServerManager', () => ({ extensionServerManager: {} })) -const { activeWindow, windowsById, windowPanelList, revealWindowPanel, upsertWindowPanel, removeWindowPanel } = vi.hoisted(() => ({ +const { activeWindow, windowsById, windowPanelList, windowPanelListener, revealWindowPanel, upsertWindowPanel, removeWindowPanel } = vi.hoisted(() => ({ activeWindow: { value: undefined as unknown }, windowsById: new Map(), windowPanelList: { value: [] as Array<{ @@ -84,7 +84,11 @@ const { activeWindow, windowsById, windowPanelList, revealWindowPanel, upsertWin filePath?: string url?: string focused?: boolean + codingAgentRunId?: string + codingAgentOwnerPanelId?: string + codingAgentStatus?: 'starting' | 'working' | 'waiting' | 'ready' | 'stopped' | 'failed' }> }, + windowPanelListener: { value: null as (() => void) | null }, revealWindowPanel: vi.fn(() => true), upsertWindowPanel: vi.fn(), removeWindowPanel: vi.fn(), @@ -95,6 +99,10 @@ vi.mock('../windowRegistry', () => ({ })) vi.mock('../windowPanels', () => ({ getWindowPanels: () => windowPanelList.value, + subscribeWindowPanels: vi.fn((listener: () => void) => { + windowPanelListener.value = listener + return () => { windowPanelListener.value = null } + }), revealWindowPanel, upsertWindowPanel, removeWindowPanel, @@ -174,6 +182,7 @@ beforeEach(() => { activeWindow.value = undefined windowsById.clear() windowPanelList.value = [] + windowPanelListener.value = null revealWindowPanel.mockClear() revealWindowPanel.mockReturnValue(true) removeWindowPanel.mockClear() @@ -406,6 +415,119 @@ describe('dispatchCateInvoke — Cate Agent orchestration boundary', () => { } expect(forward).not.toHaveBeenCalled() }) + + it('routes a transferred worker to its exact owner window', async () => { + const send = vi.fn(() => { throw new Error('closed for test') }) + windowsById.set(7, { isDestroyed: () => false, webContents: { send } }) + windowPanelList.value = [{ + panelId: 'worker-panel', + type: 'terminal', + ownerWindowId: 7, + codingAgentRunId: 'run-1', + codingAgentOwnerPanelId: 'supervisor-1', + }] + const fallback = vi.fn() + + const result = await dispatchCateInvoke({ + extensionId: 'cate-agent', + workspaceId: WS, + panelId: 'supervisor-1', + caller: 'cate-agent', + grantedScopes: [...CATE_AGENT_GRANTED_SCOPES], + forward: fallback, + }, 'cate.codingAgent.inspect', { runId: 'run-1' }) + + expect(result).toEqual({ error: 'no-owner', method: 'cate.codingAgent.inspect' }) + expect(send).toHaveBeenCalledOnce() + expect(fallback).not.toHaveBeenCalled() + }) + + it('coordinates one event-driven wait across workers in different windows', async () => { + const sendA = vi.fn(() => { throw new Error('closed for test') }) + const sendB = vi.fn(() => { throw new Error('closed for test') }) + windowsById.set(7, { isDestroyed: () => false, webContents: { send: sendA } }) + windowsById.set(8, { isDestroyed: () => false, webContents: { send: sendB } }) + windowPanelList.value = [ + { + panelId: 'worker-a', + type: 'terminal', + ownerWindowId: 7, + codingAgentRunId: 'run-a', + codingAgentOwnerPanelId: 'supervisor-1', + codingAgentStatus: 'ready', + }, + { + panelId: 'worker-b', + type: 'terminal', + ownerWindowId: 8, + codingAgentRunId: 'run-b', + codingAgentOwnerPanelId: 'supervisor-1', + codingAgentStatus: 'waiting', + }, + ] + + const result = await dispatchCateInvoke({ + extensionId: 'cate-agent', + workspaceId: WS, + panelId: 'supervisor-1', + caller: 'cate-agent', + grantedScopes: [...CATE_AGENT_GRANTED_SCOPES], + forward: vi.fn(), + }, 'cate.codingAgent.wait', { runIds: ['run-a', 'run-b'] }) + + expect(result).toEqual({ + timedOut: false, + changedRunIds: ['run-a', 'run-b'], + runs: [ + { id: 'run-a', panelId: 'worker-a', status: 'ready' }, + { id: 'run-b', panelId: 'worker-b', status: 'waiting' }, + ], + }) + expect(sendA).toHaveBeenCalledOnce() + expect(sendB).toHaveBeenCalledOnce() + }) + + it('wakes a cross-window wait from the shared window-panel event stream', async () => { + const send = vi.fn(() => { throw new Error('closed for test') }) + windowsById.set(7, { isDestroyed: () => false, webContents: { send } }) + windowsById.set(8, { isDestroyed: () => false, webContents: { send } }) + windowPanelList.value = [ + { + panelId: 'worker-a', + type: 'terminal', + ownerWindowId: 7, + codingAgentRunId: 'run-a', + codingAgentOwnerPanelId: 'supervisor-1', + codingAgentStatus: 'working', + }, + { + panelId: 'worker-b', + type: 'terminal', + ownerWindowId: 8, + codingAgentRunId: 'run-b', + codingAgentOwnerPanelId: 'supervisor-1', + codingAgentStatus: 'working', + }, + ] + const waiting = dispatchCateInvoke({ + extensionId: 'cate-agent', + workspaceId: WS, + panelId: 'supervisor-1', + caller: 'cate-agent', + grantedScopes: [...CATE_AGENT_GRANTED_SCOPES], + forward: vi.fn(), + }, 'cate.codingAgent.wait', { runIds: ['run-a', 'run-b'] }) + + expect(windowPanelListener.value).toBeTypeOf('function') + windowPanelList.value[1].codingAgentStatus = 'ready' + windowPanelListener.value!() + + await expect(waiting).resolves.toMatchObject({ + timedOut: false, + changedRunIds: ['run-b'], + }) + expect(windowPanelListener.value).toBeNull() + }) }) describe('dispatchCateInvoke — cate.agent.* (open/send/dispose; run is gone)', () => { diff --git a/src/main/extensions/cateApiHandlers.ts b/src/main/extensions/cateApiHandlers.ts index cddcbbd3..32fc6106 100644 --- a/src/main/extensions/cateApiHandlers.ts +++ b/src/main/extensions/cateApiHandlers.ts @@ -65,12 +65,19 @@ import { codingManager } from '../../cateAgent/main/codingManager' import { getExtensionStorage } from './storage' import { getWorkspaceInfo } from '../workspaceManager' import { getActiveMainWindow, getWindow } from '../windowRegistry' -import { getWindowPanels, removeWindowPanel, revealWindowPanel, upsertWindowPanel } from '../windowPanels' +import { + getWindowPanels, + removeWindowPanel, + revealWindowPanel, + subscribeWindowPanels, + upsertWindowPanel, +} from '../windowPanels' import { parseLocator, LOCAL_RUNTIME_ID } from '../../shared/runtimeLocator' import { getAllSettings, getSetting } from '../settingsFile' import { resolveActiveTheme } from '../themeBootCache' import { showOsNotification } from '../ipc/notifications' -import type { PanelType } from '../../shared/types' +import type { PanelType, WindowPanelInfo } from '../../shared/types' +import type { CodingAgentRunStatus } from '../../shared/codingAgentRuns' /** Bumped when the cateHost API surface changes incompatibly. Guests use * `cate.version` for feature detection. @@ -243,6 +250,167 @@ function resolvePanelTargetWindow( return { wc: win.webContents, ownerWindowId: win.id } } +/** Resolve a mission worker to the renderer currently hosting its terminal. + * Window reports carry both the run and supervisor identities, preventing one + * Cate Agent session from using a guessed run id to reach another mission. */ +function resolveCodingAgentTargetWindow( + runIds: string[], + ownerPanelId: string, +): { wc: WebContents } | { error: string } | null { + if (runIds.length === 0) return null + const reports = runIds.map((runId) => + getWindowPanels().find((panel) => + panel.type === 'terminal' && + panel.codingAgentRunId === runId && + panel.codingAgentOwnerPanelId === ownerPanelId, + ), + ) + // A just-created panel may not have reached the debounced discovery report + // yet. The supervisor-bound forward remains exact in that common case. + if (reports.some((report) => !report)) return null + const ownerIds = new Set(reports.map((report) => report!.ownerWindowId)) + if (ownerIds.size !== 1) return { error: 'coding-agent-runs-span-windows' } + const win = getWindow([...ownerIds][0]) + if (!win || win.isDestroyed()) return { error: 'coding-agent-window-not-found' } + return { wc: win.webContents } +} + +const ACTIONABLE_CODING_AGENT_STATUSES = new Set([ + 'waiting', + 'ready', + 'stopped', + 'failed', +]) + +function codingAgentReports( + runIds: string[], + ownerPanelId: string, +): WindowPanelInfo[] | null { + const reports = runIds.map((runId) => + getWindowPanels().find((panel) => + panel.type === 'terminal' && + panel.codingAgentRunId === runId && + panel.codingAgentOwnerPanelId === ownerPanelId, + ), + ) + return reports.some((report) => !report) ? null : reports as WindowPanelInfo[] +} + +async function inspectCodingAgentReports(args: { + reports: WindowPanelInfo[] + extensionId: string + workspaceId: string + ownerPanelId: string + originCwd?: string +}): Promise { + return Promise.all(args.reports.map(async (report) => { + const win = getWindow(report.ownerWindowId) + if (!win || win.isDestroyed()) { + return { + id: report.codingAgentRunId, + panelId: report.panelId, + status: report.codingAgentStatus, + } + } + const result = await forwardToOwner(win.webContents, { + extensionId: args.extensionId, + workspaceId: args.workspaceId, + panelId: args.ownerPanelId, + method: 'cate.codingAgent.inspect', + args: { + runId: report.codingAgentRunId, + _cateOriginCwd: args.originCwd, + }, + }) + if (result && typeof result === 'object' && !('error' in result)) { + const { recentOutput: _recentOutput, ...compact } = + result as Record + return compact + } + return { + id: report.codingAgentRunId, + panelId: report.panelId, + status: report.codingAgentStatus, + } + })) +} + +/** Wait across workers hosted by different renderers using the existing + * cross-window discovery events. No renderer polling and no orphaned parallel + * wait requests: one main-process subscription observes all worker statuses. */ +function waitForCrossWindowCodingAgents(args: { + runIds: string[] + extensionId: string + workspaceId: string + ownerPanelId: string + originCwd?: string + timeoutSeconds: unknown +}): Promise { + const initial = codingAgentReports(args.runIds, args.ownerPanelId) + if (!initial || initial.some((report) => !report.codingAgentStatus)) { + return Promise.resolve({ + error: 'coding-agent-status-unavailable', + method: 'cate.codingAgent.wait', + }) + } + const baseline = new Map(initial.map((report) => [ + report.codingAgentRunId!, + report.codingAgentStatus!, + ])) + const requestedSeconds = Number(args.timeoutSeconds ?? 60) + const timeoutMs = (Number.isFinite(requestedSeconds) + ? Math.max(15, Math.min(120, requestedSeconds)) + : 60) * 1_000 + + return new Promise((resolve) => { + let settled = false + let unsubscribe = () => {} + const finish = async ( + timedOut: boolean, + changedRunIds: string[], + reports: WindowPanelInfo[], + ): Promise => { + if (settled) return + settled = true + clearTimeout(timer) + unsubscribe() + resolve({ + timedOut, + changedRunIds, + runs: await inspectCodingAgentReports({ reports, ...args }), + }) + } + const check = (): void => { + const reports = codingAgentReports(args.runIds, args.ownerPanelId) + if (!reports) { + void finish(false, args.runIds, initial) + return + } + const changedRunIds = reports + .filter((report) => + report.codingAgentStatus !== baseline.get(report.codingAgentRunId!) && + report.codingAgentStatus !== undefined && + ACTIONABLE_CODING_AGENT_STATUSES.has(report.codingAgentStatus), + ) + .map((report) => report.codingAgentRunId!) + if (changedRunIds.length > 0) void finish(false, changedRunIds, reports) + } + const initialActionable = initial + .filter((report) => ACTIONABLE_CODING_AGENT_STATUSES.has(report.codingAgentStatus!)) + .map((report) => report.codingAgentRunId!) + const timer = setTimeout(() => { + const reports = codingAgentReports(args.runIds, args.ownerPanelId) ?? initial + void finish(true, [], reports) + }, timeoutMs) + unsubscribe = subscribeWindowPanels(check) + if (initialActionable.length > 0) { + void finish(false, initialActionable, initial) + return + } + check() + }) +} + // --------------------------------------------------------------------------- // Method dispatch // --------------------------------------------------------------------------- @@ -484,17 +652,40 @@ export async function dispatchCateInvoke( } if (method.startsWith('cate.codingAgent.')) { - const routedArgs = { + const routedArgs: Record = { ...((args ?? {}) as Record), _cateOriginCwd: scope.originCwd, } - return scope.forward({ + const name = method.slice('cate.codingAgent.'.length) + const requestedRunIds = name === 'wait' + ? (Array.isArray(routedArgs.runIds) + ? routedArgs.runIds.filter((id: unknown): id is string => typeof id === 'string') + : []) + : typeof routedArgs.runId === 'string' ? [routedArgs.runId] : [] + const target = name === 'create' + ? null + : resolveCodingAgentTargetWindow(requestedRunIds, panelId ?? '') + if (target && 'error' in target) { + if (name === 'wait' && target.error === 'coding-agent-runs-span-windows') { + return waitForCrossWindowCodingAgents({ + runIds: requestedRunIds, + extensionId, + workspaceId, + ownerPanelId: panelId ?? '', + originCwd: scope.originCwd, + timeoutSeconds: routedArgs.timeoutSeconds, + }) + } + return { error: target.error, method } + } + const payload = { extensionId, workspaceId, panelId: panelId ?? '', method, args: routedArgs, - }) + } + return target ? forwardToOwner(target.wc, payload) : scope.forward(payload) } // Storage (handled in main, backed by storage.ts). Routed by prefix — mirrors diff --git a/src/main/extensions/cateApiReverse.test.ts b/src/main/extensions/cateApiReverse.test.ts index 37388d93..bb336bf0 100644 --- a/src/main/extensions/cateApiReverse.test.ts +++ b/src/main/extensions/cateApiReverse.test.ts @@ -13,9 +13,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' // Dispatch core: an in-memory storage impl so the set->get round-trip is real. const store = vi.hoisted(() => new Map()) const dispatchCateInvoke = vi.hoisted(() => vi.fn()) +const forwardToOwner = vi.hoisted(() => vi.fn(async () => ({ ok: true }))) vi.mock('./cateApiHandlers', () => ({ dispatchCateInvoke, forwardToActiveWindow: vi.fn(async () => ({ error: 'no-host-window' })), + forwardToOwner, })) vi.mock('../logger', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } })) @@ -98,6 +100,34 @@ beforeEach(() => { }) describe('createCateApiReverse — server-side CATE_API endpoint', () => { + it('binds Cate Agent forwarding to its owning renderer', async () => { + const host = makeRuntime() + const owner = { isDestroyed: () => false } as never + const endpoint = createCateApiReverse({ + extensionId: 'cate-agent', + workspaceId: 'ws-1', + token: TOKEN, + runtime: host.runtime, + caller: 'cate-agent', + ownerWebContents: owner, + }) + await request(endpoint, host.output, { + json: { method: 'cate.codingAgent.create', args: { prompt: 'Implement it' } }, + }) + + const scope = dispatchCateInvoke.mock.calls[0][0] + const payload = { + extensionId: 'cate-agent', + workspaceId: 'ws-1', + panelId: '', + method: 'cate.codingAgent.create', + args: {}, + } + await scope.forward(payload) + expect(forwardToOwner).toHaveBeenCalledWith(owner, payload) + endpoint.dispose() + }) + it('round-trips storage.set then storage.get (the Kitchen Sink roundtrip)', async () => { // Two requests = two connections (HTTP/1.1 Connection: close). Each endpoint // gets its own runtime/output, but both dispatch into the shared store, so diff --git a/src/main/extensions/cateApiReverse.ts b/src/main/extensions/cateApiReverse.ts index cbc385f3..8edf11f6 100644 --- a/src/main/extensions/cateApiReverse.ts +++ b/src/main/extensions/cateApiReverse.ts @@ -18,7 +18,8 @@ import http from 'http' import { Duplex } from 'stream' import log from '../logger' import type { Runtime } from '../runtime/types' -import { dispatchCateInvoke, forwardToActiveWindow } from './cateApiHandlers' +import type { WebContents } from 'electron' +import { dispatchCateInvoke, forwardToActiveWindow, forwardToOwner } from './cateApiHandlers' import { reverseDuplex } from './serverTunnel' const MAX_BODY_BYTES = 1 * 1024 * 1024 @@ -39,6 +40,9 @@ export interface ReverseSession { /** Scopes granted to a first-party caller (used instead of a manifest's * `cateApi`). Absent for extension-server sessions. */ grantedScopes?: string[] + /** Exact renderer hosting the embedded Cate Agent. Server extensions do not + * have one and retain the active-window fallback. */ + ownerWebContents?: WebContents } export interface CateApiReverseEndpoint { @@ -109,7 +113,9 @@ export function createCateApiReverse(session: ReverseSession): CateApiReverseEnd // panel.setTitle) need a renderer. The server has no sender, so we // forward to the active main window (best-effort — there's no // authoritative workspace→window map for main windows). - forward: forwardToActiveWindow, + forward: session.ownerWebContents && !session.ownerWebContents.isDestroyed() + ? (payload) => forwardToOwner(session.ownerWebContents!, payload) + : forwardToActiveWindow, // Absent for extension-server sessions (undefined => 'extension' // gate + manifest scopes); set for first-party terminal/agent callers. caller: session.caller, diff --git a/src/main/extensions/workspaceCateApi.test.ts b/src/main/extensions/workspaceCateApi.test.ts index f90c52de..5a3c1e8e 100644 --- a/src/main/extensions/workspaceCateApi.test.ts +++ b/src/main/extensions/workspaceCateApi.test.ts @@ -191,7 +191,8 @@ describe('WorkspaceCateApiManager.ensureCateAgentEndpoint', () => { it('mints a panel-bound endpoint even when the terminal CLI is disabled', async () => { settingsState.cliEnabled = false const mgr = new WorkspaceCateApiManager() - const endpoint = await mgr.ensureCateAgentEndpoint('ws1', 'chat-1', '/ws/worktree') + const owner = { id: 42 } as never + const endpoint = await mgr.ensureCateAgentEndpoint('ws1', 'chat-1', '/ws/worktree', owner) expect(endpoint).toEqual({ port: 54321, token: expect.any(String) }) expect(reverseCalls).toHaveLength(1) @@ -199,6 +200,7 @@ describe('WorkspaceCateApiManager.ensureCateAgentEndpoint', () => { caller: 'cate-agent', panelId: 'chat-1', originCwd: '/ws/worktree', + ownerWebContents: owner, grantedScopes: expect.arrayContaining(['coding-agent']), }) }) diff --git a/src/main/extensions/workspaceCateApi.ts b/src/main/extensions/workspaceCateApi.ts index 2f7f758c..c06811eb 100644 --- a/src/main/extensions/workspaceCateApi.ts +++ b/src/main/extensions/workspaceCateApi.ts @@ -5,6 +5,7 @@ import log from '../logger' import { getSetting } from '../settingsFile' import { CateApiEndpointManager, cateApiEndpointManager } from './cateApiEndpointManager' +import type { WebContents } from 'electron' const FIRST_PARTY_ID = 'terminal' const endpointKey = (workspaceId: string): string => `first-party:${workspaceId}` @@ -66,6 +67,7 @@ export class WorkspaceCateApiManager { workspaceId: string, panelId: string, originCwd: string, + ownerWebContents?: WebContents, ): Promise { try { const endpoint = await this.endpoints.ensure({ @@ -78,6 +80,7 @@ export class WorkspaceCateApiManager { listenerId: `cateapi-agent-${workspaceId}-${panelId}`, caller: 'cate-agent', grantedScopes: [...CATE_AGENT_GRANTED_SCOPES], + ownerWebContents, }) const keys = this.cateAgentKeys.get(workspaceId) ?? new Set() keys.add(cateAgentEndpointKey(workspaceId, panelId)) diff --git a/src/main/windowPanels.ts b/src/main/windowPanels.ts index 054664b6..8bdbdf6c 100644 --- a/src/main/windowPanels.ts +++ b/src/main/windowPanels.ts @@ -24,6 +24,7 @@ import { broadcastToAll, focusWindow, getWindow, getWindowType, onWindowClosed, /** The latest panel report from each window, keyed by Electron window id. */ const windowPanels = new Map() +const windowPanelListeners = new Set<() => void>() /** Store a window's reported panels (stamped with its owner id + type) and * rebroadcast the union. Ignored if the window isn't tracked (e.g. a late @@ -48,6 +49,9 @@ export function setWindowPanels(windowId: number, report: WindowPanelReport[]): agentState: p.agentState, agentName: p.agentName, hasPorts: p.hasPorts, + codingAgentRunId: p.codingAgentRunId, + codingAgentOwnerPanelId: p.codingAgentOwnerPanelId, + codingAgentStatus: p.codingAgentStatus, })), ) broadcastWindowPanels() @@ -76,6 +80,9 @@ export function upsertWindowPanel(windowId: number, panel: WindowPanelReport): v agentState: panel.agentState, agentName: panel.agentName, hasPorts: panel.hasPorts, + codingAgentRunId: panel.codingAgentRunId, + codingAgentOwnerPanelId: panel.codingAgentOwnerPanelId, + codingAgentStatus: panel.codingAgentStatus, } const index = panels.findIndex((candidate) => candidate.panelId === panel.panelId) windowPanels.set(windowId, index < 0 @@ -116,6 +123,13 @@ export function getWindowPanels(): WindowPanelInfo[] { return result } +/** Observe real changes to the cross-window union. Mission waits use this same + * event-driven registry instead of polling renderer windows. */ +export function subscribeWindowPanels(listener: () => void): () => void { + windowPanelListeners.add(listener) + return () => windowPanelListeners.delete(listener) +} + // The union is rebroadcast on every window report, so guard on a cheap signature // to push only on real changes. let lastWindowPanelSignature = '' @@ -125,12 +139,13 @@ let lastWindowPanelSignature = '' export function broadcastWindowPanels(): void { const panels = getWindowPanels() const signature = panels - .map((p) => `${p.ownerWindowId}:${p.panelId}:${p.type}:${p.title}:${p.workspaceId}:${p.filePath ?? ''}:${p.url ?? ''}:${p.focused ? 1 : 0}:${p.parentCanvasId ?? ''}:${p.worktreeId ?? ''}:${p.agentState ?? ''}:${p.agentName ?? ''}:${p.hasPorts ? 1 : 0}`) + .map((p) => `${p.ownerWindowId}:${p.panelId}:${p.type}:${p.title}:${p.workspaceId}:${p.filePath ?? ''}:${p.url ?? ''}:${p.focused ? 1 : 0}:${p.parentCanvasId ?? ''}:${p.worktreeId ?? ''}:${p.agentState ?? ''}:${p.agentName ?? ''}:${p.hasPorts ? 1 : 0}:${p.codingAgentRunId ?? ''}:${p.codingAgentOwnerPanelId ?? ''}:${p.codingAgentStatus ?? ''}`) .sort() .join('|') if (signature === lastWindowPanelSignature) return lastWindowPanelSignature = signature broadcastToAll(WINDOW_PANELS_CHANGED, panels) + for (const listener of windowPanelListeners) listener() } /** Focus the window that owns `panelId` and ask it to reveal the panel within diff --git a/src/renderer/hooks/useCateHostActionResponder.ts b/src/renderer/hooks/useCateHostActionResponder.ts index 8edae9b7..8744653a 100644 --- a/src/renderer/hooks/useCateHostActionResponder.ts +++ b/src/renderer/hooks/useCateHostActionResponder.ts @@ -141,7 +141,7 @@ export function useCateHostActionResponder(): void { } if (method.startsWith('cate.codingAgent.')) { - const outcome = await handleCodingAgentMethod(workspaceId, method, args) + const outcome = await handleCodingAgentMethod(workspaceId, payload.panelId, method, args) return outcome.ok ? reply(true, { result: outcome.result }) : reply(false, { error: outcome.error }) diff --git a/src/renderer/lib/agent/codingAgentDriver.test.ts b/src/renderer/lib/agent/codingAgentDriver.test.ts new file mode 100644 index 00000000..0f9d8ca2 --- /dev/null +++ b/src/renderer/lib/agent/codingAgentDriver.test.ts @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ + app: {} as any, + settings: { agentHookInjection: { ws: { codex: 'on' } } } as any, + failure: undefined as string | undefined, +})) +const resolveDriverAgentCli = vi.hoisted(() => vi.fn()) +const getOrCreate = vi.hoisted(() => vi.fn()) +const submitTerminalText = vi.hoisted(() => vi.fn(async () => true)) + +vi.mock('../../stores/appStore', () => ({ + useAppStore: { + getState: () => state.app, + subscribe: vi.fn(() => () => {}), + }, +})) +vi.mock('../../stores/statusStore', () => ({ + useStatusStore: { + getState: () => ({ workspaces: {} }), + subscribe: vi.fn(() => () => {}), + }, +})) +vi.mock('../../stores/settingsStore', () => ({ + useSettingsStore: { getState: () => state.settings }, +})) +vi.mock('../terminal/terminalRegistry', () => ({ + terminalRegistry: { + getEntry: () => ({ + ptyId: 'pty-1', + alive: true, + terminal: {}, + }), + getFailure: () => state.failure, + getOrCreate, + subscribeFailure: vi.fn(() => () => {}), + terminate: vi.fn(), + }, +})) +vi.mock('../terminal/terminalBuffer', () => ({ + terminalBufferTail: () => 'worker output', +})) +vi.mock('../terminal/terminalDriver', () => ({ submitTerminalText })) +vi.mock('../workspace/canvasAccess', () => ({ + placementForBackgroundPanel: (_workspaceId: string, placementGroupId: string) => ({ + target: 'canvas', + placementGroupId, + }), +})) +vi.mock('../../stores/useWorktreeActions', () => ({ + createWorktreeForWorkspace: vi.fn(), +})) +vi.mock('./agentCliHooks', () => ({ resolveDriverAgentCli })) + +import { AGENTS } from '../../../shared/agents' +import { codingAgentSnapshot, handleCodingAgentMethod } from './codingAgentDriver' + +describe('codingAgentDriver mission integration', () => { + beforeEach(() => { + state.failure = undefined + state.settings = { agentHookInjection: { ws: { codex: 'on' } } } + const panels: Record = {} + state.app = { + workspaces: [{ + id: 'ws', + rootPath: '/repo', + panels, + worktrees: [], + }], + createTerminal: vi.fn(( + _workspaceId: string, + _initialInput: unknown, + _position: unknown, + placement: { placementGroupId: string }, + cwd: string, + launch: { runId: string; agentId: string; prompt: string; ownerPanelId: string }, + ) => { + panels.worker = { + id: 'worker', + type: 'terminal', + title: 'Terminal', + cwd, + placementGroupId: placement.placementGroupId, + codingAgentLaunch: launch, + codingAgentRun: { + id: launch.runId, + agentId: launch.agentId, + panelId: 'worker', + ownerPanelId: launch.ownerPanelId, + prompt: launch.prompt, + createdAt: 1, + }, + } + return 'worker' + }), + setPanelWorktreeId: vi.fn(), + setPanelCodingAgentRun: vi.fn((_ws: string, panelId: string, run: unknown) => { + panels[panelId].codingAgentRun = run + }), + updatePanelTitle: vi.fn(), + } + resolveDriverAgentCli.mockReset() + resolveDriverAgentCli.mockResolvedValue(AGENTS.find((agent) => agent.id === 'codex')) + getOrCreate.mockReset() + getOrCreate.mockResolvedValue({ ptyId: 'pty-1', alive: true, terminal: {} }) + submitTerminalText.mockClear() + }) + + it('automatically selects a hook-ready canonical agent and starts its PTY headlessly', async () => { + const outcome = await handleCodingAgentMethod( + 'ws', + 'supervisor-1', + 'cate.codingAgent.create', + { prompt: 'Implement it' }, + ) + + expect(outcome.ok).toBe(true) + expect(resolveDriverAgentCli).toHaveBeenCalledWith('/repo', '', { + fallbackLocator: '/repo', + hookConfig: { codex: 'on' }, + }) + expect(state.app.createTerminal).toHaveBeenCalledWith( + 'ws', + undefined, + undefined, + expect.objectContaining({ placementGroupId: 'coding-agent:primary' }), + '/repo', + expect.objectContaining({ + agentId: 'codex', + ownerPanelId: 'supervisor-1', + prompt: 'Implement it', + }), + ) + expect(getOrCreate).toHaveBeenCalledWith('worker', expect.objectContaining({ + workspaceId: 'ws', + cwd: '/repo', + codingAgentLaunch: expect.objectContaining({ ownerPanelId: 'supervisor-1' }), + })) + }) + + it('rejects a non-ready explicit agent before creating a terminal', async () => { + resolveDriverAgentCli.mockRejectedValue(new Error('Codex hooks are disabled')) + + const outcome = await handleCodingAgentMethod( + 'ws', + 'supervisor-1', + 'cate.codingAgent.create', + { agentId: 'codex', prompt: 'Implement it' }, + ) + + expect(outcome).toEqual({ + ok: false, + error: 'agent-hooks-not-ready: Codex hooks are disabled', + }) + expect(state.app.createTerminal).not.toHaveBeenCalled() + expect(getOrCreate).not.toHaveBeenCalled() + }) + + it('isolates run lookup to the Cate Agent session that created it', async () => { + await handleCodingAgentMethod( + 'ws', + 'supervisor-1', + 'cate.codingAgent.create', + { agentId: 'codex', prompt: 'Implement it' }, + ) + const runId = state.app.workspaces[0].panels.worker.codingAgentRun.id + + expect(codingAgentSnapshot('ws', 'supervisor-1', runId)).not.toBeNull() + expect(codingAgentSnapshot('ws', 'supervisor-2', runId)).toBeNull() + await expect(handleCodingAgentMethod( + 'ws', + 'supervisor-2', + 'cate.codingAgent.inspect', + { runId }, + )).resolves.toEqual({ ok: false, error: 'coding-agent-not-found' }) + }) + + it('returns terminal startup failures as actionable mission diagnostics', async () => { + state.failure = 'spawn codex ENOENT' + + const outcome = await handleCodingAgentMethod( + 'ws', + 'supervisor-1', + 'cate.codingAgent.create', + { agentId: 'codex', prompt: 'Implement it' }, + ) + + expect(outcome).toMatchObject({ + ok: true, + result: { + status: 'failed', + failureReason: 'spawn codex ENOENT', + }, + }) + }) +}) diff --git a/src/renderer/lib/agent/codingAgentDriver.ts b/src/renderer/lib/agent/codingAgentDriver.ts index d2e0e92e..1d514fbd 100644 --- a/src/renderer/lib/agent/codingAgentDriver.ts +++ b/src/renderer/lib/agent/codingAgentDriver.ts @@ -1,6 +1,9 @@ import { useAppStore } from '../../stores/appStore' import { useStatusStore } from '../../stores/statusStore' +import { useSettingsStore } from '../../stores/settingsStore' import { terminalRegistry } from '../terminal/terminalRegistry' +import { terminalBufferTail } from '../terminal/terminalBuffer' +import { submitTerminalText } from '../terminal/terminalDriver' import { placementForBackgroundPanel } from '../workspace/canvasAccess' import { parseLocator, formatLocator } from '../../../shared/runtimeLocator' import { pathKey } from '../../../shared/pathUtils' @@ -8,11 +11,15 @@ import { createWorktreeForWorkspace } from '../../stores/useWorktreeActions' import { MAX_CONCURRENT_CODING_AGENTS, codingAgentDisplayName, + codingAgentSupportsFollowUp, + deriveCodingAgentRunStatus, parseCodingAgentId, type CodingAgentRun, type CodingAgentRunSnapshot, type CodingAgentRunStatus, } from '../../../shared/codingAgentRuns' +import { resolveDriverAgentCli } from './agentCliHooks' +import type { AgentId } from '../../../shared/agents' import { actionableCodingAgentRunIds, changedCodingAgentRunIds, @@ -28,49 +35,40 @@ function workspace(workspaceId: string) { return useAppStore.getState().workspaces.find((candidate) => candidate.id === workspaceId) } -function runPanel(workspaceId: string, runId: string) { +function runPanel(workspaceId: string, ownerPanelId: string, runId: string) { const ws = workspace(workspaceId) - return Object.values(ws?.panels ?? {}).find((panel) => panel.codingAgentRun?.id === runId) + return Object.values(ws?.panels ?? {}).find((panel) => + panel.codingAgentRun?.id === runId && + panel.codingAgentRun.ownerPanelId === ownerPanelId, + ) } function terminalText(panelId: string, maxChars = 4_000): string { const terminal = terminalRegistry.getEntry(panelId)?.terminal - if (!terminal) return '' - const buffer = terminal.buffer.active - const lines: string[] = [] - for (let index = 0; index < buffer.length; index++) { - lines.push(buffer.getLine(index)?.translateToString(true) ?? '') - } - while (lines.length > 0 && !lines[lines.length - 1]) lines.pop() - return lines.join('\n').slice(-maxChars) + return terminal ? terminalBufferTail(terminal, maxChars) : '' } function runStatus(workspaceId: string, panelId: string, run: CodingAgentRun): CodingAgentRunStatus { - if (run.stoppedAt) return 'stopped' - if (run.endedAt) return run.exitCode === 0 ? 'ready' : 'failed' const failure = terminalRegistry.getFailure(panelId) - if (failure) return 'failed' const entry = terminalRegistry.getEntry(panelId) - if (!entry) return 'starting' - if (!entry.alive) return 'ready' - const runtime = entry.ptyId + const runtime = entry?.ptyId ? useStatusStore.getState().workspaces[workspaceId]?.terminals[entry.ptyId] : undefined - switch (runtime?.agentState) { - case 'running': return 'working' - case 'waitingForInput': return 'waiting' - case 'finished': return 'ready' - case 'notRunning': - default: - return runtime?.agentPresent || runtime?.agentName ? 'working' : 'starting' - } + return deriveCodingAgentRunStatus(run, { + terminalStarted: entry !== undefined, + terminalAlive: entry?.alive === true, + terminalFailed: failure !== undefined, + agentState: runtime?.agentState, + agentPresent: runtime?.agentPresent === true || Boolean(runtime?.agentName), + }) } export function codingAgentSnapshot( workspaceId: string, + ownerPanelId: string, runId: string, ): CodingAgentRunSnapshot | null { - const panel = runPanel(workspaceId, runId) + const panel = runPanel(workspaceId, ownerPanelId, runId) const run = panel?.codingAgentRun if (!panel || !run) return null const entry = terminalRegistry.getEntry(panel.id) @@ -82,16 +80,19 @@ export function codingAgentSnapshot( agentName: codingAgentDisplayName(run.agentId), cwd: panel.cwd ?? workspace(workspaceId)?.rootPath ?? '', alive: entry?.alive === true, - followUpSupported: run.agentId !== 'opencode', + followUpSupported: codingAgentSupportsFollowUp(run.agentId), ...(lastLine ? { statusLine: lastLine.slice(0, 200) } : {}), + ...(terminalRegistry.getFailure(panel.id) + ? { failureReason: terminalRegistry.getFailure(panel.id)!.slice(0, 500) } + : {}), } } -function allSnapshots(workspaceId: string): CodingAgentRunSnapshot[] { +function allSnapshots(workspaceId: string, ownerPanelId: string): CodingAgentRunSnapshot[] { const ws = workspace(workspaceId) return Object.values(ws?.panels ?? {}) - .filter((panel) => panel.codingAgentRun) - .map((panel) => codingAgentSnapshot(workspaceId, panel.codingAgentRun!.id)) + .filter((panel) => panel.codingAgentRun?.ownerPanelId === ownerPanelId) + .map((panel) => codingAgentSnapshot(workspaceId, ownerPanelId, panel.codingAgentRun!.id)) .filter((snapshot): snapshot is CodingAgentRunSnapshot => snapshot !== null) .sort((a, b) => a.createdAt - b.createdAt) } @@ -135,15 +136,16 @@ function resolveTarget( function findRequestedRuns( workspaceId: string, + ownerPanelId: string, raw: unknown, ): CodingAgentRunSnapshot[] | { error: string } { if (raw !== undefined && !Array.isArray(raw)) return { error: 'runIds-must-be-an-array' } const ids = raw as unknown[] | undefined - if (!ids) return allSnapshots(workspaceId) + if (!ids) return allSnapshots(workspaceId, ownerPanelId) const snapshots: CodingAgentRunSnapshot[] = [] for (const value of ids) { if (typeof value !== 'string') return { error: 'invalid-run-id' } - const snapshot = codingAgentSnapshot(workspaceId, value) + const snapshot = codingAgentSnapshot(workspaceId, ownerPanelId, value) if (!snapshot) return { error: 'coding-agent-not-found' } snapshots.push(snapshot) } @@ -152,10 +154,11 @@ function findRequestedRuns( async function waitForCodingAgentChange( workspaceId: string, + ownerPanelId: string, rawRunIds: unknown, timeoutMs: number, ): Promise { - const initial = findRequestedRuns(workspaceId, rawRunIds) + const initial = findRequestedRuns(workspaceId, ownerPanelId, rawRunIds) if ('error' in initial) return { ok: false, error: initial.error } // With no explicit target, monitor only live mission work. Historical ready // runs must not make every future wait return immediately. @@ -196,7 +199,7 @@ async function waitForCodingAgentChange( resolve(outcome) } const current = (): CodingAgentRunSnapshot[] | { error: string } => - findRequestedRuns(workspaceId, ids) + findRequestedRuns(workspaceId, ownerPanelId, ids) const check = (): void => { const snapshots = current() if ('error' in snapshots) { @@ -239,19 +242,23 @@ async function waitForCodingAgentChange( export async function handleCodingAgentMethod( workspaceId: string, + ownerPanelId: string, method: string, args: Record, ): Promise { const name = method.slice('cate.codingAgent.'.length) + if (!ownerPanelId) return { ok: false, error: 'mission-owner-required' } if (name === 'create') { - const agentId = parseCodingAgentId(args.agentId) + const requestedAgentId = args.agentId === undefined ? '' : parseCodingAgentId(args.agentId) const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : '' - if (!agentId) return { ok: false, error: 'unsupported-agent' } + if (args.agentId !== undefined && !requestedAgentId) { + return { ok: false, error: 'unsupported-agent' } + } if (!prompt) return { ok: false, error: 'prompt-required' } if (prompt.includes('\0')) return { ok: false, error: 'invalid-prompt' } if (prompt.length > 50_000) return { ok: false, error: 'prompt-too-long' } - const active = allSnapshots(workspaceId).filter((run) => + const active = allSnapshots(workspaceId, ownerPanelId).filter((run) => run.status === 'starting' || run.status === 'working' || run.status === 'waiting', ) if (active.length >= MAX_CONCURRENT_CODING_AGENTS) { @@ -280,18 +287,35 @@ export async function handleCodingAgentMethod( } const target = resolveTarget(workspaceId, args) if ('error' in target) return { ok: false, error: target.error } + const ws = workspace(workspaceId) + let agentId: AgentId + try { + const agent = await resolveDriverAgentCli(target.cwd, requestedAgentId || '', { + fallbackLocator: ws?.rootPath, + hookConfig: useSettingsStore.getState().agentHookInjection[workspaceId], + }) + agentId = agent.id + } catch (error) { + return { + ok: false, + error: error instanceof Error + ? `agent-hooks-not-ready: ${error.message}` + : 'agent-hooks-not-ready', + } + } const runId = crypto.randomUUID() const placementGroupId = target.worktreeId ? `coding-agent:${target.worktreeId}` : 'coding-agent:primary' + const launch = { runId, agentId, prompt, ownerPanelId } const panelId = useAppStore.getState().createTerminal( workspaceId, undefined, undefined, placementForBackgroundPanel(workspaceId, placementGroupId), target.cwd, - { runId, agentId, prompt }, + launch, ) if (!panelId) return { ok: false, error: 'panel-creation-failed' } const store = useAppStore.getState() @@ -305,7 +329,17 @@ export async function handleCodingAgentMethod( } const label = prompt.replace(/\s+/g, ' ').slice(0, 54) store.updatePanelTitle(workspaceId, panelId, `${codingAgentDisplayName(agentId)} · ${label}`) - const snapshot = codingAgentSnapshot(workspaceId, runId) + + // Mission workers are processes, not a React mount side effect. Starting + // the existing terminal lifecycle here keeps them alive in inactive + // workspaces/canvases; TerminalPanel later attaches to the same entry. + await terminalRegistry.getOrCreate(panelId, { + workspaceId, + cwd: target.cwd, + codingAgentLaunch: launch, + placementGroupId, + }) + const snapshot = codingAgentSnapshot(workspaceId, ownerPanelId, runId) return { ok: true, result: snapshot ? compactCodingAgentSnapshot(snapshot) : { @@ -321,7 +355,7 @@ export async function handleCodingAgentMethod( if (name !== 'wait' && !runId) return { ok: false, error: 'runId-required' } if (name === 'inspect') { - const snapshot = codingAgentSnapshot(workspaceId, runId) + const snapshot = codingAgentSnapshot(workspaceId, ownerPanelId, runId) if (!snapshot) return { ok: false, error: 'coding-agent-not-found' } return { ok: true, @@ -333,27 +367,28 @@ export async function handleCodingAgentMethod( } if (name === 'send') { - const panel = runPanel(workspaceId, runId) + const panel = runPanel(workspaceId, ownerPanelId, runId) const run = panel?.codingAgentRun const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : '' if (!panel || !run) return { ok: false, error: 'coding-agent-not-found' } if (!prompt) return { ok: false, error: 'prompt-required' } if (run.stoppedAt) return { ok: false, error: 'coding-agent-stopped' } - const entry = terminalRegistry.getEntry(panel.id) - if (!entry?.ptyId || !entry.alive) return { ok: false, error: 'coding-agent-not-ready' } - entry.terminal.paste(prompt) - await new Promise((resolve) => setTimeout(resolve, 0)) - await window.electronAPI.terminalWrite(entry.ptyId, '\r') + if (!codingAgentSupportsFollowUp(run.agentId)) { + return { ok: false, error: 'coding-agent-follow-up-unsupported' } + } + if (!(await submitTerminalText(panel.id, prompt))) { + return { ok: false, error: 'coding-agent-not-ready' } + } useAppStore.getState().setPanelCodingAgentRun(workspaceId, panel.id, { ...run, followUps: [...(run.followUps ?? []), { prompt, sentAt: Date.now() }], }) - const snapshot = codingAgentSnapshot(workspaceId, runId) + const snapshot = codingAgentSnapshot(workspaceId, ownerPanelId, runId) return { ok: true, result: snapshot ? compactCodingAgentSnapshot(snapshot) : null } } if (name === 'stop') { - const panel = runPanel(workspaceId, runId) + const panel = runPanel(workspaceId, ownerPanelId, runId) const run = panel?.codingAgentRun if (!panel || !run) return { ok: false, error: 'coding-agent-not-found' } terminalRegistry.terminate(panel.id) @@ -361,13 +396,14 @@ export async function handleCodingAgentMethod( ...run, stoppedAt: Date.now(), }) - const snapshot = codingAgentSnapshot(workspaceId, runId) + const snapshot = codingAgentSnapshot(workspaceId, ownerPanelId, runId) return { ok: true, result: snapshot ? compactCodingAgentSnapshot(snapshot) : null } } if (name === 'wait') { return waitForCodingAgentChange( workspaceId, + ownerPanelId, args.runIds, codingAgentWaitMs(args.timeoutSeconds), ) diff --git a/src/renderer/lib/agent/codingAgentWait.test.ts b/src/renderer/lib/agent/codingAgentWait.test.ts index 6b819b2b..6f634875 100644 --- a/src/renderer/lib/agent/codingAgentWait.test.ts +++ b/src/renderer/lib/agent/codingAgentWait.test.ts @@ -14,6 +14,7 @@ function run(id: string, status: CodingAgentRunSnapshot['status']): CodingAgentR agentId: 'codex', agentName: 'Codex', panelId: `panel-${id}`, + ownerPanelId: 'owner-1', prompt: 'Test', createdAt: 1, cwd: '/repo', diff --git a/src/renderer/lib/agent/codingAgentWait.ts b/src/renderer/lib/agent/codingAgentWait.ts index 14b375c6..7599fa69 100644 --- a/src/renderer/lib/agent/codingAgentWait.ts +++ b/src/renderer/lib/agent/codingAgentWait.ts @@ -18,6 +18,7 @@ export function compactCodingAgentSnapshot(snapshot: CodingAgentRunSnapshot) { followUpSupported: snapshot.followUpSupported, ...(snapshot.worktreeId ? { worktreeId: snapshot.worktreeId } : {}), ...(snapshot.statusLine ? { statusLine: snapshot.statusLine } : {}), + ...(snapshot.failureReason ? { failureReason: snapshot.failureReason } : {}), } } diff --git a/src/renderer/lib/terminal/terminalBuffer.ts b/src/renderer/lib/terminal/terminalBuffer.ts new file mode 100644 index 00000000..d841b5c3 --- /dev/null +++ b/src/renderer/lib/terminal/terminalBuffer.ts @@ -0,0 +1,17 @@ +import type { Terminal } from '@xterm/xterm' + +/** Read the active xterm buffer once, preserving scrollback and dropping only + * trailing empty rows. Shared by the terminal API and mission supervision. */ +export function readTerminalBuffer(terminal: Terminal): { alt: boolean; text: string } { + const buffer = terminal.buffer.active + const lines: string[] = [] + for (let index = 0; index < buffer.length; index++) { + lines.push(buffer.getLine(index)?.translateToString(true) ?? '') + } + while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop() + return { alt: buffer.type === 'alternate', text: lines.join('\n') } +} + +export function terminalBufferTail(terminal: Terminal, maxChars = 4_000): string { + return readTerminalBuffer(terminal).text.slice(-maxChars) +} diff --git a/src/renderer/lib/terminal/terminalDriver.ts b/src/renderer/lib/terminal/terminalDriver.ts index 0b2ec4ca..99ab52b9 100644 --- a/src/renderer/lib/terminal/terminalDriver.ts +++ b/src/renderer/lib/terminal/terminalDriver.ts @@ -25,10 +25,26 @@ import { useAppStore } from '../../stores/appStore' import { getActivePanelId } from '../activePanel' import { getEntry } from './registryState' -import type { Terminal } from '@xterm/xterm' +import { readTerminalBuffer } from './terminalBuffer' export type TerminalOutcome = { ok: true; result?: unknown } | { ok: false; error: string } +/** Paste one complete prompt using xterm's bracketed-paste semantics, then + * submit it through the same PTY write bridge used by terminal input. */ +export async function submitTerminalText(panelId: string, text: string): Promise { + const entry = getEntry(panelId) + if (!entry?.ptyId || entry.alive === false) return false + try { + entry.terminal.paste(text) + // Let xterm emit the paste payload before the trailing Enter. + await new Promise((resolve) => setTimeout(resolve, 0)) + await window.electronAPI.terminalWrite(entry.ptyId, '\r') + return true + } catch { + return false + } +} + /** Resolve which terminal panel a call targets. `allowFocused` is true only for * `read` — input verbs must address their target explicitly. */ function resolveTargetPanelId( @@ -88,20 +104,6 @@ export function sequenceForKey(raw: string): string | null { // --- read -------------------------------------------------------------------- -/** The rendered screen: the alt screen when a TUI holds the alternate buffer, - * otherwise the whole normal buffer including scrollback (the CLI caps the - * printed tail). Trailing blank lines are dropped. */ -function readScreen(terminal: Terminal): { alt: boolean; text: string } { - const buf = terminal.buffer.active - const alt = buf.type === 'alternate' - const lines: string[] = [] - for (let i = 0; i < buf.length; i++) { - lines.push(buf.getLine(i)?.translateToString(true) ?? '') - } - while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop() - return { alt, text: lines.join('\n') } -} - // --- Entry point ------------------------------------------------------------- /** Execute one `cate.terminal.*` method. `method` keeps its full @@ -123,7 +125,7 @@ export async function handleTerminalMethod( if (!entry) return { ok: false, error: 'terminal-not-ready' } if (name === 'read') { - const screen = readScreen(entry.terminal) + const screen = readTerminalBuffer(entry.terminal) return { ok: true, result: { panelId: target.panelId, ...screen } } } diff --git a/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts b/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts index 44d1219d..6d5e8059 100644 --- a/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts +++ b/src/renderer/lib/workspace/sessionSerialize.roundTrip.test.ts @@ -196,6 +196,7 @@ describe('workspace.json + session.json round-trip', () => { id: 'run-1', agentId: 'codex' as const, panelId: 'term-1', + ownerPanelId: 'agent-1', prompt: 'Implement the parser', createdAt: 123, worktreeId: 'wt-1', @@ -208,6 +209,7 @@ describe('workspace.json + session.json round-trip', () => { runId: 'run-1', agentId: 'codex', prompt: 'Implement the parser', + ownerPanelId: 'agent-1', }, } diff --git a/src/renderer/lib/workspace/windowPanelSync.ts b/src/renderer/lib/workspace/windowPanelSync.ts index 38d03ec6..c0634a84 100644 --- a/src/renderer/lib/workspace/windowPanelSync.ts +++ b/src/renderer/lib/workspace/windowPanelSync.ts @@ -29,6 +29,7 @@ import { useCateAgentStore, } from '../../../cateAgent/renderer/cateAgentStore' import { useChatsStore } from '../../stores/chatsStore' +import { deriveCodingAgentRunStatus } from '../../../shared/codingAgentRuns' let cleanup: (() => void) | null = null @@ -125,6 +126,9 @@ export function setupWindowPanelSync(): () => void { ...freePanels, ] for (const p of placed) { + const terminalEntry = p.codingAgentRun ? terminalRegistry.getEntry(p.id) : undefined + const terminalFailure = p.codingAgentRun ? terminalRegistry.getFailure(p.id) : undefined + const workerAgent = agentInfo[p.id] report.push({ panelId: p.id, type: p.type, @@ -143,6 +147,17 @@ export function setupWindowPanelSync(): () => void { : p.type === 'terminal' ? p.worktreeId : undefined, agentState: agentInfo[p.id]?.state, agentName: agentInfo[p.id]?.name ?? null, + codingAgentRunId: p.codingAgentRun?.id, + codingAgentOwnerPanelId: p.codingAgentRun?.ownerPanelId, + codingAgentStatus: p.codingAgentRun + ? deriveCodingAgentRunStatus(p.codingAgentRun, { + terminalStarted: terminalEntry !== undefined, + terminalAlive: terminalEntry?.alive === true, + terminalFailed: terminalFailure !== undefined, + agentState: workerAgent?.state, + agentPresent: Boolean(workerAgent?.name), + }) + : undefined, hasPorts: withPorts.has(p.id), }) } @@ -197,6 +212,7 @@ export function setupWindowPanelSync(): () => void { lastStatusSig = sig schedule() }) + const unsubscribeTerminalFailure = terminalRegistry.subscribeFailure(schedule) cleanup = () => { unsubscribeApp() @@ -204,6 +220,7 @@ export function setupWindowPanelSync(): () => void { unsubscribeActiveChats() unsubscribeChats() unsubscribeStatus() + unsubscribeTerminalFailure() for (const unsub of canvasSubs.values()) unsub() canvasSubs.clear() if (timer) clearTimeout(timer) diff --git a/src/renderer/panels/keepMountedPanels.test.tsx b/src/renderer/panels/keepMountedPanels.test.tsx index 3702fdbe..e8c4f07f 100644 --- a/src/renderer/panels/keepMountedPanels.test.tsx +++ b/src/renderer/panels/keepMountedPanels.test.tsx @@ -53,6 +53,7 @@ describe('keepMountedOffscreenPanelIds', () => { id: 'run-1', agentId: 'codex', panelId: 'worker', + ownerPanelId: 'agent-1', prompt: 'Implement it', createdAt: 1, }, diff --git a/src/renderer/stores/appStore/panelSlice.ts b/src/renderer/stores/appStore/panelSlice.ts index 7eeeeb34..591831a3 100644 --- a/src/renderer/stores/appStore/panelSlice.ts +++ b/src/renderer/stores/appStore/panelSlice.ts @@ -98,6 +98,7 @@ export function createPanelSlice(set: AppSet, get: AppGet): PanelSliceActions { id: codingAgentLaunch.runId, agentId: codingAgentLaunch.agentId, panelId, + ownerPanelId: codingAgentLaunch.ownerPanelId, prompt: codingAgentLaunch.prompt, createdAt: Date.now(), }, diff --git a/src/shared/agents.ts b/src/shared/agents.ts index 81501555..8d8a0a27 100644 --- a/src/shared/agents.ts +++ b/src/shared/agents.ts @@ -80,6 +80,10 @@ export interface AgentDef { /** The CLI command that launches this agent in a terminal — usually the same * as the detected process name. */ command: string + /** Build the shell-free argv for a new Cate-owned mission worker. */ + codingAgentArgs: (prompt: string) => string[] + /** Whether the launched surface accepts another prompt on the same PTY. */ + codingAgentFollowUp: boolean /** True when a shell child process with this (already-lowercased) name means * this agent is the one running in that terminal. */ matchProcess: (procName: string) => boolean @@ -120,6 +124,8 @@ export const AGENTS: readonly AgentDef[] = [ id: 'claude-code', displayName: 'Claude Code', command: 'claude', + codingAgentArgs: (prompt) => [prompt], + codingAgentFollowUp: true, matchProcess: (n) => n === 'claude' || n === 'claude-code' || n.startsWith('claude'), resumeArgs: (sid) => ['--resume', sid], // claude is the standard's origin: it REQUIRES frontmatter name === dir name. @@ -129,6 +135,8 @@ export const AGENTS: readonly AgentDef[] = [ id: 'codex', displayName: 'Codex', command: 'codex', + codingAgentArgs: (prompt) => [prompt], + codingAgentFollowUp: true, matchProcess: (n) => n === 'codex', resumeArgs: (sid) => ['resume', sid], skills: folderSkills('codex', ['.codex', 'skills']), @@ -140,6 +148,8 @@ export const AGENTS: readonly AgentDef[] = [ id: 'cursor', displayName: 'Cursor', command: 'cursor-agent', + codingAgentArgs: (prompt) => [prompt], + codingAgentFollowUp: true, matchProcess: (n) => n === 'cursor-agent' || n === 'cursor', // --resume ADOPTS an unknown id (fresh chat under that id, exit 0) rather // than failing — a stale stamp degrades to a fresh session, never a wrong one. @@ -156,6 +166,8 @@ export const AGENTS: readonly AgentDef[] = [ id: 'grok', displayName: 'Grok', command: 'grok', + codingAgentArgs: (prompt) => [prompt], + codingAgentFollowUp: true, matchProcess: (n) => n === 'grok' || /^grok-\d/.test(n), // --resume ERRORS on an id with no session on disk (pinned live), so a stale // stamp falls back to a plain shell instead of silently opening a fresh chat. @@ -170,6 +182,8 @@ export const AGENTS: readonly AgentDef[] = [ id: 'opencode', displayName: 'OpenCode', command: 'opencode', + codingAgentArgs: (prompt) => ['run', prompt], + codingAgentFollowUp: false, matchProcess: (n) => n === 'opencode', resumeArgs: (sid) => ['--session', sid], skills: folderSkills('opencode', ['.opencode', 'skills']), @@ -179,6 +193,8 @@ export const AGENTS: readonly AgentDef[] = [ id: 'pi', displayName: 'PI Agent', command: 'pi', + codingAgentArgs: (prompt) => [prompt], + codingAgentFollowUp: true, matchProcess: (n) => n === 'pi', // pi's --resume is an interactive picker; --session takes an exact id. resumeArgs: (sid) => ['--session', sid], diff --git a/src/shared/codingAgentRuns.ts b/src/shared/codingAgentRuns.ts index c1715042..4366af45 100644 --- a/src/shared/codingAgentRuns.ts +++ b/src/shared/codingAgentRuns.ts @@ -5,6 +5,8 @@ export interface CodingAgentRun { id: string agentId: AgentId panelId: string + /** Cate Agent panel/session that owns and may control this run. */ + ownerPanelId: string prompt: string createdAt: number worktreeId?: string @@ -21,6 +23,7 @@ export interface CodingAgentLaunch { runId: string agentId: AgentId prompt: string + ownerPanelId: string } export type CodingAgentRunStatus = @@ -31,15 +34,45 @@ export type CodingAgentRunStatus = | 'stopped' | 'failed' +export interface CodingAgentRuntimeState { + terminalStarted: boolean + terminalAlive: boolean + terminalFailed: boolean + agentState?: 'notRunning' | 'running' | 'waitingForInput' | 'finished' + agentPresent?: boolean +} + +/** One status policy shared by the renderer supervisor and the cross-window + * discovery report. Keeping this pure prevents detached-worker waits from + * interpreting the same terminal differently. */ +export function deriveCodingAgentRunStatus( + run: CodingAgentRun, + runtime: CodingAgentRuntimeState, +): CodingAgentRunStatus { + if (run.stoppedAt) return 'stopped' + if (run.endedAt) return run.exitCode === 0 ? 'ready' : 'failed' + if (runtime.terminalFailed) return 'failed' + if (!runtime.terminalStarted) return 'starting' + if (!runtime.terminalAlive) return 'ready' + switch (runtime.agentState) { + case 'running': return 'working' + case 'waitingForInput': return 'waiting' + case 'finished': return 'ready' + case 'notRunning': + default: + return runtime.agentPresent ? 'working' : 'starting' + } +} + export interface CodingAgentRunSnapshot extends CodingAgentRun { status: CodingAgentRunStatus agentName: string cwd: string alive: boolean - /** OpenCode's prompt-bearing `run` surface is one-shot; the other registered - * interactive CLIs can accept follow-up prompts in the same terminal. */ + /** Derived from the canonical agent capability registry. */ followUpSupported: boolean statusLine?: string + failureReason?: string } export const MAX_CONCURRENT_CODING_AGENTS = 5 @@ -68,10 +101,14 @@ export function codingAgentCommand( if (prompt.includes('\0')) throw new Error('Coding-agent prompts cannot contain NUL bytes') return { executable: agent.command, - args: launch.agentId === 'opencode' ? ['run', prompt] : [prompt], + args: agent.codingAgentArgs(prompt), } } export function codingAgentDisplayName(agentId: AgentId): string { return AGENTS.find((agent) => agent.id === agentId)?.displayName ?? agentId } + +export function codingAgentSupportsFollowUp(agentId: AgentId): boolean { + return AGENTS.find((agent) => agent.id === agentId)?.codingAgentFollowUp ?? false +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 7a50f822..2de08cf8 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -7,7 +7,7 @@ import type { Theme } from './theme' export type { Theme } from './theme' import type { AgentId } from './agents' import type { AgentHookMode } from './agentHooks' -import type { CodingAgentLaunch, CodingAgentRun } from './codingAgentRuns' +import type { CodingAgentLaunch, CodingAgentRun, CodingAgentRunStatus } from './codingAgentRuns' // ----------------------------------------------------------------------------- // Geometry primitives @@ -341,6 +341,11 @@ export interface WindowPanelReport { /** Whether the owner window's scan found listening ports for this panel, so a * detached row shows the same port dot as a local one. */ hasPorts?: boolean + /** Mission identity used only for exact owner-window routing of a live + * Cate-owned worker after cross-window terminal transfer. */ + codingAgentRunId?: string + codingAgentOwnerPanelId?: string + codingAgentStatus?: CodingAgentRunStatus } export interface CateWindowParams { From 92be9186a17c5548d72f9416177b731db476f49f Mon Sep 17 00:00:00 2001 From: Anton Date: Mon, 27 Jul 2026 14:55:52 +0200 Subject: [PATCH 11/12] Fix coding agent failure status --- .../cate-orchestrator/index.test.ts | 27 +++------- .../extensions/cate-orchestrator/index.ts | 19 +------ .../lib/agent/codingAgentDriver.test.ts | 53 +++++++++++++++++-- src/renderer/lib/agent/codingAgentDriver.ts | 22 ++++++-- .../lib/workspace/windowPanelSync.test.tsx | 38 +++++++++++++ src/renderer/lib/workspace/windowPanelSync.ts | 4 +- 6 files changed, 116 insertions(+), 47 deletions(-) diff --git a/src/cateAgent/extensions/cate-orchestrator/index.test.ts b/src/cateAgent/extensions/cate-orchestrator/index.test.ts index 4e37e587..0fb244e2 100644 --- a/src/cateAgent/extensions/cate-orchestrator/index.test.ts +++ b/src/cateAgent/extensions/cate-orchestrator/index.test.ts @@ -101,7 +101,7 @@ describe("cate-orchestrator", () => { expect(api.getActiveTools()).toEqual(["read", "bash"]) }) - it("asks once per session before creating workers and invokes the scoped API", async () => { + it("creates workers without another confirmation after orchestration mode is selected", async () => { const fetch = vi.fn(async (_url: string, init: RequestInit) => ({ ok: true, status: 200, @@ -109,14 +109,11 @@ describe("cate-orchestrator", () => { init, })) vi.stubGlobal("fetch", fetch) - const confirm = vi.fn(async () => true) const tool = registeredTools().get("create_coding_agent") - const ctx = { ui: { confirm } } - await tool.execute("call-1", { agentId: "codex", prompt: "Implement it" }, undefined, undefined, ctx) - await tool.execute("call-2", { agentId: "codex", prompt: "Test it" }, undefined, undefined, ctx) + await tool.execute("call-1", { agentId: "codex", prompt: "Implement it" }) + await tool.execute("call-2", { agentId: "codex", prompt: "Test it" }) - expect(confirm).toHaveBeenCalledTimes(1) expect(fetch).toHaveBeenCalledTimes(2) const [, init] = fetch.mock.calls[0] expect(init.headers).toMatchObject({ Authorization: "Bearer supervisor-token" }) @@ -126,19 +123,9 @@ describe("cate-orchestrator", () => { }) }) - it("remembers a denied mission and never reaches the API", async () => { - const fetch = vi.fn() - vi.stubGlobal("fetch", fetch) - const confirm = vi.fn(async () => false) - const tool = registeredTools().get("create_coding_agent") - const ctx = { ui: { confirm } } - - const first = await tool.execute("call-1", { agentId: "codex", prompt: "No" }, undefined, undefined, ctx) - const second = await tool.execute("call-2", { agentId: "codex", prompt: "Still no" }, undefined, undefined, ctx) - - expect(first.details).toEqual({ approved: false }) - expect(second.details).toEqual({ approved: false }) - expect(confirm).toHaveBeenCalledTimes(1) - expect(fetch).not.toHaveBeenCalled() + it("teaches the supervisor to switch CLIs for CLI-specific failures", () => { + const guidelines = registeredTools().get("create_coding_agent").promptGuidelines + expect(guidelines.join("\n")).toContain("failureReason") + expect(guidelines.join("\n")).toContain("different registered agentId") }) }) diff --git a/src/cateAgent/extensions/cate-orchestrator/index.ts b/src/cateAgent/extensions/cate-orchestrator/index.ts index 5e1bad63..e5ade843 100644 --- a/src/cateAgent/extensions/cate-orchestrator/index.ts +++ b/src/cateAgent/extensions/cate-orchestrator/index.ts @@ -75,10 +75,6 @@ function toolResult(result: unknown) { } export default function (pi: ExtensionAPI) { - // Approval is scoped to this live Cate Agent session. A restart or a new chat - // asks again, keeping autonomous spend visible without interrupting every - // individual worker in an approved mission. - let delegationApproved: boolean | null = null let active = false const setMode = ( @@ -113,6 +109,7 @@ export default function (pi: ExtensionAPI) { "After delegation, call wait_for_coding_agents once and let it block until worker state changes. Do not repeatedly inspect a worker that is still working; inspect after a change or timeout, then send a targeted follow-up only when needed.", "Never create more than five live workers. Reuse a run with send_to_coding_agent when follow-up belongs to the same task.", "Respect followUpSupported in each run result; create a fresh run when that capability is false.", + "When a run fails, use its failureReason or inspect it for full output. If the failure is specific to that CLI, such as quota, authentication, or service availability, create a fresh run with a different registered agentId.", ], parameters: Type.Object({ agentId: Type.Optional(agentIdSchema()), @@ -127,19 +124,7 @@ export default function (pi: ExtensionAPI) { Type.String({ description: "Optional git base ref for newWorktree. Omit to use the repository default." }), ), }), - async execute(_id, params, signal, _onUpdate, ctx) { - if (delegationApproved === null) { - delegationApproved = await ctx.ui.confirm( - "Start coding agents?", - "Cate wants to create visible coding-agent terminals for this mission. They can edit the selected checkouts and use your configured agent subscriptions. Allow up to five concurrent workers?", - ) - } - if (!delegationApproved) { - return { - content: [{ type: "text" as const, text: "The user did not approve coding-agent delegation for this mission." }], - details: { approved: false }, - } - } + async execute(_id, params, signal) { return toolResult(await invoke("cate.codingAgent.create", params, signal)) }, }) diff --git a/src/renderer/lib/agent/codingAgentDriver.test.ts b/src/renderer/lib/agent/codingAgentDriver.test.ts index 0f9d8ca2..7dc6635b 100644 --- a/src/renderer/lib/agent/codingAgentDriver.test.ts +++ b/src/renderer/lib/agent/codingAgentDriver.test.ts @@ -3,7 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const state = vi.hoisted(() => ({ app: {} as any, settings: { agentHookInjection: { ws: { codex: 'on' } } } as any, - failure: undefined as string | undefined, + failure: null as string | null, + terminalOutput: 'worker output', })) const resolveDriverAgentCli = vi.hoisted(() => vi.fn()) const getOrCreate = vi.hoisted(() => vi.fn()) @@ -38,7 +39,7 @@ vi.mock('../terminal/terminalRegistry', () => ({ }, })) vi.mock('../terminal/terminalBuffer', () => ({ - terminalBufferTail: () => 'worker output', + terminalBufferTail: () => state.terminalOutput, })) vi.mock('../terminal/terminalDriver', () => ({ submitTerminalText })) vi.mock('../workspace/canvasAccess', () => ({ @@ -57,7 +58,8 @@ import { codingAgentSnapshot, handleCodingAgentMethod } from './codingAgentDrive describe('codingAgentDriver mission integration', () => { beforeEach(() => { - state.failure = undefined + state.failure = null + state.terminalOutput = 'worker output' state.settings = { agentHookInjection: { ws: { codex: 'on' } } } const panels: Record = {} state.app = { @@ -115,6 +117,12 @@ describe('codingAgentDriver mission integration', () => { ) expect(outcome.ok).toBe(true) + expect(outcome).toMatchObject({ + result: { + status: 'starting', + alive: true, + }, + }) expect(resolveDriverAgentCli).toHaveBeenCalledWith('/repo', '', { fallbackLocator: '/repo', hookConfig: { codex: 'on' }, @@ -193,4 +201,43 @@ describe('codingAgentDriver mission integration', () => { }, }) }) + + it('returns the useful CLI output when a worker exits unsuccessfully', async () => { + await handleCodingAgentMethod( + 'ws', + 'supervisor-1', + 'cate.codingAgent.create', + { agentId: 'codex', prompt: 'Implement it' }, + ) + const run = state.app.workspaces[0].panels.worker.codingAgentRun + state.app.workspaces[0].panels.worker.codingAgentRun = { + ...run, + endedAt: 2, + exitCode: 1, + } + state.terminalOutput = [ + 'Error: You have no usage remaining for Codex.', + '[Process exited with code 1]', + ].join('\n') + + const outcome = await handleCodingAgentMethod( + 'ws', + 'supervisor-1', + 'cate.codingAgent.wait', + { runIds: [run.id] }, + ) + + expect(outcome).toMatchObject({ + ok: true, + result: { + timedOut: false, + changedRunIds: [run.id], + runs: [{ + id: run.id, + status: 'failed', + failureReason: 'Process exited with code 1: Error: You have no usage remaining for Codex.', + }], + }, + }) + }) }) diff --git a/src/renderer/lib/agent/codingAgentDriver.ts b/src/renderer/lib/agent/codingAgentDriver.ts index 1d514fbd..202e24ab 100644 --- a/src/renderer/lib/agent/codingAgentDriver.ts +++ b/src/renderer/lib/agent/codingAgentDriver.ts @@ -57,7 +57,7 @@ function runStatus(workspaceId: string, panelId: string, run: CodingAgentRun): C return deriveCodingAgentRunStatus(run, { terminalStarted: entry !== undefined, terminalAlive: entry?.alive === true, - terminalFailed: failure !== undefined, + terminalFailed: failure !== null, agentState: runtime?.agentState, agentPresent: runtime?.agentPresent === true || Boolean(runtime?.agentName), }) @@ -74,17 +74,29 @@ export function codingAgentSnapshot( const entry = terminalRegistry.getEntry(panel.id) const output = terminalText(panel.id) const lastLine = output.split('\n').reverse().find((line) => line.trim())?.trim() + const status = runStatus(workspaceId, panel.id, run) + const terminalFailure = terminalRegistry.getFailure(panel.id) + const failureDiagnostic = status === 'failed' + ? output + .split('\n') + .reverse() + .map((line) => line.trim()) + .find((line) => line && !/^\[Process exited with code \d+\]$/.test(line)) + : undefined + const failureReason = terminalFailure + ? terminalFailure.slice(0, 500) + : status === 'failed' + ? `Process exited with code ${run.exitCode ?? 'unknown'}${failureDiagnostic ? `: ${failureDiagnostic}` : ''}`.slice(0, 500) + : undefined return { ...run, - status: runStatus(workspaceId, panel.id, run), + status, agentName: codingAgentDisplayName(run.agentId), cwd: panel.cwd ?? workspace(workspaceId)?.rootPath ?? '', alive: entry?.alive === true, followUpSupported: codingAgentSupportsFollowUp(run.agentId), ...(lastLine ? { statusLine: lastLine.slice(0, 200) } : {}), - ...(terminalRegistry.getFailure(panel.id) - ? { failureReason: terminalRegistry.getFailure(panel.id)!.slice(0, 500) } - : {}), + ...(failureReason ? { failureReason } : {}), } } diff --git a/src/renderer/lib/workspace/windowPanelSync.test.tsx b/src/renderer/lib/workspace/windowPanelSync.test.tsx index 51c5c6c9..62e2ffd1 100644 --- a/src/renderer/lib/workspace/windowPanelSync.test.tsx +++ b/src/renderer/lib/workspace/windowPanelSync.test.tsx @@ -43,6 +43,44 @@ function panel(id: string, type: PanelState['type'], worktreeId?: string): Panel const tick = () => new Promise((r) => setTimeout(r, 50)) +describe('windowPanelSync — coding-agent status', () => { + it('does not mark a coding-agent run failed when the terminal has no failure', async () => { + const reports: WindowPanelReport[][] = [] + ;(window as any).electronAPI = { + reportWindowPanels: vi.fn(async (r: WindowPanelReport[]) => { reports.push(r) }), + } + + const ws = 'ws-coding-agent' + const worker = { + ...panel('worker', 'terminal'), + codingAgentRun: { + id: 'run-1', + agentId: 'codex', + panelId: 'worker', + ownerPanelId: 'supervisor-1', + prompt: 'Implement it', + createdAt: 1, + }, + } as PanelState + useAppStore.setState({ + workspaces: [{ + id: ws, name: 'W', color: '', rootPath: '/x', rootPathError: null, + isRootPathPending: false, worktrees: [], + panels: { worker }, + } as any], + selectedWorkspaceId: ws, + } as any) + + const stop = setupWindowPanelSync() + await tick() + + const report = reports[reports.length - 1].find((row) => row.panelId === 'worker') + expect(report?.codingAgentStatus).toBe('starting') + + stop() + }) +}) + describe('windowPanelSync — canvas child parentCanvasId', () => { it('attributes a node whose panel lives only in the LIVE per-node dock (stale raw projection)', async () => { const reports: WindowPanelReport[][] = [] diff --git a/src/renderer/lib/workspace/windowPanelSync.ts b/src/renderer/lib/workspace/windowPanelSync.ts index c0634a84..d6d3ec1f 100644 --- a/src/renderer/lib/workspace/windowPanelSync.ts +++ b/src/renderer/lib/workspace/windowPanelSync.ts @@ -127,7 +127,7 @@ export function setupWindowPanelSync(): () => void { ] for (const p of placed) { const terminalEntry = p.codingAgentRun ? terminalRegistry.getEntry(p.id) : undefined - const terminalFailure = p.codingAgentRun ? terminalRegistry.getFailure(p.id) : undefined + const terminalFailure = p.codingAgentRun ? terminalRegistry.getFailure(p.id) : null const workerAgent = agentInfo[p.id] report.push({ panelId: p.id, @@ -153,7 +153,7 @@ export function setupWindowPanelSync(): () => void { ? deriveCodingAgentRunStatus(p.codingAgentRun, { terminalStarted: terminalEntry !== undefined, terminalAlive: terminalEntry?.alive === true, - terminalFailed: terminalFailure !== undefined, + terminalFailed: terminalFailure !== null, agentState: workerAgent?.state, agentPresent: Boolean(workerAgent?.name), }) From 280abbef486a084771b61df78f5ce43896faf5ff Mon Sep 17 00:00:00 2001 From: Anton Date: Mon, 27 Jul 2026 16:37:00 +0200 Subject: [PATCH 12/12] Use horizontal chat tabs in Cate Agent panels --- .../renderer/CateAgentChatTabs.test.tsx | 105 +++++++++++++++ src/cateAgent/renderer/CateAgentChatTabs.tsx | 103 +++++++++------ src/cateAgent/renderer/CateAgentPanel.tsx | 99 +++++--------- .../renderer/CateAgentPanelSidebar.tsx | 121 ------------------ src/renderer/styles/globals.css | 13 ++ 5 files changed, 214 insertions(+), 227 deletions(-) create mode 100644 src/cateAgent/renderer/CateAgentChatTabs.test.tsx delete mode 100644 src/cateAgent/renderer/CateAgentPanelSidebar.tsx diff --git a/src/cateAgent/renderer/CateAgentChatTabs.test.tsx b/src/cateAgent/renderer/CateAgentChatTabs.test.tsx new file mode 100644 index 00000000..3bf58c9b --- /dev/null +++ b/src/cateAgent/renderer/CateAgentChatTabs.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment jsdom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useChatDragState } from '../../renderer/drag/chatDragState' +import { useChatsStore } from '../../renderer/stores/chatsStore' +import { CateAgentChatTabs } from './CateAgentChatTabs' +import { useCateAgentStore } from './cateAgentStore' + +;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +let host: HTMLDivElement +let root: Root + +beforeEach(() => { + ;(window as unknown as { electronAPI: Record }).electronAPI = { + projectChatsSave: vi.fn(), + } + useCateAgentStore.setState({ + byWs: { 'ws-1': { activeChatId: 'chat-a' } }, + activeChatByPanel: {}, + }) + useChatsStore.setState({ + chatsByRoot: { + '/repo': [ + { id: 'chat-a', title: 'Chat A', createdAt: 1, updatedAt: 1 }, + { id: 'chat-b', title: 'Chat B', createdAt: 2, updatedAt: 2 }, + ], + }, + loadedRoots: { '/repo': true }, + }) + useChatDragState.setState({ active: null, destinationHostPanelId: undefined }) + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() +}) + +describe('CateAgentChatTabs', () => { + it('reveals a horizontal scrollbar on hover while keeping chats draggable', () => { + act(() => root.render()) + + const strip = host.querySelector('.overflow-x-auto') as HTMLDivElement + const chatB = [...host.querySelectorAll('[role="tab"]')] + .find((tab) => tab.textContent?.includes('Chat B'))! + + expect(strip.classList).not.toContain('no-scrollbar') + expect(strip.classList).toContain('cate-agent-chat-tabs-scroll') + expect(strip.parentElement?.classList).toContain('cate-agent-chat-tabs') + expect(chatB.draggable).toBe(true) + }) + + it('shows and creates only chats owned by a panel', () => { + useChatsStore.setState({ + chatsByRoot: { + '/repo': [ + { id: 'sidebar-chat', title: 'Sidebar chat', createdAt: 1, updatedAt: 1 }, + { + id: 'panel-chat', + title: 'Panel chat', + createdAt: 2, + updatedAt: 2, + hostPanelId: 'panel-1', + }, + { + id: 'other-panel-chat', + title: 'Other panel chat', + createdAt: 3, + updatedAt: 3, + hostPanelId: 'panel-2', + }, + ], + }, + loadedRoots: { '/repo': true }, + }) + const onActiveChatChange = vi.fn() + + act(() => root.render( + , + )) + + expect(host.textContent).toContain('Panel chat') + expect(host.textContent).not.toContain('Sidebar chat') + expect(host.textContent).not.toContain('Other panel chat') + + act(() => { + host.querySelector('button[title="New chat"]')!.click() + }) + + const created = useChatsStore.getState().getChats('/repo').at(-1)! + expect(created.hostPanelId).toBe('panel-1') + expect(onActiveChatChange).toHaveBeenCalledWith(created.id) + }) +}) diff --git a/src/cateAgent/renderer/CateAgentChatTabs.tsx b/src/cateAgent/renderer/CateAgentChatTabs.tsx index 1658c662..2eed4e7a 100644 --- a/src/cateAgent/renderer/CateAgentChatTabs.tsx +++ b/src/cateAgent/renderer/CateAgentChatTabs.tsx @@ -2,7 +2,7 @@ import React from 'react' import { Plus, X } from '@phosphor-icons/react' -import { isSidebarChat, useChatsStore } from '../../renderer/stores/chatsStore' +import { isPanelChat, isSidebarChat, useChatsStore } from '../../renderer/stores/chatsStore' import { useCateAgentStore, useCateAgentWs } from './cateAgentStore' import { disposeDirectChatSession } from './directChatSession' import { @@ -50,58 +50,83 @@ const Tab: React.FC<{
) -export const CateAgentChatTabs: React.FC<{ wsId: string; rootPath: string }> = ({ wsId, rootPath }) => { +type CateAgentChatTabsProps = { + wsId: string + rootPath: string +} & ({ + panelId: string + activeChatId: string | null + onActiveChatChange: (chatId: string | null) => void +} | { + panelId?: undefined + activeChatId?: never + onActiveChatChange?: never +}) + +export const CateAgentChatTabs: React.FC = (props) => { + const { wsId, rootPath, panelId } = props const cateAgent = useCateAgentWs(wsId) const chats = (useChatsStore((s) => s.chatsByRoot[rootPath]) ?? []) - .filter(isSidebarChat) - const setActiveChat = useCateAgentStore((s) => s.setActiveChat) + .filter((chat) => panelId ? isPanelChat(chat, panelId) : isSidebarChat(chat)) + const setSidebarActiveChat = useCateAgentStore((s) => s.setActiveChat) const drag = useChatDragState((state) => state.active) const dragDestination = useChatDragState((state) => state.destinationHostPanelId) - const showGhost = showChatDropGhost(drag, dragDestination, rootPath, null) + const activeChatId = panelId ? props.activeChatId : cateAgent.activeChatId + const showGhost = showChatDropGhost(drag, dragDestination, rootPath, panelId ?? null) const ordered = [...chats].reverse() const previewItems = showGhost ? [...ordered, drag.chat].sort((a, b) => b.createdAt - a.createdAt) : ordered + const setActiveChat = (chatId: string | null): void => { + if (panelId) props.onActiveChatChange(chatId) + else setSidebarActiveChat(wsId, chatId ?? '') + } + const newChat = (): void => { - const chat = useChatsStore.getState().createChat(rootPath, 'New chat') - setActiveChat(wsId, chat.id) + const chat = useChatsStore.getState().createChat(rootPath, 'New chat', panelId) + setActiveChat(chat.id) } return ( -
- {previewItems.map((chat) => chat.id === drag?.chat.id && showGhost ? ( - - ) : ( - setActiveChat(wsId, chat.id)} - onClose={() => { - disposeDirectChatSession(chat.id) - useChatsStore.getState().removeChat(rootPath, chat.id) - if (chat.id !== cateAgent.activeChatId) return - const remaining = useChatsStore.getState().getChats(rootPath).filter(isSidebarChat) - setActiveChat(wsId, remaining[remaining.length - 1]?.id ?? '') - }} - onDragStart={(e) => { - e.dataTransfer.effectAllowed = 'move' - beginChatDrag(e.dataTransfer, { chat, rootPath, sourceHostPanelId: null }) - }} - onDragEnd={endChatDrag} +
+
+ {previewItems.map((chat) => chat.id === drag?.chat.id && showGhost ? ( + + ) : ( + setActiveChat(chat.id)} + onClose={() => { + disposeDirectChatSession(chat.id) + useChatsStore.getState().removeChat(rootPath, chat.id) + if (chat.id !== activeChatId) return + const remaining = useChatsStore.getState().getChats(rootPath) + .filter((candidate) => panelId + ? isPanelChat(candidate, panelId) + : isSidebarChat(candidate)) + setActiveChat(remaining[remaining.length - 1]?.id ?? null) + }} + onDragStart={(e) => { + e.dataTransfer.effectAllowed = 'move' + beginChatDrag(e.dataTransfer, { chat, rootPath, sourceHostPanelId: panelId ?? null }) + }} + onDragEnd={endChatDrag} + > + + {chat.title} + + ))} + + + +
) } diff --git a/src/cateAgent/renderer/CateAgentPanel.tsx b/src/cateAgent/renderer/CateAgentPanel.tsx index 232a96d3..daa2cf9e 100644 --- a/src/cateAgent/renderer/CateAgentPanel.tsx +++ b/src/cateAgent/renderer/CateAgentPanel.tsx @@ -2,18 +2,17 @@ // panel, using the same chat view/capabilities as the workspace sidebar. import { useCallback, useEffect, useState } from 'react' -import { Sidebar as SidebarIcon } from '@phosphor-icons/react' import type { PanelProps } from '../../renderer/panels/types' import { useAppStore } from '../../renderer/stores/appStore' import { isPanelChat, useChatsStore } from '../../renderer/stores/chatsStore' import { useCateAgentReady } from '../../renderer/stores/providerReadinessStore' import { useStatusStore } from '../../renderer/stores/statusStore' import { useUIStore } from '../../renderer/stores/uiStore' -import { CateAgentPanelSidebar } from './CateAgentPanelSidebar' +import { CateAgentChatTabs } from './CateAgentChatTabs' import { CateAgentChatView } from './CateAgentChatView' import { useCodingStore } from './codingStore' import { useCateAgentStore } from './cateAgentStore' -import { directAgentKey, disposeDirectChatSession } from './directChatSession' +import { directAgentKey } from './directChatSession' import { CHAT_DRAG_MIME, readChatDrag } from '../../renderer/drag/fileDragPayload' import { endChatDrag, useChatDragState } from '../../renderer/drag/chatDragState' @@ -28,9 +27,7 @@ export default function CateAgentPanel({ panelId, workspaceId }: PanelProps) { const ready = useCateAgentReady() === 'ok' const [activeChatId, setActiveChatId] = useState(null) - const [sidebarOpen, setSidebarOpen] = useState(true) - const activeChat = activeChatId ? chats.find((chat) => chat.id === activeChatId) : undefined useEffect(() => { if (panel?.worktreeId) { useAppStore.getState().setPanelWorktreeId(workspaceId, panelId, undefined) @@ -84,28 +81,12 @@ export default function CateAgentPanel({ panelId, workspaceId }: PanelProps) { setActiveChatId(chatId) }, []) - const newChat = useCallback(async () => { - await loadChats(rootPath) - const chat = useChatsStore.getState().createChat(rootPath, 'New chat', panelId) - selectChat(chat.id) - }, [loadChats, panelId, rootPath, selectChat]) - - const deleteChat = useCallback((chatId: string) => { - disposeDirectChatSession(chatId) - useChatsStore.getState().removeChat(rootPath, chatId) - if (activeChatId !== chatId) return - const remaining = useChatsStore.getState().getChats(rootPath) - .filter((chat) => isPanelChat(chat, panelId)) - setActiveChatId(remaining[remaining.length - 1]?.id ?? null) - }, [activeChatId, panelId, rootPath]) - const handleChatDragOver = useCallback((event: React.DragEvent) => { if (!event.dataTransfer.types.includes(CHAT_DRAG_MIME)) return event.preventDefault() event.dataTransfer.dropEffect = 'move' useChatDragState.getState().setDestination(panelId) - if (!sidebarOpen) setSidebarOpen(true) - }, [panelId, sidebarOpen]) + }, [panelId]) const handleChatDrop = useCallback((event: React.DragEvent) => { const payload = readChatDrag(event.dataTransfer) @@ -130,58 +111,42 @@ export default function CateAgentPanel({ panelId, workspaceId }: PanelProps) { return (
- {sidebarOpen && ( - { void newChat() }} - onOpenChat={selectChat} - onDeleteChat={deleteChat} - onOpenSettings={() => useUIStore.getState().openSettings('cate agent')} - onCollapse={() => setSidebarOpen(false)} - /> - )} - -
- {!sidebarOpen && ( -
- -
- )} - - {!ready ? ( -
- Connect a provider to use the Cate Agent. - -
- ) : !chatsLoaded ? null : ( - + - )} -
+
+ )} + + {!ready ? ( +
+ Connect a provider to use the Cate Agent. + +
+ ) : !chatsLoaded ? null : ( + + )}
) } diff --git a/src/cateAgent/renderer/CateAgentPanelSidebar.tsx b/src/cateAgent/renderer/CateAgentPanelSidebar.tsx deleted file mode 100644 index 9aba59bb..00000000 --- a/src/cateAgent/renderer/CateAgentPanelSidebar.tsx +++ /dev/null @@ -1,121 +0,0 @@ -// Chat-list rail for the floating Cate Agent panel. - -import { useMemo } from 'react' -import { Plus, Sidebar as SidebarIcon, Gear, Trash } from '@phosphor-icons/react' -import type { Chat } from '../../shared/types' -import { Tooltip } from '../../renderer/ui/Tooltip' -import { - beginChatDrag, - endChatDrag, - showChatDropGhost, - useChatDragState, -} from '../../renderer/drag/chatDragState' -import { ChatDropGhost, ChatStatusGlyph } from './chatListPrimitives' - -export function CateAgentPanelSidebar({ - chats, - activeChatId, - rootPath, - panelId, - onNewChat, - onOpenChat, - onDeleteChat, - onOpenSettings, - onCollapse, -}: { - chats: Chat[] - activeChatId: string | null - rootPath: string - panelId: string - onNewChat: () => void - onOpenChat: (chatId: string) => void - onDeleteChat: (chatId: string) => void - onOpenSettings: () => void - onCollapse: () => void -}) { - const ordered = useMemo(() => [...chats].reverse(), [chats]) - const drag = useChatDragState((state) => state.active) - const dragDestination = useChatDragState((state) => state.destinationHostPanelId) - const showGhost = showChatDropGhost(drag, dragDestination, rootPath, panelId) - const previewItems = useMemo( - () => showGhost && drag - ? [...ordered, drag.chat].sort((a, b) => b.createdAt - a.createdAt) - : ordered, - [drag, ordered, showGhost], - ) - - return ( -
-
- - - -
- - - -
- -
- {previewItems.length === 0 ? ( -
No chats yet.
- ) : previewItems.map((chat) => chat.id === drag?.chat.id && showGhost ? ( - - ) : ( -
{ - e.dataTransfer.effectAllowed = 'move' - beginChatDrag(e.dataTransfer, { chat, rootPath, sourceHostPanelId: panelId }) - }} - onDragEnd={endChatDrag} - className={`group flex items-center gap-1 rounded-md px-1 ${ - chat.id === activeChatId ? 'bg-hover-strong' : 'hover:bg-hover' - }`} - > - - - - -
- ))} -
- -
- -
-
- ) -} diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index e8008621..49bb912d 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -214,6 +214,19 @@ background-color: var(--scrollbar-thumb-hover); } +/* Chat tabs reserve their horizontal scrollbar lane, but reveal its thumb only + while the pointer is over the tab strip. */ +.cate-agent-chat-tabs .cate-agent-chat-tabs-scroll::-webkit-scrollbar-thumb { + background-color: transparent; + transition: background-color 160ms ease; +} +.cate-agent-chat-tabs:hover .cate-agent-chat-tabs-scroll::-webkit-scrollbar-thumb { + background-color: var(--scrollbar-thumb); +} +.cate-agent-chat-tabs:hover .cate-agent-chat-tabs-scroll::-webkit-scrollbar-thumb:hover { + background-color: var(--scrollbar-thumb-hover); +} + /* Terminal scrollbar: the same thin rounded floating pill as the base, but only visible while the pointer is over the terminal or the terminal is focused (xterm toggles `.focus` on its root element). The thumb is transparent until