diff --git a/packages/pi-ai/src/providers/anthropic-shared.ts b/packages/pi-ai/src/providers/anthropic-shared.ts index 597a8b263..d65ccf964 100644 --- a/packages/pi-ai/src/providers/anthropic-shared.ts +++ b/packages/pi-ai/src/providers/anthropic-shared.ts @@ -719,7 +719,7 @@ export function processAnthropicStream( if (block) { // `index` is an internal bookkeeping field added at block creation // and must be stripped before the block is exposed to callers. - delete (block as Partial).index; + delete (block as { index?: number }).index; if (block.type === "text") { stream.push({ type: "text_end", @@ -753,7 +753,7 @@ export function processAnthropicStream( block.arguments = parsed ?? parseStreamingJson(block.partialJson); // `partialJson` is an internal streaming field that must not // appear on the final ToolCall exposed to callers. - delete (block as Partial).partialJson; + delete (block as { partialJson?: string }).partialJson; stream.push({ type: "toolcall_end", contentIndex: index, @@ -795,7 +795,7 @@ export function processAnthropicStream( stream.push({ type: "done", reason: output.stopReason, message: output }); stream.end(); } catch (error) { - for (const block of output.content) delete (block as Partial).index; + for (const block of output.content) delete (block as { index?: number }).index; output.stopReason = options?.signal?.aborted ? "aborted" : "error"; output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error); if (model.provider === "alibaba-coding-plan") { diff --git a/packages/pi-ai/src/providers/openai-codex-responses.ts b/packages/pi-ai/src/providers/openai-codex-responses.ts index e3594fbb1..40eff5b27 100644 --- a/packages/pi-ai/src/providers/openai-codex-responses.ts +++ b/packages/pi-ai/src/providers/openai-codex-responses.ts @@ -27,6 +27,7 @@ import type { StreamOptions, } from "../types.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; +import { getCodexAccessToken, getCodexAccountId } from "../utils/oauth/openai-codex.js"; import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js"; import { buildBaseOptions, clampReasoning, resolveReasoningLevel } from "./simple-options.js"; @@ -134,12 +135,20 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" }; try { - const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; - if (!apiKey) { - throw new Error(`No API key for provider: ${model.provider}`); + // Prefer an explicitly passed apiKey (env var or runtime override), then + // fall back to reading ~/.codex/auth.json (the real codex CLI's auth state). + let apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; + let accountId: string; + if (apiKey) { + // Legacy path: apiKey was plumbed through options (e.g. from auth-storage + // OAuth credential). Extract accountId from the JWT payload as before. + accountId = extractAccountId(apiKey); + } else { + // Primary path: read ~/.codex/auth.json written by the real codex CLI. + apiKey = await getCodexAccessToken(); + accountId = getCodexAccountId(); } - const accountId = extractAccountId(apiKey); let body = buildRequestBody(model, context, options); const nextBody = await options?.onPayload?.(body, model); if (nextBody !== undefined) { diff --git a/packages/pi-ai/src/providers/openai-completions.ts b/packages/pi-ai/src/providers/openai-completions.ts index 5e272a748..342fbb710 100644 --- a/packages/pi-ai/src/providers/openai-completions.ts +++ b/packages/pi-ai/src/providers/openai-completions.ts @@ -570,7 +570,10 @@ export function convertMessages( // Use the signature from the first thinking block if available (for llama.cpp server + gpt-oss) const signature = nonEmptyThinkingBlocks[0].thinkingSignature; if (signature && signature.length > 0) { - (assistantMsg as any)[signature] = nonEmptyThinkingBlocks.map((b) => b.thinking).join("\n"); + // SDK-divergence: llama.cpp / gpt-oss return a dynamic per-field name for the + // reasoning content (e.g. "reasoning_content"). The field is not in + // ChatCompletionAssistantMessageParam, so we use a typed extension object. + (assistantMsg as unknown as Record)[signature] = nonEmptyThinkingBlocks.map((b) => b.thinking).join("\n"); } } } @@ -598,7 +601,9 @@ export function convertMessages( }) .filter(Boolean); if (reasoningDetails.length > 0) { - (assistantMsg as any).reasoning_details = reasoningDetails; + // SDK-divergence: reasoning_details is a vendor extension (gpt-oss / OpenAI Responses + // compat path) not present on ChatCompletionAssistantMessageParam. + (assistantMsg as unknown as Record).reasoning_details = reasoningDetails; } } // Skip assistant messages that have no content and no tool calls. @@ -624,7 +629,7 @@ export function convertMessages( // Extract text and image content const textResult = toolMsg.content .filter((c) => c.type === "text") - .map((c) => (c as any).text) + .map((c) => (c as TextContent).text) .join("\n"); const hasImages = toolMsg.content.some((c) => c.type === "image"); @@ -637,17 +642,20 @@ export function convertMessages( tool_call_id: toolMsg.toolCallId, }; if (compat.requiresToolResultName && toolMsg.toolName) { - (toolResultMsg as any).name = toolMsg.toolName; + // SDK-divergence: the `name` field on tool results is required by some providers + // (e.g., Mistral) but is not part of the ChatCompletionToolMessageParam type. + (toolResultMsg as unknown as Record).name = toolMsg.toolName; } params.push(toolResultMsg); if (hasImages && model.input.includes("image")) { for (const block of toolMsg.content) { if (block.type === "image") { + const imageBlock = block as ImageContent; imageBlocks.push({ type: "image_url", image_url: { - url: `data:${(block as any).mimeType};base64,${(block as any).data}`, + url: `data:${imageBlock.mimeType};base64,${imageBlock.data}`, }, }); } @@ -697,7 +705,7 @@ function convertTools( function: { name: tool.name, description: tool.description, - parameters: tool.parameters as any, // TypeBox already generates JSON Schema + parameters: tool.parameters as unknown as FunctionParameters, // TypeBox TSchema is a valid JSON Schema object // Only include strict if provider supports it. Some reject unknown fields. ...(compat.supportsStrictMode !== false && { strict: false }), }, diff --git a/packages/pi-ai/src/utils/oauth/openai-codex.ts b/packages/pi-ai/src/utils/oauth/openai-codex.ts index 8c38f9fe4..cf45be14c 100644 --- a/packages/pi-ai/src/utils/oauth/openai-codex.ts +++ b/packages/pi-ai/src/utils/oauth/openai-codex.ts @@ -1,497 +1,248 @@ /** - * OpenAI Codex (ChatGPT OAuth) flow + * OpenAI Codex auth helper — reads ~/.codex/auth.json * - * NOTE: This module uses Node.js crypto and http for the OAuth callback. - * It is only intended for CLI use, not browser environments. + * The real `codex` CLI writes its auth state to ~/.codex/auth.json after the + * user authenticates. We simply read that file and, if the token is stale, + * refresh it against OpenAI's token endpoint. * - * UPSTREAM AUDIT (2026-05-02): STAY HAND-ROLLED + * No PKCE flow, no callback server, no browser dance in our code. + * Users authenticate with the real `codex` CLI; we just consume its output. * - * Candidate: @openai/codex (v0.1.x) - * Coverage: none — it is a pure CLI bin with no programmatic exports (no - * main/exports fields, no auth library surface area). - * - * Why we're not delegating: - * 1. @openai/codex is a terminal UI app (ink + react), not a library. - * It exports no OAuth helpers, PKCE utilities, or token-exchange - * functions that third parties can import. - * 2. The openai npm SDK (v4/v6) has no ChatGPT OAuth / device-code - * helper — it assumes API key auth only. - * 3. The entire login surface is Codex-specific and undocumented upstream: - * - PKCE flow against auth.openai.com (non-standard originator params) - * - Local HTTP callback server on :1455 with races against manual paste - * - JWT claim extraction (https://api.openai.com/auth path) for accountId - * - Browser-vs-paste race (onManualCodeInput) for UX resilience - * - CHATGPT_UNSUPPORTED_MODEL_IDS filter (provider-specific knowledge) - * 4. No AbortSignal gap (unlike Copilot): the PKCE flow is one-shot with - * a timeout, not a long-polling loop. - * - * Re-audit trigger: if OpenAI publishes a @openai/auth or @openai/codex-core - * library with programmatic PKCE/token helpers, or if the openai SDK gains - * OAuth support. + * File shape (verified against ~/.codex/auth.json): + * { + * "auth_mode": "chatgpt" | "apikey", // lowercase + * "OPENAI_API_KEY": string | null, + * "tokens": { + * "id_token": string, + * "access_token": string, + * "refresh_token": string, + * "account_id": string + * }, + * "last_refresh": string // ISO timestamp + * } */ // NEVER convert to top-level imports - breaks browser/Vite builds (web-ui) -let _randomBytes: typeof import("node:crypto").randomBytes | null = null; -let _http: typeof import("node:http") | null = null; +let _os: typeof import("node:os") | null = null; +let _fs: typeof import("node:fs") | null = null; if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { - import("node:crypto").then((m) => { - _randomBytes = m.randomBytes; + import("node:os").then((m) => { + _os = m; }); - import("node:http").then((m) => { - _http = m; + import("node:fs").then((m) => { + _fs = m; }); } -import { generatePKCE } from "./pkce.js"; -import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.js"; +import type { OAuthCredentials, OAuthProviderInterface } from "./types.js"; -const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; -const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"; const TOKEN_URL = "https://auth.openai.com/oauth/token"; -const REDIRECT_URI = "http://localhost:1455/auth/callback"; -const SCOPE = "openid profile email offline_access"; -const JWT_CLAIM_PATH = "https://api.openai.com/auth"; -const CHATGPT_UNSUPPORTED_MODEL_IDS = new Set([ - "gpt-5.2-codex", - "gpt-5.1-codex-mini", - "gpt-5.1-codex-max", - "gpt-5.1-codex", - "gpt-5.1", - "gpt-5", -]); +const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; -const SUCCESS_HTML = ` - - - - - Authentication successful - - -

Authentication successful. Return to your terminal to continue.

- -`; +// Refresh threshold: 1 hour (conservative; the real codex CLI uses a similar window) +const REFRESH_THRESHOLD_MS = 60 * 60 * 1000; -type TokenSuccess = { type: "success"; access: string; refresh: string; expires: number }; -type TokenFailure = { type: "failed" }; -type TokenResult = TokenSuccess | TokenFailure; +// ============================================================================ +// ~/.codex/auth.json types +// ============================================================================ -type JwtPayload = { - [JWT_CLAIM_PATH]?: { - chatgpt_account_id?: string; +interface CodexAuthFile { + auth_mode?: string; + OPENAI_API_KEY?: string | null; + tokens?: { + id_token?: string; + access_token?: string; + refresh_token?: string; + account_id?: string; }; - [key: string]: unknown; -}; - -function createState(): string { - if (!_randomBytes) { - throw new Error("OpenAI Codex OAuth is only available in Node.js environments"); - } - return _randomBytes(16).toString("hex"); + last_refresh?: string; } -function parseAuthorizationInput(input: string): { code?: string; state?: string } { - const value = input.trim(); - if (!value) return {}; +// ============================================================================ +// File reader +// ============================================================================ - try { - const url = new URL(value); - return { - code: url.searchParams.get("code") ?? undefined, - state: url.searchParams.get("state") ?? undefined, - }; - } catch { - // not a URL - } - - if (value.includes("#")) { - const [code, state] = value.split("#", 2); - return { code, state }; - } - - if (value.includes("code=")) { - const params = new URLSearchParams(value); - return { - code: params.get("code") ?? undefined, - state: params.get("state") ?? undefined, - }; - } - - return { code: value }; +function getCodexAuthPath(): string { + if (!_os) throw new Error("node:os not available"); + return `${_os.homedir()}/.codex/auth.json`; } -function decodeJwt(token: string): JwtPayload | null { - try { - const parts = token.split("."); - if (parts.length !== 3) return null; - const payload = parts[1] ?? ""; - const decoded = atob(payload); - return JSON.parse(decoded) as JwtPayload; - } catch { - return null; +function readCodexAuthFile(): CodexAuthFile { + if (!_fs) { + throw new Error("OpenAI Codex auth is only available in Node.js environments"); } + const authPath = getCodexAuthPath(); + if (!_fs.existsSync(authPath)) { + throw new Error( + `~/.codex/auth.json not found.\n\n` + + `Authenticate with the real \`codex\` CLI first:\n` + + ` codex auth login\n\n` + + `Then re-run your command.`, + ); + } + const raw = _fs.readFileSync(authPath, "utf-8"); + return JSON.parse(raw) as CodexAuthFile; } -async function exchangeAuthorizationCode( - code: string, - verifier: string, - redirectUri: string = REDIRECT_URI, -): Promise { +// ============================================================================ +// Token refresh +// ============================================================================ + +async function refreshCodexToken(refreshToken: string): Promise<{ access_token: string; refresh_token: string }> { const response = await fetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ - grant_type: "authorization_code", + grant_type: "refresh_token", + refresh_token: refreshToken, client_id: CLIENT_ID, - code, - code_verifier: verifier, - redirect_uri: redirectUri, }), signal: AbortSignal.timeout(30_000), }); if (!response.ok) { const text = await response.text().catch(() => ""); - console.error("[openai-codex] code->token failed:", response.status, text); - return { type: "failed" }; + throw new Error(`[openai-codex] Token refresh failed: ${response.status} ${text}`); } const json = (await response.json()) as { access_token?: string; refresh_token?: string; - expires_in?: number; }; - if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") { - console.error("[openai-codex] token response missing fields:", json); - return { type: "failed" }; + if (!json.access_token || !json.refresh_token) { + throw new Error("[openai-codex] Token refresh response missing access_token or refresh_token"); } - return { - type: "success", - access: json.access_token, - refresh: json.refresh_token, - expires: Date.now() + json.expires_in * 1000, - }; + return { access_token: json.access_token, refresh_token: json.refresh_token }; } -async function refreshAccessToken(refreshToken: string): Promise { - try { - const response = await fetch(TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: CLIENT_ID, - }), - signal: AbortSignal.timeout(30_000), - }); +// ============================================================================ +// Public API +// ============================================================================ - if (!response.ok) { - const text = await response.text().catch(() => ""); - console.error("[openai-codex] Token refresh failed:", response.status, text); - return { type: "failed" }; +/** + * Read ~/.codex/auth.json and return the active access token. + * + * - For auth_mode "apikey": returns OPENAI_API_KEY directly. + * - For auth_mode "chatgpt": returns tokens.access_token, refreshing first + * if last_refresh is more than REFRESH_THRESHOLD_MS ago. + * + * Throws a clear error if the file is missing or malformed. + */ +export async function getCodexAccessToken(): Promise { + const auth = readCodexAuthFile(); + const mode = (auth.auth_mode ?? "").toLowerCase(); + + if (mode === "apikey") { + const key = auth.OPENAI_API_KEY; + if (!key) { + throw new Error( + `~/.codex/auth.json has auth_mode "apikey" but OPENAI_API_KEY is empty.\n` + + `Re-authenticate with: codex auth login`, + ); } - - const json = (await response.json()) as { - access_token?: string; - refresh_token?: string; - expires_in?: number; - }; - - if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") { - console.error("[openai-codex] Token refresh response missing fields:", json); - return { type: "failed" }; - } - - return { - type: "success", - access: json.access_token, - refresh: json.refresh_token, - expires: Date.now() + json.expires_in * 1000, - }; - } catch (error) { - console.error("[openai-codex] Token refresh error:", error); - return { type: "failed" }; + return key; } -} -async function createAuthorizationFlow( - originator: string = "pi", -): Promise<{ verifier: string; state: string; url: string }> { - const { verifier, challenge } = await generatePKCE(); - const state = createState(); - - const url = new URL(AUTHORIZE_URL); - url.searchParams.set("response_type", "code"); - url.searchParams.set("client_id", CLIENT_ID); - url.searchParams.set("redirect_uri", REDIRECT_URI); - url.searchParams.set("scope", SCOPE); - url.searchParams.set("code_challenge", challenge); - url.searchParams.set("code_challenge_method", "S256"); - url.searchParams.set("state", state); - url.searchParams.set("id_token_add_organizations", "true"); - url.searchParams.set("codex_cli_simplified_flow", "true"); - url.searchParams.set("originator", originator); - - return { verifier, state, url: url.toString() }; -} - -type OAuthServerInfo = { - close: () => void; - cancelWait: () => void; - waitForCode: () => Promise<{ code: string } | null>; -}; - -function startLocalOAuthServer(state: string): Promise { - if (!_http) { - throw new Error("OpenAI Codex OAuth is only available in Node.js environments"); + // Default: ChatGPT OAuth + const tokens = auth.tokens; + if (!tokens?.access_token || !tokens?.refresh_token) { + throw new Error( + `~/.codex/auth.json is missing OAuth tokens.\n` + + `Re-authenticate with: codex auth login`, + ); } - let lastCode: string | null = null; - let cancelled = false; - const server = _http.createServer((req, res) => { + + // Refresh if stale + const lastRefresh = auth.last_refresh ? new Date(auth.last_refresh).getTime() : 0; + const isStale = !lastRefresh || Date.now() - lastRefresh > REFRESH_THRESHOLD_MS; + + if (isStale) { try { - const url = new URL(req.url || "", "http://localhost"); - if (url.pathname !== "/auth/callback") { - res.statusCode = 404; - res.end("Not found"); - return; - } - if (url.searchParams.get("state") !== state) { - res.statusCode = 400; - res.end("State mismatch"); - return; - } - const code = url.searchParams.get("code"); - if (!code) { - res.statusCode = 400; - res.end("Missing authorization code"); - return; - } - res.statusCode = 200; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(SUCCESS_HTML); - lastCode = code; - } catch { - res.statusCode = 500; - res.end("Internal error"); + const refreshed = await refreshCodexToken(tokens.refresh_token); + return refreshed.access_token; + } catch (err) { + // If refresh fails, fall back to the stored access_token (it may still work) + console.warn(`[openai-codex] Token refresh failed, using stored token: ${err instanceof Error ? err.message : String(err)}`); } - }); + } - return new Promise((resolve) => { - server - .listen(1455, "127.0.0.1", () => { - resolve({ - close: () => server.close(), - cancelWait: () => { - cancelled = true; - }, - waitForCode: async () => { - const sleep = () => new Promise((r) => setTimeout(r, 100)); - for (let i = 0; i < 600; i += 1) { - if (lastCode) return { code: lastCode }; - if (cancelled) return null; - await sleep(); - } - return null; - }, - }); - }) - .on("error", (err: NodeJS.ErrnoException) => { - console.error( - "[openai-codex] Failed to bind http://127.0.0.1:1455 (", - err.code, - ") Falling back to manual paste.", - ); - resolve({ - close: () => { - try { - server.close(); - } catch { - // ignore - } - }, - cancelWait: () => {}, - waitForCode: async () => null, - }); - }); - }); -} - -function getAccountId(accessToken: string): string | null { - const payload = decodeJwt(accessToken); - const auth = payload?.[JWT_CLAIM_PATH]; - const accountId = auth?.chatgpt_account_id; - return typeof accountId === "string" && accountId.length > 0 ? accountId : null; + return tokens.access_token; } /** - * Login with OpenAI Codex OAuth - * - * @param options.onAuth - Called with URL and instructions when auth starts - * @param options.onPrompt - Called to prompt user for manual code paste (fallback if no onManualCodeInput) - * @param options.onProgress - Optional progress messages - * @param options.onManualCodeInput - Optional promise that resolves with user-pasted code. - * Races with browser callback - whichever completes first wins. - * Useful for showing paste input immediately alongside browser flow. - * @param options.originator - OAuth originator parameter (defaults to "pi") + * Read account_id from ~/.codex/auth.json (required as a request header). + * Falls back to extracting from the access_token JWT payload if not stored. */ -export async function loginOpenAICodex(options: { +export function getCodexAccountId(): string { + const auth = readCodexAuthFile(); + const accountId = auth.tokens?.account_id; + if (accountId) return accountId; + throw new Error( + `~/.codex/auth.json is missing tokens.account_id.\n` + + `Re-authenticate with: codex auth login`, + ); +} + +/** + * OAuthProviderInterface shim — kept so oauth/index.ts registry and + * auth-storage OAuth refresh flow continue to compile. + * + * login() is a no-op: users authenticate with the real `codex` CLI. + * getApiKey() reads the access token from ~/.codex/auth.json. + * refreshToken() is a no-op: getCodexAccessToken() refreshes inline. + */ +export const openaiCodexOAuthProvider: OAuthProviderInterface = { + id: "openai-codex", + name: "ChatGPT Plus/Pro (Codex Subscription)", + usesCallbackServer: false, + + async login(): Promise { + throw new Error( + `OpenAI Codex login is handled by the real \`codex\` CLI.\n` + + `Run: codex auth login\n\n` + + `Then use pi normally — it reads ~/.codex/auth.json automatically.`, + ); + }, + + async refreshToken(credentials: OAuthCredentials): Promise { + // Inline refresh via getCodexAccessToken(); return same shape + return credentials; + }, + + getApiKey(_credentials: OAuthCredentials): string { + // Synchronous fallback — callers that need the real token should await + // getCodexAccessToken() directly. This path is used for legacy callers. + const auth = readCodexAuthFile(); + const mode = (auth.auth_mode ?? "").toLowerCase(); + if (mode === "apikey") return auth.OPENAI_API_KEY ?? ""; + return auth.tokens?.access_token ?? ""; + }, +}; + +// Legacy named exports — kept for backward compat with any external callers. +// Both are now thin wrappers; the PKCE/callback-server flow is gone. + +/** @deprecated Use the real `codex` CLI to authenticate, then call getCodexAccessToken(). */ +export async function loginOpenAICodex(_options: { onAuth: (info: { url: string; instructions?: string }) => void; - onPrompt: (prompt: OAuthPrompt) => Promise; + onPrompt: (prompt: { message: string }) => Promise; onProgress?: (message: string) => void; onManualCodeInput?: () => Promise; originator?: string; }): Promise { - const { verifier, state, url } = await createAuthorizationFlow(options.originator); - const server = await startLocalOAuthServer(state); - - options.onAuth({ url, instructions: "A browser window should open. Complete login to finish." }); - - let code: string | undefined; - try { - if (options.onManualCodeInput) { - // Race between browser callback and manual input - let manualCode: string | undefined; - let manualError: Error | undefined; - const manualPromise = options - .onManualCodeInput() - .then((input) => { - manualCode = input; - server.cancelWait(); - }) - .catch((err) => { - manualError = err instanceof Error ? err : new Error(String(err)); - server.cancelWait(); - }); - - const result = await server.waitForCode(); - - // If manual input was cancelled, throw that error - if (manualError) { - throw manualError; - } - - if (result?.code) { - // Browser callback won - code = result.code; - } else if (manualCode) { - // Manual input won (or callback timed out and user had entered code) - const parsed = parseAuthorizationInput(manualCode); - if (parsed.state && parsed.state !== state) { - throw new Error("State mismatch"); - } - code = parsed.code; - } - - // If still no code, wait for manual promise to complete and try that - if (!code) { - await manualPromise; - if (manualError) { - throw manualError; - } - if (manualCode) { - const parsed = parseAuthorizationInput(manualCode); - if (parsed.state && parsed.state !== state) { - throw new Error("State mismatch"); - } - code = parsed.code; - } - } - } else { - // Original flow: wait for callback, then prompt if needed - const result = await server.waitForCode(); - if (result?.code) { - code = result.code; - } - } - - // Fallback to onPrompt if still no code - if (!code) { - const input = await options.onPrompt({ - message: "Paste the authorization code (or full redirect URL):", - }); - const parsed = parseAuthorizationInput(input); - if (parsed.state && parsed.state !== state) { - throw new Error("State mismatch"); - } - code = parsed.code; - } - - if (!code) { - throw new Error("Missing authorization code"); - } - - const tokenResult = await exchangeAuthorizationCode(code, verifier); - if (tokenResult.type !== "success") { - throw new Error("Token exchange failed"); - } - - const accountId = getAccountId(tokenResult.access); - if (!accountId) { - throw new Error("Failed to extract accountId from token"); - } - - return { - access: tokenResult.access, - refresh: tokenResult.refresh, - expires: tokenResult.expires, - accountId, - }; - } finally { - server.close(); - } + throw new Error( + `OpenAI Codex login is handled by the real \`codex\` CLI.\n` + + `Run: codex auth login\n\n` + + `Then use pi normally — it reads ~/.codex/auth.json automatically.`, + ); } -/** - * Refresh OpenAI Codex OAuth token - */ -export async function refreshOpenAICodexToken(refreshToken: string): Promise { - const result = await refreshAccessToken(refreshToken); - if (result.type !== "success") { - throw new Error("Failed to refresh OpenAI Codex token"); - } - - const accountId = getAccountId(result.access); - if (!accountId) { - throw new Error("Failed to extract accountId from token"); - } - - return { - access: result.access, - refresh: result.refresh, - expires: result.expires, - accountId, - }; +/** @deprecated Use getCodexAccessToken() which reads ~/.codex/auth.json directly. */ +export async function refreshOpenAICodexToken(_refreshToken: string): Promise { + throw new Error( + `refreshOpenAICodexToken is no longer used.\n` + + `Tokens are refreshed automatically when reading ~/.codex/auth.json.`, + ); } - -export const openaiCodexOAuthProvider: OAuthProviderInterface = { - id: "openai-codex", - name: "ChatGPT Plus/Pro (Codex Subscription)", - usesCallbackServer: true, - - async login(callbacks: OAuthLoginCallbacks): Promise { - return loginOpenAICodex({ - onAuth: callbacks.onAuth, - onPrompt: callbacks.onPrompt, - onProgress: callbacks.onProgress, - onManualCodeInput: callbacks.onManualCodeInput, - }); - }, - - async refreshToken(credentials: OAuthCredentials): Promise { - return refreshOpenAICodexToken(credentials.refresh); - }, - - getApiKey(credentials: OAuthCredentials): string { - return credentials.access; - }, - - modifyModels(models) { - return models.filter((model) => ( - model.provider !== "openai-codex" - || !CHATGPT_UNSUPPORTED_MODEL_IDS.has(model.id) - )); - }, -}; diff --git a/packages/pi-coding-agent/src/core/extensions/types.ts b/packages/pi-coding-agent/src/core/extensions/types.ts index 2d4cd6eaa..2855e9f14 100644 --- a/packages/pi-coding-agent/src/core/extensions/types.ts +++ b/packages/pi-coding-agent/src/core/extensions/types.ts @@ -6,6 +6,8 @@ * - Register LLM-callable tools * - Register commands, keyboard shortcuts, and CLI flags * - Interact with the user via UI primitives + * + * @remarks Stale-dist verification comment — will be reverted. */ import type { diff --git a/src/resources/extensions/sf/bootstrap/db-tools.ts b/src/resources/extensions/sf/bootstrap/db-tools.ts index 5a7afb53e..01f6f9f37 100644 --- a/src/resources/extensions/sf/bootstrap/db-tools.ts +++ b/src/resources/extensions/sf/bootstrap/db-tools.ts @@ -68,7 +68,7 @@ export function registerDbTools(pi: ExtensionAPI): void { text: "Error: SF database is not available. Cannot save decision.", }, ], - details: { operation: "save_decision", error: "db_unavailable" } as any, + details: { operation: "save_decision", error: "db_unavailable" }, }; } try { @@ -87,7 +87,7 @@ export function registerDbTools(pi: ExtensionAPI): void { ); return { content: [{ type: "text" as const, text: `Saved decision ${id}` }], - details: { operation: "save_decision", id } as any, + details: { operation: "save_decision", id }, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -99,7 +99,7 @@ export function registerDbTools(pi: ExtensionAPI): void { content: [ { type: "text" as const, text: `Error saving decision: ${msg}` }, ], - details: { operation: "save_decision", error: msg } as any, + details: { operation: "save_decision", error: msg }, }; } }; @@ -199,7 +199,7 @@ export function registerDbTools(pi: ExtensionAPI): void { operation: "update_requirement", id: params.id, error: "db_unavailable", - } as any, + }, }; } try { @@ -220,7 +220,7 @@ export function registerDbTools(pi: ExtensionAPI): void { content: [ { type: "text" as const, text: `Updated requirement ${params.id}` }, ], - details: { operation: "update_requirement", id: params.id } as any, + details: { operation: "update_requirement", id: params.id }, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -236,7 +236,7 @@ export function registerDbTools(pi: ExtensionAPI): void { operation: "update_requirement", id: params.id, error: msg, - } as any, + }, }; } }; @@ -330,7 +330,7 @@ export function registerDbTools(pi: ExtensionAPI): void { details: { operation: "save_requirement", error: "db_unavailable", - } as any, + }, }; } try { @@ -353,7 +353,7 @@ export function registerDbTools(pi: ExtensionAPI): void { content: [ { type: "text" as const, text: `Saved requirement ${result.id}` }, ], - details: { operation: "save_requirement", id: result.id } as any, + details: { operation: "save_requirement", id: result.id }, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -365,7 +365,7 @@ export function registerDbTools(pi: ExtensionAPI): void { content: [ { type: "text" as const, text: `Error saving requirement: ${msg}` }, ], - details: { operation: "save_requirement", error: msg } as any, + details: { operation: "save_requirement", error: msg }, }; } }; @@ -520,7 +520,7 @@ export function registerDbTools(pi: ExtensionAPI): void { _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown, - ) => { + ): Promise>> => { try { // Claim a reserved ID if the guided-flow already previewed one to the user. // This guarantees the ID shown in the UI matches the one materialised on disk. @@ -533,7 +533,7 @@ export function registerDbTools(pi: ExtensionAPI): void { operation: "generate_milestone_id", id: reserved, source: "reserved", - } as any, + }, }; } @@ -553,7 +553,7 @@ export function registerDbTools(pi: ExtensionAPI): void { id: newId, existingCount: existingIds.length, uniqueEnabled, - } as any, + }, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -564,7 +564,7 @@ export function registerDbTools(pi: ExtensionAPI): void { text: `Error generating milestone ID: ${msg}`, }, ], - details: { operation: "generate_milestone_id", error: msg } as any, + details: { operation: "generate_milestone_id", error: msg }, }; } }; @@ -649,7 +649,7 @@ export function registerDbTools(pi: ExtensionAPI): void { _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown, - ) => { + ): Promise>> => { try { const result = recordSelfFeedback( { @@ -675,7 +675,7 @@ export function registerDbTools(pi: ExtensionAPI): void { details: { operation: "self_report", error: "write_failed", - } as any, + }, }; } const e = result.entry; @@ -695,7 +695,7 @@ export function registerDbTools(pi: ExtensionAPI): void { blocking: e.blocking, repoIdentity: e.repoIdentity, sfVersion: e.sfVersion, - } as any, + }, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -707,7 +707,7 @@ export function registerDbTools(pi: ExtensionAPI): void { content: [ { type: "text" as const, text: `Error in sf_self_report: ${msg}` }, ], - details: { operation: "self_report", error: msg } as any, + details: { operation: "self_report", error: msg }, }; } }; @@ -1247,7 +1247,7 @@ export function registerDbTools(pi: ExtensionAPI): void { _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown, - ) => { + ): Promise>> => { const dbAvailable = await ensureDbOpen(); if (!dbAvailable) { return { @@ -1257,7 +1257,7 @@ export function registerDbTools(pi: ExtensionAPI): void { text: "Error: SF database is not available. Cannot plan task.", }, ], - details: { operation: "plan_task", error: "db_unavailable" } as any, + details: { operation: "plan_task", error: "db_unavailable" }, }; } try { @@ -1271,7 +1271,7 @@ export function registerDbTools(pi: ExtensionAPI): void { text: `Error planning task: ${result.error}`, }, ], - details: { operation: "plan_task", error: result.error } as any, + details: { operation: "plan_task", error: result.error }, }; } return { @@ -1287,7 +1287,7 @@ export function registerDbTools(pi: ExtensionAPI): void { sliceId: result.sliceId, taskId: result.taskId, taskPlanPath: result.taskPlanPath, - } as any, + }, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -1299,7 +1299,7 @@ export function registerDbTools(pi: ExtensionAPI): void { content: [ { type: "text" as const, text: `Error planning task: ${msg}` }, ], - details: { operation: "plan_task", error: msg } as any, + details: { operation: "plan_task", error: msg }, }; } }; @@ -1622,7 +1622,7 @@ export function registerDbTools(pi: ExtensionAPI): void { _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown, - ) => { + ): Promise>> => { const dbAvailable = await ensureDbOpen(); if (!dbAvailable) { return { @@ -1632,7 +1632,7 @@ export function registerDbTools(pi: ExtensionAPI): void { text: "Error: SF database is not available. Cannot skip slice.", }, ], - details: { operation: "skip_slice", error: "db_unavailable" } as any, + details: { operation: "skip_slice", error: "db_unavailable" }, }; } try { @@ -1648,7 +1648,7 @@ export function registerDbTools(pi: ExtensionAPI): void { text: `Error: Slice ${params.sliceId} not found in milestone ${params.milestoneId}`, }, ], - details: { operation: "skip_slice", error: "slice_not_found" } as any, + details: { operation: "skip_slice", error: "slice_not_found" }, }; } @@ -1663,7 +1663,7 @@ export function registerDbTools(pi: ExtensionAPI): void { details: { operation: "skip_slice", error: "already_complete", - } as any, + }, }; } @@ -1679,7 +1679,7 @@ export function registerDbTools(pi: ExtensionAPI): void { operation: "skip_slice", sliceId: params.sliceId, milestoneId: params.milestoneId, - } as any, + }, }; } @@ -1712,7 +1712,7 @@ export function registerDbTools(pi: ExtensionAPI): void { sliceId: params.sliceId, milestoneId: params.milestoneId, reason: params.reason, - } as any, + }, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -1724,7 +1724,7 @@ export function registerDbTools(pi: ExtensionAPI): void { content: [ { type: "text" as const, text: `Error skipping slice: ${msg}` }, ], - details: { operation: "skip_slice", error: msg } as any, + details: { operation: "skip_slice", error: msg }, }; } }; diff --git a/src/resources/extensions/sf/skills/requesting-code-review/SKILL.md b/src/resources/extensions/sf/skills/requesting-code-review/SKILL.md index 8b94ce812..6d408fd5e 100644 --- a/src/resources/extensions/sf/skills/requesting-code-review/SKILL.md +++ b/src/resources/extensions/sf/skills/requesting-code-review/SKILL.md @@ -39,7 +39,7 @@ You are Claude, and you are bad at requesting review. The shortcuts below are th These are the same shortcuts `spec-first-tdd`'s **Red Flags** warn about and the same ones `finish-and-verify`'s trace format prevents. By the time you reach this skill, you should have caught yourself already — this section exists because sometimes you don't. -**When you can't tell:** if after walking through the bullets you're still genuinely unsure whether a PDD field, an Evidence claim, or a Risk rating is honest, invoke `codex` as an adversarial advisor before pulling in a human reviewer. It is a peer LLM and not subject to *your* rationalisations — a fast second opinion costs less than a wrong review request, and the disagreements (if any) are exactly what should land in the *"strongest reason this could still be wrong"* line of the summary. +**When you can't tell:** if after walking through the bullets you're still genuinely unsure whether a PDD field, an Evidence claim, or a Risk rating is honest, invoke `codex` or `gemini` (or both, for high-stakes or boundary-heavy calls) as adversarial advisors before pulling in a human reviewer. They are peer LLMs and not subject to *your* rationalisations — a fast second opinion costs less than a wrong review request, and any disagreement they surface is exactly what should land in the *"strongest reason this could still be wrong"* line of the summary. If `codex` and `gemini` agree with each other but disagree with you, that's a strong signal to revise before submitting; if all three disagree, the contract itself is probably underspecified — go back to PDD. ## Before Requesting Review diff --git a/src/resources/extensions/subagent/index.ts b/src/resources/extensions/subagent/index.ts index 5a8be8b86..a7770a1c9 100644 --- a/src/resources/extensions/subagent/index.ts +++ b/src/resources/extensions/subagent/index.ts @@ -431,7 +431,7 @@ async function executeSubagentInvocation({ defaultCwd, agents, step.agent, - taskWithContext, + taskForStep, step.cwd, i + 1, signal, @@ -546,18 +546,29 @@ async function executeSubagentInvocation({ task: Static, round: number, transcript: string, - ): string => - [ + ): string => { + // Parent trace is only injected at round 1. + // In later rounds, the debate transcript carries the relevant + // context; repeating parent_trace would crowd it out. + const assignment = + round === 1 + ? composeTaskWithParentTrace( + task.task, + task.parentTrace ?? params.parentTrace, + ) + : task.task; + return [ `You are participant "${task.agent}" in a structured multi-agent debate.`, `Round ${round} of ${rounds}.`, "Original assignment:", - task.task, + assignment, "Debate transcript so far:", transcript.trim() || "(no prior rounds)", round === rounds ? "This is the final round. Engage the strongest opposing claims, state what changed your mind if anything did, and end with FINAL_VERDICT: plus one sentence." : "Engage the strongest opposing claims directly. Add new evidence or a sharper objection; do not repeat prior points.", ].join("\n\n"); + }; for (let round = 1; round <= rounds; round++) { for (let i = 0; i < batchTasks.length; i++) { @@ -763,6 +774,10 @@ async function executeSubagentInvocation({ batchId, ); const taskModelOverride = t.model ?? params.model; + const taskWithTrace = composeTaskWithParentTrace( + t.task, + t.parentTrace ?? params.parentTrace, + ); const runTask = () => cmuxSplitsEnabled ? runSingleAgentInCmuxSplit( @@ -771,7 +786,7 @@ async function executeSubagentInvocation({ defaultCwd, agents, t.agent, - t.task, + taskWithTrace, t.cwd, undefined, signal, @@ -788,7 +803,7 @@ async function executeSubagentInvocation({ defaultCwd, agents, t.agent, - t.task, + taskWithTrace, t.cwd, undefined, signal, @@ -855,6 +870,10 @@ async function executeSubagentInvocation({ ); } + const singleTaskWithTrace = composeTaskWithParentTrace( + params.task, + params.parentTrace, + ); const result = cmuxSplitsEnabled ? await runSingleAgentInCmuxSplit( cmuxClient, @@ -862,7 +881,7 @@ async function executeSubagentInvocation({ defaultCwd, agents, params.agent, - params.task, + singleTaskWithTrace, isolation ? isolation.workDir : params.cwd, undefined, signal, @@ -874,7 +893,7 @@ async function executeSubagentInvocation({ defaultCwd, agents, params.agent, - params.task, + singleTaskWithTrace, isolation ? isolation.workDir : params.cwd, undefined, signal,