Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 15 additions & 14 deletions scripts/build-runtime-tarball.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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')}. ` +
Expand Down
131 changes: 131 additions & 0 deletions src/cateAgent/extensions/cate-orchestrator/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import registerOrchestrator from "./index"

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<string, any>()
const commands = new Map<string, any>()
const handlers = new Map<string, (event: any) => Promise<any>>()
let activeTools = ["read", "bash"]
let setActiveToolsCalls = 0
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<any>) =>
handlers.set(event, handler),
getActiveTools: () => [...activeTools],
setActiveTools: (names: string[]) => {
setActiveToolsCalls += 1
activeTools = [...names]
},
}
registerOrchestrator(pi as any)
return {
tools,
commands,
handlers,
getActiveTools: () => [...activeTools],
getSetActiveToolsCalls: () => setActiveToolsCalls,
}
}

function registeredTools() {
return makeApi().tools
}

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()
})

describe("cate-orchestrator", () => {
it("registers the complete worker lifecycle surface", () => {
const tools = registeredTools()
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 () => {
const api = makeApi()
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" }))
.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")
// 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" }))
.toBeUndefined()

await api.commands.get("orchestrate").handler("", ctx)

expect(setStatus).toHaveBeenLastCalledWith("orchestrator-mode", undefined)
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("creates workers without another confirmation after orchestration mode is selected", 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 tool = registeredTools().get("create_coding_agent")

await tool.execute("call-1", { agentId: "codex", prompt: "Implement it" })
await tool.execute("call-2", { agentId: "codex", prompt: "Test it" })

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("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")
})
})
213 changes: 213 additions & 0 deletions src/cateAgent/extensions/cate-orchestrator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
// 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 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<string> = new Set(TOOL_NAMES)

const ORCHESTRATOR_PROMPT = `
<orchestration_mode>
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.
</orchestration_mode>
`.trim()

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<typeof Type.Literal>, ...ReturnType<typeof Type.Literal>[]])
: Type.String({ minLength: 1, description: "A coding agent registered by Cate." })
}

async function invoke(
method: string,
args: Record<string, unknown>,
signal?: AbortSignal,
): Promise<unknown> {
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) {
let active = false

const setMode = (
enabled: boolean,
ctx: { ui: { setStatus: (key: string, value: string | undefined) => void } },
): void => {
active = enabled
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",
description:
"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, 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.",
"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()),
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) {
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:
"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.Array(Type.String(), { minItems: 1, maxItems: 5 }),
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))
},
})

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))
},
})

pi.registerCommand("orchestrate", {
description: "Toggle coding-agent orchestration mode.",
handler: async (_args, ctx) => {
setMode(!active, ctx)
},
})

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}` }
})

// 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.",
}
}
})

// 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()
})
}
Loading