import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { createAssistantMessageEventStream, type Api, type AssistantMessage, type AssistantMessageEventStream, type Context, type Model, type SimpleStreamOptions, } from "@earendil-works/pi-ai"; import { randomUUID } from "node:crypto"; import path from "node:path"; import { Type } from "typebox"; import { DEFAULT_EFFORT, DEFAULT_HOME, DEFAULT_MODEL, captureSession, listJobs, listSessions, getJob, listPendingPiToolRequests, piToolCommandPath, prepareSessionForInput, readHookEvents, recordHookEvent, safeName, sendPrompt, startSession, steerSession, writePiToolResponse, } from "../src/core.mjs"; const PROVIDER_ID = "claude-code-tmux"; const PROVIDER_API = "claude-code-tmux-api"; const PROVIDER_BASE_URL = "tmux://claude-code"; const PROVIDER_API_KEY = "ccmux-local"; interface ActiveProviderJob { id: string; sessionName: string; hookOffset: number; replayedEventIds: string[]; dispatchedPiToolRequestIds: string[]; } interface CcmuxProviderState { cwd: string; activeProviderSession?: string; activeJobs: Record; } export default function (pi: ExtensionAPI) { const providerState: CcmuxProviderState = { cwd: process.cwd(), activeJobs: {} }; pi.on("session_start", async (_event, ctx) => { providerState.cwd = ctx.cwd; }); pi.on("session_shutdown", async () => { providerState.activeProviderSession = undefined; }); pi.registerProvider(PROVIDER_ID, { name: "Claude Code tmux", baseUrl: PROVIDER_BASE_URL, apiKey: PROVIDER_API_KEY, api: PROVIDER_API, models: [ providerModel("opus", "Claude Code tmux Opus"), providerModel("sonnet", "Claude Code tmux Sonnet"), ], streamSimple: (model, context, options) => streamCcmuxProvider(model, context, options, providerState), }); pi.registerTool({ name: "ccmux_replay_tool_result", label: "Replay Claude Code tool result", description: "Replay an already-executed Claude Code tool event into Pi's transcript. This does not run the tool again.", parameters: Type.Object({ jobId: Type.String({ description: "ccmux job id." }), eventId: Type.String({ description: "Recorded Claude Code hook event id." }), }), async execute(_toolCallId, params) { const event = readHookEvents(params.jobId).events.find((event: any) => event.id === params.eventId); if (!event) { return { content: [{ type: "text", text: `No recorded Claude Code hook event ${params.eventId} for job ${params.jobId}.` }], isError: true, details: { jobId: params.jobId, eventId: params.eventId }, }; } recordHookEvent({ hook_event_name: "PiReplayToolResult", prompt: `ccmux job ${params.jobId}`, tool_name: event.toolName, tool_input: event.toolInput, tool_response: event.toolResponse, }); return { content: [{ type: "text", text: formatReplayToolResult(event) }], details: { event }, }; }, }); pi.registerTool({ name: "ccmux_start", label: "Start Claude Code tmux", description: "Start or reuse an interactive Claude Code session inside tmux.", promptSnippet: "Start or reuse a durable interactive Claude Code session in tmux", promptGuidelines: [ "Use ccmux_start before ccmux_send when no Claude Code tmux session exists for the requested workspace.", "Use ccmux_send to delegate coding work to interactive Claude Code in tmux, not as a normal LLM provider.", "Use the claude-code-tmux provider when the user explicitly asks to run Pi itself on the tmux-backed Claude Code provider.", ], parameters: Type.Object({ name: Type.Optional(Type.String({ description: "Short session name. Defaults to cwd basename." })), cwd: Type.Optional(Type.String({ description: "Working directory for Claude Code." })), permissionMode: Type.Optional(Type.String({ description: "Optional Claude Code permission mode. ccmux uses dangerously skip permissions by default." })), model: Type.Optional(Type.String({ description: `Claude Code model alias or full id. Defaults to ${DEFAULT_MODEL}, currently latest Opus.` })), effort: Type.Optional(Type.String({ description: `Effort level, for example high, xhigh, max. Defaults to ${DEFAULT_EFFORT}.` })), dangerouslySkipPermissions: Type.Optional(Type.Boolean({ description: "Pass --dangerously-skip-permissions. Defaults to true." })), agentsMd: Type.Optional(Type.Boolean({ description: "Auto-import AGENTS.md files from parent directories. Defaults to true." })), remoteControl: Type.Optional(Type.Boolean({ description: "Also enable Claude Code Remote Control." })), }), async execute(_toolCallId, params) { const session = startSession({ name: params.name, cwd: params.cwd, permissionMode: params.permissionMode, model: params.model, effort: params.effort, dangerouslySkipPermissions: params.dangerouslySkipPermissions !== false, agentsMd: params.agentsMd !== false, remoteControl: params.remoteControl ? params.name || true : false, }); return { content: [ { type: "text", text: `Started Claude Code tmux session ${session.name} (${session.tmuxSession}) in ${session.cwd}. Log: ${session.logPath}`, }, ], details: { session }, }; }, }); pi.registerTool({ name: "ccmux_send", label: "Send to Claude Code tmux", description: "Send a task to a durable interactive Claude Code tmux session and optionally wait for the completion marker.", promptSnippet: "Delegate a task to interactive Claude Code in tmux and optionally wait for completion", promptGuidelines: [ "Use ccmux_send when the user asks to try Claude Code through the tmux bridge or delegate implementation work to it.", "When ccmux_send returns a timeout, inspect ccmux_capture or ask the user before assuming the task failed.", ], parameters: Type.Object({ session: Type.Optional(Type.String({ description: "ccmux session name. Defaults to default." })), prompt: Type.String({ description: "Task to send to Claude Code." }), wait: Type.Optional(Type.Boolean({ description: "Wait for the completion protocol marker or done file." })), timeoutMs: Type.Optional(Type.Number({ description: "Wait timeout in milliseconds." })), settleMs: Type.Optional(Type.Number({ description: "Extra milliseconds to wait after done file appears, unless marker appears first." })), protocol: Type.Optional(Type.Boolean({ description: "Append ccmux completion protocol. Defaults to true." })), }), async execute(_toolCallId, params, signal, onUpdate) { onUpdate?.({ content: [{ type: "text", text: "Sending task to Claude Code tmux..." }] }); const result = await sendPrompt({ session: params.session || "default", prompt: params.prompt, wait: params.wait ?? true, timeoutMs: params.timeoutMs ?? 10 * 60 * 1000, settleMs: params.settleMs ?? 3000, protocol: params.protocol !== false, }); if (signal?.aborted) { return { content: [{ type: "text", text: "Aborted while waiting for ccmux." }], isError: true }; } const summary = result.doneFile?.summary ? `\nSummary: ${result.doneFile.summary}` : ""; return { content: [ { type: "text", text: `ccmux job ${result.id} is ${result.status}. Marker seen: ${Boolean(result.markerSeen)}.${summary}\nLog: ${result.logPath}\nDone file: ${result.donePath}`, }, ], details: { job: result }, }; }, }); pi.registerTool({ name: "ccmux_steer", label: "Steer Claude Code tmux", description: "Send a live steering update to an interactive Claude Code tmux session.", promptSnippet: "Send a live steering update to a running Claude Code tmux session", parameters: Type.Object({ session: Type.Optional(Type.String({ description: "ccmux session name. Defaults to default." })), message: Type.String({ description: "Steering message to paste into Claude Code." }), }), async execute(_toolCallId, params) { const result = steerSession({ session: params.session || "default", message: params.message }); return { content: [{ type: "text", text: `Sent steering update to ${result.session} (${result.tmuxSession}).` }], details: result, }; }, }); pi.registerTool({ name: "ccmux_status", label: "Claude Code tmux status", description: "List ccmux sessions and jobs.", promptSnippet: "List Claude Code tmux sessions and jobs", parameters: Type.Object({}), async execute() { const sessions = listSessions(); const jobs = listJobs().map(({ logTail, ...job }) => job); return { content: [ { type: "text", text: `Sessions: ${sessions.length}\nJobs: ${jobs.length}\n` + JSON.stringify({ sessions, jobs }, null, 2), }, ], details: { sessions, jobs }, }; }, }); pi.registerTool({ name: "ccmux_capture", label: "Capture Claude Code tmux", description: "Capture recent terminal text from a ccmux tmux session.", promptSnippet: "Capture recent terminal text from Claude Code tmux", parameters: Type.Object({ session: Type.Optional(Type.String({ description: "ccmux session name. Defaults to default." })), lines: Type.Optional(Type.Number({ description: "Number of lines to capture." })), }), async execute(_toolCallId, params) { const result = captureSession(safeName(params.session || "default"), { lines: params.lines || 120 }); return { content: [{ type: "text", text: result.text || result.error || "No captured output." }], details: result, isError: !result.ok, }; }, }); } function providerModel(id: string, name: string) { return { id, name, reasoning: true, thinkingLevelMap: { off: "high", minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "xhigh", }, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1_000_000, maxTokens: 64_000, }; } function streamCcmuxProvider( model: Model, context: Context, options: SimpleStreamOptions | undefined, state: CcmuxProviderState, ): AssistantMessageEventStream { const stream = createAssistantMessageEventStream(); (async () => { const output: AssistantMessage = { role: "assistant", content: [], api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, stopReason: "stop", timestamp: Date.now(), }; stream.push({ type: "start", partial: output }); try { const cwd = state.cwd || process.cwd(); const sessionName = providerSessionName(cwd, model.id); const activeKey = providerJobKey(cwd, model.id); let active = state.activeJobs[activeKey]; const piToolResult = extractPiToolResult(context); if (active && piToolResult) { writePiToolResponse(active.id, piToolResult.requestId, piToolResult.response); recordHookEvent({ hook_event_name: "PiNativeToolResult", prompt: `ccmux job ${active.id}`, tool_name: piToolResult.toolName, tool_response: piToolResult.response, }); } if (!active) { const effort = effortFromReasoning(options?.reasoning); const session = startSession({ name: sessionName, cwd, model: model.id, effort, dangerouslySkipPermissions: true, agentsMd: true, hooks: true, }); state.activeProviderSession = session.name; if (!session.reused) { sleepSync(Number(process.env.CCMUX_PROVIDER_STARTUP_DELAY_MS ?? 8000)); } const readiness = prepareSessionForInput(session, { timeoutMs: Number(process.env.CCMUX_PROVIDER_READY_TIMEOUT_MS ?? 45_000), }); if (!readiness.ready) { throw new Error(`Claude Code tmux session ${session.name} was not ready for input`); } const jobId = randomUUID(); const sent = await sendPrompt({ jobId, session: session.name, prompt: buildProviderPrompt(context, model, jobId), wait: false, protocol: true, requireDoneFile: true, pasteDelayMs: Number(process.env.CCMUX_PROVIDER_PASTE_DELAY_MS ?? 15000), }); active = { id: sent.id, sessionName: session.name, hookOffset: 0, replayedEventIds: [], dispatchedPiToolRequestIds: [] }; state.activeJobs[activeKey] = active; } const result = await waitForProviderJob(active, output, stream, options); if (options?.signal?.aborted) throw new Error("Request was aborted"); if (result.kind === "replayToolCall") { endThinking(output, stream); emitReplayToolCall(output, stream, result.event); return; } if (result.kind === "nativePiToolCall") { endThinking(output, stream); emitNativePiToolCall(output, stream, active.id, result.request); return; } delete state.activeJobs[activeKey]; endThinking(output, stream); const text = providerResponseFromJob(result.job); startText(output, stream); appendText(output, stream, text); stream.push({ type: "text_end", contentIndex: output.content.length - 1, content: text, partial: output }); stream.push({ type: "done", reason: "stop", message: output }); stream.end(); } catch (error) { output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = error instanceof Error ? error.message : String(error); stream.push({ type: "error", reason: output.stopReason, error: output }); stream.end(); } })(); return stream; } function startText(output: AssistantMessage, stream: AssistantMessageEventStream) { output.content.push({ type: "text", text: "" }); stream.push({ type: "text_start", contentIndex: output.content.length - 1, partial: output }); } function appendText(output: AssistantMessage, stream: AssistantMessageEventStream, text: string) { const contentIndex = output.content.length - 1; const block = output.content[contentIndex]; if (block?.type === "text") { block.text += text; stream.push({ type: "text_delta", contentIndex, delta: text, partial: output }); } } function startThinking(output: AssistantMessage, stream: AssistantMessageEventStream) { if (output.content.some((block) => block.type === "thinking")) return; output.content.push({ type: "thinking", thinking: "" } as any); stream.push({ type: "thinking_start", contentIndex: output.content.length - 1, partial: output }); } function appendThinking(output: AssistantMessage, stream: AssistantMessageEventStream, text: string) { startThinking(output, stream); const contentIndex = output.content.findIndex((block) => block.type === "thinking"); const block = output.content[contentIndex] as any; block.thinking += text; stream.push({ type: "thinking_delta", contentIndex, delta: text, partial: output }); } function endThinking(output: AssistantMessage, stream: AssistantMessageEventStream) { const contentIndex = output.content.findIndex((block) => block.type === "thinking"); if (contentIndex === -1) return; const block = output.content[contentIndex] as any; stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: output }); } async function waitForProviderJob( active: ActiveProviderJob, output: AssistantMessage, stream: AssistantMessageEventStream, options?: SimpleStreamOptions, ): Promise<{ kind: "done"; job: any } | { kind: "replayToolCall"; event: any } | { kind: "nativePiToolCall"; request: any }> { const timeoutMs = Number(process.env.CCMUX_PROVIDER_TIMEOUT_MS ?? 20 * 60 * 1000); const pollMs = Number(process.env.CCMUX_PROVIDER_EVENT_POLL_MS ?? 500); const settleMs = Number(process.env.CCMUX_PROVIDER_SETTLE_MS ?? 2000); const started = Date.now(); let doneFileFirstSeenAt: number | undefined; while (Date.now() - started < timeoutMs) { if (options?.signal?.aborted) throw new Error("Request was aborted"); const pendingPiTool = listPendingPiToolRequests(active.id, { dispatchedIds: active.dispatchedPiToolRequestIds })[0]; if (pendingPiTool) { active.dispatchedPiToolRequestIds.push(pendingPiTool.id); recordHookEvent({ hook_event_name: "PiNativeToolRequest", prompt: `ccmux job ${active.id}`, tool_name: pendingPiTool.toolName, tool_input: pendingPiTool.arguments, }); return { kind: "nativePiToolCall", request: pendingPiTool }; } const hookRead = readHookEvents(active.id, { offset: active.hookOffset }); active.hookOffset = hookRead.nextOffset; for (const event of hookRead.events) { const line = formatHookProgress(event); if (line) appendThinking(output, stream, `${line}\n`); if (isReplayableToolEvent(event) && !active.replayedEventIds.includes(event.id)) { active.replayedEventIds.push(event.id); return { kind: "replayToolCall", event }; } } const job = getJob(active.id); if (job.markerSeen) return { kind: "done", job }; if (job.doneFile) { doneFileFirstSeenAt ??= Date.now(); if (Date.now() - doneFileFirstSeenAt >= settleMs) return { kind: "done", job: getJob(active.id) }; } await sleep(pollMs); } return { kind: "done", job: { ...getJob(active.id), status: "timeout" } }; } function isReplayableToolEvent(event: any) { return event.hookEventName === "PostToolUse" || event.hookEventName === "PostToolUseFailure"; } function emitToolCall(output: AssistantMessage, stream: AssistantMessageEventStream, id: string, name: string, args: any) { const toolCall = { type: "toolCall" as const, id, name, arguments: args, }; output.content.push(toolCall); const contentIndex = output.content.length - 1; stream.push({ type: "toolcall_start", contentIndex, partial: output }); stream.push({ type: "toolcall_delta", contentIndex, delta: JSON.stringify(args), partial: output }); stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output }); output.stopReason = "toolUse"; stream.push({ type: "done", reason: "toolUse", message: output }); stream.end(); } function emitReplayToolCall(output: AssistantMessage, stream: AssistantMessageEventStream, event: any) { emitToolCall(output, stream, `ccmux-replay-${event.id}`, "ccmux_replay_tool_result", { jobId: event.jobId, eventId: event.id }); } function emitNativePiToolCall(output: AssistantMessage, stream: AssistantMessageEventStream, jobId: string, request: any) { emitToolCall(output, stream, `ccmux-pi-${jobId}-${request.id}`, request.toolName, request.arguments || {}); } function formatHookProgress(event: any) { const name = event.hookEventName; if (name === "PreToolUse") return `Claude Code tool start: ${event.toolName || "tool"}${formatToolInput(event)}`; if (name === "PostToolUse") return `Claude Code tool done: ${event.toolName || "tool"}`; if (name === "PostToolUseFailure") return `Claude Code tool failed: ${event.toolName || "tool"}`; if (name === "PostToolBatch") return "Claude Code tool batch done"; if (name === "PermissionRequest") return `Claude Code permission request: ${event.toolName || "tool"}`; if (name === "Notification") return `Claude Code notification: ${event.notificationType || event.message || "notification"}`; if (name === "SubagentStart") return "Claude Code subagent started"; if (name === "SubagentStop") return "Claude Code subagent stopped"; if (name === "Stop") return "Claude Code turn stopped"; if (name === "StopFailure") return `Claude Code turn failed${event.error ? `: ${event.error}` : ""}`; return undefined; } function formatToolInput(event: any) { const input = event.toolInput || {}; const pathValue = input.file_path || input.path; if (pathValue) return `(${pathValue})`; if (event.toolName === "Bash" && input.command) return `(${truncateText(String(input.command), 120)})`; return ""; } function formatReplayToolResult(event: any) { const lines = [ `Claude Code ${event.hookEventName === "PostToolUseFailure" ? "failed" : "completed"} tool: ${event.toolName || "tool"}`, ]; if (event.toolInput) lines.push(`Input: ${truncateText(JSON.stringify(event.toolInput), 2000)}`); if (event.toolResponse) lines.push(`Result: ${truncateText(JSON.stringify(event.toolResponse), 4000)}`); if (event.error) lines.push(`Error: ${truncateText(String(event.error), 2000)}`); return lines.join("\n"); } function providerJobKey(cwd: string, modelId: string) { return `${path.resolve(cwd)}::${modelId}`; } function providerSessionName(cwd: string, modelId: string) { return safeName(`pi-provider-${path.basename(cwd)}-${modelId}`); } function effortFromReasoning(reasoning?: string) { if (reasoning === "minimal" || reasoning === "low") return "low"; if (reasoning === "medium") return "medium"; if (reasoning === "xhigh") return "xhigh"; return "high"; } function buildProviderPrompt(context: Context, model: Model, jobId: string) { const systemPrompt = truncateText(context.systemPrompt || "", 8_000); const messages = context.messages.slice(-14).map((message) => formatMessageForPrompt(message)).join("\n\n"); const toolsNote = context.tools?.length ? `Pi exposed ${context.tools.length} tool definitions to its model provider. You are running inside Claude Code instead. You may use Claude Code's own tools, or call Pi native tools through the bridge below. Do not emit raw JSON tool calls.` : "Pi did not expose tool definitions for this request."; const nativeTools = formatNativePiTools(context.tools || []); const bridgeCommand = `node ${piToolCommandPath()} call --home ${DEFAULT_HOME} --job ${jobId} --tool TOOL_NAME --args-json 'JSON_ARGUMENTS'`; return [ "You are serving as Pi's native claude-code-tmux provider.", "Behind the scenes, this request is being relayed into an interactive Claude Code session running in tmux.", "Answer the latest user request as a normal assistant. If useful, use Claude Code's local tools in this tmux session to inspect or edit files.", "Before finishing, write the ccmux done JSON with a `final_response` string containing exactly what Pi should display to the user.", `Selected Claude Code model alias: ${model.id}.`, "", `\n${systemPrompt}\n`, "", `\n${toolsNote}\n`, "", `\nTo call a Pi native tool, run this exact command pattern with Claude Code's Bash tool:\n${bridgeCommand}\nThis creates a request that Pi can see, waits for Pi to execute the native tool, and then prints the tool result as JSON. Prefer this bridge when the user expects Pi-native tools, browser automation, Slack, GitHub, or custom Pi tools. Available Pi tools:\n${nativeTools}\n`, "", `\n${messages}\n`, ].join("\n"); } function formatNativePiTools(tools: any[]) { const lines = tools .filter((tool) => tool?.name && tool.name !== "ccmux_replay_tool_result") .slice(0, 60) .map((tool) => `- ${tool.name}: ${truncateText(tool.description || "", 180)} params=${truncateText(JSON.stringify(tool.parameters || {}), 500)}`); return lines.join("\n") || "No Pi native tools were provided."; } function extractPiToolResult(context: Context) { for (const message of [...context.messages].reverse() as any[]) { if (message.role !== "toolResult") continue; const match = String(message.toolCallId || "").match(/^ccmux-pi-([0-9a-fA-F-]{36})-([0-9a-fA-F-]{36})$/); if (!match) continue; return { jobId: match[1], requestId: match[2], toolName: message.toolName, response: { isError: message.isError, content: message.content, details: message.details, }, }; } return undefined; } function formatMessageForPrompt(message: any) { if (message.role === "assistant") { return `Assistant: ${formatAssistantContent(message.content)}`; } if (message.role === "toolResult") { return `Tool result (${message.toolName || message.toolCallId || "tool"}): ${truncateText(formatContent(message.content), 6000)}`; } return `${capitalize(message.role || "message")}: ${truncateText(formatContent(message.content), 8000)}`; } function formatAssistantContent(content: any) { if (!Array.isArray(content)) return truncateText(String(content ?? ""), 8000); return truncateText(content.map((block) => { if (block.type === "text") return block.text; if (block.type === "thinking") return `[thinking omitted]`; if (block.type === "toolCall") return `[tool call ${block.name} ${JSON.stringify(block.arguments ?? {})}]`; return `[${block.type || "content"}]`; }).join("\n"), 8000); } function formatContent(content: any) { if (typeof content === "string") return content; if (!Array.isArray(content)) return String(content ?? ""); return content.map((item) => { if (item.type === "text") return item.text; if (item.type === "image") return "[image]"; return `[${item.type || "content"}]`; }).join("\n"); } function providerResponseFromJob(job: any) { if (job.status === "timeout") { return `ccmux provider job ${job.id} timed out. Inspect it with: ccmux capture --session ${job.session} --lines 160`; } const done = job.doneFile; if (typeof done?.final_response === "string" && done.final_response.trim()) return done.final_response.trim(); if (typeof done?.summary === "string" && done.summary.trim()) return done.summary.trim(); const tail = String(job.logTail || "").trim(); if (tail) return truncateText(tail, 4000); return `ccmux provider job ${job.id} completed.`; } function truncateText(text: string, max: number) { if (text.length <= max) return text; const half = Math.floor((max - 32) / 2); return `${text.slice(0, half)}\n[...truncated...]\n${text.slice(-half)}`; } function sleepSync(ms: number) { if (!Number.isFinite(ms) || ms <= 0) return; Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } function capitalize(value: string) { return value ? value[0].toUpperCase() + value.slice(1) : value; }