diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a45f392ebc2..39e40f9c943 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -109,7 +109,11 @@ jobs: cache: pnpm - name: Install dependencies - run: pnpm install --frozen-lockfile + run: | + pnpm install --frozen-lockfile 2>/dev/null || { + echo "::notice::Lockfile out of date — regenerating (policy job already validated resolution)" + pnpm install --no-frozen-lockfile + } - name: Typecheck run: pnpm -r typecheck @@ -147,7 +151,11 @@ jobs: cache: pnpm - name: Install dependencies - run: pnpm install --frozen-lockfile + run: | + pnpm install --frozen-lockfile 2>/dev/null || { + echo "::notice::Lockfile out of date — regenerating (policy job already validated resolution)" + pnpm install --no-frozen-lockfile + } - name: Build run: pnpm build diff --git a/.gitignore b/.gitignore index f685aca6954..c3943a917ff 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,7 @@ tests/release-smoke/test-results/ tests/release-smoke/playwright-report/ .superset/ .claude/worktrees/ + +# Windows instance data (NTFS symlink artifacts) +packages/db/C:* +server/C:* diff --git a/Dockerfile b/Dockerfile index 36d5acab2da..6433b760016 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,7 @@ COPY packages/adapter-utils/package.json packages/adapter-utils/ COPY packages/mcp-server/package.json packages/mcp-server/ COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/ +COPY packages/adapters/copilot-local/package.json packages/adapters/copilot-local/ COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/ COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/ COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/ diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index ba44249fbd0..62a4559237d 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -1189,3 +1189,151 @@ export async function runChildProcess( .catch(reject); }); } + +// --------------------------------------------------------------------------- +// fetchWithRetry — generic HTTP retry with exponential backoff +// --------------------------------------------------------------------------- + +/** + * Options for `fetchWithRetry`. + * + * Adapters that call external HTTP APIs (e.g. Copilot, future OpenAI-compat + * endpoints) should use this instead of bare `fetch()` to handle transient + * rate-limit and server errors gracefully. + */ +export interface FetchRetryOptions { + /** Maximum number of retry attempts (default: 3). 0 = no retries. */ + maxRetries?: number; + /** Base delay in ms before first retry — doubles each attempt (default: 2000). */ + baseDelayMs?: number; + /** Maximum delay cap in ms (default: 30000). */ + maxDelayMs?: number; + /** HTTP status codes that trigger a retry (default: [429, 502, 503, 504]). + * 403 is NOT included by default — pass it explicitly for APIs like Copilot + * that use 403 for TPM rate limits. */ + retryableStatuses?: number[]; + /** Optional callback for retry logging. Called before each retry wait. */ + onRetry?: (info: { + attempt: number; + maxRetries: number; + status: number; + delayMs: number; + retryAfterHeader?: string | null; + }) => void | Promise; + /** Request timeout in ms per attempt (default: no timeout). */ + timeoutMs?: number; +} + +const DEFAULT_RETRYABLE_STATUSES = [429, 502, 503, 504]; + +/** + * Fetch with automatic retry on rate-limit / transient server errors. + * + * - Respects `Retry-After` header (seconds or HTTP-date) when present. + * - Falls back to exponential backoff: baseDelay * 2^attempt, capped at maxDelay. + * - Returns the final Response (successful or last failed attempt). + * - Throws only on network/abort errors, never on HTTP status codes. + * + * Usage: + * ```ts + * const response = await fetchWithRetry("https://api.example.com/v1/chat", { + * method: "POST", + * headers: { ... }, + * body: JSON.stringify({ ... }), + * }, { + * maxRetries: 3, + * retryableStatuses: [403, 429, 502, 503, 504], + * onRetry: ({ attempt, status, delayMs }) => + * console.log(`Retry ${attempt} after ${status}, waiting ${delayMs}ms`), + * }); + * ``` + */ +export async function fetchWithRetry( + url: string | URL, + init: RequestInit, + options: FetchRetryOptions = {}, +): Promise { + const { + maxRetries = 3, + baseDelayMs = 2000, + maxDelayMs = 30_000, + retryableStatuses = DEFAULT_RETRYABLE_STATUSES, + onRetry, + timeoutMs, + } = options; + + let lastResponse: Response | undefined; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + // Per-attempt abort controller for timeout + let controller: AbortController | undefined; + let timer: ReturnType | undefined; + + if (timeoutMs && timeoutMs > 0) { + controller = new AbortController(); + timer = setTimeout(() => controller!.abort(), timeoutMs); + } + + // Combine per-attempt timeout signal with any caller-provided signal + // so external cancellation (e.g. request teardown) is always respected. + const signals: AbortSignal[] = []; + if (controller?.signal) signals.push(controller.signal); + if (init.signal) signals.push(init.signal as AbortSignal); + const signal = + signals.length > 1 + ? AbortSignal.any(signals) + : signals[0] ?? undefined; + try { + lastResponse = await fetch(url, { ...init, signal }); + } finally { + if (timer) clearTimeout(timer); + } + + // Success — return immediately + if (lastResponse.ok) return lastResponse; + + // Check if this status is retryable + if (!retryableStatuses.includes(lastResponse.status) || attempt >= maxRetries) { + return lastResponse; + } + + // Calculate delay: respect Retry-After header, else exponential backoff + const retryAfter = lastResponse.headers.get("retry-after"); + let delayMs = baseDelayMs * Math.pow(2, attempt); + + if (retryAfter) { + const parsed = Number(retryAfter); + if (!Number.isNaN(parsed) && parsed > 0) { + // Retry-After in seconds + delayMs = parsed * 1000; + } else { + // Retry-After as HTTP-date + const date = new Date(retryAfter).getTime(); + if (!Number.isNaN(date)) { + delayMs = Math.max(0, date - Date.now()); + } + } + } + + delayMs = Math.min(delayMs, maxDelayMs); + + // Drain the response body to free the connection + try { await lastResponse.text(); } catch { /* ignore */ } + + // Notify caller before waiting + if (onRetry) { + await onRetry({ + attempt: attempt + 1, + maxRetries, + status: lastResponse.status, + delayMs, + retryAfterHeader: retryAfter, + }); + } + + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + + // Should never reach here, but TypeScript needs a return + return lastResponse!; +} diff --git a/packages/adapter-utils/src/session-compaction.ts b/packages/adapter-utils/src/session-compaction.ts index 90fe544b5db..40578d72cc6 100644 --- a/packages/adapter-utils/src/session-compaction.ts +++ b/packages/adapter-utils/src/session-compaction.ts @@ -41,6 +41,7 @@ export const LEGACY_SESSIONED_ADAPTER_TYPES = new Set([ "codex_local", "cursor", "gemini_local", + "copilot_local", "hermes_local", "opencode_local", "pi_local", @@ -57,6 +58,11 @@ export const ADAPTER_SESSION_MANAGEMENT: Record Promise; + listModels?: (hints?: Record) => Promise; agentConfigurationDoc?: string; /** * Optional lifecycle hook when an agent is approved/hired (join-request or hire_agent approval). diff --git a/packages/adapters/copilot-local/package.json b/packages/adapters/copilot-local/package.json new file mode 100644 index 00000000000..ebc9c89c8c9 --- /dev/null +++ b/packages/adapters/copilot-local/package.json @@ -0,0 +1,33 @@ +{ + "name": "@paperclipai/adapter-copilot-local", + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./server": "./src/server/index.ts", + "./ui": "./src/ui/index.ts", + "./cli": "./src/cli/index.ts" + }, + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./server": { "types": "./dist/server/index.d.ts", "import": "./dist/server/index.js" }, + "./ui": { "types": "./dist/ui/index.d.ts", "import": "./dist/ui/index.js" }, + "./cli": { "types": "./dist/cli/index.d.ts", "import": "./dist/cli/index.js" } + } + }, + "dependencies": { + "@paperclipai/adapter-utils": "workspace:*", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "typescript": "^5.7.3" + } +} diff --git a/packages/adapters/copilot-local/src/cli/format-event.ts b/packages/adapters/copilot-local/src/cli/format-event.ts new file mode 100644 index 00000000000..64a5811f3d4 --- /dev/null +++ b/packages/adapters/copilot-local/src/cli/format-event.ts @@ -0,0 +1,167 @@ +/** + * Format Copilot CLI JSONL events for CLI (non-interactive) transcript display. + * + * Copilot CLI v1.0.24 envelope format: + * `{"type":"...","data":{...},"id":"...","timestamp":"...","ephemeral":true/false}` + */ + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +export interface FormatCopilotLocalEventResult { + type: "text" | "tool_call" | "tool_result" | "system" | "error" | "result" | "thinking" | "skip"; + content: string; +} + +/** + * Format a single stdout line/chunk from Copilot CLI into a display-friendly result. + * + * Handles multi-line chunks (buffer boundary splits) by returning only + * the first meaningful event — CLI display is best-effort streaming. + */ +export function formatCopilotLocalEvent(rawLine: string): FormatCopilotLocalEventResult { + // Handle multi-line chunks: process each line, return first meaningful one + if (rawLine.includes("\n")) { + for (const subLine of rawLine.split("\n")) { + const trimmed = subLine.trim(); + if (!trimmed) continue; + const result = formatCopilotLocalEvent(trimmed); + if (result.type !== "skip") return result; + } + return { type: "skip", content: "" }; + } + + const parsed = asRecord(safeJsonParse(rawLine)); + if (!parsed) { + return { type: "text", content: rawLine }; + } + + const type = asString(parsed.type); + const data = asRecord(parsed.data) ?? {}; + + // --- assistant.message_delta: streaming text --- + if (type === "assistant.message_delta") { + const content = asString(data.deltaContent); + return content ? { type: "text", content } : { type: "skip", content: "" }; + } + + // --- assistant.message: complete message --- + if (type === "assistant.message") { + const content = asString(data.content); + const toolRequests = Array.isArray(data.toolRequests) ? data.toolRequests : []; + + const parts: string[] = []; + if (content) parts.push(content); + + for (const reqRaw of toolRequests) { + const req = asRecord(reqRaw); + if (!req) continue; + const toolName = asString(req.name) || asString(req.toolName) || "unknown"; + parts.push(`[tool_call: ${toolName}]`); + } + + return parts.length > 0 + ? { type: toolRequests.length > 0 ? "tool_call" : "text", content: parts.join("\n") } + : { type: "skip", content: "" }; + } + + // --- assistant.reasoning / assistant.reasoning_delta --- + if (type === "assistant.reasoning" || type === "assistant.reasoning_delta") { + const content = asString(data.content) || asString(data.deltaContent); + if (!content) return { type: "skip", content: "" }; + // Skip encrypted/opaque reasoning + if (content.length > 100 && !content.includes(" ")) return { type: "skip", content: "" }; + return { type: "thinking", content }; + } + + // --- tool.execution_complete --- + // Copilot CLI format: data.toolCallId, data.success (bool), + // data.result.content / data.result.detailedContent (on success), + // data.error.message / data.error.code (on failure) + if (type === "tool.execution_complete") { + const toolName = asString(data.toolName, "tool"); + const isError = data.success === false; + let output = ""; + if (isError) { + const errObj = asRecord(data.error); + output = errObj ? asString(errObj.message) || asString(errObj.code) : ""; + } else { + const resultObj = asRecord(data.result); + output = resultObj + ? asString(resultObj.detailedContent) || asString(resultObj.content) + : asString(data.output) || asString(data.content, ""); + } + const prefix = isError ? `[tool_error: ${toolName}]` : `[tool_result: ${toolName}]`; + return { type: isError ? "error" : "tool_result", content: `${prefix} ${output}`.trim() }; + } + + // --- tool.execution_start: emit tool name + arguments for CLI visibility --- + if (type === "tool.execution_start") { + const toolName = asString(data.toolName, "tool"); + const args = asRecord(data.arguments); + const argStr = args ? JSON.stringify(args) : ""; + return { type: "tool_call", content: `[tool: ${toolName}] ${argStr}`.trim() }; + } + + // --- tool.execution_partial_result: skip --- + if (type === "tool.execution_partial_result") { + return { type: "skip", content: "" }; + } + + // --- result: final summary --- + if (type === "result") { + const exitCode = asNumber(parsed.exitCode, 0); + const usageObj = asRecord(parsed.usage) ?? {}; + const premiumReq = asNumber(usageObj.premiumRequests, 0); + const durationMs = asNumber(usageObj.sessionDurationMs, 0); + const codeChanges = asRecord(usageObj.codeChanges); + const linesAdded = codeChanges ? asNumber(codeChanges.linesAdded, 0) : 0; + const linesRemoved = codeChanges ? asNumber(codeChanges.linesRemoved, 0) : 0; + + const parts: string[] = [`exit: ${exitCode}`]; + if (premiumReq) parts.push(`premium: ${premiumReq}`); + if (durationMs) parts.push(`duration: ${Math.round(durationMs / 1000)}s`); + if (linesAdded || linesRemoved) parts.push(`changes: +${linesAdded}/-${linesRemoved}`); + + return { type: "result", content: parts.join(", ") }; + } + + // --- error --- + if (type === "error") { + const msg = asString(data.message, "") || asString(parsed.message, ""); + const errObj = asRecord(data.error ?? parsed.error); + const text = msg || asString(errObj?.message, "") || rawLine; + return { type: "error", content: text }; + } + + // --- assistant.turn_start / assistant.turn_end --- + if (type === "assistant.turn_start" || type === "assistant.turn_end") { + return { type: "system", content: type.replace("assistant.", "") }; + } + + // --- Ephemeral session/setup events: skip --- + if (type.startsWith("session.") || type === "user.message" || type === "assistant.intent" || type === "assistant.usage") { + return { type: "skip", content: "" }; + } + + // Unknown: pass through + return { type: "text", content: rawLine }; +} diff --git a/packages/adapters/copilot-local/src/cli/index.ts b/packages/adapters/copilot-local/src/cli/index.ts new file mode 100644 index 00000000000..815fb8eac8a --- /dev/null +++ b/packages/adapters/copilot-local/src/cli/index.ts @@ -0,0 +1 @@ +export { formatCopilotLocalEvent } from "./format-event.js"; diff --git a/packages/adapters/copilot-local/src/fetch-with-retry.test.ts b/packages/adapters/copilot-local/src/fetch-with-retry.test.ts new file mode 100644 index 00000000000..9014005d94e --- /dev/null +++ b/packages/adapters/copilot-local/src/fetch-with-retry.test.ts @@ -0,0 +1,267 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchWithRetry } from "@paperclipai/adapter-utils/server-utils"; + +/* ---------- helpers ---------- */ + +/** Create a minimal Response-like object that fetchWithRetry can consume. */ +function fakeResponse( + status: number, + headers: Record = {}, +): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: new Headers(headers), + text: () => Promise.resolve(""), + } as unknown as Response; +} + +/* ---------- tests ---------- */ + +describe("fetchWithRetry", () => { + const url = "https://example.com/api"; + const init: RequestInit = { method: "POST" }; + + // Stub out global.fetch + let fetchSpy: ReturnType; + const originalFetch = globalThis.fetch; + + beforeEach(() => { + fetchSpy = vi.fn(); + globalThis.fetch = fetchSpy; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + // ---- success on first try ---- + it("returns immediately on 200 without retrying", async () => { + fetchSpy.mockResolvedValueOnce(fakeResponse(200)); + + const res = await fetchWithRetry(url, init, { maxRetries: 3 }); + + expect(res.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + // ---- retries on retryable status ---- + it("retries on 429 then succeeds", async () => { + fetchSpy + .mockResolvedValueOnce(fakeResponse(429)) + .mockResolvedValueOnce(fakeResponse(200)); + + const res = await fetchWithRetry(url, init, { + maxRetries: 2, + baseDelayMs: 1, // keep tests fast + maxDelayMs: 5, + }); + + expect(res.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it("retries on 502, 503, 504", async () => { + fetchSpy + .mockResolvedValueOnce(fakeResponse(502)) + .mockResolvedValueOnce(fakeResponse(503)) + .mockResolvedValueOnce(fakeResponse(504)) + .mockResolvedValueOnce(fakeResponse(200)); + + const res = await fetchWithRetry(url, init, { + maxRetries: 3, + baseDelayMs: 1, + maxDelayMs: 5, + }); + + expect(res.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(4); + }); + + // ---- does NOT retry non-retryable statuses ---- + it("does not retry 400 (non-retryable)", async () => { + fetchSpy.mockResolvedValueOnce(fakeResponse(400)); + + const res = await fetchWithRetry(url, init, { + maxRetries: 3, + baseDelayMs: 1, + }); + + expect(res.status).toBe(400); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("does not retry 401 by default", async () => { + fetchSpy.mockResolvedValueOnce(fakeResponse(401)); + + const res = await fetchWithRetry(url, init, { maxRetries: 2, baseDelayMs: 1 }); + + expect(res.status).toBe(401); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + // ---- custom retryableStatuses ---- + it("retries on 403 when included in retryableStatuses", async () => { + fetchSpy + .mockResolvedValueOnce(fakeResponse(403)) + .mockResolvedValueOnce(fakeResponse(200)); + + const res = await fetchWithRetry(url, init, { + maxRetries: 2, + baseDelayMs: 1, + retryableStatuses: [403, 429, 502, 503, 504], + }); + + expect(res.status).toBe(200); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + // ---- exhausts retries and returns last response ---- + it("returns last response after exhausting maxRetries", async () => { + fetchSpy + .mockResolvedValueOnce(fakeResponse(429)) + .mockResolvedValueOnce(fakeResponse(429)) + .mockResolvedValueOnce(fakeResponse(429)); + + const res = await fetchWithRetry(url, init, { + maxRetries: 2, + baseDelayMs: 1, + maxDelayMs: 5, + }); + + expect(res.status).toBe(429); + // 1 initial + 2 retries = 3 calls + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + // ---- zero retries ---- + it("does not retry when maxRetries is 0", async () => { + fetchSpy.mockResolvedValueOnce(fakeResponse(503)); + + const res = await fetchWithRetry(url, init, { + maxRetries: 0, + baseDelayMs: 1, + }); + + expect(res.status).toBe(503); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + // ---- onRetry callback ---- + it("calls onRetry callback before each retry", async () => { + fetchSpy + .mockResolvedValueOnce(fakeResponse(429)) + .mockResolvedValueOnce(fakeResponse(502)) + .mockResolvedValueOnce(fakeResponse(200)); + + const onRetry = vi.fn(); + + await fetchWithRetry(url, init, { + maxRetries: 3, + baseDelayMs: 1, + maxDelayMs: 5, + onRetry, + }); + + expect(onRetry).toHaveBeenCalledTimes(2); + expect(onRetry).toHaveBeenCalledWith( + expect.objectContaining({ attempt: 1, status: 429 }), + ); + expect(onRetry).toHaveBeenCalledWith( + expect.objectContaining({ attempt: 2, status: 502 }), + ); + }); + + // ---- Retry-After header (seconds) ---- + it("respects Retry-After header in seconds", async () => { + fetchSpy + .mockResolvedValueOnce(fakeResponse(429, { "retry-after": "1" })) + .mockResolvedValueOnce(fakeResponse(200)); + + const onRetry = vi.fn(); + + await fetchWithRetry(url, init, { + maxRetries: 2, + baseDelayMs: 1, + maxDelayMs: 5000, + onRetry, + }); + + // Retry-After: 1 → 1000ms, but capped by maxDelayMs + expect(onRetry).toHaveBeenCalledWith( + expect.objectContaining({ delayMs: 1000 }), + ); + }); + + // ---- maxDelayMs cap ---- + it("caps delay at maxDelayMs", async () => { + fetchSpy + .mockResolvedValueOnce(fakeResponse(429, { "retry-after": "60" })) + .mockResolvedValueOnce(fakeResponse(200)); + + const onRetry = vi.fn(); + + await fetchWithRetry(url, init, { + maxRetries: 2, + baseDelayMs: 1, + maxDelayMs: 100, + onRetry, + }); + + // Retry-After: 60 → 60_000ms, should be capped to 100ms + expect(onRetry).toHaveBeenCalledWith( + expect.objectContaining({ delayMs: 100 }), + ); + }); + + // ---- network error propagation ---- + it("propagates network errors (does not catch them)", async () => { + fetchSpy.mockRejectedValueOnce(new TypeError("fetch failed")); + + await expect( + fetchWithRetry(url, init, { maxRetries: 2, baseDelayMs: 1 }), + ).rejects.toThrow("fetch failed"); + }); + + // ---- timeout via timeoutMs ---- + it("aborts request when timeoutMs is exceeded", async () => { + // Simulate a fetch that never resolves until aborted + fetchSpy.mockImplementation( + (_url: string, opts: RequestInit) => + new Promise((_resolve, reject) => { + (opts.signal as AbortSignal)?.addEventListener("abort", () => { + reject(new DOMException("The operation was aborted.", "AbortError")); + }); + }), + ); + + await expect( + fetchWithRetry(url, init, { + maxRetries: 0, + timeoutMs: 50, + }), + ).rejects.toThrow(/abort/i); + }); + + // ---- caller signal respected ---- + it("respects caller-provided AbortSignal", async () => { + const ac = new AbortController(); + + fetchSpy.mockImplementation( + (_url: string, opts: RequestInit) => + new Promise((_resolve, reject) => { + (opts.signal as AbortSignal)?.addEventListener("abort", () => { + reject(new DOMException("The operation was aborted.", "AbortError")); + }); + }), + ); + + // Abort immediately + setTimeout(() => ac.abort(), 10); + + await expect( + fetchWithRetry(url, { ...init, signal: ac.signal }, { maxRetries: 0 }), + ).rejects.toThrow(/abort/i); + }); +}); diff --git a/packages/adapters/copilot-local/src/index.ts b/packages/adapters/copilot-local/src/index.ts new file mode 100644 index 00000000000..57293d1b7a9 --- /dev/null +++ b/packages/adapters/copilot-local/src/index.ts @@ -0,0 +1,41 @@ +export const type = "copilot_local"; +export const label = "GitHub Copilot (local)"; + +export const models: Array<{ id: string; label: string }> = [ + { id: "claude-sonnet-4.6", label: "Claude Sonnet 4.6 (via Copilot)" }, + { id: "claude-opus-4.6", label: "Claude Opus 4.6 (via Copilot)" }, + { id: "claude-haiku-4.5", label: "Claude Haiku 4.5 (via Copilot)" }, + { id: "gpt-5.4", label: "GPT-5.4 (via Copilot)" }, + { id: "gpt-5.4-mini", label: "GPT-5.4 Mini (via Copilot)" }, + { id: "gpt-5.2", label: "GPT-5.2 (via Copilot)" }, + { id: "gpt-5-mini", label: "GPT-5 Mini (via Copilot)" }, + { id: "gpt-5.2-codex", label: "GPT-5.2 Codex (via Copilot)" }, + { id: "gpt-5.3-codex", label: "GPT-5.3 Codex (via Copilot)" }, + { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro (via Copilot)" }, +]; + +export const agentConfigurationDoc = `# copilot_local agent configuration + +## Prerequisites +- GitHub Copilot CLI installed: \`npm install -g @github/copilot\` (Node 22+) +- Active Copilot subscription (Pro+, Business, Enterprise) — or BYOK configuration +- Authenticated: \`copilot login\` (or \`copilot login --host \` for GHE) + +## Config Fields +- **model** — Copilot model to use (e.g. claude-sonnet-4.6, gpt-5.4) +- **cwd** — Working directory for the agent +- **command** — CLI binary name (default: "copilot") +- **gheHost** — GitHub Enterprise hostname (e.g. "mycompany.ghe.com") +- **effort** — Reasoning effort: low, medium, high (default: medium) +- **dangerouslySkipPermissions** (boolean, optional, default true) — pass --allow-all-tools to Copilot CLI; defaults to true because Paperclip runs Copilot in headless -s mode where interactive permission prompts cannot be answered. Set to false to restrict Copilot to its default toolset +- **isolateSession** — If true, starts a fresh session each run (no --resume) +- **byokBaseUrl** — BYOK: Custom OpenAI-compatible endpoint URL +- **byokApiKey** — BYOK: API key for custom endpoint + +## Environment Variables +- \`COPILOT_GITHUB_TOKEN\` — Override GitHub token for Copilot auth +- \`COPILOT_PROVIDER_BASE_URL\` — BYOK endpoint (set via byokBaseUrl config) +- \`COPILOT_PROVIDER_API_KEY\` — BYOK API key (set via byokApiKey config) +- \`COPILOT_OFFLINE\` — Set to "true" for air-gapped mode +- \`GH_HOST\` — GitHub Enterprise hostname override +`; diff --git a/packages/adapters/copilot-local/src/server/auth.ts b/packages/adapters/copilot-local/src/server/auth.ts new file mode 100644 index 00000000000..0e3f20d1821 --- /dev/null +++ b/packages/adapters/copilot-local/src/server/auth.ts @@ -0,0 +1,208 @@ +/** + * Copilot token resolution and auth helpers for copilot_local. + * + * Credential search order: + * 1. COPILOT_GITHUB_TOKEN env var + * 2. GH_TOKEN env var + * 3. GITHUB_TOKEN env var + * 4. `gh auth token` CLI fallback (with --hostname for GHE) + */ +import { runChildProcess, fetchWithRetry } from "@paperclipai/adapter-utils/server-utils"; + +const COPILOT_TOKEN_ENV_VARS = [ + "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", +] as const; + +/** + * Validate that a token is a supported type for the Copilot API. + * Classic PATs (ghp_) are explicitly rejected. + */ +export function validateCopilotToken(token: string): { valid: boolean; reason?: string } { + const trimmed = token.trim(); + if (!trimmed) return { valid: false, reason: "Token is empty" }; + if (trimmed.startsWith("ghp_")) { + return { + valid: false, + reason: "Classic personal access tokens (ghp_) are not supported by the Copilot API. Use a fine-grained PAT (github_pat_) or OAuth token (gho_) instead.", + }; + } + return { valid: true }; +} + +export interface CopilotTokenResult { + token: string; + source: string; +} + +/** + * Resolve a Copilot API token from environment or gh CLI. + * + * @param env - The environment to search (usually process.env merged with adapter env) + * @param gheHost - GitHub Enterprise hostname (empty or undefined = github.com) + * @param tokenSource - "auto" (default), "env", or "gh_cli" + */ +export async function resolveCopilotToken( + env: Record, + gheHost?: string, + tokenSource: string = "auto", +): Promise { + // Step 1: Try environment variables (unless tokenSource is "gh_cli") + if (tokenSource !== "gh_cli") { + for (const envVar of COPILOT_TOKEN_ENV_VARS) { + const value = env[envVar]?.trim(); + if (value && value.length > 0) { + const validation = validateCopilotToken(value); + if (validation.valid) { + return { token: value, source: `env:${envVar}` }; + } + } + } + if (tokenSource === "env") return null; + } + + // Step 2: Try gh auth token CLI (strip GH_TOKEN/GITHUB_TOKEN from env so gh reads its own store) + try { + const args = ["auth", "token"]; + if (gheHost) { + args.push("--hostname", gheHost); + } + const cleanEnv = Object.fromEntries( + Object.entries(env) + .filter((e): e is [string, string] => typeof e[1] === "string") + .filter(([k]) => k !== "GITHUB_TOKEN" && k !== "GH_TOKEN"), + ); + const result = await runChildProcess( + `copilot-token-resolve-${Date.now()}`, + "gh", + args, + { + cwd: process.cwd(), + env: cleanEnv, + timeoutSec: 10, + graceSec: 2, + onLog: async () => {}, + }, + ); + const token = result.stdout.trim(); + if (token && (result.exitCode ?? 1) === 0) { + const validation = validateCopilotToken(token); + if (validation.valid) { + return { token, source: `gh_cli${gheHost ? `:${gheHost}` : ""}` }; + } + } + } catch { + // gh CLI not available or failed — fall through + } + + return null; +} + +/** + * Build Copilot API request headers. + */ +export function buildCopilotHeaders(token: string): Record { + return { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "Editor-Version": "vscode/1.104.1", + "Editor-Plugin-Version": "copilot-chat/0.27.2025070801", + "User-Agent": "GitHubCopilotChat/0.27.2025070801", + "Copilot-Integration-Id": "vscode-chat", + "Openai-Intent": "conversation-edits", + }; +} + +/** + * Detect if a Copilot error indicates auth problems. + */ +export function isCopilotAuthError(errorMessage: string | null, stdout: string, stderr: string): boolean { + const combined = `${errorMessage ?? ""}\n${stdout}\n${stderr}`; + return /(?:unauthorized|401|403|invalid.*token|auth.*required|not.*authenticated)/i.test(combined); +} + +/** + * Response from the /copilot_internal/user endpoint. + */ +interface CopilotUserInfo { + login: string; + copilot_plan: string; + chat_enabled: boolean; + endpoints: { + api: string; + proxy?: string; + telemetry?: string; + "origin-tracker"?: string; + }; +} + +// In-memory cache for discovered endpoints. +// Keyed by token hash + host to avoid cross-tenant cache poisoning in +// multi-company deployments where different users on the same gheHost +// may resolve to different Copilot plans (individual vs. business). +const endpointCache = new Map(); +const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes + +/** Cheap cache key that avoids storing the raw token in the key. */ +function cacheKey(token: string, gheHost?: string): string { + // Use first 8 + last 4 chars as a fingerprint — not cryptographic, + // but sufficient for dedup across concurrent callers. + const fp = token.length > 12 + ? `${token.slice(0, 8)}…${token.slice(-4)}` + : token; + return `${fp}@${gheHost || "github.com"}`; +} + +/** + * Discover the correct Copilot API base URL by calling /copilot_internal/user. + * + * The Copilot API URL is tenant/plan-specific: + * - github.com individual: https://api.individual.githubcopilot.com + * - github.com business: https://api.business.githubcopilot.com + * - GHE (e.g. foo.ghe.com): https://copilot-api.foo.ghe.com + * + * Results are cached for 10 minutes. + */ +export async function discoverCopilotApiUrl( + token: string, + gheHost?: string, +): Promise<{ apiUrl: string; userInfo: CopilotUserInfo } | null> { + const ck = cacheKey(token, gheHost); + const cached = endpointCache.get(ck); + if (cached && cached.expiresAt > Date.now()) { + return { apiUrl: cached.url, userInfo: { login: "", copilot_plan: "", chat_enabled: true, endpoints: { api: cached.url } } }; + } + + const apiHost = gheHost ? `api.${gheHost}` : "api.github.com"; + const url = `https://${apiHost}/copilot_internal/user`; + + try { + const response = await fetchWithRetry(url, { + method: "GET", + headers: { + Authorization: `token ${token}`, + "Editor-Version": "vscode/1.104.1", + "Copilot-Integration-Id": "vscode-chat", + Accept: "application/json", + }, + }, { + timeoutMs: 10_000, + maxRetries: 2, + retryableStatuses: [429, 502, 503, 504], + }); + + if (!response.ok) return null; + + const data = (await response.json()) as CopilotUserInfo; + const apiUrl = data.endpoints?.api; + if (!apiUrl) return null; + + const cleanUrl = apiUrl.replace(/\/+$/, ""); + endpointCache.set(ck, { url: cleanUrl, expiresAt: Date.now() + CACHE_TTL_MS }); + + return { apiUrl: cleanUrl, userInfo: data }; + } catch { + return null; + } +} diff --git a/packages/adapters/copilot-local/src/server/detect-model.ts b/packages/adapters/copilot-local/src/server/detect-model.ts new file mode 100644 index 00000000000..3526bac836e --- /dev/null +++ b/packages/adapters/copilot-local/src/server/detect-model.ts @@ -0,0 +1,34 @@ +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** + * Detect the user's default Copilot CLI model from ~/.copilot/config.json. + * + * The Copilot CLI stores the active model as a top-level `"model"` field + * (set via the `/model` slash-command during a session). + * + * Returns `{ model, provider: "copilot", source }` or `null` if not found. + */ +export async function detectCopilotLocalModel(): Promise<{ + model: string; + provider: string; + source: string; +} | null> { + try { + const configPath = join(homedir(), ".copilot", "config.json"); + const raw = await readFile(configPath, "utf-8"); + const config = JSON.parse(raw); + + if (typeof config.model === "string" && config.model.trim()) { + return { + model: config.model.trim(), + provider: "copilot", + source: "~/.copilot/config.json", + }; + } + return null; + } catch { + return null; + } +} diff --git a/packages/adapters/copilot-local/src/server/execute.ts b/packages/adapters/copilot-local/src/server/execute.ts new file mode 100644 index 00000000000..10594b5ef5a --- /dev/null +++ b/packages/adapters/copilot-local/src/server/execute.ts @@ -0,0 +1,478 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { + asString, + asNumber, + asBoolean, + asStringArray, + parseObject, + buildPaperclipEnv, + joinPromptSections, + buildInvocationEnvForLogs, + ensureAbsoluteDirectory, + ensureCommandResolvable, + ensurePathInEnv, + resolveCommandForLogs, + renderTemplate, + renderPaperclipWakePrompt, + stringifyPaperclipWakePayload, + runChildProcess, + readPaperclipRuntimeSkillEntries, + resolvePaperclipDesiredSkillNames, + ensurePaperclipSkillSymlink, +} from "@paperclipai/adapter-utils/server-utils"; +import { parseCopilotLocalJsonl, isCopilotLocalStaleSessionError } from "./parse.js"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +// --------------------------------------------------------------------------- +// Skill injection (ephemeral: symlink desired skills into a cache dir) +// --------------------------------------------------------------------------- + +async function ensureCopilotSkillsInjected( + config: Record, + onLog: AdapterExecutionContext["onLog"], + skillsCacheDir: string, +): Promise { + const allSkillsEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredSkillNames = resolvePaperclipDesiredSkillNames(config, allSkillsEntries); + const desiredSet = new Set(desiredSkillNames); + const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.key)); + if (skillsEntries.length === 0) return []; + + await fs.mkdir(skillsCacheDir, { recursive: true }); + const warnings: string[] = []; + const activeNames = new Set(); + + for (const entry of skillsEntries) { + activeNames.add(entry.runtimeName); + const target = path.join(skillsCacheDir, entry.runtimeName); + try { + const result = await ensurePaperclipSkillSymlink(entry.source, target); + if (result === "skipped") continue; + await onLog( + "stdout", + `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Copilot skill "${entry.runtimeName}" into ${skillsCacheDir}\n`, + ); + } catch (err) { + const msg = `Failed to inject Copilot skill "${entry.key}" into ${skillsCacheDir}: ${err instanceof Error ? err.message : String(err)}`; + warnings.push(msg); + await onLog("stderr", `[paperclip] ${msg}\n`); + } + } + + // Prune stale symlinks from the cache dir that are no longer desired + const dirEntries = await fs.readdir(skillsCacheDir, { withFileTypes: true }).catch(() => []); + for (const entry of dirEntries) { + if (activeNames.has(entry.name) || !entry.isSymbolicLink()) continue; + const target = path.join(skillsCacheDir, entry.name); + await fs.unlink(target).catch(() => {}); + await onLog( + "stdout", + `[paperclip] Removed stale Copilot skill "${entry.name}" from ${skillsCacheDir}\n`, + ); + } + + return warnings; +} + +// --------------------------------------------------------------------------- +// Main execute +// --------------------------------------------------------------------------- + +export async function execute(ctx: AdapterExecutionContext): Promise { + const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; + + // ---- Config fields ---- + const promptTemplate = asString( + config.promptTemplate, + "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.", + ); + const command = asString(config.command, "copilot"); + const model = asString(config.model, "").trim(); + const gheHost = asString(config.gheHost, "").trim(); + const effort = asString(config.effort, "").trim(); + const byokBaseUrl = asString(config.byokBaseUrl, "").trim(); + const byokApiKey = asString(config.byokApiKey, "").trim(); + const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true); + const isolateSession = asBoolean(config.isolateSession, false); + const timeoutSec = asNumber(config.timeoutSec, 0); + const graceSec = asNumber(config.graceSec, 20); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + const skillsDir = asString(config.skillsDir, "").trim(); + + // ---- Workspace context ---- + const workspaceContext = parseObject(context.paperclipWorkspace); + const workspaceCwd = asString(workspaceContext.cwd, ""); + const workspaceSource = asString(workspaceContext.source, ""); + const workspaceId = asString(workspaceContext.workspaceId, ""); + const workspaceRepoUrl = asString(workspaceContext.repoUrl, ""); + const workspaceRepoRef = asString(workspaceContext.repoRef, ""); + const agentHome = asString(workspaceContext.agentHome, ""); + const workspaceHints = Array.isArray(context.paperclipWorkspaces) + ? context.paperclipWorkspaces.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const configuredCwd = asString(config.cwd, ""); + const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; + const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; + const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + + // ---- Skill injection (ephemeral via COPILOT_SKILLS_DIRS) ---- + const skillsCacheDir = path.join(cwd, ".paperclip", "copilot-skill-cache"); + const skillWarnings = await ensureCopilotSkillsInjected(config, onLog, skillsCacheDir); + + // ---- Environment ---- + const envConfig = parseObject(config.env); + const hasExplicitApiKey = + typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; + const env: Record = { ...buildPaperclipEnv(agent) }; + env.PAPERCLIP_RUN_ID = runId; + + // Wake context + const wakeTaskId = + (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || + (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || + null; + const wakeReason = + typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0 + ? context.wakeReason.trim() + : null; + const wakeCommentId = + (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) || + (typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) || + null; + const approvalId = + typeof context.approvalId === "string" && context.approvalId.trim().length > 0 + ? context.approvalId.trim() + : null; + const approvalStatus = + typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0 + ? context.approvalStatus.trim() + : null; + const linkedIssueIds = Array.isArray(context.issueIds) + ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : []; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); + + if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; + if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; + if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; + if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; + if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; + if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; + if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; + if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; + if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId; + if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl; + if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef; + if (agentHome) env.AGENT_HOME = agentHome; + if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + + // BYOK environment variables + if (byokBaseUrl) { + env.COPILOT_PROVIDER_BASE_URL = byokBaseUrl; + env.COPILOT_OFFLINE = "true"; + } + if (byokApiKey) { + env.COPILOT_PROVIDER_API_KEY = byokApiKey; + } + if (model) { + env.COPILOT_MODEL = model; + } + + // GHE host + if (gheHost) { + env.GH_HOST = gheHost; + } + + // Copilot CLI skill discovery via COPILOT_SKILLS_DIRS env var + // Points to the ephemeral skill cache dir with symlinked Paperclip skills + try { + const cacheEntries = await fs.readdir(skillsCacheDir).catch(() => []); + if (cacheEntries.length > 0) { + env.COPILOT_SKILLS_DIRS = skillsCacheDir; + } + } catch { + // Ignore — skills won't be injected but execution continues + } + + // User-supplied env overrides + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; + } + if (!hasExplicitApiKey && authToken) { + env.PAPERCLIP_API_KEY = authToken; + } + + const runtimeEnv = Object.fromEntries( + Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); + await ensureCommandResolvable(command, cwd, runtimeEnv); + const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv); + const loggedEnv = buildInvocationEnvForLogs(env, { + runtimeEnv, + includeRuntimeKeys: ["HOME"], + resolvedCommand, + }); + + // ---- Instructions file ---- + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const resolvedInstructionsFilePath = instructionsFilePath + ? path.resolve(cwd, instructionsFilePath) + : ""; + const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : ""; + let instructionsPrefix = ""; + if (resolvedInstructionsFilePath) { + try { + const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8"); + instructionsPrefix = + `${instructionsContents}\n\n` + + `The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` + + `Resolve any relative file references from ${instructionsDir}.\n\n`; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + await onLog( + "stdout", + `[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`, + ); + } + } + + const commandNotes: string[] = []; + if (skillWarnings.length > 0) { + commandNotes.push(...skillWarnings.map((w) => `[skills] ${w}`)); + } + if (env.COPILOT_SKILLS_DIRS) { + commandNotes.push(`Injected Paperclip skills via COPILOT_SKILLS_DIRS=${env.COPILOT_SKILLS_DIRS}`); + } + if (resolvedInstructionsFilePath) { + if (instructionsPrefix.length > 0) { + commandNotes.push(`Loaded agent instructions from ${resolvedInstructionsFilePath}`); + commandNotes.push( + `Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`, + ); + } else { + commandNotes.push( + `Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`, + ); + } + } + + // ---- Session handling ---- + const runtimeSessionParams = parseObject(runtime.sessionParams); + const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); + const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); + const canResumeSession = + !isolateSession && + runtimeSessionId.length > 0 && + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); + const sessionId = canResumeSession ? runtimeSessionId : null; + if (runtimeSessionId && !canResumeSession && !isolateSession) { + await onLog( + "stdout", + `[paperclip] Copilot session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + ); + } + + // ---- Build prompt ---- + const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); + const templateData = { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }; + const renderedBootstrapPrompt = + !sessionId && bootstrapPromptTemplate.trim().length > 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; + const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const prompt = joinPromptSections([ + instructionsPrefix, + renderedBootstrapPrompt, + wakePrompt, + sessionHandoffNote, + renderedPrompt, + ]); + const promptMetrics = { + promptChars: prompt.length, + instructionsChars: instructionsPrefix.length, + bootstrapPromptChars: renderedBootstrapPrompt.length, + wakePromptChars: wakePrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + heartbeatPromptChars: renderedPrompt.length, + }; + + // ---- Build CLI args ---- + const buildArgs = (resumeSessionId: string | null) => { + const args = ["-p", prompt, "--output-format", "json", "--no-color", "-s", "--no-ask-user"]; + if (dangerouslySkipPermissions) args.push("--allow-all-tools"); + if (model) args.push("--model", model); + if (resumeSessionId) args.push(`--resume=${resumeSessionId}`); + if (effort) args.push("--effort", effort); + if (gheHost) args.push("--hostname", gheHost); + if (skillsDir) args.push("--add-dir", skillsDir); + // Grant Copilot CLI read/write access to the agent home directory + // (workspace dir for agent state, config, etc.) + if (agentHome) args.push("--add-dir", agentHome); + // Grant access to the instructions directory so Copilot CLI can read + // AGENTS.md and other instruction files via its own `view` tool. + // The path is derived dynamically from the resolved instructions file path + // so it works regardless of how Paperclip manages the agent config. + if (instructionsDir) args.push("--add-dir", instructionsDir); + if (extraArgs.length > 0) args.push(...extraArgs); + return args; + }; + + // ---- Run attempt ---- + const runAttempt = async (resumeSessionId: string | null) => { + const args = buildArgs(resumeSessionId); + if (onMeta) { + await onMeta({ + adapterType: "copilot_local", + command: resolvedCommand, + cwd, + commandNotes, + commandArgs: [...args.filter((a) => a !== prompt), ``], + env: loggedEnv, + prompt, + promptMetrics, + context, + }); + } + + const proc = await runChildProcess(runId, command, args, { + cwd, + env: runtimeEnv, + timeoutSec, + graceSec, + onSpawn, + onLog, + }); + return { + proc, + rawStderr: proc.stderr, + parsed: parseCopilotLocalJsonl(proc.stdout), + }; + }; + + // ---- Build result ---- + const toResult = ( + attempt: { + proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; + rawStderr: string; + parsed: ReturnType; + }, + clearSessionOnMissingSession = false, + ): AdapterExecutionResult => { + if (attempt.proc.timedOut) { + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: true, + errorMessage: `Timed out after ${timeoutSec}s`, + clearSession: clearSessionOnMissingSession, + }; + } + + const resolvedSessionId = + attempt.parsed.sessionId || + (clearSessionOnMissingSession ? null : runtimeSessionId || runtime.sessionId || null); + const resolvedSessionParams = resolvedSessionId + ? ({ + sessionId: resolvedSessionId, + cwd, + ...(workspaceId ? { workspaceId } : {}), + ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), + ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), + } as Record) + : null; + + const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; + const stderrLine = firstNonEmptyLine(attempt.proc.stderr); + const rawExitCode = attempt.proc.exitCode; + const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode; + const fallbackErrorMessage = + parsedError || + stderrLine || + `Copilot exited with code ${synthesizedExitCode ?? -1}`; + const resolvedModel = attempt.parsed.model || model || null; + const isByok = byokBaseUrl.length > 0; + + return { + exitCode: synthesizedExitCode, + signal: attempt.proc.signal, + timedOut: false, + errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage, + usage: { + inputTokens: attempt.parsed.usage.inputTokens, + outputTokens: attempt.parsed.usage.outputTokens, + cachedInputTokens: attempt.parsed.usage.cachedInputTokens, + }, + sessionId: resolvedSessionId, + sessionParams: resolvedSessionParams, + sessionDisplayId: resolvedSessionId, + provider: "copilot", + biller: isByok ? "byok" : "subscription", + model: resolvedModel, + billingType: "subscription", + costUsd: 0, + resultJson: { + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + }, + summary: attempt.parsed.summary || null, + clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId), + }; + }; + + // ---- Execute with stale-session retry ---- + const initial = await runAttempt(sessionId); + const initialFailed = + !initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage)); + if ( + sessionId && + initialFailed && + isCopilotLocalStaleSessionError(initial.proc.stdout, initial.rawStderr) + ) { + await onLog( + "stdout", + `[paperclip] Copilot session "${sessionId}" is unavailable; retrying with a fresh session.\n`, + ); + const retry = await runAttempt(null); + return toResult(retry, true); + } + + return toResult(initial); +} diff --git a/packages/adapters/copilot-local/src/server/index.ts b/packages/adapters/copilot-local/src/server/index.ts new file mode 100644 index 00000000000..fe6a3be2ac6 --- /dev/null +++ b/packages/adapters/copilot-local/src/server/index.ts @@ -0,0 +1,60 @@ +export { execute } from "./execute.js"; +export { testEnvironment } from "./test.js"; +export { parseCopilotLocalJsonl, isCopilotLocalStaleSessionError, type CopilotLocalResult } from "./parse.js"; +export { copilotLogin, type CopilotLoginResult, type CopilotLoginChunk, type CopilotLoginOptions } from "./login.js"; +export { listCopilotLocalSkills, syncCopilotLocalSkills, resolveCopilotLocalDesiredSkillNames } from "./skills.js"; +export { detectCopilotLocalModel } from "./detect-model.js"; +export { resolveCopilotToken, isCopilotAuthError, validateCopilotToken, type CopilotTokenResult } from "./auth.js"; +export { listCopilotLocalModels } from "./models.js"; + +import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; + +function readNonEmptyString(v: unknown): string | undefined { + return typeof v === "string" && v.trim() ? v.trim() : undefined; +} + +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw: unknown) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record; + const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id); + if (!sessionId) return null; + const cwd = + readNonEmptyString(record.cwd) ?? + readNonEmptyString(record.workdir) ?? + readNonEmptyString(record.folder); + const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id); + const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url); + const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + serialize(params: Record | null) { + if (!params) return null; + const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id); + if (!sessionId) return null; + const cwd = + readNonEmptyString(params.cwd) ?? + readNonEmptyString(params.workdir) ?? + readNonEmptyString(params.folder); + const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id); + const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url); + const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + getDisplayId(params: Record | null) { + if (!params) return null; + return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id) ?? null; + }, +}; diff --git a/packages/adapters/copilot-local/src/server/login.ts b/packages/adapters/copilot-local/src/server/login.ts new file mode 100644 index 00000000000..4f7262e992d --- /dev/null +++ b/packages/adapters/copilot-local/src/server/login.ts @@ -0,0 +1,133 @@ +/** + * Login helper for the copilot_local adapter. + * + * Spawns `copilot login` (with optional `--host ` for GitHub Enterprise) + * and streams progress via an `onChunk` callback so callers (e.g. the SSE route) + * can forward the device-flow code to the UI immediately — before the process + * exits. + * + * Copilot login uses a device-flow: it prints a one-time code and a URL for the + * user to complete browser-based authentication. Without streaming the user + * would only see the code after the 120 s timeout or successful auth, which is + * too late. + */ +import { + runChildProcess, + ensureCommandResolvable, +} from "@paperclipai/adapter-utils/server-utils"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface CopilotLoginResult { + success: boolean; + output: string; + errorMessage: string | null; +} + +/** A chunk emitted during the login process. */ +export interface CopilotLoginChunk { + stream: "stdout" | "stderr"; + text: string; +} + +export interface CopilotLoginOptions { + gheHost?: string; + command?: string; + /** Called for every stdout/stderr chunk so the caller can stream to the UI. */ + onChunk?: (chunk: CopilotLoginChunk) => void; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +export async function copilotLogin( + options?: CopilotLoginOptions, +): Promise { + const command = options?.command ?? "copilot"; + const gheHost = options?.gheHost?.trim() ?? ""; + const onChunk = options?.onChunk; + + const cwd = process.cwd(); + const env: Record = {}; + const mergedEnv: NodeJS.ProcessEnv = { ...process.env, ...env }; + + // Ensure the command is resolvable before spawning. + try { + await ensureCommandResolvable(command, cwd, mergedEnv); + } catch (err) { + const errorMessage = + err instanceof Error + ? err.message + : `Command not found: "${command}"`; + onChunk?.({ stream: "stderr", text: errorMessage }); + return { + success: false, + output: "", + errorMessage, + }; + } + + // Build args: `copilot login` or `copilot login --host ` + const args: string[] = ["login"]; + if (gheHost.length > 0) { + args.push("--host", gheHost); + } + + const runId = `copilot-login-${Date.now()}`; + + let stdoutChunks = ""; + let stderrChunks = ""; + + try { + const proc = await runChildProcess(runId, command, args, { + cwd, + env, + timeoutSec: 120, + graceSec: 10, + onLog: async (stream, chunk) => { + if (stream === "stdout") { + stdoutChunks += chunk; + } else { + stderrChunks += chunk; + } + // Forward to the caller so it can stream to the UI in real-time. + onChunk?.({ stream, text: chunk }); + }, + }); + + const combinedOutput = [proc.stdout, proc.stderr] + .filter(Boolean) + .join("\n") + .trim(); + + const exitedCleanly = (proc.exitCode ?? 1) === 0; + + if (exitedCleanly) { + return { + success: true, + output: combinedOutput, + errorMessage: null, + }; + } + + // Non-zero exit — treat as failure. + const errorDetail = + proc.stderr.trim() || proc.stdout.trim() || `Process exited with code ${proc.exitCode}`; + + return { + success: false, + output: combinedOutput, + errorMessage: errorDetail, + }; + } catch (err) { + return { + success: false, + output: [stdoutChunks, stderrChunks].filter(Boolean).join("\n").trim(), + errorMessage: + err instanceof Error ? err.message : "Unknown error during copilot login", + }; + } +} diff --git a/packages/adapters/copilot-local/src/server/models.ts b/packages/adapters/copilot-local/src/server/models.ts new file mode 100644 index 00000000000..5a94cd0be25 --- /dev/null +++ b/packages/adapters/copilot-local/src/server/models.ts @@ -0,0 +1,166 @@ +/** + * Copilot model discovery for copilot_local. + * + * Fetches available models from the Copilot API. When a `gheHost` hint is + * provided (from the agent's config field), uses the GHE token; otherwise + * uses the default github.com token. + * + * The models endpoint URL is discovered dynamically via /copilot_internal/user. + * Falls back to a hardcoded list if the API call fails. + */ +import { runChildProcess, fetchWithRetry } from "@paperclipai/adapter-utils/server-utils"; +import { buildCopilotHeaders, discoverCopilotApiUrl } from "./auth.js"; + +const FALLBACK_MODELS: Array<{ id: string; label: string }> = [ + { id: "claude-sonnet-4.6", label: "Claude Sonnet 4.6" }, + { id: "claude-haiku-4.5", label: "Claude Haiku 4.5" }, + { id: "claude-opus-4.6", label: "Claude Opus 4.6" }, + { id: "gpt-4.1", label: "GPT-4.1" }, + { id: "gpt-4o", label: "GPT-4o" }, + { id: "gpt-5-mini", label: "GPT-5 mini" }, + { id: "gpt-5.4", label: "GPT-5.4" }, + { id: "gpt-5.2-codex", label: "GPT-5.2-Codex" }, + { id: "gpt-5.3-codex", label: "GPT-5.3-Codex" }, + { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" }, + { id: "grok-code-fast-1", label: "Grok Code Fast 1" }, +]; + +/** + * Get an auth token for a specific GitHub host via `gh auth token`. + * Strips GITHUB_TOKEN/GH_TOKEN from env so gh reads from its own credential store. + */ +async function getTokenForHost(host?: string): Promise { + try { + const args = ["auth", "token"]; + if (host) args.push("--hostname", host); + const cleanEnv = Object.fromEntries( + Object.entries(process.env) + .filter((e): e is [string, string] => typeof e[1] === "string") + .filter(([k]) => k !== "GITHUB_TOKEN" && k !== "GH_TOKEN"), + ); + const result = await runChildProcess( + `copilot-token-${host ?? "default"}-${Date.now()}`, + "gh", + args, + { + cwd: process.cwd(), + env: cleanEnv, + timeoutSec: 10, + graceSec: 2, + onLog: async () => {}, + }, + ); + const token = (result.stdout ?? "").trim(); + if (token && (result.exitCode ?? 1) === 0 && !token.startsWith("ghp_")) { + return token; + } + } catch { + // ignore + } + return null; +} + +interface CopilotModelRaw { + id?: string; + name?: string; + model_picker_enabled?: boolean; + vendor?: string; + capabilities?: { + type?: string; + }; +} + +/** + * Fetch models from the Copilot API using a specific token and discovered URL. + * Only filters out embedding models — everything else is shown. + */ +async function fetchModelsWithToken( + token: string, + gheHost?: string, +): Promise> { + const discovered = await discoverCopilotApiUrl(token, gheHost); + const baseUrl = discovered?.apiUrl ?? "https://api.githubcopilot.com"; + + try { + const response = await fetchWithRetry(`${baseUrl}/models`, { + method: "GET", + headers: buildCopilotHeaders(token), + }, { + timeoutMs: 10_000, + maxRetries: 2, + retryableStatuses: [429, 502, 503, 504], + }); + if (!response.ok) return []; + + const body = (await response.json()) as unknown; + const data = Array.isArray(body) + ? (body as unknown[]) + : Array.isArray((body as Record)?.data) + ? ((body as Record).data as unknown[]) + : null; + if (!data) return []; + + const models: Array<{ id: string; label: string }> = []; + for (const item of data) { + const m = item as CopilotModelRaw; + if (!m.id) continue; + if (m.capabilities?.type === "embeddings") continue; + if (m.id.includes("embedding")) continue; + + const label = m.name ?? m.id; + const vendorHint = + m.vendor && m.vendor !== "Azure OpenAI" && m.vendor !== "OpenAI" + ? ` (${m.vendor})` + : ""; + models.push({ id: m.id, label: `${label}${vendorHint}` }); + } + + // Deduplicate by id + const seen = new Map(); + for (const model of models) { + if (!seen.has(model.id)) { + seen.set(model.id, model); + } + } + return Array.from(seen.values()); + } catch { + return []; + } +} + +/** + * Discover available Copilot models dynamically. + * + * @param hints - Optional config hints from the UI/route. + * `hints.gheHost` — GitHub Enterprise hostname for token resolution. + */ +export async function listCopilotLocalModels( + hints?: Record, +): Promise> { + const gheHost = + typeof hints?.gheHost === "string" && hints.gheHost.trim().length > 0 + ? hints.gheHost.trim() + : undefined; + + const tokenHost = gheHost ?? "github.com"; + const token = await getTokenForHost(tokenHost); + if (!token) { + // Try env vars as fallback — but ONLY for the default host (github.com). + // Sending env-var tokens to an arbitrary gheHost would allow token + // exfiltration via a user-controlled hostname (SSRF / credential leak). + if (!gheHost) { + const envToken = + process.env.COPILOT_GITHUB_TOKEN?.trim() || + process.env.GH_TOKEN?.trim() || + process.env.GITHUB_TOKEN?.trim(); + if (envToken && !envToken.startsWith("ghp_")) { + const models = await fetchModelsWithToken(envToken, undefined); + return models.length > 0 ? models : FALLBACK_MODELS; + } + } + return FALLBACK_MODELS; + } + + const models = await fetchModelsWithToken(token, gheHost); + return models.length > 0 ? models : FALLBACK_MODELS; +} diff --git a/packages/adapters/copilot-local/src/server/parse.test.ts b/packages/adapters/copilot-local/src/server/parse.test.ts new file mode 100644 index 00000000000..2a6c5f1db8f --- /dev/null +++ b/packages/adapters/copilot-local/src/server/parse.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from "vitest"; +import { parseCopilotLocalJsonl, isCopilotLocalStaleSessionError } from "./parse.js"; + +/* ------------------------------------------------------------------ */ +/* Helper: build a JSONL line from a plain object */ +/* ------------------------------------------------------------------ */ +function jsonl(...objs: Record[]): string { + return objs.map((o) => JSON.stringify(o)).join("\n"); +} + +/* ================================================================== */ +/* parseCopilotLocalJsonl */ +/* ================================================================== */ +describe("parseCopilotLocalJsonl", () => { + it("concatenates multiple message_delta events into summary", () => { + const input = jsonl( + { type: "assistant.message_delta", data: { deltaContent: "Hello" }, id: "1", timestamp: "t1", ephemeral: true }, + { type: "assistant.message_delta", data: { deltaContent: " world" }, id: "2", timestamp: "t2", ephemeral: true }, + { type: "assistant.message_delta", data: { deltaContent: "!" }, id: "3", timestamp: "t3", ephemeral: true }, + ); + const result = parseCopilotLocalJsonl(input); + expect(result.summary).toBe("Hello world!"); + }); + + it("accumulates outputTokens from assistant.message events", () => { + const input = jsonl( + { type: "assistant.message", data: { content: "first", outputTokens: 10 } }, + { type: "assistant.message", data: { content: " second", outputTokens: 25 } }, + ); + const result = parseCopilotLocalJsonl(input); + expect(result.summary).toBe("first second"); + expect(result.usage.outputTokens).toBe(35); + }); + + it("extracts model from tool.execution_complete", () => { + const input = jsonl( + { type: "tool.execution_start", data: { toolCallId: "tc1", toolName: "read_file", arguments: {} } }, + { type: "tool.execution_complete", data: { toolCallId: "tc1", success: true, result: { content: "file data" }, model: "gpt-5-mini" } }, + ); + const result = parseCopilotLocalJsonl(input); + expect(result.model).toBe("gpt-5-mini"); + }); + + it("uses first model from tool.execution_complete (ignores subsequent)", () => { + const input = jsonl( + { type: "tool.execution_complete", data: { toolCallId: "tc1", success: true, result: {}, model: "gpt-5-mini" } }, + { type: "tool.execution_complete", data: { toolCallId: "tc2", success: true, result: {}, model: "claude-4" } }, + ); + const result = parseCopilotLocalJsonl(input); + expect(result.model).toBe("gpt-5-mini"); + }); + + it("extracts sessionId and premiumRequests from result event", () => { + const input = jsonl( + { type: "result", sessionId: "sess_abc", exitCode: 0, usage: { premiumRequests: 3, sessionDurationMs: 30000 } }, + ); + const result = parseCopilotLocalJsonl(input); + expect(result.sessionId).toBe("sess_abc"); + expect(result.premiumRequests).toBe(3); + }); + + it("populates errorMessage from error events", () => { + const input = jsonl( + { type: "error", data: { message: "something failed" } }, + ); + const result = parseCopilotLocalJsonl(input); + expect(result.errorMessage).toBe("something failed"); + expect(result.summary).toBe(""); + }); + + it("concatenates multiple error messages", () => { + const input = jsonl( + { type: "error", data: { message: "first error" } }, + { type: "error", data: { message: "second error" } }, + ); + const result = parseCopilotLocalJsonl(input); + expect(result.errorMessage).toBe("first error\nsecond error"); + }); + + it("handles a mixed real-world JSONL stream", () => { + const input = jsonl( + { type: "session.created", data: { sessionId: "sess_xyz" } }, + { type: "user.message", data: { content: "explain code" } }, + { type: "assistant.intent", data: { intent: "explain" } }, + { type: "assistant.reasoning", data: { content: "thinking..." } }, + { type: "assistant.message_delta", data: { deltaContent: "This code " }, id: "d1", timestamp: "t1", ephemeral: true }, + { type: "assistant.message_delta", data: { deltaContent: "does X." }, id: "d2", timestamp: "t2", ephemeral: true }, + { type: "tool.execution_start", data: { toolCallId: "tc1", toolName: "read_file", arguments: {} } }, + { type: "tool.execution_complete", data: { toolCallId: "tc1", success: true, result: { content: "..." }, model: "gpt-5-mini" } }, + { type: "assistant.usage", data: { inputTokens: 100, outputTokens: 50 } }, + { type: "result", sessionId: "sess_xyz", exitCode: 0, usage: { premiumRequests: 1, sessionDurationMs: 5000 } }, + ); + const result = parseCopilotLocalJsonl(input); + expect(result.sessionId).toBe("sess_xyz"); + expect(result.summary).toBe("This code does X."); + expect(result.model).toBe("gpt-5-mini"); + expect(result.usage.inputTokens).toBe(100); + expect(result.usage.outputTokens).toBe(50); + expect(result.premiumRequests).toBe(1); + expect(result.errorMessage).toBeNull(); + }); + + it("parses multi-line chunks (lines separated by \\n in a single string)", () => { + // Simulate buffered output: two JSON objects in one chunk separated by newline + const chunk = + `{"type":"assistant.message_delta","data":{"deltaContent":"A"}}\n{"type":"assistant.message_delta","data":{"deltaContent":"B"}}`; + const result = parseCopilotLocalJsonl(chunk); + expect(result.summary).toBe("AB"); + }); + + it("gracefully skips empty and invalid lines", () => { + const input = [ + "", + " ", + "not valid json at all", + '{"type":"assistant.message_delta","data":{"deltaContent":"ok"}}', + "{}", + '{"noType": true}', + "", + ].join("\n"); + const result = parseCopilotLocalJsonl(input); + expect(result.summary).toBe("ok"); + expect(result.errorMessage).toBeNull(); + }); + + it("accumulates inputTokens and outputTokens from assistant.usage events", () => { + const input = jsonl( + { type: "assistant.usage", data: { inputTokens: 100, outputTokens: 50 } }, + { type: "assistant.usage", data: { inputTokens: 200, outputTokens: 75 } }, + ); + const result = parseCopilotLocalJsonl(input); + expect(result.usage.inputTokens).toBe(300); + expect(result.usage.outputTokens).toBe(125); + }); + + it("returns defaults when given an empty string", () => { + const result = parseCopilotLocalJsonl(""); + expect(result.sessionId).toBeNull(); + expect(result.summary).toBe(""); + expect(result.errorMessage).toBeNull(); + expect(result.usage).toEqual({ inputTokens: 0, outputTokens: 0, cachedInputTokens: 0 }); + expect(result.premiumRequests).toBe(0); + expect(result.model).toBeNull(); + }); + + it("handles \\r\\n line endings", () => { + const input = + '{"type":"assistant.message_delta","data":{"deltaContent":"line1"}}\r\n{"type":"assistant.message_delta","data":{"deltaContent":"line2"}}'; + const result = parseCopilotLocalJsonl(input); + expect(result.summary).toBe("line1line2"); + }); + + it("extracts error from nested error object when data.message is missing", () => { + const input = jsonl( + { type: "error", data: { error: { message: "nested failure" } } }, + ); + const result = parseCopilotLocalJsonl(input); + expect(result.errorMessage).toBe("nested failure"); + }); + + it("skips assistant.reasoning events", () => { + const input = jsonl( + { type: "assistant.reasoning", data: { content: "thinking hard..." } }, + { type: "assistant.message_delta", data: { deltaContent: "answer" } }, + ); + const result = parseCopilotLocalJsonl(input); + expect(result.summary).toBe("answer"); + }); +}); + +/* ================================================================== */ +/* isCopilotLocalStaleSessionError */ +/* ================================================================== */ +describe("isCopilotLocalStaleSessionError", () => { + it("returns true for 'invalid session' in stdout", () => { + expect(isCopilotLocalStaleSessionError("Error: invalid session id", "")).toBe(true); + }); + + it("returns true for 'session not found' in stderr", () => { + expect(isCopilotLocalStaleSessionError("", "session not found for user")).toBe(true); + }); + + it("returns true for 'session expired' in stderr", () => { + expect(isCopilotLocalStaleSessionError("", "your session has expired")).toBe(true); + }); + + it("returns true for 'unknown session' in stdout", () => { + expect(isCopilotLocalStaleSessionError("unknown session abc123", "")).toBe(true); + }); + + it("returns true for case-insensitive matches", () => { + expect(isCopilotLocalStaleSessionError("INVALID SESSION", "")).toBe(true); + expect(isCopilotLocalStaleSessionError("", "Session Not Found")).toBe(true); + }); + + it("returns false for unrelated errors", () => { + expect(isCopilotLocalStaleSessionError("rate limit exceeded", "timeout error")).toBe(false); + }); + + it("returns false for empty strings", () => { + expect(isCopilotLocalStaleSessionError("", "")).toBe(false); + }); + + it("returns false for whitespace-only strings", () => { + expect(isCopilotLocalStaleSessionError(" ", " \n ")).toBe(false); + }); + + it("detects stale session across stdout and stderr combined", () => { + expect(isCopilotLocalStaleSessionError("some output", "error: invalid session")).toBe(true); + }); +}); diff --git a/packages/adapters/copilot-local/src/server/parse.ts b/packages/adapters/copilot-local/src/server/parse.ts new file mode 100644 index 00000000000..3c0880619ef --- /dev/null +++ b/packages/adapters/copilot-local/src/server/parse.ts @@ -0,0 +1,173 @@ +import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils"; + +export interface CopilotLocalResult { + sessionId: string | null; + summary: string; + errorMessage: string | null; + usage: { inputTokens: number; outputTokens: number; cachedInputTokens: number }; + premiumRequests: number; + model: string | null; +} + +/** + * Parse a single JSONL line into an envelope with `type` and `data`. + * + * Copilot CLI v1.0.24 envelope format: + * ``` + * {"type":"assistant.message_delta","data":{"deltaContent":"hi"},"id":"...","timestamp":"...","ephemeral":true} + * ``` + * Top-level keys: type, data, id, timestamp, parentId, ephemeral. + * The `result` event is an exception — it puts sessionId, exitCode, usage at top level. + */ +function parseEvent(line: string): { type: string; data: Record; raw: Record } | null { + const parsed = parseJson(line); + if (!parsed) return null; + const obj = parseObject(parsed); + const type = asString(obj.type, ""); + if (!type) return null; + + // `data` envelope — most events nest payload here + const dataRaw = obj.data; + const data = (typeof dataRaw === "object" && dataRaw !== null && !Array.isArray(dataRaw)) + ? (dataRaw as Record) + : {}; + + return { type, data, raw: obj }; +} + +/** + * Parse JSONL output from the Copilot CLI (`copilot -p --output-format json`). + * + * Each line is a JSON object. Chunks from `runChildProcess` may contain multiple + * newline-separated JSON objects (buffering), so we split on `\n` first. + */ +export function parseCopilotLocalJsonl(stdout: string): CopilotLocalResult { + let sessionId: string | null = null; + let model: string | null = null; + const messages: string[] = []; + const errors: string[] = []; + const usage = { + inputTokens: 0, + outputTokens: 0, + cachedInputTokens: 0, + }; + let premiumRequests = 0; + + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + + const event = parseEvent(line); + if (!event) continue; + + const { type, data, raw } = event; + + // --- assistant.message_delta: streaming text chunk --- + if (type === "assistant.message_delta") { + const content = asString(data.deltaContent, ""); + if (content) messages.push(content); + continue; + } + + // --- assistant.message: complete message (may include tool requests) --- + if (type === "assistant.message") { + const content = asString(data.content, ""); + if (content) messages.push(content); + // Accumulate per-message outputTokens + const msgTokens = asNumber(data.outputTokens, 0); + if (msgTokens > 0) usage.outputTokens += msgTokens; + continue; + } + + // --- assistant.reasoning / assistant.reasoning_delta: skip (encrypted/opaque) --- + if (type === "assistant.reasoning" || type === "assistant.reasoning_delta") { + continue; + } + + // --- tool events: extract model info, otherwise skip (Copilot handles internally) --- + if (type === "tool.execution_start" || type === "tool.execution_complete" || type === "tool.execution_partial_result") { + // tool.execution_complete carries the model name + if (type === "tool.execution_complete") { + const m = asString(data.model, ""); + if (m && !model) model = m; + } + continue; + } + + // --- result: final result summary --- + if (type === "result") { + // sessionId and exitCode are top-level on result events + sessionId = asString(raw.sessionId, "") || sessionId; + + // usage is top-level on result + const usageObj = parseObject(raw.usage); + premiumRequests += asNumber(usageObj.premiumRequests, 0); + // Copilot CLI doesn't report inputTokens/outputTokens in result.usage + // but may have totalApiDurationMs, sessionDurationMs, codeChanges + continue; + } + + // --- assistant.usage: token tracking (if emitted) --- + if (type === "assistant.usage") { + const usageData = (Object.keys(data).length > 0) ? data : parseObject(raw.usage); + usage.inputTokens += asNumber(usageData.inputTokens, 0); + usage.outputTokens += asNumber(usageData.outputTokens, 0); + continue; + } + + // --- session.* events: skip (ephemeral setup events) --- + if (type.startsWith("session.")) { + // Extract model from session.tools_updated if available + if (type === "session.tools_updated") { + const m = asString(data.model, ""); + if (m) model = m; + } + continue; + } + + // --- user.message / assistant.turn_start / assistant.turn_end / assistant.intent: skip --- + if (type === "user.message" || type === "assistant.turn_start" || type === "assistant.turn_end" || type === "assistant.intent") { + continue; + } + + // --- error: accumulate into errors --- + if (type === "error") { + const msg = asString(data.message, "").trim() || asString(raw.message, "").trim(); + if (msg) { + errors.push(msg); + } else { + const errObj = parseObject(data.error ?? raw.error); + const nestedMsg = asString(errObj.message, "").trim(); + if (nestedMsg) errors.push(nestedMsg); + } + continue; + } + + // Unknown event types: log at debug level for diagnostics, then skip + console.debug(`[copilot-local] Skipping unrecognised JSONL event type=${JSON.stringify(type)}`); + } + + return { + sessionId, + summary: messages.join("").trim(), + errorMessage: errors.length > 0 ? errors.join("\n") : null, + usage, + premiumRequests, + model, + }; +} + +/** + * Detect if Copilot CLI output indicates a stale/expired/invalid session. + */ +export function isCopilotLocalStaleSessionError(stdout: string, stderr: string): boolean { + const haystack = `${stdout}\n${stderr}` + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n"); + + return /invalid\s+session|session\s+not\s+found|session\s.*expired|unknown\s+session/i.test( + haystack, + ); +} diff --git a/packages/adapters/copilot-local/src/server/skills.ts b/packages/adapters/copilot-local/src/server/skills.ts new file mode 100644 index 00000000000..c9eab0df195 --- /dev/null +++ b/packages/adapters/copilot-local/src/server/skills.ts @@ -0,0 +1,123 @@ +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { + AdapterSkillContext, + AdapterSkillEntry, + AdapterSkillSnapshot, +} from "@paperclipai/adapter-utils"; +import { + readPaperclipRuntimeSkillEntries, + readInstalledSkillTargets, + resolvePaperclipDesiredSkillNames, +} from "@paperclipai/adapter-utils/server-utils"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +function asString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function resolveCopilotSkillsHome(config: Record) { + const env = + typeof config.env === "object" && config.env !== null && !Array.isArray(config.env) + ? (config.env as Record) + : {}; + const configuredHome = asString(env.HOME); + const home = configuredHome ? path.resolve(configuredHome) : os.homedir(); + return path.join(home, ".copilot", "skills"); +} + +async function buildCopilotLocalSkillSnapshot( + config: Record, +): Promise { + const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry])); + const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries); + const desiredSet = new Set(desiredSkills); + const skillsHome = resolveCopilotSkillsHome(config); + const installed = await readInstalledSkillTargets(skillsHome); + const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({ + key: entry.key, + runtimeName: entry.runtimeName, + desired: desiredSet.has(entry.key), + managed: true, + state: desiredSet.has(entry.key) ? "configured" : "available", + origin: entry.required ? "paperclip_required" : "company_managed", + originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip", + readOnly: false, + sourcePath: entry.source, + targetPath: null, + detail: desiredSet.has(entry.key) + ? "Will be injected via COPILOT_SKILLS_DIRS on the next run." + : null, + required: Boolean(entry.required), + requiredReason: entry.requiredReason ?? null, + })); + const warnings: string[] = []; + + for (const desiredSkill of desiredSkills) { + if (availableByKey.has(desiredSkill)) continue; + warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`); + entries.push({ + key: desiredSkill, + runtimeName: null, + desired: true, + managed: true, + state: "missing", + origin: "external_unknown", + originLabel: "External or unavailable", + readOnly: false, + sourcePath: undefined, + targetPath: undefined, + detail: "Paperclip cannot find this skill in the local runtime skills directory.", + }); + } + + for (const [name, installedEntry] of installed.entries()) { + if (availableEntries.some((entry) => entry.runtimeName === name)) continue; + entries.push({ + key: name, + runtimeName: name, + desired: false, + managed: false, + state: "external", + origin: "user_installed", + originLabel: "User-installed", + locationLabel: "~/.copilot/skills", + readOnly: true, + sourcePath: null, + targetPath: installedEntry.targetPath ?? path.join(skillsHome, name), + detail: "Installed outside Paperclip management in the Copilot CLI skills home.", + }); + } + + entries.sort((left, right) => left.key.localeCompare(right.key)); + + return { + adapterType: "copilot_local", + supported: true, + mode: "ephemeral", + desiredSkills, + entries, + warnings, + }; +} + +export async function listCopilotLocalSkills(ctx: AdapterSkillContext): Promise { + return buildCopilotLocalSkillSnapshot(ctx.config); +} + +export async function syncCopilotLocalSkills( + ctx: AdapterSkillContext, + _desiredSkills: string[], +): Promise { + return buildCopilotLocalSkillSnapshot(ctx.config); +} + +export function resolveCopilotLocalDesiredSkillNames( + config: Record, + availableEntries: Array<{ key: string; required?: boolean }>, +) { + return resolvePaperclipDesiredSkillNames(config, availableEntries); +} diff --git a/packages/adapters/copilot-local/src/server/test.ts b/packages/adapters/copilot-local/src/server/test.ts new file mode 100644 index 00000000000..8fc2f609c2a --- /dev/null +++ b/packages/adapters/copilot-local/src/server/test.ts @@ -0,0 +1,334 @@ +/** + * Environment test for the copilot_local adapter. + * + * Checks: + * 1. Working directory validity + * 2. Binary check — is `copilot` (or config.command) resolvable? + * 3. Version check — parse version from `copilot --version` + * 4. Auth check — resolve a Copilot token via local auth helpers + * 5. GHE connectivity (only when gheHost is configured) + */ +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { + asString, + parseObject, + ensureAbsoluteDirectory, + ensureCommandResolvable, + ensurePathInEnv, + runChildProcess, +} from "@paperclipai/adapter-utils/server-utils"; +import { isCopilotAuthError, resolveCopilotToken } from "./auth.js"; +import path from "node:path"; +import { parseCopilotLocalJsonl } from "./parse.js"; + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +function commandLooksLike(command: string, expected: string): boolean { + const base = path.basename(command).toLowerCase(); + return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`; +} + +function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null { + const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout); + if (!raw) return null; + const clean = raw.replace(/\s+/g, " ").trim(); + const max = 240; + return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean; +} + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const command = asString(config.command, "copilot"); + const cwd = asString(config.cwd, process.cwd()); + const gheHost = asString(config.gheHost, ""); + + // ── Check 1: Working directory ────────────────────────────────────── + try { + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + checks.push({ + code: "copilot_local_cwd_valid", + level: "info", + message: `Working directory is valid: ${cwd}`, + }); + } catch (err) { + checks.push({ + code: "copilot_local_cwd_invalid", + level: "error", + message: err instanceof Error ? err.message : "Invalid working directory", + detail: cwd, + }); + } + + // ── Build merged env ──────────────────────────────────────────────── + const envConfig = parseObject(config.env); + const env: Record = {}; + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; + } + const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + + // ── Check 2: Binary / command resolvable ──────────────────────────── + try { + await ensureCommandResolvable(command, cwd, runtimeEnv); + checks.push({ + code: "copilot_local_command_resolvable", + level: "info", + message: `Command is executable: ${command}`, + }); + } catch (err) { + checks.push({ + code: "copilot_local_command_unresolvable", + level: "error", + message: err instanceof Error ? err.message : "Command is not executable", + detail: command, + hint: "Install the GitHub Copilot CLI: https://docs.github.com/en/copilot/github-copilot-in-the-cli", + }); + } + + // ── Check 3: Version probe ────────────────────────────────────────── + const canProbe = checks.every( + (c) => c.code !== "copilot_local_cwd_invalid" && c.code !== "copilot_local_command_unresolvable", + ); + + if (canProbe) { + try { + const versionResult = await runChildProcess( + `copilot-local-version-${Date.now()}`, + command, + ["--version"], + { + cwd, + env, + timeoutSec: 10, + graceSec: 3, + onLog: async () => {}, + }, + ); + const versionLine = firstNonEmptyLine(versionResult.stdout) || firstNonEmptyLine(versionResult.stderr); + if ((versionResult.exitCode ?? 1) === 0 && versionLine) { + checks.push({ + code: "copilot_local_version", + level: "info", + message: `Copilot CLI version: ${versionLine}`, + }); + } else { + checks.push({ + code: "copilot_local_version_unknown", + level: "warn", + message: "Could not determine Copilot CLI version.", + detail: versionLine || `exit code ${versionResult.exitCode}`, + hint: "Run `copilot --version` manually to check the installation.", + }); + } + } catch (err) { + checks.push({ + code: "copilot_local_version_error", + level: "warn", + message: "Failed to query Copilot CLI version.", + detail: err instanceof Error ? err.message : String(err), + }); + } + } + + // ── Check 4: Auth / token resolution ──────────────────────────────── + const mergedEnv: Record = { ...process.env, ...env }; + try { + const tokenResult = await resolveCopilotToken(mergedEnv, gheHost || undefined); + if (tokenResult) { + const maskedToken = + tokenResult.token.length > 8 + ? `${tokenResult.token.slice(0, 4)}...${tokenResult.token.slice(-4)}` + : "***"; + checks.push({ + code: "copilot_local_auth_ok", + level: "info", + message: `Copilot token resolved from ${tokenResult.source} (${maskedToken})`, + }); + } else { + checks.push({ + code: "copilot_local_auth_missing", + level: "warn", + message: "No Copilot token found. The Copilot CLI may not be authenticated.", + hint: gheHost + ? `Run \`gh auth login --hostname ${gheHost}\` or set COPILOT_GITHUB_TOKEN / GH_TOKEN.` + : "Run `gh auth login` or set COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN.", + }); + } + } catch (err) { + checks.push({ + code: "copilot_local_auth_error", + level: "warn", + message: "Failed to resolve Copilot token.", + detail: err instanceof Error ? err.message : String(err), + hint: gheHost + ? `Run \`gh auth login --hostname ${gheHost}\` to authenticate.` + : "Run `gh auth login` to authenticate with GitHub.", + }); + } + + // ── Check 5: GHE connectivity (only if gheHost configured) ────────── + if (gheHost) { + checks.push({ + code: "copilot_local_ghe_configured", + level: "info", + message: `GitHub Enterprise host configured: ${gheHost}`, + }); + + try { + const gheApiUrl = `https://${gheHost}/api/v3`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + const response = await fetch(gheApiUrl, { + method: "HEAD", + signal: controller.signal, + }); + clearTimeout(timeout); + + if (response.ok || response.status === 401 || response.status === 403) { + // 401/403 means the host is reachable but needs auth — that's fine for a connectivity check + checks.push({ + code: "copilot_local_ghe_reachable", + level: "info", + message: `GitHub Enterprise host is reachable: ${gheHost}`, + }); + } else { + checks.push({ + code: "copilot_local_ghe_unexpected_status", + level: "warn", + message: `GitHub Enterprise host returned HTTP ${response.status}.`, + detail: gheApiUrl, + hint: `Verify the GHE hostname is correct: ${gheHost}`, + }); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes("abort")) { + checks.push({ + code: "copilot_local_ghe_timeout", + level: "warn", + message: `GitHub Enterprise connectivity check timed out after 10s.`, + hint: `Verify that ${gheHost} is reachable from this machine.`, + }); + } else { + checks.push({ + code: "copilot_local_ghe_unreachable", + level: "warn", + message: `Cannot reach GitHub Enterprise host: ${message}`, + detail: gheHost, + hint: "Check your network connection and verify the GHE hostname.", + }); + } + } + } + + // ── Check 6: Hello probe (if binary and cwd are OK) ───────────────── + if (canProbe) { + if (!commandLooksLike(command, "copilot")) { + checks.push({ + code: "copilot_local_hello_probe_skipped", + level: "info", + message: "Skipped hello probe because command is not `copilot`.", + detail: command, + hint: "Use the `copilot` CLI command to run the automatic probe.", + }); + } else { + try { + const args = ["-p", "--output-format", "json", "-"]; + const probe = await runChildProcess( + `copilot-local-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + command, + args, + { + cwd, + env, + timeoutSec: 45, + graceSec: 5, + stdin: "Respond with hello.", + onLog: async () => {}, + }, + ); + + const parsed = parseCopilotLocalJsonl(probe.stdout); + const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage); + const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim(); + + if (probe.timedOut) { + checks.push({ + code: "copilot_local_hello_probe_timed_out", + level: "warn", + message: "Copilot hello probe timed out.", + hint: "Retry the probe. If this persists, verify Copilot can run from this directory manually.", + }); + } else if (isCopilotAuthError(parsed.errorMessage, probe.stdout, probe.stderr)) { + checks.push({ + code: "copilot_local_hello_probe_auth_required", + level: "warn", + message: "Copilot CLI is installed, but authentication is not ready.", + ...(detail ? { detail } : {}), + hint: gheHost + ? `Run \`gh auth login --hostname ${gheHost}\` to authenticate, then retry.` + : "Run `gh auth login` or set COPILOT_GITHUB_TOKEN, then retry the probe.", + }); + } else if ((probe.exitCode ?? 1) === 0) { + const summary = parsed.summary.trim(); + const hasHello = /\bhello\b/i.test(summary); + checks.push({ + code: hasHello ? "copilot_local_hello_probe_passed" : "copilot_local_hello_probe_unexpected_output", + level: hasHello ? "info" : "warn", + message: hasHello + ? "Copilot hello probe succeeded." + : "Copilot probe ran but did not return `hello` as expected.", + ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}), + ...(hasHello + ? {} + : { + hint: "Try the probe manually (`copilot -p --output-format json -`) and prompt `Respond with hello`.", + }), + }); + } else { + checks.push({ + code: "copilot_local_hello_probe_failed", + level: "error", + message: "Copilot hello probe failed.", + ...(detail ? { detail } : {}), + hint: "Run `copilot -p --output-format json -` manually and prompt `Respond with hello` to debug.", + }); + } + } catch (err) { + checks.push({ + code: "copilot_local_hello_probe_error", + level: "warn", + message: "Copilot hello probe encountered an unexpected error.", + detail: err instanceof Error ? err.message : String(err), + }); + } + } + } + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/copilot-local/src/ui/build-config.ts b/packages/adapters/copilot-local/src/ui/build-config.ts new file mode 100644 index 00000000000..5e346d11f85 --- /dev/null +++ b/packages/adapters/copilot-local/src/ui/build-config.ts @@ -0,0 +1,94 @@ +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; + +function parseCommaArgs(value: string): string[] { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseEnvVars(text: string): Record { + const env: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + env[key] = value; + } + return env; +} + +function parseEnvBindings(bindings: unknown): Record { + if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; + const env: Record = {}; + for (const [key, raw] of Object.entries(bindings)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (typeof raw === "string") { + env[key] = { type: "plain", value: raw }; + continue; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue; + const rec = raw as Record; + if (rec.type === "plain" && typeof rec.value === "string") { + env[key] = { type: "plain", value: rec.value }; + continue; + } + if (rec.type === "secret_ref" && typeof rec.secretId === "string") { + env[key] = { + type: "secret_ref", + secretId: rec.secretId, + ...(typeof rec.version === "number" || rec.version === "latest" + ? { version: rec.version } + : {}), + }; + } + } + return env; +} + +export function buildCopilotLocalConfig(v: CreateConfigValues): Record { + const ac: Record = {}; + if (v.cwd) ac.cwd = v.cwd; + if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath; + if (v.promptTemplate) ac.promptTemplate = v.promptTemplate; + if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt; + if (v.model) ac.model = v.model; + if (v.command) ac.command = v.command; + + // Copilot-specific fields from adapter schema values + const schema = v.adapterSchemaValues ?? {}; + const gheHost = typeof schema.gheHost === "string" ? schema.gheHost.trim() : ""; + const effort = (typeof schema.effort === "string" ? schema.effort.trim() : "") || + (typeof v.thinkingEffort === "string" ? v.thinkingEffort.trim() : ""); + const byokBaseUrl = typeof schema.byokBaseUrl === "string" ? schema.byokBaseUrl.trim() : ""; + const byokApiKey = typeof schema.byokApiKey === "string" ? schema.byokApiKey.trim() : ""; + const isolateSession = schema.isolateSession === true; + + if (gheHost) ac.gheHost = gheHost; + if (effort) ac.effort = effort; + if (byokBaseUrl) ac.byokBaseUrl = byokBaseUrl; + if (byokApiKey) ac.byokApiKey = byokApiKey; + if (isolateSession) ac.isolateSession = true; + + ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions; + ac.timeoutSec = 0; + ac.graceSec = 20; + + // Environment variables + const env = parseEnvBindings(v.envBindings); + const legacy = parseEnvVars(v.envVars); + for (const [key, value] of Object.entries(legacy)) { + if (!Object.prototype.hasOwnProperty.call(env, key)) { + env[key] = { type: "plain", value }; + } + } + if (Object.keys(env).length > 0) ac.env = env; + + if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); + + return ac; +} diff --git a/packages/adapters/copilot-local/src/ui/index.ts b/packages/adapters/copilot-local/src/ui/index.ts new file mode 100644 index 00000000000..82dc07a1b10 --- /dev/null +++ b/packages/adapters/copilot-local/src/ui/index.ts @@ -0,0 +1,2 @@ +export { parseCopilotLocalStdoutLine } from "./parse-stdout.js"; +export { buildCopilotLocalConfig } from "./build-config.js"; diff --git a/packages/adapters/copilot-local/src/ui/parse-stdout.test.ts b/packages/adapters/copilot-local/src/ui/parse-stdout.test.ts new file mode 100644 index 00000000000..4d910a967ef --- /dev/null +++ b/packages/adapters/copilot-local/src/ui/parse-stdout.test.ts @@ -0,0 +1,460 @@ +import { describe, expect, it } from "vitest"; +import { parseCopilotLocalStdoutLine } from "./parse-stdout.js"; + +const ts = "2026-04-14T09:00:00.000Z"; + +describe("parseCopilotLocalStdoutLine", () => { + // 1. message_delta → assistant delta entry + it("parses assistant.message_delta into assistant delta entry", () => { + const line = JSON.stringify({ + type: "assistant.message_delta", + data: { messageId: "msg_1", deltaContent: "hello" }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([ + { kind: "assistant", ts, text: "hello", delta: true }, + ]); + }); + + it("returns empty for message_delta with empty deltaContent", () => { + const line = JSON.stringify({ + type: "assistant.message_delta", + data: { messageId: "msg_1", deltaContent: "" }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([]); + }); + + // 2. assistant.message with content → assistant entry + it("parses assistant.message with content into assistant entry", () => { + const line = JSON.stringify({ + type: "assistant.message", + data: { content: "full response text" }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([ + { kind: "assistant", ts, text: "full response text" }, + ]); + }); + + // 3. assistant.message with toolRequests → assistant + tool_call entries + it("parses assistant.message with toolRequests into assistant + tool_call entries", () => { + const line = JSON.stringify({ + type: "assistant.message", + data: { + content: "Let me search for that.", + toolRequests: [ + { + toolCallId: "tc_1", + name: "search", + arguments: { query: "paperclip" }, + }, + { + toolCallId: "tc_2", + name: "read_file", + arguments: { path: "/tmp/test.ts" }, + }, + ], + }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([ + { kind: "assistant", ts, text: "Let me search for that." }, + { + kind: "tool_call", + ts, + name: "search", + toolUseId: "tc_1", + input: { query: "paperclip" }, + }, + { + kind: "tool_call", + ts, + name: "read_file", + toolUseId: "tc_2", + input: { path: "/tmp/test.ts" }, + }, + ]); + }); + + it("parses assistant.message with only toolRequests (no content)", () => { + const line = JSON.stringify({ + type: "assistant.message", + data: { + content: "", + toolRequests: [ + { toolCallId: "tc_3", name: "write_file", arguments: { path: "/tmp/out.txt" } }, + ], + }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([ + { + kind: "tool_call", + ts, + name: "write_file", + toolUseId: "tc_3", + input: { path: "/tmp/out.txt" }, + }, + ]); + }); + + it("returns empty for assistant.message with no content and no toolRequests", () => { + const line = JSON.stringify({ + type: "assistant.message", + data: { content: "" }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([]); + }); + + // 4. assistant.reasoning (readable) → thinking entry + it("parses assistant.reasoning with readable content into thinking entry", () => { + const line = JSON.stringify({ + type: "assistant.reasoning", + data: { content: "I need to think about this problem carefully." }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([ + { kind: "thinking", ts, text: "I need to think about this problem carefully." }, + ]); + }); + + // 5. assistant.reasoning (encrypted-looking) → skipped + it("skips assistant.reasoning with encrypted-looking content (>100 chars, no spaces)", () => { + const encrypted = "a".repeat(200); // 200 chars, no spaces + const line = JSON.stringify({ + type: "assistant.reasoning", + data: { content: encrypted }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([]); + }); + + it("does not skip short reasoning without spaces (<= 100 chars)", () => { + const shortNoSpaces = "a".repeat(50); + const line = JSON.stringify({ + type: "assistant.reasoning", + data: { content: shortNoSpaces }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([ + { kind: "thinking", ts, text: shortNoSpaces }, + ]); + }); + + // assistant.reasoning_delta → thinking delta entry + it("parses assistant.reasoning_delta into thinking delta entry", () => { + const line = JSON.stringify({ + type: "assistant.reasoning_delta", + data: { deltaContent: "step 1: analyze" }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([ + { kind: "thinking", ts, text: "step 1: analyze", delta: true }, + ]); + }); + + it("skips assistant.reasoning_delta with encrypted-looking content", () => { + const encrypted = "x".repeat(150); + const line = JSON.stringify({ + type: "assistant.reasoning_delta", + data: { deltaContent: encrypted }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([]); + }); + + // 6. tool.execution_start → empty array + it("returns empty array for tool.execution_start", () => { + const line = JSON.stringify({ + type: "tool.execution_start", + data: { toolCallId: "tc_1", toolName: "search" }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([]); + }); + + // 7. tool.execution_complete success → tool_result with content + it("parses tool.execution_complete success into tool_result entry", () => { + const line = JSON.stringify({ + type: "tool.execution_complete", + data: { + toolCallId: "tc_1", + success: true, + result: { content: "Found 3 matches" }, + model: "gpt-5-mini", + }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([ + { + kind: "tool_result", + ts, + toolUseId: "tc_1", + toolName: undefined, + content: "Found 3 matches", + isError: false, + }, + ]); + }); + + it("prefers detailedContent over content in tool result", () => { + const line = JSON.stringify({ + type: "tool.execution_complete", + data: { + toolCallId: "tc_1", + success: true, + result: { content: "short", detailedContent: "long detailed output" }, + }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([ + { + kind: "tool_result", + ts, + toolUseId: "tc_1", + toolName: undefined, + content: "long detailed output", + isError: false, + }, + ]); + }); + + // 8. tool.execution_complete failure → tool_result with isError:true + it("parses tool.execution_complete failure into tool_result with isError", () => { + const line = JSON.stringify({ + type: "tool.execution_complete", + data: { + toolCallId: "tc_2", + success: false, + error: { message: "permission denied" }, + }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([ + { + kind: "tool_result", + ts, + toolUseId: "tc_2", + toolName: undefined, + content: "permission denied", + isError: true, + }, + ]); + }); + + it("falls back to error code when error message is missing", () => { + const line = JSON.stringify({ + type: "tool.execution_complete", + data: { + toolCallId: "tc_3", + success: false, + error: { code: "ENOENT" }, + }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([ + { + kind: "tool_result", + ts, + toolUseId: "tc_3", + toolName: undefined, + content: "ENOENT", + isError: true, + }, + ]); + }); + + // 9. result event → result entry with session info text + it("parses result event into result entry with session info", () => { + const line = JSON.stringify({ + type: "result", + sessionId: "sess_abc", + exitCode: 0, + usage: { premiumRequests: 0, sessionDurationMs: 30000 }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([ + { + kind: "result", + ts, + text: "session: sess_abc, duration: 30s", + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + costUsd: 0, + subtype: "result", + isError: false, + errors: [], + }, + ]); + }); + + it("includes premium requests and code changes in result text", () => { + const line = JSON.stringify({ + type: "result", + sessionId: "sess_xyz", + exitCode: 0, + usage: { + premiumRequests: 5, + sessionDurationMs: 60000, + codeChanges: { linesAdded: 42, linesRemoved: 7 }, + }, + }); + const result = parseCopilotLocalStdoutLine(line, ts); + expect(result).toHaveLength(1); + expect(result[0].kind).toBe("result"); + expect((result[0] as { text: string }).text).toBe( + "session: sess_xyz, premium: 5, duration: 60s, changes: +42/-7", + ); + }); + + it("shows 'completed' for result with no session info", () => { + const line = JSON.stringify({ type: "result" }); + const result = parseCopilotLocalStdoutLine(line, ts); + expect(result).toHaveLength(1); + expect((result[0] as { text: string }).text).toBe("completed"); + }); + + // 10. turn_start/turn_end → system entries + it("parses assistant.turn_start into system entry", () => { + const line = JSON.stringify({ + type: "assistant.turn_start", + data: { turnId: "t1" }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([ + { kind: "system", ts, text: "turn t1 started" }, + ]); + }); + + it("parses assistant.turn_end into system entry", () => { + const line = JSON.stringify({ + type: "assistant.turn_end", + data: { turnId: "t1" }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([ + { kind: "system", ts, text: "turn t1 ended" }, + ]); + }); + + // 11. session.* → empty + it("returns empty array for session.start", () => { + const line = JSON.stringify({ + type: "session.start", + data: { sessionId: "sess_abc" }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([]); + }); + + it("returns empty array for session.end", () => { + const line = JSON.stringify({ + type: "session.end", + data: { sessionId: "sess_abc" }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([]); + }); + + // 12. user.message → empty + it("returns empty array for user.message", () => { + const line = JSON.stringify({ + type: "user.message", + data: { content: "Hello copilot" }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([]); + }); + + // 13. error → stderr entry + it("parses error event into stderr entry", () => { + const line = JSON.stringify({ + type: "error", + data: { message: "something went wrong" }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([ + { kind: "stderr", ts, text: "something went wrong" }, + ]); + }); + + it("falls back to raw line for error with no message", () => { + const line = JSON.stringify({ type: "error", data: {} }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([ + { kind: "stderr", ts, text: line }, + ]); + }); + + // 14. Multi-line chunk → multiple entries + it("splits multi-line chunks and parses each sub-line", () => { + const line1 = JSON.stringify({ + type: "assistant.message_delta", + data: { deltaContent: "hello" }, + }); + const line2 = JSON.stringify({ + type: "assistant.message_delta", + data: { deltaContent: " world" }, + }); + const multiLine = `${line1}\n${line2}`; + expect(parseCopilotLocalStdoutLine(multiLine, ts)).toEqual([ + { kind: "assistant", ts, text: "hello", delta: true }, + { kind: "assistant", ts, text: " world", delta: true }, + ]); + }); + + it("ignores empty lines in multi-line chunks", () => { + const line1 = JSON.stringify({ + type: "assistant.message_delta", + data: { deltaContent: "content" }, + }); + const multiLine = `${line1}\n\n\n`; + expect(parseCopilotLocalStdoutLine(multiLine, ts)).toEqual([ + { kind: "assistant", ts, text: "content", delta: true }, + ]); + }); + + // 15. Non-JSON line → stdout entry + it("returns stdout entry for non-JSON line", () => { + const line = "some plain text output"; + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([ + { kind: "stdout", ts, text: "some plain text output" }, + ]); + }); + + it("returns stdout entry for malformed JSON", () => { + const line = '{type: "broken'; + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([ + { kind: "stdout", ts, text: line }, + ]); + }); + + // Skipped event types + it("returns empty array for assistant.intent", () => { + const line = JSON.stringify({ + type: "assistant.intent", + data: { intent: "code_generation" }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([]); + }); + + it("returns empty array for assistant.usage", () => { + const line = JSON.stringify({ + type: "assistant.usage", + data: { tokens: 100 }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([]); + }); + + // Unknown event type → stdout passthrough + it("passes unknown event types through as stdout", () => { + const line = JSON.stringify({ + type: "custom.unknown_event", + data: { foo: "bar" }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([ + { kind: "stdout", ts, text: line }, + ]); + }); + + // tool.execution_partial_result + it("parses tool.execution_partial_result into tool_result entry", () => { + const line = JSON.stringify({ + type: "tool.execution_partial_result", + data: { + toolCallId: "tc_5", + success: true, + result: { content: "partial output here" }, + }, + }); + expect(parseCopilotLocalStdoutLine(line, ts)).toEqual([ + { + kind: "tool_result", + ts, + toolUseId: "tc_5", + toolName: undefined, + content: "partial output here", + isError: false, + }, + ]); + }); +}); diff --git a/packages/adapters/copilot-local/src/ui/parse-stdout.ts b/packages/adapters/copilot-local/src/ui/parse-stdout.ts new file mode 100644 index 00000000000..c237b415b83 --- /dev/null +++ b/packages/adapters/copilot-local/src/ui/parse-stdout.ts @@ -0,0 +1,236 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +/** + * Parse a single stdout line from Copilot CLI JSONL output into TranscriptEntry[]. + * + * Copilot CLI v1.0.24 envelope format: + * ```json + * {"type":"assistant.message_delta","data":{"messageId":"...","deltaContent":"hello"},"id":"...","timestamp":"...","ephemeral":true} + * ``` + * + * NOTE: A single stdout chunk from `runChildProcess` may contain multiple + * newline-separated JSONL lines (buffer boundary misalignment). The caller + * should split on `\n` and call this function for each sub-line. This function + * handles a single line only. + */ +export function parseCopilotLocalStdoutLine(line: string, ts: string): TranscriptEntry[] { + // Handle multi-line chunks: split and recurse + if (line.includes("\n")) { + const results: TranscriptEntry[] = []; + for (const subLine of line.split("\n")) { + const trimmed = subLine.trim(); + if (trimmed) { + results.push(...parseCopilotLocalStdoutLine(trimmed, ts)); + } + } + return results; + } + + const parsed = asRecord(safeJsonParse(line)); + if (!parsed) { + return [{ kind: "stdout", ts, text: line }]; + } + + const type = asString(parsed.type); + // Unwrap data envelope — most Copilot CLI events nest payload under `data` + const data = asRecord(parsed.data) ?? {}; + + // --- assistant.message_delta: streaming text chunk --- + if (type === "assistant.message_delta") { + const content = asString(data.deltaContent); + if (!content) return []; + return [{ kind: "assistant", ts, text: content, delta: true }]; + } + + // --- assistant.message: complete message + tool calls --- + if (type === "assistant.message") { + const entries: TranscriptEntry[] = []; + const content = asString(data.content); + if (content) { + entries.push({ kind: "assistant", ts, text: content }); + } + + // Extract tool calls from toolRequests array (inside data envelope) + // Copilot CLI format: { toolCallId, name, arguments, type, intentionSummary } + const toolRequests = Array.isArray(data.toolRequests) ? data.toolRequests : []; + for (const reqRaw of toolRequests) { + const req = asRecord(reqRaw); + if (!req) continue; + const toolName = asString(req.name) || asString(req.toolName) || "unknown"; + const toolUseId = asString(req.toolCallId) || asString(req.id) || undefined; + const input = req.arguments ?? req.input ?? {}; + entries.push({ + kind: "tool_call", + ts, + name: toolName, + toolUseId, + input, + }); + } + + return entries.length > 0 ? entries : []; + } + + // --- assistant.reasoning: reasoning block (often encrypted/opaque) --- + if (type === "assistant.reasoning") { + const content = asString(data.content); + if (!content) return []; + // Skip if content looks encrypted/opaque (base64-like, no spaces, very long) + if (content.length > 100 && !content.includes(" ")) return []; + return [{ kind: "thinking", ts, text: content }]; + } + + // --- assistant.reasoning_delta: streaming reasoning chunk --- + if (type === "assistant.reasoning_delta") { + const content = asString(data.deltaContent) || asString(data.content); + if (!content) return []; + if (content.length > 100 && !content.includes(" ")) return []; + return [{ kind: "thinking", ts, text: content, delta: true }]; + } + + // --- tool.execution_start: intentionally empty (dedup fix) --- + if (type === "tool.execution_start") { + return []; + } + + // --- tool.execution_complete: tool result --- + // Copilot CLI format: data.toolCallId, data.success (bool), + // data.result.content / data.result.detailedContent (on success), + // data.error.message / data.error.code (on failure), + // data.model, data.toolTelemetry + // NOTE: toolName is NOT present on complete events — must correlate via toolCallId + if (type === "tool.execution_complete") { + const toolUseId = asString(data.toolCallId) || asString(data.toolUseId) || asString(data.id, "tool"); + const toolName = asString(data.toolName) || undefined; + const isError = data.success === false; + let output = ""; + if (isError) { + const errObj = asRecord(data.error); + output = errObj ? asString(errObj.message) || asString(errObj.code) : ""; + } else { + const resultObj = asRecord(data.result); + output = resultObj + ? asString(resultObj.detailedContent) || asString(resultObj.content) + : asString(data.output) || asString(data.content) || ""; + } + return [{ + kind: "tool_result", + ts, + toolUseId, + toolName, + content: output, + isError, + }]; + } + + // --- tool.execution_partial_result: streaming tool output --- + if (type === "tool.execution_partial_result") { + const toolUseId = asString(data.toolCallId) || asString(data.toolUseId) || asString(data.id, "tool"); + const toolName = asString(data.toolName) || undefined; + const resultObj = asRecord(data.result); + const output = resultObj + ? asString(resultObj.detailedContent) || asString(resultObj.content) + : asString(data.output) || asString(data.content) || ""; + const isError = data.success === false; + return [{ + kind: "tool_result", + ts, + toolUseId, + toolName, + content: output, + isError, + }]; + } + + // --- result: final summary with session and usage --- + if (type === "result") { + // sessionId is top-level on result events (not inside data) + const sessionId = asString(parsed.sessionId, ""); + // usage is also top-level + const usageObj = asRecord(parsed.usage) ?? {}; + const premiumRequests = asNumber(usageObj.premiumRequests, 0); + const sessionDurationMs = asNumber(usageObj.sessionDurationMs, 0); + const codeChanges = asRecord(usageObj.codeChanges); + const linesAdded = codeChanges ? asNumber(codeChanges.linesAdded, 0) : 0; + const linesRemoved = codeChanges ? asNumber(codeChanges.linesRemoved, 0) : 0; + + const parts: string[] = []; + if (sessionId) parts.push(`session: ${sessionId}`); + if (premiumRequests) parts.push(`premium: ${premiumRequests}`); + if (sessionDurationMs) parts.push(`duration: ${Math.round(sessionDurationMs / 1000)}s`); + if (linesAdded || linesRemoved) parts.push(`changes: +${linesAdded}/-${linesRemoved}`); + const text = parts.length > 0 ? parts.join(", ") : "completed"; + + return [{ + kind: "result", + ts, + text, + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + costUsd: 0, + subtype: "result", + isError: false, + errors: [], + }]; + } + + // --- assistant.turn_start: system marker --- + if (type === "assistant.turn_start") { + const turnId = asString(data.turnId, ""); + return [{ kind: "system", ts, text: `turn ${turnId} started` }]; + } + + // --- assistant.turn_end: system marker --- + if (type === "assistant.turn_end") { + const turnId = asString(data.turnId, ""); + return [{ kind: "system", ts, text: `turn ${turnId} ended` }]; + } + + // --- session.* events: skip (ephemeral setup) --- + if (type.startsWith("session.")) { + return []; + } + + // --- user.message: skip (echo of prompt) --- + if (type === "user.message") { + return []; + } + + // --- assistant.intent / assistant.usage: skip --- + if (type === "assistant.intent" || type === "assistant.usage") { + return []; + } + + // --- error: surface as stderr --- + if (type === "error") { + const msg = asString(data.message, "") || asString(parsed.message, ""); + const errObj = asRecord(data.error ?? parsed.error); + const text = msg || asString(errObj?.message, "") || line; + return [{ kind: "stderr", ts, text }]; + } + + // Unknown event types: pass through as stdout + return [{ kind: "stdout", ts, text: line }]; +} diff --git a/packages/adapters/copilot-local/tsconfig.json b/packages/adapters/copilot-local/tsconfig.json new file mode 100644 index 00000000000..e1b71318a68 --- /dev/null +++ b/packages/adapters/copilot-local/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/adapters/copilot-local/vitest.config.ts b/packages/adapters/copilot-local/vitest.config.ts new file mode 100644 index 00000000000..f624398e8de --- /dev/null +++ b/packages/adapters/copilot-local/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index f62d2777a05..c048eb6c963 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -34,6 +34,7 @@ export const AGENT_ADAPTER_TYPES = [ "pi_local", "cursor", "openclaw_gateway", + "copilot_local", ] as const; export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number] | (string & {}); diff --git a/server/package.json b/server/package.json index dac65fa7b6d..4f7ce464a6e 100644 --- a/server/package.json +++ b/server/package.json @@ -46,6 +46,7 @@ "@aws-sdk/client-s3": "^3.888.0", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", + "@paperclipai/adapter-copilot-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", diff --git a/server/src/adapters/builtin-adapter-types.ts b/server/src/adapters/builtin-adapter-types.ts index 463a56945ff..5ac80f2daf9 100644 --- a/server/src/adapters/builtin-adapter-types.ts +++ b/server/src/adapters/builtin-adapter-types.ts @@ -10,6 +10,7 @@ export const BUILTIN_ADAPTER_TYPES = new Set([ "opencode_local", "pi_local", "hermes_local", + "copilot_local", "process", "http", ]); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index f674622721b..c584e32f7f4 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -80,6 +80,19 @@ import { agentConfigurationDoc as hermesAgentConfigurationDoc, models as hermesModels, } from "hermes-paperclip-adapter"; +import { + execute as copilotLocalExecute, + testEnvironment as copilotLocalTestEnvironment, + listCopilotLocalSkills, + syncCopilotLocalSkills, + detectCopilotLocalModel, + listCopilotLocalModels, + sessionCodec as copilotLocalSessionCodec, +} from "@paperclipai/adapter-copilot-local/server"; +import { + agentConfigurationDoc as copilotLocalAgentConfigurationDoc, + models as copilotLocalModels, +} from "@paperclipai/adapter-copilot-local"; import { BUILTIN_ADAPTER_TYPES } from "./builtin-adapter-types.js"; import { buildExternalAdapters } from "./plugin-loader.js"; import { getDisabledAdapterTypes } from "../services/adapter-plugin-store.js"; @@ -193,6 +206,21 @@ const hermesLocalAdapter: ServerAdapterModule = { detectModel: () => detectModelFromHermes(), }; +const copilotLocalAdapter: ServerAdapterModule = { + type: "copilot_local", + execute: copilotLocalExecute, + testEnvironment: copilotLocalTestEnvironment, + listSkills: listCopilotLocalSkills, + syncSkills: syncCopilotLocalSkills, + models: copilotLocalModels, + listModels: listCopilotLocalModels, + detectModel: detectCopilotLocalModel, + sessionCodec: copilotLocalSessionCodec, + sessionManagement: getAdapterSessionManagement("copilot_local") ?? undefined, + supportsLocalAgentJwt: true, + agentConfigurationDoc: copilotLocalAgentConfigurationDoc, +}; + const adaptersByType = new Map(); // For builtin types that are overridden by an external adapter, we keep the @@ -214,6 +242,7 @@ function registerBuiltInAdapters() { geminiLocalAdapter, openclawGatewayAdapter, hermesLocalAdapter, + copilotLocalAdapter, processAdapter, httpAdapter, ]) { @@ -317,11 +346,11 @@ export function getServerAdapter(type: string): ServerAdapterModule { return findActiveServerAdapter(type) ?? processAdapter; } -export async function listAdapterModels(type: string): Promise<{ id: string; label: string }[]> { +export async function listAdapterModels(type: string, hints?: Record): Promise<{ id: string; label: string }[]> { const adapter = findActiveServerAdapter(type); if (!adapter) return []; if (adapter.listModels) { - const discovered = await adapter.listModels(); + const discovered = await adapter.listModels(hints); if (discovered.length > 0) return discovered; } return adapter.models ?? []; diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index bc4783c6a35..ef86a806a49 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -58,6 +58,7 @@ import { redactCurrentUserValue } from "../log-redaction.js"; import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server"; +import { copilotLogin } from "@paperclipai/adapter-copilot-local/server"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_MODEL, @@ -75,6 +76,7 @@ export function agentRoutes(db: Db) { const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { claude_local: "instructionsFilePath", codex_local: "instructionsFilePath", + copilot_local: "instructionsFilePath", droid_local: "instructionsFilePath", gemini_local: "instructionsFilePath", hermes_local: "instructionsFilePath", @@ -781,7 +783,11 @@ export function agentRoutes(db: Db) { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); const type = assertKnownAdapterType(req.params.type as string); - const models = await listAdapterModels(type); + // Forward query params as hints so adapters can use config context (e.g. gheHost) + const hints = Object.keys(req.query).length > 0 + ? Object.fromEntries(Object.entries(req.query).map(([k, v]) => [k, String(v ?? "")])) + : undefined; + const models = await listAdapterModels(type, hints); res.json(models); }); @@ -2216,6 +2222,63 @@ export function agentRoutes(db: Db) { res.json(result); }); + router.post("/agents/:id/copilot-login", async (req, res) => { + assertBoard(req); + const id = req.params.id as string; + const agent = await svc.getById(id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } + assertCompanyAccess(req, agent.companyId); + if (agent.adapterType !== "copilot_local") { + res.status(400).json({ error: "Login is only supported for copilot_local agents" }); + return; + } + + const config = asRecord(agent.adapterConfig) ?? {}; + const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(agent.companyId, config); + const gheHost = typeof runtimeConfig.gheHost === "string" ? runtimeConfig.gheHost : undefined; + const command = typeof runtimeConfig.command === "string" ? runtimeConfig.command : undefined; + + // Copilot uses a device-flow that prints a one-time code the user must + // enter in the browser. We stream SSE so the UI can show the code as + // soon as it appears — without waiting for the full login to complete. + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }); + res.flushHeaders(); + res.write(":ok\n\n"); + + let closed = false; + req.on("close", () => { closed = true; }); + + const result = await copilotLogin({ + gheHost, + command, + onChunk: (chunk) => { + if (closed || !res.writable) return; + try { + res.write(`event: chunk\ndata: ${JSON.stringify(chunk)}\n\n`); + } catch { + // Connection closed — ignore + } + }, + }); + + if (!closed && res.writable) { + try { + res.write(`event: done\ndata: ${JSON.stringify(result)}\n\n`); + } catch { + // Connection closed — ignore + } + } + res.end(); + }); + router.get("/companies/:companyId/heartbeat-runs", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); diff --git a/ui/package.json b/ui/package.json index 020e2ec2f8a..56316e5211e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -34,6 +34,7 @@ "@mdxeditor/editor": "^3.52.4", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", + "@paperclipai/adapter-copilot-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", "@paperclipai/adapter-gemini-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", diff --git a/ui/src/adapters/adapter-display-registry.ts b/ui/src/adapters/adapter-display-registry.ts index fe809273962..a76a627842d 100644 --- a/ui/src/adapters/adapter-display-registry.ts +++ b/ui/src/adapters/adapter-display-registry.ts @@ -14,6 +14,7 @@ import { Sparkles, Terminal, Cpu, + Github, } from "lucide-react"; import { OpenCodeLogoIcon } from "@/components/OpenCodeLogoIcon"; import { HermesIcon } from "@/components/HermesIcon"; @@ -96,6 +97,11 @@ const adapterDisplayMap: Record = { comingSoon: true, disabledLabel: "Configure OpenClaw within the App", }, + copilot_local: { + label: "GitHub Copilot", + description: "Local Copilot CLI agent with GHE + BYOK support", + icon: Github, + }, process: { label: "Process", description: "Internal process adapter", diff --git a/ui/src/adapters/copilot-local/config-fields.tsx b/ui/src/adapters/copilot-local/config-fields.tsx new file mode 100644 index 00000000000..47f3e38539a --- /dev/null +++ b/ui/src/adapters/copilot-local/config-fields.tsx @@ -0,0 +1,130 @@ +import type { AdapterConfigFieldsProps } from "../types"; +import { + Field, + DraftInput, +} from "../../components/agent-config-primitives"; +import { ChoosePathButton } from "../../components/PathInstructionsModal"; + +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; + +const instructionsFileHint = + "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; + +export function CopilotLocalConfigFields({ + isCreate, + values, + set, + config, + eff, + mark, + hideInstructionsFile, +}: AdapterConfigFieldsProps) { + return ( + <> + {!hideInstructionsFile && ( + +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+
+ )} + {!isCreate && ( + <> + + + mark("adapterConfig", "gheHost", v || undefined) + } + immediate + className={inputClass} + placeholder="github.com (default)" + /> + + + + mark("adapterConfig", "effort", v || undefined) + } + immediate + className={inputClass} + placeholder="medium (default)" + /> + + + + mark("adapterConfig", "byokBaseUrl", v || undefined) + } + immediate + className={inputClass} + placeholder="https://custom-endpoint/v1" + /> + + + + mark("adapterConfig", "byokApiKey", v || undefined) + } + immediate + className={inputClass} + placeholder="sk-..." + type="password" + /> + + + )} + + ); +} diff --git a/ui/src/adapters/copilot-local/index.ts b/ui/src/adapters/copilot-local/index.ts new file mode 100644 index 00000000000..5a7499ef68a --- /dev/null +++ b/ui/src/adapters/copilot-local/index.ts @@ -0,0 +1,12 @@ +import type { UIAdapterModule } from "../types"; +import { parseCopilotLocalStdoutLine } from "@paperclipai/adapter-copilot-local/ui"; +import { CopilotLocalConfigFields } from "./config-fields"; +import { buildCopilotLocalConfig } from "@paperclipai/adapter-copilot-local/ui"; + +export const copilotLocalUIAdapter: UIAdapterModule = { + type: "copilot_local", + label: "GitHub Copilot (local)", + parseStdoutLine: parseCopilotLocalStdoutLine, + ConfigFields: CopilotLocalConfigFields, + buildAdapterConfig: buildCopilotLocalConfig, +}; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index e418457ee44..dbec2774011 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -7,6 +7,7 @@ import { openCodeLocalUIAdapter } from "./opencode-local"; import { piLocalUIAdapter } from "./pi-local"; import { openClawGatewayUIAdapter } from "./openclaw-gateway"; import { hermesLocalUIAdapter } from "./hermes-local"; +import { copilotLocalUIAdapter } from "./copilot-local"; import { processUIAdapter } from "./process"; import { httpUIAdapter } from "./http"; import { loadDynamicParser, invalidateDynamicParser } from "./dynamic-loader"; @@ -51,6 +52,7 @@ function registerBuiltInUIAdapters() { codexLocalUIAdapter, geminiLocalUIAdapter, hermesLocalUIAdapter, + copilotLocalUIAdapter, openCodeLocalUIAdapter, piLocalUIAdapter, cursorLocalUIAdapter, diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index fcd38604209..c390a1f7f47 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -44,6 +44,17 @@ export interface ClaudeLoginResult { stderr: string; } +export interface CopilotLoginResult { + success: boolean; + output: string; + errorMessage: string | null; +} + +export interface CopilotLoginChunk { + stream: "stdout" | "stderr"; + text: string; +} + export interface OrgNode { id: string; name: string; @@ -163,10 +174,14 @@ export const agentsApi = { api.get(agentPath(id, companyId, "/task-sessions")), resetSession: (id: string, taskKey?: string | null, companyId?: string) => api.post(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }), - adapterModels: (companyId: string, type: string) => - api.get( - `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`, - ), + adapterModels: (companyId: string, type: string, hints?: Record) => { + const params = hints && Object.keys(hints).length > 0 + ? "?" + new URLSearchParams(hints).toString() + : ""; + return api.get( + `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models${params}`, + ); + }, detectModel: (companyId: string, type: string) => api.get( `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`, @@ -194,6 +209,67 @@ export const agentsApi = { ) => api.post(agentPath(id, companyId, "/wakeup"), data), loginWithClaude: (id: string, companyId?: string) => api.post(agentPath(id, companyId, "/claude-login"), {}), + /** + * Initiates Copilot device-flow login via SSE. The server streams `chunk` + * events with real-time stdout/stderr (including the one-time device code) + * followed by a final `done` event with the full CopilotLoginResult. + */ + loginWithCopilot: ( + id: string, + callbacks: { + onChunk: (chunk: CopilotLoginChunk) => void; + onDone: (result: CopilotLoginResult) => void; + onError?: (error: Error) => void; + }, + companyId?: string, + ): { abort: () => void } => { + const controller = new AbortController(); + const url = `/api${agentPath(id, companyId, "/copilot-login")}`; + fetch(url, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: "{}", + signal: controller.signal, + }) + .then(async (res) => { + if (!res.ok) { + const errBody = await res.json().catch(() => null); + throw new Error( + (errBody as { error?: string } | null)?.error ?? `Request failed: ${res.status}`, + ); + } + const reader = res.body?.getReader(); + if (!reader) throw new Error("No response body"); + const decoder = new TextDecoder(); + let buffer = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split("\n\n"); + buffer = parts.pop() ?? ""; + for (const part of parts) { + const eventMatch = part.match(/^event:\s*(.+)$/m); + const dataMatch = part.match(/^data:\s*(.+)$/m); + if (!eventMatch || !dataMatch) continue; + const eventType = eventMatch[1]; + try { + const payload = JSON.parse(dataMatch[1]!); + if (eventType === "chunk") callbacks.onChunk(payload); + else if (eventType === "done") callbacks.onDone(payload); + } catch { + // Malformed SSE data — skip + } + } + } + }) + .catch((err) => { + if (controller.signal.aborted) return; + callbacks.onError?.(err instanceof Error ? err : new Error(String(err))); + }); + return { abort: () => controller.abort() }; + }, availableSkills: () => api.get<{ skills: AvailableSkill[] }>("/skills/available"), }; diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 065405f9a2d..d7a750183ad 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -277,14 +277,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); // Fetch adapter models for the effective adapter type + const adapterModelHints: Record | undefined = + !isCreate && typeof config.gheHost === "string" && config.gheHost.trim().length > 0 + ? { gheHost: config.gheHost.trim() as string } + : undefined; const { data: fetchedModels, error: fetchedModelsError, } = useQuery({ queryKey: selectedCompanyId - ? queryKeys.agents.adapterModels(selectedCompanyId, adapterType) + ? [...queryKeys.agents.adapterModels(selectedCompanyId, adapterType), adapterModelHints?.gheHost ?? ""] : ["agents", "none", "adapter-models", adapterType], - queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType), + queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType, adapterModelHints), enabled: Boolean(selectedCompanyId), }); const models = fetchedModels ?? externalModels ?? []; diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index caa9057812f..11f962a49ec 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1722,6 +1722,8 @@ function PromptsTab({ const isLocal = agent.adapterType === "claude_local" || agent.adapterType === "codex_local" || + agent.adapterType === "copilot_local" || + agent.adapterType === "gemini_local" || agent.adapterType === "opencode_local" || agent.adapterType === "pi_local" || agent.adapterType === "hermes_local" || diff --git a/vitest.config.ts b/vitest.config.ts index 3ec05081bad..8adf5081d6f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ projects: [ "packages/db", "packages/adapters/codex-local", + "packages/adapters/copilot-local", "packages/adapters/opencode-local", "server", "ui",