diff --git a/CHANGELOG.md b/CHANGELOG.md index a134359f5..1611c5f1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Status: beta. - Memory Search: allow extra paths for memory indexing. (#3600) Thanks @kira-ariaki. ### Changes +- Agents: add GitHub Copilot SDK integration for `copilot-cli` backend. Supports programmatic control of the Copilot CLI via `@github/copilot-sdk`. - Providers: add Venice AI integration; update Moonshot Kimi references to kimi-k2.5; update MiniMax API endpoint/format. (#2762, #3064) - Providers: add Xiaomi MiMo (mimo-v2-flash) support and onboarding flow. (#3454) Thanks @WqyJh. - Telegram: quote replies, edit-message action, silent sends, sticker support + vision caching, linkPreview toggle, plugin sendPayload support. (#2900, #2394, #2382, #2548, #1700, #1917) diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index b80683a16..67bcc8ddd 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -200,6 +200,25 @@ Moltbot also ships a default for `codex-cli`: - `imageArg: "--image"` - `sessionMode: "existing"` +Moltbot also ships a default for `copilot-cli`: + +- `command: "copilot"` +- `sessionMode: "always"` +- Uses the `@github/copilot-sdk` for programmatic control via JSON-RPC +- Supports models: `gpt-5`, `gpt-4.1`, `gpt-4.1-mini`, `gpt-4o`, `o1`, `o1-mini`, `o3-mini`, `claude-sonnet-4.5` + +To use Copilot CLI: +```bash +# Install the CLI globally +npm install -g @github/copilot + +# Authenticate +copilot auth login + +# Use in Moltbot +moltbot agent --message "hi" --model copilot-cli/gpt-4.1 +``` + Override only if needed (common: absolute `command` path). ## Limitations diff --git a/docs/providers/github-copilot.md b/docs/providers/github-copilot.md index c7b68d1bd..0757904fc 100644 --- a/docs/providers/github-copilot.md +++ b/docs/providers/github-copilot.md @@ -3,6 +3,7 @@ summary: "Sign in to GitHub Copilot from Moltbot using the device flow" read_when: - You want to use GitHub Copilot as a model provider - You need the `moltbot models auth login-github-copilot` flow + - You want to use the Copilot CLI as a backend --- # Github Copilot @@ -10,9 +11,9 @@ read_when: GitHub Copilot is GitHub's AI coding assistant. It provides access to Copilot models for your GitHub account and plan. Moltbot can use Copilot as a model -provider in two different ways. +provider in three different ways. -## Two ways to use Copilot in Moltbot +## Three ways to use Copilot in Moltbot ### 1) Built-in GitHub Copilot provider (`github-copilot`) @@ -20,7 +21,17 @@ Use the native device-login flow to obtain a GitHub token, then exchange it for Copilot API tokens when Moltbot runs. This is the **default** and simplest path because it does not require VS Code. -### 2) Copilot Proxy plugin (`copilot-proxy`) +### 2) Copilot CLI backend (`copilot-cli`) + +Use the official [GitHub Copilot CLI](https://docs.github.com/en/copilot/using-github-copilot/using-github-copilot-in-the-command-line) +as a CLI backend via the `@github/copilot-sdk`. This provides access to Copilot's +coding agent capabilities with tool execution and session persistence. + +Prerequisites: +- Install the Copilot CLI: `npm install -g @github/copilot` +- Authenticate: `copilot auth login` + +### 3) Copilot Proxy plugin (`copilot-proxy`) Use the **Copilot Proxy** VS Code extension as a local bridge. Moltbot talks to the proxy’s `/v1` endpoint and uses the model list you configure there. Choose @@ -68,3 +79,43 @@ moltbot models set github-copilot/gpt-4o another ID (for example `github-copilot/gpt-4.1`). - The login stores a GitHub token in the auth profile store and exchanges it for a Copilot API token when Moltbot runs. + +## Copilot CLI backend configuration + +To use the Copilot CLI as a backend (instead of the API), configure it in your +`moltbot.config.json`: + +```json5 +{ + agents: { + defaults: { + model: { primary: "copilot-cli/gpt-4.1" }, + cliBackends: { + "copilot-cli": { + command: "copilot", + // Optional: customize the CLI path + // command: "/usr/local/bin/copilot" + } + } + } + } +} +``` + +### Available models via Copilot CLI + +The Copilot CLI supports the following models (availability depends on your plan): + +- `gpt-5` +- `gpt-4.1` +- `gpt-4.1-mini` +- `gpt-4.1-nano` +- `gpt-4o` +- `o1` +- `o1-mini` +- `o3-mini` +- `claude-sonnet-4.5` +- `claude-sonnet-4` + +The Copilot CLI backend uses the `@github/copilot-sdk` for programmatic control +of the CLI via JSON-RPC. diff --git a/package.json b/package.json index 4d38edf18..46d4a0f4b 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,7 @@ "@aws-sdk/client-bedrock": "^3.975.0", "@buape/carbon": "0.14.0", "@clack/prompts": "^0.11.0", + "@github/copilot-sdk": "^0.1.19", "@grammyjs/runner": "^2.0.3", "@grammyjs/transformer-throttler": "^1.2.1", "@homebridge/ciao": "^1.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c0f99928..7a0283d1b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ importers: '@clack/prompts': specifier: ^0.11.0 version: 0.11.0 + '@github/copilot-sdk': + specifier: ^0.1.19 + version: 0.1.19 '@grammyjs/runner': specifier: ^2.0.3 version: 2.0.3(grammy@1.39.3) @@ -383,12 +386,12 @@ importers: '@microsoft/agents-hosting-extensions-teams': specifier: ^1.2.2 version: 1.2.2 - moltbot: - specifier: workspace:* - version: link:../.. express: specifier: ^5.2.1 version: 5.2.1 + moltbot: + specifier: workspace:* + version: link:../.. proper-lockfile: specifier: ^4.1.2 version: 4.1.2 @@ -1040,6 +1043,50 @@ packages: '@eshaz/web-worker@1.2.2': resolution: {integrity: sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw==} + '@github/copilot-darwin-arm64@0.0.394': + resolution: {integrity: sha512-qDmDFiFaYFW45UhxylN2JyQRLVGLCpkr5UmgbfH5e0aksf+69qytK/MwpD2Cq12KdTjyGMEorlADkSu5eftELA==} + cpu: [arm64] + os: [darwin] + hasBin: true + + '@github/copilot-darwin-x64@0.0.394': + resolution: {integrity: sha512-iN4YwSVFxhASiBjLk46f+AzRTNHCvYcmyTKBASxieMIhnDxznYmpo+haFKPCv2lCsEWU8s5LARCnXxxx8J1wKA==} + cpu: [x64] + os: [darwin] + hasBin: true + + '@github/copilot-linux-arm64@0.0.394': + resolution: {integrity: sha512-9NeGvmO2tGztuneXZfYAyW3fDk6Pdl6Ffg8MAUaevA/p0awvA+ti/Vh0ZSTcI81nDTjkzONvrcIcjYAN7x0oSg==} + cpu: [arm64] + os: [linux] + hasBin: true + + '@github/copilot-linux-x64@0.0.394': + resolution: {integrity: sha512-toahsYQORrP/TPSBQ7sxj4/fJg3YUrD0ksCj/Z4y2vT6EwrE9iC2BspKgQRa4CBoCqxYDNB2blc+mQ1UuzPOxg==} + cpu: [x64] + os: [linux] + hasBin: true + + '@github/copilot-sdk@0.1.19': + resolution: {integrity: sha512-h/KvYb6g99v9SurNJGxeXUatmP7GO8KHTAb68GYfmgUqH1EUeN5g0xMUc5lvKxAi7hwj2OxRR73dd37zMMiiiQ==} + engines: {node: '>=18.0.0'} + + '@github/copilot-win32-arm64@0.0.394': + resolution: {integrity: sha512-R7XBP3l+oeDuBrP0KD80ZBEMsZoxAW8QO2MNsDUV8eVrNJnp6KtGHoA+iCsKYKNOD6wHA/q5qm/jR+gpsz46Aw==} + cpu: [arm64] + os: [win32] + hasBin: true + + '@github/copilot-win32-x64@0.0.394': + resolution: {integrity: sha512-/XYV8srP+pMXbf9Gc3wr58zCzBZvsdA3X4poSvr2uU8yCZ6E4pD0agFaZ1c/CikANJi8nb0Id3kulhEhePz/3A==} + cpu: [x64] + os: [win32] + hasBin: true + + '@github/copilot@0.0.394': + resolution: {integrity: sha512-koSiaHvVwjgppgh+puxf6dgsR8ql/WST1scS5bjzMsJFfWk7f4xtEXla7TCQfSGoZkCmCsr2Tis27v5TpssiCg==} + hasBin: true + '@glideapps/ts-necessities@2.2.3': resolution: {integrity: sha512-gXi0awOZLHk3TbW55GZLCPP6O+y/b5X1pBXKBVckFONSwF1z1E5ND2BGJsghQFah+pW7pkkyFb2VhUQI2qhL5w==} @@ -3214,11 +3261,6 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - clawdbot@2026.1.24-3: - resolution: {integrity: sha512-zt9BzhWXduq8ZZR4rfzQDurQWAgmijTTyPZCQGrn5ew6wCEwhxxEr2/NHG7IlCwcfRsKymsY4se9KMhoNz0JtQ==} - engines: {node: '>=22.12.0'} - hasBin: true - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -5457,6 +5499,10 @@ packages: jsdom: optional: true + vscode-jsonrpc@8.2.1: + resolution: {integrity: sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==} + engines: {node: '>=14.0.0'} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -6646,6 +6692,39 @@ snapshots: '@eshaz/web-worker@1.2.2': optional: true + '@github/copilot-darwin-arm64@0.0.394': + optional: true + + '@github/copilot-darwin-x64@0.0.394': + optional: true + + '@github/copilot-linux-arm64@0.0.394': + optional: true + + '@github/copilot-linux-x64@0.0.394': + optional: true + + '@github/copilot-sdk@0.1.19': + dependencies: + '@github/copilot': 0.0.394 + vscode-jsonrpc: 8.2.1 + zod: 4.3.6 + + '@github/copilot-win32-arm64@0.0.394': + optional: true + + '@github/copilot-win32-x64@0.0.394': + optional: true + + '@github/copilot@0.0.394': + optionalDependencies: + '@github/copilot-darwin-arm64': 0.0.394 + '@github/copilot-darwin-x64': 0.0.394 + '@github/copilot-linux-arm64': 0.0.394 + '@github/copilot-linux-x64': 0.0.394 + '@github/copilot-win32-arm64': 0.0.394 + '@github/copilot-win32-x64': 0.0.394 + '@glideapps/ts-necessities@2.2.3': {} '@google/genai@1.34.0': @@ -9098,84 +9177,6 @@ snapshots: dependencies: clsx: 2.1.1 - clawdbot@2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3): - dependencies: - '@agentclientprotocol/sdk': 0.13.1(zod@4.3.6) - '@aws-sdk/client-bedrock': 3.975.0 - '@buape/carbon': 0.14.0(hono@4.11.4) - '@clack/prompts': 0.11.0 - '@grammyjs/runner': 2.0.3(grammy@1.39.3) - '@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3) - '@homebridge/ciao': 1.3.4 - '@line/bot-sdk': 10.6.0 - '@lydell/node-pty': 1.2.0-beta.3 - '@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.49.3 - '@mozilla/readability': 0.6.0 - '@sinclair/typebox': 0.34.47 - '@slack/bolt': 4.6.0(@types/express@5.0.6) - '@slack/web-api': 7.13.0 - '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) - ajv: 8.17.1 - body-parser: 2.2.2 - chalk: 5.6.2 - chokidar: 5.0.0 - chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482) - cli-highlight: 2.1.11 - commander: 14.0.2 - croner: 9.1.0 - detect-libc: 2.1.2 - discord-api-types: 0.38.37 - dotenv: 17.2.3 - express: 5.2.1 - file-type: 21.3.0 - grammy: 1.39.3 - hono: 4.11.4 - jiti: 2.6.1 - json5: 2.2.3 - jszip: 3.10.1 - linkedom: 0.18.12 - long: 5.3.2 - markdown-it: 14.1.0 - node-edge-tts: 1.2.9 - osc-progress: 0.3.0 - pdfjs-dist: 5.4.530 - playwright-core: 1.58.0 - proper-lockfile: 4.1.2 - qrcode-terminal: 0.12.0 - sharp: 0.34.5 - sqlite-vec: 0.1.7-alpha.2 - tar: 7.5.4 - tslog: 4.10.2 - undici: 7.19.0 - ws: 8.19.0 - yaml: 2.8.2 - zod: 4.3.6 - optionalDependencies: - '@napi-rs/canvas': 0.1.88 - node-llama-cpp: 3.15.0(typescript@5.9.3) - transitivePeerDependencies: - - '@discordjs/opus' - - '@modelcontextprotocol/sdk' - - '@types/express' - - audio-decode - - aws-crt - - bufferutil - - canvas - - debug - - devtools-protocol - - encoding - - ffmpeg-static - - jimp - - link-preview-js - - node-opus - - opusscript - - supports-color - - typescript - - utf-8-validate - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -11689,6 +11690,8 @@ snapshots: - tsx - yaml + vscode-jsonrpc@8.2.1: {} + web-streams-polyfill@3.3.3: {} webidl-conversions@3.0.1: {} diff --git a/src/agents/auth-profiles/constants.ts b/src/agents/auth-profiles/constants.ts index 65c2f7a54..888c6e3b1 100644 --- a/src/agents/auth-profiles/constants.ts +++ b/src/agents/auth-profiles/constants.ts @@ -7,6 +7,7 @@ export const LEGACY_AUTH_FILENAME = "auth.json"; export const CLAUDE_CLI_PROFILE_ID = "anthropic:claude-cli"; export const CODEX_CLI_PROFILE_ID = "openai-codex:codex-cli"; export const QWEN_CLI_PROFILE_ID = "qwen-portal:qwen-cli"; +export const COPILOT_CLI_PROFILE_ID = "github-copilot:copilot-cli"; export const AUTH_STORE_LOCK_OPTIONS = { retries: { diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index 55344dedc..731669820 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -74,6 +74,37 @@ const DEFAULT_CODEX_BACKEND: CliBackendConfig = { serialize: true, }; +const COPILOT_MODEL_ALIASES: Record = { + "gpt-5": "gpt-5", + "gpt-4.1": "gpt-4.1", + "gpt-4.1-mini": "gpt-4.1-mini", + "gpt-4.1-nano": "gpt-4.1-nano", + "gpt-4o": "gpt-4o", + o1: "o1", + "o1-mini": "o1-mini", + "o3-mini": "o3-mini", + "claude-sonnet-4.5": "claude-sonnet-4.5", + "claude-sonnet-4": "claude-sonnet-4", +}; + +/** + * Default configuration for the Copilot CLI backend. + * + * Note: The Copilot CLI uses the `@github/copilot-sdk` for programmatic control. + * This backend config is used for CLI-style invocation patterns, but the actual + * execution is handled by the SDK in `copilot-runner.ts`. + */ +const DEFAULT_COPILOT_BACKEND: CliBackendConfig = { + command: "copilot", + args: [], + output: "text", + input: "arg", + modelAliases: COPILOT_MODEL_ALIASES, + sessionMode: "always", + sessionIdFields: ["sessionId", "session_id"], + serialize: true, +}; + function normalizeBackendKey(key: string): string { return normalizeProviderId(key); } @@ -107,6 +138,7 @@ export function resolveCliBackendIds(cfg?: MoltbotConfig): Set { const ids = new Set([ normalizeBackendKey("claude-cli"), normalizeBackendKey("codex-cli"), + normalizeBackendKey("copilot-cli"), ]); const configured = cfg?.agents?.defaults?.cliBackends ?? {}; for (const key of Object.keys(configured)) { @@ -135,6 +167,12 @@ export function resolveCliBackendConfig( if (!command) return null; return { id: normalized, config: { ...merged, command } }; } + if (normalized === "copilot-cli") { + const merged = mergeBackendConfig(DEFAULT_COPILOT_BACKEND, override); + const command = merged.command?.trim(); + if (!command) return null; + return { id: normalized, config: { ...merged, command } }; + } if (!override) return null; const command = override.command?.trim(); diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index c998a9e6f..19cdd4317 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -26,9 +26,11 @@ import { resolveSystemPromptUsage, writeCliImages, } from "./cli-runner/helpers.js"; +import { runCopilotCliAgent, isCopilotCliAvailable } from "./copilot-runner.js"; import { FailoverError, resolveFailoverStatus } from "./failover-error.js"; import { classifyFailoverReason, isFailoverErrorMessage } from "./pi-embedded-helpers.js"; import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js"; +import { normalizeProviderId } from "./model-selection.js"; const log = createSubsystemLogger("agent/claude-cli"); @@ -50,6 +52,29 @@ export async function runCliAgent(params: { cliSessionId?: string; images?: ImageContent[]; }): Promise { + // Route copilot-cli requests to the SDK-based runner + const normalizedProvider = normalizeProviderId(params.provider); + if (normalizedProvider === "copilot-cli") { + return runCopilotCliAgent({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + workspaceDir: params.workspaceDir, + config: params.config, + prompt: params.prompt, + provider: params.provider, + model: params.model, + thinkLevel: params.thinkLevel, + timeoutMs: params.timeoutMs, + runId: params.runId, + extraSystemPrompt: params.extraSystemPrompt, + streamParams: params.streamParams, + ownerNumbers: params.ownerNumbers, + cliSessionId: params.cliSessionId, + images: params.images, + }); + } + const started = Date.now(); const resolvedWorkspace = resolveUserPath(params.workspaceDir); const workspaceDir = resolvedWorkspace; @@ -333,3 +358,6 @@ export async function runClaudeCliAgent(params: { images: params.images, }); } + +// Re-export Copilot-specific utilities +export { isCopilotCliAvailable } from "./copilot-runner.js"; diff --git a/src/agents/copilot-credentials.test.ts b/src/agents/copilot-credentials.test.ts new file mode 100644 index 000000000..8d93649e3 --- /dev/null +++ b/src/agents/copilot-credentials.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import { + isCopilotCliInstalled, + readCopilotAuthStatus, + readCopilotAuthStatusCached, + resetCopilotCredentialCacheForTest, +} from "./copilot-credentials.js"; + +describe("copilot-credentials", () => { + beforeEach(() => { + resetCopilotCredentialCacheForTest(); + }); + + describe("isCopilotCliInstalled", () => { + it("returns true when copilot --version succeeds", () => { + const execSync = vi.fn().mockReturnValue("0.0.1"); + const result = isCopilotCliInstalled({ execSync }); + expect(result).toBe(true); + expect(execSync).toHaveBeenCalledWith("copilot --version", expect.any(Object)); + }); + + it("returns false when copilot --version fails", () => { + const execSync = vi.fn().mockImplementation(() => { + throw new Error("ENOENT: not found"); + }); + const result = isCopilotCliInstalled({ execSync }); + expect(result).toBe(false); + }); + + it("uses custom cliPath when provided", () => { + const execSync = vi.fn().mockReturnValue("0.0.1"); + isCopilotCliInstalled({ cliPath: "/usr/local/bin/copilot", execSync }); + expect(execSync).toHaveBeenCalledWith("/usr/local/bin/copilot --version", expect.any(Object)); + }); + }); + + describe("readCopilotAuthStatus", () => { + it("returns authenticated status when CLI reports authenticated", () => { + const execSync = vi.fn().mockReturnValue( + JSON.stringify({ + isAuthenticated: true, + user: { + login: "testuser", + avatarUrl: "https://example.com/avatar.png", + }, + }), + ); + const result = readCopilotAuthStatus({ execSync }); + expect(result).toEqual({ + authenticated: true, + login: "testuser", + avatarUrl: "https://example.com/avatar.png", + }); + }); + + it("returns not authenticated when CLI reports not authenticated", () => { + const execSync = vi.fn().mockReturnValue( + JSON.stringify({ + isAuthenticated: false, + }), + ); + const result = readCopilotAuthStatus({ execSync }); + expect(result).toEqual({ authenticated: false }); + }); + + it("returns null when CLI is not installed", () => { + const execSync = vi.fn().mockImplementation(() => { + const error = new Error("ENOENT: not found"); + throw error; + }); + const result = readCopilotAuthStatus({ execSync }); + expect(result).toBe(null); + }); + + it("returns not authenticated on stderr indicating not logged in", () => { + const execSync = vi.fn().mockImplementation(() => { + const error = new Error("Command failed") as Error & { stderr: string }; + error.stderr = "You are not logged in"; + throw error; + }); + const result = readCopilotAuthStatus({ execSync }); + expect(result).toEqual({ authenticated: false }); + }); + }); + + describe("readCopilotAuthStatusCached", () => { + it("returns cached value within TTL", () => { + const execSync = vi.fn().mockReturnValue( + JSON.stringify({ + isAuthenticated: true, + user: { login: "testuser" }, + }), + ); + + // First call populates cache + const result1 = readCopilotAuthStatusCached({ execSync, ttlMs: 60000 }); + expect(result1?.authenticated).toBe(true); + expect(execSync).toHaveBeenCalledTimes(1); + + // Second call should use cache + const result2 = readCopilotAuthStatusCached({ execSync, ttlMs: 60000 }); + expect(result2?.authenticated).toBe(true); + expect(execSync).toHaveBeenCalledTimes(1); // Not called again + }); + + it("refreshes cache after TTL expires", async () => { + const execSync = vi.fn().mockReturnValue( + JSON.stringify({ + isAuthenticated: true, + user: { login: "testuser" }, + }), + ); + + // First call with very short TTL + readCopilotAuthStatusCached({ execSync, ttlMs: 1 }); + expect(execSync).toHaveBeenCalledTimes(1); + + // Wait for TTL to expire + await new Promise((r) => setTimeout(r, 5)); + + // Second call should refresh + readCopilotAuthStatusCached({ execSync, ttlMs: 1 }); + expect(execSync).toHaveBeenCalledTimes(2); + }); + + it("does not cache when ttlMs is 0", () => { + const execSync = vi.fn().mockReturnValue( + JSON.stringify({ + isAuthenticated: true, + user: { login: "testuser" }, + }), + ); + + readCopilotAuthStatusCached({ execSync, ttlMs: 0 }); + readCopilotAuthStatusCached({ execSync, ttlMs: 0 }); + expect(execSync).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/agents/copilot-credentials.ts b/src/agents/copilot-credentials.ts new file mode 100644 index 000000000..b91562c1c --- /dev/null +++ b/src/agents/copilot-credentials.ts @@ -0,0 +1,168 @@ +/** + * Credential management for the GitHub Copilot CLI. + * + * This module provides functions to check the authentication status of the Copilot CLI + * and cache the results for efficient repeated access. + */ +import { execSync } from "node:child_process"; + +import { createSubsystemLogger } from "../logging/subsystem.js"; + +const log = createSubsystemLogger("agents/copilot-credentials"); + +/** + * Cached authentication status from the Copilot CLI. + */ +export type CopilotCredential = { + /** Whether the CLI is authenticated with a valid GitHub account. */ + authenticated: boolean; + /** GitHub login username (if authenticated). */ + login?: string; + /** GitHub avatar URL (if authenticated). */ + avatarUrl?: string; +}; + +type CachedValue = { + value: T | null; + readAt: number; + cacheKey: string; +}; + +let copilotCliCache: CachedValue | null = null; + +/** Reset the cache for testing purposes. */ +export function resetCopilotCredentialCacheForTest(): void { + copilotCliCache = null; +} + +type ExecSyncFn = typeof execSync; + +/** + * Check if the Copilot CLI is installed and available on PATH. + */ +export function isCopilotCliInstalled(options?: { + cliPath?: string; + execSync?: ExecSyncFn; +}): boolean { + const execSyncImpl = options?.execSync ?? execSync; + const cliPath = options?.cliPath ?? "copilot"; + + try { + execSyncImpl(`${cliPath} --version`, { + encoding: "utf8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }); + return true; + } catch { + return false; + } +} + +/** + * Read the authentication status from the Copilot CLI. + * + * Requires the `copilot` CLI to be installed. If the CLI is not installed + * or authentication status cannot be determined, returns null. + */ +export function readCopilotAuthStatus(options?: { + cliPath?: string; + execSync?: ExecSyncFn; +}): CopilotCredential | null { + const execSyncImpl = options?.execSync ?? execSync; + const cliPath = options?.cliPath ?? "copilot"; + + try { + // The Copilot CLI has a `copilot auth status --json` command that returns auth info. + const result = execSyncImpl(`${cliPath} auth status --json`, { + encoding: "utf8", + timeout: 10000, + stdio: ["pipe", "pipe", "pipe"], + }); + + const data = JSON.parse(result.trim()) as Record; + + // The CLI returns { isAuthenticated: boolean, user?: { login, avatarUrl } } + const isAuthenticated = data.isAuthenticated === true; + const user = data.user as Record | undefined; + + if (!isAuthenticated) { + log.info("copilot cli is not authenticated"); + return { authenticated: false }; + } + + const login = typeof user?.login === "string" ? user.login : undefined; + const avatarUrl = typeof user?.avatarUrl === "string" ? user.avatarUrl : undefined; + + log.info("read copilot auth status from cli", { + authenticated: true, + login, + }); + + return { + authenticated: true, + login, + avatarUrl, + }; + } catch (error) { + // Check if it's a "not installed" error vs an auth error + const message = error instanceof Error ? error.message : String(error); + if (message.includes("ENOENT") || message.includes("not found")) { + log.debug("copilot cli not found"); + return null; + } + + // Parse error response - CLI may return JSON even on error + try { + const stderr = + error instanceof Error && "stderr" in error + ? String((error as { stderr: unknown }).stderr) + : ""; + if (stderr.includes("not logged in") || stderr.includes("not authenticated")) { + return { authenticated: false }; + } + } catch { + // Ignore parse errors + } + + log.warn("failed to read copilot auth status", { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +/** + * Read the Copilot CLI authentication status with caching. + * + * @param options.ttlMs - How long to cache the result in milliseconds (default: no caching) + */ +export function readCopilotAuthStatusCached(options?: { + cliPath?: string; + ttlMs?: number; + execSync?: ExecSyncFn; +}): CopilotCredential | null { + const ttlMs = options?.ttlMs ?? 0; + const now = Date.now(); + const cacheKey = options?.cliPath ?? "copilot"; + + if ( + ttlMs > 0 && + copilotCliCache && + copilotCliCache.cacheKey === cacheKey && + now - copilotCliCache.readAt < ttlMs + ) { + return copilotCliCache.value; + } + + const value = readCopilotAuthStatus({ + cliPath: options?.cliPath, + execSync: options?.execSync, + }); + + if (ttlMs > 0) { + copilotCliCache = { value, readAt: now, cacheKey }; + } + + return value; +} diff --git a/src/agents/copilot-runner.ts b/src/agents/copilot-runner.ts new file mode 100644 index 000000000..ee188b173 --- /dev/null +++ b/src/agents/copilot-runner.ts @@ -0,0 +1,157 @@ +/** + * Copilot CLI Runner - runs agent prompts through the GitHub Copilot SDK. + * + * This module provides a runner that uses the `@github/copilot-sdk` to execute + * agent prompts through the Copilot CLI, similar to how `cli-runner.ts` handles + * Claude CLI and Codex CLI backends. + */ +import type { ImageContent } from "@mariozechner/pi-ai"; + +import type { MoltbotConfig } from "../config/config.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { resolveUserPath } from "../utils.js"; +import { resolveSessionAgentIds } from "./agent-scope.js"; +import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js"; +import { resolveCliBackendConfig } from "./cli-backends.js"; +import { normalizeCliModel, buildSystemPrompt } from "./cli-runner/helpers.js"; +import { runCopilotAgent, checkCopilotAvailable } from "./copilot-sdk.js"; +import { resolveMoltbotDocsPath } from "./docs-path.js"; +import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js"; +import type { ThinkLevel } from "../auto-reply/thinking.js"; +import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js"; + +const log = createSubsystemLogger("agent/copilot-cli"); + +/** + * Check if the Copilot CLI backend is available. + */ +export function isCopilotCliAvailable(options?: { cliPath?: string }): boolean { + const status = checkCopilotAvailable({ cliPath: options?.cliPath }); + return status.available && status.authenticated; +} + +/** + * Run an agent prompt through the Copilot CLI using the SDK. + * + * This function is designed to match the signature of `runCliAgent` for + * compatibility with the existing CLI backend infrastructure. + */ +export async function runCopilotCliAgent(params: { + sessionId: string; + sessionKey?: string; + sessionFile: string; + workspaceDir: string; + config?: MoltbotConfig; + prompt: string; + provider?: string; + model?: string; + thinkLevel?: ThinkLevel; + timeoutMs: number; + runId: string; + extraSystemPrompt?: string; + streamParams?: import("../commands/agent/types.js").AgentStreamParams; + ownerNumbers?: string[]; + cliSessionId?: string; + images?: ImageContent[]; +}): Promise { + const started = Date.now(); + const resolvedWorkspace = resolveUserPath(params.workspaceDir); + const workspaceDir = resolvedWorkspace; + const provider = params.provider ?? "copilot-cli"; + + const backendResolved = resolveCliBackendConfig(provider, params.config); + if (!backendResolved) { + throw new Error(`Unknown CLI backend: ${provider}`); + } + const backend = backendResolved.config; + const modelId = (params.model ?? "gpt-4.1").trim() || "gpt-4.1"; + const normalizedModel = normalizeCliModel(modelId, backend); + const modelDisplay = `${provider}/${modelId}`; + + // Build system prompt with context + const extraSystemPrompt = [ + params.extraSystemPrompt?.trim(), + "Tools are disabled in this session. Do not call tools.", + ] + .filter(Boolean) + .join("\n"); + + const sessionLabel = params.sessionKey ?? params.sessionId; + const { contextFiles } = await resolveBootstrapContextForRun({ + workspaceDir, + config: params.config, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }), + }); + + const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({ + sessionKey: params.sessionKey, + config: params.config, + }); + + const heartbeatPrompt = + sessionAgentId === defaultAgentId + ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) + : undefined; + + const docsPath = await resolveMoltbotDocsPath({ + workspaceDir, + argv1: process.argv[1], + cwd: process.cwd(), + moduleUrl: import.meta.url, + }); + + const systemPrompt = buildSystemPrompt({ + workspaceDir, + config: params.config, + defaultThinkLevel: params.thinkLevel, + extraSystemPrompt, + ownerNumbers: params.ownerNumbers, + heartbeatPrompt, + docsPath: docsPath ?? undefined, + tools: [], + contextFiles, + modelDisplay, + agentId: sessionAgentId, + }); + + log.info(`copilot-cli exec: model=${normalizedModel} promptChars=${params.prompt.length}`); + + try { + const result = await runCopilotAgent({ + prompt: params.prompt, + model: normalizedModel, + cliPath: backend.command, + cwd: workspaceDir, + systemPrompt, + timeoutMs: params.timeoutMs, + sessionId: params.cliSessionId, + }); + + const text = result.text?.trim(); + const payloads = text ? [{ text }] : undefined; + + // When resuming a session, the SDK's sessionId should be authoritative. + // For new sessions, fall back to params.sessionId if SDK doesn't return one. + const resolvedSessionId = params.cliSessionId + ? result.sessionId // Resuming: use SDK's session ID + : (result.sessionId ?? params.sessionId ?? ""); // New: SDK or fallback + + return { + payloads, + meta: { + durationMs: Date.now() - started, + agentMeta: { + sessionId: resolvedSessionId, + provider, + model: modelId, + }, + }, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error("copilot-cli run failed", { error: message }); + throw err; + } +} diff --git a/src/agents/copilot-sdk.test.ts b/src/agents/copilot-sdk.test.ts new file mode 100644 index 000000000..00154619c --- /dev/null +++ b/src/agents/copilot-sdk.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import { checkCopilotAvailable } from "./copilot-sdk.js"; +import * as copilotCredentials from "./copilot-credentials.js"; + +vi.mock("./copilot-credentials.js", () => ({ + isCopilotCliInstalled: vi.fn(), + readCopilotAuthStatusCached: vi.fn(), +})); + +describe("copilot-sdk", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("checkCopilotAvailable", () => { + it("returns not available when CLI is not installed", () => { + vi.mocked(copilotCredentials.isCopilotCliInstalled).mockReturnValue(false); + + const result = checkCopilotAvailable(); + + expect(result).toEqual({ + available: false, + authenticated: false, + }); + }); + + it("returns available but not authenticated when CLI is installed but not authenticated", () => { + vi.mocked(copilotCredentials.isCopilotCliInstalled).mockReturnValue(true); + vi.mocked(copilotCredentials.readCopilotAuthStatusCached).mockReturnValue({ + authenticated: false, + }); + + const result = checkCopilotAvailable(); + + expect(result).toEqual({ + available: true, + authenticated: false, + }); + }); + + it("returns available and authenticated with user info when CLI is authenticated", () => { + vi.mocked(copilotCredentials.isCopilotCliInstalled).mockReturnValue(true); + vi.mocked(copilotCredentials.readCopilotAuthStatusCached).mockReturnValue({ + authenticated: true, + login: "testuser", + avatarUrl: "https://example.com/avatar.png", + }); + + const result = checkCopilotAvailable(); + + expect(result).toEqual({ + available: true, + authenticated: true, + login: "testuser", + avatarUrl: "https://example.com/avatar.png", + }); + }); + + it("returns available but not authenticated when auth status is null", () => { + vi.mocked(copilotCredentials.isCopilotCliInstalled).mockReturnValue(true); + vi.mocked(copilotCredentials.readCopilotAuthStatusCached).mockReturnValue(null); + + const result = checkCopilotAvailable(); + + expect(result).toEqual({ + available: true, + authenticated: false, + }); + }); + + it("passes cliPath option to credential functions", () => { + vi.mocked(copilotCredentials.isCopilotCliInstalled).mockReturnValue(true); + vi.mocked(copilotCredentials.readCopilotAuthStatusCached).mockReturnValue({ + authenticated: true, + }); + + checkCopilotAvailable({ cliPath: "/custom/path/copilot" }); + + expect(copilotCredentials.isCopilotCliInstalled).toHaveBeenCalledWith({ + cliPath: "/custom/path/copilot", + }); + expect(copilotCredentials.readCopilotAuthStatusCached).toHaveBeenCalledWith( + expect.objectContaining({ + cliPath: "/custom/path/copilot", + }), + ); + }); + }); +}); diff --git a/src/agents/copilot-sdk.ts b/src/agents/copilot-sdk.ts new file mode 100644 index 000000000..7eef2ac95 --- /dev/null +++ b/src/agents/copilot-sdk.ts @@ -0,0 +1,345 @@ +/** + * GitHub Copilot SDK integration for Moltbot. + * + * This module provides a thin wrapper around the `@github/copilot-sdk` package + * for programmatic control of GitHub Copilot CLI via JSON-RPC. + */ +import type { + CopilotClient, + CopilotClientOptions, + CopilotSession, + SessionConfig, + SessionEvent, +} from "@github/copilot-sdk"; + +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { isCopilotCliInstalled, readCopilotAuthStatusCached } from "./copilot-credentials.js"; + +const log = createSubsystemLogger("agents/copilot-sdk"); + +// Re-export SDK types that consumers may need. +export type { CopilotClient, CopilotClientOptions, CopilotSession, SessionConfig, SessionEvent }; + +/** + * Options for creating a Moltbot-configured Copilot client. + */ +export type MoltbotCopilotClientOptions = { + /** Path to the Copilot CLI executable (default: "copilot" from PATH). */ + cliPath?: string; + /** Working directory for the CLI process. */ + cwd?: string; + /** Log level for the CLI server. */ + logLevel?: "none" | "error" | "warning" | "info" | "debug" | "all"; + /** Auto-restart the CLI server if it crashes (default: true). */ + autoRestart?: boolean; + /** Environment variables to pass to the CLI process. */ + env?: Record; +}; + +/** + * Check if the Copilot CLI is available and authenticated. + * + * @returns Object with `available` and `authenticated` flags, plus optional user info. + */ +export function checkCopilotAvailable(options?: { cliPath?: string }): { + available: boolean; + authenticated: boolean; + login?: string; + avatarUrl?: string; +} { + const installed = isCopilotCliInstalled({ cliPath: options?.cliPath }); + if (!installed) { + return { available: false, authenticated: false }; + } + + const authStatus = readCopilotAuthStatusCached({ + cliPath: options?.cliPath, + ttlMs: 5 * 60 * 1000, // Cache for 5 minutes + }); + + if (!authStatus) { + return { available: true, authenticated: false }; + } + + return { + available: true, + authenticated: authStatus.authenticated, + login: authStatus.login, + avatarUrl: authStatus.avatarUrl, + }; +} + +/** + * Create a CopilotClient with Moltbot-specific defaults. + * + * This is a factory function that lazily imports the SDK and creates + * a client instance. The client should be started before use. + */ +export async function createCopilotClient( + options?: MoltbotCopilotClientOptions, +): Promise { + const { CopilotClient: CopilotClientClass } = await import("@github/copilot-sdk"); + + const clientOptions: CopilotClientOptions = { + cliPath: options?.cliPath ?? "copilot", + cwd: options?.cwd, + logLevel: options?.logLevel ?? "warning", + autoRestart: false, // Disable auto-restart to avoid keeping event loop alive + useStdio: true, // Use stdio transport for better process control + autoStart: false, // We'll start manually for better error handling + env: options?.env, + }; + + log.info("creating copilot client", { + cliPath: clientOptions.cliPath, + cwd: clientOptions.cwd, + logLevel: clientOptions.logLevel, + }); + + return new CopilotClientClass(clientOptions); +} + +/** + * Parameters for running a single-turn Copilot agent interaction. + */ +export type RunCopilotAgentParams = { + /** The prompt/message to send. */ + prompt: string; + /** Model to use (e.g., "gpt-5", "gpt-4.1", "claude-sonnet-4.5"). */ + model?: string; + /** Path to the Copilot CLI executable. */ + cliPath?: string; + /** Working directory for the CLI process. */ + cwd?: string; + /** System message content (appended to CLI defaults). */ + systemPrompt?: string; + /** Timeout in milliseconds (default: 120000). */ + timeoutMs?: number; + /** Session ID to resume (for multi-turn conversations). */ + sessionId?: string; + /** Environment variables to pass to the CLI. */ + env?: Record; + /** Callback for streaming events. */ + onEvent?: (event: SessionEvent) => void; +}; + +/** + * Result from a Copilot agent run. + */ +export type CopilotAgentResult = { + /** The final assistant response text. */ + text: string; + /** Session ID for resuming conversations. */ + sessionId: string; + /** Events received during the run. */ + events: SessionEvent[]; + /** Duration in milliseconds. */ + durationMs: number; +}; + +/** + * Run a single-turn Copilot agent interaction. + * + * Creates a client, starts a session, sends the prompt, waits for completion, + * and cleans up. For multi-turn conversations, pass the returned `sessionId` + * back in subsequent calls. + * + * Note: When resuming a session, model and system prompt settings from the + * original session are preserved. New configuration values are not applied + * during resumption. + */ +export async function runCopilotAgent(params: RunCopilotAgentParams): Promise { + const started = Date.now(); + const events: SessionEvent[] = []; + let finalText = ""; + + const client = await createCopilotClient({ + cliPath: params.cliPath, + cwd: params.cwd, + env: params.env, + }); + + let session: CopilotSession | null = null; + let unsubscribe: (() => void) | null = null; + + try { + // Start the client + await client.start(); + + // Configure session for new sessions + const sessionConfig: SessionConfig = { + model: params.model, + sessionId: params.sessionId, + }; + + // Add system message if provided (only applies to new sessions) + if (params.systemPrompt) { + sessionConfig.systemMessage = { + mode: "append", + content: params.systemPrompt, + }; + } + + // Create or resume session + // Note: When resuming, the SDK preserves the original session's model/prompt. + // The ResumeSessionConfig type only supports tools, provider, streaming, + // onPermissionRequest, mcpServers, customAgents, skillDirectories, disabledSkills. + if (params.sessionId) { + session = await client.resumeSession(params.sessionId, { + streaming: true, + }); + } else { + session = await client.createSession(sessionConfig); + } + + const sessionId = session.sessionId; + + // Set up event handler + unsubscribe = session.on((event: SessionEvent) => { + events.push(event); + params.onEvent?.(event); + + // Capture final text from assistant message events + if (event.type === "assistant.message") { + const data = event.data as { content?: string }; + if (typeof data.content === "string") { + finalText = data.content; + } + } + }); + + // Send the message and wait for completion + const result = await session.sendAndWait({ prompt: params.prompt }, params.timeoutMs ?? 120000); + + // Extract text from result if available + if (result?.data?.content && typeof result.data.content === "string") { + finalText = result.data.content; + } + + return { + text: finalText, + sessionId, + events, + durationMs: Date.now() - started, + }; + } finally { + // Clean up in order: unsubscribe, abort session, destroy session, stop client + // NOTE: Due to a limitation in vscode-jsonrpc (used by @github/copilot-sdk), + // the process may not exit cleanly after cleanup. This is acceptable for gateway + // usage but means CLI one-off commands will hang. The SDK team should fix this. + if (unsubscribe) { + unsubscribe(); + } + + // Abort any in-flight request, then destroy the session + if (session) { + try { + await session.abort(); + } catch { + // Ignore abort errors (may not have active request) + } + + // Only destroy session if we created a new one (not resuming) + if (!params.sessionId) { + try { + await session.destroy(); + } catch { + // Ignore destroy errors + } + } + } + + // Access internal SDK handles BEFORE cleanup so we can unref them after + const clientAny = client as unknown as { + cliProcess?: { + unref?: () => void; + stdin?: { unref?: () => void }; + stdout?: { unref?: () => void }; + stderr?: { unref?: () => void }; + removeAllListeners?: () => void; + }; + socket?: { unref?: () => void }; + }; + const cliProcess = clientAny.cliProcess; + const socket = clientAny.socket; + + // Stop the client gracefully first, then force if needed + try { + await client.stop(); + } catch { + try { + await client.forceStop(); + } catch { + // Ignore forceStop errors + } + } + + // Unref all handles to allow Node to exit (works around SDK cleanup bug) + if (cliProcess) { + cliProcess.unref?.(); + cliProcess.stdin?.unref?.(); + cliProcess.stdout?.unref?.(); + cliProcess.stderr?.unref?.(); + cliProcess.removeAllListeners?.(); + } + if (socket) { + socket.unref?.(); + } + } +} + +/** + * List available Copilot sessions. + */ +export async function listCopilotSessions(options?: { + cliPath?: string; + cwd?: string; +}): Promise> { + const client = await createCopilotClient({ + cliPath: options?.cliPath, + cwd: options?.cwd, + }); + + try { + await client.start(); + const sessions = await client.listSessions(); + return sessions.map((s) => ({ + sessionId: s.sessionId, + createdAt: s.startTime?.toISOString(), + })); + } finally { + try { + await client.stop(); + } catch { + // Ignore cleanup errors + } + } +} + +/** + * Delete a Copilot session. + */ +export async function deleteCopilotSession( + sessionId: string, + options?: { + cliPath?: string; + cwd?: string; + }, +): Promise { + const client = await createCopilotClient({ + cliPath: options?.cliPath, + cwd: options?.cwd, + }); + + try { + await client.start(); + await client.deleteSession(sessionId); + log.info("deleted copilot session", { sessionId }); + } finally { + try { + await client.stop(); + } catch { + // Ignore cleanup errors + } + } +} diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 8d6db36de..d21c95db2 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -36,6 +36,7 @@ export function isCliProvider(provider: string, cfg?: MoltbotConfig): boolean { const normalized = normalizeProviderId(provider); if (normalized === "claude-cli") return true; if (normalized === "codex-cli") return true; + if (normalized === "copilot-cli") return true; const backends = cfg?.agents?.defaults?.cliBackends ?? {}; return Object.keys(backends).some((key) => normalizeProviderId(key) === normalized); }