diff --git a/README.md b/README.md index 9d57e62..545ee63 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ Those models are available through the local Devin CLI. This package uses that C ## Requirements -- Pi Coding Agent 0.80+ +- Pi Coding Agent 0.86+ (managed fork validated against 0.87.1) - A signed-in [Devin CLI](https://docs.devin.ai/cli) (`devin auth status`) -- Node 18+ +- Node 22.19+ The CLI binary is resolved in this order: @@ -81,3 +81,7 @@ This is a standard Pi package (`keywords: ["pi-package"]` + `pi.extensions`). Af ## License MIT. Unofficial. Not affiliated with Cognition. + +## Managed fork + +Based on npm pi-devin 0.2.1. Retains native signature/response replay, corrected wire fields, bounded frames, context clamping, multi-window catalog variants and abort cleanup. Uses the upstream six-hour XDG catalog cache. Tests and typecheck are maintained in the parent app tests directory. diff --git a/extensions/index.ts b/extensions/index.ts index 801eb9f..ae57f81 100644 --- a/extensions/index.ts +++ b/extensions/index.ts @@ -1,10 +1,14 @@ import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent"; import type { Api, Model, OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai"; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; import { authStatus, loginWithCli, readCredentials } from "../src/credentials.js"; import { whichDevin, devinVersion } from "../src/cli.js"; +import { + type CachedDevinCatalog, + isUsableCatalog, + isCatalogCacheFresh, + readCatalogCache, + writeCatalogCache, +} from "../src/catalog-cache.js"; import { FALLBACK_MODELS, loadCliCatalog, modelsFromCatalog } from "../src/models.js"; import { CLIENT_IDE, CLIENT_VERSION } from "../src/metadata.js"; import { streamDevin } from "../src/stream.js"; @@ -12,36 +16,38 @@ import { streamDevin } from "../src/stream.js"; const PROVIDER_ID = "devin"; const PLACEHOLDER_BASE_URL = "https://server.codeium.com"; -const CACHE_PATH = join(homedir(), ".pi", "agent", "devin-catalog-cache.json"); -const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours - -interface CachedCatalog { - catalog: unknown; - timestamp: number; -} - -function loadCachedCatalog(): unknown | null { - try { - if (!existsSync(CACHE_PATH)) return null; - const raw = readFileSync(CACHE_PATH, "utf-8"); - const cached = JSON.parse(raw) as CachedCatalog; - if (Date.now() - cached.timestamp > CACHE_TTL_MS) return null; - return cached.catalog; - } catch { - return null; - } -} - -function saveCachedCatalog(catalog: unknown): void { - try { - mkdirSync(join(homedir(), ".pi", "agent"), { recursive: true }); - writeFileSync(CACHE_PATH, JSON.stringify({ catalog, timestamp: Date.now() })); - } catch { - // ignore cache write failures - } -} - let _pi: ExtensionAPI | null = null; +let catalogRequest: Promise | null = null; + +function isOffline(): boolean { + const value = process.env.PI_OFFLINE?.toLowerCase(); + return value === "1" || value === "true" || value === "yes"; +} + +function refreshCatalog(pi: ExtensionAPI): Promise { + if (!catalogRequest) { + const pending = loadCliCatalog().then((catalog) => { + if (!isUsableCatalog(catalog)) { + throw new Error("Devin CLI returned no usable model families"); + } + const models = modelsFromCatalog(catalog); + try { + writeCatalogCache(catalog); + } catch (error) { + console.warn(`Devin: failed to cache model catalog: ${error instanceof Error ? error.message : String(error)}`); + } + return models; + }); + catalogRequest = pending; + void pending.finally(() => { + if (catalogRequest === pending) catalogRequest = null; + }).catch(() => {}); + } + return catalogRequest.then((models) => { + if (_pi === pi) registerDevinProvider(pi, models); + return models; + }); +} function registerDevinProvider(pi: ExtensionAPI, models: ProviderModelConfig[]): void { pi.registerProvider(PROVIDER_ID, { @@ -55,9 +61,7 @@ function registerDevinProvider(pi: ExtensionAPI, models: ProviderModelConfig[]): const creds = await loginWithCli(); if (_pi) { try { - const catalog = await loadCliCatalog(); - saveCachedCatalog(catalog); - registerDevinProvider(_pi, modelsFromCatalog(catalog)); + await refreshCatalog(_pi); } catch { // keep current models } @@ -90,47 +94,32 @@ function registerDevinProvider(pi: ExtensionAPI, models: ProviderModelConfig[]): export default async function (pi: ExtensionAPI): Promise { _pi = pi; - - // Synchronously load cached catalog so subagent processes have the full - // model list immediately (they may not live long enough for the async - // refresh to complete). - const cached = loadCachedCatalog(); - const initialModels = cached - ? modelsFromCatalog(cached as Parameters[0]) - : FALLBACK_MODELS; + let cached: CachedDevinCatalog | null | undefined; + let initialModels = FALLBACK_MODELS; + try { + cached = readCatalogCache(); + if (cached) initialModels = modelsFromCatalog(cached.catalog); + } catch (error) { + cached = null; + console.warn(`Devin: failed to read model catalog cache: ${error instanceof Error ? error.message : String(error)}`); + } registerDevinProvider(pi, initialModels); - try { - if (readCredentials()) { - // Refresh in background without blocking startup - loadCliCatalog().then((catalog) => { - if (catalog) { - saveCachedCatalog(catalog); - if (_pi) registerDevinProvider(_pi, modelsFromCatalog(catalog)); - } - }).catch(() => {}); - } - } catch { - // fallback models already registered - } - - pi.on("session_start", async () => { + if (!isOffline()) { try { - if (!_pi || !readCredentials()) return; - const cached = loadCachedCatalog(); - if (cached) { - registerDevinProvider(_pi, modelsFromCatalog(cached as Parameters[0])); - } - loadCliCatalog().then((catalog) => { - if (catalog) { - saveCachedCatalog(catalog); - if (_pi) registerDevinProvider(_pi, modelsFromCatalog(catalog)); + if (readCredentials()) { + if (!cached) { + await refreshCatalog(pi); + } else if (!isCatalogCacheFresh(cached)) { + void refreshCatalog(pi).catch((error) => { + console.warn(`Devin: background model catalog refresh failed: ${error instanceof Error ? error.message : String(error)}`); + }); } - }).catch(() => {}); + } } catch { - // keep current models + // Cached or fallback models stay registered when credential discovery or a cold refresh fails. } - }); + } pi.registerCommand("devin-status", { description: "Show Devin CLI auth + binary status", @@ -154,10 +143,7 @@ export default async function (pi: ExtensionAPI): Promise { description: "Refresh Devin Local model catalog from `devin models list`", handler: async (_args, ctx) => { try { - const catalog = await loadCliCatalog(); - saveCachedCatalog(catalog); - const models = modelsFromCatalog(catalog); - registerDevinProvider(pi, models); + const models = await refreshCatalog(pi); ctx.ui.notify(`Devin: loaded ${models.length} families from the local CLI.`, "info"); } catch (error) { ctx.ui.notify( @@ -169,6 +155,6 @@ export default async function (pi: ExtensionAPI): Promise { }); pi.on("session_shutdown", async () => { - _pi = null; + if (_pi === pi) _pi = null; }); } diff --git a/package.json b/package.json index f108db7..51be419 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-devin", - "version": "0.1.2", + "version": "0.2.1", "description": "Pi provider for Devin Local models. Auth and catalog come from the local Devin CLI; Pi stays the harness.", "type": "module", "keywords": [ @@ -28,18 +28,16 @@ "AGENTS.md", "LICENSE" ], - "scripts": { - "typecheck": "tsc --noEmit" - }, "peerDependencies": { - "@earendil-works/pi-ai": "*", - "@earendil-works/pi-coding-agent": "*" + "@earendil-works/pi-ai": ">=0.86.0", + "@earendil-works/pi-coding-agent": ">=0.86.0" }, "devDependencies": { "@types/node": "^24.13.3", + "tsx": "^4.23.15", "typescript": "^5.9.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.19.0" } } diff --git a/src/catalog-cache.ts b/src/catalog-cache.ts new file mode 100644 index 0000000..2d3fd72 --- /dev/null +++ b/src/catalog-cache.ts @@ -0,0 +1,104 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import type { DevinCatalog } from "./models.js"; + +const CACHE_VERSION = 1; + +export const CATALOG_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000; + +export interface CachedDevinCatalog { + catalog: DevinCatalog; + fetchedAt: number; +} + +interface CatalogCacheFile extends CachedDevinCatalog { + version: typeof CACHE_VERSION; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function optionalString(value: unknown): boolean { + return value === undefined || typeof value === "string"; +} + +function optionalTokenLimit(value: unknown): boolean { + return value === undefined || (typeof value === "number" && Number.isSafeInteger(value) && value > 0); +} + +export function isUsableCatalog(value: unknown): value is DevinCatalog { + if (!isRecord(value) || !Array.isArray(value.families) || value.families.length === 0) return false; + return value.families.some((family) => isRecord(family) && Array.isArray(family.variants) && family.variants.length > 0) + && value.families.every((family) => + isRecord(family) + && typeof family.family_label === "string" + && typeof family.family_uid === "string" + && typeof family.slug === "string" + && (family.aliases === undefined || (Array.isArray(family.aliases) && family.aliases.every((alias) => typeof alias === "string"))) + && Array.isArray(family.variants) + && family.variants.every((variant) => + isRecord(variant) + && typeof variant.model_uid === "string" + && variant.model_uid.trim().length > 0 + && typeof variant.label === "string" + && optionalString(variant.cost_summary) + && optionalString(variant.cost_tier) + && optionalTokenLimit(variant.max_context_tokens) + && optionalTokenLimit(variant.max_output_tokens) + && (variant.is_new === undefined || typeof variant.is_new === "boolean") + && (variant.is_beta === undefined || typeof variant.is_beta === "boolean") + ) + ); +} + +export function catalogCachePath( + env: NodeJS.ProcessEnv = process.env, + home: string = homedir(), +): string { + const cacheRoot = env.XDG_CACHE_HOME || join(home, ".cache"); + return join(cacheRoot, "pi-devin", "models.json"); +} + +export function readCatalogCache(path: string = catalogCachePath()): CachedDevinCatalog | null { + if (!existsSync(path)) return null; + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if ( + !isRecord(parsed) + || parsed.version !== CACHE_VERSION + || typeof parsed.fetchedAt !== "number" + || !Number.isFinite(parsed.fetchedAt) + || !isUsableCatalog(parsed.catalog) + ) { + throw new Error(`Invalid Devin model catalog cache: ${path}`); + } + return { catalog: parsed.catalog, fetchedAt: parsed.fetchedAt }; +} + +export function writeCatalogCache( + catalog: DevinCatalog, + path: string = catalogCachePath(), + fetchedAt: number = Date.now(), +): void { + if (!isUsableCatalog(catalog)) throw new Error("Refusing to cache an invalid Devin model catalog"); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const temporaryPath = `${path}.${randomUUID()}.tmp`; + const body: CatalogCacheFile = { version: CACHE_VERSION, fetchedAt, catalog }; + try { + writeFileSync(temporaryPath, `${JSON.stringify(body)}\n`, { mode: 0o600 }); + renameSync(temporaryPath, path); + } finally { + if (existsSync(temporaryPath)) unlinkSync(temporaryPath); + } +} + +export function isCatalogCacheFresh( + cached: CachedDevinCatalog, + now: number = Date.now(), + maxAgeMs: number = CATALOG_CACHE_MAX_AGE_MS, +): boolean { + const age = now - cached.fetchedAt; + return age >= 0 && age <= maxAgeMs; +} diff --git a/src/cli.ts b/src/cli.ts index f8627ba..df448ff 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { statSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { execFile, spawn } from "node:child_process"; @@ -6,21 +6,50 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); -const KNOWN_BINS = [ - process.env.DEVIN_CLI, - join(homedir(), ".local/bin/devin"), - join(homedir(), ".devin/bin/devin"), - "/opt/homebrew/bin/devin", - "/usr/local/bin/devin", - "/Applications/Devin.app/Contents/Resources/app/extensions/windsurf/devin/bin/devin", -].filter((p): p is string => Boolean(p)); +function candidateBins(): string[] { + const localAppData = process.env.LOCALAPPDATA ?? join(homedir(), "AppData/Local"); + return [ + // Explicit override always wins. Read lazily so a value set after module + // load (e.g. by the host process) is still honoured. + process.env.DEVIN_CLI, + ...(process.platform === "win32" ? [ + // Official installer location (%LOCALAPPDATA%\devin\cli\bin\devin.exe) + join(localAppData, "devin/cli/bin/devin.exe"), + // Devin Desktop (Windsurf-based) bundled CLI, per-user install + join(localAppData, "Programs/Devin/resources/app/extensions/windsurf/devin/bin/devin.exe"), + // Devin Desktop bundled CLI, machine-wide install + process.env.ProgramFiles && + join(process.env.ProgramFiles, "Devin/resources/app/extensions/windsurf/devin/bin/devin.exe"), + // Set by the official Windows installer when it completes normally. + process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, "devin/bin/devin.exe"), + // Manual fallback for installs that could not finish while Devin was running. + join(homedir(), ".devin-cli/devin.exe"), + ] : [ + join(homedir(), ".local/bin/devin"), + join(homedir(), ".devin/bin/devin"), + "/opt/homebrew/bin/devin", + "/usr/local/bin/devin", + "/Applications/Devin.app/Contents/Resources/app/extensions/windsurf/devin/bin/devin", + ]), + ].filter((p): p is string => Boolean(p)); +} let cachedBin: string | null | undefined; +function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + export function findDevinBin(): string | null { + // An explicit override must take effect even after a successful discovery. + if (process.env.DEVIN_CLI && isFile(process.env.DEVIN_CLI)) return process.env.DEVIN_CLI; if (cachedBin !== undefined) return cachedBin; - for (const bin of KNOWN_BINS) { - if (existsSync(bin)) { + for (const bin of candidateBins()) { + if (isFile(bin)) { cachedBin = bin; return bin; } @@ -36,15 +65,24 @@ export function clearDevinBinCache(): void { export async function whichDevin(): Promise { const known = findDevinBin(); if (known) return known; - try { - const { stdout } = await execFileAsync("/usr/bin/which", ["devin"], { timeout: 5_000 }); - const path = stdout.trim(); - if (path && existsSync(path)) { - cachedBin = path; - return path; + // Windows has no `which`; use `where` and probe the returned paths directly. + const isWindows = process.platform === "win32"; + const locator = isWindows ? "where.exe" : "/usr/bin/which"; + const candidates = isWindows ? ["devin.exe", "devin.cmd", "devin"] : ["devin"]; + for (const candidate of candidates) { + try { + const { stdout } = await execFileAsync(locator, [candidate], { timeout: 5_000 }); + // `where` may return several lines; take the first existing one. + for (const line of stdout.split(/\r?\n/)) { + const path = line.trim(); + if (path && isFile(path)) { + cachedBin = path; + return path; + } + } + } catch { + // not on PATH } - } catch { - // not on PATH } return null; } @@ -60,9 +98,18 @@ export async function runDevin( ); } + // Batch wrappers need cmd.exe. Quote paths with spaces, and reject shell + // expansion characters rather than interpreting them as part of a command. + const wrapper = process.platform === "win32" && /\.(cmd|bat)$/i.test(bin); + if (wrapper && [bin, ...args].some((part) => /["%!\r\n]/.test(part))) { + throw new Error("Unsupported shell characters in Devin wrapper path or arguments. Set DEVIN_CLI to devin.exe."); + } + const executable = wrapper ? process.env.ComSpec || "cmd.exe" : bin; + const commandArgs = wrapper ? ["/d", "/s", "/c", `"${[bin, ...args].map((part) => `"${part}"`).join(" ")}"`] : args; + if (opts.inheritStdio) { return await new Promise((resolve, reject) => { - const child = spawn(bin, args, { stdio: "inherit" }); + const child = spawn(executable, commandArgs, { stdio: "inherit", windowsVerbatimArguments: wrapper }); child.on("error", reject); child.on("close", (code) => { resolve({ stdout: "", stderr: "", code: code ?? 1 }); @@ -71,9 +118,10 @@ export async function runDevin( } try { - const { stdout, stderr } = await execFileAsync(bin, args, { + const { stdout, stderr } = await execFileAsync(executable, commandArgs, { timeout: opts.timeoutMs ?? 30_000, maxBuffer: 8 * 1024 * 1024, + windowsVerbatimArguments: wrapper, }); return { stdout, stderr, code: 0 }; } catch (error) { diff --git a/src/context-estimate.ts b/src/context-estimate.ts index 0532384..064030b 100644 --- a/src/context-estimate.ts +++ b/src/context-estimate.ts @@ -1,7 +1,7 @@ -import type { Api, Context, Message, Model, Usage } from "@earendil-works/pi-ai"; +import { getSystemMessageText, type Api, type TranscriptContext, type Message, type Model, type Usage } from "@earendil-works/pi-ai"; /** - * Local port of pi-ai's `utils/estimate.js` + `clampMaxTokensToContext`. + * Local port of pi-ai 0.87.1's `utils/estimate.js` + `clampMaxTokensToContext`. * Extensions can only resolve the pi-ai compat entry, so the upstream helpers * are reproduced here to keep the maxTokens context clamp identical. */ @@ -38,6 +38,10 @@ function estimateTextAndImageContentTokens(content: Message["content"]): number } function estimateMessageTokens(message: Message): number { + if (message.role === "system") { + return estimateTextTokens(getSystemMessageText(message)) + + estimateToolsTokens(message.toolsAdded) + estimateToolsTokens(message.toolsRemoved); + } if (message.role === "user" || message.role === "toolResult") { return estimateTextAndImageContentTokens(message.content); } @@ -98,39 +102,16 @@ function estimateMessages(messages: Message[]): ContextEstimate { return { tokens, usageTokens: 0, trailingTokens: tokens, lastUsageIndex: null }; } -function estimateToolsTokens(tools: Context["tools"]): number { +function estimateToolsTokens(tools: readonly unknown[] | undefined): number { if (!tools || tools.length === 0) return 0; return estimateTextTokens(safeJsonStringify(tools)); } -export function estimateContextTokens(context: Context): ContextEstimate { - const estimate = estimateMessages(context.messages); - if (estimate.lastUsageIndex !== null) { - const addedNames = new Set( - context.messages - .slice(estimate.lastUsageIndex + 1) - .filter((message) => message.role === "toolResult") - .flatMap((message) => (message.role === "toolResult" ? (message.addedToolNames ?? []) : [])), - ); - const addedToolTokens = estimateToolsTokens(context.tools?.filter((tool) => addedNames.has(tool.name))); - return { - tokens: estimate.tokens + addedToolTokens, - usageTokens: estimate.usageTokens, - trailingTokens: estimate.trailingTokens + addedToolTokens, - lastUsageIndex: estimate.lastUsageIndex, - }; - } - const prefixTokens = - (context.systemPrompt ? estimateTextTokens(context.systemPrompt) : 0) + estimateToolsTokens(context.tools); - return { - tokens: estimate.tokens + prefixTokens, - usageTokens: estimate.usageTokens, - trailingTokens: estimate.trailingTokens + prefixTokens, - lastUsageIndex: estimate.lastUsageIndex, - }; +export function estimateContextTokens(context: TranscriptContext): ContextEstimate { + return estimateMessages(context.messages); } -export function clampMaxTokensToContext(model: Model, context: Context, maxTokens: number): number { +export function clampMaxTokensToContext(model: Model, context: TranscriptContext, maxTokens: number): number { if (model.contextWindow <= 0) return Math.max(MIN_MAX_TOKENS, maxTokens); const available = model.contextWindow - estimateContextTokens(context).tokens - CONTEXT_SAFETY_TOKENS; return Math.min(maxTokens, Math.max(MIN_MAX_TOKENS, available)); diff --git a/src/context-map.ts b/src/context-map.ts index 137c480..955c487 100644 --- a/src/context-map.ts +++ b/src/context-map.ts @@ -1,4 +1,7 @@ -import type { Api, Context, Message, Model, Tool } from "@earendil-works/pi-ai"; +import { + collapseSystemMessages, getCurrentSystemPrompt, getCurrentTools, withoutInitialSystemMessage, + type Api, type TranscriptContext, type Message, type Model, type Tool, +} from "@earendil-works/pi-ai"; export interface ContentPart { type: "text" | "image"; @@ -45,10 +48,11 @@ function contentParts(content: Message["content"] | string): ContentPart[] { return parts; } -export function mapContextToChat(context: Context, model: Model): MappedChat { +export function mapContextToChat(context: TranscriptContext, model: Model): MappedChat { + const transcript = collapseSystemMessages(context); const messages: ChatHistoryItem[] = []; - for (const message of context.messages) { + for (const message of withoutInitialSystemMessage(transcript.messages)) { if (message.role === "user") { const content = contentParts(message.content); if (content.length === 0) continue; @@ -106,15 +110,13 @@ export function mapContextToChat(context: Context, model: Model): MappedCha } } - const tools: ToolDef[] = (context.tools ?? []).map((tool: Tool) => ({ + const tools: ToolDef[] = getCurrentTools(transcript.messages).map((tool: Tool) => ({ name: tool.name, description: tool.description, parameters: tool.parameters, })); - const systemPrompt = Array.isArray(context.systemPrompt) - ? context.systemPrompt.join("\n\n") - : context.systemPrompt; + const systemPrompt = getCurrentSystemPrompt(transcript.messages) || undefined; return { systemPrompt, messages, tools }; } diff --git a/src/stream.ts b/src/stream.ts index e78a232..8f73316 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -4,7 +4,7 @@ import { type Api, type AssistantMessage, type AssistantMessageEventStream, - type Context, + type TranscriptContext, type Model, type SimpleStreamOptions, calculateCost, @@ -400,11 +400,8 @@ async function* streamChatEvents(args: { // ignore } try { - // cancel() on an already-errored stream rejects with the stored - // error (e.g. undici TypeError("terminated") after the server drops - // the socket). try/catch cannot observe that rejection, so swallow - // it explicitly or it escapes as an uncaughtException and kills pi. - void resp.body?.cancel()?.catch(() => {}); + // Await teardown so a rejection from an errored stream is observed. + await resp.body?.cancel(); } catch { // ignore } @@ -432,7 +429,7 @@ async function* streamChatEvents(args: { export function streamDevin( model: Model, - context: Context, + context: TranscriptContext, options?: SimpleStreamOptions, ): AssistantMessageEventStream { const stream = createAssistantMessageEventStream();